diff --git a/src/components/Sing/AudioExportOverlay.vue b/src/components/Sing/AudioExportOverlay.vue
new file mode 100644
index 0000000000..756cb5497b
--- /dev/null
+++ b/src/components/Sing/AudioExportOverlay.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+ {{ nowRendering ? "レンダリング中・・・" : "音声を書き出し中・・・" }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Sing/LabelExportOverlay.vue b/src/components/Sing/LabelExportOverlay.vue
new file mode 100644
index 0000000000..ffbf90c44f
--- /dev/null
+++ b/src/components/Sing/LabelExportOverlay.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+ {{
+ nowRendering
+ ? "レンダリング中・・・"
+ : "labファイルを書き出し中・・・"
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue
index 4f5f4db6e2..147bb5964d 100644
--- a/src/components/Sing/SingEditor.vue
+++ b/src/components/Sing/SingEditor.vue
@@ -2,22 +2,8 @@
-
-
-
-
- {{ nowRendering ? "レンダリング中・・・" : "音声を書き出し中・・・" }}
-
-
-
-
+
+
{
- return store.state.nowRendering;
-});
-const nowAudioExporting = computed(() => {
- return store.state.nowAudioExporting;
-});
-
-const cancelExport = () => {
- void store.actions.CANCEL_AUDIO_EXPORT();
-};
-
const isCompletedInitialStartup = ref(false);
// TODO: Vueっぽくないので解体する
onetimeWatch(
diff --git a/src/components/Sing/menuBarData.ts b/src/components/Sing/menuBarData.ts
index a32dbfe144..4fd22070e6 100644
--- a/src/components/Sing/menuBarData.ts
+++ b/src/components/Sing/menuBarData.ts
@@ -2,6 +2,7 @@ import { computed } from "vue";
import { useStore } from "@/store";
import { MenuItemData } from "@/components/Menu/type";
import { useRootMiscSetting } from "@/composables/useRootMiscSetting";
+import { notifyResult } from "@/components/Dialog/Dialog";
export const useMenuBarData = () => {
const store = useStore();
@@ -24,16 +25,38 @@ export const useMenuBarData = () => {
});
};
+ const exportLabelFile = async () => {
+ const results = await store.actions.EXPORT_LABEL_FILES({});
+
+ if (results.length === 0) {
+ throw new Error("results.length is 0.");
+ }
+ notifyResult(
+ results[0], // TODO: SaveResultObject[] に対応する
+ "text",
+ store.actions,
+ store.state.confirmedTips.notifyOnGenerate,
+ );
+ };
+
// 「ファイル」メニュー
const fileSubMenuData = computed(() => [
{
type: "button",
- label: "音声を出力",
+ label: "音声書き出し",
onClick: () => {
void exportAudioFile();
},
disableWhenUiLocked: true,
},
+ {
+ type: "button",
+ label: "labファイルを書き出し",
+ onClick: () => {
+ void exportLabelFile();
+ },
+ disableWhenUiLocked: true,
+ },
{ type: "separator" },
{
type: "button",
diff --git a/src/sing/domain.ts b/src/sing/domain.ts
index 850d691a75..aedd531d0e 100644
--- a/src/sing/domain.ts
+++ b/src/sing/domain.ts
@@ -513,7 +513,7 @@ export type PhonemeTimingEditData = Map;
/**
* 音素列を音素タイミング列に変換する。
*/
-function phonemesToPhonemeTimings(phonemes: FramePhoneme[]) {
+export function phonemesToPhonemeTimings(phonemes: FramePhoneme[]) {
const phonemeTimings: PhonemeTiming[] = [];
let cumulativeFrame = 0;
for (const phoneme of phonemes) {
@@ -531,7 +531,7 @@ function phonemesToPhonemeTimings(phonemes: FramePhoneme[]) {
/**
* 音素タイミング列を音素列に変換する。
*/
-function phonemeTimingsToPhonemes(phonemeTimings: PhonemeTiming[]) {
+export function phonemeTimingsToPhonemes(phonemeTimings: PhonemeTiming[]) {
return phonemeTimings.map(
(value): FramePhoneme => ({
phoneme: value.phoneme,
@@ -544,7 +544,7 @@ function phonemeTimingsToPhonemes(phonemeTimings: PhonemeTiming[]) {
/**
* フレーズごとの音素列を全体の音素タイミング列に変換する。
*/
-function toEntirePhonemeTimings(
+export function toEntirePhonemeTimings(
phrasePhonemeSequences: FramePhoneme[][],
phraseStartFrames: number[],
) {
@@ -725,7 +725,7 @@ function applyPhonemeTimingEditToPhonemeTimings(
/**
* 音素が重ならないように音素タイミングとフレーズの終了フレームを調整する。
*/
-function adjustPhonemeTimingsAndPhraseEndFrames(
+export function adjustPhonemeTimingsAndPhraseEndFrames(
phonemeTimings: PhonemeTiming[],
phraseStartFrames: number[],
phraseEndFrames: number[],
@@ -816,13 +816,24 @@ function adjustPhonemeTimingsAndPhraseEndFrames(
}
}
-function calcPhraseStartFrames(phraseStartTimes: number[], frameRate: number) {
+/**
+ * フレーズの開始フレームを算出する。
+ * 開始フレームは整数。
+ */
+export function calcPhraseStartFrames(
+ phraseStartTimes: number[],
+ frameRate: number,
+) {
return phraseStartTimes.map((value) =>
secondToRoundedFrame(value, frameRate),
);
}
-function calcPhraseEndFrames(
+/**
+ * フレーズの終了フレームを算出する。
+ * 終了フレームは整数。
+ */
+export function calcPhraseEndFrames(
phraseStartFrames: number[],
phraseQueries: EditorFrameAudioQuery[],
) {
diff --git a/src/sing/convertToWavFileData.ts b/src/sing/fileDataGenerator.ts
similarity index 52%
rename from src/sing/convertToWavFileData.ts
rename to src/sing/fileDataGenerator.ts
index 1ddfb637e4..0980bc2033 100644
--- a/src/sing/convertToWavFileData.ts
+++ b/src/sing/fileDataGenerator.ts
@@ -1,4 +1,8 @@
-export const convertToWavFileData = (audioBuffer: AudioBuffer) => {
+import Encoding from "encoding-japanese";
+import { Encoding as EncodingType } from "@/type/preload";
+import { FramePhoneme } from "@/openapi";
+
+export function generateWavFileData(audioBuffer: AudioBuffer) {
const bytesPerSample = 4; // Float32
const formatCode = 3; // WAVE_FORMAT_IEEE_FLOAT
@@ -53,4 +57,53 @@ export const convertToWavFileData = (audioBuffer: AudioBuffer) => {
}
return new Uint8Array(buffer);
-};
+}
+
+export async function generateTextFileData(obj: {
+ text: string;
+ encoding?: EncodingType;
+}) {
+ obj.encoding ??= "UTF-8";
+
+ const textBlob = {
+ "UTF-8": (text: string) => {
+ const bom = new Uint8Array([0xef, 0xbb, 0xbf]);
+ return new Blob([bom, text], {
+ type: "text/plain;charset=UTF-8",
+ });
+ },
+ Shift_JIS: (text: string) => {
+ const sjisArray = Encoding.convert(Encoding.stringToCode(text), {
+ to: "SJIS",
+ type: "arraybuffer",
+ });
+ return new Blob([new Uint8Array(sjisArray)], {
+ type: "text/plain;charset=Shift_JIS",
+ });
+ },
+ }[obj.encoding](obj.text);
+
+ return await textBlob.arrayBuffer();
+}
+
+export async function generateLabelFileData(
+ phonemes: FramePhoneme[],
+ frameRate: number,
+) {
+ let labString = "";
+ let timestamp = 0;
+
+ const writeLine = (phonemeLengthSeconds: number, phoneme: string) => {
+ labString += timestamp.toFixed() + " ";
+ timestamp += phonemeLengthSeconds * 10e7; // 100ns単位に変換
+ labString += timestamp.toFixed() + " ";
+ labString += phoneme + "\n";
+ };
+
+ for (const phoneme of phonemes) {
+ // REVIEW: vowel != "N" のときに vowel.toLowerCase() する必要がある…?
+ writeLine(phoneme.frameLength / frameRate, phoneme.phoneme);
+ }
+
+ return await generateTextFileData({ text: labString });
+}
diff --git a/src/store/singing.ts b/src/store/singing.ts
index d343bfe912..1c5e55b7f6 100644
--- a/src/store/singing.ts
+++ b/src/store/singing.ts
@@ -87,11 +87,17 @@ import {
shouldPlayTracks,
decibelToLinear,
applyPitchEdit,
+ calcPhraseStartFrames,
+ calcPhraseEndFrames,
+ toEntirePhonemeTimings,
+ adjustPhonemeTimingsAndPhraseEndFrames,
+ phonemeTimingsToPhonemes,
} from "@/sing/domain";
import { getOverlappingNoteIds } from "@/sing/storeHelper";
import {
AnimationTimer,
calculateHash,
+ createArray,
createPromiseThatResolvesWhen,
linearInterpolation,
round,
@@ -103,8 +109,11 @@ import { getOrThrow } from "@/helpers/mapHelper";
import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy";
import { ufProjectToVoicevox } from "@/sing/utaformatixProject/toVoicevox";
import { uuid4 } from "@/helpers/random";
-import { convertToWavFileData } from "@/sing/convertToWavFileData";
import { generateWriteErrorMessage } from "@/helpers/fileHelper";
+import {
+ generateLabelFileData,
+ generateWavFileData,
+} from "@/sing/fileDataGenerator";
import path from "@/helpers/path";
import { showAlertDialog } from "@/components/Dialog/Dialog";
@@ -784,7 +793,9 @@ export const singingStoreState: SingingStoreState = {
stopRenderingRequested: false,
nowRendering: false,
nowAudioExporting: false,
+ nowLabelExporting: false,
cancellationOfAudioExportRequested: false,
+ cancellationOfLabelExportRequested: false,
isSongSidebarOpen: false,
};
@@ -2671,12 +2682,19 @@ export const singingStore = createPartialStore({
});
},
},
+
SET_NOW_AUDIO_EXPORTING: {
mutation(state, { nowAudioExporting }) {
state.nowAudioExporting = nowAudioExporting;
},
},
+ SET_NOW_LABEL_EXPORTING: {
+ mutation(state, { nowLabelExporting }) {
+ state.nowLabelExporting = nowLabelExporting;
+ },
+ },
+
SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED: {
mutation(state, { cancellationOfAudioExportRequested }) {
state.cancellationOfAudioExportRequested =
@@ -2684,6 +2702,13 @@ export const singingStore = createPartialStore({
},
},
+ SET_CANCELLATION_OF_LABEL_EXPORT_REQUESTED: {
+ mutation(state, { cancellationOfLabelExportRequested }) {
+ state.cancellationOfLabelExportRequested =
+ cancellationOfLabelExportRequested;
+ },
+ },
+
EXPORT_AUDIO_FILE: {
action: createUILockAction(
async ({ state, mutations, getters, actions }, { filePath, setting }) => {
@@ -2747,7 +2772,7 @@ export const singingStore = createPartialStore({
phraseSingingVoices,
);
- const fileData = convertToWavFileData(audioBuffer);
+ const fileData = generateWavFileData(audioBuffer);
const result = await actions.EXPORT_FILE({
filePath,
@@ -2874,7 +2899,7 @@ export const singingStore = createPartialStore({
singingVoiceCache,
);
- const fileData = convertToWavFileData(audioBuffer);
+ const fileData = generateWavFileData(audioBuffer);
const result = await actions.EXPORT_FILE({
filePath,
@@ -2903,6 +2928,199 @@ export const singingStore = createPartialStore({
),
},
+ EXPORT_LABEL_FILES: {
+ action: createUILockAction(
+ async ({ actions, mutations, state, getters }, { dirPath }) => {
+ const exportLabelFile = async () => {
+ if (state.nowPlaying) {
+ await actions.SING_STOP_AUDIO();
+ }
+
+ if (state.savingSetting.fixedExportEnabled) {
+ dirPath = state.savingSetting.fixedExportDir;
+ } else {
+ dirPath ??= await window.backend.showSaveDirectoryDialog({
+ title: "labファイルを保存",
+ });
+ }
+ if (!dirPath) {
+ return createArray(
+ state.tracks.size,
+ (): SaveResultObject => ({ result: "CANCELED", path: "" }),
+ );
+ }
+
+ if (state.nowRendering) {
+ await createPromiseThatResolvesWhen(() => {
+ return (
+ !state.nowRendering || state.cancellationOfLabelExportRequested
+ );
+ });
+ if (state.cancellationOfLabelExportRequested) {
+ return createArray(
+ state.tracks.size,
+ (): SaveResultObject => ({ result: "CANCELED", path: "" }),
+ );
+ }
+ }
+
+ const results: SaveResultObject[] = [];
+
+ for (const [i, trackId] of state.trackOrder.entries()) {
+ const track = getOrThrow(state.tracks, trackId);
+ if (!track.singer) {
+ continue;
+ }
+
+ const characterInfo = getters.CHARACTER_INFO(
+ track.singer.engineId,
+ track.singer.styleId,
+ );
+ if (!characterInfo) {
+ continue;
+ }
+
+ const style = characterInfo.metas.styles.find(
+ (style) => style.styleId === track.singer?.styleId,
+ );
+ if (style == undefined) {
+ throw new Error("assert style != undefined");
+ }
+
+ const styleName = style.styleName || DEFAULT_STYLE_NAME;
+ const projectName = getters.PROJECT_NAME ?? DEFAULT_PROJECT_NAME;
+
+ const trackFileName = buildSongTrackAudioFileNameFromRawData(
+ state.savingSetting.songTrackFileNamePattern,
+ {
+ characterName: characterInfo.metas.speakerName,
+ index: i,
+ styleName,
+ date: currentDateString(),
+ projectName,
+ trackName: track.name,
+ },
+ );
+ let filePath = path.join(dirPath, `${trackFileName}.lab`);
+ if (state.savingSetting.avoidOverwrite) {
+ let tail = 1;
+ const pathWithoutExt = filePath.slice(0, -4);
+ while (await window.backend.checkFileExists(filePath)) {
+ filePath = `${pathWithoutExt}[${tail}].lab`;
+ tail += 1;
+ }
+ }
+
+ const frameRate = state.editorFrameRate;
+ const phrases = [...state.phrases.values()]
+ .filter((value) => value.trackId === trackId)
+ .filter((value) => value.queryKey != undefined)
+ .toSorted((a, b) => a.startTime - b.startTime);
+
+ if (phrases.length === 0) {
+ continue;
+ }
+
+ const phraseQueries = phrases.map((value) => {
+ const phraseQuery =
+ value.queryKey != undefined
+ ? state.phraseQueries.get(value.queryKey)
+ : undefined;
+ if (phraseQuery == undefined) {
+ throw new Error("phraseQuery is undefined.");
+ }
+ return phraseQuery;
+ });
+ const phraseStartTimes = phrases.map((value) => value.startTime);
+
+ for (const phraseQuery of phraseQueries) {
+ // フレーズのクエリのフレームレートとエディターのフレームレートが一致しない場合はエラー
+ // TODO: 補間するようにする
+ if (phraseQuery.frameRate != frameRate) {
+ throw new Error(
+ "The frame rate between the phrase query and the editor does not match.",
+ );
+ }
+ }
+
+ const phraseStartFrames = calcPhraseStartFrames(
+ phraseStartTimes,
+ frameRate,
+ );
+ const phraseEndFrames = calcPhraseEndFrames(
+ phraseStartFrames,
+ phraseQueries,
+ );
+
+ const phrasePhonemeSequences = phraseQueries.map((query) => {
+ return query.phonemes;
+ });
+ const entirePhonemeTimings = toEntirePhonemeTimings(
+ phrasePhonemeSequences,
+ phraseStartFrames,
+ );
+
+ // TODO: 音素タイミング編集データを取得して適用するようにする
+
+ adjustPhonemeTimingsAndPhraseEndFrames(
+ entirePhonemeTimings,
+ phraseStartFrames,
+ phraseEndFrames,
+ );
+
+ const entirePhonemes =
+ phonemeTimingsToPhonemes(entirePhonemeTimings);
+ const labFileData = await generateLabelFileData(
+ entirePhonemes,
+ frameRate,
+ );
+
+ try {
+ await window.backend
+ .writeFile({
+ filePath,
+ buffer: labFileData,
+ })
+ .then(getValueOrThrow);
+
+ results.push({ result: "SUCCESS", path: filePath });
+ } catch (e) {
+ logger.error("Failed to export file.", e);
+
+ if (e instanceof ResultError) {
+ results.push({
+ result: "WRITE_ERROR",
+ path: filePath,
+ errorMessage: generateWriteErrorMessage(
+ e as ResultError,
+ ),
+ });
+ } else {
+ results.push({
+ result: "UNKNOWN_ERROR",
+ path: filePath,
+ errorMessage:
+ (e instanceof Error ? e.message : String(e)) ||
+ "不明なエラーが発生しました。",
+ });
+ break; // 想定外のエラーなので書き出しを中断
+ }
+ }
+ }
+ return results;
+ };
+
+ mutations.SET_NOW_LABEL_EXPORTING({ nowLabelExporting: true });
+ return exportLabelFile().finally(() => {
+ mutations.SET_CANCELLATION_OF_LABEL_EXPORT_REQUESTED({
+ cancellationOfLabelExportRequested: false,
+ });
+ mutations.SET_NOW_LABEL_EXPORTING({ nowLabelExporting: false });
+ });
+ },
+ ),
+ },
+
EXPORT_FILE: {
async action(_, { filePath, content }) {
try {
@@ -2946,6 +3164,18 @@ export const singingStore = createPartialStore({
},
},
+ CANCEL_LABEL_EXPORT: {
+ async action({ state, mutations }) {
+ if (!state.nowLabelExporting) {
+ logger.warn("CANCEL_LAB_EXPORT on !nowLabelExporting");
+ return;
+ }
+ mutations.SET_CANCELLATION_OF_LABEL_EXPORT_REQUESTED({
+ cancellationOfLabelExportRequested: true,
+ });
+ },
+ },
+
COPY_NOTES_TO_CLIPBOARD: {
async action({ getters }) {
const selectedTrack = getters.SELECTED_TRACK;
diff --git a/src/store/type.ts b/src/store/type.ts
index 50c523d22a..419c58ec00 100644
--- a/src/store/type.ts
+++ b/src/store/type.ts
@@ -877,7 +877,9 @@ export type SingingStoreState = {
stopRenderingRequested: boolean;
nowRendering: boolean;
nowAudioExporting: boolean;
+ nowLabelExporting: boolean;
cancellationOfAudioExportRequested: boolean;
+ cancellationOfLabelExportRequested: boolean;
isSongSidebarOpen: boolean;
};
@@ -1118,6 +1120,10 @@ export type SingingStoreTypes = {
action(payload: { isDrag: boolean }): void;
};
+ EXPORT_LABEL_FILES: {
+ action(payload: { dirPath?: string }): SaveResultObject[];
+ };
+
EXPORT_AUDIO_FILE: {
action(payload: {
filePath?: string;
@@ -1143,6 +1149,10 @@ export type SingingStoreTypes = {
action(): void;
};
+ CANCEL_LABEL_EXPORT: {
+ action(): void;
+ };
+
FETCH_SING_FRAME_VOLUME: {
action(palyoad: {
notes: NoteForRequestToEngine[];
@@ -1209,10 +1219,18 @@ export type SingingStoreTypes = {
mutation: { nowAudioExporting: boolean };
};
+ SET_NOW_LABEL_EXPORTING: {
+ mutation: { nowLabelExporting: boolean };
+ };
+
SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED: {
mutation: { cancellationOfAudioExportRequested: boolean };
};
+ SET_CANCELLATION_OF_LABEL_EXPORT_REQUESTED: {
+ mutation: { cancellationOfLabelExportRequested: boolean };
+ };
+
RENDER: {
action(): void;
};