From 642a35437fc60d3738ff5b2a2eb66df1cc3f4297 Mon Sep 17 00:00:00 2001 From: Nanashi Date: Mon, 3 Jun 2024 00:06:16 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=E3=83=9E=E3=83=AB=E3=83=81=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=AF=EF=BC=9Astore=E3=82=92=E8=89=B2?= =?UTF-8?q?=E3=80=85=E5=A4=89=E3=81=88=E3=82=8B=20(#2093)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Change: phrase以外をtrackIdを受け取るようにする * wip * Update: singing.tsを色々変える * Update: store以外を良い感じに合わせる * Add: マイグレーションを追加 * Fix: SET_TRACKでselectedTrackIdが無を参照するのを修正 * Fix: 二重にセットするように * Fix: trackOrderにする * Change: SELECT_TRACKしないようにする * Code: data -> pitchArray * Code: SET_SINGING_GUIDE_KEY_TO_PHRASEに型をつける * Code: こっちも型をつける * WIP: むずい * Change: PhraseにtrackIdを持たせる * Fix: 型エラーを修正 * Change: SET_NOTESとかの処理をSET_TRACKに写す * Fix: overlappingNoteIds.hasが抜けてたのを修正 * Change: initialTrackIdにする * Code: コメントを修正 * Change: singerAndFrameRatesを最初に作っておく * Code: トラック周りのactionを移動 * Fix: overlappingNoteInfos周りを修正 * Change: Mapのキーを変える Co-Authored-By: Hiroshiba * Change: CREATE_TRACKをADD_TRACKと分割 * Code: 空行を開ける Co-Authored-By: Hiroshiba * Change: overlappingNoteIdsをトラック毎に持つようにする * Code: コメントを移動 Co-Authored-By: sigprogramming * Fix: Renderする * Add: trackOrderに入ってるかバリデーションする * Add: tracksに入ってるかバリデーションする * Delete: SET_TRACK_ORDERを一度消す * Delete: singerAndFrameRatesの片引き数を消す * Add: trackのバリデーションを追加 * Fix: overlappingNoteIdsを代入してなかったので修正 * Change: 判定方法を変更 * Change: tracks.hasに一本化する --------- Co-authored-by: Hiroshiba Co-authored-by: sigprogramming --- .../Sing/CharacterMenuButton/MenuButton.vue | 4 + src/components/Sing/ScoreSequencer.vue | 42 +- src/components/Sing/SequencerNote.vue | 9 +- src/components/Sing/SingEditor.vue | 10 +- src/components/Sing/ToolBar/ToolBar.vue | 7 +- src/composables/useLyricInput.ts | 11 +- src/domain/project/schema.ts | 4 +- src/sing/domain.ts | 17 +- src/store/project.ts | 60 ++- src/store/singing.ts | 498 +++++++++++------- src/store/type.ts | 119 +++-- src/type/preload.ts | 4 + tests/unit/lib/selectPriorPhrase.spec.ts | 5 +- 13 files changed, 535 insertions(+), 255 deletions(-) diff --git a/src/components/Sing/CharacterMenuButton/MenuButton.vue b/src/components/Sing/CharacterMenuButton/MenuButton.vue index 47511384c4..e9ed240b96 100644 --- a/src/components/Sing/CharacterMenuButton/MenuButton.vue +++ b/src/components/Sing/CharacterMenuButton/MenuButton.vue @@ -173,6 +173,8 @@ const reassignSubMenuOpen = debounce((idx: number) => { }, 100); const showSkeleton = computed(() => selectedCharacterInfo.value == undefined); +const selectedTrackId = computed(() => store.state.selectedTrackId); + const changeStyleId = (speakerUuid: SpeakerId, styleId: StyleId) => { const engineId = store.state.engineIds.find((_engineId) => (store.state.characterInfos[_engineId] ?? []).some( @@ -189,6 +191,8 @@ const changeStyleId = (speakerUuid: SpeakerId, styleId: StyleId) => { store.dispatch("COMMAND_SET_SINGER", { singer: { engineId, styleId }, withRelated: true, + + trackId: selectedTrackId.value, }); }; diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 6bfcd457b9..39d5a4a409 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -112,11 +112,15 @@ transform: `translateX(${guideLineX}px)`, }" > + + + state.selectedTrackId); + // 分解能(Ticks Per Quarter Note) const tpqn = computed(() => state.tpqn); @@ -906,12 +914,18 @@ const endPreview = () => { if (edited) { if (previewMode === "ADD") { - 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", { @@ -938,14 +952,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); @@ -1178,7 +1194,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", { @@ -1197,7 +1216,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", { @@ -1217,7 +1239,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 = () => { @@ -1232,7 +1257,10 @@ const handleNotesArrowLeft = () => { ) { return; } - store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes }); + store.dispatch("COMMAND_UPDATE_NOTES", { + notes: editedNotes, + trackId: selectedTrackId.value, + }); }; const handleNotesBackspaceOrDelete = () => { diff --git a/src/components/Sing/SequencerNote.vue b/src/components/Sing/SequencerNote.vue index 56c13bb0ec..d87a8d7e2b 100644 --- a/src/components/Sing/SequencerNote.vue +++ b/src/components/Sing/SequencerNote.vue @@ -98,6 +98,8 @@ import { import ContextMenu, { ContextMenuItemData, } from "@/components/Menu/ContextMenu.vue"; +import { TrackId } from "@/type/preload"; +import { getOrThrow } from "@/helpers/mapHelper"; type NoteState = "NORMAL" | "SELECTED"; @@ -110,6 +112,7 @@ const vFocus = { const props = withDefaults( defineProps<{ + trackId: TrackId; note: Note; isSelected: boolean; previewLyric: string | null; @@ -164,7 +167,11 @@ const editTargetIsPitch = computed(() => { // ノートの重なりエラー const hasOverlappingError = computed(() => { - return state.overlappingNoteIds.has(props.note.id); + const overlappingNoteIds = getOrThrow( + state.overlappingNoteIds, + props.trackId, + ); + return overlappingNoteIds.has(props.note.id); }); // フレーズ生成エラー diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index a472082130..c7ad953e0a 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -82,11 +82,17 @@ onetimeWatch( await store.dispatch("SET_TIME_SIGNATURES", { timeSignatures: [createDefaultTimeSignature(1)], }); - await store.dispatch("SET_NOTES", { notes: [] }); + await store.dispatch("SET_NOTES", { + notes: [], + trackId: store.state.selectedTrackId, + }); // CI上のe2eテストのNemoエンジンには歌手がいないためエラーになるのでワークアラウンド // FIXME: 歌手をいると見せかけるmock APIを作り、ここのtry catchを削除する try { - await store.dispatch("SET_SINGER", { withRelated: true }); + await store.dispatch("SET_SINGER", { + withRelated: true, + trackId: store.state.selectedTrackId, + }); } catch (e) { window.backend.logError(e); } diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index a70bdde457..ffc702ed93 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -218,6 +218,7 @@ const keyRangeAdjustment = computed( const volumeRangeAdjustment = computed( () => store.getters.SELECTED_TRACK.volumeRangeAdjustment, ); +const selectedTrackId = computed(() => store.state.selectedTrackId); const bpmInputBuffer = ref(120); const beatsInputBuffer = ref(4); @@ -326,13 +327,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/composables/useLyricInput.ts b/src/composables/useLyricInput.ts index ffaf85c144..dd0e34f836 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 currentTrack = store.getters.SELECTED_TRACK; + const inputNoteIndex = currentTrack.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, + currentTrack.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.state.selectedTrackId, + }); }; return { previewLyrics, splitAndUpdatePreview, commitPreviewLyrics }; diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts index ca74e7987f..ad07bc4ab7 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"; // トーク系のスキーマ @@ -104,7 +105,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/sing/domain.ts b/src/sing/domain.ts index 5eed5a7f61..5c62dec419 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -4,7 +4,6 @@ import { Note, Phrase, PhraseSource, - PhraseSourceHash, SingingGuide, SingingGuideSource, SingingVoiceSource, @@ -90,6 +89,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, @@ -393,7 +400,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]); @@ -409,10 +416,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"); } diff --git a/src/store/project.ts b/src/store/project.ts index b8fd91512d..2c528eec80 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -1,4 +1,5 @@ import semver from "semver"; +import { v4 as uuidv4 } from "uuid"; import { getBaseName } from "./utility"; import { createPartialStore, Dispatch } from "./vuex"; import { createUILockAction } from "@/store/ui"; @@ -9,12 +10,13 @@ import { ProjectStoreTypes, } from "@/store/type"; import { AccentPhrase } from "@/openapi"; -import { EngineId } from "@/type/preload"; +import { EngineId, TrackId } from "@/type/preload"; import { getValueOrThrow, ResultError } from "@/type/result"; import { LatestProjectType, projectSchema } from "@/domain/project/schema"; import { createDefaultTempo, createDefaultTimeSignature, + createDefaultTrack, DEFAULT_BEAT_TYPE, DEFAULT_BEATS, DEFAULT_BPM, @@ -97,25 +99,19 @@ const applySongProjectToStore = async ( dispatch: Dispatch, songProject: LatestProjectType["song"], ) => { - const { tpqn, tempos, timeSignatures, tracks } = songProject; - // TODO: マルチトラック対応 - await dispatch("SET_SINGER", { - singer: tracks[0].singer, - }); - await dispatch("SET_KEY_RANGE_ADJUSTMENT", { - keyRangeAdjustment: tracks[0].keyRangeAdjustment, - }); - await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { - volumeRangeAdjustment: tracks[0].volumeRangeAdjustment, - }); + const { tpqn, tempos, timeSignatures, tracks, trackOrder } = songProject; + await dispatch("SET_TPQN", { tpqn }); await dispatch("SET_TEMPOS", { tempos }); await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes: tracks[0].notes }); - await dispatch("CLEAR_PITCH_EDIT_DATA"); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 - await dispatch("SET_PITCH_EDIT_DATA", { - data: tracks[0].pitchEditData, - startFrame: 0, + await dispatch("SET_TRACKS", { + tracks: new Map( + trackOrder.map((trackId) => { + const track = tracks[trackId]; + if (!track) throw new Error("track == undefined"); + return [trackId, track]; + }), + ), }); }; @@ -166,9 +162,13 @@ export const projectStore = createPartialStore({ await context.dispatch("SET_TIME_SIGNATURES", { timeSignatures: [createDefaultTimeSignature(1)], }); - await context.dispatch("SET_NOTES", { notes: [] }); - await context.dispatch("SET_SINGER", { withRelated: true }); - await context.dispatch("CLEAR_PITCH_EDIT_DATA"); + const trackId = TrackId(uuidv4()); + await context.dispatch("SET_TRACKS", { + tracks: new Map([[trackId, createDefaultTrack()]]), + }); + await context.dispatch("SET_NOTES", { notes: [], trackId }); + await context.dispatch("SET_SINGER", { withRelated: true, trackId }); + await context.dispatch("CLEAR_PITCH_EDIT_DATA", { trackId }); context.commit("SET_PROJECT_FILEPATH", { filePath: undefined }); context.commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); @@ -456,6 +456,22 @@ export const projectStore = createPartialStore({ } } + if ( + semver.satisfies( + projectAppVersion, + "<0.20.0", + semverSatisfiesOptions, + ) + ) { + // tracks: Track[] -> tracks: Record + trackOrder: TrackId[] + const newTracks: Record = {}; + for (const track of projectData.song.tracks) { + newTracks[TrackId(uuidv4())] = track; + } + projectData.song.tracks = newTracks; + projectData.song.trackOrder = Object.keys(newTracks); + } + // Validation check // トークはvalidateTalkProjectで検証する // ソングはSET_SCOREの中の`isValidScore`関数で検証される @@ -562,6 +578,7 @@ export const projectStore = createPartialStore({ tempos, timeSignatures, tracks, + trackOrder, } = context.state; const projectData: LatestProjectType = { appVersion: appInfos.version, @@ -573,7 +590,8 @@ export const projectStore = createPartialStore({ tpqn, tempos, timeSignatures, - tracks, + tracks: Object.fromEntries(tracks), + trackOrder, }, }; diff --git a/src/store/singing.ts b/src/store/singing.ts index f16b5ba369..2582355daf 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -14,7 +14,6 @@ import { SaveResultObject, Singer, Phrase, - PhraseState, transformCommandStore, SingingGuide, SingingVoice, @@ -24,7 +23,7 @@ import { PhraseSourceHash, } from "./type"; import { sanitizeFileName } from "./utility"; -import { EngineId, NoteId, StyleId } from "@/type/preload"; +import { EngineId, NoteId, StyleId, TrackId } from "@/type/preload"; import { Midi } from "@/sing/midi"; import { FrameAudioQuery, Note as NoteForRequestToEngine } from "@/openapi"; import { ResultError, getValueOrThrow } from "@/type/result"; @@ -70,6 +69,7 @@ import { createDefaultTempo, createDefaultTimeSignature, isValidNotes, + isValidTrack, } from "@/sing/domain"; import { FrequentlyUpdatedState, @@ -138,20 +138,21 @@ if (window.AudioContext) { 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(uuidv4()); export const singingStoreState: SingingStoreState = { tpqn: DEFAULT_TPQN, tempos: [createDefaultTempo(0)], timeSignatures: [createDefaultTimeSignature(1)], - tracks: [createDefaultTrack()], + tracks: new Map([[initialTrackId, createDefaultTrack()]]), + trackOrder: [initialTrackId], + selectedTrackId: initialTrackId, editFrameRate: DEPRECATED_DEFAULT_EDIT_FRAME_RATE, phrases: new Map(), singingGuides: new Map(), @@ -162,8 +163,8 @@ export const singingStoreState: SingingStoreState = { sequencerSnapType: 16, sequencerEditTarget: "NOTE", selectedNoteIds: new Set(), - overlappingNoteIds: new Set(), - overlappingNoteInfos: new Map(), + overlappingNoteIds: new Map([[initialTrackId, new Set()]]), + overlappingNoteInfos: new Map([[initialTrackId, new Map()]]), nowPlaying: false, volume: 0, startRenderingRequested: false, @@ -201,11 +202,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) { // 音域調整量マジックナンバーを設定するワークアラウンド @@ -213,13 +212,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"); @@ -238,46 +236,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"); @@ -419,76 +414,101 @@ export const singingStore = createPartialStore({ NOTE_IDS: { getter(state) { - const selectedTrack = state.tracks[selectedTrackIndex]; + const selectedTrack = getOrThrow(state.tracks, state.selectedTrackId); const noteIds = selectedTrack.notes.map((value) => value.id); return new Set(noteIds); }, }, SET_NOTES: { - mutation(state, { notes }: { notes: Note[] }) { - // TODO: マルチトラック対応 - state.overlappingNoteInfos.clear(); + mutation(state, { notes, trackId }) { state.overlappingNoteIds.clear(); state.editingLyricNoteId = undefined; state.selectedNoteIds.clear(); - state.tracks[selectedTrackIndex].notes = notes; - addNotesToOverlappingNoteInfos(state.overlappingNoteInfos, notes); - state.overlappingNoteIds = getOverlappingNoteIds( + const selectedTrack = getOrThrow(state.tracks, trackId); + selectedTrack.notes = notes; + + const overlappingNoteInfos = getOrThrow( state.overlappingNoteInfos, + trackId, + ); + overlappingNoteInfos.clear(); + addNotesToOverlappingNoteInfos(overlappingNoteInfos, notes); + state.overlappingNoteIds.set( + trackId, + getOverlappingNoteIds(overlappingNoteInfos), ); }, - 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; - addNotesToOverlappingNoteInfos(state.overlappingNoteInfos, notes); - state.overlappingNoteIds = getOverlappingNoteIds( + + const overlappingNoteInfos = getOrThrow( state.overlappingNoteInfos, + trackId, + ); + addNotesToOverlappingNoteInfos(overlappingNoteInfos, notes); + state.overlappingNoteIds.set( + trackId, + getOverlappingNoteIds(overlappingNoteInfos), ); }, }, 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); - updateNotesOfOverlappingNoteInfos(state.overlappingNoteInfos, notes); - state.overlappingNoteIds = getOverlappingNoteIds( + + const overlappingNoteInfos = getOrThrow( state.overlappingNoteInfos, + trackId, + ); + updateNotesOfOverlappingNoteInfos(overlappingNoteInfos, notes); + state.overlappingNoteIds.set( + trackId, + getOverlappingNoteIds(overlappingNoteInfos), ); }, }, 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); const notes = selectedTrack.notes.filter((value) => { return noteIdsSet.has(value.id); }); - removeNotesFromOverlappingNoteInfos(state.overlappingNoteInfos, notes); - state.overlappingNoteIds = getOverlappingNoteIds( + + const overlappingNoteInfos = getOrThrow( state.overlappingNoteInfos, + trackId, + ); + removeNotesFromOverlappingNoteInfos(overlappingNoteInfos, notes); + state.overlappingNoteIds.set( + trackId, + getOverlappingNoteIds(overlappingNoteInfos), ); + if ( state.editingLyricNoteId != undefined && noteIdsSet.has(state.editingLyricNoteId) @@ -524,7 +544,7 @@ export const singingStore = createPartialStore({ SELECT_ALL_NOTES: { mutation(state) { - const currentTrack = state.tracks[selectedTrackIndex]; + const currentTrack = getOrThrow(state.tracks, state.selectedTrackId); const allNoteIds = currentTrack.notes.map((note) => note.id); state.selectedNoteIds = new Set(allNoteIds); }, @@ -562,77 +582,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; @@ -699,7 +707,7 @@ export const singingStore = createPartialStore({ SELECTED_TRACK: { getter(state) { - return state.tracks[selectedTrackIndex]; + return getOrThrow(state.tracks, state.selectedTrackId); }, }, @@ -901,6 +909,89 @@ export const singingStore = createPartialStore({ }, }, + CREATE_TRACK: { + action() { + const trackId = TrackId(uuidv4()); + const track = createDefaultTrack(); + + return { trackId, track }; + }, + }, + + REGISTER_TRACK: { + mutation(state, { trackId, track }) { + state.tracks.set(trackId, track); + state.trackOrder.push(trackId); + state.overlappingNoteInfos.set(trackId, new Map()); + state.overlappingNoteIds.set(trackId, new Set()); + }, + 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"); + }, + }, + + SELECT_TRACK: { + mutation(state, { trackId }) { + 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_TRACKS: { + mutation(state, { tracks }) { + state.tracks = tracks; + state.trackOrder = Array.from(tracks.keys()); + state.overlappingNoteIds = new Map( + [...tracks.keys()].map((trackId) => [trackId, new Set()]), + ); + state.overlappingNoteInfos = new Map( + [...tracks.keys()].map((trackId) => [trackId, new Map()]), + ); + state.selectedTrackId = state.trackOrder[0]; + }, + async action({ commit, dispatch }, { tracks }) { + commit("SET_TRACKS", { tracks }); + // 色々な処理を動かすため、二重にセットする + // TODO: もっとスマートな方法を考える + for (const [trackId, track] of tracks) { + await dispatch("SET_SINGER", { + singer: track.singer, + trackId, + }); + await dispatch("SET_KEY_RANGE_ADJUSTMENT", { + keyRangeAdjustment: track.keyRangeAdjustment, + trackId, + }); + await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment: track.volumeRangeAdjustment, + trackId, + }); + await dispatch("SET_NOTES", { notes: track.notes, trackId }); + await dispatch("CLEAR_PITCH_EDIT_DATA", { + trackId, + }); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 + await dispatch("SET_PITCH_EDIT_DATA", { + pitchArray: track.pitchEditData, + startFrame: 0, + trackId, + }); + } + }, + }, + /** * レンダリングを行う。レンダリング中だった場合は停止して再レンダリングする。 */ @@ -958,6 +1049,7 @@ export const singingStore = createPartialStore({ tempos: Tempo[], tpqn: number, phraseFirstRestMinDurationSeconds: number, + trackId: TrackId, ) => { const foundPhrases = new Map(); @@ -987,11 +1079,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) { @@ -1240,38 +1334,57 @@ export const singingStore = createPartialStore({ const audioContextRef = audioContext; const transportRef = transport; const channelStripRef = channelStrip; - const trackRef = getters.SELECTED_TRACK; // レンダリング中に変更される可能性のあるデータをコピーする - // 重なっているノートの削除も行う + const tracks = structuredClone(toRaw(state.tracks)); + + 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( + state.overlappingNoteIds, + 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; @@ -1296,17 +1409,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, + ); + // すでに存在するフレーズの場合 // 再レンダリングする必要があるかどうかをチェックする // シンガーが未設定の場合、とりあえず常に再レンダリングする @@ -1334,8 +1453,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; @@ -1361,7 +1480,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, @@ -1381,10 +1500,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."); @@ -1431,6 +1550,13 @@ export const singingStore = createPartialStore({ ); phrasesToBeRendered.delete(phraseKey); + const track = getOrThrow(tracks, phrase.trackId); + + const singerAndFrameRate = getOrThrow( + singerAndFrameRates, + phrase.trackId, + ); + // シンガーが未設定の場合は、歌い方の生成や音声合成は行わない if (!singerAndFrameRate) { @@ -1458,7 +1584,7 @@ export const singingStore = createPartialStore({ ); // リクエスト用のノーツのキーのシフトを行う - shiftKeyOfNotes(notesForRequestToEngine, -keyRangeAdjustment); + shiftKeyOfNotes(notesForRequestToEngine, -track.keyRangeAdjustment); // 歌い方が存在する場合、歌い方を取得する // 歌い方が存在しない場合、キャッシュがあれば取得し、なければ歌い方を生成する @@ -1478,8 +1604,8 @@ export const singingStore = createPartialStore({ firstRestDuration: phrase.firstRestDuration, lastRestDurationSeconds, notes: phrase.notes, - keyRangeAdjustment, - volumeRangeAdjustment, + keyRangeAdjustment: track.keyRangeAdjustment, + volumeRangeAdjustment: track.volumeRangeAdjustment, frameRate: singerAndFrameRate.frameRate, }); @@ -1500,7 +1626,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); @@ -1524,7 +1650,7 @@ export const singingStore = createPartialStore({ singingGuide = structuredClone(toRaw(singingGuide)); // ピッチ編集を適用する - applyPitchEdit(singingGuide, pitchEditData, editFrameRate); + applyPitchEdit(singingGuide, track.pitchEditData, editFrameRate); // 歌声のキャッシュがあれば取得し、なければ音声合成を行う @@ -1549,7 +1675,10 @@ export const singingStore = createPartialStore({ const queryForVolumeGeneration = structuredClone( singingGuide.query, ); - shiftGuidePitch(queryForVolumeGeneration, -keyRangeAdjustment); + shiftGuidePitch( + queryForVolumeGeneration, + -track.keyRangeAdjustment, + ); // 音量を生成して、生成した音量を歌い方のクエリにセットする // 音量値はAPIを叩く毎に変わるので、calc hashしたあとに音量を取得している @@ -1562,7 +1691,7 @@ export const singingStore = createPartialStore({ singingGuide.query.volume = volumes; // 音量のシフトを行う - shiftGuideVolume(singingGuide.query, volumeRangeAdjustment); + shiftGuideVolume(singingGuide.query, track.volumeRangeAdjustment); // 末尾のpauの区間の音量を0にする muteLastPauSection( @@ -1821,7 +1950,7 @@ export const singingStore = createPartialStore({ } await dispatch("SET_TEMPOS", { tempos }); await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes }); + await dispatch("SET_NOTES", { notes, trackId: state.selectedTrackId }); }, ), }, @@ -2131,7 +2260,7 @@ export const singingStore = createPartialStore({ } await dispatch("SET_TEMPOS", { tempos }); await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes }); + await dispatch("SET_NOTES", { notes, trackId: state.selectedTrackId }); }, ), }, @@ -2244,7 +2373,7 @@ export const singingStore = createPartialStore({ } await dispatch("SET_TEMPOS", { tempos }); await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes }); + await dispatch("SET_NOTES", { notes, trackId: state.selectedTrackId }); }, ), }, @@ -2644,7 +2773,10 @@ export const singingStore = createPartialStore({ }); const pastedNoteIds = notesToPaste.map((note) => note.id); // ノートを追加してレンダリングする - commit("COMMAND_ADD_NOTES", { notes: notesToPaste }); + commit("COMMAND_ADD_NOTES", { + notes: notesToPaste, + trackId: state.selectedTrackId, + }); dispatch("RENDER"); // 貼り付けたノートを選択する commit("DESELECT_ALL_NOTES"); @@ -2667,7 +2799,10 @@ 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: state.selectedTrackId, + }); dispatch("RENDER"); }, }, @@ -2678,49 +2813,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"); @@ -2806,10 +2945,10 @@ 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[] }) { + action({ getters, commit, dispatch }, { notes, trackId }) { const existingNoteIds = getters.NOTE_IDS; const isValidNotes = notes.every((value) => { return !existingNoteIds.has(value.id) && isValidNote(value); @@ -2817,16 +2956,16 @@ export const singingCommandStore = transformCommandStore( 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[] }) { + action({ getters, commit, dispatch }, { notes, trackId }) { const existingNoteIds = getters.NOTE_IDS; const isValidNotes = notes.every((value) => { return existingNoteIds.has(value.id) && isValidNote(value); @@ -2834,16 +2973,16 @@ export const singingCommandStore = transformCommandStore( 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 }) { + action({ getters, commit, dispatch }, { noteIds, trackId }) { const existingNoteIds = getters.NOTE_IDS; const isValidNoteIds = noteIds.every((value) => { return existingNoteIds.has(value); @@ -2851,58 +2990,65 @@ export const singingCommandStore = transformCommandStore( 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] }); + commit("COMMAND_REMOVE_NOTES", { + noteIds: [...state.selectedNoteIds], + trackId: state.selectedTrackId, + }); 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"); }, diff --git a/src/store/type.ts b/src/store/type.ts index d134eef477..7148aec028 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -51,6 +51,7 @@ import { RootMiscSettingType, EditorType, NoteId, + TrackId, } from "@/type/preload"; import { IEngineConnectorFactory } from "@/infrastructures/EngineConnector"; import { @@ -793,6 +794,7 @@ export type SingingVoiceSourceHash = z.infer< */ export type Phrase = { firstRestDuration: number; + trackId: TrackId; notes: Note[]; state: PhraseState; singingGuideKey?: SingingGuideSourceHash; @@ -804,6 +806,7 @@ export type Phrase = { */ export type PhraseSource = { firstRestDuration: number; + trackId: TrackId; notes: Note[]; }; @@ -816,7 +819,9 @@ export type SingingStoreState = { tpqn: number; tempos: Tempo[]; timeSignatures: TimeSignature[]; - tracks: Track[]; + tracks: Map; + trackOrder: TrackId[]; + selectedTrackId: TrackId; editFrameRate: number; phrases: Map; singingGuides: Map; @@ -827,8 +832,8 @@ export type SingingStoreState = { sequencerSnapType: number; sequencerEditTarget: SequencerEditTarget; selectedNoteIds: Set; - overlappingNoteIds: Set; - overlappingNoteInfos: OverlappingNoteInfos; + overlappingNoteIds: Map>; + overlappingNoteInfos: Map; editingLyricNoteId?: NoteId; nowPlaying: boolean; volume: number; @@ -850,18 +855,22 @@ export type SingingStoreTypes = { }; 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: { @@ -900,20 +909,20 @@ export type SingingStoreTypes = { }; 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: { @@ -937,17 +946,21 @@ export type SingingStoreTypes = { }; SET_PITCH_EDIT_DATA: { - mutation: { data: number[]; startFrame: number }; - action(payload: { data: number[]; startFrame: number }): void; + mutation: { pitchArray: number[]; startFrame: number; trackId: TrackId }; + action(payload: { + pitchArray: number[]; + startFrame: number; + trackId: TrackId; + }): void; }; ERASE_PITCH_EDIT_DATA: { - mutation: { startFrame: number; frameLength: number }; + mutation: { startFrame: number; frameLength: number; trackId: TrackId }; }; CLEAR_PITCH_EDIT_DATA: { - mutation: undefined; - action(): void; + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; }; SET_PHRASES: { @@ -955,7 +968,10 @@ export type SingingStoreTypes = { }; SET_STATE_TO_PHRASE: { - mutation: { phraseKey: PhraseSourceHash; phraseState: PhraseState }; + mutation: { + phraseKey: PhraseSourceHash; + phraseState: PhraseState; + }; }; SET_SINGING_GUIDE_KEY_TO_PHRASE: { @@ -1133,6 +1149,25 @@ 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; + }; + + SELECT_TRACK: { + mutation: { trackId: TrackId }; + action(payload: { trackId: TrackId }): void; + }; + + SET_TRACKS: { + mutation: { tracks: Map }; + action(payload: { tracks: Map }): Promise; + }; }; export type SingingCommandStoreState = { @@ -1141,18 +1176,22 @@ export type SingingCommandStoreState = { export type SingingCommandStoreTypes = { COMMAND_SET_SINGER: { - mutation: { singer: Singer; withRelated?: boolean }; - action(payload: { singer: Singer; withRelated?: boolean }): void; + mutation: { singer: Singer; withRelated?: boolean; trackId: TrackId }; + action(payload: { + singer: Singer; + withRelated?: boolean; + trackId: TrackId; + }): void; }; COMMAND_SET_KEY_RANGE_ADJUSTMENT: { - mutation: { keyRangeAdjustment: number }; - action(payload: { keyRangeAdjustment: number }): void; + mutation: { keyRangeAdjustment: number; trackId: TrackId }; + action(payload: { keyRangeAdjustment: number; trackId: TrackId }): void; }; COMMAND_SET_VOLUME_RANGE_ADJUSTMENT: { - mutation: { volumeRangeAdjustment: number }; - action(payload: { volumeRangeAdjustment: number }): void; + mutation: { volumeRangeAdjustment: number; trackId: TrackId }; + action(payload: { volumeRangeAdjustment: number; trackId: TrackId }): void; }; COMMAND_SET_TEMPO: { @@ -1176,18 +1215,18 @@ export type SingingCommandStoreTypes = { }; COMMAND_ADD_NOTES: { - mutation: { notes: Note[] }; - action(payload: { notes: Note[] }): void; + mutation: { notes: Note[]; trackId: TrackId }; + action(payload: { notes: Note[]; trackId: TrackId }): void; }; COMMAND_UPDATE_NOTES: { - mutation: { notes: Note[] }; - action(payload: { notes: Note[] }): void; + mutation: { notes: Note[]; trackId: TrackId }; + action(payload: { notes: Note[]; trackId: TrackId }): void; }; COMMAND_REMOVE_NOTES: { - mutation: { noteIds: NoteId[] }; - action(payload: { noteIds: NoteId[] }): void; + mutation: { noteIds: NoteId[]; trackId: TrackId }; + action(payload: { noteIds: NoteId[]; trackId: TrackId }): void; }; COMMAND_REMOVE_SELECTED_NOTES: { @@ -1195,13 +1234,21 @@ 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; }; }; diff --git a/src/type/preload.ts b/src/type/preload.ts index 179f5a1b05..2c868815ec 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -66,6 +66,10 @@ export const noteIdSchema = z.string().brand<"NoteId">(); export type NoteId = z.infer; export const NoteId = (id: string): NoteId => noteIdSchema.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 = "番目のキャラクターを選択"; diff --git a/tests/unit/lib/selectPriorPhrase.spec.ts b/tests/unit/lib/selectPriorPhrase.spec.ts index 034b65bd07..45446d15c6 100644 --- a/tests/unit/lib/selectPriorPhrase.spec.ts +++ b/tests/unit/lib/selectPriorPhrase.spec.ts @@ -7,7 +7,9 @@ import { phraseSourceHashSchema, } from "@/store/type"; import { DEFAULT_TPQN, selectPriorPhrase } from "@/sing/domain"; -import { NoteId } from "@/type/preload"; +import { NoteId, TrackId } from "@/type/preload"; + +const trackId = TrackId("00000000-0000-0000-0000-000000000000"); const createPhrase = ( firstRestDuration: number, @@ -16,6 +18,7 @@ const createPhrase = ( state: PhraseState, ): Phrase => { return { + trackId, firstRestDuration: firstRestDuration * DEFAULT_TPQN, notes: [ { From f05d1744161a08ee1310d031231eec322755a7ae Mon Sep 17 00:00:00 2001 From: Nanashi Date: Sun, 23 Jun 2024 01:59:51 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=E3=83=9E=E3=83=AB=E3=83=81=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=AF=EF=BC=9A=E3=82=B3=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=82=92=E5=88=86=E9=9B=A2?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#2129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: コンポーネントを分離 * Fix: エンジンアイコン周りの変更に追従 * Fix: engineIconsの引数を修正 * Apply suggestions from code review --------- Co-authored-by: Hiroshiba --- .../CharacterSelectMenu.vue | 232 ++++++++++++++++ .../Sing/CharacterMenuButton/MenuButton.vue | 253 +----------------- src/components/Sing/SingerIcon.vue | 36 +++ 3 files changed, 280 insertions(+), 241 deletions(-) create mode 100644 src/components/Sing/CharacterMenuButton/CharacterSelectMenu.vue create mode 100644 src/components/Sing/SingerIcon.vue diff --git a/src/components/Sing/CharacterMenuButton/CharacterSelectMenu.vue b/src/components/Sing/CharacterMenuButton/CharacterSelectMenu.vue new file mode 100644 index 0000000000..de2ead8f1e --- /dev/null +++ b/src/components/Sing/CharacterMenuButton/CharacterSelectMenu.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/src/components/Sing/CharacterMenuButton/MenuButton.vue b/src/components/Sing/CharacterMenuButton/MenuButton.vue index 26bc5ade75..e5eabfd65b 100644 --- a/src/components/Sing/CharacterMenuButton/MenuButton.vue +++ b/src/components/Sing/CharacterMenuButton/MenuButton.vue @@ -1,215 +1,30 @@ - - - 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 @@ + + + + + From 7771ae952468df2d5b285689904186dbd63b4453 Mon Sep 17 00:00:00 2001 From: Nanashi Date: Mon, 1 Jul 2024 06:39:52 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=E3=83=9E=E3=83=AB=E3=83=81=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=AF=EF=BC=9A=E3=82=A4=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=AB=E5=AF=BE=E5=BF=9C=20(#2126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Change: SET_TRACKを作る * Add: インポートをマルチトラック対応 * Add: 複数トラックをインポートできるように * Fix: indexじゃなくてiだった * Add: 共通化 + undo出来るように * Change: トラック選択のUIを変更 * Change: mapを使う * Change: isTracksEmptyを移動 * Change: IMPORT_*_PROJECTをCOMMANDに * Add: isValidTrackチェックを追加 * Change: QCheckboxを使う * Change: 見た目を良い感じにする * デザインちょっと調整 --------- Co-authored-by: Hiroshiba --- .../Dialog/ImportSongProjectDialog.vue | 151 ++++---- src/sing/domain.ts | 3 + src/store/singing.ts | 321 +++++++++++------- src/store/type.ts | 39 ++- 4 files changed, 321 insertions(+), 193 deletions(-) diff --git a/src/components/Dialog/ImportSongProjectDialog.vue b/src/components/Dialog/ImportSongProjectDialog.vue index 7f51aa4eaf..64549833c9 100644 --- a/src/components/Dialog/ImportSongProjectDialog.vue +++ b/src/components/Dialog/ImportSongProjectDialog.vue @@ -1,14 +1,15 @@ @@ -201,24 +218,22 @@ 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 initializeValues = () => { projectFile.value = null; project.value = null; - selectedTrack.value = null; + selectedTrackIndexes.value = null; }; // ファイル変更時 @@ -236,7 +251,7 @@ const handleFileChange = async (event: Event) => { // 既存のデータおよび選択中のトラックをクリア project.value = null; - selectedTrack.value = null; + selectedTrackIndexes.value = null; error.value = null; const file = input.files[0]; @@ -259,12 +274,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"; @@ -281,19 +298,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(); @@ -304,3 +321,15 @@ const handleCancel = () => { onDialogCancel(); }; + + diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 68cd5fe483..1e7553c08d 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -20,6 +20,9 @@ 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) && diff --git a/src/store/singing.ts b/src/store/singing.ts index 965a9497a2..3b40e7e513 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -20,6 +20,7 @@ import { SingingVoiceSourceHash, SequencerEditTarget, PhraseSourceHash, + Track, } from "./type"; import { sanitizeFileName } from "./utility"; import { EngineId, NoteId, StyleId, TrackId } from "@/type/preload"; @@ -69,6 +70,7 @@ import { isValidTrack, SEQUENCER_MIN_NUM_MEASURES, getNumMeasures, + isTracksEmpty, } from "@/sing/domain"; import { FrequentlyUpdatedState, @@ -962,6 +964,47 @@ export const singingStore = createPartialStore({ }, }, + SET_TRACK: { + mutation(state, { trackId, track }) { + state.tracks.set(trackId, track); + state.overlappingNoteIds.set(trackId, new Set()); + state.overlappingNoteInfos.set(trackId, new Map()); + }, + 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 }); + // 色々な処理を動かすため、二重にセットする + // TODO: もっとスマートな方法を考える + await dispatch("SET_SINGER", { + singer: track.singer, + trackId, + }); + await dispatch("SET_KEY_RANGE_ADJUSTMENT", { + keyRangeAdjustment: track.keyRangeAdjustment, + trackId, + }); + await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment: track.volumeRangeAdjustment, + trackId, + }); + await dispatch("SET_NOTES", { notes: track.notes, trackId }); + await dispatch("CLEAR_PITCH_EDIT_DATA", { + trackId, + }); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 + await dispatch("SET_PITCH_EDIT_DATA", { + pitchArray: track.pitchEditData, + startFrame: 0, + trackId, + }); + }, + }, + SET_TRACKS: { mutation(state, { tracks }) { state.tracks = tracks; @@ -975,31 +1018,15 @@ export const singingStore = createPartialStore({ 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 }); - // 色々な処理を動かすため、二重にセットする - // TODO: もっとスマートな方法を考える + for (const [trackId, track] of tracks) { - await dispatch("SET_SINGER", { - singer: track.singer, - trackId, - }); - await dispatch("SET_KEY_RANGE_ADJUSTMENT", { - keyRangeAdjustment: track.keyRangeAdjustment, - trackId, - }); - await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { - volumeRangeAdjustment: track.volumeRangeAdjustment, - trackId, - }); - await dispatch("SET_NOTES", { notes: track.notes, trackId }); - await dispatch("CLEAR_PITCH_EDIT_DATA", { - trackId, - }); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 - await dispatch("SET_PITCH_EDIT_DATA", { - pitchArray: track.pitchEditData, - startFrame: 0, - trackId, - }); + // 色々な処理を動かすため、二重にセットする + // TODO: もっとスマートな方法を考える + await dispatch("SET_TRACK", { trackId, track }); } }, }, @@ -1043,12 +1070,12 @@ export const singingStore = createPartialStore({ phraseFirstRestDuration = Math.max( phraseFirstRestDuration, phraseFirstNote.position - - secondToTick( - tickToSecond(phraseFirstNote.position, tempos, tpqn) - - phraseFirstRestMinDurationSeconds, - tempos, - tpqn, - ), + secondToTick( + tickToSecond(phraseFirstNote.position, tempos, tpqn) - + phraseFirstRestMinDurationSeconds, + tempos, + tpqn, + ), ); // 1tick以上にする phraseFirstRestDuration = Math.max(1, phraseFirstRestDuration); @@ -1355,10 +1382,10 @@ export const singingStore = createPartialStore({ trackId, track.singer ? { - singer: track.singer, - frameRate: - state.engineManifests[track.singer.engineId].frameRate, - } + singer: track.singer, + frameRate: + state.engineManifests[track.singer.engineId].frameRate, + } : undefined, ]), ); @@ -1819,97 +1846,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, trackId: state.selectedTrackId }); - - commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); - commit("CLEAR_COMMANDS"); - dispatch("RENDER"); - }, - ), - }, - - // TODO: Undoできるようにする - IMPORT_VOICEVOX_PROJECT: { - action: createUILockAction( - async ({ state, commit, dispatch }, { project, trackIndex = 0 }) => { - const { tempos, timeSignatures, tracks, tpqn, trackOrder } = - project.song; - - const track = tracks[trackOrder[trackIndex]]; - if (!track) { - throw new Error("Track not found."); - } - const notes = track.notes.map((note) => ({ - ...note, - id: NoteId(crypto.randomUUID()), - })); - - if (tpqn !== state.tpqn) { - throw new Error("TPQN does not match. Must be converted."); - } - - // TODO: ここら辺のSET系の処理をまとめる - await dispatch("SET_SINGER", { - singer: track.singer, - trackId: state.selectedTrackId, - }); - await dispatch("SET_KEY_RANGE_ADJUSTMENT", { - keyRangeAdjustment: track.keyRangeAdjustment, - - trackId: state.selectedTrackId, - }); - await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { - volumeRangeAdjustment: track.volumeRangeAdjustment, - - trackId: state.selectedTrackId, - }); - await dispatch("SET_TPQN", { tpqn }); - await dispatch("SET_TEMPOS", { tempos }); - await dispatch("SET_TIME_SIGNATURES", { timeSignatures }); - await dispatch("SET_NOTES", { notes, trackId: state.selectedTrackId }); - await dispatch("CLEAR_PITCH_EDIT_DATA", { - trackId: state.selectedTrackId, - }); // FIXME: SET_PITCH_EDIT_DATAがセッターになれば不要 - await dispatch("SET_PITCH_EDIT_DATA", { - pitchArray: track.pitchEditData, - startFrame: 0, - trackId: state.selectedTrackId, - }); - - commit("SET_SAVED_LAST_COMMAND_UNIX_MILLISEC", null); - commit("CLEAR_COMMANDS"); - dispatch("RENDER"); - }, - ), - }, - FETCH_SING_FRAME_VOLUME: { async action( { dispatch }, @@ -2585,6 +2521,143 @@ export const singingCommandStore = transformCommandStore( dispatch("RENDER"); }, }, + + 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, dispatch }, + { tpqn, tempos, timeSignatures, tracks }, + ) { + const payload: { + track: Track; + trackId: TrackId; + overwrite: boolean; + }[] = []; + 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: state.selectedTrackId, + overwrite: true, + }); + } else { + const { trackId } = await dispatch("CREATE_TRACK"); + payload.push({ track, trackId, overwrite: false }); + } + } + + commit("COMMAND_IMPORT_TRACKS", { + tpqn, + tempos, + timeSignatures, + tracks: payload, + }); + + dispatch("RENDER"); + }, + }, + + COMMAND_IMPORT_UTAFORMATIX_PROJECT: { + action: createUILockAction( + async ({ state, 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, state.selectedTrackId); + + 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(crypto.randomUUID()), + })), + }; + }); + + 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(crypto.randomUUID()), + })), + }; + }); + + 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 007e17f25e..59e112077c 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -1034,14 +1034,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; }; @@ -1166,6 +1158,11 @@ export type SingingStoreTypes = { 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; @@ -1252,6 +1249,32 @@ export type SingingCommandStoreTypes = { trackId: TrackId; }): 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; + }; }; /* From 63dbe098505cfff7ef1af24bd4f91d2130c50a12 Mon Sep 17 00:00:00 2001 From: Nanashi Date: Sat, 6 Jul 2024 06:31:00 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=E3=83=9E=E3=83=AB=E3=83=81=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=AF=EF=BC=9A=E3=82=B5=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=83=90=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0=20(#2148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add: とりあえず移植 * Add: 型とかを追加 * Add: トラック追加できるように * Add: actionを追加 * Improve: 見た目を良い感じにする * Add: utaformatixの変換にnameを含める * Add: テストを追加 * Add: TODOを追加 * Delete: overlappingNoteInfosあたりを削除 * Add: フォールバックを追加 * Add: コメントを沢山追加 * Add: コメントをちょっと追加 * Add: コメントをもうちょっと追加 * Change: singer-name -> track-name * Delete: z-indexに頼らないようにする * Improve: CSS周りを整理 * Add: character周りをまとめる * add: cloneWithUnwrapProxyに包む * Add: 設定出来るように * Update: スナップショット更新 [update-snapshots] * Update: スナップショット更新 [update snapshots] * (スナップショットを更新) * Change: コメントの位置を変える * Change: QOptionGroupにする * Change: プロパティ名を変える * Refactor: 細かいところを良い感じにする Co-Authored-By: Hiroshiba * Code: コメントを足す Co-Authored-By: Hiroshiba * Code: コメントを足す * Delete: isDraggingを消す * Fix: 細かいところを修正 * (スナップショット更新) [update snapshots] * (スナップショットを更新) * Delete: TODOを消す Co-authored-by: Hiroshiba * Code: 順番を揃える Co-authored-by: Hiroshiba * Delete: $qを消す Co-authored-by: Hiroshiba * Change: volume -> gain * Change: singer-style -> singer-name * Code: コメントを追加 * Change: heightの指定をSingEditorに移す * Change: 影響を受けないようにするやり方を変える * Change: サイドバーの幅を保存する * Change: 幅を細かくする * Delete: 余白を消す * Change: content: strictに Co-authored-by: Hiroshiba * Change: watchEffectにする Co-authored-by: Hiroshiba * Delete: setTrackNameをなくす Co-authored-by: Hiroshiba * Change: コロンを使わないようにする Co-authored-by: Hiroshiba * Change: mutationの外でcloneWithUnwrapProxyを使う Co-authored-by: Hiroshiba * Add: watchEffectをインポートする * Change: 警告を出すのは開発時だけにする * Add: TODOを追加 * Change: 幅保存のロジックを変える Co-Authored-By: Hiroshiba * Update src/components/Sing/SequencerPitch.vue --------- Co-authored-by: github-actions[bot] Co-authored-by: Hiroshiba Co-authored-by: Hiroshiba --- package-lock.json | 6 + package.json | 1 + .../Dialog/ImportSongProjectDialog.vue | 7 +- .../Dialog/SettingDialog/SettingDialog.vue | 36 ++ src/components/Sing/ScoreSequencer.vue | 7 + src/components/Sing/SequencerNote.vue | 10 +- src/components/Sing/SequencerPitch.vue | 2 + src/components/Sing/SideBar/SideBar.vue | 156 ++++++++ src/components/Sing/SideBar/TrackItem.vue | 368 ++++++++++++++++++ src/components/Sing/SingEditor.vue | 41 +- src/components/Sing/ToolBar/ToolBar.vue | 14 + src/domain/project/index.ts | 6 + src/domain/project/schema.ts | 6 + src/helpers/cloneWithUnwrapProxy.ts | 6 + src/sing/domain.ts | 27 ++ src/sing/utaformatixProject/fromVoicevox.ts | 2 +- src/sing/utaformatixProject/toVoicevox.ts | 1 + src/store/setting.ts | 5 + src/store/singing.ts | 198 ++++++++++ src/store/type.ts | 96 +++++ src/type/preload.ts | 7 + ...3\203\203\343\203\210-1-browser-win32.png" | Bin 55355 -> 60476 bytes ...3\203\203\343\203\210-2-browser-win32.png" | Bin 53693 -> 54212 bytes tests/unit/lib/cloneWithUnwrapProxy.spec.ts | 29 ++ 24 files changed, 1015 insertions(+), 16 deletions(-) create mode 100644 src/components/Sing/SideBar/SideBar.vue create mode 100644 src/components/Sing/SideBar/TrackItem.vue create mode 100644 src/helpers/cloneWithUnwrapProxy.ts create mode 100644 tests/unit/lib/cloneWithUnwrapProxy.spec.ts diff --git a/package-lock.json b/package-lock.json index 4ae8deb995..52e292cafc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "multistream": "4.1.0", "pixi.js": "7.4.0", "quasar": "2.11.6", + "rfdc": "1.4.1", "semver": "7.5.4", "shlex": "2.1.2", "systeminformation": "5.21.15", @@ -20256,6 +20257,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/package.json b/package.json index fe8d183225..c2376a7a93 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "multistream": "4.1.0", "pixi.js": "7.4.0", "quasar": "2.11.6", + "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 9ebfb990b3..a4d2eadd57 100644 --- a/src/components/Dialog/ImportSongProjectDialog.vue +++ b/src/components/Dialog/ImportSongProjectDialog.vue @@ -46,7 +46,9 @@ /> - {{ index + 1 }}:{{ track.name }} + + {{ index + 1 }}:{{ track.name || DEFAULT_TRACK_NAME }} + ノート数:{{ track.noteLength }} @@ -101,6 +103,7 @@ 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(); @@ -206,8 +209,8 @@ function getProjectTracks(project: Project) { ) : project.project.song.trackOrder.map((trackId) => toTrack( - undefined, // zodが何故かundefinedを入れてくるので、null-safe operatorを使う + project.project.song.tracks[trackId]?.name, project.project.song.tracks[trackId]?.notes.length ?? 0, ), ); diff --git a/src/components/Dialog/SettingDialog/SettingDialog.vue b/src/components/Dialog/SettingDialog/SettingDialog.vue index 75574e3a90..4b1677c0a4 100644 --- a/src/components/Dialog/SettingDialog/SettingDialog.vue +++ b/src/components/Dialog/SettingDialog/SettingDialog.vue @@ -229,6 +229,17 @@ > + + + @@ -537,6 +548,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 +619,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, diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index ed97908219..5f8f58ff04 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -1244,6 +1244,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; diff --git a/src/components/Sing/SequencerNote.vue b/src/components/Sing/SequencerNote.vue index 81cbe4ab34..496160b9df 100644 --- a/src/components/Sing/SequencerNote.vue +++ b/src/components/Sing/SequencerNote.vue @@ -240,20 +240,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/SequencerPitch.vue b/src/components/Sing/SequencerPitch.vue index 5d475bdc89..b787280862 100644 --- a/src/components/Sing/SequencerPitch.vue +++ b/src/components/Sing/SequencerPitch.vue @@ -463,5 +463,7 @@ onUnmountedOrDeactivated(() => { overflow: hidden; z-index: 0; pointer-events: none; + + contain: strict; // canvasのサイズが変わるのを無視する } diff --git a/src/components/Sing/SideBar/SideBar.vue b/src/components/Sing/SideBar/SideBar.vue new file mode 100644 index 0000000000..028c90694d --- /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..7fecf1a6c9 --- /dev/null +++ b/src/components/Sing/SideBar/TrackItem.vue @@ -0,0 +1,368 @@ + + + diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index ff285be445..ecf72bd1da 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,15 @@ const props = defineProps<{ }>(); const store = useStore(); -//const $q = useQuasar(); + +const isSidebarOpen = computed(() => 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,16 +107,14 @@ onetimeWatch( await store.dispatch("SET_TIME_SIGNATURES", { timeSignatures: [createDefaultTimeSignature(1)], }); - await store.dispatch("SET_NOTES", { - notes: [], - trackId: store.state.selectedTrackId, - }); + 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", { + trackId, withRelated: true, - trackId: store.state.selectedTrackId, }); } catch (e) { window.backend.logError(e); diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index 3fa8b78a9d..34e218f72a 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -2,6 +2,13 @@
+ { 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( diff --git a/src/domain/project/index.ts b/src/domain/project/index.ts index d0bd3f1bc1..aee4ed3fab 100644 --- a/src/domain/project/index.ts +++ b/src/domain/project/index.ts @@ -12,6 +12,7 @@ import { DEFAULT_BEATS, DEFAULT_BPM, DEFAULT_TPQN, + DEFAULT_TRACK_NAME, } from "@/sing/domain"; const DEFAULT_SAMPLING_RATE = 24000; @@ -287,6 +288,11 @@ export const migrateProjectFileObject = async ( // 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; diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts index ad07bc4ab7..c23a6812f1 100644 --- a/src/domain/project/schema.ts +++ b/src/domain/project/schema.ts @@ -85,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(), }); // プロジェクトファイルのスキーマ 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/domain.ts b/src/sing/domain.ts index 1e7553c08d..f7d6b44e6e 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -15,6 +15,7 @@ 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; @@ -287,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; @@ -328,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, }; } @@ -557,3 +566,21 @@ export const splitLyricsByMoras = ( } return moraAndNonMoras; }; + +/** + * トラックのミュート・ソロ状態から再生すべきトラックを判定する。 + * + * ソロのトラックが存在する場合は、ソロのトラックのみ再生する。(ミュートは無視される) + * ソロのトラックが存在しない場合は、ミュートされていないトラックを再生する。 + */ +export const shouldPlayTracks = ( + tracks: Map, +): Map => { + const soloTrackExists = [...tracks.values()].some((track) => track.solo); + const shouldPlayMap = new Map(); + for (const [trackKey, track] of tracks) { + shouldPlayMap.set(trackKey, soloTrackExists ? track.solo : !track.mute); + } + + return shouldPlayMap; +}; diff --git a/src/sing/utaformatixProject/fromVoicevox.ts b/src/sing/utaformatixProject/fromVoicevox.ts index fba4a3ee9b..f369ccb45c 100644 --- a/src/sing/utaformatixProject/fromVoicevox.ts +++ b/src/sing/utaformatixProject/fromVoicevox.ts @@ -24,7 +24,7 @@ export const ufProjectFromVoicevox = ( denominator: timeSignature.beatType, })), tracks: tracks.map((track) => ({ - name: `無名トラック`, + name: track.name, notes: track.notes.map((note) => ({ key: note.noteNumber, tickOn: convertTicks(note.position), diff --git a/src/sing/utaformatixProject/toVoicevox.ts b/src/sing/utaformatixProject/toVoicevox.ts index f3e2448d67..9b10fe31ed 100644 --- a/src/sing/utaformatixProject/toVoicevox.ts +++ b/src/sing/utaformatixProject/toVoicevox.ts @@ -87,6 +87,7 @@ export const ufProjectToVoicevox = (project: UfProject): VoicevoxScore => { return { ...createDefaultTrack(), + name: projectTrack.name, notes, }; }); diff --git a/src/store/setting.ts b/src/store/setting.ts index 6200c8fe44..7aa4bdf87e 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -65,6 +65,10 @@ export const settingStoreState: SettingStoreState = { enableMultiEngine: false, enableMemoNotation: false, enableRubyNotation: false, + songUndoableTrackOptions: { + soloAndMute: true, + panAndGain: true, + }, }; export const settingStore = createPartialStore({ @@ -141,6 +145,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 c774dc2da2..8f934f3a3a 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -86,6 +86,7 @@ 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"; const logger = createLogger("store/singing"); @@ -169,6 +170,7 @@ export const singingStoreState: SingingStoreState = { nowRendering: false, nowAudioExporting: false, cancellationOfAudioExportRequested: false, + isSongSidebarOpen: false, }; export const singingStore = createPartialStore({ @@ -914,6 +916,25 @@ export const singingStore = createPartialStore({ }, }, + DELETE_TRACK: { + mutation(state, { trackId }) { + state.tracks.delete(trackId); + state.trackOrder = state.trackOrder.filter((value) => value !== trackId); + state.overlappingNoteIds.delete(trackId); + if (state.selectedTrackId === trackId) { + state.selectedTrackId = state.trackOrder[0]; + } + }, + 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.selectedTrackId = trackId; @@ -2235,6 +2256,94 @@ export const singingStore = createPartialStore({ 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 }, { trackId, mute }) { + commit("SET_TRACK_MUTE", { trackId, mute }); + }, + }, + + SET_TRACK_SOLO: { + mutation(state, { trackId, solo }) { + const track = getOrThrow(state.tracks, trackId); + track.solo = solo; + }, + action({ commit }, { trackId, solo }) { + commit("SET_TRACK_SOLO", { trackId, solo }); + }, + }, + + SET_TRACK_GAIN: { + mutation(state, { trackId, gain }) { + const track = getOrThrow(state.tracks, trackId); + track.gain = gain; + }, + action({ commit }, { trackId, gain }) { + commit("SET_TRACK_GAIN", { trackId, gain }); + }, + }, + + SET_TRACK_PAN: { + mutation(state, { trackId, pan }) { + const track = getOrThrow(state.tracks, trackId); + track.pan = pan; + }, + action({ commit }, { trackId, pan }) { + commit("SET_TRACK_PAN", { trackId, pan }); + }, + }, + + 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"); + }, + }, }); export const singingCommandStoreState: SingingCommandStoreState = {}; @@ -2483,6 +2592,95 @@ export const singingCommandStore = transformCommandStore( }, }, + 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 }, { trackId }) { + commit("COMMAND_DELETE_TRACK", { trackId }); + }, + }, + + 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 }, { trackId, mute }) { + commit("COMMAND_SET_TRACK_MUTE", { trackId, mute }); + }, + }, + + COMMAND_SET_TRACK_SOLO: { + mutation(draft, { trackId, solo }) { + singingStore.mutations.SET_TRACK_SOLO(draft, { trackId, solo }); + }, + action({ commit }, { trackId, solo }) { + commit("COMMAND_SET_TRACK_SOLO", { trackId, solo }); + }, + }, + + COMMAND_SET_TRACK_GAIN: { + mutation(draft, { trackId, gain }) { + singingStore.mutations.SET_TRACK_GAIN(draft, { trackId, gain }); + }, + action({ commit }, { trackId, gain }) { + commit("COMMAND_SET_TRACK_GAIN", { trackId, gain }); + }, + }, + + COMMAND_SET_TRACK_PAN: { + mutation(draft, { trackId, pan }) { + singingStore.mutations.SET_TRACK_PAN(draft, { trackId, pan }); + }, + action({ commit }, { trackId, pan }) { + commit("COMMAND_SET_TRACK_PAN", { trackId, pan }); + }, + }, + + 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 }); diff --git a/src/store/type.ts b/src/store/type.ts index 2c79e4abe3..a663cbe121 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -842,6 +842,7 @@ export type SingingStoreState = { nowRendering: boolean; nowAudioExporting: boolean; cancellationOfAudioExportRequested: boolean; + isSongSidebarOpen: boolean; }; export type SingingStoreTypes = { @@ -1151,6 +1152,11 @@ export type SingingStoreTypes = { 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; @@ -1165,6 +1171,51 @@ export type SingingStoreTypes = { 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; + }; }; export type SingingCommandStoreState = { @@ -1248,6 +1299,51 @@ export type SingingCommandStoreTypes = { }): 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; diff --git a/src/type/preload.ts b/src/type/preload.ts index 80031ca5c8..2354aa9be2 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -599,6 +599,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 571f3498bb56d77373409b9f1d194504812dcfb5..6b0ad186281058ab4d255fbf104ece5c4ae176fc 100644 GIT binary patch delta 44055 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 delta 38893 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