diff --git a/src/components/Sing/MenuBar.vue b/src/components/Sing/MenuBar.vue index 578b08cabf..7723f943eb 100644 --- a/src/components/Sing/MenuBar.vue +++ b/src/components/Sing/MenuBar.vue @@ -21,6 +21,11 @@ const importMusicXMLFile = async () => { await store.dispatch("IMPORT_MUSICXML_FILE", {}); }; +const importUstFile = async () => { + if (uiLocked.value) return; + await store.dispatch("IMPORT_UST_FILE", {}); +}; + const exportWaveFile = async () => { if (uiLocked.value) return; await store.dispatch("EXPORT_WAVE_FILE", {}); @@ -52,5 +57,13 @@ const fileSubMenuData: MenuItemData[] = [ }, disableWhenUiLocked: true, }, + { + type: "button", + label: "UST読み込み", + onClick: () => { + importUstFile(); + }, + disableWhenUiLocked: true, + }, ]; diff --git a/src/store/singing.ts b/src/store/singing.ts index 720d442e60..e52d317a59 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -1717,6 +1717,127 @@ export const singingStore = createPartialStore({ ), }, + IMPORT_UST_FILE: { + action: createUILockAction( + async ({ dispatch }, { filePath }: { filePath?: string }) => { + // USTファイルの読み込み + if (!filePath) { + filePath = await window.backend.showImportFileDialog({ + title: "UST読み込み", + name: "UST", + extensions: ["ust"], + }); + if (!filePath) return; + } + // ファイルの読み込み + const fileData = getValueOrThrow( + await window.backend.readFile({ filePath }) + ); + + // ファイルフォーマットに応じてエンコーディングを変える + // UTF-8とShiftJISの2種類に対応 + let ustData; + try { + ustData = new TextDecoder("utf-8").decode(fileData); + // ShiftJISの場合はShiftJISでデコードし直す + if (ustData.includes("\ufffd")) { + ustData = new TextDecoder("shift-jis").decode(fileData); + } + } catch (error) { + throw new Error("Failed to decode UST file.", { cause: error }); + } + if (!ustData || typeof ustData !== "string") { + throw new Error("Failed to decode UST file."); + } + + // 初期化 + const tpqn = DEFAULT_TPQN; + const tempos: Tempo[] = [ + { + position: 0, + bpm: DEFAULT_BPM, + }, + ]; + const timeSignatures: TimeSignature[] = [ + { + measureNumber: 1, + beats: DEFAULT_BEATS, + beatType: DEFAULT_BEAT_TYPE, + }, + ]; + const notes: Note[] = []; + + // USTファイルのセクションをパース + const parseSection = (section: string): { [key: string]: string } => { + const sectionNameMatch = section.match(/\[(.+)\]/); + if (!sectionNameMatch) { + throw new Error("UST section name not found"); + } + const params = section.split(/[\r\n]+/).reduce((acc, line) => { + const [key, value] = line.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, {} as { [key: string]: string }); + return { + ...params, + sectionName: sectionNameMatch[1], + }; + }; + + // セクションを分割 + const sections = ustData.split(/^(?=\[)/m); + // ポジション + let position = 0; + // セクションごとに処理 + sections.forEach((section) => { + const params = parseSection(section); + // SETTINGセクション + if (params.sectionName === "#SETTING") { + const tempo = Number(params["Tempo"]); + if (tempo) tempos[0].bpm = tempo; + } + // ノートセクション + if (params.sectionName.match(/^#\d{4}/)) { + // テンポ変更があれば追加 + const tempo = Number(params["Tempo"]); + if (tempo) tempos.push({ position, bpm: tempo }); + const noteNumber = Number(params["NoteNum"]); + const duration = Number(params["Length"]); + // 歌詞の前に連続音が含まれている場合は除去 + const lyric = params["Lyric"].includes(" ") + ? params["Lyric"].split(" ")[1] + : params["Lyric"]; + // 休符であればポジションを進めるのみ + if (lyric === "R") { + position += duration; + } else { + // それ以外の場合はノートを追加 + notes.push({ + id: uuidv4(), + position, + duration, + noteNumber, + lyric, + }); + position += duration; + } + } + }); + + await dispatch("SET_SCORE", { + score: { + tpqn, + tempos, + timeSignatures, + notes, + }, + }); + } + ), + }, + SET_NOW_AUDIO_EXPORTING: { mutation(state, { nowAudioExporting }) { state.nowAudioExporting = nowAudioExporting; diff --git a/src/store/type.ts b/src/store/type.ts index 4740e8a31d..988a966461 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -932,6 +932,10 @@ export type SingingStoreTypes = { action(payload: { filePath?: string }): void; }; + IMPORT_UST_FILE: { + action(payload: { filePath?: string }): void; + }; + EXPORT_WAVE_FILE: { action(payload: { filePath?: string }): SaveResultObject; };