diff --git a/src/store/audio.ts b/src/store/audio.ts index e5749eb54a..793f1bcfd3 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -57,7 +57,13 @@ import { } from "@/type/preload"; import { AudioQuery, AccentPhrase, Speaker, SpeakerInfo } from "@/openapi"; import { base64ImageToUri, base64ToUri } from "@/helpers/base64Helper"; -import { getValueOrThrow, ResultError } from "@/type/result"; +import { + getValueOrThrow, + ResultError, + Result, + success, + failure, +} from "@/type/result"; import { generateWriteErrorMessage } from "@/helpers/fileHelper"; import { uuid4 } from "@/helpers/random"; import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; @@ -1364,6 +1370,55 @@ export const audioStore = createPartialStore({ ), }, + GENERATE_AUDIO: { + async action( + { state, actions }, + { audioKey, labOffset }, + ): Promise< + Result< + { + audio: Blob; + lab: string; + text: string; + }, + "engine" + > + > { + const audioItem: AudioItem = cloneWithUnwrapProxy( + state.audioItems[audioKey], + ); + const instance = await actions.INSTANTIATE_ENGINE_CONNECTOR({ + engineId: audioItem.voice.engineId, + }); + + let fetchAudioResult: FetchAudioResult; + try { + fetchAudioResult = await fetchAudioFromAudioItem(state, instance, { + audioItem, + }); + } catch (e) { + const errorMessage = handlePossiblyNotMorphableError(e); + return failure("engine" as const, new Error(errorMessage)); + } + + const { blob, audioQuery } = fetchAudioResult; + const lab = await generateLabFromAudioQuery(audioQuery, labOffset); + if (lab == undefined) { + return failure( + "engine" as const, + new Error("labの生成に失敗しました。"), + ); + } + + const text = extractExportText(state.audioItems[audioKey].text, { + enableMemoNotation: state.enableMemoNotation, + enableRubyNotation: state.enableRubyNotation, + }); + + return success({ audio: blob, lab, text }); + }, + }, + GENERATE_AND_SAVE_AUDIO: { action: createUILockAction( async ( @@ -1399,48 +1454,37 @@ export const audioStore = createPartialStore({ filePath = await changeFileTailToNonExistent(filePath, "wav"); } - let fetchAudioResult: FetchAudioResult; - try { - fetchAudioResult = await actions.FETCH_AUDIO({ audioKey }); - } catch (e) { - const errorMessage = handlePossiblyNotMorphableError(e); + const generateAudioResult = await actions.GENERATE_AUDIO({ + audioKey, + }); + + if (!generateAudioResult.ok) { return { result: "ENGINE_ERROR", path: filePath, - errorMessage, + errorMessage: generateAudioResult.error.message, }; } - const { blob, audioQuery } = fetchAudioResult; + const { audio, lab, text } = generateAudioResult.value; try { await window.backend .writeFile({ filePath, - buffer: await blob.arrayBuffer(), + buffer: await audio.arrayBuffer(), }) .then(getValueOrThrow); if (state.savingSetting.exportLab) { - const labString = await generateLabFromAudioQuery(audioQuery); - if (labString == undefined) - return { - result: "WRITE_ERROR", - path: filePath, - errorMessage: "labの生成に失敗しました。", - }; - await writeTextFile({ - text: labString, + text: lab, filePath: filePath.replace(/\.wav$/, ".lab"), }).then(getValueOrThrow); } if (state.savingSetting.exportText) { await writeTextFile({ - text: extractExportText(state.audioItems[audioKey].text, { - enableMemoNotation: state.enableMemoNotation, - enableRubyNotation: state.enableRubyNotation, - }), + text, filePath: filePath.replace(/\.wav$/, ".txt"), encoding: state.savingSetting.fileEncoding, }).then(getValueOrThrow); @@ -1503,8 +1547,9 @@ export const audioStore = createPartialStore({ audioKey, filePath: path.join(dirPath, name), }) - .then((value) => { + .then(async (value) => { callback?.(++finishedCount); + await new Promise((resolve) => setTimeout(resolve, 0)); return value; }); }); @@ -1563,41 +1608,36 @@ export const audioStore = createPartialStore({ let labOffset = 0; for (const audioKey of state.audioKeys) { - let fetchAudioResult: FetchAudioResult; - try { - fetchAudioResult = await actions.FETCH_AUDIO({ audioKey }); - } catch (e) { - const errorMessage = handlePossiblyNotMorphableError(e); + const generateAudioResult = await actions.GENERATE_AUDIO({ + audioKey, + labOffset, + }); + + callback?.(++finishedCount, totalCount); + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (!generateAudioResult.ok) { return { result: "ENGINE_ERROR", path: filePath, - errorMessage, + errorMessage: generateAudioResult.error.message, }; - } finally { - callback?.(++finishedCount, totalCount); } - const { blob, audioQuery } = fetchAudioResult; - const encodedBlob = await base64Encoder(blob); + const { audio, lab, text } = generateAudioResult.value; + const encodedBlob = await base64Encoder(audio); if (encodedBlob == undefined) { return { result: "WRITE_ERROR", path: filePath }; } encodedBlobs.push(encodedBlob); - // 大して処理能力を要しないので、生成設定のon/offにかかわらず生成してしまう - const lab = await generateLabFromAudioQuery(audioQuery, labOffset); labs.push(lab); // 最終音素の終了時刻を取得する const splitLab = lab.split(" "); labOffset = Number(splitLab[splitLab.length - 2]); - texts.push( - extractExportText(state.audioItems[audioKey].text, { - enableMemoNotation: state.enableMemoNotation, - enableRubyNotation: state.enableRubyNotation, - }), - ); + texts.push(text); } const connectedWav = await actions.CONNECT_AUDIO({ diff --git a/src/store/type.ts b/src/store/type.ts index 8443a42bca..8487bc5c6d 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -77,6 +77,7 @@ import type { } from "@/domain/project/type"; import { LatestProjectType } from "@/infrastructures/projectFile/type"; import { WavFormat } from "@/helpers/fileDataGenerator"; +import { Result } from "@/type/result"; /** * エディタ用のAudioQuery @@ -434,6 +435,19 @@ export type AudioStoreTypes = { action(payload: { encodedBlobs: string[] }): Blob | null; }; + GENERATE_AUDIO: { + action(payload: { audioKey: AudioKey; labOffset?: number }): Promise< + Result< + { + audio: Blob; + lab: string; + text: string; + }, + "engine" + > + >; + }; + GENERATE_AND_SAVE_AUDIO: { action(payload: { audioKey: AudioKey; diff --git a/tests/unit/sing/fileUtils.spec.ts b/tests/unit/sing/fileUtils.spec.ts new file mode 100644 index 0000000000..b8f9be80ca --- /dev/null +++ b/tests/unit/sing/fileUtils.spec.ts @@ -0,0 +1,38 @@ +import { vi, describe, it, expect } from "vitest"; +import { generateUniqueFilePath } from "@/sing/fileUtils"; + +// window.backend.checkFileExistsをモックする +// FIXME: テスト実行環境でwindow.backendが未定義なため、vi.stubGlobalが使用できない +// @ts-expect-error: テスト用に window をモックする +global.window = { + backend: { + checkFileExists: vi.fn(), + }, +} as unknown as Window & { + backend: { + checkFileExists: (path: string) => Promise; + }; +}; + +describe("generateUniqueFilePath", () => { + it("ファイルが存在しない場合、元のファイルパスを返す", async () => { + // checkFileExistsが常にfalseを返すようにモック + ( + window.backend.checkFileExists as ReturnType + ).mockResolvedValue(false); + + const filePath = await generateUniqueFilePath("test", "wav"); + expect(filePath).toBe("test.wav"); + }); + + it("ファイルが存在する場合、連番のサフィックスが付与されたファイルパスを返す", async () => { + // 最初の2回はtrue、3回目はfalseを返すようにモック + (window.backend.checkFileExists as ReturnType) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + + const filePath = await generateUniqueFilePath("test", "wav"); + expect(filePath).toBe("test[2].wav"); + }); +});