From ded51ea89f883363e794484c0510ca324c005842 Mon Sep 17 00:00:00 2001 From: sevenc-nanashi Date: Sun, 22 Dec 2024 21:01:25 +0900 Subject: [PATCH] =?UTF-8?q?Add:=20=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=81=AE=E3=82=A8=E3=82=AF=E3=82=B9=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/browser/fileImpl.ts | 41 +++++++ src/backend/browser/sandbox.ts | 10 ++ src/backend/electron/main.ts | 14 +++ src/backend/electron/preload.ts | 9 ++ src/components/Dialog/Dialog.ts | 3 +- src/components/Sing/menuBarData.ts | 39 +++++++ src/store/singing.ts | 167 +++++++++++++++++++++++++++++ src/store/type.ts | 10 ++ src/type/ipc.ts | 12 +++ src/type/preload.ts | 6 ++ 10 files changed, 310 insertions(+), 1 deletion(-) diff --git a/src/backend/browser/fileImpl.ts b/src/backend/browser/fileImpl.ts index 2c78966774..33519fee9b 100644 --- a/src/backend/browser/fileImpl.ts +++ b/src/backend/browser/fileImpl.ts @@ -115,10 +115,18 @@ const getDirectoryHandleFromDirectoryPath = async ( // NOTE: fixedExportEnabled が有効になっている GENERATE_AND_SAVE_AUDIO action では、ファイル名に加えディレクトリ名も指定された状態でfilePathが渡ってくる // また GENERATE_AND_SAVE_ALL_AUDIO action では fixedExportEnabled の有効の有無に関わらず、ディレクトリ名も指定された状態でfilePathが渡ってくる +// showExportFilePicker での疑似パスが渡ってくる可能性もある。 export const writeFileImpl: (typeof window)[typeof SandboxKey]["writeFile"] = async (obj: { filePath: string; buffer: ArrayBuffer }) => { const filePath = obj.filePath; + const fileHandle = fileHandleMap.get(filePath); + if (fileHandle != undefined) { + const writable = await fileHandle.createWritable(); + await writable.write(obj.buffer); + return writable.close().then(() => success(undefined)); + } + if (!filePath.includes(path.SEPARATOR)) { const aTag = document.createElement("a"); const blob = URL.createObjectURL(new Blob([obj.buffer])); @@ -222,3 +230,36 @@ export const readFileImpl = async (filePath: string) => { const buffer = await file.arrayBuffer(); return success(buffer); }; + +// ファイル選択ダイアログを開く +// 返り値はファイルパスではなく、疑似パスを返す +export const showExportFilePickerImpl: (typeof window)[typeof SandboxKey]["showExportFileDialog"] = + async (obj: { + defaultName?: string; + extensionName?: string; + extensions?: string[]; + title: string; + }) => { + const handle = await showSaveFilePicker({ + suggestedName: obj.defaultName, + types: [ + { + description: + obj.extensionName ?? obj.extensions?.join("、") ?? "Text", + accept: obj.extensions + ? { + "application/octet-stream": obj.extensions.map( + (ext) => `.${ext}`, + ), + } + : { + "plain/text": [".txt"], + }, + }, + ], + }); + const fakePath = `-${handle.name}`; + fileHandleMap.set(fakePath, handle); + + return fakePath; + }; diff --git a/src/backend/browser/sandbox.ts b/src/backend/browser/sandbox.ts index 38b0f12e7a..c5e6be1bbc 100644 --- a/src/backend/browser/sandbox.ts +++ b/src/backend/browser/sandbox.ts @@ -2,6 +2,7 @@ import { defaultEngine } from "./contract"; import { checkFileExistsImpl, readFileImpl, + showExportFilePickerImpl, showOpenDirectoryDialogImpl, showOpenFilePickerImpl, writeFileImpl, @@ -163,6 +164,15 @@ export const api: Sandbox = { }); return fileHandle?.[0]; }, + async showExportFileDialog(obj: { + defaultName?: string; + extensionName?: string; + extensions?: string[]; + title: string; + }) { + const fileHandle = await showExportFilePickerImpl(obj); + return fileHandle; + }, writeFile(obj: { filePath: string; buffer: ArrayBuffer }) { return writeFileImpl(obj); }, diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index c8da2eac37..c834176d6c 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -600,6 +600,20 @@ registerIpcMainHandle({ })?.[0]; }, + SHOW_EXPORT_FILE_DIALOG: ( + _, + { title, defaultName, extensionName, extensions }, + ) => { + return dialog.showSaveDialogSync(win, { + title, + defaultPath: defaultName, + filters: [ + { name: extensionName ?? "Text", extensions: extensions ?? ["txt"] }, + ], + properties: ["createDirectory"], + }); + }, + IS_AVAILABLE_GPU_MODE: () => { return hasSupportedGpu(process.platform); }, diff --git a/src/backend/electron/preload.ts b/src/backend/electron/preload.ts index 2d3015b124..2cb28ec6d8 100644 --- a/src/backend/electron/preload.ts +++ b/src/backend/electron/preload.ts @@ -75,6 +75,15 @@ const api: Sandbox = { }); }, + showExportFileDialog: ({ title, defaultName, extensionName, extensions }) => { + return ipcRendererInvokeProxy.SHOW_EXPORT_FILE_DIALOG({ + title, + defaultName, + extensionName, + extensions, + }); + }, + writeFile: async ({ filePath, buffer }) => { return await ipcRendererInvokeProxy.WRITE_FILE({ filePath, buffer }); }, diff --git a/src/components/Dialog/Dialog.ts b/src/components/Dialog/Dialog.ts index 93f088d961..3805797d39 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" | "project"; export type TextDialogResult = "OK" | "CANCEL"; export type MessageDialogOptions = { @@ -302,6 +302,7 @@ const showWriteSuccessNotify = ({ const mediaTypeNames: Record = { audio: "音声", text: "テキスト", + project: "プロジェクト", }; void actions.SHOW_NOTIFY_AND_NOT_SHOW_AGAIN_BUTTON({ message: `${mediaTypeNames[mediaType]}を書き出しました`, diff --git a/src/components/Sing/menuBarData.ts b/src/components/Sing/menuBarData.ts index a32dbfe144..4c6d760c54 100644 --- a/src/components/Sing/menuBarData.ts +++ b/src/components/Sing/menuBarData.ts @@ -1,7 +1,9 @@ import { computed } from "vue"; +import { notifyResult } from "../Dialog/Dialog"; import { useStore } from "@/store"; import { MenuItemData } from "@/components/Menu/type"; import { useRootMiscSetting } from "@/composables/useRootMiscSetting"; +import { ExportSongProjectFileType } from "@/store/type"; export const useMenuBarData = () => { const store = useStore(); @@ -24,6 +26,23 @@ export const useMenuBarData = () => { }); }; + const exportSongProject = async ( + fileType: ExportSongProjectFileType, + fileTypeLabel: string, + ) => { + if (uiLocked.value) return; + const result = await store.actions.EXPORT_SONG_PROJECT({ + fileType, + fileTypeLabel, + }); + notifyResult( + result, + "project", + store.actions, + store.state.confirmedTips.notifyOnGenerate, + ); + }; + // 「ファイル」メニュー const fileSubMenuData = computed(() => [ { @@ -43,6 +62,26 @@ export const useMenuBarData = () => { }, disableWhenUiLocked: true, }, + { + type: "root", + label: "エクスポート", + subMenu: ( + [ + ["smf", "MIDI (SMF)"], + ["musicxml", "MusicXML"], + ["ufdata", "Utaformatix"], + ["ust", "UTAU"], + ] satisfies [fileType: ExportSongProjectFileType, name: string][] + ).map(([fileType, label]) => ({ + type: "button", + label, + onClick: () => { + void exportSongProject(fileType, label); + }, + disableWhenUiLocked: true, + })), + disableWhenUiLocked: true, + }, ]); // 「編集」メニュー diff --git a/src/store/singing.ts b/src/store/singing.ts index 7c0809621a..1f3cb867e5 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -107,6 +107,8 @@ import { convertToWavFileData } from "@/helpers/convertToWavFileData"; import { generateWriteErrorMessage } from "@/helpers/fileHelper"; import path from "@/helpers/path"; import { showAlertDialog } from "@/components/Dialog/Dialog"; +import { ufProjectFromVoicevox } from "@/sing/utaformatixProject/fromVoicevox"; +import { ExhaustiveError, UnreachableError } from "@/type/utility"; const logger = createLogger("store/singing"); @@ -3213,6 +3215,171 @@ export const singingStore = createPartialStore({ return Math.max(1, lastNoteEndTime + 1); }, }, + + EXPORT_SONG_PROJECT: { + action: createUILockAction( + async ( + { state, getters, actions }, + { fileType, fileTypeLabel }, + ): Promise => { + const fileBaseName = generateDefaultSongFileBaseName( + getters.PROJECT_NAME, + getters.SELECTED_TRACK, + getters.CHARACTER_INFO, + ); + const project = ufProjectFromVoicevox( + { + tempos: state.tempos, + timeSignatures: state.timeSignatures, + tpqn: state.tpqn, + tracks: state.trackOrder.map((trackId) => + getOrThrow(state.tracks, trackId), + ), + }, + fileBaseName, + ); + + // 複数トラックかつ複数ファイルの形式はディレクトリを選択する + if ( + state.trackOrder.length > 1 && + ["ust", "xml", "musicxml"].includes(fileType) + ) { + const dirPath = await window.backend.showSaveDirectoryDialog({ + title: "プロジェクトを書き出し", + }); + if (!dirPath) { + return { + result: "CANCELED", + path: "", + }; + } + + let extension: string; + let tracksBytes: Uint8Array[]; + switch (fileType) { + case "musicxml": + tracksBytes = await project.toMusicXml(); + extension = "musicxml"; + break; + case "ust": + tracksBytes = await project.toUst(); + extension = "ust"; + break; + default: + throw new UnreachableError(`Unexpected fileType: ${fileType}`); + } + + let firstFilePath = ""; + for (const [i, trackBytes] of tracksBytes.entries()) { + const track = getOrThrow(state.tracks, state.trackOrder[i]); + 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 trackFileName = buildSongTrackAudioFileNameFromRawData( + state.savingSetting.songTrackFileNamePattern, + { + characterName: characterInfo.metas.speakerName, + index: i, + styleName: style.styleName || DEFAULT_STYLE_NAME, + date: currentDateString(), + projectName: getters.PROJECT_NAME ?? DEFAULT_PROJECT_NAME, + trackName: track.name, + }, + ); + let filePath = path.join(dirPath, `${trackFileName}.${extension}`); + if (state.savingSetting.avoidOverwrite) { + let tail = 1; + while (await window.backend.checkFileExists(filePath)) { + filePath = path.join( + dirPath, + `${trackFileName}[${tail}].${extension}`, + ); + tail += 1; + } + } + if (i === 0) { + firstFilePath = filePath; + } + + const result = await actions.EXPORT_FILE({ + filePath, + content: trackBytes, + }); + if (result.result !== "SUCCESS") { + return result; + } + } + + return { + result: "SUCCESS", + path: firstFilePath, + }; + } else { + let buffer: Uint8Array; + let extension: string; + switch (fileType) { + case "musicxml": + buffer = (await project.toMusicXml())[0]; + extension = "musicxml"; + break; + case "ust": + buffer = (await project.toUst())[0]; + extension = "ust"; + break; + case "smf": + buffer = await project.toStandardMid(); + extension = "mid"; + break; + case "ufdata": + buffer = await project.toUfData(); + extension = "ufdata"; + break; + default: + throw new ExhaustiveError(fileType); + } + + let filePath = await window.backend.showExportFileDialog({ + title: "プロジェクトを書き出し", + defaultName: fileBaseName, + extensionName: fileTypeLabel, + extensions: [extension], + }); + if (!filePath) { + return { + result: "CANCELED", + path: "", + }; + } + if (state.savingSetting.avoidOverwrite) { + let tail = 1; + const filePathWithoutExt = path.basename(filePath, `.${extension}`); + while (await window.backend.checkFileExists(filePath)) { + filePath = `${filePathWithoutExt}[${tail}].${extension}`; + tail += 1; + } + } + + return await actions.EXPORT_FILE({ + filePath, + content: buffer, + }); + } + }, + ), + }, }); export const singingCommandStoreState: SingingCommandStoreState = {}; diff --git a/src/store/type.ts b/src/store/type.ts index 580165ebf1..ccdfe6fc65 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -847,6 +847,9 @@ export type NoteEditTool = "SELECT_FIRST" | "EDIT_FIRST"; // ピッチ編集ツール export type PitchEditTool = "DRAW" | "ERASE"; +// プロジェクトを書き出しできるファイル形式 +export type ExportSongProjectFileType = "ufdata" | "smf" | "ust" | "musicxml"; + export type TrackParameters = { gain: boolean; pan: boolean; @@ -1354,6 +1357,13 @@ export type SingingStoreTypes = { APPLY_DEVICE_ID_TO_AUDIO_CONTEXT: { action(payload: { device: string }): void; }; + + EXPORT_SONG_PROJECT: { + action(payload: { + fileType: ExportSongProjectFileType; + fileTypeLabel: string; + }): Promise; + }; }; export type SingingCommandStoreState = { diff --git a/src/type/ipc.ts b/src/type/ipc.ts index d4c64162fe..ddddfeb00f 100644 --- a/src/type/ipc.ts +++ b/src/type/ipc.ts @@ -68,6 +68,18 @@ export type IpcIHData = { return?: string; }; + SHOW_EXPORT_FILE_DIALOG: { + args: [ + obj: { + title: string; + defaultName?: string; + extensionName?: string; + extensions?: string[]; + }, + ]; + return?: string; + }; + SHOW_PROJECT_SAVE_DIALOG: { args: [obj: { title: string; defaultPath?: string }]; return?: string; diff --git a/src/type/preload.ts b/src/type/preload.ts index 24e4afc361..49f85b29b5 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -97,6 +97,12 @@ export interface Sandbox { name?: string; extensions?: string[]; }): Promise; + showExportFileDialog(obj: { + title: string; + defaultName?: string; + extensionName?: string; + extensions?: string[]; + }): Promise; writeFile(obj: { filePath: string; buffer: ArrayBuffer | Uint8Array;