From f45ec284faf79d0b3e3e021356350579753f328f Mon Sep 17 00:00:00 2001 From: "Nanashi." Date: Sat, 12 Oct 2024 20:43:21 +0900 Subject: [PATCH] =?UTF-8?q?Add:=20=E3=82=BD=E3=83=B3=E3=82=B0=E3=81=AE?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E5=87=BA=E3=81=97=E3=83=80=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=92=E8=BF=BD=E5=8A=A0=20(#2287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add: ソングの書き出しダイアログを追加 * Add: ダイアログを完成させる * Add: パラメータ適用以外は実装 * Add: 書き出せるように * Change: wave -> audio * Add: wav以外の書き出しを追加 * Change: メモ -> NOTE Co-authored-by: Hiroshiba * Fix: ファイル名のプレビューを修正 * Fix: トーク側のファイル名を修正 * Change: DialogStatesに定義を置く * Add: 出力ポップアップを追加 * Fix: プレビューを修正 * Delete: フォーマット選択を削除 * Change: テキストを変える Co-Authored-By: sigprogramming * Update: 色々更新 * Change: モノラル時はpan=0を使う用に * Change: ステレオでandをかける * Change: wav -> WAV Co-authored-by: Hiroshiba * Code: コメントを変える Co-authored-by: Hiroshiba * Change: 書き出し -> 書き出す Co-authored-by: Hiroshiba * Code: コメントを追加 Co-authored-by: Hiroshiba * Revert: package-lock.jsonの変更を戻す * Change: TrackParametersをstore/type.tsに移動 * Change: isMonoに * Code: コメントを追加 * Fix: デフォルト値を修正 * Fix: 条件を修正 Co-authored-by: Sig <62321214+sigprogramming@users.noreply.github.com> * Improvr: テキストをいい感じにする * Change: isStereo -> isMono * Improve: モノラル書き出しをオンにするとパンが無効化されるように * 微調整 --------- Co-authored-by: Hiroshiba Co-authored-by: sigprogramming Co-authored-by: Sig <62321214+sigprogramming@users.noreply.github.com> --- src/backend/common/ConfigManager.ts | 7 + src/backend/electron/main.ts | 12 +- src/components/Dialog/AllDialog.vue | 11 + src/components/Dialog/Dialog.ts | 60 +++--- .../Dialog/ExportSongAudioDialog/BaseCell.vue | 45 ++++ .../ExportSongAudioDialog/Container.vue | 36 ++++ .../ExportSongAudioDialog/Presentation.vue | 194 ++++++++++++++++++ .../FileNameTemplateDialog.stories.ts | 4 +- .../SettingDialog/FileNameTemplateDialog.vue | 37 ++-- .../Dialog/SettingDialog/SettingDialog.vue | 20 +- .../UpdateNotificationDialog/Container.vue | 2 +- src/components/Sing/SingEditor.vue | 3 +- src/components/Sing/menuBarData.ts | 21 +- src/sing/convertToWavFileData.ts | 2 +- src/store/audio.ts | 18 +- src/store/singing.ts | 177 ++++++++-------- src/store/type.ts | 78 +++---- src/store/ui.ts | 19 +- src/store/utility.ts | 6 +- src/type/ipc.ts | 9 +- src/type/preload.ts | 4 +- 21 files changed, 521 insertions(+), 244 deletions(-) create mode 100644 src/components/Dialog/ExportSongAudioDialog/BaseCell.vue create mode 100644 src/components/Dialog/ExportSongAudioDialog/Container.vue create mode 100644 src/components/Dialog/ExportSongAudioDialog/Presentation.vue diff --git a/src/backend/common/ConfigManager.ts b/src/backend/common/ConfigManager.ts index 35b069e03a..75172c8133 100644 --- a/src/backend/common/ConfigManager.ts +++ b/src/backend/common/ConfigManager.ts @@ -255,6 +255,13 @@ const migrations: [string, (store: Record) => unknown][] = [ delete experimentalSetting.shouldApplyDefaultPresetOnVoiceChanged; } + // 書き出しテンプレートから拡張子を削除 + const savingSetting = config.savingSetting as { fileNamePattern: string }; + savingSetting.fileNamePattern = savingSetting.fileNamePattern.replace( + ".wav", + "", + ); + return config; }, ], diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index 723259239b..6fa17cb80a 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -584,7 +584,12 @@ registerIpcMainHandle({ dialog.showSaveDialog(win, { title, defaultPath, - filters: [{ name: "Wave File", extensions: ["wav"] }], + filters: [ + { + name: "WAVファイル", + extensions: ["wav"], + }, + ], properties: ["createDirectory"], }), ); @@ -872,7 +877,10 @@ registerIpcMainHandle({ WRITE_FILE: (_, { filePath, buffer }) => { try { - fs.writeFileSync(filePath, new DataView(buffer)); + fs.writeFileSync( + filePath, + new DataView(buffer instanceof Uint8Array ? buffer.buffer : buffer), + ); return success(undefined); } catch (e) { // throwだと`.code`の情報が消えるのでreturn diff --git a/src/components/Dialog/AllDialog.vue b/src/components/Dialog/AllDialog.vue index 30d538c65b..68bec331a1 100644 --- a/src/components/Dialog/AllDialog.vue +++ b/src/components/Dialog/AllDialog.vue @@ -22,6 +22,7 @@ + @@ -39,6 +40,7 @@ import DictionaryManageDialog from "@/components/Dialog/DictionaryManageDialog.v import EngineManageDialog from "@/components/Dialog/EngineManageDialog.vue"; import UpdateNotificationDialogContainer from "@/components/Dialog/UpdateNotificationDialog/Container.vue"; import ImportSongProjectDialog from "@/components/Dialog/ImportSongProjectDialog.vue"; +import ExportSongAudioDialog from "@/components/Dialog/ExportSongAudioDialog/Container.vue"; import { useStore } from "@/store"; import { filterCharacterInfosByStyleType } from "@/store/utility"; @@ -159,6 +161,15 @@ const canOpenNotificationDialog = computed(() => { ); }); +// ソングのオーディオエクスポート時の設定ダイアログ +const isExportSongAudioDialogOpen = computed({ + get: () => store.state.isExportSongAudioDialogOpen, + set: (val) => + store.dispatch("SET_DIALOG_OPEN", { + isExportSongAudioDialogOpen: val, + }), +}); + // ソングのプロジェクトファイルのインポート時の設定ダイアログ const isImportSongProjectDialogOpenComputed = computed({ get: () => store.state.isImportSongProjectDialogOpen, diff --git a/src/components/Dialog/Dialog.ts b/src/components/Dialog/Dialog.ts index e3a7702edc..54b33bf78b 100644 --- a/src/components/Dialog/Dialog.ts +++ b/src/components/Dialog/Dialog.ts @@ -160,18 +160,8 @@ export async function generateAndSaveOneAudioWithDialog({ actions, ); - if (result.result === "CANCELED") return; - - if (result.result === "SUCCESS") { - if (disableNotifyOnGenerate) return; - // 書き出し成功時に通知をする - showWriteSuccessNotify({ - mediaType: "audio", - actions, - }); - } else { - showWriteErrorDialog({ mediaType: "audio", result, actions }); - } + if (result == undefined) return; + notifyResult(result, "audio", actions, disableNotifyOnGenerate); } export async function multiGenerateAndSaveAudioWithDialog({ @@ -260,17 +250,8 @@ export async function generateAndConnectAndSaveAudioWithDialog({ actions, ); - if (result == undefined || result.result === "CANCELED") return; - - if (result.result === "SUCCESS") { - if (disableNotifyOnGenerate) return; - showWriteSuccessNotify({ - mediaType: "audio", - actions, - }); - } else { - showWriteErrorDialog({ mediaType: "audio", result, actions }); - } + if (result == undefined) return; + notifyResult(result, "audio", actions, disableNotifyOnGenerate); } export async function connectAndExportTextWithDialog({ @@ -285,18 +266,8 @@ export async function connectAndExportTextWithDialog({ const result = await actions.CONNECT_AND_EXPORT_TEXT({ filePath, }); - - if (result == undefined || result.result === "CANCELED") return; - - if (result.result === "SUCCESS") { - if (disableNotifyOnGenerate) return; - showWriteSuccessNotify({ - mediaType: "text", - actions, - }); - } else { - showWriteErrorDialog({ mediaType: "text", result, actions }); - } + if (!result) return; + notifyResult(result, "text", actions, disableNotifyOnGenerate); } // 書き出し成功時の通知を表示 @@ -352,6 +323,25 @@ const showWriteErrorDialog = ({ } }; +/** 保存結果に応じてユーザーに通知する。キャンセルされた場合は何もしない。 */ +export const notifyResult = ( + result: SaveResultObject, + mediaType: MediaType, + actions: DotNotationDispatch, + disableNotifyOnGenerate: boolean, +) => { + if (result.result === "CANCELED") return; + if (result.result === "SUCCESS") { + if (disableNotifyOnGenerate) return; + showWriteSuccessNotify({ + mediaType, + actions, + }); + } else { + showWriteErrorDialog({ mediaType, result, actions }); + } +}; + const NOTIFY_TIMEOUT = 7000; export const showNotifyAndNotShowAgainButton = ( diff --git a/src/components/Dialog/ExportSongAudioDialog/BaseCell.vue b/src/components/Dialog/ExportSongAudioDialog/BaseCell.vue new file mode 100644 index 0000000000..37ae1c231c --- /dev/null +++ b/src/components/Dialog/ExportSongAudioDialog/BaseCell.vue @@ -0,0 +1,45 @@ + + + + + + + diff --git a/src/components/Dialog/ExportSongAudioDialog/Container.vue b/src/components/Dialog/ExportSongAudioDialog/Container.vue new file mode 100644 index 0000000000..7e92efccdf --- /dev/null +++ b/src/components/Dialog/ExportSongAudioDialog/Container.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/Dialog/ExportSongAudioDialog/Presentation.vue b/src/components/Dialog/ExportSongAudioDialog/Presentation.vue new file mode 100644 index 0000000000..0a049e78b9 --- /dev/null +++ b/src/components/Dialog/ExportSongAudioDialog/Presentation.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/src/components/Dialog/SettingDialog/FileNameTemplateDialog.stories.ts b/src/components/Dialog/SettingDialog/FileNameTemplateDialog.stories.ts index b550c01a13..cd00934484 100644 --- a/src/components/Dialog/SettingDialog/FileNameTemplateDialog.stories.ts +++ b/src/components/Dialog/SettingDialog/FileNameTemplateDialog.stories.ts @@ -4,7 +4,7 @@ import { Meta, StoryObj } from "@storybook/vue3"; import FileNameTemplateDialog from "./FileNameTemplateDialog.vue"; import { buildAudioFileNameFromRawData, - DEFAULT_AUDIO_FILE_BASE_NAME_TEMPLATE, + DEFAULT_AUDIO_FILE_NAME_TEMPLATE, } from "@/store/utility"; const meta: Meta = { @@ -18,7 +18,7 @@ const meta: Meta = { "date", "projectName", ], - defaultTemplate: DEFAULT_AUDIO_FILE_BASE_NAME_TEMPLATE, + defaultTemplate: DEFAULT_AUDIO_FILE_NAME_TEMPLATE, savedTemplate: "", fileNameBuilder: buildAudioFileNameFromRawData, }, diff --git a/src/components/Dialog/SettingDialog/FileNameTemplateDialog.vue b/src/components/Dialog/SettingDialog/FileNameTemplateDialog.vue index e2f6259b8a..08c1de26e9 100644 --- a/src/components/Dialog/SettingDialog/FileNameTemplateDialog.vue +++ b/src/components/Dialog/SettingDialog/FileNameTemplateDialog.vue @@ -16,12 +16,12 @@
string; + /** ドットまで含んだ拡張子 */ + extension: string; }>(); const emit = defineEmits<{ @@ -109,27 +111,18 @@ const tagStrings = computed(() => props.availableTags.map((tag) => replaceTagIdToTagString[tag]), ); -const savedTemplateWithoutExt = computed(() => - props.savedTemplate.replace(/\.wav$/, ""), -); -const temporaryTemplateWithoutExt = ref(savedTemplateWithoutExt.value); -const temporaryTemplate = computed( - () => temporaryTemplateWithoutExt.value + ".wav", -); +const temporaryTemplate = ref(props.savedTemplate); const missingIndexTagString = computed( - () => - !temporaryTemplateWithoutExt.value.includes( - replaceTagIdToTagString["index"], - ), + () => !temporaryTemplate.value.includes(replaceTagIdToTagString["index"]), ); const invalidChar = computed(() => { - const current = temporaryTemplateWithoutExt.value; + const current = temporaryTemplate.value; const sanitized = sanitizeFileName(current); return Array.from(current).find((char, i) => char !== sanitized[i]); }); const errorMessage = computed(() => { - if (temporaryTemplateWithoutExt.value === "") { + if (temporaryTemplate.value === "") { return "何か入力してください"; } @@ -147,19 +140,19 @@ const errorMessage = computed(() => { }); const hasError = computed(() => errorMessage.value !== ""); -const previewFileName = computed(() => - props.fileNameBuilder(`${temporaryTemplateWithoutExt.value}.wav`), +const previewFileName = computed( + () => props.fileNameBuilder(temporaryTemplate.value) + props.extension, ); const initializeInput = () => { - temporaryTemplateWithoutExt.value = savedTemplateWithoutExt.value; + temporaryTemplate.value = props.savedTemplate; - if (temporaryTemplateWithoutExt.value === "") { - temporaryTemplateWithoutExt.value = props.defaultTemplate; + if (temporaryTemplate.value === "") { + temporaryTemplate.value = props.defaultTemplate; } }; const resetToDefault = () => { - temporaryTemplateWithoutExt.value = props.defaultTemplate; + temporaryTemplate.value = props.defaultTemplate; patternInput.value?.focus(); }; @@ -175,7 +168,7 @@ const insertTagToCurrentPosition = (tag: string) => { const from = elem.selectionStart ?? 0; const to = elem.selectionEnd ?? 0; const newText = text.substring(0, from) + tag + text.substring(to); - temporaryTemplateWithoutExt.value = newText; + temporaryTemplate.value = newText; // キャレットの位置を挿入した後の位置にずらす void nextTick(() => { diff --git a/src/components/Dialog/SettingDialog/SettingDialog.vue b/src/components/Dialog/SettingDialog/SettingDialog.vue index 81a819356c..69835fa1f0 100644 --- a/src/components/Dialog/SettingDialog/SettingDialog.vue +++ b/src/components/Dialog/SettingDialog/SettingDialog.vue @@ -281,7 +281,7 @@ @@ -360,7 +362,7 @@ @@ -536,8 +538,8 @@ import BaseCell from "./BaseCell.vue"; import EditButtonCell from "./EditButtonCell.vue"; import { useStore } from "@/store"; import { - DEFAULT_AUDIO_FILE_BASE_NAME_TEMPLATE, - DEFAULT_SONG_AUDIO_FILE_BASE_NAME_TEMPLATE, + DEFAULT_AUDIO_FILE_NAME_TEMPLATE, + DEFAULT_SONG_AUDIO_FILE_NAME_TEMPLATE, buildAudioFileNameFromRawData, buildSongTrackAudioFileNameFromRawData, } from "@/store/utility"; @@ -794,6 +796,12 @@ const audioFileNamePattern = computed( const songTrackFileNamePattern = computed( () => store.state.savingSetting.songTrackFileNamePattern, ); +const audioFileNamePatternWithExt = computed(() => + audioFileNamePattern.value ? audioFileNamePattern.value + ".wav" : "", +); +const songTrackFileNamePatternWithExt = computed(() => + songTrackFileNamePattern.value ? songTrackFileNamePattern.value + ".wav" : "", +); const gpuSwitchEnabled = (engineId: EngineId) => { // CPU版でもGPUモードからCPUモードに変更できるようにする diff --git a/src/components/Dialog/UpdateNotificationDialog/Container.vue b/src/components/Dialog/UpdateNotificationDialog/Container.vue index 781e8dd685..f29b515657 100644 --- a/src/components/Dialog/UpdateNotificationDialog/Container.vue +++ b/src/components/Dialog/UpdateNotificationDialog/Container.vue @@ -1,4 +1,4 @@ - diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index 2fa6b79dfb..d2bdcf9eb5 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -12,9 +12,8 @@ v-if="nowRendering" padding="xs md" label="音声の書き出しをキャンセル" - color="surface" - textColor="display" class="q-mt-sm" + outline @click="cancelExport" />
diff --git a/src/components/Sing/menuBarData.ts b/src/components/Sing/menuBarData.ts index fd8d451e4d..6823c3e053 100644 --- a/src/components/Sing/menuBarData.ts +++ b/src/components/Sing/menuBarData.ts @@ -25,14 +25,11 @@ export const useMenuBarData = () => { }); }; - const exportWaveFile = async () => { + const exportAudioFile = async () => { if (uiLocked.value) return; - await store.dispatch("EXPORT_WAVE_FILE", {}); - }; - - const exportStemWaveFile = async () => { - if (uiLocked.value) return; - await store.dispatch("EXPORT_STEM_WAVE_FILE", {}); + await store.dispatch("SET_DIALOG_OPEN", { + isExportSongAudioDialogOpen: true, + }); }; const fileSubMenuData = computed(() => [ @@ -40,15 +37,7 @@ export const useMenuBarData = () => { type: "button", label: "音声を出力", onClick: () => { - void exportWaveFile(); - }, - disableWhenUiLocked: true, - }, - { - type: "button", - label: "トラックごとに音声を出力", - onClick: () => { - void exportStemWaveFile(); + void exportAudioFile(); }, disableWhenUiLocked: true, }, diff --git a/src/sing/convertToWavFileData.ts b/src/sing/convertToWavFileData.ts index 97fc4fa4c9..1ddfb637e4 100644 --- a/src/sing/convertToWavFileData.ts +++ b/src/sing/convertToWavFileData.ts @@ -52,5 +52,5 @@ export const convertToWavFileData = (audioBuffer: AudioBuffer) => { } } - return buffer; + return new Uint8Array(buffer); }; diff --git a/src/store/audio.ts b/src/store/audio.ts index 7bded00aca..85e522aaf0 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -1243,14 +1243,16 @@ export const audioStore = createPartialStore({ const styleName = style.styleName || DEFAULT_STYLE_NAME; const projectName = getters.PROJECT_NAME ?? DEFAULT_PROJECT_NAME; - return buildAudioFileNameFromRawData(fileNamePattern, { - characterName: character.metas.speakerName, - index, - styleName, - text: audioItem.text, - date: currentDateString(), - projectName, - }); + return ( + buildAudioFileNameFromRawData(fileNamePattern, { + characterName: character.metas.speakerName, + index, + styleName, + text: audioItem.text, + date: currentDateString(), + projectName, + }) + ".wav" + ); }, }, diff --git a/src/store/singing.ts b/src/store/singing.ts index a8b83aad0a..130d429295 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -24,6 +24,7 @@ import { SingingVoiceKey, EditorFrameAudioQueryKey, EditorFrameAudioQuery, + TrackParameters, } from "./type"; import { buildSongTrackAudioFileNameFromRawData, @@ -131,7 +132,7 @@ const generateNoteEvents = (notes: Note[], tempos: Tempo[], tpqn: number) => { }); }; -const generateDefaultSongFileName = ( +const generateDefaultSongFileBaseName = ( projectName: string | undefined, selectedTrack: Track, getCharacterInfo: ( @@ -140,7 +141,7 @@ const generateDefaultSongFileName = ( ) => CharacterInfo | undefined, ) => { if (projectName) { - return projectName + ".wav"; + return projectName; } const singer = selectedTrack.singer; @@ -150,11 +151,11 @@ const generateDefaultSongFileName = ( if (singerName) { const notes = selectedTrack.notes.slice(0, 5); const beginningPartLyrics = notes.map((note) => note.lyric).join(""); - return sanitizeFileName(`${singerName}_${beginningPartLyrics}.wav`); + return sanitizeFileName(`${singerName}_${beginningPartLyrics}`); } } - return `${DEFAULT_PROJECT_NAME}.wav`; + return DEFAULT_PROJECT_NAME; }; const offlineRenderTracks = async ( @@ -162,7 +163,7 @@ const offlineRenderTracks = async ( sampleRate: number, renderDuration: number, withLimiter: boolean, - shouldApplyTrackParameters: boolean, + shouldApplyTrackParameters: TrackParameters, tracks: Map, phrases: Map, singingVoices: Map, @@ -180,9 +181,10 @@ const offlineRenderTracks = async ( const shouldPlays = shouldPlayTracks(tracks); for (const [trackId, track] of tracks) { const channelStrip = new ChannelStrip(offlineAudioContext); - channelStrip.volume = shouldApplyTrackParameters ? track.gain : 1; - channelStrip.pan = shouldApplyTrackParameters ? track.pan : 0; - channelStrip.mute = shouldApplyTrackParameters + channelStrip.volume = shouldApplyTrackParameters.gain ? track.gain : 1; + channelStrip.pan = + shouldApplyTrackParameters.pan && numberOfChannels === 2 ? track.pan : 0; + channelStrip.mute = shouldApplyTrackParameters.soloAndMute ? !shouldPlays.has(trackId) : false; @@ -1936,18 +1938,19 @@ export const singingStore = createPartialStore({ }, }, - EXPORT_WAVE_FILE: { + EXPORT_AUDIO_FILE: { action: createUILockAction( - async ({ state, mutations, getters, actions }, { filePath }) => { - const exportWaveFile = async (): Promise => { - const fileName = generateDefaultSongFileName( + async ({ state, mutations, getters, actions }, { filePath, setting }) => { + const exportAudioFile = async (): Promise => { + const fileBaseName = generateDefaultSongFileBaseName( getters.PROJECT_NAME, getters.SELECTED_TRACK, getters.CHARACTER_INFO, ); - const numberOfChannels = 2; - const sampleRate = 48000; // TODO: 設定できるようにする - const withLimiter = true; // TODO: 設定できるようにする + const fileName = `${fileBaseName}.wav`; + const numberOfChannels = setting.isMono ? 1 : 2; + const sampleRate = setting.sampleRate; + const withLimiter = setting.withLimiter; const renderDuration = getters.CALC_RENDER_DURATION; @@ -1969,9 +1972,9 @@ export const singingStore = createPartialStore({ if (state.savingSetting.avoidOverwrite) { let tail = 1; - const name = filePath.slice(0, filePath.length - 4); + const pathWithoutExt = filePath.slice(0, -4); while (await window.backend.checkFileExists(filePath)) { - filePath = name + "[" + tail.toString() + "]" + ".wav"; + filePath = `${pathWithoutExt}[${tail}].wav`; tail += 1; } } @@ -1992,46 +1995,24 @@ export const singingStore = createPartialStore({ sampleRate, renderDuration, withLimiter, - true, + setting.withTrackParameters, state.tracks, state.phrases, phraseSingingVoices, ); - const waveFileData = convertToWavFileData(audioBuffer); + const fileData = convertToWavFileData(audioBuffer); - try { - await window.backend - .writeFile({ - filePath, - buffer: waveFileData, - }) - .then(getValueOrThrow); - } catch (e) { - logger.error("Failed to export the wav file.", e); - if (e instanceof ResultError) { - return { - result: "WRITE_ERROR", - path: filePath, - errorMessage: generateWriteErrorMessage( - e as ResultError, - ), - }; - } - return { - result: "UNKNOWN_ERROR", - path: filePath, - errorMessage: - (e instanceof Error ? e.message : String(e)) || - "不明なエラーが発生しました。", - }; - } + const result = await actions.EXPORT_FILE({ + filePath, + content: fileData, + }); - return { result: "SUCCESS", path: filePath }; + return result; }; mutations.SET_NOW_AUDIO_EXPORTING({ nowAudioExporting: true }); - return exportWaveFile().finally(() => { + return exportAudioFile().finally(() => { mutations.SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED({ cancellationOfAudioExportRequested: false, }); @@ -2041,15 +2022,14 @@ export const singingStore = createPartialStore({ ), }, - // TODO: EXPORT_WAVE_FILEとコードが重複しているので、共通化する - EXPORT_STEM_WAVE_FILE: { + EXPORT_STEM_AUDIO_FILE: { action: createUILockAction( - async ({ state, mutations, getters, actions }, { dirPath }) => { + async ({ state, mutations, getters, actions }, { dirPath, setting }) => { let firstFilePath = ""; - const exportWaveFile = async (): Promise => { - const numberOfChannels = 2; - const sampleRate = 48000; // TODO: 設定できるようにする - const withLimiter = true; // TODO: 設定できるようにする + const exportAudioFile = async (): Promise => { + const numberOfChannels = setting.isMono ? 1 : 2; + const sampleRate = setting.sampleRate; + const withLimiter = setting.withLimiter; const renderDuration = getters.CALC_RENDER_DURATION; @@ -2079,12 +2059,22 @@ export const singingStore = createPartialStore({ } } + const shouldPlays = shouldPlayTracks(state.tracks); + for (const [i, trackId] of state.trackOrder.entries()) { const track = getOrThrow(state.tracks, trackId); if (!track.singer) { continue; } + // ミュート/ソロにより再生されないトラックは除外 + if ( + setting.withTrackParameters.soloAndMute && + !shouldPlays.has(trackId) + ) { + continue; + } + const characterInfo = getters.CHARACTER_INFO( track.singer.engineId, track.singer.styleId, @@ -2113,12 +2103,12 @@ export const singingStore = createPartialStore({ trackName: track.name, }, ); - let filePath = path.join(dirPath, trackFileName); + let filePath = path.join(dirPath, `${trackFileName}.wav`); if (state.savingSetting.avoidOverwrite) { let tail = 1; - const name = filePath.slice(0, filePath.length - 4); + const pathWithoutExt = filePath.slice(0, -4); while (await window.backend.checkFileExists(filePath)) { - filePath = name + "[" + tail.toString() + "]" + ".wav"; + filePath = `${pathWithoutExt}[${tail}].wav`; tail += 1; } } @@ -2128,7 +2118,7 @@ export const singingStore = createPartialStore({ sampleRate, renderDuration, withLimiter, - true, + setting.withTrackParameters, new Map([[trackId, { ...track, solo: false, mute: false }]]), new Map( [...state.phrases.entries()].filter( @@ -2138,36 +2128,18 @@ export const singingStore = createPartialStore({ singingVoiceCache, ); - const waveFileData = convertToWavFileData(audioBuffer); - if (i === 0) { - firstFilePath = filePath; + const fileData = convertToWavFileData(audioBuffer); + + const result = await actions.EXPORT_FILE({ + filePath, + content: fileData, + }); + if (result.result !== "SUCCESS") { + return result; } - try { - await window.backend - .writeFile({ - filePath, - buffer: waveFileData, - }) - .then(getValueOrThrow); - } catch (e) { - logger.error("Failed to export the wav file.", e); - if (e instanceof ResultError) { - return { - result: "WRITE_ERROR", - path: filePath, - errorMessage: generateWriteErrorMessage( - e as ResultError, - ), - }; - } - return { - result: "UNKNOWN_ERROR", - path: filePath, - errorMessage: - (e instanceof Error ? e.message : String(e)) || - "不明なエラーが発生しました。", - }; + if (i === 0) { + firstFilePath = filePath; } } @@ -2175,7 +2147,7 @@ export const singingStore = createPartialStore({ }; mutations.SET_NOW_AUDIO_EXPORTING({ nowAudioExporting: true }); - return exportWaveFile().finally(() => { + return exportAudioFile().finally(() => { mutations.SET_CANCELLATION_OF_AUDIO_EXPORT_REQUESTED({ cancellationOfAudioExportRequested: false, }); @@ -2185,6 +2157,37 @@ export const singingStore = createPartialStore({ ), }, + EXPORT_FILE: { + async action(_, { filePath, content }) { + try { + await window.backend + .writeFile({ + filePath, + buffer: content, + }) + .then(getValueOrThrow); + } catch (e) { + logger.error("Failed to export file.", e); + if (e instanceof ResultError) { + return { + result: "WRITE_ERROR", + path: filePath, + errorMessage: generateWriteErrorMessage(e as ResultError), + }; + } + return { + result: "UNKNOWN_ERROR", + path: filePath, + errorMessage: + (e instanceof Error ? e.message : String(e)) || + "不明なエラーが発生しました。", + }; + } + + return { result: "SUCCESS", path: filePath }; + }, + }, + CANCEL_AUDIO_EXPORT: { async action({ state, mutations }) { if (!state.nowAudioExporting) { diff --git a/src/store/type.ts b/src/store/type.ts index 66f0035b7e..63b275238b 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -815,6 +815,19 @@ export const PhraseKey = (id: string): PhraseKey => phraseKeySchema.parse(id); export type SequencerEditTarget = "NOTE" | "PITCH"; +export type TrackParameters = { + gain: boolean; + pan: boolean; + soloAndMute: boolean; +}; + +export type SongExportSetting = { + isMono: boolean; + sampleRate: number; + withLimiter: boolean; + withTrackParameters: TrackParameters; +}; + export type SingingStoreState = { tpqn: number; // Ticks Per Quarter Note tempos: Tempo[]; @@ -1061,12 +1074,25 @@ export type SingingStoreTypes = { action(payload: { isDrag: boolean }): void; }; - EXPORT_WAVE_FILE: { - action(payload: { filePath?: string }): SaveResultObject; + EXPORT_AUDIO_FILE: { + action(payload: { + filePath?: string; + setting: SongExportSetting; + }): SaveResultObject; + }; + + EXPORT_STEM_AUDIO_FILE: { + action(payload: { + dirPath?: string; + setting: SongExportSetting; + }): SaveResultObject; }; - EXPORT_STEM_WAVE_FILE: { - action(payload: { dirPath?: string }): SaveResultObject; + EXPORT_FILE: { + action(payload: { + filePath: string; + content: Uint8Array; + }): Promise; }; CANCEL_AUDIO_EXPORT: { @@ -1864,6 +1890,14 @@ export type UiStoreState = { reloadingLock: boolean; inheritAudioInfo: boolean; activePointScrollMode: ActivePointScrollMode; + isMaximized: boolean; + isPinned: boolean; + isFullscreen: boolean; + progress: number; + isVuexReady: boolean; +} & DialogStates; + +export type DialogStates = { isHelpDialogOpen: boolean; isSettingDialogOpen: boolean; isCharacterOrderDialogOpen: boolean; @@ -1875,12 +1909,8 @@ export type UiStoreState = { isDictionaryManageDialogOpen: boolean; isEngineManageDialogOpen: boolean; isUpdateNotificationDialogOpen: boolean; + isExportSongAudioDialogOpen: boolean; isImportSongProjectDialogOpen: boolean; - isMaximized: boolean; - isPinned: boolean; - isFullscreen: boolean; - progress: number; - isVuexReady: boolean; }; export type UiStoreTypes = { @@ -1935,34 +1965,8 @@ export type UiStoreTypes = { }; SET_DIALOG_OPEN: { - mutation: { - isDefaultStyleSelectDialogOpen?: boolean; - isAcceptRetrieveTelemetryDialogOpen?: boolean; - isAcceptTermsDialogOpen?: boolean; - isDictionaryManageDialogOpen?: boolean; - isHelpDialogOpen?: boolean; - isSettingDialogOpen?: boolean; - isHotkeySettingDialogOpen?: boolean; - isToolbarSettingDialogOpen?: boolean; - isCharacterOrderDialogOpen?: boolean; - isEngineManageDialogOpen?: boolean; - isUpdateNotificationDialogOpen?: boolean; - isImportExternalProjectDialogOpen?: boolean; - }; - action(payload: { - isDefaultStyleSelectDialogOpen?: boolean; - isAcceptRetrieveTelemetryDialogOpen?: boolean; - isAcceptTermsDialogOpen?: boolean; - isDictionaryManageDialogOpen?: boolean; - isHelpDialogOpen?: boolean; - isSettingDialogOpen?: boolean; - isHotkeySettingDialogOpen?: boolean; - isToolbarSettingDialogOpen?: boolean; - isCharacterOrderDialogOpen?: boolean; - isEngineManageDialogOpen?: boolean; - isUpdateNotificationDialogOpen?: boolean; - isImportSongProjectDialogOpen?: boolean; - }): void; + mutation: Partial; + action(payload: Partial): void; }; SHOW_ALERT_DIALOG: { diff --git a/src/store/ui.ts b/src/store/ui.ts index 9d6bdb5afa..e251f5c7f5 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -110,6 +110,7 @@ export const uiStoreState: UiStoreState = { isDictionaryManageDialogOpen: false, isEngineManageDialogOpen: false, isUpdateNotificationDialogOpen: false, + isExportSongAudioDialogOpen: false, isImportSongProjectDialogOpen: false, isMaximized: false, isPinned: false, @@ -216,23 +217,7 @@ export const uiStore = createPartialStore({ }, SET_DIALOG_OPEN: { - mutation( - state, - dialogState: { - isDefaultStyleSelectDialogOpen?: boolean; - isAcceptRetrieveTelemetryDialogOpen?: boolean; - isAcceptTermsDialogOpen?: boolean; - isDictionaryManageDialogOpen?: boolean; - isHelpDialogOpen?: boolean; - isSettingDialogOpen?: boolean; - isHotkeySettingDialogOpen?: boolean; - isToolbarSettingDialogOpen?: boolean; - isCharacterOrderDialogOpen?: boolean; - isEngineManageDialogOpen?: boolean; - isUpdateNotificationDialogOpen?: boolean; - isImportExternalProjectDialogOpen?: boolean; - }, - ) { + mutation(state, dialogState) { for (const [key, value] of Object.entries(dialogState)) { if (!(key in state)) { throw new Error(`Unknown dialog state: ${key}`); diff --git a/src/store/utility.ts b/src/store/utility.ts index 9fc4bdd8fd..405deeec33 100644 --- a/src/store/utility.ts +++ b/src/store/utility.ts @@ -130,9 +130,8 @@ const replaceTagStringToTagId: Record = Object.entries( replaceTagIdToTagString, ).reduce((prev, [k, v]) => ({ ...prev, [v]: k }), {}); -export const DEFAULT_AUDIO_FILE_BASE_NAME_TEMPLATE = +export const DEFAULT_AUDIO_FILE_NAME_TEMPLATE = "$連番$_$キャラ$($スタイル$)_$テキスト$"; -export const DEFAULT_AUDIO_FILE_NAME_TEMPLATE = `${DEFAULT_AUDIO_FILE_BASE_NAME_TEMPLATE}.wav`; const DEFAULT_AUDIO_FILE_NAME_VARIABLES = { index: 0, characterName: "四国めたん", @@ -142,9 +141,8 @@ const DEFAULT_AUDIO_FILE_NAME_VARIABLES = { projectName: "VOICEVOXプロジェクト", }; -export const DEFAULT_SONG_AUDIO_FILE_BASE_NAME_TEMPLATE = +export const DEFAULT_SONG_AUDIO_FILE_NAME_TEMPLATE = "$連番$_$キャラ$($スタイル$)_$トラック名$"; -export const DEFAULT_SONG_AUDIO_FILE_NAME_TEMPLATE = `${DEFAULT_SONG_AUDIO_FILE_BASE_NAME_TEMPLATE}.wav`; const DEFAULT_SONG_AUDIO_FILE_NAME_VARIABLES = { index: 0, characterName: "四国めたん", diff --git a/src/type/ipc.ts b/src/type/ipc.ts index 006aca3532..0311c5efca 100644 --- a/src/type/ipc.ts +++ b/src/type/ipc.ts @@ -70,7 +70,12 @@ export type IpcIHData = { }; SHOW_AUDIO_SAVE_DIALOG: { - args: [obj: { title: string; defaultPath?: string }]; + args: [ + obj: { + title: string; + defaultPath?: string; + }, + ]; return?: string; }; @@ -275,7 +280,7 @@ export type IpcIHData = { }; WRITE_FILE: { - args: [obj: { filePath: string; buffer: ArrayBuffer }]; + args: [obj: { filePath: string; buffer: ArrayBuffer | Uint8Array }]; return: Result; }; diff --git a/src/type/preload.ts b/src/type/preload.ts index a4ab896177..da847c703f 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -259,7 +259,7 @@ export interface Sandbox { }): Promise; writeFile(obj: { filePath: string; - buffer: ArrayBuffer; + buffer: ArrayBuffer | Uint8Array; }): Promise>; readFile(obj: { filePath: string }): Promise>; isAvailableGPUMode(): Promise; @@ -617,7 +617,7 @@ export const configSchema = z savingSetting: z .object({ fileEncoding: z.enum(["UTF-8", "Shift_JIS"]).default("UTF-8"), - fileNamePattern: z.string().default(""), + fileNamePattern: z.string().default(""), // NOTE: ファイル名パターンは拡張子を含まない fixedExportEnabled: z.boolean().default(false), avoidOverwrite: z.boolean().default(false), fixedExportDir: z.string().default(""),