From 8c944eb202a7a18cee7e137f8802920fd43915b0 Mon Sep 17 00:00:00 2001 From: Nanashi Date: Sun, 16 Jun 2024 03:52:12 +0900 Subject: [PATCH] =?UTF-8?q?Add:=20UtaFormatix=E3=82=92=E5=B0=8E=E5=85=A5?= =?UTF-8?q?=E3=81=97=E3=81=A6=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E5=AF=BE=E5=BF=9C=E5=BD=A2=E5=BC=8F?= =?UTF-8?q?=E3=82=92=E5=A2=97=E3=82=84=E3=81=99=20(#2104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add: @sevenc-nanashi/utaformatix-tsを入れる * Change: ImportExternalProjectDialogにする * Change: IMPORT_MIDI_FILEをIMPORT_EXTERNAL_PROJECT_FILEに * Add: 歌詞を変換する * Delete: midi-fileを依存から消す * Add: parseFailedのメッセージを追加 * Change: convertVowelConnectionsを有効にする * Add: vvprojを読み込めるように * Change: externalSongProjectにする * Change: 残っていたmidiを変える * Change: 0.2.3に更新 * Fix: ファイル名を修正 * Fix: 型エラーを修正 * Code: フォーマット * Add: 拡張子一覧を追加 * Update: DIの方を更新 * Change: プロジェクトファイルの順番を変える Co-authored-by: Hiroshiba * Change: CeVIO AI -> CeVIO * Refactor: projectFileErrorを良い感じにする * Change: SMFの表記を変える * Change: details-summaryで囲む * Change: Project -> UfProject * Change: PARSE_PROJECT_FILEに移動 * Change: ufDataの変換を別ファイルに移動 * Add: テストを追加 * Change: midi -> ufProjectToSongState * Fix: Add忘れ * Change: log.errorの引数を変える * Update: パスを更新 * Code: コメントを追加 * Change: プロジェクトファイルのインポート -> インポート * Refactor: 整理 * Fix: パスを更新 * Add: vvprojの読み込みお変える * Update: Update utaformatix-ts * Update: 本家の色々に追従 * Delete: 不要なフォールバックを削除 * Fix: 変換を修正 * Update: テスTを更新 * Change: uuidv4 -> crypto.randomUUID * Change: コンバーターの名前を変える * Change: SongState -> VoicevoxScore * Code: npm run fmt * Add: TODOを追加 * Code: TODOを追加 Co-Authored-By: Hiroshiba * Code: TODOを削除 * Code: コメントの解釈を一つに * Code: TODOを追加 * Refactor: 良い感じにする * Add: エクスポートのテストを追加 * 改変してみた * Delete: 不要なroundを削除 * Delete: 不要なimportを削除 --------- Co-authored-by: Hiroshiba Co-authored-by: Hiroshiba --- .npmrc | 1 + package-lock.json | 50 +- package.json | 2 +- src/components/Dialog/AllDialog.vue | 12 +- src/components/Dialog/ImportMidiDialog.vue | 180 ------ .../Dialog/ImportSongProjectDialog.vue | 302 +++++++++ src/components/Sing/menuBarData.ts | 34 +- src/domain/frontend/log.ts | 4 +- src/sing/midi.ts | 167 ----- src/sing/utaformatixProject/common.ts | 8 + src/sing/utaformatixProject/fromVoicevox.ts | 38 ++ src/sing/utaformatixProject/toVoicevox.ts | 121 ++++ src/store/project.ts | 80 ++- src/store/singing.ts | 588 ++---------------- src/store/type.ts | 24 +- src/store/ui.ts | 4 +- src/styles/_index.scss | 12 +- tests/unit/lib/midi/midi.spec.ts | 59 -- .../__snapshots__/export.spec.ts.snap | 37 ++ .../lib/{midi => utaformatixProject}/bpm.mid | Bin .../lib/utaformatixProject/export.spec.ts | 35 ++ .../lib/utaformatixProject/import.spec.ts | 61 ++ .../{midi => utaformatixProject}/synthv.mid | Bin .../{midi => utaformatixProject}/timeSig.mid | Bin 24 files changed, 752 insertions(+), 1067 deletions(-) delete mode 100644 src/components/Dialog/ImportMidiDialog.vue create mode 100644 src/components/Dialog/ImportSongProjectDialog.vue delete mode 100644 src/sing/midi.ts create mode 100644 src/sing/utaformatixProject/common.ts create mode 100644 src/sing/utaformatixProject/fromVoicevox.ts create mode 100644 src/sing/utaformatixProject/toVoicevox.ts delete mode 100644 tests/unit/lib/midi/midi.spec.ts create mode 100644 tests/unit/lib/utaformatixProject/__snapshots__/export.spec.ts.snap rename tests/unit/lib/{midi => utaformatixProject}/bpm.mid (100%) create mode 100644 tests/unit/lib/utaformatixProject/export.spec.ts create mode 100644 tests/unit/lib/utaformatixProject/import.spec.ts rename tests/unit/lib/{midi => utaformatixProject}/synthv.mid (100%) rename tests/unit/lib/{midi => utaformatixProject}/timeSig.mid (100%) diff --git a/.npmrc b/.npmrc index d9ca8860b0..335b2f1803 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ engine-strict=true save-exact=true +@jsr:registry=https://npm.jsr.io diff --git a/package-lock.json b/package-lock.json index b8fe0a6ff0..f0b1d9500b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@gtm-support/vue-gtm": "1.2.3", "@quasar/extras": "1.10.10", + "@sevenc-nanashi/utaformatix-ts": "npm:@jsr/sevenc-nanashi__utaformatix-ts@0.3.0", "async-lock": "1.4.0", "dayjs": "1.10.7", "electron-log": "5.1.2", @@ -22,7 +23,6 @@ "hotkeys-js": "3.13.6", "immer": "9.0.21", "markdown-it": "13.0.2", - "midi-file": "1.2.4", "move-file": "3.0.0", "multistream": "4.1.0", "pixi.js": "7.4.0", @@ -2199,6 +2199,16 @@ "win32" ] }, + "node_modules/@sevenc-nanashi/utaformatix-ts": { + "name": "@jsr/sevenc-nanashi__utaformatix-ts", + "version": "0.3.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/sevenc-nanashi__utaformatix-ts/0.3.0.tgz", + "integrity": "sha512-D6Y6lkxOawpv3LKSgrTGKLquuzaFvkwNThSGDXT5QBnfk7VE4bFbqJr9JlgjvAdh5sNQVGPku6uMdIdvSDxzAg==", + "dependencies": { + "jszip": "^3.10.1", + "utaformatix-data": "^1.1.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4504,8 +4514,7 @@ "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "devOptional": true + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, "node_modules/crc": { "version": "3.8.0", @@ -7105,8 +7114,7 @@ "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "dev": true + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, "node_modules/immer": { "version": "9.0.21", @@ -7815,7 +7823,6 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", @@ -7826,14 +7833,12 @@ "node_modules/jszip/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "node_modules/jszip/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7847,14 +7852,12 @@ "node_modules/jszip/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/jszip/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -7982,7 +7985,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, "dependencies": { "immediate": "~3.0.5" } @@ -8320,11 +8322,6 @@ "node": ">=8.6" } }, - "node_modules/midi-file": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/midi-file/-/midi-file-1.2.4.tgz", - "integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==" - }, "node_modules/miller-rabin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", @@ -8994,8 +8991,7 @@ "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -9390,8 +9386,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/progress": { "version": "2.0.3", @@ -10173,8 +10168,7 @@ "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, "node_modules/sha.js": { "version": "2.4.11", @@ -11193,6 +11187,14 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" }, + "node_modules/utaformatix-data": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/utaformatix-data/-/utaformatix-data-1.1.0.tgz", + "integrity": "sha512-1AoxBvRMkXjifHqvIpTnvLLGo3Qyj1Q4PSQLgKd8e6RMq4HA5o6QNI4ila9BO1fkJAWA9Azj/hVANHWBkpbVvg==", + "engines": { + "node": ">=10" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", diff --git a/package.json b/package.json index 7f3d7e90ea..712f0241e7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@gtm-support/vue-gtm": "1.2.3", "@quasar/extras": "1.10.10", + "@sevenc-nanashi/utaformatix-ts": "npm:@jsr/sevenc-nanashi__utaformatix-ts@0.3.0", "async-lock": "1.4.0", "dayjs": "1.10.7", "electron-log": "5.1.2", @@ -46,7 +47,6 @@ "hotkeys-js": "3.13.6", "immer": "9.0.21", "markdown-it": "13.0.2", - "midi-file": "1.2.4", "move-file": "3.0.0", "multistream": "4.1.0", "pixi.js": "7.4.0", diff --git a/src/components/Dialog/AllDialog.vue b/src/components/Dialog/AllDialog.vue index 4711b8aea6..30d538c65b 100644 --- a/src/components/Dialog/AllDialog.vue +++ b/src/components/Dialog/AllDialog.vue @@ -22,7 +22,7 @@ - + diff --git a/src/components/Dialog/ImportMidiDialog.vue b/src/components/Dialog/ImportMidiDialog.vue deleted file mode 100644 index 6abdf11d33..0000000000 --- a/src/components/Dialog/ImportMidiDialog.vue +++ /dev/null @@ -1,180 +0,0 @@ - - - diff --git a/src/components/Dialog/ImportSongProjectDialog.vue b/src/components/Dialog/ImportSongProjectDialog.vue new file mode 100644 index 0000000000..9b6153cba0 --- /dev/null +++ b/src/components/Dialog/ImportSongProjectDialog.vue @@ -0,0 +1,302 @@ + + + diff --git a/src/components/Sing/menuBarData.ts b/src/components/Sing/menuBarData.ts index 56af239d8a..b13733822e 100644 --- a/src/components/Sing/menuBarData.ts +++ b/src/components/Sing/menuBarData.ts @@ -7,23 +7,13 @@ export const useMenuBarData = () => { const uiLocked = computed(() => store.getters.UI_LOCKED); const isNotesSelected = computed(() => store.state.selectedNoteIds.size > 0); - const importMidiFile = async () => { + const importExternalSongProject = async () => { if (uiLocked.value) return; await store.dispatch("SET_DIALOG_OPEN", { - isImportMidiDialogOpen: true, + isImportSongProjectDialogOpen: true, }); }; - const importMusicXMLFile = async () => { - if (uiLocked.value) return; - 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", {}); @@ -41,25 +31,9 @@ export const useMenuBarData = () => { { type: "separator" }, { type: "button", - label: "MIDI読み込み", - onClick: () => { - importMidiFile(); - }, - disableWhenUiLocked: true, - }, - { - type: "button", - label: "MusicXML読み込み", - onClick: () => { - importMusicXMLFile(); - }, - disableWhenUiLocked: true, - }, - { - type: "button", - label: "UST読み込み", + label: "インポート", onClick: () => { - importUstFile(); + importExternalSongProject(); }, disableWhenUiLocked: true, }, diff --git a/src/domain/frontend/log.ts b/src/domain/frontend/log.ts index ef4ab4554a..3b7a224d9f 100644 --- a/src/domain/frontend/log.ts +++ b/src/domain/frontend/log.ts @@ -3,8 +3,8 @@ export function createLogger(scope: string) { const createInner = (method: "logInfo" | "logError" | "logWarn") => - (message: string, ...args: unknown[]) => { - window.backend[method](`[${scope}] ${message}`, ...args); + (...args: unknown[]) => { + window.backend[method](`[${scope}] ${args[0]}`, ...args.slice(1)); }; return { info: createInner("logInfo"), diff --git a/src/sing/midi.ts b/src/sing/midi.ts deleted file mode 100644 index c493d9c18c..0000000000 --- a/src/sing/midi.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - MidiData, - parseMidi, - MidiTrackNameEvent, - MidiEvent, - MidiSetTempoEvent, - MidiTimeSignatureEvent, - MidiLyricsEvent, - MidiNoteOnEvent, - MidiNoteOffEvent, -} from "midi-file"; -type Tempo = { ticks: number; bpm: number }; -type TimeSignature = { - ticks: number; - numerator: number; - denominator: number; -}; -type Note = { - ticks: number; - noteNumber: number; - duration: number; - lyric?: string; -}; - -// BPMの精度。(小数点以下の桁数) -const bpmPrecision = 2; - -/** - * midi-fileの軽いラッパー。 - */ -export class Midi { - data: MidiData; - tracks: Track[]; - constructor(data: ArrayBuffer) { - this.data = parseMidi(new Uint8Array(data)); - this.tracks = this.data.tracks.map((track) => new Track(track)); - } - - get header() { - return this.data.header; - } - - get ticksPerBeat() { - const maybeTicksPerBeat = this.data.header.ticksPerBeat; - if (maybeTicksPerBeat == undefined) { - throw new Error("ticksPerBeat is undefined"); - } - return maybeTicksPerBeat; - } - - get tempos(): Tempo[] { - const tempos = this.tracks.flatMap((track) => track.tempos); - tempos.sort((a, b) => a.ticks - b.ticks); - return tempos; - } - - get timeSignatures(): TimeSignature[] { - const timeSignatures = this.tracks.flatMap((track) => track.timeSignatures); - timeSignatures.sort((a, b) => a.ticks - b.ticks); - return timeSignatures; - } -} - -type MidiEventWithTime = { - time: number; -} & T; -export class Track { - readonly data: MidiData["tracks"][0]; - readonly events: MidiEventWithTime[]; - readonly notes: Note[]; - constructor(data: MidiData["tracks"][0]) { - this.data = data; - let time = 0; - this.events = data.map((event) => { - time += event.deltaTime; - return { time, ...event }; - }); - const lyrics = this.events.filter( - (e) => e.type === "lyrics", - ) as MidiEventWithTime[]; - const lyricsMap = new Map( - lyrics.map((e) => { - // midi-fileはUTF-8としてデコードしてくれないので、ここでデコードする - const buffer = new Uint8Array( - e.text.split("").map((c) => c.charCodeAt(0)), - ); - const decoder = new TextDecoder("utf-8"); - return [e.time, decoder.decode(buffer)]; - }), - ); - - const noteOnOffs = this.events.filter( - (e) => e.type === "noteOn" || e.type === "noteOff", - ) as MidiEventWithTime[]; - noteOnOffs.sort((a, b) => a.time - b.time); - this.notes = []; - const temporaryNotes = new Map< - number, - { noteNumber: number; time: number } - >(); - for (const event of noteOnOffs) { - if (event.type === "noteOn") { - if (temporaryNotes.has(event.noteNumber)) { - throw new Error("noteOn without noteOff"); - } - temporaryNotes.set(event.noteNumber, { - noteNumber: event.noteNumber, - time: event.time, - }); - } else { - const note = temporaryNotes.get(event.noteNumber); - if (!note) { - throw new Error("noteOff without noteOn"); - } - temporaryNotes.delete(event.noteNumber); - this.notes.push({ - ticks: note.time, - noteNumber: note.noteNumber, - duration: event.time - note.time, - // 同じタイミングの歌詞をノートの歌詞として使う - lyric: lyricsMap.get(note.time), - }); - } - } - } - - get name() { - const nameEvent = this.data.find( - (e) => e.type === "trackName", - ) as MidiTrackNameEvent; - if (!nameEvent) { - return ""; - } - return nameEvent.text; - } - - get tempos(): Tempo[] { - const tempoEvents = this.events.filter( - (e) => e.type === "setTempo", - ) as MidiEventWithTime[]; - - const tempos = tempoEvents.map((e) => ({ - ticks: e.time, - bpm: - Math.round( - ((60 * 1000000) / e.microsecondsPerBeat) * 10 ** bpmPrecision, - ) / - 10 ** bpmPrecision, - })); - tempos.sort((a, b) => a.ticks - b.ticks); - return tempos; - } - - get timeSignatures(): TimeSignature[] { - const timeSignatureEvents = this.events.filter( - (e) => e.type === "timeSignature", - ) as MidiEventWithTime[]; - - const timeSignatures = timeSignatureEvents.map((e) => ({ - ticks: e.time, - numerator: e.numerator, - denominator: e.denominator, - })); - timeSignatures.sort((a, b) => a.ticks - b.ticks); - return timeSignatures; - } -} diff --git a/src/sing/utaformatixProject/common.ts b/src/sing/utaformatixProject/common.ts new file mode 100644 index 0000000000..dee6839344 --- /dev/null +++ b/src/sing/utaformatixProject/common.ts @@ -0,0 +1,8 @@ +import { Tempo, TimeSignature, Track } from "@/store/type"; + +export type VoicevoxScore = { + tracks: Track[]; + tpqn: number; + tempos: Tempo[]; + timeSignatures: TimeSignature[]; +}; diff --git a/src/sing/utaformatixProject/fromVoicevox.ts b/src/sing/utaformatixProject/fromVoicevox.ts new file mode 100644 index 0000000000..fba4a3ee9b --- /dev/null +++ b/src/sing/utaformatixProject/fromVoicevox.ts @@ -0,0 +1,38 @@ +// TODO: エクスポート機能を実装する + +import { Project as UfProject, UfData } from "@sevenc-nanashi/utaformatix-ts"; +import { VoicevoxScore } from "./common"; + +/** Voicevoxの楽譜データをUtaformatixのProjectに変換する */ +export const ufProjectFromVoicevox = ( + { tracks, tpqn, tempos, timeSignatures }: VoicevoxScore, + projectName: string, +): UfProject => { + const convertTicks = (ticks: number) => Math.round((ticks / tpqn) * 480); + const ufData: UfData = { + formatVersion: 1, + project: { + measurePrefix: 0, + name: projectName, + tempos: tempos.map((tempo) => ({ + tickPosition: convertTicks(tempo.position), + bpm: tempo.bpm, + })), + timeSignatures: timeSignatures.map((timeSignature) => ({ + measurePosition: timeSignature.measureNumber, + numerator: timeSignature.beats, + denominator: timeSignature.beatType, + })), + tracks: tracks.map((track) => ({ + name: `無名トラック`, + notes: track.notes.map((note) => ({ + key: note.noteNumber, + tickOn: convertTicks(note.position), + tickOff: convertTicks(note.position + note.duration), + lyric: note.lyric, + })), + })), + }, + }; + return new UfProject(ufData); +}; diff --git a/src/sing/utaformatixProject/toVoicevox.ts b/src/sing/utaformatixProject/toVoicevox.ts new file mode 100644 index 0000000000..f3e2448d67 --- /dev/null +++ b/src/sing/utaformatixProject/toVoicevox.ts @@ -0,0 +1,121 @@ +import { Project as UfProject } from "@sevenc-nanashi/utaformatix-ts"; +import { VoicevoxScore } from "./common"; +import { DEFAULT_TPQN, createDefaultTrack } from "@/sing/domain"; +import { getDoremiFromNoteNumber } from "@/sing/viewHelper"; +import { NoteId } from "@/type/preload"; +import { Note, Tempo, TimeSignature, Track } from "@/store/type"; + +/** UtaformatixのプロジェクトをVoicevoxの楽譜データに変換する */ +export const ufProjectToVoicevox = (project: UfProject): VoicevoxScore => { + const convertPosition = ( + position: number, + sourceTpqn: number, + targetTpqn: number, + ) => { + return Math.round(position * (targetTpqn / sourceTpqn)); + }; + + const convertDuration = ( + startPosition: number, + endPosition: number, + sourceTpqn: number, + targetTpqn: number, + ) => { + const convertedEndPosition = convertPosition( + endPosition, + sourceTpqn, + targetTpqn, + ); + const convertedStartPosition = convertPosition( + startPosition, + sourceTpqn, + targetTpqn, + ); + return Math.max(1, convertedEndPosition - convertedStartPosition); + }; + + const removeDuplicateTempos = (tempos: Tempo[]) => { + return tempos.filter((value, index, array) => { + return ( + index === array.length - 1 || + value.position !== array[index + 1].position + ); + }); + }; + + const removeDuplicateTimeSignatures = (timeSignatures: TimeSignature[]) => { + return timeSignatures.filter((value, index, array) => { + return ( + index === array.length - 1 || + value.measureNumber !== array[index + 1].measureNumber + ); + }); + }; + + // 歌詞をひらがなの単独音に変換する + const convertedProject = project.convertJapaneseLyrics("auto", "KanaCv", { + convertVowelConnections: true, + }); + + // 480は固定値。 + // https://github.com/sdercolin/utaformatix-data?tab=readme-ov-file#value-conventions + const projectTpqn = 480; + const projectTempos = convertedProject.tempos; + const projectTimeSignatures = convertedProject.timeSignatures; + + const tpqn = DEFAULT_TPQN; + + const tracks: Track[] = convertedProject.tracks.map((projectTrack) => { + const trackNotes = projectTrack.notes; + + trackNotes.sort((a, b) => a.tickOn - b.tickOn); + + const notes = trackNotes.map((value): Note => { + return { + id: NoteId(crypto.randomUUID()), + position: convertPosition(value.tickOn, projectTpqn, tpqn), + duration: convertDuration( + value.tickOn, + value.tickOff, + projectTpqn, + tpqn, + ), + noteNumber: value.key, + lyric: value.lyric || getDoremiFromNoteNumber(value.key), + }; + }); + + return { + ...createDefaultTrack(), + notes, + }; + }); + + let tempos = projectTempos.map((value): Tempo => { + return { + position: convertPosition(value.tickPosition, projectTpqn, tpqn), + bpm: value.bpm, + }; + }); + tempos = removeDuplicateTempos(tempos); + + let timeSignatures: TimeSignature[] = []; + for (const ts of projectTimeSignatures) { + const beats = ts.numerator; + const beatType = ts.denominator; + timeSignatures.push({ + // UtaFormatixは0から始まるので+1する + measureNumber: ts.measurePosition + 1, + beats, + beatType, + }); + } + timeSignatures = removeDuplicateTimeSignatures(timeSignatures); + + return { + tracks, + tpqn, + tempos, + timeSignatures, + }; +}; diff --git a/src/store/project.ts b/src/store/project.ts index 8b6feae799..64f14a2910 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -130,6 +130,29 @@ export const projectStore = createPartialStore({ ), }, + PARSE_PROJECT_FILE: { + async action({ dispatch, getters }, { projectJson }) { + const projectData = JSON.parse(projectJson); + + const characterInfos = getters.USER_ORDERED_CHARACTER_INFOS("talk"); + if (characterInfos == undefined) + throw new Error("characterInfos == undefined"); + + const parsedProjectData = await migrateProjectFileObject(projectData, { + fetchMoraData: (payload) => dispatch("FETCH_MORA_DATA", payload), + voices: characterInfos.flatMap((characterInfo) => + characterInfo.metas.styles.map((style) => ({ + engineId: style.engineId, + speakerId: characterInfo.metas.speakerUuid, + styleId: style.styleId, + })), + ), + }); + + return parsedProjectData; + }, + }, + LOAD_PROJECT_FILE: { /** * プロジェクトファイルを読み込む。読み込めたかの成否が返る。 @@ -137,7 +160,7 @@ export const projectStore = createPartialStore({ */ action: createUILockAction( async ( - context, + { dispatch, commit, getters }, { filePath, confirm }: { filePath?: string; confirm?: boolean }, ) => { if (!filePath) { @@ -157,58 +180,31 @@ export const projectStore = createPartialStore({ .readFile({ filePath }) .then(getValueOrThrow); - await context.dispatch("APPEND_RECENTLY_USED_PROJECT", { + await dispatch("APPEND_RECENTLY_USED_PROJECT", { filePath, }); const text = new TextDecoder("utf-8").decode(buf).trim(); - const projectData = JSON.parse(text); - - const characterInfos = - context.getters.USER_ORDERED_CHARACTER_INFOS("talk"); - if (characterInfos == undefined) - throw new Error("characterInfos == undefined"); - - const parsedProjectData = await migrateProjectFileObject( - projectData, - { - fetchMoraData: (payload) => - context.dispatch("FETCH_MORA_DATA", payload), - voices: characterInfos.flatMap((characterInfo) => - characterInfo.metas.styles.map((style) => ({ - engineId: style.engineId, - speakerId: characterInfo.metas.speakerUuid, - styleId: style.styleId, - })), - ), - }, - ); + const parsedProjectData = await dispatch("PARSE_PROJECT_FILE", { + projectJson: text, + }); - if (confirm !== false && context.getters.IS_EDITED) { - const result = await context.dispatch( - "SAVE_OR_DISCARD_PROJECT_FILE", - { - additionalMessage: - "プロジェクトをロードすると現在のプロジェクトは破棄されます。", - }, - ); + if (confirm !== false && getters.IS_EDITED) { + const result = await dispatch("SAVE_OR_DISCARD_PROJECT_FILE", { + additionalMessage: + "プロジェクトをロードすると現在のプロジェクトは破棄されます。", + }); if (result == "canceled") { return false; } } - await applyTalkProjectToStore( - context.dispatch, - parsedProjectData.talk, - ); - await applySongProjectToStore( - context.dispatch, - parsedProjectData.song, - ); + await applyTalkProjectToStore(dispatch, parsedProjectData.talk); + await applySongProjectToStore(dispatch, parsedProjectData.song); - context.commit("SET_PROJECT_FILEPATH", { filePath }); - context.commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); - context.commit("CLEAR_COMMANDS"); + commit("SET_PROJECT_FILEPATH", { filePath }); + commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); + commit("CLEAR_COMMANDS"); return true; } catch (err) { window.backend.logError(err); diff --git a/src/store/singing.ts b/src/store/singing.ts index 446e43692b..af159545d9 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -24,7 +24,6 @@ import { } from "./type"; import { sanitizeFileName } from "./utility"; import { EngineId, NoteId, StyleId } from "@/type/preload"; -import { Midi } from "@/sing/midi"; import { FrameAudioQuery, Note as NoteForRequestToEngine } from "@/openapi"; import { ResultError, getValueOrThrow } from "@/type/result"; import { @@ -43,7 +42,6 @@ import { } from "@/sing/audioRendering"; import { selectPriorPhrase, - getMeasureDuration, getNoteDuration, isValidNote, isValidSnapType, @@ -79,7 +77,6 @@ import { removeNotesFromOverlappingNoteInfos, updateNotesOfOverlappingNoteInfos, } from "@/sing/storeHelper"; -import { getDoremiFromNoteNumber } from "@/sing/viewHelper"; import { AnimationTimer, createPromiseThatResolvesWhen, @@ -90,6 +87,7 @@ import { getWorkaroundKeyRangeAdjustment } from "@/sing/workaroundKeyRangeAdjust import { createLogger } from "@/domain/frontend/log"; import { noteSchema } from "@/domain/project/schema"; import { getOrThrow } from "@/helpers/mapHelper"; +import { ufProjectToVoicevox } from "@/sing/utaformatixProject/toVoicevox"; const logger = createLogger("store/singing"); @@ -1692,140 +1690,21 @@ export const singingStore = createPartialStore({ }), }, - IMPORT_MIDI_FILE: { + // TODO: Undoできるようにする + IMPORT_UTAFORMATIX_PROJECT: { action: createUILockAction( - async ( - { state, dispatch }, - { filePath, trackIndex = 0 }: { filePath: string; trackIndex: number }, - ) => { - const convertPosition = ( - position: number, - sourceTpqn: number, - targetTpqn: number, - ) => { - return Math.round(position * (targetTpqn / sourceTpqn)); - }; - - const convertDuration = ( - startPosition: number, - endPosition: number, - sourceTpqn: number, - targetTpqn: number, - ) => { - const convertedEndPosition = convertPosition( - endPosition, - sourceTpqn, - targetTpqn, - ); - const convertedStartPosition = convertPosition( - startPosition, - sourceTpqn, - targetTpqn, - ); - return Math.max(1, convertedEndPosition - convertedStartPosition); - }; + async ({ state, commit, dispatch }, { project, trackIndex = 0 }) => { + const { tempos, timeSignatures, tracks, tpqn } = + ufProjectToVoicevox(project); - const getTopNotes = (notes: Note[]) => { - const topNotes: Note[] = []; - for (const note of notes) { - if (topNotes.length === 0) { - topNotes.push(note); - continue; - } - const topNote = topNotes[topNotes.length - 1]; - const topNoteEndPos = topNote.position + topNote.duration; - if (topNoteEndPos <= note.position) { - topNotes.push(note); - continue; - } - if (topNote.noteNumber < note.noteNumber) { - topNotes.pop(); - topNotes.push(note); - } - } - return topNotes; - }; - - const removeDuplicateTempos = (tempos: Tempo[]) => { - return tempos.filter((value, index, array) => { - return ( - index === array.length - 1 || - value.position !== array[index + 1].position - ); - }); - }; - - const removeDuplicateTimeSignatures = ( - timeSignatures: TimeSignature[], - ) => { - return timeSignatures.filter((value, index, array) => { - return ( - index === array.length - 1 || - value.measureNumber !== array[index + 1].measureNumber - ); - }); - }; - - // NOTE: トラック選択のために一度ファイルを読み込んでいるので、Midiを渡すなどでもよさそう - const midiData = getValueOrThrow( - await window.backend.readFile({ filePath }), - ); - const midi = new Midi(midiData); - const midiTpqn = midi.ticksPerBeat; - const midiTempos = midi.tempos; - const midiTimeSignatures = midi.timeSignatures; + const notes = tracks[trackIndex].notes; - const midiNotes = midi.tracks[trackIndex].notes; - - midiNotes.sort((a, b) => a.ticks - b.ticks); - - const tpqn = DEFAULT_TPQN; - - let notes = midiNotes.map((value): Note => { - return { - id: NoteId(crypto.randomUUID()), - position: convertPosition(value.ticks, midiTpqn, tpqn), - duration: convertDuration( - value.ticks, - value.ticks + value.duration, - midiTpqn, - tpqn, - ), - noteNumber: value.noteNumber, - lyric: value.lyric || getDoremiFromNoteNumber(value.noteNumber), - }; - }); - // ノートの重なりを考慮して、一番音が高いノート(トップノート)のみインポートする - notes = getTopNotes(notes); - - let tempos = midiTempos.map((value): Tempo => { - return { - position: convertPosition(value.ticks, midiTpqn, tpqn), - bpm: round(value.bpm, 2), - }; - }); - tempos.unshift(createDefaultTempo(0)); - tempos = removeDuplicateTempos(tempos); - - let timeSignatures: TimeSignature[] = []; - let tsPosition = 0; - let measureNumber = 1; - for (let i = 0; i < midiTimeSignatures.length; i++) { - const midiTs = midiTimeSignatures[i]; - const beats = midiTs.numerator; - const beatType = midiTs.denominator; - timeSignatures.push({ measureNumber, beats, beatType }); - if (i < midiTimeSignatures.length - 1) { - const nextTsTicks = midiTimeSignatures[i + 1].ticks; - const nextTsPos = convertPosition(nextTsTicks, midiTpqn, tpqn); - const tsDuration = nextTsPos - tsPosition; - const measureDuration = getMeasureDuration(beats, beatType, tpqn); - tsPosition = nextTsPos; - measureNumber += tsDuration / measureDuration; - } + if (tempos.length > 1) { + logger.warn("Multiple tempos are not supported."); + } + if (timeSignatures.length > 1) { + logger.warn("Multiple time signatures are not supported."); } - timeSignatures.unshift(createDefaultTimeSignature(1)); - timeSignatures = removeDuplicateTimeSignatures(timeSignatures); tempos.splice(1, tempos.length - 1); // TODO: 複数テンポに対応したら削除 timeSignatures.splice(1, timeSignatures.length - 1); // TODO: 複数拍子に対応したら削除 @@ -1833,432 +1712,59 @@ export const singingStore = createPartialStore({ if (tpqn !== state.tpqn) { throw new Error("TPQN does not match. Must be converted."); } + + // TODO: ここら辺のSET系の処理をまとめる + await dispatch("SET_TPQN", { tpqn }); await dispatch("SET_TEMPOS", { tempos }); await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); await dispatch("SET_NOTES", { notes }); + + commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); + commit("CLEAR_COMMANDS"); + dispatch("RENDER"); }, ), }, - IMPORT_MUSICXML_FILE: { + // TODO: Undoできるようにする + IMPORT_VOICEVOX_PROJECT: { action: createUILockAction( - async ({ state, dispatch }, { filePath }: { filePath?: string }) => { - if (!filePath) { - filePath = await window.backend.showImportFileDialog({ - title: "MusicXML読み込み", - name: "MusicXML", - extensions: ["musicxml", "xml"], - }); - if (!filePath) return; - } + async ({ state, commit, dispatch }, { project, trackIndex = 0 }) => { + const { tempos, timeSignatures, tracks, tpqn } = project.song; - let xmlStr = new TextDecoder("utf-8").decode( - getValueOrThrow(await window.backend.readFile({ filePath })), - ); - if (xmlStr.indexOf("\ufffd") > -1) { - xmlStr = new TextDecoder("shift-jis").decode( - getValueOrThrow(await window.backend.readFile({ filePath })), - ); - } - - const tpqn = DEFAULT_TPQN; - const tempos = [createDefaultTempo(0)]; - const timeSignatures = [createDefaultTimeSignature(1)]; - const notes: Note[] = []; - - let divisions = 1; - let position = 0; - let measureNumber = 1; - let measurePosition = 0; - let measureDuration = getMeasureDuration( - timeSignatures[0].beats, - timeSignatures[0].beatType, - tpqn, - ); - let tieStartNote: Note | undefined; - - const getChild = (element: Element | undefined, tagName: string) => { - if (element) { - for (const childElement of element.children) { - if (childElement.tagName === tagName) { - return childElement; - } - } - } - return undefined; - }; - - const getValueAsNumber = (element: Element) => { - const value = Number(element.textContent); - if (Number.isNaN(value)) { - throw new Error("The value is invalid."); - } - return value; - }; - - const getAttributeAsNumber = ( - element: Element, - qualifiedName: string, - ) => { - const value = Number(element.getAttribute(qualifiedName)); - if (Number.isNaN(value)) { - throw new Error("The value is invalid."); - } - return value; - }; - - const getStepNumber = (stepElement: Element) => { - const stepNumberDict: { [key: string]: number } = { - C: 0, - D: 2, - E: 4, - F: 5, - G: 7, - A: 9, - B: 11, - }; - const stepChar = stepElement.textContent; - if (stepChar == null) { - throw new Error("The value is invalid."); - } - return stepNumberDict[stepChar]; - }; - - const getDuration = (durationElement: Element) => { - const duration = getValueAsNumber(durationElement); - return Math.round((tpqn * duration) / divisions); - }; - - const getTie = (elementThatMayBeTied: Element) => { - let tie: boolean | undefined; - for (const childElement of elementThatMayBeTied.children) { - if ( - childElement.tagName === "tie" || - childElement.tagName === "tied" - ) { - const tieType = childElement.getAttribute("type"); - if (tieType === "start") { - tie = true; - } else if (tieType === "stop") { - tie = false; - } else { - throw new Error("The value is invalid."); - } - } - } - return tie; - }; - - const parseSound = (soundElement: Element) => { - if (!soundElement.hasAttribute("tempo")) { - return; - } - if (tempos.length !== 0) { - const lastTempo = tempos[tempos.length - 1]; - if (lastTempo.position === position) { - tempos.pop(); - } - } - const tempo = getAttributeAsNumber(soundElement, "tempo"); - tempos.push({ - position: position, - bpm: round(tempo, 2), - }); - }; - - const parseDirection = (directionElement: Element) => { - for (const childElement of directionElement.children) { - if (childElement.tagName === "sound") { - parseSound(childElement); - } - } - }; - - const parseDivisions = (divisionsElement: Element) => { - divisions = getValueAsNumber(divisionsElement); - }; - - const parseTime = (timeElement: Element) => { - const beatsElement = getChild(timeElement, "beats"); - if (!beatsElement) { - throw new Error("beats element does not exist."); - } - const beatTypeElement = getChild(timeElement, "beat-type"); - if (!beatTypeElement) { - throw new Error("beat-type element does not exist."); - } - const beats = getValueAsNumber(beatsElement); - const beatType = getValueAsNumber(beatTypeElement); - measureDuration = getMeasureDuration(beats, beatType, tpqn); - if (timeSignatures.length !== 0) { - const lastTimeSignature = timeSignatures[timeSignatures.length - 1]; - if (lastTimeSignature.measureNumber === measureNumber) { - timeSignatures.pop(); - } - } - timeSignatures.push({ - measureNumber, - beats, - beatType, - }); - }; - - const parseAttributes = (attributesElement: Element) => { - for (const childElement of attributesElement.children) { - if (childElement.tagName === "divisions") { - parseDivisions(childElement); - } else if (childElement.tagName === "time") { - parseTime(childElement); - } else if (childElement.tagName === "sound") { - parseSound(childElement); - } - } - }; - - const parseNote = (noteElement: Element) => { - // TODO: ノートの重なり・和音を考慮していないので、 - // それらが存在する場合でも読み込めるようにする - - const durationElement = getChild(noteElement, "duration"); - if (!durationElement) { - throw new Error("duration element does not exist."); - } - let duration = getDuration(durationElement); - let noteEnd = position + duration; - const measureEnd = measurePosition + measureDuration; - if (noteEnd > measureEnd) { - // 小節に収まらない場合、ノートの長さを変えて小節に収まるようにする - duration = measureEnd - position; - noteEnd = position + duration; - } - - if (getChild(noteElement, "rest")) { - position += duration; - return; - } - - const pitchElement = getChild(noteElement, "pitch"); - if (!pitchElement) { - throw new Error("pitch element does not exist."); - } - const octaveElement = getChild(pitchElement, "octave"); - if (!octaveElement) { - throw new Error("octave element does not exist."); - } - const stepElement = getChild(pitchElement, "step"); - if (!stepElement) { - throw new Error("step element does not exist."); - } - const alterElement = getChild(pitchElement, "alter"); - - const octave = getValueAsNumber(octaveElement); - const stepNumber = getStepNumber(stepElement); - let noteNumber = 12 * (octave + 1) + stepNumber; - if (alterElement) { - noteNumber += getValueAsNumber(alterElement); - } - - const lyricElement = getChild(noteElement, "lyric"); - let lyric = getChild(lyricElement, "text")?.textContent ?? ""; - lyric = lyric.trim(); - - let tie = getTie(noteElement); - for (const childElement of noteElement.children) { - if (childElement.tagName === "notations") { - tie = getTie(childElement); - } - } - - const note: Note = { - id: NoteId(crypto.randomUUID()), - position, - duration, - noteNumber, - lyric, - }; - - if (tieStartNote) { - if (tie === false) { - tieStartNote.duration = noteEnd - tieStartNote.position; - notes.push(tieStartNote); - tieStartNote = undefined; - } - } else { - if (tie === true) { - tieStartNote = note; - } else { - notes.push(note); - } - } - position += duration; - }; - - const parseMeasure = (measureElement: Element) => { - measurePosition = position; - measureNumber = getAttributeAsNumber(measureElement, "number"); - for (const childElement of measureElement.children) { - if (childElement.tagName === "direction") { - parseDirection(childElement); - } else if (childElement.tagName === "sound") { - parseSound(childElement); - } else if (childElement.tagName === "attributes") { - parseAttributes(childElement); - } else if (childElement.tagName === "note") { - if (position < measurePosition + measureDuration) { - parseNote(childElement); - } - } - } - const measureEnd = measurePosition + measureDuration; - if (position !== measureEnd) { - tieStartNote = undefined; - position = measureEnd; - } - }; - - const parsePart = (partElement: Element) => { - for (const childElement of partElement.children) { - if (childElement.tagName === "measure") { - parseMeasure(childElement); - } - } - }; - - const parseMusicXml = (xmlStr: string) => { - const parser = new DOMParser(); - const dom = parser.parseFromString(xmlStr, "application/xml"); - const partElements = dom.getElementsByTagName("part"); - if (partElements.length === 0) { - throw new Error("part element does not exist."); - } - // TODO: UIで読み込むパートを選択できるようにする - parsePart(partElements[0]); - }; - - parseMusicXml(xmlStr); - - tempos.splice(1, tempos.length - 1); // TODO: 複数テンポに対応したら削除 - timeSignatures.splice(1, timeSignatures.length - 1); // TODO: 複数拍子に対応したら削除 + const track = tracks[trackIndex]; + const notes = track.notes.map((note) => ({ + ...note, + id: NoteId(crypto.randomUUID()), + })); if (tpqn !== state.tpqn) { throw new Error("TPQN does not match. Must be converted."); } - await dispatch("SET_TEMPOS", { tempos }); - await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes }); - }, - ), - }, - - IMPORT_UST_FILE: { - action: createUILockAction( - async ({ state, 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 = [createDefaultTempo(0)]; - const timeSignatures = [createDefaultTimeSignature(1)]; - 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; - } - // ノートセクション - // #以降に数字の場合はノートセクション ex: #0, #0000 - if (params.sectionName.match(/^#\d+$/)) { - // テンポ変更があれば追加 - const tempo = Number(params["Tempo"]); - if (tempo) tempos.push({ position, bpm: tempo }); - const noteNumber = Number(params["NoteNum"]); - const duration = Number(params["Length"]); - let lyric = params["Lyric"].trim(); - // 歌詞の前に連続音が含まれている場合は除去 - if (lyric.includes(" ")) { - lyric = lyric.split(" ")[1]; - } - // 休符であればポジションを進めるのみ - if (lyric === "R") { - position += duration; - } else { - // それ以外の場合はノートを追加 - notes.push({ - id: NoteId(crypto.randomUUID()), - position, - duration, - noteNumber, - lyric, - }); - position += duration; - } - } + // TODO: ここら辺のSET系の処理をまとめる + await dispatch("SET_SINGER", { + singer: track.singer, }); - - if (tpqn !== state.tpqn) { - throw new Error("TPQN does not match. Must be converted."); - } + await dispatch("SET_KEY_RANGE_ADJUSTMENT", { + keyRangeAdjustment: track.keyRangeAdjustment, + }); + await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment: track.volumeRangeAdjustment, + }); + await dispatch("SET_TPQN", { tpqn }); await dispatch("SET_TEMPOS", { tempos }); await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); await dispatch("SET_NOTES", { notes }); + await dispatch("CLEAR_PITCH_EDIT_DATA"); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 + await dispatch("SET_PITCH_EDIT_DATA", { + data: track.pitchEditData, + startFrame: 0, + }); + + commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); + commit("CLEAR_COMMANDS"); + dispatch("RENDER"); }, ), }, diff --git a/src/store/type.ts b/src/store/type.ts index a5873a13b5..f926511550 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -1,5 +1,6 @@ import { Patch } from "immer"; import { z } from "zod"; +import { Project as UfProject } from "@sevenc-nanashi/utaformatix-ts"; import { MutationTree, MutationsBase, @@ -61,6 +62,7 @@ import { } from "@/components/Dialog/Dialog"; import { OverlappingNoteInfos } from "@/sing/storeHelper"; import { + LatestProjectType, noteSchema, singerSchema, tempoSchema, @@ -1016,16 +1018,12 @@ export type SingingStoreTypes = { action(payload: { isDrag: boolean }): void; }; - IMPORT_MIDI_FILE: { - action(payload: { filePath: string; trackIndex: number }): void; + IMPORT_UTAFORMATIX_PROJECT: { + action(payload: { project: UfProject; trackIndex: number }): void; }; - IMPORT_MUSICXML_FILE: { - action(payload: { filePath?: string }): void; - }; - - IMPORT_UST_FILE: { - action(payload: { filePath?: string }): void; + IMPORT_VOICEVOX_PROJECT: { + action(payload: { project: LatestProjectType; trackIndex: number }): void; }; EXPORT_WAVE_FILE: { @@ -1489,6 +1487,10 @@ export type ProjectStoreTypes = { action(payload: { confirm?: boolean }): void; }; + PARSE_PROJECT_FILE: { + action(payload: { projectJson: string }): Promise; + }; + LOAD_PROJECT_FILE: { action(payload: { filePath?: string; confirm?: boolean }): boolean; }; @@ -1642,7 +1644,7 @@ export type UiStoreState = { isDictionaryManageDialogOpen: boolean; isEngineManageDialogOpen: boolean; isUpdateNotificationDialogOpen: boolean; - isImportMidiDialogOpen: boolean; + isImportSongProjectDialogOpen: boolean; isMaximized: boolean; isPinned: boolean; isFullscreen: boolean; @@ -1714,7 +1716,7 @@ export type UiStoreTypes = { isCharacterOrderDialogOpen?: boolean; isEngineManageDialogOpen?: boolean; isUpdateNotificationDialogOpen?: boolean; - isImportMidiDialogOpen?: boolean; + isImportExternalProjectDialogOpen?: boolean; }; action(payload: { isDefaultStyleSelectDialogOpen?: boolean; @@ -1728,7 +1730,7 @@ export type UiStoreTypes = { isCharacterOrderDialogOpen?: boolean; isEngineManageDialogOpen?: boolean; isUpdateNotificationDialogOpen?: boolean; - isImportMidiDialogOpen?: boolean; + isImportSongProjectDialogOpen?: boolean; }): void; }; diff --git a/src/store/ui.ts b/src/store/ui.ts index d83d9974db..59ac754d6d 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -66,7 +66,7 @@ export const uiStoreState: UiStoreState = { isDictionaryManageDialogOpen: false, isEngineManageDialogOpen: false, isUpdateNotificationDialogOpen: false, - isImportMidiDialogOpen: false, + isImportSongProjectDialogOpen: false, isMaximized: false, isPinned: false, isFullscreen: false, @@ -186,7 +186,7 @@ export const uiStore = createPartialStore({ isCharacterOrderDialogOpen?: boolean; isEngineManageDialogOpen?: boolean; isUpdateNotificationDialogOpen?: boolean; - isImportMidiDialogOpen?: boolean; + isImportExternalProjectDialogOpen?: boolean; }, ) { for (const [key, value] of Object.entries(dialogState)) { diff --git a/src/styles/_index.scss b/src/styles/_index.scss index 02912d5603..f598a5995a 100644 --- a/src/styles/_index.scss +++ b/src/styles/_index.scss @@ -1,5 +1,5 @@ -@use './variables' as vars; -@use './colors' as colors; +@use "./variables" as vars; +@use "./colors" as colors; @import "./fonts"; // 優先度を強引に上げる @@ -20,6 +20,14 @@ img { pointer-events: none; } +// detailsタグのスタイル +details { + summary { + display: list-item; + cursor: pointer; + } +} + // スクロールバーのデザイン ::-webkit-scrollbar { width: 12px; diff --git a/tests/unit/lib/midi/midi.spec.ts b/tests/unit/lib/midi/midi.spec.ts deleted file mode 100644 index 10df1ee4b3..0000000000 --- a/tests/unit/lib/midi/midi.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @vitest-environment node - -import { promises as fs } from "fs"; -import { it, expect } from "vitest"; -import { Midi } from "@/sing/midi"; - -// MIDIファイルの作成情報: -// - synthv.mid:SynthVで作成(Synthesizer V Studio Pro 1.11.0、プロジェクトファイルは https://github.com/VOICEVOX/voicevox/pull/1982 を参照) -// - timeSig.mid、bpm.mid:signalで作成(https://signal.vercel.app/edit) - -const midiRoot = "tests/unit/lib/midi/"; - -it("BPMをパースできる", async () => { - const bpmMid = await fs.readFile(midiRoot + "bpm.mid"); - const midi = new Midi(bpmMid); - const ticksPerBeat = midi.ticksPerBeat; - expect(midi.tempos).toEqual([ - { ticks: 0, bpm: 120 }, - { ticks: ticksPerBeat * 4, bpm: 180 }, - { ticks: ticksPerBeat * 8, bpm: 240 }, - ]); -}); - -const lyricExpectation: [noteNumber: number, lyric: string][] = [ - [60, "ど"], - [62, "れ"], - [64, "み"], - [65, "ふぁ"], - [67, "そ"], - [69, "ら"], - [71, "し"], - [72, "ど"], -]; - -it("SynthVのノートと歌詞をパースできる", async () => { - const synthvMid = await fs.readFile(midiRoot + "synthv.mid"); - const midi = new Midi(synthvMid); - const ticksPerBeat = midi.ticksPerBeat; - // SynthVのMIDIファイルの1トラック目はBPM情報のみなので、2トラック目を取得 - expect(midi.tracks[1].notes).toEqual( - lyricExpectation.map(([noteNumber, lyric], index) => ({ - ticks: index * ticksPerBeat, - noteNumber, - duration: ticksPerBeat, - lyric, - })), - ); -}); - -it("拍子をパースできる", async () => { - const timeSigMid = await fs.readFile(midiRoot + "timeSig.mid"); - const midi = new Midi(timeSigMid); - const ticksPerBeat = midi.ticksPerBeat; - expect(midi.timeSignatures).toEqual([ - { ticks: 0, numerator: 4, denominator: 4 }, - { ticks: ticksPerBeat * 4, numerator: 3, denominator: 4 }, - { ticks: ticksPerBeat * 7, numerator: 4, denominator: 8 }, - ]); -}); diff --git a/tests/unit/lib/utaformatixProject/__snapshots__/export.spec.ts.snap b/tests/unit/lib/utaformatixProject/__snapshots__/export.spec.ts.snap new file mode 100644 index 0000000000..f15a3be488 --- /dev/null +++ b/tests/unit/lib/utaformatixProject/__snapshots__/export.spec.ts.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`トラックを変換できる 1`] = ` +{ + "formatVersion": 1, + "project": { + "measurePrefix": 0, + "name": "test", + "tempos": [ + { + "bpm": 120, + "tickPosition": 0, + }, + ], + "timeSignatures": [ + { + "denominator": 4, + "measurePosition": 1, + "numerator": 4, + }, + ], + "tracks": [ + { + "name": "無名トラック", + "notes": [ + { + "key": 60, + "lyric": "ど", + "tickOff": 480, + "tickOn": 0, + }, + ], + }, + ], + }, +} +`; diff --git a/tests/unit/lib/midi/bpm.mid b/tests/unit/lib/utaformatixProject/bpm.mid similarity index 100% rename from tests/unit/lib/midi/bpm.mid rename to tests/unit/lib/utaformatixProject/bpm.mid diff --git a/tests/unit/lib/utaformatixProject/export.spec.ts b/tests/unit/lib/utaformatixProject/export.spec.ts new file mode 100644 index 0000000000..1fad3469b5 --- /dev/null +++ b/tests/unit/lib/utaformatixProject/export.spec.ts @@ -0,0 +1,35 @@ +import { it, expect } from "vitest"; +import { ufProjectFromVoicevox } from "@/sing/utaformatixProject/fromVoicevox"; +import { + createDefaultTempo, + createDefaultTimeSignature, + createDefaultTrack, +} from "@/sing/domain"; +import { NoteId } from "@/type/preload"; + +const createNoteId = () => NoteId(crypto.randomUUID()); + +it("トラックを変換できる", async () => { + const track = createDefaultTrack(); + track.notes.push({ + id: createNoteId(), + noteNumber: 60, + position: 0, + duration: 480, + lyric: "ど", + }); + + const project = ufProjectFromVoicevox( + { + tracks: [track], + tpqn: 480, + tempos: [createDefaultTempo(0)], + timeSignatures: [createDefaultTimeSignature(1)], + }, + "test", + ); + + const ufData = project.toUfDataObject(); + + expect(ufData).toMatchSnapshot(); +}); diff --git a/tests/unit/lib/utaformatixProject/import.spec.ts b/tests/unit/lib/utaformatixProject/import.spec.ts new file mode 100644 index 0000000000..ea9632464e --- /dev/null +++ b/tests/unit/lib/utaformatixProject/import.spec.ts @@ -0,0 +1,61 @@ +// @vitest-environment node + +import { promises as fs } from "fs"; +import { it, expect } from "vitest"; +import { Project as UfProject } from "@sevenc-nanashi/utaformatix-ts"; +import { ufProjectToVoicevox } from "@/sing/utaformatixProject/toVoicevox"; + +// MIDIファイルの作成情報: +// - synthv.mid:SynthVで作成(Synthesizer V Studio Pro 1.11.0、プロジェクトファイルは https://github.com/VOICEVOX/voicevox/pull/1982 を参照) +// - timeSig.mid、bpm.mid:signalで作成(https://signal.vercel.app/edit) + +const midiRoot = "tests/unit/lib/utaformatixProject/"; + +const convertMidi = async (filename: string) => { + const midi = await fs.readFile(midiRoot + filename); + const project = await UfProject.fromStandardMid(midi); + return ufProjectToVoicevox(project); +}; + +it("BPMを変換できる", async () => { + const state = await convertMidi("bpm.mid"); + const tpqn = state.tpqn; + expect(state.tempos).toEqual([ + { position: 0, bpm: 120 }, + { position: tpqn * 4, bpm: 180 }, + { position: tpqn * 8, bpm: 240 }, + ]); +}); + +const lyricExpectation: [noteNumber: number, lyric: string][] = [ + [60, "ど"], + [62, "れ"], + [64, "み"], + [65, "ふぁ"], + [67, "そ"], + [69, "ら"], + [71, "し"], + [72, "ど"], +]; + +it("SynthVのノートと歌詞を変換できる", async () => { + const state = await convertMidi("synthv.mid"); + expect(state.tracks[0].notes).toMatchObject( + lyricExpectation.map(([noteNumber, lyric], index) => ({ + // id: string, + noteNumber, + position: index * state.tpqn, + duration: state.tpqn, + lyric, + })), + ); +}); + +it("拍子を変換できる", async () => { + const state = await convertMidi("timeSig.mid"); + expect(state.timeSignatures).toEqual([ + { measureNumber: 1, beats: 4, beatType: 4 }, + { measureNumber: 2, beats: 3, beatType: 4 }, + { measureNumber: 3, beats: 4, beatType: 8 }, + ]); +}); diff --git a/tests/unit/lib/midi/synthv.mid b/tests/unit/lib/utaformatixProject/synthv.mid similarity index 100% rename from tests/unit/lib/midi/synthv.mid rename to tests/unit/lib/utaformatixProject/synthv.mid diff --git a/tests/unit/lib/midi/timeSig.mid b/tests/unit/lib/utaformatixProject/timeSig.mid similarity index 100% rename from tests/unit/lib/midi/timeSig.mid rename to tests/unit/lib/utaformatixProject/timeSig.mid