Skip to content

Commit

Permalink
Add: プロジェクトのエクスポート機能を追加
Browse files Browse the repository at this point in the history
  • Loading branch information
sevenc-nanashi committed Dec 22, 2024
1 parent f1e3966 commit ded51ea
Show file tree
Hide file tree
Showing 10 changed files with 310 additions and 1 deletion.
41 changes: 41 additions & 0 deletions src/backend/browser/fileImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down Expand Up @@ -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 = `<browser-dummy-${uuid4()}>-${handle.name}`;
fileHandleMap.set(fakePath, handle);

return fakePath;
};
10 changes: 10 additions & 0 deletions src/backend/browser/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defaultEngine } from "./contract";
import {
checkFileExistsImpl,
readFileImpl,
showExportFilePickerImpl,
showOpenDirectoryDialogImpl,
showOpenFilePickerImpl,
writeFileImpl,
Expand Down Expand Up @@ -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);
},
Expand Down
14 changes: 14 additions & 0 deletions src/backend/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,20 @@ registerIpcMainHandle<IpcMainHandle>({
})?.[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);
},
Expand Down
9 changes: 9 additions & 0 deletions src/backend/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/Dialog/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -302,6 +302,7 @@ const showWriteSuccessNotify = ({
const mediaTypeNames: Record<MediaType, string> = {
audio: "音声",
text: "テキスト",
project: "プロジェクト",
};
void actions.SHOW_NOTIFY_AND_NOT_SHOW_AGAIN_BUTTON({
message: `${mediaTypeNames[mediaType]}を書き出しました`,
Expand Down
39 changes: 39 additions & 0 deletions src/components/Sing/menuBarData.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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<MenuItemData[]>(() => [
{
Expand All @@ -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,
},
]);

// 「編集」メニュー
Expand Down
167 changes: 167 additions & 0 deletions src/store/singing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -3213,6 +3215,171 @@ export const singingStore = createPartialStore<SingingStoreTypes>({
return Math.max(1, lastNoteEndTime + 1);
},
},

EXPORT_SONG_PROJECT: {
action: createUILockAction(
async (
{ state, getters, actions },
{ fileType, fileTypeLabel },
): Promise<SaveResultObject> => {
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 = {};
Expand Down
10 changes: 10 additions & 0 deletions src/store/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SaveResultObject>;
};
};

export type SingingCommandStoreState = {
Expand Down
Loading

0 comments on commit ded51ea

Please sign in to comment.