Skip to content

Commit

Permalink
[ソング] Undo/Redoの実装 (VOICEVOX#1836)
Browse files Browse the repository at this point in the history
* [wip] add song undo redo function

* improve last command time

* clear song commands too

* add undo redo button to song toolbar

* resolve conflict

* fix vuex test

* improve command set singer (add setup singer)

* refactor set tempo

* refactor remove tempo

* refactor set time signature

* refactor remove time signature

* refactor add notes

* refactor update notes

* refactor remove notes

* remove remove selected notes

* remove comment

* add COMMAND_SET_VOICE_KEY_SHIFT

* revert comments

* remove song

* create overlapping note infos type

* remove overlapping notes detector

* add return type

* remove comment

* remove copy and modify destructively

* add editor type and use it

* integrate undo redo queues of talk and song

* must editor type args

* refactor LAST_COMMAND_UNIX_MILLISEC

* fix store test

* update unit test

* remove export

Co-authored-by: Hiroshiba <[email protected]>

* move func

* デザイン微調整

---------

Co-authored-by: Hiroshiba <[email protected]>
  • Loading branch information
y-chan and Hiroshiba authored Feb 20, 2024
1 parent 8bb3f42 commit 008f020
Show file tree
Hide file tree
Showing 13 changed files with 502 additions and 287 deletions.
5 changes: 3 additions & 2 deletions src/components/Menu/MenuBar/TitleBarEditorSwitcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,22 @@
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "@/store";
import { EditorType } from "@/type/preload";

const store = useStore();
const router = useRouter();

const uiLocked = computed(() => store.getters.UI_LOCKED);

const nowEditor = computed<"talk" | "song">(() => {
const nowEditor = computed<EditorType>(() => {
const path = router.currentRoute.value.path;
if (path === "/talk") return "talk";
if (path === "/song") return "song";
window.electron.logWarn(`unknown path: ${path}`);
return "talk";
});

const gotoLink = (editor: "talk" | "song") => {
const gotoLink = (editor: EditorType) => {
router.push("/" + editor);
};
</script>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Sing/CharacterMenuButton/MenuButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ const changeStyleId = (speakerUuid: SpeakerId, styleId: StyleId) => {
`No engineId for target character style (speakerUuid == ${speakerUuid}, styleId == ${styleId})`
);
store.dispatch("SET_SINGER", { singer: { engineId, styleId } });
store.dispatch("COMMAND_SET_SINGER", { singer: { engineId, styleId } });
};
const getDefaultStyle = (speakerUuid: string) => {
Expand Down
16 changes: 8 additions & 8 deletions src/components/Sing/ScoreSequencer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -733,12 +733,12 @@ const onMouseUp = (event: MouseEvent) => {
cancelAnimationFrame(previewRequestId);
if (edited) {
if (previewMode === "ADD") {
store.dispatch("ADD_NOTES", { notes: previewNotes.value });
store.dispatch("COMMAND_ADD_NOTES", { notes: previewNotes.value });
store.dispatch("SELECT_NOTES", {
noteIds: previewNotes.value.map((value) => value.id),
});
} else {
store.dispatch("UPDATE_NOTES", { notes: previewNotes.value });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: previewNotes.value });
}
if (previewNotes.value.length === 1) {
store.dispatch("PLAY_PREVIEW_SOUND", {
Expand Down Expand Up @@ -783,7 +783,7 @@ const handleNotesArrowUp = () => {
if (editedNotes.some((note) => note.noteNumber > 127)) {
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });
if (editedNotes.length === 1) {
store.dispatch("PLAY_PREVIEW_SOUND", {
Expand All @@ -802,7 +802,7 @@ const handleNotesArrowDown = () => {
if (editedNotes.some((note) => note.noteNumber < 0)) {
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });
if (editedNotes.length === 1) {
store.dispatch("PLAY_PREVIEW_SOUND", {
Expand All @@ -822,7 +822,7 @@ const handleNotesArrowRight = () => {
// TODO: 例外処理は`UPDATE_NOTES`内に移す?
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });
};
const handleNotesArrowLeft = () => {
Expand All @@ -837,15 +837,15 @@ const handleNotesArrowLeft = () => {
) {
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });
};
const handleNotesBackspaceOrDelete = () => {
if (state.selectedNoteIds.size === 0) {
// TODO: 例外処理は`REMOVE_SELECTED_NOTES`内に移す?
// TODO: 例外処理は`COMMAND_REMOVE_SELECTED_NOTES`内に移す?
return;
}
store.dispatch("REMOVE_SELECTED_NOTES");
store.dispatch("COMMAND_REMOVE_SELECTED_NOTES");
};
const handleKeydown = (event: KeyboardEvent) => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/Sing/SequencerNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ const lyric = computed({
return;
}
const note: Note = { ...props.note, lyric: value };
store.dispatch("UPDATE_NOTES", { notes: [note] });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: [note] });
},
});
const showLyricInput = computed(() => {
Expand All @@ -123,7 +123,7 @@ const contextMenuData = ref<[MenuItemButton]>([
label: "削除",
onClick: async () => {
contextMenu.value?.hide();
store.dispatch("REMOVE_SELECTED_NOTES");
store.dispatch("COMMAND_REMOVE_SELECTED_NOTES");
},
disableWhenUiLocked: true,
},
Expand Down
45 changes: 42 additions & 3 deletions src/components/Sing/ToolBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@
</div>
<!-- settings for edit controls -->
<div class="sing-controls">
<q-btn
flat
dense
round
icon="undo"
class="sing-undo-button"
:disable="!canUndo"
@click="undo"
/>
<q-btn
flat
dense
round
icon="redo"
class="sing-redo-button"
:disable="!canRedo"
@click="redo"
/>
<q-icon name="volume_up" size="xs" class="sing-volume-icon" />
<q-slider v-model.number="volume" class="sing-volume" />
<q-select
Expand Down Expand Up @@ -118,6 +136,17 @@ import CharacterMenuButton from "@/components/Sing/CharacterMenuButton/MenuButto
const store = useStore();
const editor = "song";
const canUndo = computed(() => store.getters.CAN_UNDO(editor));
const canRedo = computed(() => store.getters.CAN_REDO(editor));
const undo = () => {
store.dispatch("UNDO", { editor });
};
const redo = () => {
store.dispatch("REDO", { editor });
};
const tempos = computed(() => store.state.tempos);
const timeSignatures = computed(() => store.state.timeSignatures);
const keyShift = computed(() => store.getters.SELECTED_TRACK.voiceKeyShift);
Expand Down Expand Up @@ -182,7 +211,7 @@ const setKeyShiftInputBuffer = (keyShiftStr: string | number | null) => {
const setTempo = () => {
const bpm = bpmInputBuffer.value;
store.dispatch("SET_TEMPO", {
store.dispatch("COMMAND_SET_TEMPO", {
tempo: {
position: 0,
bpm,
Expand All @@ -193,7 +222,7 @@ const setTempo = () => {
const setTimeSignature = () => {
const beats = beatsInputBuffer.value;
const beatType = beatTypeInputBuffer.value;
store.dispatch("SET_TIME_SIGNATURE", {
store.dispatch("COMMAND_SET_TIME_SIGNATURE", {
timeSignature: {
measureNumber: 1,
beats,
Expand All @@ -204,7 +233,7 @@ const setTimeSignature = () => {
const setKeyShift = () => {
const voiceKeyShift = keyShiftInputBuffer.value;
store.dispatch("SET_VOICE_KEY_SHIFT", { voiceKeyShift });
store.dispatch("COMMAND_SET_VOICE_KEY_SHIFT", { voiceKeyShift });
};
const playheadTicks = ref(0);
Expand Down Expand Up @@ -407,6 +436,16 @@ onUnmounted(() => {
flex: 1;
}
.sing-undo-button,
.sing-redo-button {
&.disabled {
opacity: 0.4 !important;
}
}
.sing-redo-button {
margin-right: 16px;
}
.sing-volume-icon {
margin-right: 8px;
opacity: 0.6;
Expand Down
9 changes: 5 additions & 4 deletions src/components/Talk/ToolBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ type SpacerContent = {
const store = useStore();
const uiLocked = computed(() => store.getters.UI_LOCKED);
const canUndo = computed(() => store.getters.CAN_UNDO);
const canRedo = computed(() => store.getters.CAN_REDO);
const editor = "talk";
const canUndo = computed(() => store.getters.CAN_UNDO(editor));
const canRedo = computed(() => store.getters.CAN_REDO(editor));
const activeAudioKey = computed(() => store.getters.ACTIVE_AUDIO_KEY);
const nowPlayingContinuously = computed(
() => store.state.nowPlayingContinuously
Expand Down Expand Up @@ -98,10 +99,10 @@ const hotkeyMap = new Map<HotkeyActionType, () => HotkeyReturnType>([
setHotkeyFunctions(hotkeyMap);
const undo = () => {
store.dispatch("UNDO");
store.dispatch("UNDO", { editor });
};
const redo = () => {
store.dispatch("REDO");
store.dispatch("REDO", { editor });
};
const playContinuously = async () => {
try {
Expand Down
144 changes: 73 additions & 71 deletions src/sing/storeHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,89 +60,91 @@ type NoteInfo = {
overlappingNoteIds: Set<string>;
};

/**
* 重なっているノートを検出します。
*/
export class OverlappingNotesDetector {
private readonly noteInfos = new Map<string, NoteInfo>();

addNotes(notes: Note[]) {
for (const note of notes) {
this.noteInfos.set(note.id, {
startTicks: note.position,
endTicks: note.position + note.duration,
overlappingNoteIds: new Set<string>(),
});
}
// TODO: 計算量がO(n^2)になっているので、区間木などを使用してO(nlogn)にする
for (const note of notes) {
const overlappingNoteIds = new Set<string>();
for (const [noteId, noteInfo] of this.noteInfos) {
if (noteId === note.id) {
continue;
}
if (noteInfo.startTicks >= note.position + note.duration) {
continue;
}
if (noteInfo.endTicks <= note.position) {
continue;
}
overlappingNoteIds.add(noteId);
export type OverlappingNoteInfos = Map<string, NoteInfo>;

export function addNotesToOverlappingNoteInfos(
overlappingNoteInfos: OverlappingNoteInfos,
notes: Note[]
): void {
for (const note of notes) {
overlappingNoteInfos.set(note.id, {
startTicks: note.position,
endTicks: note.position + note.duration,
overlappingNoteIds: new Set(),
});
}
// TODO: 計算量がO(n^2)になっているので、区間木などを使用してO(nlogn)にする
for (const note of notes) {
const overlappingNoteIds = new Set<string>();
for (const [noteId, noteInfo] of overlappingNoteInfos) {
if (noteId === note.id) {
continue;
}

const noteId1 = note.id;
const noteInfo1 = this.noteInfos.get(noteId1);
if (!noteInfo1) {
throw new Error("noteInfo1 is undefined.");
if (noteInfo.startTicks >= note.position + note.duration) {
continue;
}
for (const noteId2 of overlappingNoteIds) {
const noteInfo2 = this.noteInfos.get(noteId2);
if (!noteInfo2) {
throw new Error("noteInfo2 is undefined.");
}
noteInfo2.overlappingNoteIds.add(noteId1);
noteInfo1.overlappingNoteIds.add(noteId2);
if (noteInfo.endTicks <= note.position) {
continue;
}
overlappingNoteIds.add(noteId);
}
}

removeNotes(notes: Note[]) {
for (const note of notes) {
const noteId1 = note.id;
const noteInfo1 = this.noteInfos.get(noteId1);
if (!noteInfo1) {
throw new Error("noteInfo1 is undefined.");
}
for (const noteId2 of noteInfo1.overlappingNoteIds) {
const noteInfo2 = this.noteInfos.get(noteId2);
if (!noteInfo2) {
throw new Error("noteInfo2 is undefined.");
}
noteInfo2.overlappingNoteIds.delete(noteId1);
noteInfo1.overlappingNoteIds.delete(noteId2);
}
const noteId1 = note.id;
const noteInfo1 = overlappingNoteInfos.get(noteId1);
if (!noteInfo1) {
throw new Error("noteInfo1 is undefined.");
}
for (const note of notes) {
this.noteInfos.delete(note.id);
for (const noteId2 of overlappingNoteIds) {
const noteInfo2 = overlappingNoteInfos.get(noteId2);
if (!noteInfo2) {
throw new Error("noteInfo2 is undefined.");
}
noteInfo2.overlappingNoteIds.add(noteId1);
noteInfo1.overlappingNoteIds.add(noteId2);
}
}
}

updateNotes(notes: Note[]) {
this.removeNotes(notes);
this.addNotes(notes);
}

getOverlappingNoteIds() {
const overlappingNoteIds = new Set<string>();
for (const [noteId, noteInfo] of this.noteInfos) {
if (noteInfo.overlappingNoteIds.size !== 0) {
overlappingNoteIds.add(noteId);
export function removeNotesFromOverlappingNoteInfos(
overlappingNoteInfos: OverlappingNoteInfos,
notes: Note[]
): void {
for (const note of notes) {
const noteId1 = note.id;
const noteInfo1 = overlappingNoteInfos.get(noteId1);
if (!noteInfo1) {
throw new Error("noteInfo1 is undefined.");
}
for (const noteId2 of noteInfo1.overlappingNoteIds) {
const noteInfo2 = overlappingNoteInfos.get(noteId2);
if (!noteInfo2) {
throw new Error("noteInfo2 is undefined.");
}
noteInfo2.overlappingNoteIds.delete(noteId1);
noteInfo1.overlappingNoteIds.delete(noteId2);
}
return overlappingNoteIds;
}
for (const note of notes) {
overlappingNoteInfos.delete(note.id);
}
}

clear() {
this.noteInfos.clear();
export function updateNotesOfOverlappingNoteInfos(
overlappingNoteInfos: OverlappingNoteInfos,
notes: Note[]
): void {
removeNotesFromOverlappingNoteInfos(overlappingNoteInfos, notes);
addNotesToOverlappingNoteInfos(overlappingNoteInfos, notes);
}

export function getOverlappingNoteIds(
currentNoteInfos: OverlappingNoteInfos
): Set<string> {
const overlappingNoteIds = new Set<string>();
for (const [noteId, noteInfo] of currentNoteInfos) {
if (noteInfo.overlappingNoteIds.size !== 0) {
overlappingNoteIds.add(noteId);
}
}
return overlappingNoteIds;
}
3 changes: 2 additions & 1 deletion src/store/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2885,5 +2885,6 @@ export const audioCommandStore = transformCommandStore(
}
),
},
})
}),
"talk"
);
Loading

0 comments on commit 008f020

Please sign in to comment.