diff --git a/package-lock.json b/package-lock.json index fb8fe9e771..105cebdbe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "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", @@ -3069,6 +3070,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fal-works/esbuild-plugin-global-externals": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz", + "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", @@ -3126,12 +3133,6 @@ } } }, - "node_modules/@fal-works/esbuild-plugin-global-externals": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz", - "integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==", - "dev": true - }, "node_modules/@gtm-support/core": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-1.1.0.tgz", @@ -20323,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 67f1c4d03b..1b21df166e 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "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/src/components/Dialog/ImportSongProjectDialog.vue b/src/components/Dialog/ImportSongProjectDialog.vue index 8bc528f960..275606382d 100644 --- a/src/components/Dialog/ImportSongProjectDialog.vue +++ b/src/components/Dialog/ImportSongProjectDialog.vue @@ -1,14 +1,15 @@ @@ -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(); @@ -303,11 +355,20 @@ const handleCancel = () => { diff --git a/src/components/Dialog/SettingDialog/SettingDialog.vue b/src/components/Dialog/SettingDialog/SettingDialog.vue index 75574e3a90..d74ef2b31b 100644 --- a/src/components/Dialog/SettingDialog/SettingDialog.vue +++ b/src/components/Dialog/SettingDialog/SettingDialog.vue @@ -229,6 +229,18 @@ > + + + @@ -513,6 +525,17 @@ ) " /> + + + 現在のプロジェクトに複数のトラックが存在するため、無効化できません。 + + @@ -537,6 +560,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 +631,30 @@ const isDefaultConfirmedTips = computed(() => { return Object.values(confirmedTips).every((v) => !v); }); +// ソング:元に戻す対象のトラックの設定 +type SongUndoableTrackOption = + keyof RootMiscSettingType["songUndoableTrackOptions"]; +const songUndoableTrackOptionLabels = [ + { value: "soloAndMute", label: "ミュート・ソロ" }, + { value: "panAndGain", label: "パン・音量" }, +]; +const songUndoableTrackOptions = computed({ + get: () => + Object.keys(store.state.songUndoableTrackOptions).filter( + (key) => + store.state.songUndoableTrackOptions[key as SongUndoableTrackOption], + ) as SongUndoableTrackOption[], + set: (songUndoableTrackOptions: SongUndoableTrackOption[]) => { + store.dispatch("SET_ROOT_MISC_SETTING", { + key: "songUndoableTrackOptions", + value: { + soloAndMute: songUndoableTrackOptions.includes("soloAndMute"), + panAndGain: songUndoableTrackOptions.includes("panAndGain"), + }, + }); + }, +}); + // 外観 const currentThemeNameComputed = computed({ get: () => store.state.themeSetting.currentTheme, @@ -860,6 +908,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 a3717121ab..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 = @@ -242,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) { @@ -302,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); @@ -359,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, + ); }); // 入力を補助する線 @@ -734,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) { @@ -813,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", { @@ -845,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); @@ -871,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); } }; @@ -893,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); } }; @@ -905,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); } }; @@ -1003,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 && @@ -1037,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", { @@ -1056,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", { @@ -1076,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 = () => { @@ -1091,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; } @@ -1219,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; @@ -1309,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"); @@ -1323,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"); @@ -1348,7 +1429,9 @@ registerHotkeyWithCleanup({ if (nowPreviewing.value) { return; } - store.dispatch("SELECT_ALL_NOTES"); + store.dispatch("SELECT_ALL_NOTES_IN_TRACK", { + trackId: selectedTrackId.value, + }); }, }); @@ -1391,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..3714ed5ba7 --- /dev/null +++ b/src/components/Sing/SideBar/SideBar.vue @@ -0,0 +1,156 @@ + + + diff --git a/src/components/Sing/SideBar/TrackItem.vue b/src/components/Sing/SideBar/TrackItem.vue new file mode 100644 index 0000000000..a893683268 --- /dev/null +++ b/src/components/Sing/SideBar/TrackItem.vue @@ -0,0 +1,367 @@ + + + diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index b2c8999867..354aed8b0a 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -1,6 +1,6 @@ @@ -27,6 +45,7 @@ import { computed, ref, watch } from "vue"; import ToolBar from "./ToolBar/ToolBar.vue"; import ScoreSequencer from "./ScoreSequencer.vue"; +import SideBar from "./SideBar/SideBar.vue"; import EngineStartupOverlay from "@/components/EngineStartupOverlay.vue"; import { useStore } from "@/store"; import onetimeWatch from "@/helpers/onetimeWatch"; @@ -42,7 +61,19 @@ const props = defineProps<{ }>(); const store = useStore(); -//const $q = useQuasar(); + +const isSidebarOpen = computed( + () => + store.state.experimentalSetting.enableMultiTrack && + store.state.isSongSidebarOpen, +); +const sidebarWidth = ref(300); + +const setSidebarWidth = (width: number) => { + if (isSidebarOpen.value) { + sidebarWidth.value = width; + } +}; const nowRendering = computed(() => { return store.state.nowRendering; @@ -80,11 +111,15 @@ onetimeWatch( await store.dispatch("SET_TIME_SIGNATURES", { timeSignatures: [createDefaultTimeSignature(1)], }); - await store.dispatch("SET_NOTES", { notes: [] }); + const trackId = store.state.trackOrder[0]; + await store.dispatch("SET_NOTES", { notes: [], trackId }); // CI上のe2eテストのNemoエンジンには歌手がいないためエラーになるのでワークアラウンド // FIXME: 歌手をいると見せかけるmock APIを作り、ここのtry catchを削除する try { - await store.dispatch("SET_SINGER", { withRelated: true }); + await store.dispatch("SET_SINGER", { + trackId, + withRelated: true, + }); } catch (e) { window.backend.logError(e); } diff --git a/src/components/Sing/SingerIcon.vue b/src/components/Sing/SingerIcon.vue new file mode 100644 index 0000000000..f2c3dbe702 --- /dev/null +++ b/src/components/Sing/SingerIcon.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index ef6edf207a..1f8a50dea6 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, @@ -210,6 +222,13 @@ 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 +237,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 +346,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/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 04a80a6244..a1f6e2f226 100644 --- a/src/sing/utaformatixProject/toVoicevox.ts +++ b/src/sing/utaformatixProject/toVoicevox.ts @@ -88,6 +88,7 @@ export const ufProjectToVoicevox = (project: UfProject): VoicevoxScore => { return { ...createDefaultTrack(), + name: projectTrack.name, notes, }; }); diff --git a/src/store/project.ts b/src/store/project.ts index 87535320bd..438e88fa75 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -10,6 +10,7 @@ import { ProjectStoreState, ProjectStoreTypes, } from "@/store/type"; +import { TrackId } from "@/type/preload"; import { getValueOrThrow, ResultError } from "@/type/result"; import { LatestProjectType } from "@/domain/project/schema"; @@ -20,6 +21,7 @@ import { import { createDefaultTempo, createDefaultTimeSignature, + createDefaultTrack, DEFAULT_TPQN, } from "@/sing/domain"; import { EditorType } from "@/type/preload"; @@ -55,25 +57,19 @@ const applySongProjectToStore = async ( actions: DotNotationDispatch, songProject: LatestProjectType["song"], ) => { - const { tpqn, tempos, timeSignatures, tracks } = songProject; - // TODO: マルチトラック対応 - await actions.SET_SINGER({ - singer: tracks[0].singer, - }); - await actions.SET_KEY_RANGE_ADJUSTMENT({ - keyRangeAdjustment: tracks[0].keyRangeAdjustment, - }); - await actions.SET_VOLUME_RANGE_ADJUSTMENT({ - volumeRangeAdjustment: tracks[0].volumeRangeAdjustment, - }); + 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_NOTES({ notes: tracks[0].notes }); - await actions.CLEAR_PITCH_EDIT_DATA(); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 - await actions.SET_PITCH_EDIT_DATA({ - data: tracks[0].pitchEditData, - startFrame: 0, + await actions.SET_TRACKS({ + tracks: new Map( + trackOrder.map((trackId) => { + const track = tracks[trackId]; + if (!track) throw new Error("track == undefined"); + return [trackId, track]; + }), + ), }); }; @@ -128,13 +124,16 @@ export const projectStore = createPartialStore({ await context.actions.SET_TIME_SIGNATURES({ timeSignatures: [createDefaultTimeSignature(1)], }); - await context.actions.SET_NOTES({ notes: [] }); - await context.actions.SET_SINGER({ withRelated: true }); - await context.actions.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.mutations.SET_PROJECT_FILEPATH({ filePath: undefined }); - context.mutations.RESET_SAVED_LAST_COMMAND_IDS(); - context.mutations.CLEAR_COMMANDS(); + context.actions.CLEAR_UNDO_HISTORY(); }, ), }, @@ -169,7 +168,7 @@ export const projectStore = createPartialStore({ */ action: createUILockAction( async ( - { actions, mutations, getters }, + { actions, mutations, state, getters }, { filePath, confirm }: { filePath?: string; confirm?: boolean }, ) => { if (!filePath) { @@ -198,6 +197,20 @@ export const projectStore = createPartialStore({ 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 actions.SAVE_OR_DISCARD_PROJECT_FILE({ additionalMessage: @@ -212,8 +225,7 @@ export const projectStore = createPartialStore({ await applySongProjectToStore(actions, parsedProjectData.song); mutations.SET_PROJECT_FILEPATH({ filePath }); - mutations.RESET_SAVED_LAST_COMMAND_IDS(); - mutations.CLEAR_COMMANDS(); + actions.CLEAR_UNDO_HISTORY(); return true; } catch (err) { window.backend.logError(err); @@ -289,6 +301,7 @@ export const projectStore = createPartialStore({ tempos, timeSignatures, tracks, + trackOrder, } = context.state; const projectData: LatestProjectType = { appVersion: appInfos.version, @@ -300,7 +313,8 @@ export const projectStore = createPartialStore({ tpqn, tempos, timeSignatures, - tracks, + tracks: Object.fromEntries(tracks), + trackOrder, }, }; @@ -397,4 +411,11 @@ export const projectStore = createPartialStore({ state.savedLastCommandIds = { talk: null, song: null }; }, }, + + 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..5603aeb82b 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -49,6 +49,7 @@ export const settingStoreState: SettingStoreState = { enableMultiSelect: false, shouldKeepTuningOnTextChange: false, enablePitchEditInSongEditor: false, + enableMultiTrack: false, }, splitTextWhenPaste: "PERIOD_AND_NEW_LINE", splitterPosition: { @@ -65,6 +66,10 @@ export const settingStoreState: SettingStoreState = { enableMultiEngine: false, enableMemoNotation: false, enableRubyNotation: false, + songUndoableTrackOptions: { + soloAndMute: true, + panAndGain: true, + }, }; export const settingStore = createPartialStore({ @@ -141,6 +146,7 @@ export const settingStore = createPartialStore({ "enableRubyNotation", "enableMemoNotation", "skipUpdateVersion", + "songUndoableTrackOptions", ] as const; // rootMiscSettingKeysに値を足し忘れていたときに型エラーを出す検出用コード diff --git a/src/store/singing.ts b/src/store/singing.ts index fbc4a2896e..70f63b2664 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,8 +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"); @@ -111,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; @@ -123,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(), @@ -158,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, @@ -167,6 +301,7 @@ export const singingStoreState: SingingStoreState = { nowRendering: false, nowAudioExporting: false, cancellationOfAudioExportRequested: false, + isSongSidebarOpen: false, }; export const singingStore = createPartialStore({ @@ -181,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に対して、エンジン側の初期化を行う @@ -197,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) { // 音域調整量マジックナンバーを設定するワークアラウンド @@ -209,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"); @@ -231,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"); @@ -410,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) @@ -478,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); }); @@ -508,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"); @@ -531,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 }); @@ -548,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; @@ -685,7 +821,7 @@ export const singingStore = createPartialStore({ SELECTED_TRACK: { getter(state) { - return state.tracks[selectedTrackIndex]; + return getSelectedTrackWithFallback(state); }, }, @@ -709,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, @@ -838,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; }, }, @@ -903,6 +1039,101 @@ export const singingStore = createPartialStore({ }, }, + CREATE_TRACK: { + action() { + const trackId = TrackId(crypto.randomUUID()); + const track = createDefaultTrack(); + + return { trackId, track }; + }, + }, + + REGISTER_TRACK: { + mutation(state, { trackId, track }) { + state.tracks.set(trackId, track); + state.trackOrder.push(trackId); + }, + action({ state, commit, dispatch }, { trackId, track }) { + if (state.tracks.has(trackId)) { + throw new Error(`Track ${trackId} is already registered.`); + } + if (!isValidTrack(track)) { + throw new Error("The track is invalid."); + } + commit("REGISTER_TRACK", { trackId, track }); + + dispatch("RENDER"); + }, + }, + + DELETE_TRACK: { + mutation(state, { trackId }) { + state.tracks.delete(trackId); + const trackIndex = state.trackOrder.indexOf(trackId); + state.trackOrder = state.trackOrder.filter((value) => value !== trackId); + if (state._selectedTrackId === trackId) { + state._selectedTrackId = + state.trackOrder[trackIndex === 0 ? 0 : trackIndex - 1]; + } + }, + 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()); + state._selectedTrackId = state.trackOrder[0]; + }, + 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"); + }, + }, + /** * レンダリングを行う。レンダリング中だった場合は停止して再レンダリングする。 */ @@ -960,6 +1191,7 @@ export const singingStore = createPartialStore({ tempos: Tempo[], tpqn: number, phraseFirstRestMinDurationSeconds: number, + trackId: TrackId, ) => { const foundPhrases = new Map(); @@ -989,11 +1221,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) { @@ -1236,44 +1470,100 @@ 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, - ); + const foundPhrases = new Map(); + for (const [trackId, track] of tracks) { + if (!track.singer) { + continue; + } + + // 重なっているノートを削除する + 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); + } + } for (const [phraseKey, phrase] of state.phrases) { const notesHash = phraseKey; @@ -1298,17 +1588,23 @@ export const singingStore = createPartialStore({ } } - const phrases = new Map(); + const newPhrases = new Map(); - for (const [notesHash, foundPhrase] of foundPhrases) { - const phraseKey = notesHash; + for (const [phraseKey, foundPhrase] of foundPhrases) { const existingPhrase = state.phrases.get(phraseKey); if (!existingPhrase) { // 新しいフレーズの場合 - phrases.set(phraseKey, foundPhrase); + newPhrases.set(phraseKey, foundPhrase); continue; } + const track = getOrThrow(tracks, existingPhrase.trackId); + + const singerAndFrameRate = getOrThrow( + singerAndFrameRates, + existingPhrase.trackId, + ); + // すでに存在するフレーズの場合 // 再レンダリングする必要があるかどうかをチェックする // シンガーが未設定の場合、とりあえず常に再レンダリングする @@ -1336,8 +1632,8 @@ export const singingStore = createPartialStore({ firstRestDuration: phrase.firstRestDuration, lastRestDurationSeconds, notes: phrase.notes, - keyRangeAdjustment, - volumeRangeAdjustment, + keyRangeAdjustment: track.keyRangeAdjustment, + volumeRangeAdjustment: track.volumeRangeAdjustment, frameRate: singerAndFrameRate.frameRate, }); const hash = phrase.singingGuideKey; @@ -1363,7 +1659,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, @@ -1383,10 +1679,10 @@ export const singingStore = createPartialStore({ phrase.state = "WAITING_TO_BE_RENDERED"; } - phrases.set(phraseKey, phrase); + newPhrases.set(phraseKey, phrase); } - commit("SET_PHRASES", { phrases }); + commit("SET_PHRASES", { phrases: newPhrases }); logger.info("Phrases updated."); @@ -1418,7 +1714,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); } @@ -1433,6 +1730,13 @@ export const singingStore = createPartialStore({ ); phrasesToBeRendered.delete(phraseKey); + const track = getOrThrow(tracks, phrase.trackId); + + const singerAndFrameRate = getOrThrow( + singerAndFrameRates, + phrase.trackId, + ); + // シンガーが未設定の場合は、歌い方の生成や音声合成は行わない if (!singerAndFrameRate) { @@ -1460,7 +1764,7 @@ export const singingStore = createPartialStore({ ); // リクエスト用のノーツのキーのシフトを行う - shiftKeyOfNotes(notesForRequestToEngine, -keyRangeAdjustment); + shiftKeyOfNotes(notesForRequestToEngine, -track.keyRangeAdjustment); // 歌い方が存在する場合、歌い方を取得する // 歌い方が存在しない場合、キャッシュがあれば取得し、なければ歌い方を生成する @@ -1480,8 +1784,8 @@ export const singingStore = createPartialStore({ firstRestDuration: phrase.firstRestDuration, lastRestDurationSeconds, notes: phrase.notes, - keyRangeAdjustment, - volumeRangeAdjustment, + keyRangeAdjustment: track.keyRangeAdjustment, + volumeRangeAdjustment: track.volumeRangeAdjustment, frameRate: singerAndFrameRate.frameRate, }); @@ -1502,7 +1806,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); @@ -1526,7 +1830,7 @@ export const singingStore = createPartialStore({ singingGuide = structuredClone(toRaw(singingGuide)); // ピッチ編集を適用する - applyPitchEdit(singingGuide, pitchEditData, editFrameRate); + applyPitchEdit(singingGuide, track.pitchEditData, editFrameRate); // 歌声のキャッシュがあれば取得し、なければ音声合成を行う @@ -1551,7 +1855,10 @@ export const singingStore = createPartialStore({ const queryForVolumeGeneration = structuredClone( singingGuide.query, ); - shiftGuidePitch(queryForVolumeGeneration, -keyRangeAdjustment); + shiftGuidePitch( + queryForVolumeGeneration, + -track.keyRangeAdjustment, + ); // 音量を生成して、生成した音量を歌い方のクエリにセットする // 音量値はAPIを叩く毎に変わるので、calc hashしたあとに音量を取得している @@ -1564,7 +1871,7 @@ export const singingStore = createPartialStore({ singingGuide.query.volume = volumes; // 音量のシフトを行う - shiftGuideVolume(singingGuide.query, volumeRangeAdjustment); + shiftGuideVolume(singingGuide.query, track.volumeRangeAdjustment); // 末尾のpauの区間の音量を0にする muteLastPauSection( @@ -1606,13 +1913,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); @@ -1680,85 +1988,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("RESET_SAVED_LAST_COMMAND_IDS"); - 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(uuid4()), - })); - - 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("RESET_SAVED_LAST_COMMAND_IDS"); - commit("CLEAR_COMMANDS"); - dispatch("RENDER"); - }, - ), - }, - FETCH_SING_FRAME_VOLUME: { async action( { dispatch }, @@ -1803,129 +2032,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"); @@ -1963,63 +2081,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 { @@ -2074,15 +2147,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のみコピーしない @@ -2154,7 +2227,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"); @@ -2164,9 +2241,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; @@ -2177,10 +2254,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 = {}; @@ -2188,49 +2383,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"); @@ -2316,107 +2515,362 @@ 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_ADD_TRACK: { + mutation(draft, { trackId, track }) { + singingStore.mutations.REGISTER_TRACK(draft, { trackId, track }); + }, + async action({ getters, dispatch, commit }) { + const { trackId, track } = await dispatch("CREATE_TRACK"); + const selectedTrack = getters.SELECTED_TRACK; + track.singer = selectedTrack.singer; + track.keyRangeAdjustment = selectedTrack.keyRangeAdjustment; + track.volumeRangeAdjustment = selectedTrack.volumeRangeAdjustment; + commit("COMMAND_ADD_TRACK", { + trackId, + track: cloneWithUnwrapProxy(track), + }); + }, + }, + + 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 } of tracks) { + if (overwrite) { + singingStore.mutations.SET_TRACK(draft, { track, trackId }); + } else { + singingStore.mutations.REGISTER_TRACK(draft, { track, trackId }); + } + } + }, + async action( + { state, commit, getters, dispatch }, + { tpqn, tempos, timeSignatures, tracks }, + ) { + const payload: { + track: Track; + trackId: TrackId; + overwrite: boolean; + }[] = []; + if (state.experimentalSetting.enableMultiTrack) { + 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: getters.SELECTED_TRACK_ID, + overwrite: true, + }); + } else { + const { trackId } = await dispatch("CREATE_TRACK"); + payload.push({ track, trackId, overwrite: false }); + } + } + } 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 3f1516897d..d0859506cb 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 { @@ -53,6 +54,7 @@ import { EditorType, NoteId, CommandId, + TrackId, } from "@/type/preload"; import { IEngineConnectorFactory } from "@/infrastructures/EngineConnector"; import { @@ -121,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 @@ -795,6 +801,7 @@ export type SingingVoiceSourceHash = z.infer< */ export type Phrase = { firstRestDuration: number; + trackId: TrackId; notes: Note[]; state: PhraseState; singingGuideKey?: SingingGuideSourceHash; @@ -806,6 +813,7 @@ export type Phrase = { */ export type PhraseSource = { firstRestDuration: number; + trackId: TrackId; notes: Note[]; }; @@ -818,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; @@ -828,8 +838,7 @@ export type SingingStoreState = { sequencerZoomY: number; sequencerSnapType: number; sequencerEditTarget: SequencerEditTarget; - selectedNoteIds: Set; - overlappingNoteIds: Set; + _selectedNoteIds: Set; editingLyricNoteId?: NoteId; nowPlaying: boolean; volume: number; @@ -838,6 +847,7 @@ export type SingingStoreState = { nowRendering: boolean; nowAudioExporting: boolean; cancellationOfAudioExportRequested: boolean; + isSongSidebarOpen: boolean; }; export type SingingStoreTypes = { @@ -846,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: { @@ -896,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: { @@ -922,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: { @@ -938,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: { @@ -956,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: { @@ -1017,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; }; @@ -1134,6 +1158,84 @@ export type SingingStoreTypes = { COMMAND_QUANTIZE_SELECTED_NOTES: { action(): void; }; + + CREATE_TRACK: { + action(): { trackId: TrackId; track: Track }; + }; + + REGISTER_TRACK: { + mutation: { trackId: TrackId; track: Track }; + action(payload: { trackId: TrackId; track: Track }): 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 = { @@ -1142,18 +1244,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: { @@ -1177,18 +1283,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: { @@ -1196,13 +1302,92 @@ 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_ADD_TRACK: { + mutation: { trackId: TrackId; track: Track }; + action(): 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: boolean }[]; + }; + 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; }; }; @@ -1519,6 +1704,10 @@ export type ProjectStoreTypes = { RESET_SAVED_LAST_COMMAND_IDS: { mutation: void; }; + + CLEAR_UNDO_HISTORY: { + action(): void; + }; }; /* diff --git a/src/type/preload.ts b/src/type/preload.ts index 7272f90943..e689b43c3f 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -73,6 +73,10 @@ 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 = "番目のキャラクターを選択"; @@ -569,6 +573,7 @@ export const experimentalSettingSchema = z.object({ 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; @@ -599,6 +604,13 @@ export const rootMiscSettingSchema = z.object({ enableMemoNotation: z.boolean().default(false), // メモ記法を有効にするか enableRubyNotation: z.boolean().default(false), // ルビ記法を有効にするか skipUpdateVersion: z.string().optional(), // アップデートをスキップしたバージョン + // ソングエディタでのトラック操作をUndo可能にするか + songUndoableTrackOptions: z + .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..5b6bc6c587 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..5b6bc6c587 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/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 cebfdcaca6..8dc7d0f4f2 100644 --- a/tests/unit/lib/selectPriorPhrase.spec.ts +++ b/tests/unit/lib/selectPriorPhrase.spec.ts @@ -6,9 +6,11 @@ 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, start: number, @@ -16,6 +18,7 @@ const createPhrase = ( state: PhraseState, ): Phrase => { return { + trackId, firstRestDuration: firstRestDuration * DEFAULT_TPQN, notes: [ {