diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfac2dd020..996697c2f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,8 +27,8 @@ on: env: VOICEVOX_ENGINE_REPO_URL: "https://github.com/VOICEVOX/voicevox_engine" - VOICEVOX_ENGINE_VERSION: 0.20-preview.1 - VOICEVOX_RESOURCE_VERSION: 0.19.1 + VOICEVOX_ENGINE_VERSION: 0.20.0 + VOICEVOX_RESOURCE_VERSION: 0.20.0 VOICEVOX_EDITOR_VERSION: |- # releaseタグ名か、workflow_dispatchでのバージョン名か、999.999.999-developが入る ${{ github.event.release.tag_name || github.event.inputs.version || '999.999.999-develop' }} diff --git a/package-lock.json b/package-lock.json index 4ae8deb995..105cebdbe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "multistream": "4.1.0", "pixi.js": "7.4.0", "quasar": "2.11.6", + "radix-vue": "1.2.3", + "rfdc": "1.4.1", "semver": "7.5.4", "shlex": "2.1.2", "systeminformation": "5.21.15", @@ -3074,6 +3076,63 @@ "integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==", "dev": true }, + "node_modules/@floating-ui/core": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.3.tgz", + "integrity": "sha512-1ZpCvYf788/ZXOhRQGFxnYQOVgeU+pi0i+d0Ow34La7qjIXETi6RNswGVKkA6KcDO8/+Ysu2E/CeUmmeEBDvTg==", + "dependencies": { + "@floating-ui/utils": "^0.2.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.6.tgz", + "integrity": "sha512-qiTYajAnh3P+38kECeffMSQgbvXty2VB6rS+42iWR4FPIlZjLK84E9qtLnMTLIpPz2znD/TaFqaiavMUrS+Hcw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.3" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.3.tgz", + "integrity": "sha512-XGndio0l5/Gvd6CLIABvsav9HHezgDFFhDfHk1bvLfr9ni8dojqLSvBbotJEjmIwNHL7vK4QzBJTdBRoB+c1ww==" + }, + "node_modules/@floating-ui/vue": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.0.7.tgz", + "integrity": "sha512-tm9aMT9IrMzoZfzPpsoZHP7j7ULZ0p9AzCJV6i2H8sAlKe36tAnwuQLHdm7vE0SnRkHJJXuMB/gNz4gFdHLNrg==", + "dependencies": { + "@floating-ui/dom": "^1.0.0", + "@floating-ui/utils": "^0.2.3", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.8", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz", + "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@gtm-support/core": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-1.1.0.tgz", @@ -13137,8 +13196,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -19474,6 +19532,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/radix-vue": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.2.3.tgz", + "integrity": "sha512-iR4D3SoIoCzKeCldxwxjLv0roGBZNSKAxE5/CgB8V1P7Mk7RtVhnFmOIWL/Z3XNzR9XfU03s8FZLIs+1LCEXnQ==", + "dependencies": { + "@floating-ui/dom": "^1.5.3", + "@floating-ui/vue": "^1.0.2", + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/ramda": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.0.tgz", @@ -20256,6 +20324,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/package.json b/package.json index fe8d183225..1b21df166e 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "multistream": "4.1.0", "pixi.js": "7.4.0", "quasar": "2.11.6", + "radix-vue": "1.2.3", + "rfdc": "1.4.1", "semver": "7.5.4", "shlex": "2.1.2", "systeminformation": "5.21.15", diff --git a/public/howtouse.md b/public/howtouse.md index bb49033d75..c407e21984 100644 --- a/public/howtouse.md +++ b/public/howtouse.md @@ -30,6 +30,7 @@ その際は Finder で `Ctrl` キーを押しながら VOICEVOX アプリケーションアイコンをクリックし、ショートカットメニューから「開く」を選択してから、「開く」をクリックしてください。 もしくは、アップルメニューから「システム設定」を選択して「プライバシーとセキュリティ」 をクリックし、ページの下にあるセキュリティの「このまま開く」を選んでください。 + Macのシステム設定の「プライバシーとセキュリティ」を開いた画面 macOS Ventura 以前をお使いの場合は、アップルメニューから「システム環境設定」を選択して「セキュリティとプライバシー」 をクリックし、「一般」パネルで「このまま開く」選んでください。 @@ -216,7 +217,7 @@ GPU をお持ちの方は、音声の生成がずっと速い GPU モードを ## ショートカットキー -「設定」の「キー割り当て」で変更することができます。 +「設定」の「キー割り当て」でショートカットキーを表示・変更することができます。 (Mac 版をご利用の場合は Ctrl を Command に、Alt を Option に読み替えてください。) - 上下キー @@ -251,6 +252,8 @@ GPU をお持ちの方は、音声の生成がずっと速い GPU モードを - 全体のイントネーションをリセット - `R` - 選択中のイントネーションをリセット +- `Ctrl` + 数字 + - 数字番目のキャラクターを選択 ## ツールバーのカスタマイズ @@ -329,14 +332,22 @@ VOICEVOX では、歌声合成機能がプロトタイプ版として提供さ ### ピッチ編集 -「設定」→「オプション」→「実験的機能」から「ソング:ピッチ編集機能」をONにすることで、歌の音程を細かく制御することができます。 +ツールバーからピッチ編集モードに切り替えることで、歌の音程を細かく制御することができます。 + +### インポート + +様々な形式のプロジェクトファイルをインポートすることができます。 + +### マルチトラック + +「設定」→「オプション」→「実験的機能」から「ソング:マルチトラック機能」をONにすることで、複数のトラックを編集・再生できるようになります。 ### ソング機能のよくある質問 Q. 赤くなって声が再生されない A. なにかしらのエラー状態を示しています。現在のバージョンでは、1つのノート(音符)につき日本語1文字分のみ入力できます。またノートが重なっていてもエラーとなります。 -Q. 思った高さの音が出ない +Q. 思った高さの音が出ない A. 音域がずれている可能性があります。「音域調整」で調整してみてください。 ## オプション diff --git a/public/qAndA.md b/public/qAndA.md index 7c06b88788..db19968dd6 100644 --- a/public/qAndA.md +++ b/public/qAndA.md @@ -10,7 +10,7 @@ Windows/Mac/Linux 搭載の PC に対応しています。 ※Windows:Windows 10・Windows 11 ※Mac:macOS 12(Monterey)以降 -※Linux:Ubuntu 20.04 +※Linux:Ubuntu 20.04・Ubuntu 22.04 #### GPU 版 diff --git a/public/updateInfos.json b/public/updateInfos.json index 06207a30c6..49770a50d8 100644 --- a/public/updateInfos.json +++ b/public/updateInfos.json @@ -1,9 +1,46 @@ [ { - "version": "0.19.2", + "version": "0.20.0", "descriptions": [ - "キャラクター「後鬼」のスタイルを追加" + "起動を高速化", + "Apple Silicon macOSに対応", + "ヘルプダイアログのデザインを刷新", + "トーク:デフォルトプリセットが再登録できないバグの修正", + "トーク:全選択ショートカットキーを追加", + "トーク:キャラクター選択ショートカットキーを追加", + "ソング:歌声に呼吸音が被る問題を修正", + "ソング:様々な形式のプロジェクトファイルのインポートに対応", + "ソング:実験的機能としてマルチトラック機能を追加", + "ソング:ピッチ編集機能を通常機能に", + "開発環境の向上", + "バグ修正" ], + "contributors": [ + "cm-ayf", + "Hiroshiba", + "honey32", + "jdkfx", + "madosuki", + "nix6839", + "nmori", + "RikitoNoto", + "romot-co", + "sabonerune", + "Segu-g", + "sevenc-nanashi", + "ShimagayaSatoka", + "sigprogramming", + "takusea", + "tarepan", + "tsym77yoshi", + "weweweok", + "White-Green", + "X-20A" + ] + }, + { + "version": "0.19.2", + "descriptions": ["キャラクター「後鬼」のスタイルを追加"], "contributors": [] }, { diff --git a/src/backend/browser/fileImpl.ts b/src/backend/browser/fileImpl.ts index d60f85f30f..0dab45ac29 100644 --- a/src/backend/browser/fileImpl.ts +++ b/src/backend/browser/fileImpl.ts @@ -4,6 +4,7 @@ import { openDB } from "./browserConfig"; import { SandboxKey } from "@/type/preload"; import { failure, success } from "@/type/result"; import { createLogger } from "@/domain/frontend/log"; +import { uuid4 } from "@/helpers/random"; const log = createLogger("fileImpl"); @@ -200,7 +201,7 @@ export const showOpenFilePickerImpl = async (options: { }); const paths = []; for (const handle of handles) { - const fakePath = `-${handle.name}`; + const fakePath = `-${handle.name}`; fileHandleMap.set(fakePath, handle); paths.push(fakePath); } diff --git a/src/backend/common/ConfigManager.ts b/src/backend/common/ConfigManager.ts index 0c8c4f569e..adb750d54b 100644 --- a/src/backend/common/ConfigManager.ts +++ b/src/backend/common/ConfigManager.ts @@ -144,13 +144,12 @@ const migrations: [string, (store: Record) => unknown][] = [ (config) => { // ピッチ表示機能の設定をピッチ編集機能に引き継ぐ const experimentalSetting = - config.experimentalSetting as ExperimentalSettingType & { - showPitchInSongEditor?: boolean; // FIXME: TypeScript 5.4.5ならこの型の結合は不要 - }; + config.experimentalSetting as ExperimentalSettingType; if ( "showPitchInSongEditor" in experimentalSetting && typeof experimentalSetting.showPitchInSongEditor === "boolean" ) { + // @ts-expect-error 削除されたパラメータ。 experimentalSetting.enablePitchEditInSongEditor = experimentalSetting.showPitchInSongEditor; delete experimentalSetting.showPitchInSongEditor; @@ -222,6 +221,16 @@ const migrations: [string, (store: Record) => unknown][] = [ presets.keys = newPresetKeys; })(); + // ピッチ編集機能を実験的機能から通常機能に + const experimentalSetting = + config.experimentalSetting as ExperimentalSettingType; + if ( + "enablePitchEditInSongEditor" in experimentalSetting && + typeof experimentalSetting.enablePitchEditInSongEditor === "boolean" + ) { + delete experimentalSetting.enablePitchEditInSongEditor; + } + return config; }, ], diff --git a/src/components/Base/BaseButton.vue b/src/components/Base/BaseButton.vue new file mode 100644 index 0000000000..fd7a09e501 --- /dev/null +++ b/src/components/Base/BaseButton.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/src/components/Base/BaseDocumentView.vue b/src/components/Base/BaseDocumentView.vue new file mode 100644 index 0000000000..94845a8e94 --- /dev/null +++ b/src/components/Base/BaseDocumentView.vue @@ -0,0 +1,168 @@ + + + diff --git a/src/components/Base/BaseListItem.vue b/src/components/Base/BaseListItem.vue new file mode 100644 index 0000000000..24b235c83b --- /dev/null +++ b/src/components/Base/BaseListItem.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/src/components/Base/BaseRowCard.vue b/src/components/Base/BaseRowCard.vue new file mode 100644 index 0000000000..4d4b5774c0 --- /dev/null +++ b/src/components/Base/BaseRowCard.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/components/Base/BaseScrollArea.vue b/src/components/Base/BaseScrollArea.vue new file mode 100644 index 0000000000..13745fb07d --- /dev/null +++ b/src/components/Base/BaseScrollArea.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/components/Dialog/HelpDialog/ContactInfo.vue b/src/components/Dialog/HelpDialog/ContactInfo.vue deleted file mode 100644 index 7503291342..0000000000 --- a/src/components/Dialog/HelpDialog/ContactInfo.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/HelpDialog.vue b/src/components/Dialog/HelpDialog/HelpDialog.vue index 8cdfd166ae..3898ab7460 100644 --- a/src/components/Dialog/HelpDialog/HelpDialog.vue +++ b/src/components/Dialog/HelpDialog/HelpDialog.vue @@ -7,79 +7,70 @@ class="help-dialog transparent-backdrop" > - -
- - @@ -87,14 +78,12 @@ diff --git a/src/components/Dialog/HelpDialog/HelpLibraryPolicySection.vue b/src/components/Dialog/HelpDialog/HelpLibraryPolicySection.vue new file mode 100644 index 0000000000..4007eaa0dc --- /dev/null +++ b/src/components/Dialog/HelpDialog/HelpLibraryPolicySection.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/Dialog/HelpDialog/HelpMarkdownViewSection.vue b/src/components/Dialog/HelpDialog/HelpMarkdownViewSection.vue new file mode 100644 index 0000000000..44648f80c6 --- /dev/null +++ b/src/components/Dialog/HelpDialog/HelpMarkdownViewSection.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/components/Dialog/HelpDialog/HelpOssLicenseSection.vue b/src/components/Dialog/HelpDialog/HelpOssLicenseSection.vue new file mode 100644 index 0000000000..cd575c5980 --- /dev/null +++ b/src/components/Dialog/HelpDialog/HelpOssLicenseSection.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/Dialog/HelpDialog/HelpPolicy.vue b/src/components/Dialog/HelpDialog/HelpPolicy.vue deleted file mode 100644 index 2f4832cc32..0000000000 --- a/src/components/Dialog/HelpDialog/HelpPolicy.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/HelpUpdateInfoSection.vue b/src/components/Dialog/HelpDialog/HelpUpdateInfoSection.vue new file mode 100644 index 0000000000..d7639fac87 --- /dev/null +++ b/src/components/Dialog/HelpDialog/HelpUpdateInfoSection.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/components/Dialog/HelpDialog/HowToUse.vue b/src/components/Dialog/HelpDialog/HowToUse.vue deleted file mode 100644 index 2eb0fd2259..0000000000 --- a/src/components/Dialog/HelpDialog/HowToUse.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/LibraryPolicy.vue b/src/components/Dialog/HelpDialog/LibraryPolicy.vue deleted file mode 100644 index 2d56b1ee1c..0000000000 --- a/src/components/Dialog/HelpDialog/LibraryPolicy.vue +++ /dev/null @@ -1,139 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/OssCommunityInfo.vue b/src/components/Dialog/HelpDialog/OssCommunityInfo.vue deleted file mode 100644 index ff31d53391..0000000000 --- a/src/components/Dialog/HelpDialog/OssCommunityInfo.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/OssLicense.vue b/src/components/Dialog/HelpDialog/OssLicense.vue deleted file mode 100644 index b723902798..0000000000 --- a/src/components/Dialog/HelpDialog/OssLicense.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/QAndA.vue b/src/components/Dialog/HelpDialog/QAndA.vue deleted file mode 100644 index 4a304b0bfa..0000000000 --- a/src/components/Dialog/HelpDialog/QAndA.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/src/components/Dialog/HelpDialog/UpdateInfo.vue b/src/components/Dialog/HelpDialog/UpdateInfo.vue deleted file mode 100644 index ee19cf6f2d..0000000000 --- a/src/components/Dialog/HelpDialog/UpdateInfo.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/src/components/Dialog/ImportSongProjectDialog.vue b/src/components/Dialog/ImportSongProjectDialog.vue index 70cb072a58..eff9c177a6 100644 --- a/src/components/Dialog/ImportSongProjectDialog.vue +++ b/src/components/Dialog/ImportSongProjectDialog.vue @@ -1,23 +1,13 @@ @@ -84,12 +110,17 @@ import { createLogger } from "@/domain/frontend/log"; import { ExhaustiveError } from "@/type/utility"; import { IsEqual } from "@/type/utility"; import { LatestProjectType } from "@/domain/project/schema"; +import { DEFAULT_TRACK_NAME } from "@/sing/domain"; const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent(); const store = useStore(); const log = createLogger("ImportExternalProjectDialog"); +const multiTrackEnabled = computed( + () => store.state.experimentalSetting.enableMultiTrack, +); + // 受け入れる拡張子 const acceptExtensions = computed( () => supportedExtensions.map((ext) => `.${ext}`).join(",") + ",.vvproj", @@ -180,15 +211,19 @@ const project = ref(null); // トラック function getProjectTracks(project: Project) { - function _track(name: string | undefined, noteLength: number) { + function toTrack(name: string | undefined, noteLength: number) { return { name, noteLength, disable: noteLength === 0 }; } return project.type === "utaformatix" ? project.project.tracks.map((track) => - _track(track.name, track.notes.length), + toTrack(track.name, track.notes.length), ) - : project.project.song.tracks.map((track) => - _track(undefined, track.notes.length), + : project.project.song.trackOrder.map((trackId) => + toTrack( + // zodが何故かundefinedを入れてくるので、null-safe operatorを使う + project.project.song.tracks[trackId]?.name, + project.project.song.tracks[trackId]?.notes.length ?? 0, + ), ); } @@ -198,24 +233,39 @@ const trackOptions = computed(() => { return []; } // トラックリストを生成 - // "トラックNo: トラック名 / ノート数" の形式で表示 const tracks = getProjectTracks(project.value); return tracks.map((track, index) => ({ - label: `${index + 1}: ${track?.name || "(トラック名なし)"} / ノート数:${ - track.noteLength - }`, + name: track?.name, + noteLength: track.noteLength, value: index, disable: track.disable, })); }); // 選択中のトラック -const selectedTrack = ref(null); +const selectedTrackIndexes = ref(null); +const selectedTrackIndex = computed({ + get: () => { + if (selectedTrackIndexes.value == null) { + return null; + } + if (selectedTrackIndexes.value.length === 0) { + return null; + } + return selectedTrackIndexes.value[0]; + }, + set: (index: number | null) => { + if (index == null) { + throw new Error("assert: index != null"); + } + selectedTrackIndexes.value = [index]; + }, +}); // データ初期化 const initializeValues = () => { projectFile.value = null; project.value = null; - selectedTrack.value = null; + selectedTrackIndexes.value = null; }; // ファイル変更時 @@ -233,7 +283,7 @@ const handleFileChange = async (event: Event) => { // 既存のデータおよび選択中のトラックをクリア project.value = null; - selectedTrack.value = null; + selectedTrackIndexes.value = null; error.value = null; const file = input.files[0]; @@ -256,12 +306,14 @@ const handleFileChange = async (event: Event) => { }), }; } - selectedTrack.value = getProjectTracks(project.value).findIndex( + const firstSelectableTrack = getProjectTracks(project.value).findIndex( (track) => !track.disable, ); - if (selectedTrack.value === -1) { - selectedTrack.value = 0; + if (firstSelectableTrack === -1) { + error.value = "emptyProject"; + return; } + selectedTrackIndexes.value = [firstSelectableTrack]; } catch (e) { log.error(e); error.value = "unknown"; @@ -278,19 +330,19 @@ const handleFileChange = async (event: Event) => { // トラックインポート実行時 const handleImportTrack = () => { // ファイルまたは選択中のトラックが未設定の場合はエラー - if (project.value == null || selectedTrack.value == null) { + if (project.value == null || selectedTrackIndexes.value == null) { throw new Error("project or selected track is not set"); } // トラックをインポート if (project.value.type === "vvproj") { - store.dispatch("IMPORT_VOICEVOX_PROJECT", { + store.dispatch("COMMAND_IMPORT_VOICEVOX_PROJECT", { project: project.value.project, - trackIndex: selectedTrack.value, + trackIndexes: selectedTrackIndexes.value, }); } else { - store.dispatch("IMPORT_UTAFORMATIX_PROJECT", { + store.dispatch("COMMAND_IMPORT_UTAFORMATIX_PROJECT", { project: project.value.project, - trackIndex: selectedTrack.value, + trackIndexes: selectedTrackIndexes.value, }); } onDialogOK(); @@ -301,3 +353,15 @@ const handleCancel = () => { onDialogCancel(); }; + + diff --git a/src/components/Dialog/SettingDialog/SettingDialog.vue b/src/components/Dialog/SettingDialog/SettingDialog.vue index 75574e3a90..e8e001acef 100644 --- a/src/components/Dialog/SettingDialog/SettingDialog.vue +++ b/src/components/Dialog/SettingDialog/SettingDialog.vue @@ -455,6 +455,22 @@ > + + +
+ + + +
+
@@ -503,16 +519,16 @@ " /> + title="ソング:マルチトラック機能" + description="ONの場合、1つのプロジェクト内に複数のトラックを作成できるようにします。" + :modelValue="experimentalSetting.enableMultiTrack" + :disable="!canToggleMultiTrack" + @update:modelValue="setMultiTrack($event)" + > + + 現在のプロジェクトに複数のトラックが存在するため、無効化できません。 + + @@ -537,6 +553,7 @@ import { computed, ref, watchEffect } from "vue"; import FileNamePatternDialog from "./FileNamePatternDialog.vue"; import ToggleCell from "./ToggleCell.vue"; import ButtonToggleCell from "./ButtonToggleCell.vue"; +import BaseCell from "./BaseCell.vue"; import { useStore } from "@/store"; import { isProduction, @@ -607,6 +624,30 @@ const isDefaultConfirmedTips = computed(() => { return Object.values(confirmedTips).every((v) => !v); }); +// ソング:元に戻すトラック操作 +type UndoableTrackOperation = + keyof RootMiscSettingType["undoableTrackOperations"]; +const undoableTrackOperationsLabels = [ + { value: "soloAndMute", label: "ミュート・ソロ" }, + { value: "panAndGain", label: "パン・音量" }, +]; +const undoableTrackOperations = computed({ + get: () => + Object.keys(store.state.undoableTrackOperations).filter( + (key) => + store.state.undoableTrackOperations[key as UndoableTrackOperation], + ) as UndoableTrackOperation[], + set: (undoableTrackOperations: UndoableTrackOperation[]) => { + store.dispatch("SET_ROOT_MISC_SETTING", { + key: "undoableTrackOperations", + value: { + soloAndMute: undoableTrackOperations.includes("soloAndMute"), + panAndGain: undoableTrackOperations.includes("panAndGain"), + }, + }); + }, +}); + // 外観 const currentThemeNameComputed = computed({ get: () => store.state.themeSetting.currentTheme, @@ -860,6 +901,23 @@ const selectedEngineId = computed({ const renderEngineNameLabel = (engineId: EngineId) => { return engineInfos.value[engineId].name; }; + +// トラックが複数あるときはマルチトラック機能を無効化できないようにする +const canToggleMultiTrack = computed(() => { + if (!experimentalSetting.value.enableMultiTrack) { + return true; + } + + return store.state.tracks.size <= 1; +}); + +const setMultiTrack = (enableMultiTrack: boolean) => { + changeExperimentalSetting("enableMultiTrack", enableMultiTrack); + // 無効化するときはUndo/Redoをクリアする + if (!enableMultiTrack) { + store.dispatch("CLEAR_UNDO_HISTORY"); + } +}; diff --git a/src/components/Sing/CharacterMenuButton/MenuButton.vue b/src/components/Sing/CharacterMenuButton/MenuButton.vue index 8bc146b6f4..e2a08fa357 100644 --- a/src/components/Sing/CharacterMenuButton/MenuButton.vue +++ b/src/components/Sing/CharacterMenuButton/MenuButton.vue @@ -1,211 +1,30 @@ - - - diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 5a8a3c6514..700c8354b3 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -40,16 +40,25 @@ transform: `translateX(${guideLineX}px)`, }" >
+ + + + store.getters.SELECTED_TRACK_ID); + // TPQN、テンポ、ノーツ const tpqn = computed(() => state.tpqn); const tempos = computed(() => state.tempos); -const notes = computed(() => store.getters.SELECTED_TRACK.notes); -const selectedNoteIds = computed(() => new Set(state.selectedNoteIds)); +const notesInSelectedTrack = computed(() => store.getters.SELECTED_TRACK.notes); +const notesInOtherTracks = computed(() => + [...store.state.tracks.entries()].flatMap(([trackId, track]) => + trackId === selectedTrackId.value ? [] : track.notes, + ), +); +const overlappingNoteIdsInSelectedTrack = computed(() => + store.getters.OVERLAPPING_NOTE_IDS(selectedTrackId.value), +); +const selectedNotes = computed(() => + store.getters.SELECTED_TRACK.notes.filter((note) => + selectedNoteIds.value.has(note.id), + ), +); +const selectedNoteIds = computed( + () => new Set(store.getters.SELECTED_NOTE_IDS), +); const isNoteSelected = computed(() => { return selectedNoteIds.value.size > 0; }); -const selectedNotes = computed(() => { - return notes.value.filter((value) => selectedNoteIds.value.has(value.id)); -}); -const notesIncludingPreviewNotes = computed(() => { +const notesInSelectedTrackWithPreview = computed(() => { if (nowPreviewing.value) { const previewNoteIds = new Set(previewNotes.value.map((value) => value.id)); return previewNotes.value - .concat(notes.value.filter((value) => !previewNoteIds.has(value.id))) - .sort((a, b) => { + .concat( + notesInSelectedTrack.value.filter( + (note) => !previewNoteIds.has(note.id), + ), + ) + .toSorted((a, b) => { const aIsSelectedOrPreview = selectedNoteIds.value.has(a.id) || previewNoteIds.has(a.id); const bIsSelectedOrPreview = @@ -241,7 +283,7 @@ const notesIncludingPreviewNotes = computed(() => { } }); } else { - return [...notes.value].sort((a, b) => { + return notesInSelectedTrack.value.toSorted((a, b) => { const aIsSelected = selectedNoteIds.value.has(a.id); const bIsSelected = selectedNoteIds.value.has(b.id); if (aIsSelected === bIsSelected) { @@ -301,9 +343,20 @@ const phraseInfos = computed(() => { const endBaseX = tickToBaseX(endTicks, tpqn.value); const startX = startBaseX * zoomX.value; const endX = endBaseX * zoomX.value; - return { key, x: startX, width: endX - startX }; + const trackId = phrase.trackId; + return { key, x: startX, width: endX - startX, trackId }; }); }); +const phraseInfosInSelectedTrack = computed(() => { + return phraseInfos.value.filter( + (info) => info.trackId === selectedTrackId.value, + ); +}); +const phraseInfosInOtherTracks = computed(() => { + return phraseInfos.value.filter( + (info) => info.trackId !== selectedTrackId.value, + ); +}); const ctrlKey = useCommandOrControlKey(); const editTarget = computed(() => state.sequencerEditTarget); @@ -358,7 +411,9 @@ const prevCursorPos = { frame: 0, frequency: 0 }; // 前のカーソル位置 // 歌詞を編集中のノート const editingLyricNote = computed(() => { - return notes.value.find((value) => value.id === state.editingLyricNoteId); + return notesInSelectedTrack.value.find( + (note) => note.id === state.editingLyricNoteId, + ); }); // 入力を補助する線 @@ -720,7 +775,7 @@ const startPreview = (event: MouseEvent, mode: PreviewMode, note?: Note) => { return; } note = { - id: NoteId(crypto.randomUUID()), + id: NoteId(uuid4()), position: guideLineTicks, duration: snapTicks.value, noteNumber: cursorNoteNumber, @@ -733,26 +788,26 @@ const startPreview = (event: MouseEvent, mode: PreviewMode, note?: Note) => { throw new Error("note is undefined."); } if (event.shiftKey) { - let minIndex = notes.value.length - 1; + let minIndex = notesInSelectedTrack.value.length - 1; let maxIndex = 0; - for (let i = 0; i < notes.value.length; i++) { - const noteId = notes.value[i].id; - if (state.selectedNoteIds.has(noteId) || noteId === note.id) { + for (let i = 0; i < notesInSelectedTrack.value.length; i++) { + const noteId = notesInSelectedTrack.value[i].id; + if (selectedNoteIds.value.has(noteId) || noteId === note.id) { minIndex = Math.min(minIndex, i); maxIndex = Math.max(maxIndex, i); } } const noteIdsToSelect: NoteId[] = []; for (let i = minIndex; i <= maxIndex; i++) { - const noteId = notes.value[i].id; - if (!state.selectedNoteIds.has(noteId)) { + const noteId = notesInSelectedTrack.value[i].id; + if (!selectedNoteIds.value.has(noteId)) { noteIdsToSelect.push(noteId); } } store.dispatch("SELECT_NOTES", { noteIds: noteIdsToSelect }); } else if (isOnCommandOrCtrlKeyDown(event)) { store.dispatch("SELECT_NOTES", { noteIds: [note.id] }); - } else if (!state.selectedNoteIds.has(note.id)) { + } else if (!selectedNoteIds.value.has(note.id)) { selectOnlyThis(note); } for (const note of selectedNotes.value) { @@ -812,12 +867,18 @@ const endPreview = () => { if (edited) { if (previewMode === "ADD_NOTE") { - store.dispatch("COMMAND_ADD_NOTES", { notes: previewNotes.value }); + store.dispatch("COMMAND_ADD_NOTES", { + notes: previewNotes.value, + trackId: selectedTrackId.value, + }); store.dispatch("SELECT_NOTES", { noteIds: previewNotes.value.map((value) => value.id), }); } else { - store.dispatch("COMMAND_UPDATE_NOTES", { notes: previewNotes.value }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: previewNotes.value, + trackId: selectedTrackId.value, + }); } if (previewNotes.value.length === 1) { store.dispatch("PLAY_PREVIEW_SOUND", { @@ -844,14 +905,16 @@ const endPreview = () => { data = data.map((value) => Math.exp(value)); store.dispatch("COMMAND_SET_PITCH_EDIT_DATA", { - data, + pitchArray: data, startFrame: previewPitchEdit.value.startFrame, + trackId: selectedTrackId.value, }); } } else if (previewPitchEditType === "erase") { store.dispatch("COMMAND_ERASE_PITCH_EDIT_DATA", { startFrame: previewPitchEdit.value.startFrame, frameLength: previewPitchEdit.value.frameLength, + trackId: selectedTrackId.value, }); } else { throw new ExhaustiveError(previewPitchEditType); @@ -870,7 +933,7 @@ const onNoteBarMouseDown = (event: MouseEvent, note: Note) => { const mouseButton = getButton(event); if (mouseButton === "LEFT_BUTTON") { startPreview(event, "MOVE_NOTE", note); - } else if (!state.selectedNoteIds.has(note.id)) { + } else if (!selectedNoteIds.value.has(note.id)) { selectOnlyThis(note); } }; @@ -892,7 +955,7 @@ const onNoteLeftEdgeMouseDown = (event: MouseEvent, note: Note) => { const mouseButton = getButton(event); if (mouseButton === "LEFT_BUTTON") { startPreview(event, "RESIZE_NOTE_LEFT", note); - } else if (!state.selectedNoteIds.has(note.id)) { + } else if (!selectedNoteIds.value.has(note.id)) { selectOnlyThis(note); } }; @@ -904,7 +967,7 @@ const onNoteRightEdgeMouseDown = (event: MouseEvent, note: Note) => { const mouseButton = getButton(event); if (mouseButton === "LEFT_BUTTON") { startPreview(event, "RESIZE_NOTE_RIGHT", note); - } else if (!state.selectedNoteIds.has(note.id)) { + } else if (!selectedNoteIds.value.has(note.id)) { selectOnlyThis(note); } }; @@ -1002,7 +1065,7 @@ const rectSelect = (additive: boolean) => { ); const noteIdsToSelect: NoteId[] = []; - for (const note of notes.value) { + for (const note of notesInSelectedTrack.value) { if ( note.position + note.duration >= startTicks && note.position <= endTicks && @@ -1036,7 +1099,10 @@ const handleNotesArrowUp = () => { if (editedNotes.some((note) => note.noteNumber > 127)) { return; } - store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: editedNotes, + trackId: selectedTrackId.value, + }); if (editedNotes.length === 1) { store.dispatch("PLAY_PREVIEW_SOUND", { @@ -1055,7 +1121,10 @@ const handleNotesArrowDown = () => { if (editedNotes.some((note) => note.noteNumber < 0)) { return; } - store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: editedNotes, + trackId: selectedTrackId.value, + }); if (editedNotes.length === 1) { store.dispatch("PLAY_PREVIEW_SOUND", { @@ -1075,7 +1144,10 @@ const handleNotesArrowRight = () => { // TODO: 例外処理は`UPDATE_NOTES`内に移す? return; } - store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: editedNotes, + trackId: selectedTrackId.value, + }); }; const handleNotesArrowLeft = () => { @@ -1090,11 +1162,14 @@ const handleNotesArrowLeft = () => { ) { return; } - store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: editedNotes, + trackId: selectedTrackId.value, + }); }; const handleNotesBackspaceOrDelete = () => { - if (state.selectedNoteIds.size === 0) { + if (selectedNoteIds.value.size === 0) { // TODO: 例外処理は`COMMAND_REMOVE_SELECTED_NOTES`内に移す? return; } @@ -1218,6 +1293,13 @@ const playheadPositionChangeListener = (position: number) => { // オートスクロール const sequencerBodyElement = sequencerBody.value; if (!sequencerBodyElement) { + if (import.meta.env.DEV) { + // HMR時にここにたどり着くことがあるので、開発時は警告だけにする + // TODO: HMR時にここにたどり着く原因を調査して修正する + warn("sequencerBodyElement is null."); + return; + } + throw new Error("sequencerBodyElement is null."); } const scrollLeft = sequencerBodyElement.scrollLeft; @@ -1308,7 +1390,7 @@ registerHotkeyWithCleanup({ if (nowPreviewing.value) { return; } - if (state.selectedNoteIds.size === 0) { + if (selectedNoteIds.value.size === 0) { return; } store.dispatch("COPY_NOTES_TO_CLIPBOARD"); @@ -1322,7 +1404,7 @@ registerHotkeyWithCleanup({ if (nowPreviewing.value) { return; } - if (state.selectedNoteIds.size === 0) { + if (selectedNoteIds.value.size === 0) { return; } store.dispatch("COMMAND_CUT_NOTES_TO_CLIPBOARD"); @@ -1347,7 +1429,9 @@ registerHotkeyWithCleanup({ if (nowPreviewing.value) { return; } - store.dispatch("SELECT_ALL_NOTES"); + store.dispatch("SELECT_ALL_NOTES_IN_TRACK", { + trackId: selectedTrackId.value, + }); }, }); @@ -1390,7 +1474,9 @@ const contextMenuData = computed(() => { label: "すべて選択", onClick: async () => { contextMenu.value?.hide(); - await store.dispatch("SELECT_ALL_NOTES"); + await store.dispatch("SELECT_ALL_NOTES_IN_TRACK", { + trackId: selectedTrackId.value, + }); }, disableWhenUiLocked: true, }, diff --git a/src/components/Sing/SequencerNote.vue b/src/components/Sing/SequencerNote.vue index 22c12e2c35..df41b34e48 100644 --- a/src/components/Sing/SequencerNote.vue +++ b/src/components/Sing/SequencerNote.vue @@ -90,6 +90,8 @@ const props = defineProps<{ isSelected: boolean; /** このノートがプレビュー中か */ isPreview: boolean; + /** ノートが重なっているか */ + isOverlapping: boolean; previewLyric: string | null; }>(); @@ -127,10 +129,8 @@ const editTargetIsNote = computed(() => { const editTargetIsPitch = computed(() => { return state.sequencerEditTarget === "PITCH"; }); - -// ノートの重なりエラー const hasOverlappingError = computed(() => { - return state.overlappingNoteIds.has(props.note.id); + return props.isOverlapping && !props.isPreview; }); // フレーズ生成エラー @@ -233,20 +233,20 @@ const onLeftEdgeMouseDown = (event: MouseEvent) => { &:not(.below-pitch) { .note-left-edge:hover { // FIXME: hoverだとカーソル位置によって適用されないので、プレビュー中に明示的にクラス指定する - background-color: lab(80, -22.953, 14.365); + background-color: lab(80 -22.953 14.365); } .note-right-edge:hover { // FIXME: hoverだとカーソル位置によって適用されないので、プレビュー中に明示的にクラス指定する - background-color: lab(80, -22.953, 14.365); + background-color: lab(80 -22.953 14.365); } &.selected-or-preview { // 色は仮 .note-bar { - background-color: lab(95, -22.953, 14.365); - border-color: lab(65, -22.953, 14.365); - outline: solid 2px lab(70, -22.953, 14.365); + background-color: lab(95 -22.953 14.365); + border-color: lab(65 -22.953 14.365); + outline: solid 2px lab(70 -22.953 14.365); } } } diff --git a/src/components/Sing/SequencerPhraseIndicator.vue b/src/components/Sing/SequencerPhraseIndicator.vue index a9e40ee822..85049ed190 100644 --- a/src/components/Sing/SequencerPhraseIndicator.vue +++ b/src/components/Sing/SequencerPhraseIndicator.vue @@ -1,5 +1,5 @@ + + diff --git a/src/components/Sing/SideBar/SideBar.vue b/src/components/Sing/SideBar/SideBar.vue new file mode 100644 index 0000000000..05cc9b184a --- /dev/null +++ b/src/components/Sing/SideBar/SideBar.vue @@ -0,0 +1,172 @@ + + + diff --git a/src/components/Sing/SideBar/TrackItem.vue b/src/components/Sing/SideBar/TrackItem.vue new file mode 100644 index 0000000000..b5c300aefa --- /dev/null +++ b/src/components/Sing/SideBar/TrackItem.vue @@ -0,0 +1,399 @@ + + + diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index b2c8999867..40a292b6d4 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -1,6 +1,6 @@ + + diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index ef6edf207a..950e3831bb 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -2,6 +2,14 @@
+
- + store.getters.CAN_UNDO(editor)); const canRedo = computed(() => store.getters.CAN_REDO(editor)); +const multiTrackEnabled = computed( + () => store.state.experimentalSetting.enableMultiTrack, +); + const { registerHotkeyWithCleanup } = useHotkeyManager(); registerHotkeyWithCleanup({ editor, @@ -200,16 +208,19 @@ const redo = () => { store.dispatch("REDO", { editor }); }; -const showEditTargetSwitchButton = computed(() => { - return store.state.experimentalSetting.enablePitchEditInSongEditor; -}); - const editTarget = computed(() => store.state.sequencerEditTarget); const changeEditTarget = (editTarget: SequencerEditTarget) => { store.dispatch("SET_EDIT_TARGET", { editTarget }); }; +const isSidebarOpen = computed(() => store.state.isSongSidebarOpen); +const toggleSidebar = () => { + store.dispatch("SET_SONG_SIDEBAR_OPEN", { + isSongSidebarOpen: !isSidebarOpen.value, + }); +}; + const tempos = computed(() => store.state.tempos); const timeSignatures = computed(() => store.state.timeSignatures); const keyRangeAdjustment = computed( @@ -218,6 +229,7 @@ const keyRangeAdjustment = computed( const volumeRangeAdjustment = computed( () => store.getters.SELECTED_TRACK.volumeRangeAdjustment, ); +const selectedTrackId = computed(() => store.getters.SELECTED_TRACK_ID); const bpmInputBuffer = ref(120); const beatsInputBuffer = ref(4); @@ -326,13 +338,17 @@ const setTimeSignature = () => { const setKeyRangeAdjustment = () => { const keyRangeAdjustment = keyRangeAdjustmentInputBuffer.value; - store.dispatch("COMMAND_SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment }); + store.dispatch("COMMAND_SET_KEY_RANGE_ADJUSTMENT", { + keyRangeAdjustment, + trackId: selectedTrackId.value, + }); }; const setVolumeRangeAdjustment = () => { const volumeRangeAdjustment = volumeRangeAdjustmentInputBuffer.value; store.dispatch("COMMAND_SET_VOLUME_RANGE_ADJUSTMENT", { volumeRangeAdjustment, + trackId: selectedTrackId.value, }); }; diff --git a/src/components/Sing/menuBarData.ts b/src/components/Sing/menuBarData.ts index b13733822e..962afdf9aa 100644 --- a/src/components/Sing/menuBarData.ts +++ b/src/components/Sing/menuBarData.ts @@ -5,7 +5,9 @@ import { MenuItemData } from "@/components/Menu/type"; export const useMenuBarData = () => { const store = useStore(); const uiLocked = computed(() => store.getters.UI_LOCKED); - const isNotesSelected = computed(() => store.state.selectedNoteIds.size > 0); + const isNotesSelected = computed( + () => store.getters.SELECTED_NOTE_IDS.size > 0, + ); const importExternalSongProject = async () => { if (uiLocked.value) return; @@ -76,7 +78,9 @@ export const useMenuBarData = () => { label: "すべて選択", onClick: () => { if (uiLocked.value) return; - store.dispatch("SELECT_ALL_NOTES"); + store.dispatch("SELECT_ALL_NOTES_IN_TRACK", { + trackId: store.getters.SELECTED_TRACK_ID, + }); }, disableWhenUiLocked: true, }, diff --git a/src/composables/useLyricInput.ts b/src/composables/useLyricInput.ts index ffaf85c144..dca9052743 100644 --- a/src/composables/useLyricInput.ts +++ b/src/composables/useLyricInput.ts @@ -12,8 +12,8 @@ export const useLyricInput = () => { const previewLyrics = ref>(new Map()); // 入力中の歌詞を分割してプレビューに反映する。 const splitAndUpdatePreview = (lyric: string, note: Note) => { - // TODO: マルチトラック対応 - const inputNoteIndex = store.state.tracks[0].notes.findIndex( + const selectedTrack = store.getters.SELECTED_TRACK; + const inputNoteIndex = selectedTrack.notes.findIndex( (value) => value.id === note.id, ); if (inputNoteIndex === -1) { @@ -23,7 +23,7 @@ export const useLyricInput = () => { const lyricPerNote = splitLyricsByMoras( lyric, - store.state.tracks[0].notes.length - inputNoteIndex, + selectedTrack.notes.length - inputNoteIndex, ); for (const [index, mora] of lyricPerNote.entries()) { const noteIndex = inputNoteIndex + index; @@ -50,7 +50,10 @@ export const useLyricInput = () => { newNotes.push({ ...note, lyric }); } previewLyrics.value = new Map(); - store.dispatch("COMMAND_UPDATE_NOTES", { notes: newNotes }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: newNotes, + trackId: store.getters.SELECTED_TRACK_ID, + }); }; return { previewLyrics, splitAndUpdatePreview, commitPreviewLyrics }; diff --git a/src/domain/project/index.ts b/src/domain/project/index.ts index 207f647fa7..aee4ed3fab 100644 --- a/src/domain/project/index.ts +++ b/src/domain/project/index.ts @@ -6,12 +6,13 @@ import semver from "semver"; import { LatestProjectType, projectSchema } from "./schema"; import { AccentPhrase } from "@/openapi"; -import { EngineId, StyleId, Voice } from "@/type/preload"; +import { EngineId, StyleId, TrackId, Voice } from "@/type/preload"; import { DEFAULT_BEAT_TYPE, DEFAULT_BEATS, DEFAULT_BPM, DEFAULT_TPQN, + DEFAULT_TRACK_NAME, } from "@/sing/domain"; const DEFAULT_SAMPLING_RATE = 24000; @@ -283,6 +284,21 @@ export const migrateProjectFileObject = async ( } } + if (semver.satisfies(projectAppVersion, "<0.20.0", semverSatisfiesOptions)) { + // tracks: Track[] -> tracks: Record + trackOrder: TrackId[] + const newTracks: Record = {}; + for (const track of projectData.song.tracks) { + track.name = DEFAULT_TRACK_NAME; + track.solo = false; + track.mute = false; + track.gain = 1; + track.pan = 0; + newTracks[TrackId(crypto.randomUUID())] = track; + } + projectData.song.tracks = newTracks; + projectData.song.trackOrder = Object.keys(newTracks); + } + // Validation check // トークはvalidateTalkProjectで検証する // ソングはSET_SCOREの中の`isValidScore`関数で検証される diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts index ca74e7987f..c23a6812f1 100644 --- a/src/domain/project/schema.ts +++ b/src/domain/project/schema.ts @@ -7,6 +7,7 @@ import { presetKeySchema, speakerIdSchema, styleIdSchema, + trackIdSchema, } from "@/type/preload"; // トーク系のスキーマ @@ -84,11 +85,17 @@ export const singerSchema = z.object({ }); export const trackSchema = z.object({ + name: z.string(), singer: singerSchema.optional(), keyRangeAdjustment: z.number(), // 音域調整量 volumeRangeAdjustment: z.number(), // 声量調整量 notes: z.array(noteSchema), pitchEditData: z.array(z.number()), // 値の単位はHzで、データが無いところはVALUE_INDICATING_NO_DATAの値 + + solo: z.boolean(), + mute: z.boolean(), + gain: z.number(), + pan: z.number(), }); // プロジェクトファイルのスキーマ @@ -104,7 +111,8 @@ export const projectSchema = z.object({ tpqn: z.number(), tempos: z.array(tempoSchema), timeSignatures: z.array(timeSignatureSchema), - tracks: z.array(trackSchema), + tracks: z.record(trackIdSchema, trackSchema), + trackOrder: z.array(trackIdSchema), }), }); diff --git a/src/helpers/cloneWithUnwrapProxy.ts b/src/helpers/cloneWithUnwrapProxy.ts new file mode 100644 index 0000000000..b27f5cf237 --- /dev/null +++ b/src/helpers/cloneWithUnwrapProxy.ts @@ -0,0 +1,6 @@ +import createRfdc from "rfdc"; + +const rfdc = createRfdc(); + +/** Proxyを展開してクローンする。*/ +export const cloneWithUnwrapProxy = (obj: T): T => rfdc(obj); diff --git a/src/helpers/random.ts b/src/helpers/random.ts new file mode 100644 index 0000000000..62e41e1ca1 --- /dev/null +++ b/src/helpers/random.ts @@ -0,0 +1,27 @@ +/** + * 乱数値を生成する。モックに対応している。 + * モックモードでは呼ばれた回数に応じて固定の値を返す。 + */ + +let mockMode = false; +let mockCount = 0; + +/** + * モックモードにし、呼ばれた回数をリセットする。 + */ +export function resetMockMode(): void { + mockMode = true; + mockCount = 0; +} + +/** + * v4 UUID を生成する。 + */ +export function uuid4(): string { + if (!mockMode) { + return crypto.randomUUID(); + } else { + mockCount++; + return `00000000-0000-4000-0000-${mockCount.toString().padStart(12, "0")}`; + } +} diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index 955077395e..c96ef4d531 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -871,6 +871,8 @@ export class PolySynth implements Instrument { export type ChannelStripOptions = { readonly volume?: number; + readonly pan?: number; + readonly mute?: boolean; }; /** @@ -878,13 +880,15 @@ export type ChannelStripOptions = { */ export class ChannelStrip { private readonly gainNode: GainNode; + private readonly muteGainNode: GainNode; + private readonly panNode: StereoPannerNode; get input(): AudioNode { - return this.gainNode; + return this.muteGainNode; } get output(): AudioNode { - return this.gainNode; + return this.panNode; } get volume() { @@ -894,9 +898,31 @@ export class ChannelStrip { this.gainNode.gain.value = value; } + get mute() { + return this.muteGainNode.gain.value === 0; + } + set mute(value: boolean) { + this.muteGainNode.gain.value = value ? 0 : 1; + } + + get pan() { + return this.panNode.pan.value; + } + set pan(value: number) { + this.panNode.pan.value = value; + } + constructor(audioContext: BaseAudioContext, options?: ChannelStripOptions) { this.gainNode = new GainNode(audioContext); + this.muteGainNode = new GainNode(audioContext); + this.panNode = new StereoPannerNode(audioContext); + + this.muteGainNode.connect(this.gainNode); + this.gainNode.connect(this.panNode); + this.gainNode.gain.value = options?.volume ?? 0.1; + this.muteGainNode.gain.value = options?.mute ? 0 : 1; + this.panNode.pan.value = options?.pan ?? 0; } } diff --git a/src/sing/convertToWavFileData.ts b/src/sing/convertToWavFileData.ts new file mode 100644 index 0000000000..97fc4fa4c9 --- /dev/null +++ b/src/sing/convertToWavFileData.ts @@ -0,0 +1,56 @@ +export const convertToWavFileData = (audioBuffer: AudioBuffer) => { + const bytesPerSample = 4; // Float32 + const formatCode = 3; // WAVE_FORMAT_IEEE_FLOAT + + const numberOfChannels = audioBuffer.numberOfChannels; + const numberOfSamples = audioBuffer.length; + const sampleRate = audioBuffer.sampleRate; + const byteRate = sampleRate * numberOfChannels * bytesPerSample; + const blockSize = numberOfChannels * bytesPerSample; + const dataSize = numberOfSamples * numberOfChannels * bytesPerSample; + + const buffer = new ArrayBuffer(44 + dataSize); + const dataView = new DataView(buffer); + + let pos = 0; + const writeString = (value: string) => { + for (let i = 0; i < value.length; i++) { + dataView.setUint8(pos, value.charCodeAt(i)); + pos += 1; + } + }; + const writeUint32 = (value: number) => { + dataView.setUint32(pos, value, true); + pos += 4; + }; + const writeUint16 = (value: number) => { + dataView.setUint16(pos, value, true); + pos += 2; + }; + const writeSample = (offset: number, value: number) => { + dataView.setFloat32(pos + offset * 4, value, true); + }; + + writeString("RIFF"); + writeUint32(36 + dataSize); // RIFFチャンクサイズ + writeString("WAVE"); + writeString("fmt "); + writeUint32(16); // fmtチャンクサイズ + writeUint16(formatCode); + writeUint16(numberOfChannels); + writeUint32(sampleRate); + writeUint32(byteRate); + writeUint16(blockSize); + writeUint16(bytesPerSample * 8); // 1サンプルあたりのビット数 + writeString("data"); + writeUint32(dataSize); + + for (let i = 0; i < numberOfChannels; i++) { + const channelData = audioBuffer.getChannelData(i); + for (let j = 0; j < numberOfSamples; j++) { + writeSample(j * numberOfChannels + i, channelData[j]); + } + } + + return buffer; +}; diff --git a/src/sing/domain.ts b/src/sing/domain.ts index a7ad7591c9..6d84530b73 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -4,7 +4,6 @@ import { Note, Phrase, PhraseSource, - PhraseSourceHash, SingingGuide, SingingGuideSource, SingingVoiceSource, @@ -16,11 +15,15 @@ import { singingVoiceSourceHashSchema, } from "@/store/type"; import { FramePhoneme } from "@/openapi"; +import { TrackId } from "@/type/preload"; const BEAT_TYPES = [2, 4, 8, 16]; const MIN_BPM = 40; const MAX_SNAP_TYPE = 32; +export const isTracksEmpty = (tracks: Track[]) => + tracks.length === 0 || (tracks.length === 1 && tracks[0].notes.length === 0); + export const isValidTpqn = (tpqn: number) => { return ( Number.isInteger(tpqn) && @@ -90,6 +93,14 @@ export const isValidNotes = (notes: Note[]) => { return notes.every((value) => isValidNote(value)); }; +export const isValidTrack = (track: Track) => { + return ( + isValidKeyRangeAdjustment(track.keyRangeAdjustment) && + isValidVolumeRangeAdjustment(track.volumeRangeAdjustment) && + isValidNotes(track.notes) + ); +}; + const tickToSecondForConstantBpm = ( ticks: number, bpm: number, @@ -220,8 +231,8 @@ export function getNumMeasures( maxTicks = Math.max(maxTicks, lastTsPosition); maxTicks = Math.max(maxTicks, lastTempoPosition); if (notes.length > 0) { - const lastNote = notes[notes.length - 1]; - const lastNoteEndPosition = lastNote.position + lastNote.duration; + const noteEndPositions = notes.map((note) => note.position + note.duration); + const lastNoteEndPosition = Math.max(...noteEndPositions); maxTicks = Math.max(maxTicks, lastNoteEndPosition); } return tickToMeasureNumber(maxTicks, timeSignatures, tpqn); @@ -277,6 +288,8 @@ export function decibelToLinear(decibelValue: number) { return Math.pow(10, decibelValue / 20); } +export const DEFAULT_TRACK_NAME = "無名トラック"; + export const DEFAULT_TPQN = 480; export const DEFAULT_BPM = 120; export const DEFAULT_BEATS = 4; @@ -318,11 +331,17 @@ export function createDefaultTimeSignature( export function createDefaultTrack(): Track { return { + name: DEFAULT_TRACK_NAME, singer: undefined, keyRangeAdjustment: 0, volumeRangeAdjustment: 0, notes: [], pitchEditData: [], + + solo: false, + mute: false, + gain: 1, + pan: 0, }; } @@ -394,7 +413,7 @@ export function getEndTicksOfPhrase(phrase: Phrase) { return lastNote.position + lastNote.duration; } -export function toSortedPhrases(phrases: Map) { +export function toSortedPhrases(phrases: Map) { return [...phrases.entries()].sort((a, b) => { const startTicksOfPhraseA = getStartTicksOfPhrase(a[1]); const startTicksOfPhraseB = getStartTicksOfPhrase(b[1]); @@ -410,10 +429,10 @@ export function toSortedPhrases(phrases: Map) { * - 再生位置より後のPhrase * - 再生位置より前のPhrase */ -export function selectPriorPhrase( - phrases: Map, +export function selectPriorPhrase( + phrases: Map, position: number, -): [PhraseSourceHash, Phrase] { +): [K, Phrase] { if (phrases.size === 0) { throw new Error("Received empty phrases"); } @@ -547,3 +566,18 @@ export const splitLyricsByMoras = ( } return moraAndNonMoras; }; + +/** + * トラックのミュート・ソロ状態から再生すべきトラックを判定する。 + * + * ソロのトラックが存在する場合は、ソロのトラックのみ再生する。(ミュートは無視される) + * ソロのトラックが存在しない場合は、ミュートされていないトラックを再生する。 + */ +export const shouldPlayTracks = (tracks: Map): Set => { + const soloTrackExists = [...tracks.values()].some((track) => track.solo); + return new Set( + [...tracks.entries()] + .filter(([, track]) => (soloTrackExists ? track.solo : !track.mute)) + .map(([trackId]) => trackId), + ); +}; diff --git a/src/sing/utaformatixProject/fromVoicevox.ts b/src/sing/utaformatixProject/fromVoicevox.ts index fba4a3ee9b..f369ccb45c 100644 --- a/src/sing/utaformatixProject/fromVoicevox.ts +++ b/src/sing/utaformatixProject/fromVoicevox.ts @@ -24,7 +24,7 @@ export const ufProjectFromVoicevox = ( denominator: timeSignature.beatType, })), tracks: tracks.map((track) => ({ - name: `無名トラック`, + name: track.name, notes: track.notes.map((note) => ({ key: note.noteNumber, tickOn: convertTicks(note.position), diff --git a/src/sing/utaformatixProject/toVoicevox.ts b/src/sing/utaformatixProject/toVoicevox.ts index f3e2448d67..a1f6e2f226 100644 --- a/src/sing/utaformatixProject/toVoicevox.ts +++ b/src/sing/utaformatixProject/toVoicevox.ts @@ -4,6 +4,7 @@ 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"; +import { uuid4 } from "@/helpers/random"; /** UtaformatixのプロジェクトをVoicevoxの楽譜データに変換する */ export const ufProjectToVoicevox = (project: UfProject): VoicevoxScore => { @@ -72,7 +73,7 @@ export const ufProjectToVoicevox = (project: UfProject): VoicevoxScore => { const notes = trackNotes.map((value): Note => { return { - id: NoteId(crypto.randomUUID()), + id: NoteId(uuid4()), position: convertPosition(value.tickOn, projectTpqn, tpqn), duration: convertDuration( value.tickOn, @@ -87,6 +88,7 @@ export const ufProjectToVoicevox = (project: UfProject): VoicevoxScore => { return { ...createDefaultTrack(), + name: projectTrack.name, notes, }; }); diff --git a/src/store/audio.ts b/src/store/audio.ts index 343ae6e8f8..47e0ac3a22 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -1,6 +1,9 @@ import path from "path"; import Encoding from "encoding-japanese"; -import { createUILockAction, withProgress } from "./ui"; +import { + createDotNotationUILockAction as createUILockAction, + withProgressDotNotation as withProgress, +} from "./ui"; import { AudioItem, SaveResultObject, @@ -25,7 +28,7 @@ import { filterCharacterInfosByStyleType, DEFAULT_PROJECT_NAME, } from "./utility"; -import { createPartialStore } from "./vuex"; +import { createDotNotationPartialStore as createPartialStore } from "./vuex"; import { determineNextPresetKey } from "./preset"; import { fetchAudioFromAudioItem, @@ -58,9 +61,10 @@ import { AudioQuery, AccentPhrase, Speaker, SpeakerInfo } from "@/openapi"; import { base64ImageToUri, base64ToUri } from "@/helpers/base64Helper"; import { getValueOrThrow, ResultError } from "@/type/result"; import { generateWriteErrorMessage } from "@/helpers/fileHelper"; +import { uuid4 } from "@/helpers/random"; function generateAudioKey() { - return AudioKey(crypto.randomUUID()); + return AudioKey(uuid4()); } function parseTextFile( @@ -268,8 +272,8 @@ export const audioStore = createPartialStore({ */ LOAD_CHARACTER: { action: createUILockAction( - async ({ commit, dispatch, state }, { engineId }) => { - const instance = await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { + async ({ mutations, actions, state }, { engineId }) => { + const instance = await actions.INSTANTIATE_ENGINE_CONNECTOR({ engineId, }); @@ -419,7 +423,7 @@ export const audioStore = createPartialStore({ const characterInfos = await Promise.all(characterInfoPromises); - commit("SET_CHARACTER_INFOS", { engineId, characterInfos }); + mutations.SET_CHARACTER_INFOS({ engineId, characterInfos }); }, ), }, @@ -437,7 +441,7 @@ export const audioStore = createPartialStore({ }, LOAD_MORPHABLE_TARGETS: { - async action({ state, dispatch, commit }, { engineId, baseStyleId }) { + async action({ state, actions, mutations }, { engineId, baseStyleId }) { if (!state.engineManifests[engineId].supportedFeatures?.synthesisMorphing) return; @@ -445,7 +449,7 @@ export const audioStore = createPartialStore({ const rawMorphableTargets = ( await ( - await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { engineId }) + await actions.INSTANTIATE_ENGINE_CONNECTOR({ engineId }) ).invoke("morphableTargetsMorphableTargetsPost")({ requestBody: [baseStyleId], }) @@ -471,7 +475,7 @@ export const audioStore = createPartialStore({ }), ); - commit("SET_MORPHABLE_TARGETS", { + mutations.SET_MORPHABLE_TARGETS({ engineId, baseStyleId, morphableTargets, @@ -547,24 +551,26 @@ export const audioStore = createPartialStore({ /** * AudioItemに設定される話者(スタイルID)に対してエンジン側の初期化を行い、即座に音声合成ができるようにする。 */ - async action({ commit, dispatch }, { engineId, audioKeys, styleId }) { - const isInitialized = await dispatch("IS_INITIALIZED_ENGINE_SPEAKER", { + async action({ mutations, actions }, { engineId, audioKeys, styleId }) { + const isInitialized = await actions.IS_INITIALIZED_ENGINE_SPEAKER({ engineId, styleId, }); if (isInitialized) return; - commit("SET_AUDIO_KEYS_WITH_INITIALIZING_SPEAKER", { + mutations.SET_AUDIO_KEYS_WITH_INITIALIZING_SPEAKER({ audioKeys, }); - await dispatch("INITIALIZE_ENGINE_SPEAKER", { - engineId, - styleId, - }).finally(() => { - commit("SET_AUDIO_KEYS_WITH_INITIALIZING_SPEAKER", { - audioKeys: [], + await actions + .INITIALIZE_ENGINE_SPEAKER({ + engineId, + styleId, + }) + .finally(() => { + mutations.SET_AUDIO_KEYS_WITH_INITIALIZING_SPEAKER({ + audioKeys: [], + }); }); - }); }, }, @@ -578,10 +584,10 @@ export const audioStore = createPartialStore({ mutation(state, { audioKey }: { audioKey?: AudioKey }) { state._activeAudioKey = audioKey; }, - action({ commit, dispatch }, { audioKey }: { audioKey?: AudioKey }) { - commit("SET_ACTIVE_AUDIO_KEY", { audioKey }); + action({ mutations, actions }, { audioKey }: { audioKey?: AudioKey }) { + mutations.SET_ACTIVE_AUDIO_KEY({ audioKey }); // reset audio play start point - dispatch("SET_AUDIO_PLAY_START_POINT", { startPoint: undefined }); + actions.SET_AUDIO_PLAY_START_POINT({ startPoint: undefined }); }, }, @@ -590,7 +596,7 @@ export const audioStore = createPartialStore({ state._selectedAudioKeys = audioKeys; }, action( - { state, commit, getters }, + { state, mutations, getters }, { audioKeys }: { audioKeys?: AudioKey[] }, ) { const uniqueAudioKeys = new Set(audioKeys); @@ -603,7 +609,7 @@ export const audioStore = createPartialStore({ const sortedAudioKeys = state.audioKeys.filter((audioKey) => uniqueAudioKeys.has(audioKey), ); - commit("SET_SELECTED_AUDIO_KEYS", { audioKeys: sortedAudioKeys }); + mutations.SET_SELECTED_AUDIO_KEYS({ audioKeys: sortedAudioKeys }); }, }, @@ -611,8 +617,8 @@ export const audioStore = createPartialStore({ mutation(state, { startPoint }: { startPoint?: number }) { state._audioPlayStartPoint = startPoint; }, - action({ commit }, { startPoint }: { startPoint?: number }) { - commit("SET_AUDIO_PLAY_START_POINT", { startPoint }); + action({ mutations }, { startPoint }: { startPoint?: number }) { + mutations.SET_AUDIO_PLAY_START_POINT({ startPoint }); }, }, @@ -636,7 +642,7 @@ export const audioStore = createPartialStore({ GENERATE_AUDIO_ITEM: { async action( - { state, getters, dispatch }, + { state, getters, actions }, payload: { text?: string; voice?: Voice; @@ -683,9 +689,9 @@ export const audioStore = createPartialStore({ }; const query = getters.IS_ENGINE_READY(voice.engineId) - ? await dispatch("FETCH_AUDIO_QUERY", fetchQueryParams).catch( - () => undefined, - ) + ? await actions + .FETCH_AUDIO_QUERY(fetchQueryParams) + .catch(() => undefined) : undefined; const newAudioItem: AudioItem = { text, voice }; @@ -741,14 +747,14 @@ export const audioStore = createPartialStore({ REGISTER_AUDIO_ITEM: { async action( - { commit }, + { mutations }, { audioItem, prevAudioKey, }: { audioItem: AudioItem; prevAudioKey?: AudioKey }, ) { const audioKey = generateAudioKey(); - commit("INSERT_AUDIO_ITEM", { audioItem, audioKey, prevAudioKey }); + mutations.INSERT_AUDIO_ITEM({ audioItem, audioKey, prevAudioKey }); return audioKey; }, }, @@ -819,9 +825,9 @@ export const audioStore = createPartialStore({ }, REMOVE_ALL_AUDIO_ITEM: { - action({ commit, state }) { + action({ mutations, state }) { for (const audioKey of [...state.audioKeys]) { - commit("REMOVE_AUDIO_ITEM", { audioKey }); + mutations.REMOVE_AUDIO_ITEM({ audioKey }); } }, }, @@ -942,25 +948,26 @@ export const audioStore = createPartialStore({ state.audioItems[audioKey].query = audioQuery; }, action( - { commit }, + { mutations }, payload: { audioKey: AudioKey; audioQuery: AudioQuery }, ) { - commit("SET_AUDIO_QUERY", payload); + mutations.SET_AUDIO_QUERY(payload); }, }, FETCH_AUDIO_QUERY: { action( - { dispatch }, + { actions }, { text, engineId, styleId, }: { text: string; engineId: EngineId; styleId: StyleId }, ) { - return dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }) + return actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) .then((instance) => instance.invoke("audioQueryAudioQueryPost")({ text, @@ -999,7 +1006,7 @@ export const audioStore = createPartialStore({ FETCH_ACCENT_PHRASES: { action( - { dispatch }, + { actions }, { text, engineId, @@ -1012,9 +1019,10 @@ export const audioStore = createPartialStore({ isKana?: boolean; }, ) { - return dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }) + return actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) .then((instance) => instance.invoke("accentPhrasesAccentPhrasesPost")({ text, @@ -1123,7 +1131,7 @@ export const audioStore = createPartialStore({ FETCH_MORA_DATA: { action( - { dispatch }, + { actions }, { accentPhrases, engineId, @@ -1134,9 +1142,10 @@ export const audioStore = createPartialStore({ styleId: StyleId; }, ) { - return dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }) + return actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) .then((instance) => instance.invoke("moraDataMoraDataPost")({ accentPhrase: accentPhrases, @@ -1157,7 +1166,7 @@ export const audioStore = createPartialStore({ FETCH_AND_COPY_MORA_DATA: { async action( - { dispatch }, + { actions }, { accentPhrases, engineId, @@ -1170,14 +1179,12 @@ export const audioStore = createPartialStore({ copyIndexes: number[]; }, ) { - const fetchedAccentPhrases: AccentPhrase[] = await dispatch( - "FETCH_MORA_DATA", - { + const fetchedAccentPhrases: AccentPhrase[] = + await actions.FETCH_MORA_DATA({ accentPhrases, engineId, styleId, - }, - ); + }); for (const index of copyIndexes) { accentPhrases[index] = fetchedAccentPhrases[index]; } @@ -1270,13 +1277,13 @@ export const audioStore = createPartialStore({ FETCH_AUDIO: { async action( - { dispatch, state }, + { actions, state }, { audioKey, ...options }: { audioKey: AudioKey; cacheOnly?: boolean }, ) { const audioItem: AudioItem = JSON.parse( JSON.stringify(state.audioItems[audioKey]), ); - return dispatch("FETCH_AUDIO_FROM_AUDIO_ITEM", { + return actions.FETCH_AUDIO_FROM_AUDIO_ITEM({ audioItem, ...options, }); @@ -1286,10 +1293,10 @@ export const audioStore = createPartialStore({ FETCH_AUDIO_FROM_AUDIO_ITEM: { action: createUILockAction( async ( - { dispatch, state }, + { actions, state }, options: { audioItem: AudioItem; cacheOnly?: boolean }, ) => { - const instance = await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { + const instance = await actions.INSTANTIATE_ENGINE_CONNECTOR({ engineId: options.audioItem.voice.engineId, }); return fetchAudioFromAudioItem(state, instance, options); @@ -1300,14 +1307,14 @@ export const audioStore = createPartialStore({ CONNECT_AUDIO: { action: createUILockAction( async ( - { dispatch, state }, + { actions, state }, { encodedBlobs }: { encodedBlobs: string[] }, ) => { const engineId: EngineId | undefined = state.engineIds[0]; // TODO: 複数エンジン対応, 暫定的に音声結合機能は0番目のエンジンのみを使用する if (engineId == undefined) throw new Error(`No such engine registered: index == 0`); - const instance = await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { + const instance = await actions.INSTANTIATE_ENGINE_CONNECTOR({ engineId, }); try { @@ -1325,7 +1332,7 @@ export const audioStore = createPartialStore({ GENERATE_AND_SAVE_AUDIO: { action: createUILockAction( async ( - { state, getters, dispatch }, + { state, getters, actions }, { audioKey, filePath, @@ -1357,7 +1364,7 @@ export const audioStore = createPartialStore({ let fetchAudioResult: FetchAudioResult; try { - fetchAudioResult = await dispatch("FETCH_AUDIO", { audioKey }); + fetchAudioResult = await actions.FETCH_AUDIO({ audioKey }); } catch (e) { const errorMessage = handlePossiblyNotMorphableError(e); return { @@ -1427,7 +1434,7 @@ export const audioStore = createPartialStore({ MULTI_GENERATE_AND_SAVE_AUDIO: { action: createUILockAction( async ( - { state, getters, dispatch }, + { state, getters, actions }, { audioKeys, dirPath, @@ -1452,13 +1459,15 @@ export const audioStore = createPartialStore({ const promises = audioKeys.map((audioKey) => { const name = getters.DEFAULT_AUDIO_FILE_NAME(audioKey); - return dispatch("GENERATE_AND_SAVE_AUDIO", { - audioKey, - filePath: path.join(_dirPath, name), - }).then((value) => { - callback?.(++finishedCount); - return value; - }); + return actions + .GENERATE_AND_SAVE_AUDIO({ + audioKey, + filePath: path.join(_dirPath, name), + }) + .then((value) => { + callback?.(++finishedCount); + return value; + }); }); return Promise.all(promises); } @@ -1469,7 +1478,7 @@ export const audioStore = createPartialStore({ GENERATE_AND_CONNECT_AND_SAVE_AUDIO: { action: createUILockAction( async ( - { state, getters, dispatch }, + { state, getters, actions }, { filePath, callback, @@ -1528,7 +1537,7 @@ export const audioStore = createPartialStore({ for (const audioKey of state.audioKeys) { let fetchAudioResult: FetchAudioResult; try { - fetchAudioResult = await dispatch("FETCH_AUDIO", { audioKey }); + fetchAudioResult = await actions.FETCH_AUDIO({ audioKey }); } catch (e) { const errorMessage = handlePossiblyNotMorphableError(e); return { @@ -1563,7 +1572,7 @@ export const audioStore = createPartialStore({ ); } - const connectedWav = await dispatch("CONNECT_AUDIO", { + const connectedWav = await actions.CONNECT_AUDIO({ encodedBlobs, }); if (!connectedWav) { @@ -1704,29 +1713,29 @@ export const audioStore = createPartialStore({ PLAY_AUDIO: { action: createUILockAction( - async ({ commit, dispatch }, { audioKey }: { audioKey: AudioKey }) => { - await dispatch("STOP_AUDIO"); + async ({ mutations, actions }, { audioKey }: { audioKey: AudioKey }) => { + await actions.STOP_AUDIO(); // 音声用意 let fetchAudioResult: FetchAudioResult; - commit("SET_AUDIO_NOW_GENERATING", { + mutations.SET_AUDIO_NOW_GENERATING({ audioKey, nowGenerating: true, }); try { fetchAudioResult = await withProgress( - dispatch("FETCH_AUDIO", { audioKey }), - dispatch, + actions.FETCH_AUDIO({ audioKey }), + actions, ); } finally { - commit("SET_AUDIO_NOW_GENERATING", { + mutations.SET_AUDIO_NOW_GENERATING({ audioKey, nowGenerating: false, }); } const { blob } = fetchAudioResult; - return dispatch("PLAY_AUDIO_BLOB", { + return actions.PLAY_AUDIO_BLOB({ audioBlob: blob, audioKey, }); @@ -1737,14 +1746,14 @@ export const audioStore = createPartialStore({ PLAY_AUDIO_BLOB: { action: createUILockAction( async ( - { getters, commit, dispatch }, + { getters, mutations, actions }, { audioBlob, audioKey }: { audioBlob: Blob; audioKey?: AudioKey }, ) => { - commit("SET_AUDIO_SOURCE", { audioBlob }); + mutations.SET_AUDIO_SOURCE({ audioBlob }); let offset: number | undefined; // 途中再生用の処理 if (audioKey) { - const accentPhraseOffsets = await dispatch("GET_AUDIO_PLAY_OFFSETS", { + const accentPhraseOffsets = await actions.GET_AUDIO_PLAY_OFFSETS({ audioKey, }); if (accentPhraseOffsets.length === 0) @@ -1757,7 +1766,7 @@ export const audioStore = createPartialStore({ offset = startTime + 10e-6; } - return dispatch("PLAY_AUDIO_PLAYER", { offset, audioKey }); + return actions.PLAY_AUDIO_PLAYER({ offset, audioKey }); }, ), }, @@ -1779,50 +1788,52 @@ export const audioStore = createPartialStore({ }, PLAY_CONTINUOUSLY_AUDIO: { - action: createUILockAction(async ({ state, getters, commit, dispatch }) => { - const currentAudioKey = state._activeAudioKey; - const currentAudioPlayStartPoint = getters.AUDIO_PLAY_START_POINT; + action: createUILockAction( + async ({ state, getters, mutations, actions }) => { + const currentAudioKey = state._activeAudioKey; + const currentAudioPlayStartPoint = getters.AUDIO_PLAY_START_POINT; - let index = 0; - if (currentAudioKey != undefined) { - index = state.audioKeys.findIndex((v) => v === currentAudioKey); - } + let index = 0; + if (currentAudioKey != undefined) { + index = state.audioKeys.findIndex((v) => v === currentAudioKey); + } - const player = new ContinuousPlayer(state.audioKeys.slice(index), { - generateAudio: ({ audioKey }) => - dispatch("FETCH_AUDIO", { audioKey }).then((result) => result.blob), - playAudioBlob: ({ audioBlob, audioKey }) => - dispatch("PLAY_AUDIO_BLOB", { audioBlob, audioKey }), - }); - player.addEventListener("playstart", (e) => { - commit("SET_ACTIVE_AUDIO_KEY", { audioKey: e.audioKey }); - }); - player.addEventListener("waitstart", (e) => { - dispatch("START_PROGRESS"); - commit("SET_ACTIVE_AUDIO_KEY", { audioKey: e.audioKey }); - commit("SET_AUDIO_NOW_GENERATING", { - audioKey: e.audioKey, - nowGenerating: true, + const player = new ContinuousPlayer(state.audioKeys.slice(index), { + generateAudio: ({ audioKey }) => + actions.FETCH_AUDIO({ audioKey }).then((result) => result.blob), + playAudioBlob: ({ audioBlob, audioKey }) => + actions.PLAY_AUDIO_BLOB({ audioBlob, audioKey }), }); - }); - player.addEventListener("waitend", (e) => { - dispatch("RESET_PROGRESS"); - commit("SET_AUDIO_NOW_GENERATING", { - audioKey: e.audioKey, - nowGenerating: false, + player.addEventListener("playstart", (e) => { + mutations.SET_ACTIVE_AUDIO_KEY({ audioKey: e.audioKey }); + }); + player.addEventListener("waitstart", (e) => { + actions.START_PROGRESS(); + mutations.SET_ACTIVE_AUDIO_KEY({ audioKey: e.audioKey }); + mutations.SET_AUDIO_NOW_GENERATING({ + audioKey: e.audioKey, + nowGenerating: true, + }); + }); + player.addEventListener("waitend", (e) => { + actions.RESET_PROGRESS(); + mutations.SET_AUDIO_NOW_GENERATING({ + audioKey: e.audioKey, + nowGenerating: false, + }); }); - }); - commit("SET_NOW_PLAYING_CONTINUOUSLY", { nowPlaying: true }); + mutations.SET_NOW_PLAYING_CONTINUOUSLY({ nowPlaying: true }); - await player.playUntilComplete(); + await player.playUntilComplete(); - commit("SET_ACTIVE_AUDIO_KEY", { audioKey: currentAudioKey }); - commit("SET_AUDIO_PLAY_START_POINT", { - startPoint: currentAudioPlayStartPoint, - }); - commit("SET_NOW_PLAYING_CONTINUOUSLY", { nowPlaying: false }); - }), + mutations.SET_ACTIVE_AUDIO_KEY({ audioKey: currentAudioKey }); + mutations.SET_AUDIO_PLAY_START_POINT({ + startPoint: currentAudioPlayStartPoint, + }); + mutations.SET_NOW_PLAYING_CONTINUOUSLY({ nowPlaying: false }); + }, + ), }, }); @@ -1842,7 +1853,7 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.INSERT_AUDIO_ITEM(draft, payload); }, async action( - { commit }, + { mutations }, { audioItem, prevAudioKey, @@ -1852,7 +1863,7 @@ export const audioCommandStore = transformCommandStore( }, ) { const audioKey = generateAudioKey(); - commit("COMMAND_REGISTER_AUDIO_ITEM", { + mutations.COMMAND_REGISTER_AUDIO_ITEM({ audioItem, audioKey, prevAudioKey, @@ -1867,8 +1878,8 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.REMOVE_AUDIO_ITEM(draft, { audioKey }); } }, - action({ commit }, payload: { audioKeys: AudioKey[] }) { - commit("COMMAND_MULTI_REMOVE_AUDIO_ITEM", payload); + action({ mutations }, payload: { audioKeys: AudioKey[] }) { + mutations.COMMAND_MULTI_REMOVE_AUDIO_ITEM(payload); }, }, @@ -1876,8 +1887,8 @@ export const audioCommandStore = transformCommandStore( mutation(draft, payload: { audioKeys: AudioKey[] }) { audioStore.mutations.SET_AUDIO_KEYS(draft, payload); }, - action({ commit }, payload: { audioKeys: AudioKey[] }) { - commit("COMMAND_SET_AUDIO_KEYS", payload); + action({ mutations }, payload: { audioKeys: AudioKey[] }) { + mutations.COMMAND_SET_AUDIO_KEYS(payload); }, }, @@ -1885,8 +1896,8 @@ export const audioCommandStore = transformCommandStore( /** * 読みを変えずにテキストだけを変える */ - action({ commit }, payload: { audioKey: AudioKey; text: string }) { - commit("COMMAND_CHANGE_AUDIO_TEXT", { + action({ mutations }, payload: { audioKey: AudioKey; text: string }) { + mutations.COMMAND_CHANGE_AUDIO_TEXT({ audioKey: payload.audioKey, text: payload.text, update: "Text", @@ -1923,7 +1934,7 @@ export const audioCommandStore = transformCommandStore( } }, async action( - { state, commit, dispatch }, + { state, mutations, actions }, { audioKey, text }: { audioKey: AudioKey; text: string }, ) { const engineId = state.audioItems[audioKey].voice.engineId; @@ -1936,14 +1947,12 @@ export const audioCommandStore = transformCommandStore( try { if (query != undefined) { - const accentPhrases: AccentPhrase[] = await dispatch( - "FETCH_ACCENT_PHRASES", - { + const accentPhrases: AccentPhrase[] = + await actions.FETCH_ACCENT_PHRASES({ text: skippedText, engineId, styleId, - }, - ); + }); // 読みの内容が変わっていなければテキストだけ変更 const isSameText = !isAccentPhrasesTextDifferent( @@ -1965,19 +1974,19 @@ export const audioCommandStore = transformCommandStore( newAccentPhrases = mergedDiff; } } - commit("COMMAND_CHANGE_AUDIO_TEXT", { + mutations.COMMAND_CHANGE_AUDIO_TEXT({ audioKey, text, update: "AccentPhrases", accentPhrases: newAccentPhrases, }); } else { - const newAudioQuery = await dispatch("FETCH_AUDIO_QUERY", { + const newAudioQuery = await actions.FETCH_AUDIO_QUERY({ text, engineId, styleId, }); - commit("COMMAND_CHANGE_AUDIO_TEXT", { + mutations.COMMAND_CHANGE_AUDIO_TEXT({ audioKey, text, update: "AudioQuery", @@ -1985,7 +1994,7 @@ export const audioCommandStore = transformCommandStore( }); } } catch (error) { - commit("COMMAND_CHANGE_AUDIO_TEXT", { + mutations.COMMAND_CHANGE_AUDIO_TEXT({ audioKey, text, update: "Text", @@ -2057,12 +2066,12 @@ export const audioCommandStore = transformCommandStore( } }, async action( - { state, dispatch, commit }, + { state, actions, mutations }, { audioKeys, voice }: { audioKeys: AudioKey[]; voice: Voice }, ) { const engineId = voice.engineId; const styleId = voice.styleId; - await dispatch("SETUP_SPEAKER", { audioKeys, engineId, styleId }); + await actions.SETUP_SPEAKER({ audioKeys, engineId, styleId }); const errors: Record = {}; const changes: Record< AudioKey, @@ -2083,7 +2092,7 @@ export const audioCommandStore = transformCommandStore( try { const audioItem = state.audioItems[audioKey]; if (audioItem.query == undefined) { - const query: AudioQuery = await dispatch("FETCH_AUDIO_QUERY", { + const query: AudioQuery = await actions.FETCH_AUDIO_QUERY({ text: audioItem.text, engineId: voice.engineId, styleId: voice.styleId, @@ -2093,14 +2102,12 @@ export const audioCommandStore = transformCommandStore( query, }; } else { - const newAccentPhrases: AccentPhrase[] = await dispatch( - "FETCH_MORA_DATA", - { + const newAccentPhrases: AccentPhrase[] = + await actions.FETCH_MORA_DATA({ accentPhrases: audioItem.query.accentPhrases, engineId: voice.engineId, styleId: voice.styleId, - }, - ); + }); changes[audioKey] = { update: "AccentPhrases", @@ -2115,7 +2122,7 @@ export const audioCommandStore = transformCommandStore( } } - commit("COMMAND_MULTI_CHANGE_VOICE", { + mutations.COMMAND_MULTI_CHANGE_VOICE({ voice, changes, }); @@ -2144,7 +2151,7 @@ export const audioCommandStore = transformCommandStore( }); }, async action( - { state, dispatch, commit }, + { state, actions, mutations }, { audioKey, accentPhraseIndex, @@ -2162,22 +2169,20 @@ export const audioCommandStore = transformCommandStore( const engineId = state.audioItems[audioKey].voice.engineId; const styleId = state.audioItems[audioKey].voice.styleId; - const resultAccentPhrases: AccentPhrase[] = await dispatch( - "FETCH_AND_COPY_MORA_DATA", - { + const resultAccentPhrases: AccentPhrase[] = + await actions.FETCH_AND_COPY_MORA_DATA({ accentPhrases: newAccentPhrases, engineId, styleId, copyIndexes: [accentPhraseIndex], - }, - ); + }); - commit("COMMAND_CHANGE_ACCENT", { + mutations.COMMAND_CHANGE_ACCENT({ audioKey, accentPhrases: resultAccentPhrases, }); } catch (error) { - commit("COMMAND_CHANGE_ACCENT", { + mutations.COMMAND_CHANGE_ACCENT({ audioKey, accentPhrases: newAccentPhrases, }); @@ -2198,7 +2203,7 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.SET_ACCENT_PHRASES(draft, payload); }, async action( - { state, dispatch, commit }, + { state, actions, mutations }, payload: { audioKey: AudioKey; accentPhraseIndex: number; @@ -2286,21 +2291,19 @@ export const audioCommandStore = transformCommandStore( } try { - const resultAccentPhrases: AccentPhrase[] = await dispatch( - "FETCH_AND_COPY_MORA_DATA", - { + const resultAccentPhrases: AccentPhrase[] = + await actions.FETCH_AND_COPY_MORA_DATA({ accentPhrases: newAccentPhrases, engineId, styleId, copyIndexes: changeIndexes, - }, - ); - commit("COMMAND_CHANGE_ACCENT_PHRASE_SPLIT", { + }); + mutations.COMMAND_CHANGE_ACCENT_PHRASE_SPLIT({ audioKey, accentPhrases: resultAccentPhrases, }); } catch (error) { - commit("COMMAND_CHANGE_ACCENT_PHRASE_SPLIT", { + mutations.COMMAND_CHANGE_ACCENT_PHRASE_SPLIT({ audioKey, accentPhrases: newAccentPhrases, }); @@ -2311,7 +2314,7 @@ export const audioCommandStore = transformCommandStore( COMMAND_DELETE_ACCENT_PHRASE: { async action( - { state, commit }, + { state, mutations }, { audioKey, accentPhraseIndex, @@ -2331,7 +2334,7 @@ export const audioCommandStore = transformCommandStore( ]; // 自動再調整は行わない - commit("COMMAND_CHANGE_SINGLE_ACCENT_PHRASE", { + mutations.COMMAND_CHANGE_SINGLE_ACCENT_PHRASE({ audioKey, accentPhrases: newAccentPhrases, }); @@ -2349,7 +2352,7 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.SET_ACCENT_PHRASES(draft, payload); }, async action( - { state, dispatch, commit }, + { state, actions, mutations }, { audioKey, newPronunciation, @@ -2383,23 +2386,25 @@ export const audioCommandStore = transformCommandStore( // accent phraseの生成をリクエスト // 判別できない読み仮名が混じっていた場合400エラーが帰るのでfallback - newAccentPhrasesSegment = await dispatch("FETCH_ACCENT_PHRASES", { - text: pureKatakanaWithAccent, - engineId, - styleId, - isKana: true, - }).catch( - // fallback - () => - dispatch("FETCH_ACCENT_PHRASES", { - text: newPronunciation, - engineId, - styleId, - isKana: false, - }), - ); + newAccentPhrasesSegment = await actions + .FETCH_ACCENT_PHRASES({ + text: pureKatakanaWithAccent, + engineId, + styleId, + isKana: true, + }) + .catch( + // fallback + () => + actions.FETCH_ACCENT_PHRASES({ + text: newPronunciation, + engineId, + styleId, + isKana: false, + }), + ); } else { - newAccentPhrasesSegment = await dispatch("FETCH_ACCENT_PHRASES", { + newAccentPhrasesSegment = await actions.FETCH_ACCENT_PHRASES({ text: newPronunciation, engineId, styleId, @@ -2433,21 +2438,19 @@ export const audioCommandStore = transformCommandStore( ); try { - const resultAccentPhrases: AccentPhrase[] = await dispatch( - "FETCH_AND_COPY_MORA_DATA", - { + const resultAccentPhrases: AccentPhrase[] = + await actions.FETCH_AND_COPY_MORA_DATA({ accentPhrases: newAccentPhrases, engineId, styleId, copyIndexes, - }, - ); - commit("COMMAND_CHANGE_SINGLE_ACCENT_PHRASE", { + }); + mutations.COMMAND_CHANGE_SINGLE_ACCENT_PHRASE({ audioKey, accentPhrases: resultAccentPhrases, }); } catch (error) { - commit("COMMAND_CHANGE_SINGLE_ACCENT_PHRASE", { + mutations.COMMAND_CHANGE_SINGLE_ACCENT_PHRASE({ audioKey, accentPhrases: newAccentPhrases, }); @@ -2456,7 +2459,7 @@ export const audioCommandStore = transformCommandStore( }, COMMAND_MULTI_RESET_MORA_PITCH_AND_LENGTH: { - async action({ state, dispatch, commit }, { audioKeys }) { + async action({ state, actions, mutations }, { audioKeys }) { for (const audioKey of audioKeys) { const engineId = state.audioItems[audioKey].voice.engineId; const styleId = state.audioItems[audioKey].voice.styleId; @@ -2464,13 +2467,13 @@ export const audioCommandStore = transformCommandStore( const query = state.audioItems[audioKey].query; if (query == undefined) throw new Error("assert query != undefined"); - const newAccentPhrases = await dispatch("FETCH_MORA_DATA", { + const newAccentPhrases = await actions.FETCH_MORA_DATA({ accentPhrases: query.accentPhrases, engineId, styleId, }); - commit("COMMAND_CHANGE_ACCENT", { + mutations.COMMAND_CHANGE_ACCENT({ audioKey, accentPhrases: newAccentPhrases, }); @@ -2480,7 +2483,7 @@ export const audioCommandStore = transformCommandStore( COMMAND_RESET_SELECTED_MORA_PITCH_AND_LENGTH: { async action( - { state, dispatch, commit }, + { state, actions, mutations }, { audioKey, accentPhraseIndex }, ) { const engineId = state.audioItems[audioKey].voice.engineId; @@ -2489,14 +2492,14 @@ export const audioCommandStore = transformCommandStore( const query = state.audioItems[audioKey].query; if (query == undefined) throw new Error("query == undefined"); - const newAccentPhrases = await dispatch("FETCH_AND_COPY_MORA_DATA", { + const newAccentPhrases = await actions.FETCH_AND_COPY_MORA_DATA({ accentPhrases: [...query.accentPhrases], engineId, styleId, copyIndexes: [accentPhraseIndex], }); - commit("COMMAND_CHANGE_ACCENT", { + mutations.COMMAND_CHANGE_ACCENT({ audioKey, accentPhrases: newAccentPhrases, }); @@ -2517,7 +2520,7 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.SET_AUDIO_MORA_DATA(draft, payload); }, action( - { commit }, + { mutations }, payload: { audioKey: AudioKey; accentPhraseIndex: number; @@ -2526,7 +2529,7 @@ export const audioCommandStore = transformCommandStore( type: MoraDataType; }, ) { - commit("COMMAND_SET_AUDIO_MORA_DATA", payload); + mutations.COMMAND_SET_AUDIO_MORA_DATA(payload); }, }, @@ -2614,7 +2617,7 @@ export const audioCommandStore = transformCommandStore( }); }, action( - { commit }, + { mutations }, payload: { audioKey: AudioKey; accentPhraseIndex: number; @@ -2623,7 +2626,7 @@ export const audioCommandStore = transformCommandStore( type: MoraDataType; }, ) { - commit("COMMAND_SET_AUDIO_MORA_DATA_ACCENT_PHRASE", payload); + mutations.COMMAND_SET_AUDIO_MORA_DATA_ACCENT_PHRASE(payload); }, }, @@ -2637,10 +2640,10 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; speedScale: number }, ) { - commit("COMMAND_MULTI_SET_AUDIO_SPEED_SCALE", payload); + mutations.COMMAND_MULTI_SET_AUDIO_SPEED_SCALE(payload); }, }, @@ -2654,10 +2657,10 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; pitchScale: number }, ) { - commit("COMMAND_MULTI_SET_AUDIO_PITCH_SCALE", payload); + mutations.COMMAND_MULTI_SET_AUDIO_PITCH_SCALE(payload); }, }, @@ -2674,10 +2677,10 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; intonationScale: number }, ) { - commit("COMMAND_MULTI_SET_AUDIO_INTONATION_SCALE", payload); + mutations.COMMAND_MULTI_SET_AUDIO_INTONATION_SCALE(payload); }, }, @@ -2691,10 +2694,10 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; volumeScale: number }, ) { - commit("COMMAND_MULTI_SET_AUDIO_VOLUME_SCALE", payload); + mutations.COMMAND_MULTI_SET_AUDIO_VOLUME_SCALE(payload); }, }, @@ -2711,10 +2714,10 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; prePhonemeLength: number }, ) { - commit("COMMAND_MULTI_SET_AUDIO_PRE_PHONEME_LENGTH", payload); + mutations.COMMAND_MULTI_SET_AUDIO_PRE_PHONEME_LENGTH(payload); }, }, @@ -2731,10 +2734,10 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; postPhonemeLength: number }, ) { - commit("COMMAND_MULTI_SET_AUDIO_POST_PHONEME_LENGTH", payload); + mutations.COMMAND_MULTI_SET_AUDIO_POST_PHONEME_LENGTH(payload); }, }, @@ -2754,13 +2757,13 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, payload: { audioKeys: AudioKey[]; morphingInfo: MorphingInfo | undefined; }, ) { - commit("COMMAND_MULTI_SET_MORPHING_INFO", payload); + mutations.COMMAND_MULTI_SET_MORPHING_INFO(payload); }, }, @@ -2781,13 +2784,13 @@ export const audioCommandStore = transformCommandStore( } }, action( - { commit }, + { mutations }, { audioKeys, presetKey, }: { audioKeys: AudioKey[]; presetKey: PresetKey | undefined }, ) { - commit("COMMAND_MULTI_SET_AUDIO_PRESET", { audioKeys, presetKey }); + mutations.COMMAND_MULTI_SET_AUDIO_PRESET({ audioKeys, presetKey }); }, }, @@ -2797,8 +2800,8 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.APPLY_AUDIO_PRESET(draft, { audioKey }); } }, - action({ commit }, payload: { audioKeys: AudioKey[] }) { - commit("COMMAND_MULTI_APPLY_AUDIO_PRESET", payload); + action({ mutations }, payload: { audioKeys: AudioKey[] }) { + mutations.COMMAND_MULTI_APPLY_AUDIO_PRESET(payload); }, }, @@ -2811,8 +2814,8 @@ export const audioCommandStore = transformCommandStore( audioStore.mutations.APPLY_AUDIO_PRESET(draft, { audioKey }); } }, - action({ commit }, payload: { presetKey: PresetKey }) { - commit("COMMAND_FULLY_APPLY_AUDIO_PRESET", payload); + action({ mutations }, payload: { presetKey: PresetKey }) { + mutations.COMMAND_FULLY_APPLY_AUDIO_PRESET(payload); }, }, @@ -2832,7 +2835,7 @@ export const audioCommandStore = transformCommandStore( }, action: createUILockAction( async ( - { state, commit, dispatch, getters }, + { state, mutations, actions, getters }, { filePath }: { filePath?: string }, ) => { if (!filePath) { @@ -2866,7 +2869,7 @@ export const audioCommandStore = transformCommandStore( baseAudioItem?.voice, )) { audioItems.push( - await dispatch("GENERATE_AUDIO_ITEM", { + await actions.GENERATE_AUDIO_ITEM({ text, voice, baseAudioItem, @@ -2877,7 +2880,7 @@ export const audioCommandStore = transformCommandStore( audioItem, audioKey: generateAudioKey(), })); - commit("COMMAND_IMPORT_FROM_FILE", { + mutations.COMMAND_IMPORT_FROM_FILE({ audioKeyItemPairs, }); }, @@ -2902,7 +2905,7 @@ export const audioCommandStore = transformCommandStore( }, action: createUILockAction( async ( - { state, commit, dispatch }, + { state, mutations, actions }, { prevAudioKey, texts, @@ -2924,7 +2927,7 @@ export const audioCommandStore = transformCommandStore( for (const text of texts.filter((value) => value != "")) { const audioKey = generateAudioKey(); - const audioItem = await dispatch("GENERATE_AUDIO_ITEM", { + const audioItem = await actions.GENERATE_AUDIO_ITEM({ text, voice, baseAudioItem, @@ -2936,7 +2939,7 @@ export const audioCommandStore = transformCommandStore( }); } const audioKeys = audioKeyItemPairs.map((value) => value.audioKey); - commit("COMMAND_PUT_TEXTS", { + mutations.COMMAND_PUT_TEXTS({ prevAudioKey, audioKeyItemPairs, }); diff --git a/src/store/command.ts b/src/store/command.ts index 83de848ed3..8b64796edd 100644 --- a/src/store/command.ts +++ b/src/store/command.ts @@ -4,12 +4,13 @@ import { enablePatches, enableMapSet, Immer } from "immer"; import { Command, CommandStoreState, CommandStoreTypes, State } from "./type"; import { applyPatches } from "@/store/immerPatchUtility"; import { - createPartialStore, + createDotNotationPartialStore as createPartialStore, Mutation, MutationsBase, MutationTree, } from "@/store/vuex"; -import { EditorType } from "@/type/preload"; +import { CommandId, EditorType } from "@/type/preload"; +import { uuid4 } from "@/helpers/random"; enablePatches(); enableMapSet(); @@ -67,7 +68,7 @@ const recordPatches = (draft: S) => recipe(draft, payload), ); return { - unixMillisec: new Date().getTime(), + id: CommandId(uuid4()), redoPatches: doPatches, undoPatches: undoPatches, }; @@ -105,12 +106,12 @@ export const commandStore = createPartialStore({ applyPatches(state, command.undoPatches); } }, - action({ commit, dispatch }, { editor }: { editor: EditorType }) { - commit("UNDO", { editor }); + action({ mutations, actions }, { editor }: { editor: EditorType }) { + mutations.UNDO({ editor }); if (editor === "song") { // TODO: 存在しないノートのみ選択解除、あるいはSELECTED_NOTE_IDS getterを作る - commit("DESELECT_ALL_NOTES"); - dispatch("RENDER"); + mutations.DESELECT_ALL_NOTES(); + actions.RENDER(); } }, }, @@ -123,40 +124,27 @@ export const commandStore = createPartialStore({ applyPatches(state, command.redoPatches); } }, - action({ commit, dispatch }, { editor }: { editor: EditorType }) { - commit("REDO", { editor }); + action({ mutations, actions }, { editor }: { editor: EditorType }) { + mutations.REDO({ editor }); if (editor === "song") { // TODO: 存在しないノートのみ選択解除、あるいはSELECTED_NOTE_IDS getterを作る - commit("DESELECT_ALL_NOTES"); - dispatch("RENDER"); + mutations.DESELECT_ALL_NOTES(); + actions.RENDER(); } }, }, - LAST_COMMAND_UNIX_MILLISEC: { + LAST_COMMAND_IDS: { getter(state) { - const getLastCommandUnixMillisec = ( - commands: Command[], - ): number | null => { - const lastCommand = commands[commands.length - 1]; - // 型的にはundefinedにはならないが、lengthが0の場合はundefinedになる - return lastCommand ? lastCommand.unixMillisec : null; + const getLastCommandId = (commands: Command[]): CommandId | null => { + if (commands.length == 0) return null; + else return commands[commands.length - 1].id; }; - const lastTalkCommandTime = getLastCommandUnixMillisec( - state.undoCommands["talk"], - ); - const lastSongCommandTime = getLastCommandUnixMillisec( - state.undoCommands["song"], - ); - - if (lastTalkCommandTime != null && lastSongCommandTime != null) { - return Math.max(lastTalkCommandTime, lastSongCommandTime); - } else if (lastTalkCommandTime != null) { - return lastTalkCommandTime; - } else { - return lastSongCommandTime; - } + return { + talk: getLastCommandId(state.undoCommands["talk"]), + song: getLastCommandId(state.undoCommands["song"]), + }; }, }, diff --git a/src/store/engine.ts b/src/store/engine.ts index 3e8823b99b..a499856451 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -1,6 +1,6 @@ import { EngineState, EngineStoreState, EngineStoreTypes } from "./type"; -import { createUILockAction } from "./ui"; -import { createPartialStore } from "./vuex"; +import { createDotNotationUILockAction as createUILockAction } from "./ui"; +import { createDotNotationPartialStore as createPartialStore } from "./vuex"; import { createLogger } from "@/domain/frontend/log"; import type { EngineManifest } from "@/openapi"; import type { EngineId, EngineInfo } from "@/type/preload"; @@ -14,7 +14,7 @@ const { info, error } = createLogger("store/engine"); export const engineStore = createPartialStore({ GET_ENGINE_INFOS: { - async action({ state, commit }) { + async action({ state, mutations }) { const engineInfos = await window.backend.engineInfos(); // マルチエンジンオフモード時はengineIdsをデフォルトエンジンのIDだけにする。 @@ -27,7 +27,7 @@ export const engineStore = createPartialStore({ engineIds = engineInfos.map((engineInfo) => engineInfo.uuid); } - commit("SET_ENGINE_INFOS", { + mutations.SET_ENGINE_INFOS({ engineIds, engineInfos, }); @@ -41,11 +41,11 @@ export const engineStore = createPartialStore({ }, GET_ONLY_ENGINE_INFOS: { - async action({ commit }, { engineIds }) { + async action({ mutations }, { engineIds }) { const engineInfos = await window.backend.engineInfos(); for (const engineInfo of engineInfos) { if (engineIds.includes(engineInfo.uuid)) { - commit("SET_ENGINE_INFO", { + mutations.SET_ENGINE_INFO({ engineId: engineInfo.uuid, engineInfo, }); @@ -69,9 +69,9 @@ export const engineStore = createPartialStore({ }, GET_ALT_PORT_INFOS: { - async action({ commit }) { + async action({ mutations }) { const altPortInfos = await window.backend.getAltPortInfos(); - commit("SET_ALT_PORT_INFOS", { altPortInfos }); + mutations.SET_ALT_PORT_INFOS({ altPortInfos }); return altPortInfos; }, }, @@ -112,18 +112,22 @@ export const engineStore = createPartialStore({ }, FETCH_AND_SET_ENGINE_MANIFESTS: { - async action({ state, commit }) { - commit("SET_ENGINE_MANIFESTS", { + async action({ state, mutations, actions }) { + mutations.SET_ENGINE_MANIFESTS({ engineManifests: Object.fromEntries( await Promise.all( state.engineIds.map( async (engineId) => - await this.dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }).then(async (instance) => [ - engineId, - await instance.invoke("engineManifestEngineManifestGet")({}), - ]), + await actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) + .then(async (instance) => [ + engineId, + await instance.invoke("engineManifestEngineManifestGet")( + {}, + ), + ]), ), ), ), @@ -161,7 +165,7 @@ export const engineStore = createPartialStore({ START_WAITING_ENGINE: { action: createUILockAction( - async ({ state, commit, dispatch }, { engineId }) => { + async ({ state, mutations, actions }, { engineId }) => { let engineState: EngineState | undefined = state.engineStates[engineId]; if (engineState == undefined) throw new Error(`No such engineState set: engineId == ${engineId}`); @@ -176,9 +180,11 @@ export const engineStore = createPartialStore({ } try { - await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }).then((instance) => instance.invoke("versionVersionGet")({})); + await actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) + .then((instance) => instance.invoke("versionVersionGet")({})); } catch { await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -186,12 +192,12 @@ export const engineStore = createPartialStore({ continue; } engineState = "READY"; - commit("SET_ENGINE_STATE", { engineId, engineState }); + mutations.SET_ENGINE_STATE({ engineId, engineState }); break; } if (engineState !== "READY") { - commit("SET_ENGINE_STATE", { + mutations.SET_ENGINE_STATE({ engineId, engineState: "FAILED_STARTING", }); @@ -201,15 +207,15 @@ export const engineStore = createPartialStore({ }, RESTART_ENGINES: { - async action({ dispatch, commit }, { engineIds }) { + async action({ actions, mutations }, { engineIds }) { await Promise.all( engineIds.map(async (engineId) => { - commit("SET_ENGINE_STATE", { engineId, engineState: "STARTING" }); + mutations.SET_ENGINE_STATE({ engineId, engineState: "STARTING" }); try { return window.backend.restartEngine(engineId); } catch (e) { error(`Failed to restart engine: ${engineId}`); - await dispatch("DETECTED_ENGINE_ERROR", { engineId }); + await actions.DETECTED_ENGINE_ERROR({ engineId }); return { success: false, anyNewCharacters: false, @@ -218,9 +224,9 @@ export const engineStore = createPartialStore({ }), ); - await dispatch("GET_ONLY_ENGINE_INFOS", { engineIds }); + await actions.GET_ONLY_ENGINE_INFOS({ engineIds }); - const result = await dispatch("POST_ENGINE_START", { + const result = await actions.POST_ENGINE_START({ engineIds, }); @@ -229,22 +235,22 @@ export const engineStore = createPartialStore({ }, POST_ENGINE_START: { - async action({ state, dispatch }, { engineIds }) { - await dispatch("GET_ALT_PORT_INFOS"); + async action({ state, actions }, { engineIds }) { + await actions.GET_ALT_PORT_INFOS(); const result = await Promise.all( engineIds.map(async (engineId) => { if (state.engineStates[engineId] === "STARTING") { - await dispatch("START_WAITING_ENGINE", { engineId }); - await dispatch("FETCH_AND_SET_ENGINE_MANIFEST", { engineId }); - await dispatch("FETCH_AND_SET_ENGINE_SUPPORTED_DEVICES", { + await actions.START_WAITING_ENGINE({ engineId }); + await actions.FETCH_AND_SET_ENGINE_MANIFEST({ engineId }); + await actions.FETCH_AND_SET_ENGINE_SUPPORTED_DEVICES({ engineId, }); - await dispatch("LOAD_CHARACTER", { engineId }); + await actions.LOAD_CHARACTER({ engineId }); } - await dispatch("LOAD_DEFAULT_STYLE_IDS"); - await dispatch("CREATE_ALL_DEFAULT_PRESET"); - const newCharacters = await dispatch("GET_NEW_CHARACTERS"); + await actions.LOAD_DEFAULT_STYLE_IDS(); + await actions.CREATE_ALL_DEFAULT_PRESET(); + const newCharacters = await actions.GET_NEW_CHARACTERS(); const result = { success: state.engineStates[engineId] === "READY", anyNewCharacters: newCharacters.length > 0, @@ -257,7 +263,7 @@ export const engineStore = createPartialStore({ anyNewCharacters: result.some((r) => r.anyNewCharacters), }; if (mergedResult.anyNewCharacters) { - dispatch("SET_DIALOG_OPEN", { + actions.SET_DIALOG_OPEN({ isCharacterOrderDialogOpen: true, }); } @@ -267,23 +273,23 @@ export const engineStore = createPartialStore({ }, DETECTED_ENGINE_ERROR: { - action({ state, commit }, { engineId }) { + action({ state, mutations }, { engineId }) { const engineState: EngineState | undefined = state.engineStates[engineId]; if (engineState == undefined) throw new Error(`No such engineState set: engineId == ${engineId}`); switch (engineState) { case "STARTING": - commit("SET_ENGINE_STATE", { + mutations.SET_ENGINE_STATE({ engineId, engineState: "FAILED_STARTING", }); break; case "READY": - commit("SET_ENGINE_STATE", { engineId, engineState: "ERROR" }); + mutations.SET_ENGINE_STATE({ engineId, engineState: "ERROR" }); break; default: - commit("SET_ENGINE_STATE", { engineId, engineState: "ERROR" }); + mutations.SET_ENGINE_STATE({ engineId, engineState: "ERROR" }); } }, }, @@ -310,14 +316,16 @@ export const engineStore = createPartialStore({ /** * 指定した話者(スタイルID)がエンジン側で初期化されているか */ - async action({ dispatch }, { engineId, styleId }) { - const isInitialized = await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }).then((instance) => - instance.invoke("isInitializedSpeakerIsInitializedSpeakerGet")({ - speaker: styleId, - }), - ); + async action({ actions }, { engineId, styleId }) { + const isInitialized = await actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) + .then((instance) => + instance.invoke("isInitializedSpeakerIsInitializedSpeakerGet")({ + speaker: styleId, + }), + ); return isInitialized; }, @@ -327,16 +335,18 @@ export const engineStore = createPartialStore({ /** * 指定した話者(スタイルID)に対してエンジン側の初期化を行い、即座に音声合成ができるようにする。 */ - async action({ dispatch }, { engineId, styleId }) { - await dispatch("ASYNC_UI_LOCK", { + async action({ actions }, { engineId, styleId }) { + await actions.ASYNC_UI_LOCK({ callback: () => - dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }).then((instance) => - instance.invoke("initializeSpeakerInitializeSpeakerPost")({ - speaker: styleId, - }), - ), + actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) + .then((instance) => + instance.invoke("initializeSpeakerInitializeSpeakerPost")({ + speaker: styleId, + }), + ), }); }, }, @@ -393,14 +403,16 @@ export const engineStore = createPartialStore({ }, FETCH_AND_SET_ENGINE_MANIFEST: { - async action({ commit }, { engineId }) { - commit("SET_ENGINE_MANIFEST", { + async action({ mutations }, { engineId }) { + mutations.SET_ENGINE_MANIFEST({ engineId, - engineManifest: await this.dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }).then((instance) => - instance.invoke("engineManifestEngineManifestGet")({}), - ), + engineManifest: await this.actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) + .then((instance) => + instance.invoke("engineManifestEngineManifestGet")({}), + ), }); }, }, @@ -415,15 +427,17 @@ export const engineStore = createPartialStore({ }, FETCH_AND_SET_ENGINE_SUPPORTED_DEVICES: { - async action({ dispatch, commit }, { engineId }) { - const supportedDevices = await dispatch("INSTANTIATE_ENGINE_CONNECTOR", { - engineId, - }).then( - async (instance) => - await instance.invoke("supportedDevicesSupportedDevicesGet")({}), - ); + async action({ actions, mutations }, { engineId }) { + const supportedDevices = await actions + .INSTANTIATE_ENGINE_CONNECTOR({ + engineId, + }) + .then( + async (instance) => + await instance.invoke("supportedDevicesSupportedDevicesGet")({}), + ); - commit("SET_ENGINE_SUPPORTED_DEVICES", { + mutations.SET_ENGINE_SUPPORTED_DEVICES({ engineId, supportedDevices: supportedDevices, }); diff --git a/src/store/preset.ts b/src/store/preset.ts index 7ceaa9ccf9..2b827ba012 100644 --- a/src/store/preset.ts +++ b/src/store/preset.ts @@ -1,4 +1,5 @@ -import { createPartialStore } from "./vuex"; +import { createDotNotationPartialStore as createPartialStore } from "./vuex"; +import { uuid4 } from "@/helpers/random"; import { PresetStoreState, PresetStoreTypes, State } from "@/store/type"; import { Preset, PresetKey, Voice, VoiceId } from "@/type/preload"; @@ -105,11 +106,11 @@ export const presetStore = createPartialStore({ SET_DEFAULT_PRESET_MAP: { action( - { commit }, + { mutations }, { defaultPresetKeys }: { defaultPresetKeys: Record }, ) { window.backend.setSetting("defaultPresetKeys", defaultPresetKeys); - commit("SET_DEFAULT_PRESET_MAP", { defaultPresetKeys }); + mutations.SET_DEFAULT_PRESET_MAP({ defaultPresetKeys }); }, mutation( state, @@ -120,14 +121,14 @@ export const presetStore = createPartialStore({ }, HYDRATE_PRESET_STORE: { - async action({ commit }) { + async action({ mutations }) { const defaultPresetKeys = (await window.backend.getSetting( "defaultPresetKeys", // z.BRAND型のRecordはPartialになる仕様なのでasで型を変換 // TODO: 将来的にzodのバージョンを上げてasを消す https://github.com/colinhacks/zod/pull/2097 )) as Record; - commit("SET_DEFAULT_PRESET_MAP", { + mutations.SET_DEFAULT_PRESET_MAP({ defaultPresetKeys, }); @@ -138,20 +139,20 @@ export const presetStore = createPartialStore({ presetConfig.keys == undefined ) return; - commit("SET_PRESET_ITEMS", { + mutations.SET_PRESET_ITEMS({ // z.BRAND型のRecordはPartialになる仕様なのでasで型を変換 // TODO: 将来的にzodのバージョンを上げてasを消す https://github.com/colinhacks/zod/pull/2097 presetItems: presetConfig.items as Record, }); - commit("SET_PRESET_KEYS", { + mutations.SET_PRESET_KEYS({ presetKeys: presetConfig.keys, }); }, }, SAVE_PRESET_ORDER: { - action({ state, dispatch }, { presetKeys }: { presetKeys: PresetKey[] }) { - return dispatch("SAVE_PRESET_CONFIG", { + action({ state, actions }, { presetKeys }: { presetKeys: PresetKey[] }) { + return actions.SAVE_PRESET_CONFIG({ presetItems: state.presetItems, presetKeys, }); @@ -170,25 +171,25 @@ export const presetStore = createPartialStore({ items: JSON.parse(JSON.stringify(presetItems)), keys: JSON.parse(JSON.stringify(presetKeys)), }); - context.commit("SET_PRESET_ITEMS", { + context.mutations.SET_PRESET_ITEMS({ // z.BRAND型のRecordはPartialになる仕様なのでasで型を変換 // TODO: 将来的にzodのバージョンを上げてasを消す https://github.com/colinhacks/zod/pull/2097 presetItems: result.items as Record, }); - context.commit("SET_PRESET_KEYS", { presetKeys: result.keys }); + context.mutations.SET_PRESET_KEYS({ presetKeys: result.keys }); }, }, ADD_PRESET: { async action(context, { presetData }: { presetData: Preset }) { - const newKey = PresetKey(crypto.randomUUID()); + const newKey = PresetKey(uuid4()); const newPresetItems = { ...context.state.presetItems, [newKey]: presetData, }; const newPresetKeys = [newKey, ...context.state.presetKeys]; - await context.dispatch("SAVE_PRESET_CONFIG", { + await context.actions.SAVE_PRESET_CONFIG({ presetItems: newPresetItems, presetKeys: newPresetKeys, }); @@ -198,7 +199,7 @@ export const presetStore = createPartialStore({ }, CREATE_ALL_DEFAULT_PRESET: { - async action({ state, dispatch, getters }) { + async action({ state, actions, getters }) { const voices = getters.GET_ALL_VOICES("talk"); for (const voice of voices) { @@ -220,9 +221,9 @@ export const presetStore = createPartialStore({ prePhonemeLength: 0.1, postPhonemeLength: 0.1, }; - const newPresetKey = await dispatch("ADD_PRESET", { presetData }); + const newPresetKey = await actions.ADD_PRESET({ presetData }); - await dispatch("SET_DEFAULT_PRESET_MAP", { + await actions.SET_DEFAULT_PRESET_MAP({ defaultPresetKeys: { ...state.defaultPresetKeys, [voiceId]: newPresetKey, @@ -245,7 +246,7 @@ export const presetStore = createPartialStore({ ? [...context.state.presetKeys] : [presetKey, ...context.state.presetKeys]; - await context.dispatch("SAVE_PRESET_CONFIG", { + await context.actions.SAVE_PRESET_CONFIG({ presetItems: newPresetItems, presetKeys: newPresetKeys, }); @@ -260,7 +261,7 @@ export const presetStore = createPartialStore({ // Filter the `presetKey` properties from presetItems. const { [presetKey]: _, ...newPresetItems } = context.state.presetItems; - await context.dispatch("SAVE_PRESET_CONFIG", { + await context.actions.SAVE_PRESET_CONFIG({ presetItems: newPresetItems, presetKeys: newPresetKeys, }); diff --git a/src/store/project.ts b/src/store/project.ts index 951b69dbd8..438e88fa75 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -1,12 +1,16 @@ import { getBaseName } from "./utility"; -import { createPartialStore, Dispatch } from "./vuex"; -import { createUILockAction } from "@/store/ui"; +import { + createDotNotationPartialStore as createPartialStore, + DotNotationDispatch, +} from "./vuex"; +import { createDotNotationUILockAction as createUILockAction } from "@/store/ui"; import { AllActions, AudioItem, ProjectStoreState, ProjectStoreTypes, } from "@/store/type"; +import { TrackId } from "@/type/preload"; import { getValueOrThrow, ResultError } from "@/type/result"; import { LatestProjectType } from "@/domain/project/schema"; @@ -17,18 +21,21 @@ import { import { createDefaultTempo, createDefaultTimeSignature, + createDefaultTrack, DEFAULT_TPQN, } from "@/sing/domain"; +import { EditorType } from "@/type/preload"; +import { IsEqual } from "@/type/utility"; export const projectStoreState: ProjectStoreState = { - savedLastCommandUnixMillisec: null, + savedLastCommandIds: { talk: null, song: null }, }; const applyTalkProjectToStore = async ( - dispatch: Dispatch, + actions: DotNotationDispatch, talkProject: LatestProjectType["talk"], ) => { - await dispatch("REMOVE_ALL_AUDIO_ITEM"); + await actions.REMOVE_ALL_AUDIO_ITEM(); const { audioItems, audioKeys } = talkProject; @@ -39,7 +46,7 @@ const applyTalkProjectToStore = async ( // valueがundefinedにならないことを検証したあとであれば、 // このif文に引っかかることはないはずである if (audioItem == undefined) throw new Error("audioItem == undefined"); - prevAudioKey = await dispatch("REGISTER_AUDIO_ITEM", { + prevAudioKey = await actions.REGISTER_AUDIO_ITEM({ prevAudioKey, audioItem, }); @@ -47,28 +54,22 @@ const applyTalkProjectToStore = async ( }; const applySongProjectToStore = async ( - dispatch: Dispatch, + actions: DotNotationDispatch, songProject: LatestProjectType["song"], ) => { - const { tpqn, tempos, timeSignatures, tracks } = songProject; - // TODO: マルチトラック対応 - await dispatch("SET_SINGER", { - singer: tracks[0].singer, - }); - await dispatch("SET_KEY_RANGE_ADJUSTMENT", { - keyRangeAdjustment: tracks[0].keyRangeAdjustment, - }); - await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { - volumeRangeAdjustment: tracks[0].volumeRangeAdjustment, - }); - await dispatch("SET_TPQN", { tpqn }); - await dispatch("SET_TEMPOS", { tempos }); - await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes: tracks[0].notes }); - await dispatch("CLEAR_PITCH_EDIT_DATA"); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 - await dispatch("SET_PITCH_EDIT_DATA", { - data: tracks[0].pitchEditData, - startFrame: 0, + const { tpqn, tempos, timeSignatures, tracks, trackOrder } = songProject; + + await actions.SET_TPQN({ tpqn }); + await actions.SET_TEMPOS({ tempos }); + await actions.SET_TIME_SIGNATURES({ timeSignatures }); + await actions.SET_TRACKS({ + tracks: new Map( + trackOrder.map((trackId) => { + const track = tracks[trackId]; + if (!track) throw new Error("track == undefined"); + return [trackId, track]; + }), + ), }); }; @@ -99,47 +100,46 @@ export const projectStore = createPartialStore({ action: createUILockAction( async (context, { confirm }: { confirm?: boolean }) => { if (confirm !== false && context.getters.IS_EDITED) { - const result = await context.dispatch( - "SAVE_OR_DISCARD_PROJECT_FILE", - {}, - ); + const result = await context.actions.SAVE_OR_DISCARD_PROJECT_FILE({}); if (result == "canceled") { return; } } // トークプロジェクトの初期化 - await context.dispatch("REMOVE_ALL_AUDIO_ITEM"); + await context.actions.REMOVE_ALL_AUDIO_ITEM(); - const audioItem: AudioItem = await context.dispatch( - "GENERATE_AUDIO_ITEM", + const audioItem: AudioItem = await context.actions.GENERATE_AUDIO_ITEM( {}, ); - await context.dispatch("REGISTER_AUDIO_ITEM", { + await context.actions.REGISTER_AUDIO_ITEM({ audioItem, }); // ソングプロジェクトの初期化 - await context.dispatch("SET_TPQN", { tpqn: DEFAULT_TPQN }); - await context.dispatch("SET_TEMPOS", { + await context.actions.SET_TPQN({ tpqn: DEFAULT_TPQN }); + await context.actions.SET_TEMPOS({ tempos: [createDefaultTempo(0)], }); - await context.dispatch("SET_TIME_SIGNATURES", { + await context.actions.SET_TIME_SIGNATURES({ timeSignatures: [createDefaultTimeSignature(1)], }); - await context.dispatch("SET_NOTES", { notes: [] }); - await context.dispatch("SET_SINGER", { withRelated: true }); - await context.dispatch("CLEAR_PITCH_EDIT_DATA"); + const trackId = TrackId(crypto.randomUUID()); + await context.actions.SET_TRACKS({ + tracks: new Map([[trackId, createDefaultTrack()]]), + }); + await context.actions.SET_NOTES({ notes: [], trackId }); + await context.actions.SET_SINGER({ withRelated: true, trackId }); + await context.actions.CLEAR_PITCH_EDIT_DATA({ trackId }); - context.commit("SET_PROJECT_FILEPATH", { filePath: undefined }); - context.commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); - context.commit("CLEAR_COMMANDS"); + context.mutations.SET_PROJECT_FILEPATH({ filePath: undefined }); + context.actions.CLEAR_UNDO_HISTORY(); }, ), }, PARSE_PROJECT_FILE: { - async action({ dispatch, getters }, { projectJson }) { + async action({ actions, getters }, { projectJson }) { const projectData = JSON.parse(projectJson); const characterInfos = getters.USER_ORDERED_CHARACTER_INFOS("talk"); @@ -147,7 +147,7 @@ export const projectStore = createPartialStore({ throw new Error("characterInfos == undefined"); const parsedProjectData = await migrateProjectFileObject(projectData, { - fetchMoraData: (payload) => dispatch("FETCH_MORA_DATA", payload), + fetchMoraData: (payload) => actions.FETCH_MORA_DATA(payload), voices: characterInfos.flatMap((characterInfo) => characterInfo.metas.styles.map((style) => ({ engineId: style.engineId, @@ -168,7 +168,7 @@ export const projectStore = createPartialStore({ */ action: createUILockAction( async ( - { dispatch, commit, getters }, + { actions, mutations, state, getters }, { filePath, confirm }: { filePath?: string; confirm?: boolean }, ) => { if (!filePath) { @@ -188,17 +188,31 @@ export const projectStore = createPartialStore({ .readFile({ filePath }) .then(getValueOrThrow); - await dispatch("APPEND_RECENTLY_USED_PROJECT", { + await actions.APPEND_RECENTLY_USED_PROJECT({ filePath, }); const text = new TextDecoder("utf-8").decode(buf).trim(); - const parsedProjectData = await dispatch("PARSE_PROJECT_FILE", { + const parsedProjectData = await actions.PARSE_PROJECT_FILE({ projectJson: text, }); + if ( + !state.experimentalSetting.enableMultiTrack && + parsedProjectData.song.trackOrder.length > 1 + ) { + await window.backend.showMessageDialog({ + type: "error", + title: "エラー", + message: + "このプロジェクトはマルチトラック機能を使用して作成されていますが、現在の設定ではマルチトラック機能を使用できません。\n" + + "設定の「ソング:マルチトラック機能」を有効にしてからプロジェクトを読み込んでください。", + }); + return false; + } + if (confirm !== false && getters.IS_EDITED) { - const result = await dispatch("SAVE_OR_DISCARD_PROJECT_FILE", { + const result = await actions.SAVE_OR_DISCARD_PROJECT_FILE({ additionalMessage: "プロジェクトをロードすると現在のプロジェクトは破棄されます。", }); @@ -207,12 +221,11 @@ export const projectStore = createPartialStore({ } } - await applyTalkProjectToStore(dispatch, parsedProjectData.talk); - await applySongProjectToStore(dispatch, parsedProjectData.song); + await applyTalkProjectToStore(actions, parsedProjectData.talk); + await applySongProjectToStore(actions, parsedProjectData.song); - commit("SET_PROJECT_FILEPATH", { filePath }); - commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); - commit("CLEAR_COMMANDS"); + mutations.SET_PROJECT_FILEPATH({ filePath }); + actions.CLEAR_UNDO_HISTORY(); return true; } catch (err) { window.backend.logError(err); @@ -277,7 +290,7 @@ export const projectStore = createPartialStore({ }); } - await context.dispatch("APPEND_RECENTLY_USED_PROJECT", { + await context.actions.APPEND_RECENTLY_USED_PROJECT({ filePath, }); const appInfos = await window.backend.getAppInfos(); @@ -288,6 +301,7 @@ export const projectStore = createPartialStore({ tempos, timeSignatures, tracks, + trackOrder, } = context.state; const projectData: LatestProjectType = { appVersion: appInfos.version, @@ -299,7 +313,8 @@ export const projectStore = createPartialStore({ tpqn, tempos, timeSignatures, - tracks, + tracks: Object.fromEntries(tracks), + trackOrder, }, }; @@ -312,10 +327,9 @@ export const projectStore = createPartialStore({ buffer: buf, }) .then(getValueOrThrow); - context.commit("SET_PROJECT_FILEPATH", { filePath }); - context.commit( - "SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", - context.getters.LAST_COMMAND_UNIX_MILLISEC, + context.mutations.SET_PROJECT_FILEPATH({ filePath }); + context.mutations.SET_SAVED_LAST_COMMAND_IDS( + context.getters.LAST_COMMAND_IDS, ); return true; } catch (err) { @@ -342,7 +356,7 @@ export const projectStore = createPartialStore({ * 保存に失敗した場合はキャンセル扱いになる。 */ SAVE_OR_DISCARD_PROJECT_FILE: { - action: createUILockAction(async ({ dispatch }, { additionalMessage }) => { + action: createUILockAction(async ({ actions }, { additionalMessage }) => { let message = "プロジェクトの変更が保存されていません。"; if (additionalMessage) { message += "\n" + additionalMessage; @@ -358,7 +372,7 @@ export const projectStore = createPartialStore({ defaultId: 2, }); if (result == 0) { - const saved = await dispatch("SAVE_PROJECT_FILE", { + const saved = await actions.SAVE_PROJECT_FILE({ overwrite: true, }); return saved ? "saved" : "canceled"; @@ -372,16 +386,36 @@ export const projectStore = createPartialStore({ IS_EDITED: { getter(state, getters) { - return ( - getters.LAST_COMMAND_UNIX_MILLISEC !== - state.savedLastCommandUnixMillisec - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: IsEqual< + typeof state.savedLastCommandIds, + typeof getters.LAST_COMMAND_IDS + > = true; + return Object.keys(state.savedLastCommandIds).some((_editor) => { + const editor = _editor as EditorType; + return ( + state.savedLastCommandIds[editor] !== getters.LAST_COMMAND_IDS[editor] + ); + }); + }, + }, + + SET_SAVED_LAST_COMMAND_IDS: { + mutation(state, commandIds) { + state.savedLastCommandIds = commandIds; + }, + }, + + RESET_SAVED_LAST_COMMAND_IDS: { + mutation(state) { + state.savedLastCommandIds = { talk: null, song: null }; }, }, - SET_SAVED_LAST_COMMAND_UNIX_MILLISEC: { - mutation(state, unixMillisec) { - state.savedLastCommandUnixMillisec = unixMillisec; + CLEAR_UNDO_HISTORY: { + action({ commit }) { + commit("RESET_SAVED_LAST_COMMAND_IDS"); + commit("CLEAR_COMMANDS"); }, }, }); diff --git a/src/store/setting.ts b/src/store/setting.ts index ebd2dcaf17..56ef3624b9 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -48,7 +48,7 @@ export const settingStoreState: SettingStoreState = { enableMorphing: false, enableMultiSelect: false, shouldKeepTuningOnTextChange: false, - enablePitchEditInSongEditor: false, + enableMultiTrack: false, }, splitTextWhenPaste: "PERIOD_AND_NEW_LINE", splitterPosition: { @@ -65,6 +65,10 @@ export const settingStoreState: SettingStoreState = { enableMultiEngine: false, enableMemoNotation: false, enableRubyNotation: false, + undoableTrackOperations: { + soloAndMute: true, + panAndGain: true, + }, }; export const settingStore = createPartialStore({ @@ -141,6 +145,7 @@ export const settingStore = createPartialStore({ "enableRubyNotation", "enableMemoNotation", "skipUpdateVersion", + "undoableTrackOperations", ] as const; // rootMiscSettingKeysに値を足し忘れていたときに型エラーを出す検出用コード diff --git a/src/store/singing.ts b/src/store/singing.ts index d08ae47906..187620a989 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -13,7 +13,6 @@ import { SaveResultObject, Singer, Phrase, - PhraseState, transformCommandStore, SingingGuide, SingingVoice, @@ -21,9 +20,16 @@ import { SingingVoiceSourceHash, SequencerEditTarget, PhraseSourceHash, + Track, } from "./type"; import { DEFAULT_PROJECT_NAME, sanitizeFileName } from "./utility"; -import { EngineId, NoteId, StyleId } from "@/type/preload"; +import { + CharacterInfo, + EngineId, + NoteId, + StyleId, + TrackId, +} from "@/type/preload"; import { FrameAudioQuery, Note as NoteForRequestToEngine } from "@/openapi"; import { ResultError, getValueOrThrow } from "@/type/result"; import { @@ -67,8 +73,11 @@ import { createDefaultTempo, createDefaultTimeSignature, isValidNotes, + isValidTrack, SEQUENCER_MIN_NUM_MEASURES, getNumMeasures, + isTracksEmpty, + shouldPlayTracks, } from "@/sing/domain"; import { FrequentlyUpdatedState, @@ -84,7 +93,11 @@ import { getWorkaroundKeyRangeAdjustment } from "@/sing/workaroundKeyRangeAdjust import { createLogger } from "@/domain/frontend/log"; import { noteSchema } from "@/domain/project/schema"; import { getOrThrow } from "@/helpers/mapHelper"; +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; import { ufProjectToVoicevox } from "@/sing/utaformatixProject/toVoicevox"; +import { uuid4 } from "@/helpers/random"; +import { convertToWavFileData } from "@/sing/convertToWavFileData"; +import { generateWriteErrorMessage } from "@/helpers/fileHelper"; const logger = createLogger("store/singing"); @@ -110,10 +123,113 @@ const generateNoteEvents = (notes: Note[], tempos: Tempo[], tpqn: number) => { }); }; +const generateDefaultSongFileName = ( + projectName: string | undefined, + selectedTrack: Track, + getCharacterInfo: ( + engineId: EngineId, + styleId: StyleId, + ) => CharacterInfo | undefined, +) => { + if (projectName) { + return projectName + ".wav"; + } + + const singer = selectedTrack.singer; + if (singer) { + const singerName = getCharacterInfo(singer.engineId, singer.styleId)?.metas + .speakerName; + if (singerName) { + const notes = selectedTrack.notes.slice(0, 5); + const beginningPartLyrics = notes.map((note) => note.lyric).join(""); + return sanitizeFileName(`${singerName}_${beginningPartLyrics}.wav`); + } + } + + return `${DEFAULT_PROJECT_NAME}.wav`; +}; + +const offlineRenderTracks = async ( + numberOfChannels: number, + sampleRate: number, + renderDuration: number, + withLimiter: boolean, + multiTrackEnabled: boolean, + tracks: Map, + phrases: Map, + singingGuides: Map, + singingVoices: Map, +) => { + const offlineAudioContext = new OfflineAudioContext( + numberOfChannels, + sampleRate * renderDuration, + sampleRate, + ); + const offlineTransport = new OfflineTransport(); + const mainChannelStrip = new ChannelStrip(offlineAudioContext); + const limiter = withLimiter ? new Limiter(offlineAudioContext) : undefined; + const clipper = new Clipper(offlineAudioContext); + const trackChannelStrips = new Map(); + const shouldPlays = shouldPlayTracks(tracks); + for (const [trackId, track] of tracks) { + const channelStrip = new ChannelStrip(offlineAudioContext); + channelStrip.volume = multiTrackEnabled ? track.gain : 1; + channelStrip.pan = multiTrackEnabled ? track.pan : 0; + channelStrip.mute = multiTrackEnabled ? !shouldPlays.has(trackId) : false; + + channelStrip.output.connect(mainChannelStrip.input); + trackChannelStrips.set(trackId, channelStrip); + } + + for (const phrase of phrases.values()) { + if ( + phrase.singingGuideKey == undefined || + phrase.singingVoiceKey == undefined || + phrase.state !== "PLAYABLE" + ) { + continue; + } + const singingGuide = getOrThrow(singingGuides, phrase.singingGuideKey); + const singingVoice = getOrThrow(singingVoices, phrase.singingVoiceKey); + + // TODO: この辺りの処理を共通化する + const audioEvents = await generateAudioEvents( + offlineAudioContext, + singingGuide.startTime, + singingVoice.blob, + ); + const audioPlayer = new AudioPlayer(offlineAudioContext); + const audioSequence: AudioSequence = { + type: "audio", + audioPlayer, + audioEvents, + }; + const channelStrip = getOrThrow(trackChannelStrips, phrase.trackId); + audioPlayer.output.connect(channelStrip.input); + offlineTransport.addSequence(audioSequence); + } + mainChannelStrip.volume = 1; + if (limiter) { + mainChannelStrip.output.connect(limiter.input); + limiter.output.connect(clipper.input); + } else { + mainChannelStrip.output.connect(clipper.input); + } + clipper.output.connect(offlineAudioContext.destination); + + // スケジューリングを行い、オフラインレンダリングを実行 + // TODO: オフラインレンダリング後にメモリーがきちんと開放されるか確認する + offlineTransport.schedule(0, renderDuration); + const audioBuffer = await offlineAudioContext.startRendering(); + + return audioBuffer; +}; + let audioContext: AudioContext | undefined; let transport: Transport | undefined; let previewSynth: PolySynth | undefined; -let channelStrip: ChannelStrip | undefined; +let mainChannelStrip: ChannelStrip | undefined; +const trackChannelStrips = new Map(); let limiter: Limiter | undefined; let clipper: Clipper | undefined; @@ -122,32 +238,52 @@ if (window.AudioContext) { audioContext = new AudioContext(); transport = new Transport(audioContext); previewSynth = new PolySynth(audioContext); - channelStrip = new ChannelStrip(audioContext); + mainChannelStrip = new ChannelStrip(audioContext); limiter = new Limiter(audioContext); clipper = new Clipper(audioContext); - previewSynth.output.connect(channelStrip.input); - channelStrip.output.connect(limiter.input); + previewSynth.output.connect(mainChannelStrip.input); + mainChannelStrip.output.connect(limiter.input); limiter.output.connect(clipper.input); clipper.output.connect(audioContext.destination); } const playheadPosition = new FrequentlyUpdatedState(0); const singingVoices = new Map(); -const sequences = new Map(); // キーはPhraseKey +const sequences = new Map(); const animationTimer = new AnimationTimer(); const singingGuideCache = new Map(); const singingVoiceCache = new Map(); -// TODO: マルチトラックに対応する -const selectedTrackIndex = 0; +const initialTrackId = TrackId(crypto.randomUUID()); + +/** トラックを取得する。見付からないときはフォールバックとして最初のトラックを返す。 */ +const getSelectedTrackWithFallback = (partialState: { + tracks: Map; + _selectedTrackId: TrackId; + trackOrder: TrackId[]; +}) => { + if (!partialState.tracks.has(partialState._selectedTrackId)) { + return getOrThrow(partialState.tracks, partialState.trackOrder[0]); + } + return getOrThrow(partialState.tracks, partialState._selectedTrackId); +}; export const singingStoreState: SingingStoreState = { tpqn: DEFAULT_TPQN, tempos: [createDefaultTempo(0)], timeSignatures: [createDefaultTimeSignature(1)], - tracks: [createDefaultTrack()], + tracks: new Map([[initialTrackId, createDefaultTrack()]]), + trackOrder: [initialTrackId], + + /** + * 選択中のトラックID。 + * NOTE: このトラックIDは存在しない場合がある(Undo/Redoがあるため)。 + * 可能な限りgetters.SELECTED_TRACK_IDを使うこと。getSelectedTrackWithFallbackも参照。 + */ + _selectedTrackId: initialTrackId, + editFrameRate: DEPRECATED_DEFAULT_EDIT_FRAME_RATE, phrases: new Map(), singingGuides: new Map(), @@ -157,8 +293,7 @@ export const singingStoreState: SingingStoreState = { sequencerZoomY: 0.75, sequencerSnapType: 16, sequencerEditTarget: "NOTE", - selectedNoteIds: new Set(), - overlappingNoteIds: new Set(), + _selectedNoteIds: new Set(), nowPlaying: false, volume: 0, startRenderingRequested: false, @@ -166,6 +301,7 @@ export const singingStoreState: SingingStoreState = { nowRendering: false, nowAudioExporting: false, cancellationOfAudioExportRequested: false, + isSongSidebarOpen: false, }; export const singingStore = createPartialStore({ @@ -180,6 +316,33 @@ export const singingStore = createPartialStore({ }, }, + SELECTED_TRACK_ID: { + getter(state) { + // Undo/Redoで消えている場合は最初のトラックを選択していることにする + if (!state.tracks.has(state._selectedTrackId)) { + return state.trackOrder[0]; + } + return state._selectedTrackId; + }, + }, + + SELECTED_NOTE_IDS: { + // 選択中のトラックのノートだけを選択中のノートとして返す。 + getter(state) { + const selectedTrack = getSelectedTrackWithFallback(state); + + const noteIdsInSelectedTrack = new Set( + selectedTrack.notes.map((note) => note.id), + ); + + // そのままSet#intersectionを呼ぶとVueのバグでエラーになるため、new Set()でProxyなしのSetを作成する + // TODO: https://github.com/vuejs/core/issues/11398 が解決したら修正する + return new Set(state._selectedNoteIds).intersection( + noteIdsInSelectedTrack, + ); + }, + }, + SETUP_SINGER: { async action({ dispatch }, { singer }: { singer: Singer }) { // 指定されたstyleIdに対して、エンジン側の初期化を行う @@ -196,11 +359,9 @@ export const singingStore = createPartialStore({ SET_SINGER: { // 歌手をセットする。 // withRelatedがtrueの場合、関連する情報もセットする。 - mutation( - state, - { singer, withRelated }: { singer?: Singer; withRelated?: boolean }, - ) { - state.tracks[selectedTrackIndex].singer = singer; + mutation(state, { singer, withRelated, trackId }) { + const track = getOrThrow(state.tracks, trackId); + track.singer = singer; if (withRelated == true && singer != undefined) { // 音域調整量マジックナンバーを設定するワークアラウンド @@ -208,13 +369,12 @@ export const singingStore = createPartialStore({ state.characterInfos, singer, ); - state.tracks[selectedTrackIndex].keyRangeAdjustment = - keyRangeAdjustment; + track.keyRangeAdjustment = keyRangeAdjustment; } }, async action( { state, getters, dispatch, commit }, - { singer, withRelated }: { singer?: Singer; withRelated?: boolean }, + { singer, withRelated, trackId }, ) { if (state.defaultStyleIds == undefined) throw new Error("state.defaultStyleIds == undefined"); @@ -230,46 +390,43 @@ export const singingStore = createPartialStore({ const styleId = singer?.styleId ?? defaultStyleId; dispatch("SETUP_SINGER", { singer: { engineId, styleId } }); - commit("SET_SINGER", { singer: { engineId, styleId }, withRelated }); + commit("SET_SINGER", { + singer: { engineId, styleId }, + withRelated, + trackId, + }); dispatch("RENDER"); }, }, SET_KEY_RANGE_ADJUSTMENT: { - mutation(state, { keyRangeAdjustment }: { keyRangeAdjustment: number }) { - state.tracks[selectedTrackIndex].keyRangeAdjustment = keyRangeAdjustment; + mutation(state, { keyRangeAdjustment, trackId }) { + const track = getOrThrow(state.tracks, trackId); + track.keyRangeAdjustment = keyRangeAdjustment; }, - async action( - { dispatch, commit }, - { keyRangeAdjustment }: { keyRangeAdjustment: number }, - ) { + async action({ dispatch, commit }, { keyRangeAdjustment, trackId }) { if (!isValidKeyRangeAdjustment(keyRangeAdjustment)) { throw new Error("The keyRangeAdjustment is invalid."); } - commit("SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment }); + commit("SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment, trackId }); dispatch("RENDER"); }, }, SET_VOLUME_RANGE_ADJUSTMENT: { - mutation( - state, - { volumeRangeAdjustment }: { volumeRangeAdjustment: number }, - ) { - state.tracks[selectedTrackIndex].volumeRangeAdjustment = - volumeRangeAdjustment; + mutation(state, { volumeRangeAdjustment, trackId }) { + const track = getOrThrow(state.tracks, trackId); + track.volumeRangeAdjustment = volumeRangeAdjustment; }, - async action( - { dispatch, commit }, - { volumeRangeAdjustment }: { volumeRangeAdjustment: number }, - ) { + async action({ dispatch, commit }, { volumeRangeAdjustment, trackId }) { if (!isValidVolumeRangeAdjustment(volumeRangeAdjustment)) { throw new Error("The volumeRangeAdjustment is invalid."); } commit("SET_VOLUME_RANGE_ADJUSTMENT", { volumeRangeAdjustment, + trackId, }); dispatch("RENDER"); @@ -409,67 +566,65 @@ export const singingStore = createPartialStore({ }, }, - NOTE_IDS: { + ALL_NOTE_IDS: { getter(state) { - const selectedTrack = state.tracks[selectedTrackIndex]; - const noteIds = selectedTrack.notes.map((value) => value.id); + const noteIds = [...state.tracks.values()].flatMap((track) => + track.notes.map((note) => note.id), + ); return new Set(noteIds); }, }, + OVERLAPPING_NOTE_IDS: { + getter: (state) => (trackId) => { + const notes = getOrThrow(state.tracks, trackId).notes; + return getOverlappingNoteIds(notes); + }, + }, + SET_NOTES: { - mutation(state, { notes }: { notes: Note[] }) { - // TODO: マルチトラック対応 - state.overlappingNoteIds.clear(); + mutation(state, { notes, trackId }) { state.editingLyricNoteId = undefined; - state.selectedNoteIds.clear(); - state.tracks[selectedTrackIndex].notes = notes; - state.overlappingNoteIds = getOverlappingNoteIds( - state.tracks[selectedTrackIndex].notes, - ); + state._selectedNoteIds.clear(); + const selectedTrack = getOrThrow(state.tracks, trackId); + selectedTrack.notes = notes; }, - async action({ commit, dispatch }, { notes }: { notes: Note[] }) { + async action({ commit, dispatch }, { notes, trackId }) { if (!isValidNotes(notes)) { throw new Error("The notes are invalid."); } - commit("SET_NOTES", { notes }); + commit("SET_NOTES", { notes, trackId }); dispatch("RENDER"); }, }, ADD_NOTES: { - mutation(state, { notes }: { notes: Note[] }) { - const selectedTrack = state.tracks[selectedTrackIndex]; + mutation(state, { notes, trackId }) { + const selectedTrack = getOrThrow(state.tracks, trackId); const newNotes = [...selectedTrack.notes, ...notes]; newNotes.sort((a, b) => a.position - b.position); selectedTrack.notes = newNotes; - state.overlappingNoteIds = getOverlappingNoteIds( - state.tracks[selectedTrackIndex].notes, - ); }, }, UPDATE_NOTES: { - mutation(state, { notes }: { notes: Note[] }) { + mutation(state, { notes, trackId }) { const notesMap = new Map(); for (const note of notes) { notesMap.set(note.id, note); } - const selectedTrack = state.tracks[selectedTrackIndex]; + const selectedTrack = getOrThrow(state.tracks, trackId); selectedTrack.notes = selectedTrack.notes .map((value) => notesMap.get(value.id) ?? value) .sort((a, b) => a.position - b.position); - state.overlappingNoteIds = getOverlappingNoteIds( - state.tracks[selectedTrackIndex].notes, - ); }, }, REMOVE_NOTES: { - mutation(state, { noteIds }: { noteIds: NoteId[] }) { + mutation(state, { noteIds, trackId }) { const noteIdsSet = new Set(noteIds); - const selectedTrack = state.tracks[selectedTrackIndex]; + const selectedTrack = getOrThrow(state.tracks, trackId); if ( state.editingLyricNoteId != undefined && noteIdsSet.has(state.editingLyricNoteId) @@ -477,26 +632,22 @@ export const singingStore = createPartialStore({ state.editingLyricNoteId = undefined; } for (const noteId of noteIds) { - state.selectedNoteIds.delete(noteId); + state._selectedNoteIds.delete(noteId); } selectedTrack.notes = selectedTrack.notes.filter((value) => { return !noteIdsSet.has(value.id); }); - - state.overlappingNoteIds = getOverlappingNoteIds( - state.tracks[selectedTrackIndex].notes, - ); }, }, SELECT_NOTES: { mutation(state, { noteIds }: { noteIds: NoteId[] }) { for (const noteId of noteIds) { - state.selectedNoteIds.add(noteId); + state._selectedNoteIds.add(noteId); } }, async action({ getters, commit }, { noteIds }: { noteIds: NoteId[] }) { - const existingNoteIds = getters.NOTE_IDS; + const existingNoteIds = getters.ALL_NOTE_IDS; const isValidNoteIds = noteIds.every((value) => { return existingNoteIds.has(value); }); @@ -507,21 +658,19 @@ export const singingStore = createPartialStore({ }, }, - SELECT_ALL_NOTES: { - mutation(state) { - const currentTrack = state.tracks[selectedTrackIndex]; - const allNoteIds = currentTrack.notes.map((note) => note.id); - state.selectedNoteIds = new Set(allNoteIds); - }, - async action({ commit }) { - commit("SELECT_ALL_NOTES"); + SELECT_ALL_NOTES_IN_TRACK: { + async action({ state, commit }, { trackId }) { + const track = getOrThrow(state.tracks, trackId); + const noteIds = track.notes.map((note) => note.id); + commit("DESELECT_ALL_NOTES"); + commit("SELECT_NOTES", { noteIds }); }, }, DESELECT_ALL_NOTES: { mutation(state) { state.editingLyricNoteId = undefined; - state.selectedNoteIds = new Set(); + state._selectedNoteIds = new Set(); }, async action({ commit }) { commit("DESELECT_ALL_NOTES"); @@ -530,14 +679,14 @@ export const singingStore = createPartialStore({ SET_EDITING_LYRIC_NOTE_ID: { mutation(state, { noteId }: { noteId?: NoteId }) { - if (noteId != undefined && !state.selectedNoteIds.has(noteId)) { - state.selectedNoteIds.clear(); - state.selectedNoteIds.add(noteId); + if (noteId != undefined && !state._selectedNoteIds.has(noteId)) { + state._selectedNoteIds.clear(); + state._selectedNoteIds.add(noteId); } state.editingLyricNoteId = noteId; }, async action({ getters, commit }, { noteId }: { noteId?: NoteId }) { - if (noteId != undefined && !getters.NOTE_IDS.has(noteId)) { + if (noteId != undefined && !getters.ALL_NOTE_IDS.has(noteId)) { throw new Error("The note id is invalid."); } commit("SET_EDITING_LYRIC_NOTE_ID", { noteId }); @@ -547,77 +696,65 @@ export const singingStore = createPartialStore({ SET_PITCH_EDIT_DATA: { // ピッチ編集データをセットする。 // track.pitchEditDataの長さが足りない場合は、伸長も行う。 - mutation( - state, - { data, startFrame }: { data: number[]; startFrame: number }, - ) { - const pitchEditData = state.tracks[selectedTrackIndex].pitchEditData; + mutation(state, { pitchArray, startFrame, trackId }) { + const track = getOrThrow(state.tracks, trackId); + const pitchEditData = track.pitchEditData; const tempData = [...pitchEditData]; - const endFrame = startFrame + data.length; + const endFrame = startFrame + pitchArray.length; if (tempData.length < endFrame) { const valuesToPush = new Array(endFrame - tempData.length).fill( VALUE_INDICATING_NO_DATA, ); tempData.push(...valuesToPush); } - tempData.splice(startFrame, data.length, ...data); - state.tracks[selectedTrackIndex].pitchEditData = tempData; + tempData.splice(startFrame, pitchArray.length, ...pitchArray); + track.pitchEditData = tempData; }, - async action( - { dispatch, commit }, - { data, startFrame }: { data: number[]; startFrame: number }, - ) { + async action({ dispatch, commit }, { pitchArray, startFrame, trackId }) { if (startFrame < 0) { throw new Error("startFrame must be greater than or equal to 0."); } - if (!isValidPitchEditData(data)) { + if (!isValidPitchEditData(pitchArray)) { throw new Error("The pitch edit data is invalid."); } - commit("SET_PITCH_EDIT_DATA", { data, startFrame }); + commit("SET_PITCH_EDIT_DATA", { pitchArray, startFrame, trackId }); dispatch("RENDER"); }, }, ERASE_PITCH_EDIT_DATA: { - mutation( - state, - { startFrame, frameLength }: { startFrame: number; frameLength: number }, - ) { - const pitchEditData = state.tracks[selectedTrackIndex].pitchEditData; + mutation(state, { startFrame, frameLength, trackId }) { + const track = getOrThrow(state.tracks, trackId); + const pitchEditData = track.pitchEditData; const tempData = [...pitchEditData]; const endFrame = Math.min(startFrame + frameLength, tempData.length); tempData.fill(VALUE_INDICATING_NO_DATA, startFrame, endFrame); - state.tracks[selectedTrackIndex].pitchEditData = tempData; + track.pitchEditData = tempData; }, }, CLEAR_PITCH_EDIT_DATA: { // ピッチ編集データを失くす。 - mutation(state) { - state.tracks[selectedTrackIndex].pitchEditData = []; + mutation(state, { trackId }) { + const track = getOrThrow(state.tracks, trackId); + track.pitchEditData = []; }, - async action({ dispatch, commit }) { - commit("CLEAR_PITCH_EDIT_DATA"); + async action({ dispatch, commit }, { trackId }) { + commit("CLEAR_PITCH_EDIT_DATA", { trackId }); dispatch("RENDER"); }, }, SET_PHRASES: { - mutation(state, { phrases }: { phrases: Map }) { + mutation(state, { phrases }) { state.phrases = phrases; }, }, SET_STATE_TO_PHRASE: { - mutation( - state, - { - phraseKey, - phraseState, - }: { phraseKey: PhraseSourceHash; phraseState: PhraseState }, - ) { + mutation(state, { phraseKey, phraseState }) { const phrase = getOrThrow(state.phrases, phraseKey); phrase.state = phraseState; @@ -684,7 +821,7 @@ export const singingStore = createPartialStore({ SELECTED_TRACK: { getter(state) { - return state.tracks[selectedTrackIndex]; + return getSelectedTrackWithFallback(state); }, }, @@ -708,7 +845,7 @@ export const singingStore = createPartialStore({ return Math.max( SEQUENCER_MIN_NUM_MEASURES, getNumMeasures( - state.tracks[selectedTrackIndex].notes, + [...state.tracks.values()].flatMap((track) => track.notes), state.tempos, state.timeSignatures, state.tpqn, @@ -837,12 +974,12 @@ export const singingStore = createPartialStore({ state.volume = volume; }, async action({ commit }, { volume }) { - if (!channelStrip) { + if (!mainChannelStrip) { throw new Error("channelStrip is undefined."); } commit("SET_VOLUME", { volume }); - channelStrip.volume = volume; + mainChannelStrip.volume = volume; }, }, @@ -902,6 +1039,103 @@ export const singingStore = createPartialStore({ }, }, + CREATE_TRACK: { + action() { + const trackId = TrackId(crypto.randomUUID()); + const track = createDefaultTrack(); + + return { trackId, track }; + }, + }, + + INSERT_TRACK: { + /** + * トラックを挿入する。 + * prevTrackIdがundefinedの場合は最後に追加する。 + */ + mutation(state, { trackId, track, prevTrackId }) { + const index = + prevTrackId != undefined + ? state.trackOrder.indexOf(prevTrackId) + 1 + : state.trackOrder.length; + state.tracks.set(trackId, track); + state.trackOrder.splice(index, 0, trackId); + }, + action({ state, commit, dispatch }, { trackId, track, prevTrackId }) { + if (state.tracks.has(trackId)) { + throw new Error(`Track ${trackId} is already registered.`); + } + if (!isValidTrack(track)) { + throw new Error("The track is invalid."); + } + commit("INSERT_TRACK", { trackId, track, prevTrackId }); + + dispatch("RENDER"); + }, + }, + + DELETE_TRACK: { + mutation(state, { trackId }) { + state.tracks.delete(trackId); + state.trackOrder = state.trackOrder.filter((value) => value !== trackId); + }, + async action({ state, commit, dispatch }, { trackId }) { + if (!state.tracks.has(trackId)) { + throw new Error(`Track ${trackId} does not exist.`); + } + commit("DELETE_TRACK", { trackId }); + + dispatch("RENDER"); + }, + }, + + SELECT_TRACK: { + // トラックを切り替えるときに選択中のノートをクリアする。 + mutation(state, { trackId }) { + state._selectedNoteIds.clear(); + state._selectedTrackId = trackId; + }, + action({ state, commit }, { trackId }) { + if (!state.tracks.has(trackId)) { + throw new Error(`Track ${trackId} does not exist.`); + } + commit("SELECT_TRACK", { trackId }); + }, + }, + + SET_TRACK: { + mutation(state, { trackId, track }) { + state.tracks.set(trackId, track); + }, + async action({ state, commit, dispatch }, { trackId, track }) { + if (!isValidTrack(track)) { + throw new Error("The track is invalid."); + } + if (!state.tracks.has(trackId)) { + throw new Error(`Track ${trackId} does not exist.`); + } + + commit("SET_TRACK", { trackId, track }); + + dispatch("RENDER"); + }, + }, + + SET_TRACKS: { + mutation(state, { tracks }) { + state.tracks = tracks; + state.trackOrder = Array.from(tracks.keys()); + }, + async action({ commit, dispatch }, { tracks }) { + if (![...tracks.values()].every((track) => isValidTrack(track))) { + throw new Error("The track is invalid."); + } + commit("SET_TRACKS", { tracks }); + + dispatch("RENDER"); + }, + }, + /** * レンダリングを行う。レンダリング中だった場合は停止して再レンダリングする。 */ @@ -959,6 +1193,7 @@ export const singingStore = createPartialStore({ tempos: Tempo[], tpqn: number, phraseFirstRestMinDurationSeconds: number, + trackId: TrackId, ) => { const foundPhrases = new Map(); @@ -988,11 +1223,13 @@ export const singingStore = createPartialStore({ const notesHash = await calculatePhraseSourceHash({ firstRestDuration: phraseFirstRestDuration, notes: phraseNotes, + trackId, }); foundPhrases.set(notesHash, { firstRestDuration: phraseFirstRestDuration, notes: phraseNotes, state: "WAITING_TO_BE_RENDERED", + trackId, }); if (nextNote != undefined) { @@ -1235,72 +1472,111 @@ export const singingStore = createPartialStore({ if (!transport) { throw new Error("transport is undefined."); } - if (!channelStrip) { + if (!mainChannelStrip) { throw new Error("channelStrip is undefined."); } const audioContextRef = audioContext; const transportRef = transport; - const channelStripRef = channelStrip; - const trackRef = getters.SELECTED_TRACK; // レンダリング中に変更される可能性のあるデータをコピーする - // 重なっているノートの削除も行う + const tracks = cloneWithUnwrapProxy(state.tracks); + + const overlappingNoteIdsMap = new Map( + [...tracks.keys()].map((trackId) => [ + trackId, + getters.OVERLAPPING_NOTE_IDS(trackId), + ]), + ); + + // trackChannelStripsを同期する。 + // ここで更新されたChannelStripに既存のAudioPlayerなどを繋げる必要がある。 + // そのため、Phraseが変わっていなくてもPhraseの更新=AudioPlayerなどの再接続は毎回行う必要がある。 + // trackChannelStripsを同期した後、フレーズの更新が完了するまではreturnやthrowをしないこと。 + // TODO: 良い設計を考える + // ref: https://github.com/VOICEVOX/voicevox/pull/2176#discussion_r1693991784 + + const shouldPlays = shouldPlayTracks(tracks); + for (const [trackId, track] of tracks) { + if (!trackChannelStrips.has(trackId)) { + const channelStrip = new ChannelStrip(audioContext); + channelStrip.output.connect(mainChannelStrip.input); + trackChannelStrips.set(trackId, channelStrip); + } + + const channelStrip = getOrThrow(trackChannelStrips, trackId); + channelStrip.volume = state.experimentalSetting.enableMultiTrack + ? track.gain + : 1; + channelStrip.pan = state.experimentalSetting.enableMultiTrack + ? track.pan + : 0; + channelStrip.mute = state.experimentalSetting.enableMultiTrack + ? !shouldPlays.has(trackId) + : false; + } + for (const trackId of trackChannelStrips.keys()) { + if (!tracks.has(trackId)) { + const channelStrip = getOrThrow(trackChannelStrips, trackId); + channelStrip.output.disconnect(); + trackChannelStrips.delete(trackId); + } + } + + const singerAndFrameRates = new Map( + [...tracks].map(([trackId, track]) => [ + trackId, + track.singer + ? { + singer: track.singer, + frameRate: + state.engineManifests[track.singer.engineId].frameRate, + } + : undefined, + ]), + ); const tpqn = state.tpqn; const tempos = state.tempos.map((value) => ({ ...value })); - const singerAndFrameRate = trackRef.singer - ? { - singer: { ...trackRef.singer }, - frameRate: - state.engineManifests[trackRef.singer.engineId].frameRate, - } - : undefined; - const keyRangeAdjustment = trackRef.keyRangeAdjustment; - const volumeRangeAdjustment = trackRef.volumeRangeAdjustment; - const notes = trackRef.notes - .map((value) => ({ ...value })) - .filter((value) => !state.overlappingNoteIds.has(value.id)); - const pitchEditData = [...trackRef.pitchEditData]; const editFrameRate = state.editFrameRate; + const firstRestMinDurationSeconds = 0.12; const lastRestDurationSeconds = 0.5; const fadeOutDurationSeconds = 0.15; // フレーズを更新する - const foundPhrases = await searchPhrases( - notes, - tempos, - tpqn, - firstRestMinDurationSeconds, - ); - - for (const [phraseKey, phrase] of state.phrases) { - const notesHash = phraseKey; - if (!foundPhrases.has(notesHash)) { - // 歌い方と歌声を削除する - if (phrase.singingGuideKey != undefined) { - commit("DELETE_SINGING_GUIDE", { - singingGuideKey: phrase.singingGuideKey, - }); - } - if (phrase.singingVoiceKey != undefined) { - singingVoices.delete(phrase.singingVoiceKey); - } + const foundPhrases = new Map(); + for (const [trackId, track] of tracks) { + if (!track.singer) { + continue; + } - // 音源とシーケンスの接続を解除して削除する - const sequence = sequences.get(phraseKey); - if (sequence) { - getAudioSourceNode(sequence).disconnect(); - transportRef.removeSequence(sequence); - sequences.delete(phraseKey); - } + // 重なっているノートを削除する + const overlappingNoteIds = getOrThrow(overlappingNoteIdsMap, trackId); + const notes = track.notes.filter( + (value) => !overlappingNoteIds.has(value.id), + ); + const phrases = await searchPhrases( + notes, + tempos, + tpqn, + firstRestMinDurationSeconds, + trackId, + ); + for (const [phraseHash, phrase] of phrases) { + foundPhrases.set(phraseHash, phrase); } } const phrases = new Map(); + const disappearedPhraseKeys = new Set(); - for (const [notesHash, foundPhrase] of foundPhrases) { - const phraseKey = notesHash; + for (const phraseKey of state.phrases.keys()) { + if (!foundPhrases.has(phraseKey)) { + // 無くなったフレーズの場合 + disappearedPhraseKeys.add(phraseKey); + } + } + for (const [phraseKey, foundPhrase] of foundPhrases) { const existingPhrase = state.phrases.get(phraseKey); if (!existingPhrase) { // 新しいフレーズの場合 @@ -1308,53 +1584,46 @@ export const singingStore = createPartialStore({ continue; } + const track = getOrThrow(tracks, existingPhrase.trackId); + + const singerAndFrameRate = getOrThrow( + singerAndFrameRates, + existingPhrase.trackId, + ); + // すでに存在するフレーズの場合 // 再レンダリングする必要があるかどうかをチェックする // シンガーが未設定の場合、とりあえず常に再レンダリングする - // 音声合成を行う必要がある場合、現在フレーズに設定されている歌声を削除する - // 歌い方の推論も行う必要がある場合、現在フレーズに設定されている歌い方を削除する + // 音声合成を行う必要がある場合、singingVoiceKeyをundefinedにする + // 歌い方の推論も行う必要がある場合、singingGuideKeyとsingingVoiceKeyをundefinedにする // TODO: リファクタリングする const phrase = { ...existingPhrase }; if (!singerAndFrameRate || phrase.state === "COULD_NOT_RENDER") { if (phrase.singingGuideKey != undefined) { - commit("DELETE_SINGING_GUIDE", { - singingGuideKey: phrase.singingGuideKey, - }); phrase.singingGuideKey = undefined; } if (phrase.singingVoiceKey != undefined) { - singingVoices.delete(phrase.singingVoiceKey); phrase.singingVoiceKey = undefined; } - } else { - if (phrase.singingGuideKey != undefined) { - const calculatedHash = await calculateSingingGuideSourceHash({ - engineId: singerAndFrameRate.singer.engineId, - tpqn, - tempos, - firstRestDuration: phrase.firstRestDuration, - lastRestDurationSeconds, - notes: phrase.notes, - keyRangeAdjustment, - volumeRangeAdjustment, - frameRate: singerAndFrameRate.frameRate, - }); - const hash = phrase.singingGuideKey; - if (hash !== calculatedHash) { - commit("DELETE_SINGING_GUIDE", { - singingGuideKey: phrase.singingGuideKey, - }); - phrase.singingGuideKey = undefined; - if (phrase.singingVoiceKey != undefined) { - singingVoices.delete(phrase.singingVoiceKey); - phrase.singingVoiceKey = undefined; - } + } else if (phrase.singingGuideKey != undefined) { + const calculatedHash = await calculateSingingGuideSourceHash({ + engineId: singerAndFrameRate.singer.engineId, + tpqn, + tempos, + firstRestDuration: phrase.firstRestDuration, + lastRestDurationSeconds, + notes: phrase.notes, + keyRangeAdjustment: track.keyRangeAdjustment, + volumeRangeAdjustment: track.volumeRangeAdjustment, + frameRate: singerAndFrameRate.frameRate, + }); + const hash = phrase.singingGuideKey; + if (hash !== calculatedHash) { + phrase.singingGuideKey = undefined; + if (phrase.singingVoiceKey != undefined) { + phrase.singingVoiceKey = undefined; } - } - if ( - phrase.singingGuideKey != undefined && - phrase.singingVoiceKey != undefined - ) { + } else if (phrase.singingVoiceKey != undefined) { let singingGuide = getOrThrow( state.singingGuides, phrase.singingGuideKey, @@ -1362,7 +1631,7 @@ export const singingStore = createPartialStore({ // 歌い方をコピーして、ピッチ編集を適用する singingGuide = structuredClone(toRaw(singingGuide)); - applyPitchEdit(singingGuide, pitchEditData, editFrameRate); + applyPitchEdit(singingGuide, track.pitchEditData, editFrameRate); const calculatedHash = await calculateSingingVoiceSourceHash({ singer: singerAndFrameRate.singer, @@ -1370,19 +1639,58 @@ export const singingStore = createPartialStore({ }); const hash = phrase.singingVoiceKey; if (hash !== calculatedHash) { - singingVoices.delete(phrase.singingVoiceKey); phrase.singingVoiceKey = undefined; } } } + + phrases.set(phraseKey, phrase); + } + + // フレーズのstateを更新する + for (const phrase of phrases.values()) { if ( phrase.singingGuideKey == undefined || phrase.singingVoiceKey == undefined ) { phrase.state = "WAITING_TO_BE_RENDERED"; } + } - phrases.set(phraseKey, phrase); + // 無くなったフレーズの音源とシーケンスの接続を解除して削除する + for (const phraseKey of disappearedPhraseKeys) { + const sequence = sequences.get(phraseKey); + if (sequence) { + getAudioSourceNode(sequence).disconnect(); + transportRef.removeSequence(sequence); + sequences.delete(phraseKey); + } + } + + // 使われていない歌い方と歌声を削除する + const singingGuideKeysInUse = new Set( + [...phrases.values()] + .map((value) => value.singingGuideKey) + .filter((value) => value != undefined), + ); + const singingVoiceKeysInUse = new Set( + [...phrases.values()] + .map((value) => value.singingVoiceKey) + .filter((value) => value != undefined), + ); + const existingSingingGuideKeys = new Set(state.singingGuides.keys()); + const existingSingingVoiceKeys = new Set(singingVoices.keys()); + const singingGuideKeysToDelete = existingSingingGuideKeys.difference( + singingGuideKeysInUse, + ); + const singingVoiceKeysToDelete = existingSingingVoiceKeys.difference( + singingVoiceKeysInUse, + ); + for (const singingGuideKey of singingGuideKeysToDelete) { + commit("DELETE_SINGING_GUIDE", { singingGuideKey }); + } + for (const singingVoiceKey of singingVoiceKeysToDelete) { + singingVoices.delete(singingVoiceKey); } commit("SET_PHRASES", { phrases }); @@ -1417,7 +1725,8 @@ export const singingStore = createPartialStore({ instrument: polySynth, noteEvents, }; - polySynth.output.connect(channelStripRef.input); + const channelStrip = getOrThrow(trackChannelStrips, phrase.trackId); + polySynth.output.connect(channelStrip.input); transportRef.addSequence(noteSequence); sequences.set(phraseKey, noteSequence); } @@ -1432,6 +1741,13 @@ export const singingStore = createPartialStore({ ); phrasesToBeRendered.delete(phraseKey); + const track = getOrThrow(tracks, phrase.trackId); + + const singerAndFrameRate = getOrThrow( + singerAndFrameRates, + phrase.trackId, + ); + // シンガーが未設定の場合は、歌い方の生成や音声合成は行わない if (!singerAndFrameRate) { @@ -1459,7 +1775,7 @@ export const singingStore = createPartialStore({ ); // リクエスト用のノーツのキーのシフトを行う - shiftKeyOfNotes(notesForRequestToEngine, -keyRangeAdjustment); + shiftKeyOfNotes(notesForRequestToEngine, -track.keyRangeAdjustment); // 歌い方が存在する場合、歌い方を取得する // 歌い方が存在しない場合、キャッシュがあれば取得し、なければ歌い方を生成する @@ -1479,8 +1795,8 @@ export const singingStore = createPartialStore({ firstRestDuration: phrase.firstRestDuration, lastRestDurationSeconds, notes: phrase.notes, - keyRangeAdjustment, - volumeRangeAdjustment, + keyRangeAdjustment: track.keyRangeAdjustment, + volumeRangeAdjustment: track.volumeRangeAdjustment, frameRate: singerAndFrameRate.frameRate, }); @@ -1501,7 +1817,7 @@ export const singingStore = createPartialStore({ logger.info(`Fetched frame audio query. phonemes: ${phonemes}`); // ピッチのシフトを行う - shiftGuidePitch(query, keyRangeAdjustment); + shiftGuidePitch(query, track.keyRangeAdjustment); // フレーズの開始時刻を計算する const startTime = calculateStartTime(phrase, tempos, tpqn); @@ -1525,7 +1841,7 @@ export const singingStore = createPartialStore({ singingGuide = structuredClone(toRaw(singingGuide)); // ピッチ編集を適用する - applyPitchEdit(singingGuide, pitchEditData, editFrameRate); + applyPitchEdit(singingGuide, track.pitchEditData, editFrameRate); // 歌声のキャッシュがあれば取得し、なければ音声合成を行う @@ -1550,7 +1866,10 @@ export const singingStore = createPartialStore({ const queryForVolumeGeneration = structuredClone( singingGuide.query, ); - shiftGuidePitch(queryForVolumeGeneration, -keyRangeAdjustment); + shiftGuidePitch( + queryForVolumeGeneration, + -track.keyRangeAdjustment, + ); // 音量を生成して、生成した音量を歌い方のクエリにセットする // 音量値はAPIを叩く毎に変わるので、calc hashしたあとに音量を取得している @@ -1563,7 +1882,7 @@ export const singingStore = createPartialStore({ singingGuide.query.volume = volumes; // 音量のシフトを行う - shiftGuideVolume(singingGuide.query, volumeRangeAdjustment); + shiftGuideVolume(singingGuide.query, track.volumeRangeAdjustment); // 末尾のpauの区間の音量を0にする muteLastPauSection( @@ -1605,13 +1924,14 @@ export const singingStore = createPartialStore({ singingGuide.startTime, singingVoice.blob, ); - const audioPlayer = new AudioPlayer(audioContextRef); + const audioPlayer = new AudioPlayer(audioContext); const audioSequence: AudioSequence = { type: "audio", audioPlayer, audioEvents, }; - audioPlayer.output.connect(channelStripRef.input); + const channelStrip = getOrThrow(trackChannelStrips, phrase.trackId); + audioPlayer.output.connect(channelStrip.input); transportRef.addSequence(audioSequence); sequences.set(phraseKey, audioSequence); @@ -1679,85 +1999,6 @@ export const singingStore = createPartialStore({ }), }, - // TODO: Undoできるようにする - IMPORT_UTAFORMATIX_PROJECT: { - action: createUILockAction( - async ({ state, commit, dispatch }, { project, trackIndex = 0 }) => { - const { tempos, timeSignatures, tracks, tpqn } = - ufProjectToVoicevox(project); - - const notes = tracks[trackIndex].notes; - - if (tempos.length > 1) { - logger.warn("Multiple tempos are not supported."); - } - if (timeSignatures.length > 1) { - logger.warn("Multiple time signatures are not supported."); - } - - tempos.splice(1, tempos.length - 1); // TODO: 複数テンポに対応したら削除 - timeSignatures.splice(1, timeSignatures.length - 1); // TODO: 複数拍子に対応したら削除 - - 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"); - }, - ), - }, - - // TODO: Undoできるようにする - IMPORT_VOICEVOX_PROJECT: { - action: createUILockAction( - async ({ state, commit, dispatch }, { project, trackIndex = 0 }) => { - const { tempos, timeSignatures, tracks, tpqn } = project.song; - - 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."); - } - - // TODO: ここら辺のSET系の処理をまとめる - await dispatch("SET_SINGER", { - singer: track.singer, - }); - 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"); - }, - ), - }, - FETCH_SING_FRAME_VOLUME: { async action( { dispatch }, @@ -1802,129 +2043,18 @@ export const singingStore = createPartialStore({ EXPORT_WAVE_FILE: { action: createUILockAction( - async ({ state, getters, commit, dispatch }, { filePath }) => { - const convertToWavFileData = (audioBuffer: AudioBuffer) => { - const bytesPerSample = 4; // Float32 - const formatCode = 3; // WAVE_FORMAT_IEEE_FLOAT - - const numberOfChannels = audioBuffer.numberOfChannels; - const numberOfSamples = audioBuffer.length; - const sampleRate = audioBuffer.sampleRate; - const byteRate = sampleRate * numberOfChannels * bytesPerSample; - const blockSize = numberOfChannels * bytesPerSample; - const dataSize = numberOfSamples * numberOfChannels * bytesPerSample; - - const buffer = new ArrayBuffer(44 + dataSize); - const dataView = new DataView(buffer); - - let pos = 0; - const writeString = (value: string) => { - for (let i = 0; i < value.length; i++) { - dataView.setUint8(pos, value.charCodeAt(i)); - pos += 1; - } - }; - const writeUint32 = (value: number) => { - dataView.setUint32(pos, value, true); - pos += 4; - }; - const writeUint16 = (value: number) => { - dataView.setUint16(pos, value, true); - pos += 2; - }; - const writeSample = (offset: number, value: number) => { - dataView.setFloat32(pos + offset * 4, value, true); - }; - - writeString("RIFF"); - writeUint32(36 + dataSize); // RIFFチャンクサイズ - writeString("WAVE"); - writeString("fmt "); - writeUint32(16); // fmtチャンクサイズ - writeUint16(formatCode); - writeUint16(numberOfChannels); - writeUint32(sampleRate); - writeUint32(byteRate); - writeUint16(blockSize); - writeUint16(bytesPerSample * 8); // 1サンプルあたりのビット数 - writeString("data"); - writeUint32(dataSize); - - for (let i = 0; i < numberOfChannels; i++) { - const channelData = audioBuffer.getChannelData(i); - for (let j = 0; j < numberOfSamples; j++) { - writeSample(j * numberOfChannels + i, channelData[j]); - } - } - - return buffer; - }; - - const generateWriteErrorMessage = (writeFileResult: ResultError) => { - if (writeFileResult.code) { - const code = writeFileResult.code.toUpperCase(); - - if (code.startsWith("ENOSPC")) { - return "空き容量が足りません。"; - } - - if (code.startsWith("EACCES")) { - return "ファイルにアクセスする許可がありません。"; - } - - if (code.startsWith("EBUSY")) { - return "ファイルが開かれています。"; - } - } - - return `何らかの理由で失敗しました。${writeFileResult.message}`; - }; - - const calcRenderDuration = () => { - // TODO: マルチトラックに対応する - const notes = getters.SELECTED_TRACK.notes; - if (notes.length === 0) { - return 1; - } - const lastNote = notes[notes.length - 1]; - const lastNoteEndPosition = lastNote.position + lastNote.duration; - const lastNoteEndTime = getters.TICK_TO_SECOND(lastNoteEndPosition); - return Math.max(1, lastNoteEndTime + 1); - }; - - const generateDefaultSongFileName = () => { - const projectName = getters.PROJECT_NAME; - if (projectName) { - return projectName + ".wav"; - } - - const singer = getters.SELECTED_TRACK.singer; - if (singer) { - const singerName = getters.CHARACTER_INFO( - singer.engineId, - singer.styleId, - )?.metas.speakerName; - if (singerName) { - const notes = getters.SELECTED_TRACK.notes.slice(0, 5); - const beginningPartLyrics = notes - .map((note) => note.lyric) - .join(""); - return sanitizeFileName( - `${singerName}_${beginningPartLyrics}.wav`, - ); - } - } - - return `${DEFAULT_PROJECT_NAME}.wav`; - }; - + async ({ state, commit, getters, dispatch }, { filePath }) => { const exportWaveFile = async (): Promise => { - const fileName = generateDefaultSongFileName(); + const fileName = generateDefaultSongFileName( + getters.PROJECT_NAME, + getters.SELECTED_TRACK, + getters.CHARACTER_INFO, + ); const numberOfChannels = 2; const sampleRate = 48000; // TODO: 設定できるようにする - const withLimiter = false; // TODO: 設定できるようにする + const withLimiter = true; // TODO: 設定できるようにする - const renderDuration = calcRenderDuration(); + const renderDuration = getters.CALC_RENDER_DURATION; if (state.nowPlaying) { await dispatch("SING_STOP_AUDIO"); @@ -1962,63 +2092,18 @@ export const singingStore = createPartialStore({ } } - const offlineAudioContext = new OfflineAudioContext( + const audioBuffer = await offlineRenderTracks( numberOfChannels, - sampleRate * renderDuration, sampleRate, + renderDuration, + withLimiter, + state.experimentalSetting.enableMultiTrack, + state.tracks, + state.phrases, + state.singingGuides, + singingVoiceCache, ); - const offlineTransport = new OfflineTransport(); - const channelStrip = new ChannelStrip(offlineAudioContext); - const limiter = withLimiter - ? new Limiter(offlineAudioContext) - : undefined; - const clipper = new Clipper(offlineAudioContext); - - for (const phrase of state.phrases.values()) { - if ( - phrase.singingGuideKey == undefined || - phrase.singingVoiceKey == undefined || - phrase.state !== "PLAYABLE" - ) { - continue; - } - const singingGuide = getOrThrow( - state.singingGuides, - phrase.singingGuideKey, - ); - const singingVoice = getOrThrow( - singingVoices, - phrase.singingVoiceKey, - ); - - // TODO: この辺りの処理を共通化する - const audioEvents = await generateAudioEvents( - offlineAudioContext, - singingGuide.startTime, - singingVoice.blob, - ); - const audioPlayer = new AudioPlayer(offlineAudioContext); - const audioSequence: AudioSequence = { - type: "audio", - audioPlayer, - audioEvents, - }; - audioPlayer.output.connect(channelStrip.input); - offlineTransport.addSequence(audioSequence); - } - channelStrip.volume = 1; - if (limiter) { - channelStrip.output.connect(limiter.input); - limiter.output.connect(clipper.input); - } else { - channelStrip.output.connect(clipper.input); - } - clipper.output.connect(offlineAudioContext.destination); - // スケジューリングを行い、オフラインレンダリングを実行 - // TODO: オフラインレンダリング後にメモリーがきちんと開放されるか確認する - offlineTransport.schedule(0, renderDuration); - const audioBuffer = await offlineAudioContext.startRendering(); const waveFileData = convertToWavFileData(audioBuffer); try { @@ -2073,15 +2158,15 @@ export const singingStore = createPartialStore({ }, COPY_NOTES_TO_CLIPBOARD: { - async action({ state, getters }) { - const currentTrack = getters.SELECTED_TRACK; - const noteIds = state.selectedNoteIds; + async action({ getters }) { + const selectedTrack = getters.SELECTED_TRACK; + const noteIds = getters.SELECTED_NOTE_IDS; // ノートが選択されていない場合は何もしない if (noteIds.size === 0) { return; } // 選択されたノートのみをコピーする - const selectedNotes = currentTrack.notes + const selectedNotes = selectedTrack.notes .filter((note: Note) => noteIds.has(note.id)) .map((note: Note) => { // idのみコピーしない @@ -2144,7 +2229,7 @@ export const singingStore = createPartialStore({ const quantizedPastePos = Math.round(pasteOriginPos / snapTicks) * snapTicks; return { - id: NoteId(crypto.randomUUID()), + id: NoteId(uuid4()), position: quantizedPastePos, duration: Number(note.duration), noteNumber: Number(note.noteNumber), @@ -2153,7 +2238,11 @@ export const singingStore = createPartialStore({ }); const pastedNoteIds = notesToPaste.map((note) => note.id); // ノートを追加してレンダリングする - commit("COMMAND_ADD_NOTES", { notes: notesToPaste }); + commit("COMMAND_ADD_NOTES", { + notes: notesToPaste, + trackId: getters.SELECTED_TRACK_ID, + }); + dispatch("RENDER"); // 貼り付けたノートを選択する commit("DESELECT_ALL_NOTES"); @@ -2163,9 +2252,9 @@ export const singingStore = createPartialStore({ COMMAND_QUANTIZE_SELECTED_NOTES: { action({ state, commit, getters, dispatch }) { - const currentTrack = getters.SELECTED_TRACK; - const selectedNotes = currentTrack.notes.filter((note: Note) => { - return state.selectedNoteIds.has(note.id); + const selectedTrack = getters.SELECTED_TRACK; + const selectedNotes = selectedTrack.notes.filter((note: Note) => { + return getters.SELECTED_NOTE_IDS.has(note.id); }); // TODO: クオンタイズの処理を共通化する const snapType = state.sequencerSnapType; @@ -2176,10 +2265,128 @@ export const singingStore = createPartialStore({ Math.round(note.position / snapTicks) * snapTicks; return { ...note, position: quantizedPosition }; }); - commit("COMMAND_UPDATE_NOTES", { notes: quantizedNotes }); + commit("COMMAND_UPDATE_NOTES", { + notes: quantizedNotes, + trackId: getters.SELECTED_TRACK_ID, + }); + + dispatch("RENDER"); + }, + }, + + SET_SONG_SIDEBAR_OPEN: { + mutation(state, { isSongSidebarOpen }) { + state.isSongSidebarOpen = isSongSidebarOpen; + }, + action({ commit }, { isSongSidebarOpen }) { + commit("SET_SONG_SIDEBAR_OPEN", { isSongSidebarOpen }); + }, + }, + + SET_TRACK_NAME: { + mutation(state, { trackId, name }) { + const track = getOrThrow(state.tracks, trackId); + track.name = name; + }, + action({ commit }, { trackId, name }) { + commit("SET_TRACK_NAME", { trackId, name }); + }, + }, + + SET_TRACK_MUTE: { + mutation(state, { trackId, mute }) { + const track = getOrThrow(state.tracks, trackId); + track.mute = mute; + }, + action({ commit, dispatch }, { trackId, mute }) { + commit("SET_TRACK_MUTE", { trackId, mute }); + + dispatch("RENDER"); + }, + }, + + SET_TRACK_SOLO: { + mutation(state, { trackId, solo }) { + const track = getOrThrow(state.tracks, trackId); + track.solo = solo; + }, + action({ commit, dispatch }, { trackId, solo }) { + commit("SET_TRACK_SOLO", { trackId, solo }); + + dispatch("RENDER"); + }, + }, + + SET_TRACK_GAIN: { + mutation(state, { trackId, gain }) { + const track = getOrThrow(state.tracks, trackId); + track.gain = gain; + }, + action({ commit, dispatch }, { trackId, gain }) { + commit("SET_TRACK_GAIN", { trackId, gain }); + + dispatch("RENDER"); + }, + }, + + SET_TRACK_PAN: { + mutation(state, { trackId, pan }) { + const track = getOrThrow(state.tracks, trackId); + track.pan = pan; + }, + action({ commit, dispatch }, { trackId, pan }) { + commit("SET_TRACK_PAN", { trackId, pan }); + dispatch("RENDER"); }, }, + + SET_SELECTED_TRACK: { + mutation(state, { trackId }) { + state._selectedTrackId = trackId; + }, + action({ commit }, { trackId }) { + commit("SET_SELECTED_TRACK", { trackId }); + }, + }, + + REORDER_TRACKS: { + mutation(state, { trackOrder }) { + state.trackOrder = trackOrder; + }, + action({ commit }, { trackOrder }) { + commit("REORDER_TRACKS", { trackOrder }); + }, + }, + + UNSOLO_ALL_TRACKS: { + mutation(state) { + for (const track of state.tracks.values()) { + track.solo = false; + } + }, + action({ commit }) { + commit("UNSOLO_ALL_TRACKS"); + }, + }, + + CALC_RENDER_DURATION: { + getter(state) { + const notes = [...state.tracks.values()].flatMap((track) => track.notes); + if (notes.length === 0) { + return 1; + } + notes.sort((a, b) => a.position + a.duration - (b.position + b.duration)); + const lastNote = notes[notes.length - 1]; + const lastNoteEndPosition = lastNote.position + lastNote.duration; + const lastNoteEndTime = tickToSecond( + lastNoteEndPosition, + state.tempos, + state.tpqn, + ); + return Math.max(1, lastNoteEndTime + 1); + }, + }, }); export const singingCommandStoreState: SingingCommandStoreState = {}; @@ -2187,49 +2394,53 @@ export const singingCommandStoreState: SingingCommandStoreState = {}; export const singingCommandStore = transformCommandStore( createPartialStore({ COMMAND_SET_SINGER: { - mutation(draft, { singer, withRelated }) { - singingStore.mutations.SET_SINGER(draft, { singer, withRelated }); + mutation(draft, { singer, withRelated, trackId }) { + singingStore.mutations.SET_SINGER(draft, { + singer, + withRelated, + trackId, + }); }, - async action({ dispatch, commit }, { singer, withRelated }) { + async action({ dispatch, commit }, { singer, withRelated, trackId }) { dispatch("SETUP_SINGER", { singer }); - commit("COMMAND_SET_SINGER", { singer, withRelated }); + commit("COMMAND_SET_SINGER", { singer, withRelated, trackId }); dispatch("RENDER"); }, }, COMMAND_SET_KEY_RANGE_ADJUSTMENT: { - mutation(draft, { keyRangeAdjustment }) { + mutation(draft, { keyRangeAdjustment, trackId }) { singingStore.mutations.SET_KEY_RANGE_ADJUSTMENT(draft, { keyRangeAdjustment, + trackId, }); }, - async action( - { dispatch, commit }, - { keyRangeAdjustment }: { keyRangeAdjustment: number }, - ) { + async action({ dispatch, commit }, { keyRangeAdjustment, trackId }) { if (!isValidKeyRangeAdjustment(keyRangeAdjustment)) { throw new Error("The keyRangeAdjustment is invalid."); } - commit("COMMAND_SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment }); + commit("COMMAND_SET_KEY_RANGE_ADJUSTMENT", { + keyRangeAdjustment, + trackId, + }); dispatch("RENDER"); }, }, COMMAND_SET_VOLUME_RANGE_ADJUSTMENT: { - mutation(draft, { volumeRangeAdjustment }) { + mutation(draft, { volumeRangeAdjustment, trackId }) { singingStore.mutations.SET_VOLUME_RANGE_ADJUSTMENT(draft, { volumeRangeAdjustment, + trackId, }); }, - async action( - { dispatch, commit }, - { volumeRangeAdjustment }: { volumeRangeAdjustment: number }, - ) { + async action({ dispatch, commit }, { volumeRangeAdjustment, trackId }) { if (!isValidVolumeRangeAdjustment(volumeRangeAdjustment)) { throw new Error("The volumeRangeAdjustment is invalid."); } commit("COMMAND_SET_VOLUME_RANGE_ADJUSTMENT", { volumeRangeAdjustment, + trackId, }); dispatch("RENDER"); @@ -2315,107 +2526,380 @@ export const singingCommandStore = transformCommandStore( }, }, COMMAND_ADD_NOTES: { - mutation(draft, { notes }) { - singingStore.mutations.ADD_NOTES(draft, { notes }); + mutation(draft, { notes, trackId }) { + singingStore.mutations.ADD_NOTES(draft, { notes, trackId }); }, - action({ getters, commit, dispatch }, { notes }: { notes: Note[] }) { - const existingNoteIds = getters.NOTE_IDS; + action({ getters, commit, dispatch }, { notes, trackId }) { + const existingNoteIds = getters.ALL_NOTE_IDS; const isValidNotes = notes.every((value) => { return !existingNoteIds.has(value.id) && isValidNote(value); }); if (!isValidNotes) { throw new Error("The notes are invalid."); } - commit("COMMAND_ADD_NOTES", { notes }); + commit("COMMAND_ADD_NOTES", { notes, trackId }); dispatch("RENDER"); }, }, COMMAND_UPDATE_NOTES: { - mutation(draft, { notes }) { - singingStore.mutations.UPDATE_NOTES(draft, { notes }); + mutation(draft, { notes, trackId }) { + singingStore.mutations.UPDATE_NOTES(draft, { notes, trackId }); }, - action({ getters, commit, dispatch }, { notes }: { notes: Note[] }) { - const existingNoteIds = getters.NOTE_IDS; + action({ getters, commit, dispatch }, { notes, trackId }) { + const existingNoteIds = getters.ALL_NOTE_IDS; const isValidNotes = notes.every((value) => { return existingNoteIds.has(value.id) && isValidNote(value); }); if (!isValidNotes) { throw new Error("The notes are invalid."); } - commit("COMMAND_UPDATE_NOTES", { notes }); + commit("COMMAND_UPDATE_NOTES", { notes, trackId }); dispatch("RENDER"); }, }, COMMAND_REMOVE_NOTES: { - mutation(draft, { noteIds }) { - singingStore.mutations.REMOVE_NOTES(draft, { noteIds }); + mutation(draft, { noteIds, trackId }) { + singingStore.mutations.REMOVE_NOTES(draft, { noteIds, trackId }); }, - action({ getters, commit, dispatch }, { noteIds }) { - const existingNoteIds = getters.NOTE_IDS; + action({ getters, commit, dispatch }, { noteIds, trackId }) { + const existingNoteIds = getters.ALL_NOTE_IDS; const isValidNoteIds = noteIds.every((value) => { return existingNoteIds.has(value); }); if (!isValidNoteIds) { throw new Error("The note ids are invalid."); } - commit("COMMAND_REMOVE_NOTES", { noteIds }); + commit("COMMAND_REMOVE_NOTES", { noteIds, trackId }); dispatch("RENDER"); }, }, COMMAND_REMOVE_SELECTED_NOTES: { - action({ state, commit, dispatch }) { - commit("COMMAND_REMOVE_NOTES", { noteIds: [...state.selectedNoteIds] }); + action({ commit, getters, dispatch }) { + commit("COMMAND_REMOVE_NOTES", { + noteIds: [...getters.SELECTED_NOTE_IDS], + trackId: getters.SELECTED_TRACK_ID, + }); dispatch("RENDER"); }, }, COMMAND_SET_PITCH_EDIT_DATA: { - mutation(draft, { data, startFrame }) { - singingStore.mutations.SET_PITCH_EDIT_DATA(draft, { data, startFrame }); + mutation(draft, { pitchArray, startFrame, trackId }) { + singingStore.mutations.SET_PITCH_EDIT_DATA(draft, { + pitchArray, + startFrame, + trackId, + }); }, - action( - { commit, dispatch }, - { data, startFrame }: { data: number[]; startFrame: number }, - ) { + action({ commit, dispatch }, { pitchArray, startFrame, trackId }) { if (startFrame < 0) { throw new Error("startFrame must be greater than or equal to 0."); } - if (!isValidPitchEditData(data)) { + if (!isValidPitchEditData(pitchArray)) { throw new Error("The pitch edit data is invalid."); } - commit("COMMAND_SET_PITCH_EDIT_DATA", { data, startFrame }); + commit("COMMAND_SET_PITCH_EDIT_DATA", { + pitchArray, + startFrame, + trackId, + }); dispatch("RENDER"); }, }, COMMAND_ERASE_PITCH_EDIT_DATA: { - mutation(draft, { startFrame, frameLength }) { + mutation(draft, { startFrame, frameLength, trackId }) { singingStore.mutations.ERASE_PITCH_EDIT_DATA(draft, { startFrame, frameLength, + trackId, }); }, - action( - { commit, dispatch }, - { - startFrame, - frameLength, - }: { startFrame: number; frameLength: number }, - ) { + action({ commit, dispatch }, { startFrame, frameLength, trackId }) { if (startFrame < 0) { throw new Error("startFrame must be greater than or equal to 0."); } if (frameLength < 1) { throw new Error("frameLength must be at least 1."); } - commit("COMMAND_ERASE_PITCH_EDIT_DATA", { startFrame, frameLength }); + commit("COMMAND_ERASE_PITCH_EDIT_DATA", { + startFrame, + frameLength, + trackId, + }); + + dispatch("RENDER"); + }, + }, + + COMMAND_INSERT_EMPTY_TRACK: { + mutation(draft, { trackId, track, prevTrackId }) { + singingStore.mutations.INSERT_TRACK(draft, { + trackId, + track, + prevTrackId, + }); + }, + /** + * 空のトラックをprevTrackIdの後ろに挿入する。 + * prevTrackIdのトラックの情報を一部引き継ぐ。 + */ + async action({ state, dispatch, commit }, { prevTrackId }) { + const { trackId, track } = await dispatch("CREATE_TRACK"); + const sourceTrack = getOrThrow(state.tracks, prevTrackId); + track.singer = sourceTrack.singer; + track.keyRangeAdjustment = sourceTrack.keyRangeAdjustment; + track.volumeRangeAdjustment = sourceTrack.volumeRangeAdjustment; + commit("COMMAND_INSERT_EMPTY_TRACK", { + trackId, + track: cloneWithUnwrapProxy(track), + prevTrackId, + }); + }, + }, + + COMMAND_DELETE_TRACK: { + mutation(draft, { trackId }) { + singingStore.mutations.DELETE_TRACK(draft, { trackId }); + }, + action({ commit, dispatch }, { trackId }) { + commit("COMMAND_DELETE_TRACK", { trackId }); + + dispatch("RENDER"); + }, + }, + + COMMAND_SET_TRACK_NAME: { + mutation(draft, { trackId, name }) { + singingStore.mutations.SET_TRACK_NAME(draft, { trackId, name }); + }, + action({ commit }, { trackId, name }) { + commit("COMMAND_SET_TRACK_NAME", { trackId, name }); + }, + }, + + COMMAND_SET_TRACK_MUTE: { + mutation(draft, { trackId, mute }) { + singingStore.mutations.SET_TRACK_MUTE(draft, { trackId, mute }); + }, + action({ commit, dispatch }, { trackId, mute }) { + commit("COMMAND_SET_TRACK_MUTE", { trackId, mute }); + + dispatch("RENDER"); + }, + }, + + COMMAND_SET_TRACK_SOLO: { + mutation(draft, { trackId, solo }) { + singingStore.mutations.SET_TRACK_SOLO(draft, { trackId, solo }); + }, + action({ commit, dispatch }, { trackId, solo }) { + commit("COMMAND_SET_TRACK_SOLO", { trackId, solo }); + + dispatch("RENDER"); + }, + }, + + COMMAND_SET_TRACK_GAIN: { + mutation(draft, { trackId, gain }) { + singingStore.mutations.SET_TRACK_GAIN(draft, { trackId, gain }); + }, + action({ commit, dispatch }, { trackId, gain }) { + commit("COMMAND_SET_TRACK_GAIN", { trackId, gain }); + + dispatch("RENDER"); + }, + }, + + COMMAND_SET_TRACK_PAN: { + mutation(draft, { trackId, pan }) { + singingStore.mutations.SET_TRACK_PAN(draft, { trackId, pan }); + }, + action({ commit, dispatch }, { trackId, pan }) { + commit("COMMAND_SET_TRACK_PAN", { trackId, pan }); dispatch("RENDER"); }, }, + + COMMAND_REORDER_TRACKS: { + mutation(draft, { trackOrder }) { + singingStore.mutations.REORDER_TRACKS(draft, { trackOrder }); + }, + action({ commit }, { trackOrder }) { + commit("COMMAND_REORDER_TRACKS", { trackOrder }); + }, + }, + + COMMAND_UNSOLO_ALL_TRACKS: { + mutation(draft) { + singingStore.mutations.UNSOLO_ALL_TRACKS(draft, undefined); + }, + action({ commit }) { + commit("COMMAND_UNSOLO_ALL_TRACKS"); + }, + }, + + COMMAND_IMPORT_TRACKS: { + mutation(draft, { tpqn, tempos, timeSignatures, tracks }) { + singingStore.mutations.SET_TPQN(draft, { tpqn }); + singingStore.mutations.SET_TEMPOS(draft, { tempos }); + singingStore.mutations.SET_TIME_SIGNATURES(draft, { timeSignatures }); + for (const { track, trackId, overwrite, prevTrackId } of tracks) { + if (overwrite) { + singingStore.mutations.SET_TRACK(draft, { track, trackId }); + } else { + singingStore.mutations.INSERT_TRACK(draft, { + track, + trackId, + prevTrackId, + }); + } + } + }, + /** + * 複数のトラックを選択中のトラックの後ろに挿入し、テンポ情報などをインポートする。 + * 空のプロジェクトならトラックを上書きする。 + */ + async action( + { state, commit, getters, dispatch }, + { tpqn, tempos, timeSignatures, tracks }, + ) { + const payload: ({ track: Track; trackId: TrackId } & ( + | { overwrite: true; prevTrackId?: undefined } + | { overwrite?: false; prevTrackId: TrackId } + ))[] = []; + if (state.experimentalSetting.enableMultiTrack) { + let prevTrackId = getters.SELECTED_TRACK_ID; + for (const [i, track] of tracks.entries()) { + if (!isValidTrack(track)) { + throw new Error("The track is invalid."); + } + // 空のプロジェクトならトラックを上書きする + if (i === 0 && isTracksEmpty([...state.tracks.values()])) { + payload.push({ + track, + trackId: prevTrackId, + overwrite: true, + }); + } else { + const { trackId } = await dispatch("CREATE_TRACK"); + payload.push({ track, trackId, prevTrackId }); + prevTrackId = trackId; + } + } + } else { + // マルチトラックが無効な場合は最初のトラックのみをインポートする + payload.push({ + track: tracks[0], + trackId: getters.SELECTED_TRACK_ID, + overwrite: true, + }); + } + + commit("COMMAND_IMPORT_TRACKS", { + tpqn, + tempos, + timeSignatures, + tracks: payload, + }); + + dispatch("RENDER"); + }, + }, + + COMMAND_IMPORT_UTAFORMATIX_PROJECT: { + action: createUILockAction( + async ({ state, getters, dispatch }, { project, trackIndexes }) => { + const { tempos, timeSignatures, tracks, tpqn } = + ufProjectToVoicevox(project); + + if (tempos.length > 1) { + logger.warn("Multiple tempos are not supported."); + } + if (timeSignatures.length > 1) { + logger.warn("Multiple time signatures are not supported."); + } + + tempos.splice(1, tempos.length - 1); // TODO: 複数テンポに対応したら削除 + timeSignatures.splice(1, timeSignatures.length - 1); // TODO: 複数拍子に対応したら削除 + + if (tpqn !== state.tpqn) { + throw new Error("TPQN does not match. Must be converted."); + } + + const selectedTrack = getOrThrow( + state.tracks, + getters.SELECTED_TRACK_ID, + ); + + const filteredTracks = trackIndexes.map((trackIndex) => { + const track = tracks[trackIndex]; + if (!track) { + throw new Error("Track not found."); + } + return { + ...toRaw(selectedTrack), + notes: track.notes.map((note) => ({ + ...note, + id: NoteId(uuid4()), + })), + }; + }); + + await dispatch("COMMAND_IMPORT_TRACKS", { + tpqn, + tempos, + timeSignatures, + tracks: filteredTracks, + }); + + dispatch("RENDER"); + }, + ), + }, + + COMMAND_IMPORT_VOICEVOX_PROJECT: { + action: createUILockAction( + async ({ state, dispatch }, { project, trackIndexes }) => { + const { tempos, timeSignatures, tracks, tpqn, trackOrder } = + project.song; + + tempos.splice(1, tempos.length - 1); // TODO: 複数テンポに対応したら削除 + timeSignatures.splice(1, timeSignatures.length - 1); // TODO: 複数拍子に対応したら削除 + + if (tpqn !== state.tpqn) { + throw new Error("TPQN does not match. Must be converted."); + } + + const filteredTracks = trackIndexes.map((trackIndex) => { + const track = tracks[trackOrder[trackIndex]]; + if (!track) { + throw new Error("Track not found."); + } + return { + ...toRaw(track), + notes: track.notes.map((note) => ({ + ...note, + id: NoteId(uuid4()), + })), + }; + }); + + await dispatch("COMMAND_IMPORT_TRACKS", { + tpqn, + tempos, + timeSignatures, + tracks: filteredTracks, + }); + + dispatch("RENDER"); + }, + ), + }, }), "song", ); diff --git a/src/store/type.ts b/src/store/type.ts index b94864d7aa..b5963db96b 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -8,6 +8,7 @@ import { ActionsBase, StoreOptions, PayloadFunction, + Store, } from "./vuex"; import { createCommandMutationTree, PayloadRecipeTree } from "./command"; import { @@ -52,6 +53,8 @@ import { RootMiscSettingType, EditorType, NoteId, + CommandId, + TrackId, } from "@/type/preload"; import { IEngineConnectorFactory } from "@/infrastructures/EngineConnector"; import { @@ -94,7 +97,7 @@ export type FetchAudioResult = { }; export type Command = { - unixMillisec: number; + id: CommandId; undoPatches: Patch[]; redoPatches: Patch[]; }; @@ -120,6 +123,10 @@ export type ErrorTypeForSaveAllResultDialog = { message: string; }; +export type WatchStoreStatePlugin = ( + store: Store, +) => void; + export type StoreType = { [P in keyof T as Extract extends never ? never @@ -794,6 +801,7 @@ export type SingingVoiceSourceHash = z.infer< */ export type Phrase = { firstRestDuration: number; + trackId: TrackId; notes: Note[]; state: PhraseState; singingGuideKey?: SingingGuideSourceHash; @@ -805,6 +813,7 @@ export type Phrase = { */ export type PhraseSource = { firstRestDuration: number; + trackId: TrackId; notes: Note[]; }; @@ -817,7 +826,9 @@ export type SingingStoreState = { tpqn: number; // Ticks Per Quarter Note tempos: Tempo[]; timeSignatures: TimeSignature[]; - tracks: Track[]; + tracks: Map; + trackOrder: TrackId[]; + _selectedTrackId: TrackId; editFrameRate: number; phrases: Map; singingGuides: Map; @@ -827,8 +838,7 @@ export type SingingStoreState = { sequencerZoomY: number; sequencerSnapType: number; sequencerEditTarget: SequencerEditTarget; - selectedNoteIds: Set; - overlappingNoteIds: Set; + _selectedNoteIds: Set; editingLyricNoteId?: NoteId; nowPlaying: boolean; volume: number; @@ -837,6 +847,7 @@ export type SingingStoreState = { nowRendering: boolean; nowAudioExporting: boolean; cancellationOfAudioExportRequested: boolean; + isSongSidebarOpen: boolean; }; export type SingingStoreTypes = { @@ -845,23 +856,35 @@ export type SingingStoreTypes = { action(payload: { isShowSinger: boolean }): void; }; + SELECTED_TRACK_ID: { + getter: TrackId; + }; + + SELECTED_NOTE_IDS: { + getter: Set; + }; + SETUP_SINGER: { action(payload: { singer: Singer }): void; }; SET_SINGER: { - mutation: { singer?: Singer; withRelated?: boolean }; - action(payload: { singer?: Singer; withRelated?: boolean }): void; + mutation: { singer?: Singer; withRelated?: boolean; trackId: TrackId }; + action(payload: { + singer?: Singer; + withRelated?: boolean; + trackId: TrackId; + }): void; }; SET_KEY_RANGE_ADJUSTMENT: { - mutation: { keyRangeAdjustment: number }; - action(payload: { keyRangeAdjustment: number }): void; + mutation: { keyRangeAdjustment: number; trackId: TrackId }; + action(payload: { keyRangeAdjustment: number; trackId: TrackId }): void; }; SET_VOLUME_RANGE_ADJUSTMENT: { - mutation: { volumeRangeAdjustment: number }; - action(payload: { volumeRangeAdjustment: number }): void; + mutation: { volumeRangeAdjustment: number; trackId: TrackId }; + action(payload: { volumeRangeAdjustment: number; trackId: TrackId }): void; }; SET_TPQN: { @@ -895,25 +918,29 @@ export type SingingStoreTypes = { mutation: { measureNumber: number }; }; - NOTE_IDS: { + ALL_NOTE_IDS: { getter: Set; }; + OVERLAPPING_NOTE_IDS: { + getter(trackId: TrackId): Set; + }; + SET_NOTES: { - mutation: { notes: Note[] }; - action(payload: { notes: Note[] }): void; + mutation: { notes: Note[]; trackId: TrackId }; + action(payload: { notes: Note[]; trackId: TrackId }): void; }; ADD_NOTES: { - mutation: { notes: Note[] }; + mutation: { notes: Note[]; trackId: TrackId }; }; UPDATE_NOTES: { - mutation: { notes: Note[] }; + mutation: { notes: Note[]; trackId: TrackId }; }; REMOVE_NOTES: { - mutation: { noteIds: NoteId[] }; + mutation: { noteIds: NoteId[]; trackId: TrackId }; }; SELECT_NOTES: { @@ -921,9 +948,8 @@ export type SingingStoreTypes = { action(payload: { noteIds: NoteId[] }): void; }; - SELECT_ALL_NOTES: { - mutation: undefined; - action(): void; + SELECT_ALL_NOTES_IN_TRACK: { + action({ trackId }: { trackId: TrackId }): void; }; DESELECT_ALL_NOTES: { @@ -937,17 +963,21 @@ export type SingingStoreTypes = { }; SET_PITCH_EDIT_DATA: { - mutation: { data: number[]; startFrame: number }; - action(payload: { data: number[]; startFrame: number }): void; + mutation: { pitchArray: number[]; startFrame: number; trackId: TrackId }; + action(payload: { + pitchArray: number[]; + startFrame: number; + trackId: TrackId; + }): void; }; ERASE_PITCH_EDIT_DATA: { - mutation: { startFrame: number; frameLength: number }; + mutation: { startFrame: number; frameLength: number; trackId: TrackId }; }; CLEAR_PITCH_EDIT_DATA: { - mutation: undefined; - action(): void; + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; }; SET_PHRASES: { @@ -955,7 +985,10 @@ export type SingingStoreTypes = { }; SET_STATE_TO_PHRASE: { - mutation: { phraseKey: PhraseSourceHash; phraseState: PhraseState }; + mutation: { + phraseKey: PhraseSourceHash; + phraseState: PhraseState; + }; }; SET_SINGING_GUIDE_KEY_TO_PHRASE: { @@ -1016,14 +1049,6 @@ export type SingingStoreTypes = { action(payload: { isDrag: boolean }): void; }; - IMPORT_UTAFORMATIX_PROJECT: { - action(payload: { project: UfProject; trackIndex: number }): void; - }; - - IMPORT_VOICEVOX_PROJECT: { - action(payload: { project: LatestProjectType; trackIndex: number }): void; - }; - EXPORT_WAVE_FILE: { action(payload: { filePath?: string }): SaveResultObject; }; @@ -1133,6 +1158,92 @@ export type SingingStoreTypes = { COMMAND_QUANTIZE_SELECTED_NOTES: { action(): void; }; + + CREATE_TRACK: { + action(): { trackId: TrackId; track: Track }; + }; + + INSERT_TRACK: { + mutation: { + trackId: TrackId; + track: Track; + prevTrackId: TrackId | undefined; + }; + action(payload: { + trackId: TrackId; + track: Track; + prevTrackId: TrackId | undefined; + }): void; + }; + + DELETE_TRACK: { + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; + }; + + SELECT_TRACK: { + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; + }; + + SET_TRACK: { + mutation: { trackId: TrackId; track: Track }; + action(payload: { trackId: TrackId; track: Track }): void; + }; + + SET_TRACKS: { + mutation: { tracks: Map }; + action(payload: { tracks: Map }): Promise; + }; + + SET_SONG_SIDEBAR_OPEN: { + mutation: { isSongSidebarOpen: boolean }; + action(payload: { isSongSidebarOpen: boolean }): void; + }; + + SET_TRACK_NAME: { + mutation: { trackId: TrackId; name: string }; + action(payload: { trackId: TrackId; name: string }): void; + }; + + SET_TRACK_MUTE: { + mutation: { trackId: TrackId; mute: boolean }; + action(payload: { trackId: TrackId; mute: boolean }): void; + }; + + SET_TRACK_SOLO: { + mutation: { trackId: TrackId; solo: boolean }; + action(payload: { trackId: TrackId; solo: boolean }): void; + }; + + SET_TRACK_GAIN: { + mutation: { trackId: TrackId; gain: number }; + action(payload: { trackId: TrackId; gain: number }): void; + }; + + SET_TRACK_PAN: { + mutation: { trackId: TrackId; pan: number }; + action(payload: { trackId: TrackId; pan: number }): void; + }; + + SET_SELECTED_TRACK: { + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; + }; + + REORDER_TRACKS: { + mutation: { trackOrder: TrackId[] }; + action(payload: { trackOrder: TrackId[] }): void; + }; + + UNSOLO_ALL_TRACKS: { + mutation: undefined; + action(): void; + }; + + CALC_RENDER_DURATION: { + getter: number; + }; }; export type SingingCommandStoreState = { @@ -1141,18 +1252,22 @@ export type SingingCommandStoreState = { export type SingingCommandStoreTypes = { COMMAND_SET_SINGER: { - mutation: { singer: Singer; withRelated?: boolean }; - action(payload: { singer: Singer; withRelated?: boolean }): void; + mutation: { singer: Singer; withRelated?: boolean; trackId: TrackId }; + action(payload: { + singer: Singer; + withRelated?: boolean; + trackId: TrackId; + }): void; }; COMMAND_SET_KEY_RANGE_ADJUSTMENT: { - mutation: { keyRangeAdjustment: number }; - action(payload: { keyRangeAdjustment: number }): void; + mutation: { keyRangeAdjustment: number; trackId: TrackId }; + action(payload: { keyRangeAdjustment: number; trackId: TrackId }): void; }; COMMAND_SET_VOLUME_RANGE_ADJUSTMENT: { - mutation: { volumeRangeAdjustment: number }; - action(payload: { volumeRangeAdjustment: number }): void; + mutation: { volumeRangeAdjustment: number; trackId: TrackId }; + action(payload: { volumeRangeAdjustment: number; trackId: TrackId }): void; }; COMMAND_SET_TEMPO: { @@ -1176,18 +1291,18 @@ export type SingingCommandStoreTypes = { }; COMMAND_ADD_NOTES: { - mutation: { notes: Note[] }; - action(payload: { notes: Note[] }): void; + mutation: { notes: Note[]; trackId: TrackId }; + action(payload: { notes: Note[]; trackId: TrackId }): void; }; COMMAND_UPDATE_NOTES: { - mutation: { notes: Note[] }; - action(payload: { notes: Note[] }): void; + mutation: { notes: Note[]; trackId: TrackId }; + action(payload: { notes: Note[]; trackId: TrackId }): void; }; COMMAND_REMOVE_NOTES: { - mutation: { noteIds: NoteId[] }; - action(payload: { noteIds: NoteId[] }): void; + mutation: { noteIds: NoteId[]; trackId: TrackId }; + action(payload: { noteIds: NoteId[]; trackId: TrackId }): void; }; COMMAND_REMOVE_SELECTED_NOTES: { @@ -1195,13 +1310,99 @@ export type SingingCommandStoreTypes = { }; COMMAND_SET_PITCH_EDIT_DATA: { - mutation: { data: number[]; startFrame: number }; - action(payload: { data: number[]; startFrame: number }): void; + mutation: { pitchArray: number[]; startFrame: number; trackId: TrackId }; + action(payload: { + pitchArray: number[]; + startFrame: number; + trackId: TrackId; + }): void; }; COMMAND_ERASE_PITCH_EDIT_DATA: { - mutation: { startFrame: number; frameLength: number }; - action(payload: { startFrame: number; frameLength: number }): void; + mutation: { startFrame: number; frameLength: number; trackId: TrackId }; + action(payload: { + startFrame: number; + frameLength: number; + trackId: TrackId; + }): void; + }; + + COMMAND_INSERT_EMPTY_TRACK: { + mutation: { + trackId: TrackId; + track: Track; + prevTrackId: TrackId; + }; + action(payload: { prevTrackId: TrackId }): void; + }; + + COMMAND_DELETE_TRACK: { + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; + }; + + COMMAND_SET_TRACK_NAME: { + mutation: { trackId: TrackId; name: string }; + action(payload: { trackId: TrackId; name: string }): void; + }; + + COMMAND_SET_TRACK_MUTE: { + mutation: { trackId: TrackId; mute: boolean }; + action(payload: { trackId: TrackId; mute: boolean }): void; + }; + + COMMAND_SET_TRACK_SOLO: { + mutation: { trackId: TrackId; solo: boolean }; + action(payload: { trackId: TrackId; solo: boolean }): void; + }; + + COMMAND_SET_TRACK_GAIN: { + mutation: { trackId: TrackId; gain: number }; + action(payload: { trackId: TrackId; gain: number }): void; + }; + + COMMAND_SET_TRACK_PAN: { + mutation: { trackId: TrackId; pan: number }; + action(payload: { trackId: TrackId; pan: number }): void; + }; + + COMMAND_REORDER_TRACKS: { + mutation: { trackOrder: TrackId[] }; + action(payload: { trackOrder: TrackId[] }): void; + }; + + COMMAND_UNSOLO_ALL_TRACKS: { + mutation: undefined; + action(): void; + }; + + COMMAND_IMPORT_TRACKS: { + mutation: { + tpqn: number; + tempos: Tempo[]; + timeSignatures: TimeSignature[]; + tracks: ({ track: Track; trackId: TrackId } & ( + | { overwrite: true; prevTrackId?: undefined } + | { overwrite?: false; prevTrackId: TrackId } + ))[]; + }; + action(payload: { + tpqn: number; + tempos: Tempo[]; + timeSignatures: TimeSignature[]; + tracks: Track[]; + }): void; + }; + + COMMAND_IMPORT_UTAFORMATIX_PROJECT: { + action(payload: { project: UfProject; trackIndexes: number[] }): void; + }; + + COMMAND_IMPORT_VOICEVOX_PROJECT: { + action(payload: { + project: LatestProjectType; + trackIndexes: number[]; + }): void; }; }; @@ -1233,8 +1434,8 @@ export type CommandStoreTypes = { action(payload: { editor: EditorType }): void; }; - LAST_COMMAND_UNIX_MILLISEC: { - getter: number | null; + LAST_COMMAND_IDS: { + getter: Record; }; CLEAR_COMMANDS: { @@ -1469,7 +1670,7 @@ export type IndexStoreTypes = { export type ProjectStoreState = { projectFilePath?: string; - savedLastCommandUnixMillisec: number | null; + savedLastCommandIds: Record; }; export type ProjectStoreTypes = { @@ -1511,8 +1712,16 @@ export type ProjectStoreTypes = { getter: boolean; }; - SET_SAVED_LAST_COMMAND_UNIX_MILLISEC: { - mutation: number | null; + SET_SAVED_LAST_COMMAND_IDS: { + mutation: Record; + }; + + RESET_SAVED_LAST_COMMAND_IDS: { + mutation: void; + }; + + CLEAR_UNDO_HISTORY: { + action(): void; }; }; diff --git a/src/styles/_index.scss b/src/styles/_index.scss index f598a5995a..dad189293b 100644 --- a/src/styles/_index.scss +++ b/src/styles/_index.scss @@ -1,5 +1,7 @@ -@use "./variables" as vars; -@use "./colors" as colors; +@use './variables' as vars; +@use './colors' as colors; +@use './v2/variables' as vars-v2; +@use './v2/colors' as colors-v2; @import "./fonts"; // 優先度を強引に上げる @@ -20,14 +22,6 @@ img { pointer-events: none; } -// detailsタグのスタイル -details { - summary { - display: list-item; - cursor: pointer; - } -} - // スクロールバーのデザイン ::-webkit-scrollbar { width: 12px; diff --git a/src/styles/v2/colors.scss b/src/styles/v2/colors.scss new file mode 100644 index 0000000000..0be6e9fc8c --- /dev/null +++ b/src/styles/v2/colors.scss @@ -0,0 +1,114 @@ +// 新デザインで使用する色変数の定義 +// 詳細: https://github.com/VOICEVOX/voicevox_project/issues/40 + +// 基本色 +$primitive-black: #242626; +$primitive-white: #ffffff; +$primitive-primary: #a5d4ad; +$primitive-blue: #0969da; +$primitive-red: #d04756; + +// ライトテーマの色 +:root[is-dark-theme="false"] { + --color-v2-background: #{lighten($primitive-primary, 25%)}; + --color-v2-background-drawer: #{rgba(lighten($primitive-primary, 20%), 0.75)}; + + --color-v2-surface: #{$primitive-white}; + --color-v2-border: #{rgba($primitive-black, 0.2)}; + --color-v2-selected: #{rgba($primitive-primary, 0.3)}; + + --color-v2-display: #{lighten($primitive-black, 10%)}; + --color-v2-display-oncolor: #{lighten($primitive-black, 10%)}; + --color-v2-display-sub: #{rgba($primitive-black, 0.5)}; + --color-v2-display-link: #{$primitive-blue}; + --color-v2-display-warning: #{$primitive-red}; + + --color-v2-control: #{$primitive-white}; + --color-v2-control-hovered: #{darken($primitive-white, 5%)}; + --color-v2-control-pressed: #{darken($primitive-white, 10%)}; + + --color-v2-clear: #{rgba($primitive-black, 0)}; + --color-v2-clear-hovered: #{rgba($primitive-black, 0.05)}; + --color-v2-clear-pressed: #{rgba($primitive-black, 0.1)}; + + --color-v2-primary: #{$primitive-primary}; + --color-v2-primary-hovered: #{darken($primitive-primary, 5%)}; + --color-v2-primary-pressed: #{darken($primitive-primary, 10%)}; + + --color-v2-warning: #{$primitive-white}; + --color-v2-warning-hovered: #{lighten($primitive-red, 40%)}; + --color-v2-warning-pressed: #{lighten($primitive-red, 35%)}; + + --color-v2-scrollbar: #{rgba($primitive-black, 0.3)}; + --color-v2-scrollbar-hovered: #{rgba($primitive-black, 0.4)}; + --color-v2-scrollbar-pressed: #{rgba($primitive-black, 0.5)}; +} + +// ダークテーマの色 +:root[is-dark-theme="true"] { + --color-v2-background: #{$primitive-black}; + --color-v2-background-drawer: #{rgba($primitive-black, 0.75)}; + + --color-v2-surface: #{lighten($primitive-black, 3%)}; + --color-v2-border: #{rgba($primitive-white, 0.2)}; + --color-v2-selected: #{rgba($primitive-primary, 0.3)}; + + --color-v2-display: #{$primitive-white}; + --color-v2-display-oncolor: #{$primitive-black}; + --color-v2-display-sub: #{rgba($primitive-white, 0.5)}; + --color-v2-display-link: #{lighten($primitive-blue, 25%)}; + --color-v2-display-warning: #{lighten($primitive-red, 25%)}; + + --color-v2-control: #{lighten($primitive-black, 10%)}; + --color-v2-control-hovered: #{lighten($primitive-black, 15%)}; + --color-v2-control-pressed: #{lighten($primitive-black, 20%)}; + + --color-v2-clear: #{rgba($primitive-white, 0)}; + --color-v2-clear-hovered: #{rgba($primitive-white, 0.05)}; + --color-v2-clear-pressed: #{rgba($primitive-white, 0.1)}; + + --color-v2-primary: #{darken($primitive-primary, 10%)}; + --color-v2-primary-hovered: #{darken($primitive-primary, 5%)}; + --color-v2-primary-pressed: #{$primitive-primary}; + + --color-v2-warning: #{lighten($primitive-black, 10%)}; + --color-v2-warning-hovered: #{darken($primitive-red, 35%)}; + --color-v2-warning-pressed: #{darken($primitive-red, 30%)}; + + --color-v2-scrollbar: #{rgba($primitive-white, 0.3)}; + --color-v2-scrollbar-hovered: #{rgba($primitive-white, 0.4)}; + --color-v2-scrollbar-pressed: #{rgba($primitive-white, 0.5)}; +} + +$background: var(--color-v2-background); +$background-drawer: var(--color-v2-background-drawer); + +$surface: var(--color-v2-surface); +$border: var(--color-v2-border); +$selected: var(--color-v2-selected); + +$display: var(--color-v2-display); +$display-oncolor: var(--color-v2-display-oncolor); +$display-sub: var(--color-v2-display-sub); +$display-link: var(--color-v2-display-link); +$display-warning: var(--color-v2-display-warning); + +$control: var(--color-v2-control); +$control-hovered: var(--color-v2-control-hovered); +$control-pressed: var(--color-v2-control-pressed); + +$clear: var(--color-v2-clear); +$clear-hovered: var(--color-v2-clear-hovered); +$clear-pressed: var(--color-v2-clear-pressed); + +$primary: var(--color-v2-primary); +$primary-hovered: var(--color-v2-primary-hovered); +$primary-pressed: var(--color-v2-primary-pressed); + +$warning: var(--color-v2-warning); +$warning-hovered: var(--color-v2-warning-hovered); +$warning-pressed: var(--color-v2-warning-pressed); + +$scrollbar: var(--color-v2-scrollbar); +$scrollbar-hovered: var(--color-v2-scrollbar-hovered); +$scrollbar-pressed: var(--color-v2-scrollbar-pressed); diff --git a/src/styles/v2/mixin.scss b/src/styles/v2/mixin.scss new file mode 100644 index 0000000000..e547874fa0 --- /dev/null +++ b/src/styles/v2/mixin.scss @@ -0,0 +1,53 @@ +// キーボードフォーカス時のOutline表示用のスタイル +@mixin on-focus { + outline-color: #a5d4ad; + outline-width: 2px; +} + +// 見出し1(h1)用のスタイル +@mixin headline-1 { + font-size: 1.75rem; + font-weight: 600; + line-height: 1.5; + margin: 0; +} + +// 見出し2(h2)用のスタイル +@mixin headline-2 { + font-size: 1.25rem; + font-weight: bold; + line-height: 1.5; + margin: 0; +} + +// 見出し3(h3)用のスタイル +@mixin headline-3 { + font-size: 1.125rem; + font-weight: bold; + line-height: 1.5; + margin: 0; +} + +// 見出し4(h4)用のスタイル +@mixin headline-4 { + font-size: 1rem; + font-weight: bold; + line-height: 1.5; + margin: 0; +} + +// 見出し5(h5)用のスタイル +@mixin headline-5 { + font-size: 0.875rem; + font-weight: bold; + line-height: 1.5; + margin: 0; +} + +// 見出し6(h6)用のスタイル +@mixin headline-6 { + font-size: 0.75rem; + font-weight: bold; + line-height: 1.5; + margin: 0; +} diff --git a/src/styles/v2/variables.scss b/src/styles/v2/variables.scss new file mode 100644 index 0000000000..e61d32c06d --- /dev/null +++ b/src/styles/v2/variables.scss @@ -0,0 +1,24 @@ +:root { + --size-basis: 8px; + --padding-basis: 8px; + --gap-basis: 8px; + --radius-basis: 8px; +} + +$size-scrollbar: calc(var(--size-basis) * 1.5); +$size-icon: calc(var(--size-basis) * 2); +$size-indicator: calc(var(--size-basis) * 3); +$size-control: calc(var(--size-basis) * 4); +$size-listitem: calc(var(--size-basis) * 5); +$size-fab: calc(var(--size-basis) * 6); + +$padding-1: var(--padding-basis); +$padding-2: calc(var(--padding-basis) * 2); + +$gap-1: var(--gap-basis); +$gap-2: calc(var(--gap-basis) * 2); + +$radius-1: var(--radius-basis); +$radius-2: calc(var(--radius-basis) * 2); + +$transition-duration: 100ms; diff --git a/src/type/preload.ts b/src/type/preload.ts index 01a3bdecb7..b23fca98dc 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -69,6 +69,14 @@ export const noteIdSchema = z.string().brand<"NoteId">(); export type NoteId = z.infer; export const NoteId = (id: string): NoteId => noteIdSchema.parse(id); +export const commandIdSchema = z.string().brand<"CommandId">(); +export type CommandId = z.infer; +export const CommandId = (id: string): CommandId => commandIdSchema.parse(id); + +export const trackIdSchema = z.string().brand<"TrackId">(); +export type TrackId = z.infer; +export const TrackId = (id: string): TrackId => trackIdSchema.parse(id); + // 共通のアクション名 export const actionPostfixSelectNthCharacter = "番目のキャラクターを選択"; @@ -564,7 +572,7 @@ export const experimentalSettingSchema = z.object({ enableMorphing: z.boolean().default(false), enableMultiSelect: z.boolean().default(false), shouldKeepTuningOnTextChange: z.boolean().default(false), - enablePitchEditInSongEditor: z.boolean().default(false), + enableMultiTrack: z.boolean().default(false), }); export type ExperimentalSettingType = z.infer; @@ -595,6 +603,12 @@ export const rootMiscSettingSchema = z.object({ enableMemoNotation: z.boolean().default(false), // メモ記法を有効にするか enableRubyNotation: z.boolean().default(false), // ルビ記法を有効にするか skipUpdateVersion: z.string().optional(), // アップデートをスキップしたバージョン + undoableTrackOperations: z // ソングエディタでどのトラック操作をUndo可能にするか + .object({ + soloAndMute: z.boolean().default(true), + panAndGain: z.boolean().default(true), + }) + .default({}), }); export type RootMiscSettingType = z.infer; diff --git "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-1-browser-win32.png" "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-1-browser-win32.png" index 571f3498bb..7cc19f187f 100644 Binary files "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-1-browser-win32.png" and "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-1-browser-win32.png" differ diff --git "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-2-browser-win32.png" "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-2-browser-win32.png" index 2f821183c8..6b7839b4a1 100644 Binary files "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-2-browser-win32.png" and "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-2-browser-win32.png" differ diff --git "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-3-browser-win32.png" "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-3-browser-win32.png" index 9d8a1807ac..af436cdddf 100644 Binary files "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-3-browser-win32.png" and "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-3-browser-win32.png" differ diff --git "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-4-browser-win32.png" "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-4-browser-win32.png" index 9d8a1807ac..af436cdddf 100644 Binary files "a/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-4-browser-win32.png" and "b/tests/e2e/browser/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260/\350\250\255\345\256\232\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts-snapshots/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210-4-browser-win32.png" differ diff --git a/tests/unit/components/help/HelpMarkdownViewSection.spec.ts b/tests/unit/components/help/HelpMarkdownViewSection.spec.ts new file mode 100644 index 0000000000..2e6a024e0e --- /dev/null +++ b/tests/unit/components/help/HelpMarkdownViewSection.spec.ts @@ -0,0 +1,32 @@ +import { mount, flushPromises } from "@vue/test-utils"; +import { describe, it } from "vitest"; +import HelpMarkdownViewSection from "@/components/Dialog/HelpDialog/HelpMarkdownViewSection.vue"; +import { markdownItPlugin } from "@/plugins/markdownItPlugin"; + +describe("HelpMarkdownViewSection.vue", () => { + it("can mount", () => { + mount(HelpMarkdownViewSection, { + global: { + plugins: [markdownItPlugin], + }, + props: { + markdown: "", + }, + }); + }); + + it("has markdown text", async () => { + const wrapper = mount(HelpMarkdownViewSection, { + global: { + plugins: [markdownItPlugin], + }, + props: { + markdown: "# title", + }, + }); + + await flushPromises(); + + expect(wrapper.find("h1").text()).to.equal("title"); + }); +}); diff --git a/tests/unit/components/help/HowToUse.spec.ts b/tests/unit/components/help/HowToUse.spec.ts deleted file mode 100644 index c060633099..0000000000 --- a/tests/unit/components/help/HowToUse.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { mount, flushPromises } from "@vue/test-utils"; -import { createStore } from "vuex"; -import { describe, it } from "vitest"; -import { Quasar } from "quasar"; -import { wrapQPage } from "../../utils"; -import HowToUse from "@/components/Dialog/HelpDialog/HowToUse.vue"; -import { markdownItPlugin } from "@/plugins/markdownItPlugin"; -import { storeKey } from "@/store"; - -const store = createStore({ - actions: { - GET_HOW_TO_USE_TEXT: async () => { - return "test string"; - }, - }, -}); - -describe("HowToUse.vue", () => { - it("can mount", () => { - mount(wrapQPage(HowToUse), { - global: { - plugins: [markdownItPlugin, [store, storeKey], Quasar], - }, - }); - }); - - it("has how to use text", async () => { - const wrapper = mount(wrapQPage(HowToUse), { - global: { - plugins: [markdownItPlugin, [store, storeKey], Quasar], - }, - }); - - await flushPromises(); - - expect(wrapper.find(".markdown").text()).to.equal("test string"); - }); -}); diff --git a/tests/unit/domain/sing/shouldPlayTracks.spec.ts b/tests/unit/domain/sing/shouldPlayTracks.spec.ts new file mode 100644 index 0000000000..0dd166a18e --- /dev/null +++ b/tests/unit/domain/sing/shouldPlayTracks.spec.ts @@ -0,0 +1,46 @@ +import { createDefaultTrack, shouldPlayTracks } from "@/sing/domain"; +import { Track } from "@/store/type"; +import { TrackId } from "@/type/preload"; + +const createTrack = ({ solo, mute }: { solo: boolean; mute: boolean }) => { + const track = createDefaultTrack(); + track.solo = solo; + track.mute = mute; + return track; +}; +const toTracksMap = (tracks: Track[]) => { + return tracks.reduce((acc, track) => { + acc.set(TrackId(crypto.randomUUID()), track); + return acc; + }, new Map()); +}; + +describe("shouldPlayTracks", () => { + it("ソロのトラックが存在する場合はソロのトラックのみ再生する(ミュートは無視される)", () => { + const tracks = toTracksMap([ + createTrack({ solo: false, mute: false }), + createTrack({ solo: false, mute: false }), + createTrack({ solo: true, mute: false }), + createTrack({ solo: true, mute: false }), + createTrack({ solo: false, mute: true }), + createTrack({ solo: false, mute: true }), + ]); + const trackIds = [...tracks.keys()]; + + const result = shouldPlayTracks(tracks); + expect([...result]).toEqual([trackIds[2], trackIds[3]]); + }); + + it("ソロのトラックが存在しない場合はミュートされていないトラックを再生する", () => { + const tracks = toTracksMap([ + createTrack({ solo: false, mute: false }), + createTrack({ solo: false, mute: false }), + createTrack({ solo: false, mute: true }), + createTrack({ solo: false, mute: true }), + ]); + const trackIds = [...tracks.keys()]; + + const result = shouldPlayTracks(tracks); + expect([...result]).toEqual([trackIds[0], trackIds[1]]); + }); +}); diff --git a/tests/unit/lib/cloneWithUnwrapProxy.spec.ts b/tests/unit/lib/cloneWithUnwrapProxy.spec.ts new file mode 100644 index 0000000000..2df56b2203 --- /dev/null +++ b/tests/unit/lib/cloneWithUnwrapProxy.spec.ts @@ -0,0 +1,29 @@ +import { test } from "vitest"; +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; + +const original = { a: 1, b: { c: 2 } }; + +const outerProxied = new Proxy(original, {}); + +const innerProxied = cloneWithUnwrapProxy(original); +innerProxied.b = new Proxy(original.b, {}); + +test("Proxyがあってもクローンできる", () => { + const cloned = cloneWithUnwrapProxy(outerProxied); + + expect(cloned).toEqual(original); +}); + +test("内部にProxyがあってもクローンできる", () => { + const cloned = cloneWithUnwrapProxy(innerProxied); + + expect(cloned).toEqual(original); +}); + +test("structuredCloneでは出来ないことを確認する", () => { + expect(() => structuredClone(outerProxied)).toThrow(); +}); + +test("structuredCloneでは内部にProxyがあるときも出来ないことを確認する", () => { + expect(() => structuredClone(innerProxied)).toThrow(); +}); diff --git a/tests/unit/lib/selectPriorPhrase.spec.ts b/tests/unit/lib/selectPriorPhrase.spec.ts index 4f8b2e2580..8dc7d0f4f2 100644 --- a/tests/unit/lib/selectPriorPhrase.spec.ts +++ b/tests/unit/lib/selectPriorPhrase.spec.ts @@ -6,7 +6,10 @@ import { phraseSourceHashSchema, } from "@/store/type"; import { DEFAULT_TPQN, selectPriorPhrase } from "@/sing/domain"; -import { NoteId } from "@/type/preload"; +import { NoteId, TrackId } from "@/type/preload"; +import { uuid4 } from "@/helpers/random"; + +const trackId = TrackId("00000000-0000-0000-0000-000000000000"); const createPhrase = ( firstRestDuration: number, @@ -15,10 +18,11 @@ const createPhrase = ( state: PhraseState, ): Phrase => { return { + trackId, firstRestDuration: firstRestDuration * DEFAULT_TPQN, notes: [ { - id: NoteId(crypto.randomUUID()), + id: NoteId(uuid4()), position: start * DEFAULT_TPQN, duration: (end - start) * DEFAULT_TPQN, noteNumber: 60, diff --git a/tests/unit/lib/utaformatixProject/export.spec.ts b/tests/unit/lib/utaformatixProject/export.spec.ts index 1fad3469b5..5193905dc7 100644 --- a/tests/unit/lib/utaformatixProject/export.spec.ts +++ b/tests/unit/lib/utaformatixProject/export.spec.ts @@ -6,8 +6,9 @@ import { createDefaultTrack, } from "@/sing/domain"; import { NoteId } from "@/type/preload"; +import { uuid4 } from "@/helpers/random"; -const createNoteId = () => NoteId(crypto.randomUUID()); +const createNoteId = () => NoteId(uuid4()); it("トラックを変換できる", async () => { const track = createDefaultTrack(); diff --git a/tests/unit/store/__snapshots__/command.spec.ts.snap b/tests/unit/store/__snapshots__/command.spec.ts.snap new file mode 100644 index 0000000000..6d2a116d0c --- /dev/null +++ b/tests/unit/store/__snapshots__/command.spec.ts.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`コマンド実行で履歴が作られる 1`] = ` +{ + "audioKeys": [ + "00000000-0000-4000-0000-000000000001", + ], + "redoCommands": { + "song": [], + "talk": [], + }, + "undoCommands": { + "song": [], + "talk": [ + { + "id": "00000000-0000-4000-0000-000000000002", + "redoPatches": [ + { + "op": "replace", + "path": [ + "audioKeys", + ], + "value": [ + "00000000-0000-4000-0000-000000000001", + ], + }, + ], + "undoPatches": [ + { + "op": "replace", + "path": [ + "audioKeys", + ], + "value": [], + }, + ], + }, + ], + }, +} +`; diff --git a/tests/unit/store/command.spec.ts b/tests/unit/store/command.spec.ts new file mode 100644 index 0000000000..7cc4a3a800 --- /dev/null +++ b/tests/unit/store/command.spec.ts @@ -0,0 +1,19 @@ +import { store } from "@/store"; +import { AudioKey } from "@/type/preload"; +import { resetMockMode, uuid4 } from "@/helpers/random"; +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; + +const initialState = cloneWithUnwrapProxy(store.state); +beforeEach(() => { + store.replaceState(initialState); + + resetMockMode(); +}); + +test("コマンド実行で履歴が作られる", async () => { + await store.dispatch("COMMAND_SET_AUDIO_KEYS", { + audioKeys: [AudioKey(uuid4())], + }); + const { audioKeys, redoCommands, undoCommands } = store.state; + expect({ audioKeys, redoCommands, undoCommands }).toMatchSnapshot(); +}); diff --git a/tests/unit/store/singing.spec.ts b/tests/unit/store/singing.spec.ts new file mode 100644 index 0000000000..837fdf9ea3 --- /dev/null +++ b/tests/unit/store/singing.spec.ts @@ -0,0 +1,35 @@ +import { store } from "@/store"; +import { TrackId } from "@/type/preload"; +import { resetMockMode, uuid4 } from "@/helpers/random"; +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; +import { createDefaultTrack } from "@/sing/domain"; + +const initialState = cloneWithUnwrapProxy(store.state); +beforeEach(() => { + store.replaceState(initialState); + + resetMockMode(); +}); + +test("INSERT_TRACK", () => { + const dummyTrack = createDefaultTrack(); + + // 最後尾に追加 + // NOTE: 最初から1つトラックが登録されている + const trackId1 = TrackId(uuid4()); + store.commit("INSERT_TRACK", { + trackId: trackId1, + track: dummyTrack, + prevTrackId: undefined, + }); + expect(store.state.trackOrder.slice(1)).toEqual([trackId1]); + + // 途中に追加 + const trackId2 = TrackId(uuid4()); + store.commit("INSERT_TRACK", { + trackId: trackId2, + track: dummyTrack, + prevTrackId: store.state.trackOrder[0], + }); + expect(store.state.trackOrder.slice(1)).toEqual([trackId2, trackId1]); +}); diff --git a/tests/unit/store/utility.spec.ts b/tests/unit/store/utility.spec.ts index c4a8907dc9..cb6430de9e 100644 --- a/tests/unit/store/utility.spec.ts +++ b/tests/unit/store/utility.spec.ts @@ -22,6 +22,7 @@ import { isOnCommandOrCtrlKeyDown, filterCharacterInfosByStyleType, } from "@/store/utility"; +import { uuid4 } from "@/helpers/random"; function createDummyMora(text: string): Mora { return { @@ -305,13 +306,13 @@ describe("filterCharacterInfosByStyleType", () => { const createCharacterInfo = ( styleTypes: (undefined | "talk" | "frame_decode" | "sing")[], ): CharacterInfo => { - const engineId = EngineId(crypto.randomUUID()); + const engineId = EngineId(uuid4()); return { portraitPath: "path/to/portrait", metas: { policy: "policy", speakerName: "speakerName", - speakerUuid: SpeakerId(crypto.randomUUID()), + speakerUuid: SpeakerId(uuid4()), styles: styleTypes.map((styleType) => ({ styleType, styleName: "styleName",