diff --git a/src/components/Dialog/Dialog.ts b/src/components/Dialog/Dialog.ts index 93f088d961..a5aa17a655 100644 --- a/src/components/Dialog/Dialog.ts +++ b/src/components/Dialog/Dialog.ts @@ -13,7 +13,7 @@ import { import { DotNotationDispatch } from "@/store/vuex"; import { withProgress } from "@/store/ui"; -type MediaType = "audio" | "text"; +type MediaType = "audio" | "text" | "label"; export type TextDialogResult = "OK" | "CANCEL"; export type MessageDialogOptions = { @@ -302,6 +302,7 @@ const showWriteSuccessNotify = ({ const mediaTypeNames: Record = { audio: "音声", text: "テキスト", + label: "labファイル", }; void actions.SHOW_NOTIFY_AND_NOT_SHOW_AGAIN_BUTTON({ message: `${mediaTypeNames[mediaType]}を書き出しました`, diff --git a/src/components/Sing/ExportOverlay.vue b/src/components/Sing/ExportOverlay.vue new file mode 100644 index 0000000000..b36263c7ec --- /dev/null +++ b/src/components/Sing/ExportOverlay.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index 4f5f4db6e2..ca8703c774 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -2,22 +2,7 @@
-
-
- -
- {{ 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( @@ -144,22 +119,4 @@ onetimeWatch( overflow: hidden; position: relative; } - -.exporting-dialog { - background-color: rgba(colors.$display-rgb, 0.15); - position: absolute; - inset: 0; - z-index: 10; - display: flex; - text-align: center; - align-items: center; - justify-content: center; - - > div { - color: colors.$display; - background: colors.$surface; - border-radius: 6px; - padding: 14px; - } -} diff --git a/src/components/Sing/menuBarData.ts b/src/components/Sing/menuBarData.ts index a32dbfe144..3ddeaaee4e 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[] に対応する + "label", + 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/helpers/convertToWavFileData.ts b/src/helpers/fileDataGenerator.ts similarity index 68% rename from src/helpers/convertToWavFileData.ts rename to src/helpers/fileDataGenerator.ts index 424b2f514e..2e9e0184b7 100644 --- a/src/helpers/convertToWavFileData.ts +++ b/src/helpers/fileDataGenerator.ts @@ -1,9 +1,12 @@ -export const convertToWavFileData = ( +import Encoding from "encoding-japanese"; +import { Encoding as EncodingType } from "@/type/preload"; + +export function generateWavFileData( audioBuffer: Pick< AudioBuffer, "sampleRate" | "length" | "numberOfChannels" | "getChannelData" >, -) => { +) { const bytesPerSample = 4; // Float32 const formatCode = 3; // WAVE_FORMAT_IEEE_FLOAT @@ -58,4 +61,31 @@ export const convertToWavFileData = ( } 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(); +} diff --git a/src/mock/engineMock/synthesisMock.ts b/src/mock/engineMock/synthesisMock.ts index 9042fc764b..dbe9254e37 100644 --- a/src/mock/engineMock/synthesisMock.ts +++ b/src/mock/engineMock/synthesisMock.ts @@ -5,7 +5,7 @@ */ import { FrameAudioQuery } from "@/openapi"; -import { convertToWavFileData } from "@/helpers/convertToWavFileData"; +import { generateWavFileData } from "@/helpers/fileDataGenerator"; import { applyGaussianFilter } from "@/sing/utility"; /** 0~1を返す疑似乱数生成器 */ @@ -244,7 +244,7 @@ export function synthesisFrameAudioQueryMock( // Blobに変換 const numberOfChannels = frameAudioQuery.outputStereo ? 2 : 1; - const buffer = convertToWavFileData({ + const buffer = generateWavFileData({ sampleRate, length: wave.length, numberOfChannels, diff --git a/src/sing/domain.ts b/src/sing/domain.ts index c5e6a5468b..bbd4482977 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -339,6 +339,8 @@ export const DEPRECATED_DEFAULT_EDITOR_FRAME_RATE = 93.75; export const VALUE_INDICATING_NO_DATA = -1; +export const VOWELS = ["N", "a", "e", "i", "o", "u", "A", "E", "I", "O", "U"]; + export const UNVOICED_PHONEMES = [ "pau", "cl", @@ -353,6 +355,10 @@ export const UNVOICED_PHONEMES = [ "ts", ]; +export function isVowel(phoneme: string) { + return VOWELS.includes(phoneme); +} + export function createDefaultTempo(position: number): Tempo { return { position, bpm: DEFAULT_BPM }; } @@ -513,7 +519,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 +537,7 @@ function phonemesToPhonemeTimings(phonemes: FramePhoneme[]) { /** * 音素タイミング列を音素列に変換する。 */ -function phonemeTimingsToPhonemes(phonemeTimings: PhonemeTiming[]) { +export function phonemeTimingsToPhonemes(phonemeTimings: PhonemeTiming[]) { return phonemeTimings.map( (value): FramePhoneme => ({ phoneme: value.phoneme, @@ -544,7 +550,7 @@ function phonemeTimingsToPhonemes(phonemeTimings: PhonemeTiming[]) { /** * フレーズごとの音素列を全体の音素タイミング列に変換する。 */ -function toEntirePhonemeTimings( +export function toEntirePhonemeTimings( phrasePhonemeSequences: FramePhoneme[][], phraseStartFrames: number[], ) { @@ -725,7 +731,7 @@ function applyPhonemeTimingEditToPhonemeTimings( /** * 音素が重ならないように音素タイミングとフレーズの終了フレームを調整する。 */ -function adjustPhonemeTimingsAndPhraseEndFrames( +export function adjustPhonemeTimingsAndPhraseEndFrames( phonemeTimings: PhonemeTiming[], phraseStartFrames: number[], phraseEndFrames: number[], @@ -816,13 +822,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/fileUtils.ts b/src/sing/fileUtils.ts new file mode 100644 index 0000000000..504708d69f --- /dev/null +++ b/src/sing/fileUtils.ts @@ -0,0 +1,17 @@ +/** + * 指定されたファイルパスに対応するファイルが既に存在する場合、 + * ファイル名に連番のサフィックスを追加してユニークなファイルパスを生成する。 + * TODO: src/store/audio.tsのchangeFileTailToNonExistent関数と統合する + */ +export async function generateUniqueFilePath( + filePathWithoutExtension: string, + extension: string, +) { + let filePath = `${filePathWithoutExtension}.${extension}`; + let tail = 1; + while (await window.backend.checkFileExists(filePath)) { + filePath = `${filePathWithoutExtension}[${tail}].${extension}`; + tail += 1; + } + return filePath; +} diff --git a/src/store/audio.ts b/src/store/audio.ts index f93c7d2f91..bea3d9425f 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -1,4 +1,3 @@ -import Encoding from "encoding-japanese"; import { createUILockAction, withProgress } from "./ui"; import { AudioItem, @@ -64,6 +63,7 @@ import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; import { UnreachableError } from "@/type/utility"; import { errorToMessage } from "@/helpers/errorHelper"; import path from "@/helpers/path"; +import { generateTextFileData } from "@/helpers/fileDataGenerator"; function generateAudioKey() { return AudioKey(uuid4()); @@ -129,6 +129,7 @@ function parseTextFile( return audioItems; } +// TODO: src/sing/fileUtils.tsのgenerateUniqueFilePathと統合する async function changeFileTailToNonExistent( filePath: string, extension: string, @@ -147,29 +148,13 @@ export async function writeTextFile(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); - + const textFileData = await generateTextFileData({ + text: obj.text, + encoding: obj.encoding, + }); return window.backend.writeFile({ filePath: obj.filePath, - buffer: await textBlob.arrayBuffer(), + buffer: textFileData, }); } diff --git a/src/store/singing.ts b/src/store/singing.ts index b939f37f97..65da791f3e 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -32,6 +32,7 @@ import { currentDateString, DEFAULT_PROJECT_NAME, DEFAULT_STYLE_NAME, + generateLabelFileDataFromFramePhonemes, sanitizeFileName, } from "./utility"; import { @@ -87,11 +88,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,10 +110,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 "@/helpers/convertToWavFileData"; import { generateWriteErrorMessage } from "@/helpers/fileHelper"; +import { generateWavFileData } from "@/helpers/fileDataGenerator"; import path from "@/helpers/path"; import { showAlertDialog } from "@/components/Dialog/Dialog"; +import { generateUniqueFilePath } from "@/sing/fileUtils"; const logger = createLogger("store/singing"); @@ -739,8 +747,8 @@ export const singingStoreState: SingingStoreState = { startRenderingRequested: false, stopRenderingRequested: false, nowRendering: false, - nowAudioExporting: false, - cancellationOfAudioExportRequested: false, + exportState: "NOT_EXPORTING", + cancellationOfExportRequested: false, isSongSidebarOpen: false, }; @@ -1538,6 +1546,7 @@ export const singingStore = createPartialStore({ SET_IS_DRAG: { mutation(state, { isDrag }: { isDrag: boolean }) { + // FIXME: state.isDragが無くなっているので修正する state.isDrag = isDrag; }, async action({ mutations }, { isDrag }) { @@ -2715,16 +2724,16 @@ export const singingStore = createPartialStore({ }); }, }, - SET_NOW_AUDIO_EXPORTING: { - mutation(state, { nowAudioExporting }) { - state.nowAudioExporting = nowAudioExporting; + + SET_EXPORT_STATE: { + mutation(state, { exportState }) { + state.exportState = exportState; }, }, - SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED: { - mutation(state, { cancellationOfAudioExportRequested }) { - state.cancellationOfAudioExportRequested = - cancellationOfAudioExportRequested; + SET_CANCELLATION_OF_EXPORT_REQUESTED: { + mutation(state, { cancellationOfExportRequested }) { + state.cancellationOfExportRequested = cancellationOfExportRequested; }, }, @@ -2771,11 +2780,9 @@ export const singingStore = createPartialStore({ if (state.nowRendering) { await createPromiseThatResolvesWhen(() => { - return ( - !state.nowRendering || state.cancellationOfAudioExportRequested - ); + return !state.nowRendering || state.cancellationOfExportRequested; }); - if (state.cancellationOfAudioExportRequested) { + if (state.cancellationOfExportRequested) { return { result: "CANCELED", path: "" }; } } @@ -2791,7 +2798,7 @@ export const singingStore = createPartialStore({ phraseSingingVoices, ); - const fileData = convertToWavFileData(audioBuffer); + const fileData = generateWavFileData(audioBuffer); const result = await actions.EXPORT_FILE({ filePath, @@ -2801,12 +2808,16 @@ export const singingStore = createPartialStore({ return result; }; - mutations.SET_NOW_AUDIO_EXPORTING({ nowAudioExporting: true }); + if (state.exportState !== "NOT_EXPORTING") { + throw new Error("Export is in progress."); + } + + mutations.SET_EXPORT_STATE({ exportState: "EXPORTING_AUDIO" }); return exportAudioFile().finally(() => { - mutations.SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED({ - cancellationOfAudioExportRequested: false, + mutations.SET_CANCELLATION_OF_EXPORT_REQUESTED({ + cancellationOfExportRequested: false, }); - mutations.SET_NOW_AUDIO_EXPORTING({ nowAudioExporting: false }); + mutations.SET_EXPORT_STATE({ exportState: "NOT_EXPORTING" }); }); }, ), @@ -2840,11 +2851,9 @@ export const singingStore = createPartialStore({ if (state.nowRendering) { await createPromiseThatResolvesWhen(() => { - return ( - !state.nowRendering || state.cancellationOfAudioExportRequested - ); + return !state.nowRendering || state.cancellationOfExportRequested; }); - if (state.cancellationOfAudioExportRequested) { + if (state.cancellationOfExportRequested) { return { result: "CANCELED", path: "" }; } } @@ -2853,7 +2862,7 @@ export const singingStore = createPartialStore({ for (const [i, trackId] of state.trackOrder.entries()) { const track = getOrThrow(state.tracks, trackId); - if (!track.singer) { + if (track.singer == undefined) { continue; } @@ -2865,43 +2874,11 @@ export const singingStore = createPartialStore({ 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}.wav`); - if (state.savingSetting.avoidOverwrite) { - let tail = 1; - const pathWithoutExt = filePath.slice(0, -4); - while (await window.backend.checkFileExists(filePath)) { - filePath = `${pathWithoutExt}[${tail}].wav`; - tail += 1; - } - } + const filePath = await actions.GENERATE_FILE_PATH_FOR_TRACK_EXPORT({ + trackId, + directoryPath: dirPath, + extension: "wav", + }); const audioBuffer = await offlineRenderTracks( numberOfChannels, @@ -2918,7 +2895,7 @@ export const singingStore = createPartialStore({ singingVoiceCache, ); - const fileData = convertToWavFileData(audioBuffer); + const fileData = generateWavFileData(audioBuffer); const result = await actions.EXPORT_FILE({ filePath, @@ -2936,17 +2913,266 @@ export const singingStore = createPartialStore({ return { result: "SUCCESS", path: firstFilePath }; }; - mutations.SET_NOW_AUDIO_EXPORTING({ nowAudioExporting: true }); + if (state.exportState !== "NOT_EXPORTING") { + throw new Error("Export is in progress."); + } + + mutations.SET_EXPORT_STATE({ exportState: "EXPORTING_AUDIO" }); return exportAudioFile().finally(() => { - mutations.SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED({ - cancellationOfAudioExportRequested: false, + mutations.SET_CANCELLATION_OF_EXPORT_REQUESTED({ + cancellationOfExportRequested: false, }); - mutations.SET_NOW_AUDIO_EXPORTING({ nowAudioExporting: false }); + mutations.SET_EXPORT_STATE({ exportState: "NOT_EXPORTING" }); }); }, ), }, + EXPORT_LABEL_FILES: { + action: createUILockAction( + async ({ actions, mutations, state }, { 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.cancellationOfExportRequested; + }); + if (state.cancellationOfExportRequested) { + return createArray( + state.tracks.size, + (): SaveResultObject => ({ result: "CANCELED", path: "" }), + ); + } + } + + const results: SaveResultObject[] = []; + + for (const trackId of state.tracks.keys()) { + const track = getOrThrow(state.tracks, trackId); + if (track.singer == undefined) { + continue; + } + + const filePath = await actions.GENERATE_FILE_PATH_FOR_TRACK_EXPORT({ + trackId, + directoryPath: dirPath, + extension: "lab", + }); + + 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, + ); + + // 一番最初のpauseの開始フレームの値が0より大きい場合は0にする + if (entirePhonemeTimings.length === 0) { + throw new Error("entirePhonemeTimings.length is 0."); + } + if (entirePhonemeTimings[0].startFrame > 0) { + entirePhonemeTimings[0].startFrame = 0; + } + + // 音素の開始・終了フレームの値が0より小さい場合は0にする + // (マイナス時間のところを書き出さないようにするため) + for (const phonemeTiming of entirePhonemeTimings) { + if (phonemeTiming.startFrame < 0) { + phonemeTiming.startFrame = 0; + } + if (phonemeTiming.endFrame < 0) { + phonemeTiming.endFrame = 0; + } + } + + // フレーム数が1未満の音素を除く + const filteredEntirePhonemeTimings = entirePhonemeTimings.filter( + (value) => { + const frameLength = value.endFrame - value.startFrame; + return frameLength >= 1; + }, + ); + + const entirePhonemes = phonemeTimingsToPhonemes( + filteredEntirePhonemeTimings, + ); + const labFileData = await generateLabelFileDataFromFramePhonemes( + 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; + }; + + if (state.exportState !== "NOT_EXPORTING") { + throw new Error("Export is in progress."); + } + + mutations.SET_EXPORT_STATE({ exportState: "EXPORTING_LABEL" }); + return exportLabelFile().finally(() => { + mutations.SET_CANCELLATION_OF_EXPORT_REQUESTED({ + cancellationOfExportRequested: false, + }); + mutations.SET_EXPORT_STATE({ exportState: "NOT_EXPORTING" }); + }); + }, + ), + }, + + GENERATE_FILE_PATH_FOR_TRACK_EXPORT: { + async action({ state, getters }, { trackId, directoryPath, extension }) { + const track = getOrThrow(state.tracks, trackId); + + const trackSinger = track.singer; + if (trackSinger == undefined) { + throw new Error("trackSinger is undefined."); + } + + const characterInfo = getters.CHARACTER_INFO( + trackSinger.engineId, + trackSinger.styleId, + ); + if (characterInfo == undefined) { + // NOTE: characterInfoが存在しないというのは起こり得ないはずなので、存在しなかった場合はエラー + throw new Error( + "CharacterInfo corresponding to engineId and styleId does not exist.", + ); + } + + const style = characterInfo.metas.styles.find( + (style) => style.styleId === trackSinger.styleId, + ); + if (style == undefined) { + throw new Error("assert style != undefined"); + } + + const characterName = characterInfo.metas.speakerName; + const styleName = style.styleName ?? DEFAULT_STYLE_NAME; + const projectName = getters.PROJECT_NAME ?? DEFAULT_PROJECT_NAME; + const trackIndex = state.trackOrder.findIndex( + (value) => value === trackId, + ); + + const fileName = buildSongTrackAudioFileNameFromRawData( + state.savingSetting.songTrackFileNamePattern, + { + characterName, + index: trackIndex, + styleName, + date: currentDateString(), + projectName, + trackName: track.name, + }, + ); + const filePathWithoutExt = path.join(directoryPath, fileName); + + if (state.savingSetting.avoidOverwrite) { + return await generateUniqueFilePath(filePathWithoutExt, extension); + } else { + return `${filePathWithoutExt}.${extension}`; + } + }, + }, + EXPORT_FILE: { async action(_, { filePath, content }) { try { @@ -2978,14 +3204,14 @@ export const singingStore = createPartialStore({ }, }, - CANCEL_AUDIO_EXPORT: { + CANCEL_EXPORT: { async action({ state, mutations }) { - if (!state.nowAudioExporting) { - logger.warn("CANCEL_AUDIO_EXPORT on !nowAudioExporting"); + if (state.exportState === "NOT_EXPORTING") { + logger.warn("CANCEL_EXPORT on NOT_EXPORTING"); return; } - mutations.SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED({ - cancellationOfAudioExportRequested: true, + mutations.SET_CANCELLATION_OF_EXPORT_REQUESTED({ + cancellationOfExportRequested: true, }); }, }, diff --git a/src/store/type.ts b/src/store/type.ts index a103a4a881..29ece09124 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -860,6 +860,11 @@ export type SongExportSetting = { withTrackParameters: TrackParameters; }; +export type SongExportState = + | "EXPORTING_AUDIO" + | "EXPORTING_LABEL" + | "NOT_EXPORTING"; + export type SingingStoreState = { tpqn: number; // Ticks Per Quarter Note tempos: Tempo[]; @@ -885,8 +890,8 @@ export type SingingStoreState = { startRenderingRequested: boolean; stopRenderingRequested: boolean; nowRendering: boolean; - nowAudioExporting: boolean; - cancellationOfAudioExportRequested: boolean; + exportState: SongExportState; + cancellationOfExportRequested: boolean; isSongSidebarOpen: boolean; }; @@ -1142,6 +1147,10 @@ export type SingingStoreTypes = { action(payload: { isDrag: boolean }): void; }; + EXPORT_LABEL_FILES: { + action(payload: { dirPath?: string }): SaveResultObject[]; + }; + EXPORT_AUDIO_FILE: { action(payload: { filePath?: string; @@ -1156,6 +1165,14 @@ export type SingingStoreTypes = { }): SaveResultObject; }; + GENERATE_FILE_PATH_FOR_TRACK_EXPORT: { + action(payload: { + trackId: TrackId; + directoryPath: string; + extension: string; + }): Promise; + }; + EXPORT_FILE: { action(payload: { filePath: string; @@ -1163,7 +1180,7 @@ export type SingingStoreTypes = { }): Promise; }; - CANCEL_AUDIO_EXPORT: { + CANCEL_EXPORT: { action(): void; }; @@ -1229,12 +1246,12 @@ export type SingingStoreTypes = { mutation: { nowRendering: boolean }; }; - SET_NOW_AUDIO_EXPORTING: { - mutation: { nowAudioExporting: boolean }; + SET_EXPORT_STATE: { + mutation: { exportState: SongExportState }; }; - SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED: { - mutation: { cancellationOfAudioExportRequested: boolean }; + SET_CANCELLATION_OF_EXPORT_REQUESTED: { + mutation: { cancellationOfExportRequested: boolean }; }; RENDER: { diff --git a/src/store/utility.ts b/src/store/utility.ts index 3334fa44fd..667c5eba22 100644 --- a/src/store/utility.ts +++ b/src/store/utility.ts @@ -6,10 +6,11 @@ import { StyleType, ToolbarButtonTagType, } from "@/type/preload"; -import { AccentPhrase, Mora } from "@/openapi"; +import { AccentPhrase, FramePhoneme, Mora } from "@/openapi"; import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; -import { DEFAULT_TRACK_NAME } from "@/sing/domain"; +import { DEFAULT_TRACK_NAME, isVowel } from "@/sing/domain"; import { isMac } from "@/helpers/platform"; +import { generateTextFileData } from "@/helpers/fileDataGenerator"; export const DEFAULT_STYLE_NAME = "ノーマル"; export const DEFAULT_PROJECT_NAME = "Untitled"; @@ -513,3 +514,28 @@ export const filterCharacterInfosByStyleType = ( return withoutEmptyStyles; }; + +export async function generateLabelFileDataFromFramePhonemes( + phonemes: FramePhoneme[], + frameRate: number, +) { + let labString = ""; + let timestamp = 0; + + const writeLine = (phonemeLengthSeconds: number, phoneme: string) => { + labString += timestamp.toFixed() + " "; + timestamp += phonemeLengthSeconds * 1e7; // 100ns単位に変換 + labString += timestamp.toFixed() + " "; + labString += phoneme + "\n"; + }; + + for (const phoneme of phonemes) { + if (isVowel(phoneme.phoneme) && phoneme.phoneme !== "N") { + writeLine(phoneme.frameLength / frameRate, phoneme.phoneme.toLowerCase()); + } else { + writeLine(phoneme.frameLength / frameRate, phoneme.phoneme); + } + } + + return await generateTextFileData({ text: labString }); +}