-
-
+
+
@@ -97,7 +97,6 @@ const selectedStyleIconPath = computed(() => {
}
.character-avatar {
- background: var(--scheme-color-background);
width: 36px;
height: 36px;
}
@@ -139,10 +138,16 @@ const selectedStyleIconPath = computed(() => {
.character-style {
color: var(--scheme-color-on-surface-variant);
font-size: 9px;
- transform: translateY(-5px);
font-weight: 400;
line-height: 16px;
margin-bottom: 8px;
+
+ &.skeleton {
+ transform: translateY(5px);
+ }
+ &:not(.skeleton) {
+ transform: translateY(-5px);
+ }
}
.character-menu-dropdown-icon {
diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue
index cd25e6f370..da50e1dbd4 100644
--- a/src/components/Sing/ScoreSequencer.vue
+++ b/src/components/Sing/ScoreSequencer.vue
@@ -24,6 +24,7 @@
@mousedown="onMouseDown"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
+ @dblclick.stop="onDoubleClick"
@wheel="onWheel"
@scroll="onScroll"
@contextmenu.prevent
@@ -152,10 +153,23 @@
trackSize="2px"
@update:modelValue="setZoomY"
/>
-
+
+ store.dispatch('SET_SEQUENCER_NOTE_TOOL', {
+ sequencerNoteTool: value,
+ })
+ "
+ @update:sequencerPitchTool="
+ (value) =>
+ store.dispatch('SET_SEQUENCER_PITCH_TOOL', {
+ sequencerPitchTool: value,
+ })
+ "
/>
@@ -175,7 +189,12 @@ import ContextMenu, {
} from "@/components/Menu/ContextMenu/Container.vue";
import { NoteId } from "@/type/preload";
import { useStore } from "@/store";
-import { Note, SequencerEditTarget } from "@/store/type";
+import {
+ Note,
+ SequencerEditTarget,
+ NoteEditTool,
+ PitchEditTool,
+} from "@/store/type";
import {
getEndTicksOfPhrase,
getNoteDuration,
@@ -200,6 +219,10 @@ import {
PREVIEW_SOUND_DURATION,
getButton,
PreviewMode,
+ MouseButton,
+ MouseDownBehavior,
+ MouseDoubleClickBehavior,
+ CursorState,
} from "@/sing/viewHelper";
import SequencerGrid from "@/components/Sing/SequencerGrid/Container.vue";
import SequencerRuler from "@/components/Sing/SequencerRuler/Container.vue";
@@ -210,6 +233,7 @@ import SequencerPhraseIndicator from "@/components/Sing/SequencerPhraseIndicator
import CharacterPortrait from "@/components/Sing/CharacterPortrait.vue";
import SequencerPitch from "@/components/Sing/SequencerPitch.vue";
import SequencerLyricInput from "@/components/Sing/SequencerLyricInput.vue";
+import SequencerToolPalette from "@/components/Sing/SequencerToolPalette.vue";
import { isOnCommandOrCtrlKeyDown } from "@/store/utility";
import { createLogger } from "@/domain/frontend/log";
import { useHotkeyManager } from "@/plugins/hotkeyPlugin";
@@ -219,7 +243,6 @@ import {
} from "@/composables/useModifierKey";
import { applyGaussianFilter, linearInterpolation } from "@/sing/utility";
import { useLyricInput } from "@/composables/useLyricInput";
-import { useCursorState, CursorState } from "@/composables/useCursorState";
import { ExhaustiveError } from "@/type/utility";
import { uuid4 } from "@/helpers/random";
@@ -231,6 +254,7 @@ const isSelfEventTarget = (event: UIEvent) => {
const { warn } = createLogger("ScoreSequencer");
const store = useStore();
const state = store.state;
+
// 選択中のトラックID
const selectedTrackId = computed(() => store.getters.SELECTED_TRACK_ID);
@@ -356,7 +380,6 @@ const phraseInfosInOtherTracks = computed(() => {
});
const ctrlKey = useCommandOrControlKey();
-const editTarget = computed(() => state.sequencerEditTarget);
const editorFrameRate = computed(() => state.editorFrameRate);
const scrollBarWidth = ref(12);
const sequencerBody = ref
(null);
@@ -365,8 +388,6 @@ const sequencerBody = ref(null);
const cursorX = ref(0);
const cursorY = ref(0);
-const { cursorClass, setCursorState } = useCursorState();
-
// 歌詞入力
const { previewLyrics, commitPreviewLyrics, splitAndUpdatePreview } =
useLyricInput();
@@ -419,31 +440,235 @@ const editingLyricNote = computed(() => {
const showGuideLine = ref(true);
const guideLineX = ref(0);
-// プレビュー中でないときの処理
-// TODO: ステートパターンにして、この処理をIdleStateに移す
-watch([ctrlKey, shiftKey, nowPreviewing, editTarget], () => {
- if (nowPreviewing.value) {
- return;
+// 編集モード
+// NOTE: ステートマシン実装後に削除する
+// 議論 https://github.com/VOICEVOX/voicevox/pull/2367#discussion_r1853262865
+
+// 編集モードの外部コンテキスト
+interface EditModeContext {
+ readonly ctrlKey: boolean;
+ readonly shiftKey: boolean;
+ readonly nowPreviewing: boolean;
+ readonly editTarget: SequencerEditTarget;
+ readonly sequencerNoteTool: NoteEditTool;
+ readonly sequencerPitchTool: PitchEditTool;
+ readonly isSelfEventTarget?: boolean;
+ readonly mouseButton?: MouseButton;
+ readonly editingLyricNoteId?: NoteId;
+}
+
+// 編集対象
+const editTarget = computed(() => store.state.sequencerEditTarget);
+// 選択中のノート編集ツール
+const sequencerNoteTool = computed(() => state.sequencerNoteTool);
+// 選択中のピッチ編集ツール
+const sequencerPitchTool = computed(() => state.sequencerPitchTool);
+
+/**
+ * マウスダウン時の振る舞いを判定する
+ * 条件の判定のみを行い、実際の処理は呼び出し側で行う
+ */
+const determineMouseDownBehavior = (
+ context: EditModeContext,
+): MouseDownBehavior => {
+ const { isSelfEventTarget, mouseButton, editingLyricNoteId } = context;
+
+ // プレビュー中は無視
+ if (nowPreviewing.value) return "IGNORE";
+
+ // ノート編集の場合
+ if (editTarget.value === "NOTE") {
+ // イベントが来ていない場合は無視
+ if (!isSelfEventTarget) return "IGNORE";
+ // 歌詞編集中は無視
+ if (editingLyricNoteId != undefined) return "IGNORE";
+
+ // 左クリックの場合
+ if (mouseButton === "LEFT_BUTTON") {
+ // シフトキーが押されている場合は常に矩形選択開始
+ if (shiftKey.value) return "START_RECT_SELECT";
+
+ // 編集優先ツールの場合
+ if (sequencerNoteTool.value === "EDIT_FIRST") {
+ // コントロールキーが押されている場合は全選択解除
+ if (ctrlKey.value) {
+ return "DESELECT_ALL";
+ }
+ return "ADD_NOTE";
+ }
+
+ // 選択優先ツールの場合
+ if (sequencerNoteTool.value === "SELECT_FIRST") {
+ // 矩形選択開始
+ return "START_RECT_SELECT";
+ }
+ }
+
+ return "DESELECT_ALL";
}
+
+ // ピッチ編集の場合
if (editTarget.value === "PITCH") {
+ // 左クリック以外は無視
+ if (mouseButton !== "LEFT_BUTTON") return "IGNORE";
+
+ // ピッチ削除ツールが選択されている場合はピッチ削除
+ if (sequencerPitchTool.value === "ERASE") {
+ return "ERASE_PITCH";
+ }
+
+ // それ以外はピッチ編集
+ return "DRAW_PITCH";
+ }
+
+ return "IGNORE";
+};
+
+/**
+ * ダブルクリック時の振る舞いを判定する
+ */
+const determineDoubleClickBehavior = (
+ context: EditModeContext,
+): MouseDoubleClickBehavior => {
+ const { isSelfEventTarget, mouseButton } = context;
+
+ // ノート編集の場合
+ if (editTarget.value === "NOTE") {
+ // 直接イベントが来ていない場合は無視
+ if (!isSelfEventTarget) return "IGNORE";
+
+ // プレビュー中は無視
+ if (nowPreviewing.value) return "IGNORE";
+
+ // 選択優先ツールではノート追加
+ if (mouseButton === "LEFT_BUTTON") {
+ if (sequencerNoteTool.value === "SELECT_FIRST") {
+ return "ADD_NOTE";
+ }
+ }
+ return "IGNORE";
+ }
+
+ return "IGNORE";
+};
+
+// 以下のtoolChangedByCtrlは2024/12/04時点での期待動作が以下のため必要…
+//
+// DRAW選択時 → Ctrlキー押す → → Ctrlキー離す → DRAWに戻る
+// ERASE選択時 → Ctrlキー押す → なにも起こらない(DRAWに変更されない)
+//
+// 単純にCtrlキーやPitchToolの新旧比較ではCtrlキー離されたときに常にツールがDRAWに戻ってしまうため
+// 一時的な切り替えであることを保持しておく必要がある
+
+// Ctrlキーが押されたときにピッチツールを変更したかどうか
+const toolChangedByCtrl = ref(false);
+
+// ピッチ編集モードにおいてCtrlキーが押されたときにピッチツールを消しゴムツールにする
+watch([ctrlKey], () => {
+ // ピッチ編集モードでない場合は無視
+ if (editTarget.value !== "PITCH") {
+ return;
+ }
+
+ // 現在のツールがピッチ描画ツールの場合
+ if (sequencerPitchTool.value === "DRAW") {
+ // Ctrlキーが押されたときはピッチ削除ツールに変更
if (ctrlKey.value) {
- // ピッチ消去
- setCursorState(CursorState.ERASE);
- } else {
- // ピッチ描画
- setCursorState(CursorState.DRAW);
+ void store.actions.SET_SEQUENCER_PITCH_TOOL({
+ sequencerPitchTool: "ERASE",
+ });
+ toolChangedByCtrl.value = true;
+ }
+ }
+
+ // 現在のツールがピッチ削除ツールかつCtrlキーが離されたとき
+ if (sequencerPitchTool.value === "ERASE" && toolChangedByCtrl.value) {
+ // ピッチ描画ツールに戻す
+ if (!ctrlKey.value) {
+ void store.actions.SET_SEQUENCER_PITCH_TOOL({
+ sequencerPitchTool: "DRAW",
+ });
+ toolChangedByCtrl.value = false;
}
}
+});
+
+// カーソルの状態
+// TODO: ステートマシン実装後に削除する
+// 議論 https://github.com/VOICEVOX/voicevox/pull/2367#discussion_r1853262865
+
+/**
+ * カーソルの状態を関連するコンテキストから取得する
+ */
+const determineCursorBehavior = (): CursorState => {
+ // プレビューの場合
+ if (nowPreviewing.value && previewMode.value !== "IDLE") {
+ switch (previewMode.value) {
+ case "ADD_NOTE":
+ return "DRAW";
+ case "MOVE_NOTE":
+ return "MOVE";
+ case "RESIZE_NOTE_RIGHT":
+ case "RESIZE_NOTE_LEFT":
+ return "EW_RESIZE";
+ case "DRAW_PITCH":
+ return "DRAW";
+ case "ERASE_PITCH":
+ return "ERASE";
+ default:
+ return "UNSET";
+ }
+ }
+
+ // ノート編集の場合
if (editTarget.value === "NOTE") {
+ // シフトキーが押されていたら常に十字カーソル
if (shiftKey.value) {
- // 範囲選択
- setCursorState(CursorState.CROSSHAIR);
- } else {
- setCursorState(CursorState.UNSET);
+ return "CROSSHAIR";
+ }
+ // ノート編集ツールが選択されておりCtrlキーが押されていない場合は描画カーソル
+ if (sequencerNoteTool.value === "EDIT_FIRST" && !ctrlKey.value) {
+ return "DRAW";
}
+ // それ以外は未設定
+ return "UNSET";
+ }
+
+ // ピッチ編集の場合
+ if (editTarget.value === "PITCH") {
+ // 描画ツールが選択されていたら描画カーソル
+ if (sequencerPitchTool.value === "DRAW") {
+ return "DRAW";
+ }
+ // 削除ツールが選択されていたら消しゴムカーソル
+ if (sequencerPitchTool.value === "ERASE") {
+ return "ERASE";
+ }
+ }
+ return "UNSET";
+};
+
+// カーソル用のCSSクラス名ヘルパー
+const cursorClass = computed(() => {
+ switch (cursorState.value) {
+ case "EW_RESIZE":
+ return "cursor-ew-resize";
+ case "CROSSHAIR":
+ return "cursor-crosshair";
+ case "MOVE":
+ return "cursor-move";
+ case "DRAW":
+ return "cursor-draw";
+ case "ERASE":
+ return "cursor-erase";
+ default:
+ return "";
}
});
+// カーソルの状態
+const cursorState = computed(() => determineCursorBehavior());
+
const previewAdd = () => {
const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value;
const cursorTicks = baseXToTick(cursorBaseX, tpqn.value);
@@ -475,7 +700,6 @@ const previewAdd = () => {
const guideLineBaseX = tickToBaseX(noteEndPos, tpqn.value);
guideLineX.value = guideLineBaseX * zoomX.value;
- setCursorState(CursorState.DRAW);
};
const previewMove = () => {
@@ -529,7 +753,6 @@ const previewMove = () => {
tpqn.value,
);
guideLineX.value = guideLineBaseX * zoomX.value;
- setCursorState(CursorState.MOVE);
};
const previewResizeRight = () => {
@@ -570,7 +793,6 @@ const previewResizeRight = () => {
const guideLineBaseX = tickToBaseX(newNoteEndPos, tpqn.value);
guideLineX.value = guideLineBaseX * zoomX.value;
- setCursorState(CursorState.EW_RESIZE);
};
const previewResizeLeft = () => {
@@ -618,7 +840,6 @@ const previewResizeLeft = () => {
const guideLineBaseX = tickToBaseX(newNotePos, tpqn.value);
guideLineX.value = guideLineBaseX * zoomX.value;
- setCursorState(CursorState.EW_RESIZE);
};
// ピッチを描く処理を行う
@@ -693,7 +914,6 @@ const previewDrawPitch = () => {
previewPitchEdit.value = tempPitchEdit;
prevCursorPos.frame = cursorFrame;
prevCursorPos.frequency = cursorFrequency;
- setCursorState(CursorState.DRAW);
};
// ドラッグした範囲のピッチ編集データを消去する処理を行う
@@ -726,7 +946,6 @@ const previewErasePitch = () => {
previewPitchEdit.value = tempPitchEdit;
prevCursorPos.frame = cursorFrame;
- setCursorState(CursorState.ERASE);
};
const preview = () => {
@@ -818,6 +1037,7 @@ const startPreview = (event: MouseEvent, mode: PreviewMode, note?: Note) => {
throw new Error("note is undefined.");
}
if (event.shiftKey) {
+ // Shiftキーが押されている場合は選択ノートまでの範囲選択
let minIndex = notesInSelectedTrack.value.length - 1;
let maxIndex = 0;
for (let i = 0; i < notesInSelectedTrack.value.length; i++) {
@@ -836,12 +1056,20 @@ const startPreview = (event: MouseEvent, mode: PreviewMode, note?: Note) => {
}
void store.actions.SELECT_NOTES({ noteIds: noteIdsToSelect });
} else if (isOnCommandOrCtrlKeyDown(event)) {
+ // CommandキーかCtrlキーが押されている場合
+ if (selectedNoteIds.value.has(note.id)) {
+ // 選択中のノートなら選択解除
+ void store.actions.DESELECT_NOTES({ noteIds: [note.id] });
+ return;
+ }
+ // 未選択のノートなら選択に追加
void store.actions.SELECT_NOTES({ noteIds: [note.id] });
} else if (!selectedNoteIds.value.has(note.id)) {
+ // 選択中のノートでない場合は選択状態にする
void selectOnlyThis(note);
}
- for (const note of selectedNotes.value) {
- copiedNotes.push({ ...note });
+ for (const selectedNote of selectedNotes.value) {
+ copiedNotes.push({ ...selectedNote });
}
}
dragStartTicks = cursorTicks;
@@ -894,19 +1122,28 @@ const endPreview = () => {
if (previewStartEditTarget === "NOTE") {
// 編集ターゲットがノートのときにプレビューを開始した場合の処理
if (edited) {
+ const previewTrackId = selectedTrackId.value;
+ const noteIds = previewNotes.value.map((note) => note.id);
+
if (previewMode.value === "ADD_NOTE") {
void store.actions.COMMAND_ADD_NOTES({
notes: previewNotes.value,
- trackId: selectedTrackId.value,
+ trackId: previewTrackId,
});
void store.actions.SELECT_NOTES({
- noteIds: previewNotes.value.map((value) => value.id),
+ noteIds,
});
- } else {
+ } else if (
+ previewMode.value === "MOVE_NOTE" ||
+ previewMode.value === "RESIZE_NOTE_RIGHT" ||
+ previewMode.value === "RESIZE_NOTE_LEFT"
+ ) {
+ // ノートの編集処理(移動・リサイズ)
void store.actions.COMMAND_UPDATE_NOTES({
notes: previewNotes.value,
- trackId: selectedTrackId.value,
+ trackId: previewTrackId,
});
+ void store.actions.SELECT_NOTES({ noteIds });
}
if (previewNotes.value.length === 1) {
void store.actions.PLAY_PREVIEW_SOUND({
@@ -952,12 +1189,16 @@ const endPreview = () => {
throw new ExhaustiveError(previewStartEditTarget);
}
previewMode.value = "IDLE";
+ previewNotes.value = [];
+ copiedNotesForPreview.clear();
+ edited = false;
};
const onNoteBarMouseDown = (event: MouseEvent, note: Note) => {
if (editTarget.value !== "NOTE" || !isSelfEventTarget(event)) {
return;
}
+
const mouseButton = getButton(event);
if (mouseButton === "LEFT_BUTTON") {
startPreview(event, "MOVE_NOTE", note);
@@ -1001,34 +1242,51 @@ const onNoteRightEdgeMouseDown = (event: MouseEvent, note: Note) => {
};
const onMouseDown = (event: MouseEvent) => {
- if (editTarget.value === "NOTE" && !isSelfEventTarget(event)) {
- return;
- }
- const mouseButton = getButton(event);
- // TODO: メニューが表示されている場合はメニュー非表示のみ行いたい
- if (editTarget.value === "NOTE") {
- if (mouseButton === "LEFT_BUTTON") {
- if (event.shiftKey) {
- isRectSelecting.value = true;
- rectSelectStartX.value = cursorX.value;
- rectSelectStartY.value = cursorY.value;
- setCursorState(CursorState.CROSSHAIR);
- } else {
- startPreview(event, "ADD_NOTE");
- }
- } else {
+ // TODO: isSelfEventTarget、mouseButton、editingLyricNoteId以外は必要ないが、
+ // 必要な依存関係明示のため(とuseEditModeからのコピペのためcontextに入れている
+ // ステートマシン実装時に要修正
+ const mouseDownContext = {
+ ctrlKey: ctrlKey.value,
+ shiftKey: shiftKey.value,
+ nowPreviewing: nowPreviewing.value,
+ editTarget: editTarget.value,
+ sequencerNoteTool: sequencerNoteTool.value,
+ sequencerPitchTool: sequencerPitchTool.value,
+ isSelfEventTarget: isSelfEventTarget(event),
+ mouseButton: getButton(event),
+ editingLyricNoteId: state.editingLyricNoteId,
+ } satisfies EditModeContext;
+ // マウスダウン時の振る舞い
+ const behavior = determineMouseDownBehavior(mouseDownContext);
+
+ switch (behavior) {
+ case "IGNORE":
+ return;
+
+ case "START_RECT_SELECT":
+ isRectSelecting.value = true;
+ rectSelectStartX.value = cursorX.value;
+ rectSelectStartY.value = cursorY.value;
+ break;
+
+ case "ADD_NOTE":
+ startPreview(event, "ADD_NOTE");
+ break;
+
+ case "DESELECT_ALL":
void store.actions.DESELECT_ALL_NOTES();
- }
- } else if (editTarget.value === "PITCH") {
- if (mouseButton === "LEFT_BUTTON") {
- if (isOnCommandOrCtrlKeyDown(event)) {
- startPreview(event, "ERASE_PITCH");
- } else {
- startPreview(event, "DRAW_PITCH");
- }
- }
- } else {
- throw new ExhaustiveError(editTarget.value);
+ break;
+
+ case "DRAW_PITCH":
+ startPreview(event, "DRAW_PITCH");
+ break;
+
+ case "ERASE_PITCH":
+ startPreview(event, "ERASE_PITCH");
+ break;
+
+ default:
+ break;
}
};
@@ -1066,6 +1324,41 @@ const onMouseUp = (event: MouseEvent) => {
}
};
+const onDoubleClick = (event: MouseEvent) => {
+ // TODO: isSelfEventTarget以外は必要ないが、
+ // 必要な依存関係明示のため(とuseEditModeからのコピペのため)contextに入れている
+ // ステートマシン実装時に要修正
+ const mouseDoubleClickContext = {
+ ctrlKey: ctrlKey.value,
+ shiftKey: shiftKey.value,
+ nowPreviewing: nowPreviewing.value,
+ editTarget: editTarget.value,
+ sequencerNoteTool: sequencerNoteTool.value,
+ sequencerPitchTool: sequencerPitchTool.value,
+ isSelfEventTarget: isSelfEventTarget(event),
+ mouseButton: getButton(event),
+ };
+
+ const behavior = determineDoubleClickBehavior(mouseDoubleClickContext);
+
+ // 振る舞いごとの処理
+ switch (behavior) {
+ case "IGNORE":
+ return;
+
+ case "ADD_NOTE": {
+ startPreview(event, "ADD_NOTE");
+ // ダブルクリックで追加した場合はプレビューを即終了しノートを追加する
+ // mouseDownとの二重状態を避けるため
+ endPreview();
+ return;
+ }
+
+ default:
+ break;
+ }
+};
+
/**
* 矩形選択。
* @param additive 追加選択とするかどうか。
@@ -1461,7 +1754,61 @@ registerHotkeyWithCleanup({
const contextMenu = ref>();
const contextMenuData = computed(() => {
- return [
+ // NOTE: 選択中のツールにはなんらかのアクティブな表示をしたほうがよいが、
+ // activeなどの状態がContextMenuItemにはない+iconは画像なようなため状態表現はなし
+ const toolMenuItems: ContextMenuItemData[] =
+ editTarget.value === "NOTE"
+ ? [
+ {
+ type: "button",
+ label: "選択優先ツール",
+ onClick: () => {
+ contextMenu.value?.hide();
+ void store.actions.SET_SEQUENCER_NOTE_TOOL({
+ sequencerNoteTool: "SELECT_FIRST",
+ });
+ },
+ disableWhenUiLocked: false,
+ },
+ {
+ type: "button",
+ label: "編集優先ツール",
+ onClick: () => {
+ contextMenu.value?.hide();
+ void store.actions.SET_SEQUENCER_NOTE_TOOL({
+ sequencerNoteTool: "EDIT_FIRST",
+ });
+ },
+ disableWhenUiLocked: false,
+ },
+ { type: "separator" },
+ ]
+ : [
+ {
+ type: "button",
+ label: "ピッチ描画ツール",
+ onClick: () => {
+ contextMenu.value?.hide();
+ void store.actions.SET_SEQUENCER_PITCH_TOOL({
+ sequencerPitchTool: "DRAW",
+ });
+ },
+ disableWhenUiLocked: false,
+ },
+ {
+ type: "button",
+ label: "ピッチ削除ツール",
+ onClick: () => {
+ contextMenu.value?.hide();
+ void store.actions.SET_SEQUENCER_PITCH_TOOL({
+ sequencerPitchTool: "ERASE",
+ });
+ },
+ disableWhenUiLocked: false,
+ },
+ ];
+
+ const baseMenuItems: ContextMenuItemData[] = [
{
type: "button",
label: "コピー",
@@ -1536,6 +1883,10 @@ const contextMenuData = computed(() => {
disableWhenUiLocked: true,
},
];
+
+ return editTarget.value === "NOTE"
+ ? [...toolMenuItems, ...baseMenuItems]
+ : toolMenuItems;
});
@@ -1548,6 +1899,7 @@ const contextMenuData = computed(() => {
display: grid;
grid-template-rows: 40px 1fr;
grid-template-columns: 48px 1fr;
+ position: relative;
}
.sequencer-corner {
@@ -1573,6 +1925,14 @@ const contextMenuData = computed(() => {
backface-visibility: hidden;
overflow: auto;
position: relative;
+
+ // スクロールバー上のカーソルが要素のものになってしまうためデフォルトカーソルにする
+ &::-webkit-scrollbar-thumb:hover,
+ &::-webkit-scrollbar-thumb:active,
+ &::-webkit-scrollbar-track:hover,
+ &::-webkit-scrollbar-track:active {
+ cursor: default;
+ }
}
.sequencer-grid {
diff --git a/src/components/Sing/SequencerGrid/Presentation.vue b/src/components/Sing/SequencerGrid/Presentation.vue
index 8b6f9fb5c5..ac19ace72d 100644
--- a/src/components/Sing/SequencerGrid/Presentation.vue
+++ b/src/components/Sing/SequencerGrid/Presentation.vue
@@ -8,9 +8,10 @@
>
@@ -19,49 +20,67 @@
:key="`cell-${index}`"
x="0"
:y="gridCellHeight * index"
- :width="beatWidth * beatsPerMeasure"
- :height="gridCellHeight"
+ :width="gridCellWidth"
+ :height="gridCellHeight * 12"
:class="`sequencer-grid-cell sequencer-grid-cell-${keyInfo.color}`"
/>
+
+
-
+
+
+
diff --git a/src/components/Sing/SequencerPitch.vue b/src/components/Sing/SequencerPitch.vue
index ed3ae21b78..a558f5e1fd 100644
--- a/src/components/Sing/SequencerPitch.vue
+++ b/src/components/Sing/SequencerPitch.vue
@@ -51,7 +51,7 @@ const { warn, error } = createLogger("SequencerPitch");
const store = useStore();
const tpqn = computed(() => store.state.tpqn);
const isDark = computed(() => store.state.currentTheme === "Dark");
-const tempos = computed(() => [store.state.tempos[0]]);
+const tempos = computed(() => store.state.tempos);
const pitchEditData = computed(() => {
return store.getters.SELECTED_TRACK.pitchEditData;
});
diff --git a/src/components/Sing/SequencerRuler/Container.vue b/src/components/Sing/SequencerRuler/Container.vue
index 6824af1c48..07111d281f 100644
--- a/src/components/Sing/SequencerRuler/Container.vue
+++ b/src/components/Sing/SequencerRuler/Container.vue
@@ -1,13 +1,19 @@
@@ -16,6 +22,7 @@
import { computed } from "vue";
import Presentation from "./Presentation.vue";
import { useStore } from "@/store";
+import { Tempo, TimeSignature } from "@/store/type";
defineOptions({
name: "SequencerRuler",
@@ -33,9 +40,12 @@ withDefaults(
);
const store = useStore();
+
const tpqn = computed(() => store.state.tpqn);
+const tempos = computed(() => store.state.tempos);
const timeSignatures = computed(() => store.state.timeSignatures);
const sequencerZoomX = computed(() => store.state.sequencerZoomX);
+const uiLocked = computed(() => store.getters.UI_LOCKED);
const sequencerSnapType = computed(() => store.state.sequencerSnapType);
const playheadTicks = computed(() => store.getters.PLAYHEAD_POSITION);
@@ -47,4 +57,25 @@ const updatePlayheadTicks = (ticks: number) => {
const deselectAllNotes = () => {
void store.actions.DESELECT_ALL_NOTES();
};
+
+const setTempo = (tempo: Tempo) => {
+ void store.actions.COMMAND_SET_TEMPO({
+ tempo,
+ });
+};
+const setTimeSignature = (timeSignature: TimeSignature) => {
+ void store.actions.COMMAND_SET_TIME_SIGNATURE({
+ timeSignature,
+ });
+};
+const removeTempo = (position: number) => {
+ void store.actions.COMMAND_REMOVE_TEMPO({
+ position,
+ });
+};
+const removeTimeSignature = (measureNumber: number) => {
+ void store.actions.COMMAND_REMOVE_TIME_SIGNATURE({
+ measureNumber,
+ });
+};
diff --git a/src/components/Sing/SequencerRuler/Presentation.vue b/src/components/Sing/SequencerRuler/Presentation.vue
index 9cb4c75916..30c9e32f9b 100644
--- a/src/components/Sing/SequencerRuler/Presentation.vue
+++ b/src/components/Sing/SequencerRuler/Presentation.vue
@@ -1,5 +1,16 @@
-
+
+
+
diff --git a/src/components/Sing/ToolBar/EditTargetSwicher.vue b/src/components/Sing/ToolBar/EditTargetSwicher.vue
index 99b0371975..95eefc0821 100644
--- a/src/components/Sing/ToolBar/EditTargetSwicher.vue
+++ b/src/components/Sing/ToolBar/EditTargetSwicher.vue
@@ -32,7 +32,7 @@
:delay="500"
anchor="bottom middle"
transitionShow=""
- transitionSide=""
+ transitionHide=""
>
ピッチ編集
{{ !isMac ? "Ctrl" : "Cmd" }}+クリックで消去
diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue
index bfbee5abb5..507a935ff3 100644
--- a/src/components/Sing/ToolBar/ToolBar.vue
+++ b/src/components/Sing/ToolBar/ToolBar.vue
@@ -41,7 +41,7 @@
hideBottomSpace
outlined
unelevated
- label="BPM"
+ label="テンポ"
class="sing-tempo"
padding="0"
@update:modelValue="setBpmInputBuffer"
@@ -58,7 +58,7 @@
/
store.getters.SELECTED_TRACK.volumeRangeAdjustment,
);
const selectedTrackId = computed(() => store.getters.SELECTED_TRACK_ID);
+const tpqn = computed(() => store.state.tpqn);
+const playheadTicks = computed(() => store.getters.PLAYHEAD_POSITION);
+
+const tsPositions = computed(() => {
+ return getTimeSignaturePositions(timeSignatures.value, tpqn.value);
+});
const beatsOptions = computed(() => {
return Array.from({ length: 32 }, (_, i) => ({
@@ -253,36 +262,15 @@ const beatsOptions = computed(() => {
}));
});
-const beatTypeOptions = computed(() => {
- return [2, 4, 8, 16, 32].map((beatType) => ({
- label: beatType.toString(),
- value: beatType,
- }));
-});
+const beatTypeOptions = BEAT_TYPES.map((beatType) => ({
+ label: beatType.toString(),
+ value: beatType,
+}));
const bpmInputBuffer = ref(120);
-const beatsInputBuffer = ref(4);
-const beatTypeInputBuffer = ref(4);
const keyRangeAdjustmentInputBuffer = ref(0);
const volumeRangeAdjustmentInputBuffer = ref(0);
-watch(
- tempos,
- () => {
- bpmInputBuffer.value = tempos.value[0].bpm;
- },
- { deep: true, immediate: true },
-);
-
-watch(
- timeSignatures,
- () => {
- beatsInputBuffer.value = timeSignatures.value[0].beats;
- beatTypeInputBuffer.value = timeSignatures.value[0].beatType;
- },
- { deep: true, immediate: true },
-);
-
watch(
keyRangeAdjustment,
() => {
@@ -307,15 +295,26 @@ const setBpmInputBuffer = (bpmStr: string | number | null) => {
bpmInputBuffer.value = bpmValue;
};
+const currentTimeSignature = computed(() => {
+ const maybeTimeSignature = timeSignatures.value.findLast(
+ (_timeSignature, i) => tsPositions.value[i] <= playheadTicks.value,
+ );
+ if (!maybeTimeSignature) {
+ throw new UnreachableError("assert: at least one time signature exists");
+ }
+ return maybeTimeSignature;
+});
+
const setBeats = (beats: { label: string; value: number }) => {
if (!isValidBeats(beats.value)) {
return;
}
+
void store.actions.COMMAND_SET_TIME_SIGNATURE({
timeSignature: {
- measureNumber: 1,
+ measureNumber: currentTimeSignature.value.measureNumber,
beats: beats.value,
- beatType: timeSignatures.value[0].beatType,
+ beatType: currentTimeSignature.value.beatType,
},
});
};
@@ -326,8 +325,8 @@ const setBeatType = (beatType: { label: string; value: number }) => {
}
void store.actions.COMMAND_SET_TIME_SIGNATURE({
timeSignature: {
- measureNumber: 1,
- beats: timeSignatures.value[0].beats,
+ measureNumber: currentTimeSignature.value.measureNumber,
+ beats: currentTimeSignature.value.beats,
beatType: beatType.value,
},
});
@@ -355,9 +354,15 @@ const setVolumeRangeAdjustmentInputBuffer = (
const setTempo = () => {
const bpm = bpmInputBuffer.value;
+ const position = tempos.value.findLast(
+ (tempo) => tempo.position <= playheadTicks.value,
+ )?.position;
+ if (position == undefined) {
+ throw new UnreachableError("assert: at least one tempo exists");
+ }
void store.actions.COMMAND_SET_TEMPO({
tempo: {
- position: 0,
+ position,
bpm,
},
});
@@ -379,6 +384,20 @@ const setVolumeRangeAdjustment = () => {
});
};
+watch(
+ [tempos, playheadTicks],
+ () => {
+ const currentTempo = tempos.value.findLast(
+ (tempo) => tempo.position <= playheadTicks.value,
+ );
+ if (!currentTempo) {
+ throw new UnreachableError("assert: at least one tempo exists");
+ }
+ bpmInputBuffer.value = currentTempo.bpm;
+ },
+ { immediate: true },
+);
+
const nowPlaying = computed(() => store.state.nowPlaying);
const play = () => {
diff --git a/src/components/Talk/AccentPhrase.vue b/src/components/Talk/AccentPhrase.vue
index 5e167b9878..86ab2e1856 100644
--- a/src/components/Talk/AccentPhrase.vue
+++ b/src/components/Talk/AccentPhrase.vue
@@ -1,8 +1,11 @@
@@ -468,7 +471,7 @@ const handleChangeVoicing = (mora: Mora, moraIndex: number) => {
position: relative;
}
-.mora-table {
+.accent-phrase {
display: inline-grid;
align-self: stretch;
grid-template-rows: 1fr 60px 30px;
@@ -478,12 +481,12 @@ const handleChangeVoicing = (mora: Mora, moraIndex: number) => {
}
}
-.mora-table-hover:hover {
+.accent-phrase-hover:hover {
cursor: pointer;
background-color: colors.$active-point-hover;
}
-.mora-table-focus {
+.accent-phrase-focus {
// hover色に負けるので、importantが必要
background-color: colors.$active-point-focus !important;
}
diff --git a/src/components/Talk/AudioInfo.vue b/src/components/Talk/AudioInfo.vue
index 9979426acb..7f329ef1e2 100644
--- a/src/components/Talk/AudioInfo.vue
+++ b/src/components/Talk/AudioInfo.vue
@@ -308,6 +308,7 @@ import {
MorphingInfo,
Preset,
PresetKey,
+ PresetSliderKey,
Voice,
} from "@/type/preload";
import {
@@ -348,14 +349,12 @@ const selectedAudioKeys = computed(() =>
: [props.activeAudioKey],
);
-type ParameterKey = keyof Omit
; // NOTE: パラメーターの種類はPresetのキーと同じ
-
/** パラメーターを制御するための元情報リスト */
type ParameterConfig = {
label: string;
sliderProps: Omit;
onChange: PreviewSliderHelperProps["onChange"]; // NOTE: onChangeだけ使い回すので分離している
- key: ParameterKey;
+ key: PresetSliderKey;
};
const parameterConfigs = computed(() => [
{
@@ -363,12 +362,12 @@ const parameterConfigs = computed(() => [
sliderProps: {
modelValue: () => query.value?.speedScale ?? null,
disable: () =>
- uiLocked.value || supportedFeatures.value?.adjustSpeedScale === false,
- max: SLIDER_PARAMETERS.SPEED.max,
- min: SLIDER_PARAMETERS.SPEED.min,
- step: SLIDER_PARAMETERS.SPEED.step,
- scrollStep: SLIDER_PARAMETERS.SPEED.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.SPEED.scrollMinStep,
+ uiLocked.value || !supportedFeatures.value?.adjustSpeedScale,
+ max: SLIDER_PARAMETERS.speedScale.max,
+ min: SLIDER_PARAMETERS.speedScale.min,
+ step: SLIDER_PARAMETERS.speedScale.step,
+ scrollStep: SLIDER_PARAMETERS.speedScale.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.speedScale.scrollMinStep,
},
onChange: (speedScale: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_SPEED_SCALE({
@@ -382,11 +381,11 @@ const parameterConfigs = computed(() => [
sliderProps: {
modelValue: () => query.value?.pitchScale ?? null,
disable: () =>
- uiLocked.value || supportedFeatures.value?.adjustPitchScale === false,
- max: SLIDER_PARAMETERS.PITCH.max,
- min: SLIDER_PARAMETERS.PITCH.min,
- step: SLIDER_PARAMETERS.PITCH.step,
- scrollStep: SLIDER_PARAMETERS.PITCH.scrollStep,
+ uiLocked.value || !supportedFeatures.value?.adjustPitchScale,
+ max: SLIDER_PARAMETERS.pitchScale.max,
+ min: SLIDER_PARAMETERS.pitchScale.min,
+ step: SLIDER_PARAMETERS.pitchScale.step,
+ scrollStep: SLIDER_PARAMETERS.pitchScale.scrollStep,
},
onChange: (pitchScale: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_PITCH_SCALE({
@@ -400,13 +399,12 @@ const parameterConfigs = computed(() => [
sliderProps: {
modelValue: () => query.value?.intonationScale ?? null,
disable: () =>
- uiLocked.value ||
- supportedFeatures.value?.adjustIntonationScale === false,
- max: SLIDER_PARAMETERS.INTONATION.max,
- min: SLIDER_PARAMETERS.INTONATION.min,
- step: SLIDER_PARAMETERS.INTONATION.step,
- scrollStep: SLIDER_PARAMETERS.INTONATION.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.INTONATION.scrollMinStep,
+ uiLocked.value || !supportedFeatures.value?.adjustIntonationScale,
+ max: SLIDER_PARAMETERS.intonationScale.max,
+ min: SLIDER_PARAMETERS.intonationScale.min,
+ step: SLIDER_PARAMETERS.intonationScale.step,
+ scrollStep: SLIDER_PARAMETERS.intonationScale.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.intonationScale.scrollMinStep,
},
onChange: (intonationScale: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_INTONATION_SCALE({
@@ -420,12 +418,12 @@ const parameterConfigs = computed(() => [
sliderProps: {
modelValue: () => query.value?.volumeScale ?? null,
disable: () =>
- uiLocked.value || supportedFeatures.value?.adjustVolumeScale === false,
- max: SLIDER_PARAMETERS.VOLUME.max,
- min: SLIDER_PARAMETERS.VOLUME.min,
- step: SLIDER_PARAMETERS.VOLUME.step,
- scrollStep: SLIDER_PARAMETERS.VOLUME.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.VOLUME.scrollMinStep,
+ uiLocked.value || !supportedFeatures.value?.adjustVolumeScale,
+ max: SLIDER_PARAMETERS.volumeScale.max,
+ min: SLIDER_PARAMETERS.volumeScale.min,
+ step: SLIDER_PARAMETERS.volumeScale.step,
+ scrollStep: SLIDER_PARAMETERS.volumeScale.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.volumeScale.scrollMinStep,
},
onChange: (volumeScale: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_VOLUME_SCALE({
@@ -435,15 +433,16 @@ const parameterConfigs = computed(() => [
key: "volumeScale",
},
{
- label: "文内無音倍率",
+ label: "間の長さ",
sliderProps: {
modelValue: () => query.value?.pauseLengthScale ?? null,
- disable: () => uiLocked.value,
- max: SLIDER_PARAMETERS.PAUSE_LENGTH_SCALE.max,
- min: SLIDER_PARAMETERS.PAUSE_LENGTH_SCALE.min,
- step: SLIDER_PARAMETERS.PAUSE_LENGTH_SCALE.step,
- scrollStep: SLIDER_PARAMETERS.PAUSE_LENGTH_SCALE.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.PAUSE_LENGTH_SCALE.scrollMinStep,
+ disable: () =>
+ uiLocked.value || !supportedFeatures.value?.adjustPauseLength,
+ max: SLIDER_PARAMETERS.pauseLengthScale.max,
+ min: SLIDER_PARAMETERS.pauseLengthScale.min,
+ step: SLIDER_PARAMETERS.pauseLengthScale.step,
+ scrollStep: SLIDER_PARAMETERS.pauseLengthScale.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.pauseLengthScale.scrollMinStep,
},
onChange: (pauseLengthScale: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_PAUSE_LENGTH_SCALE({
@@ -457,11 +456,11 @@ const parameterConfigs = computed(() => [
sliderProps: {
modelValue: () => query.value?.prePhonemeLength ?? null,
disable: () => uiLocked.value,
- max: SLIDER_PARAMETERS.PRE_PHONEME_LENGTH.max,
- min: SLIDER_PARAMETERS.PRE_PHONEME_LENGTH.min,
- step: SLIDER_PARAMETERS.PRE_PHONEME_LENGTH.step,
- scrollStep: SLIDER_PARAMETERS.PRE_PHONEME_LENGTH.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.PRE_PHONEME_LENGTH.scrollMinStep,
+ max: SLIDER_PARAMETERS.prePhonemeLength.max,
+ min: SLIDER_PARAMETERS.prePhonemeLength.min,
+ step: SLIDER_PARAMETERS.prePhonemeLength.step,
+ scrollStep: SLIDER_PARAMETERS.prePhonemeLength.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.prePhonemeLength.scrollMinStep,
},
onChange: (prePhonemeLength: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_PRE_PHONEME_LENGTH({
@@ -475,11 +474,11 @@ const parameterConfigs = computed(() => [
sliderProps: {
modelValue: () => query.value?.postPhonemeLength ?? null,
disable: () => uiLocked.value,
- max: SLIDER_PARAMETERS.POST_PHONEME_LENGTH.max,
- min: SLIDER_PARAMETERS.POST_PHONEME_LENGTH.min,
- step: SLIDER_PARAMETERS.POST_PHONEME_LENGTH.step,
- scrollStep: SLIDER_PARAMETERS.POST_PHONEME_LENGTH.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.POST_PHONEME_LENGTH.scrollMinStep,
+ max: SLIDER_PARAMETERS.postPhonemeLength.max,
+ min: SLIDER_PARAMETERS.postPhonemeLength.min,
+ step: SLIDER_PARAMETERS.postPhonemeLength.step,
+ scrollStep: SLIDER_PARAMETERS.postPhonemeLength.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.postPhonemeLength.scrollMinStep,
},
onChange: (postPhonemeLength: number) =>
store.actions.COMMAND_MULTI_SET_AUDIO_POST_PHONEME_LENGTH({
@@ -495,7 +494,7 @@ type Parameter = {
label: string;
slider: PreviewSliderHelper;
onChange: PreviewSliderHelperProps["onChange"];
- key: ParameterKey;
+ key: PresetSliderKey;
};
const parameters = computed(() =>
parameterConfigs.value.map((parameterConfig) => ({
@@ -665,11 +664,11 @@ const morphingRateSlider = previewSliderHelper({
modelValue: () => audioItem.value.morphingInfo?.rate ?? null,
disable: () => uiLocked.value,
onChange: setMorphingRate,
- max: SLIDER_PARAMETERS.MORPHING_RATE.max,
- min: SLIDER_PARAMETERS.MORPHING_RATE.min,
- step: SLIDER_PARAMETERS.MORPHING_RATE.step,
- scrollStep: SLIDER_PARAMETERS.MORPHING_RATE.scrollStep,
- scrollMinStep: SLIDER_PARAMETERS.MORPHING_RATE.scrollMinStep,
+ max: SLIDER_PARAMETERS.morphingRate.max,
+ min: SLIDER_PARAMETERS.morphingRate.min,
+ step: SLIDER_PARAMETERS.morphingRate.step,
+ scrollStep: SLIDER_PARAMETERS.morphingRate.scrollStep,
+ scrollMinStep: SLIDER_PARAMETERS.morphingRate.scrollMinStep,
});
// プリセット
diff --git a/src/components/Talk/TalkEditor.vue b/src/components/Talk/TalkEditor.vue
index 2038c1e3e8..ecf68b6041 100644
--- a/src/components/Talk/TalkEditor.vue
+++ b/src/components/Talk/TalkEditor.vue
@@ -135,12 +135,14 @@ import {
PresetKey,
SplitterPositionType,
Voice,
- HotkeyActionNameType,
- actionPostfixSelectNthCharacter,
} from "@/type/preload";
import { useHotkeyManager } from "@/plugins/hotkeyPlugin";
import onetimeWatch from "@/helpers/onetimeWatch";
import path from "@/helpers/path";
+import {
+ actionPostfixSelectNthCharacter,
+ HotkeyActionNameType,
+} from "@/domain/hotkeyAction";
const props = defineProps<{
isEnginesReady: boolean;
diff --git a/src/composables/useCursorState.ts b/src/composables/useCursorState.ts
deleted file mode 100644
index 3c91a50385..0000000000
--- a/src/composables/useCursorState.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { ref, computed } from "vue";
-
-// カーソル状態
-export enum CursorState {
- UNSET = "unset",
- EW_RESIZE = "ew-resize",
- MOVE = "move",
- CROSSHAIR = "crosshair",
- DRAW = "draw",
- ERASE = "erase",
-}
-
-// カーソル状態を管理するカスタムコンポーザブル
-export const useCursorState = () => {
- const cursorState = ref(CursorState.UNSET);
-
- const cursorClass = computed(() => {
- switch (cursorState.value) {
- case CursorState.EW_RESIZE:
- return "cursor-ew-resize";
- case CursorState.CROSSHAIR:
- return "cursor-crosshair";
- case CursorState.MOVE:
- return "cursor-move";
- case CursorState.DRAW:
- return "cursor-draw";
- case CursorState.ERASE:
- return "cursor-erase";
- default:
- return "";
- }
- });
-
- const setCursorState = (state: CursorState) => {
- cursorState.value = state;
- };
-
- return {
- cursorState,
- cursorClass,
- setCursorState,
- };
-};
diff --git a/src/composables/useSequencerGridPattern.ts b/src/composables/useSequencerGridPattern.ts
new file mode 100644
index 0000000000..2a59ea7ea1
--- /dev/null
+++ b/src/composables/useSequencerGridPattern.ts
@@ -0,0 +1,60 @@
+import { computed, Ref } from "vue";
+import { tickToBaseX } from "@/sing/viewHelper";
+import { TimeSignature } from "@/store/type";
+
+const beatWidth = (
+ timeSignature: TimeSignature,
+ tpqn: number,
+ sequencerZoomX: number,
+) => {
+ const beatType = timeSignature.beatType;
+ const wholeNoteDuration = tpqn * 4;
+ const beatTicks = wholeNoteDuration / beatType;
+ return tickToBaseX(beatTicks, tpqn) * sequencerZoomX;
+};
+
+export const useSequencerGrid = ({
+ timeSignatures,
+ tpqn,
+ sequencerZoomX,
+ numMeasures,
+}: {
+ timeSignatures: Ref;
+ tpqn: Ref;
+ sequencerZoomX: Ref;
+ numMeasures: Ref;
+}) =>
+ computed(() => {
+ const gridPatterns: {
+ id: string;
+ x: number;
+ timeSignature: TimeSignature;
+ beatWidth: number;
+ beatsPerMeasure: number;
+ patternWidth: number;
+ width: number;
+ }[] = [];
+ for (const [i, timeSignature] of timeSignatures.value.entries()) {
+ const maybeNextTimeSignature = timeSignatures.value.at(i + 1);
+ const nextMeasureNumber =
+ maybeNextTimeSignature?.measureNumber ?? numMeasures.value + 1;
+ const patternWidth =
+ beatWidth(timeSignature, tpqn.value, sequencerZoomX.value) *
+ timeSignature.beats;
+ gridPatterns.push({
+ id: `sequencer-grid-pattern-${i}`,
+ timeSignature,
+ x:
+ gridPatterns.length === 0
+ ? 0
+ : gridPatterns[gridPatterns.length - 1].x +
+ gridPatterns[gridPatterns.length - 1].width,
+ beatWidth: beatWidth(timeSignature, tpqn.value, sequencerZoomX.value),
+ beatsPerMeasure: timeSignature.beats,
+ patternWidth,
+ width: patternWidth * (nextMeasureNumber - timeSignature.measureNumber),
+ });
+ }
+
+ return gridPatterns;
+ });
diff --git a/src/domain/defaultEngine/envEngineInfo.ts b/src/domain/defaultEngine/envEngineInfo.ts
index 3003f70c62..f45098c264 100644
--- a/src/domain/defaultEngine/envEngineInfo.ts
+++ b/src/domain/defaultEngine/envEngineInfo.ts
@@ -7,16 +7,30 @@ import { z } from "zod";
import { engineIdSchema } from "@/type/preload";
/** .envに書くデフォルトエンジン情報のスキーマ */
-export const envEngineInfoSchema = z.object({
- uuid: engineIdSchema,
- host: z.string(),
- name: z.string(),
- executionEnabled: z.boolean(),
- executionFilePath: z.string(),
- executionArgs: z.array(z.string()),
- path: z.string().optional(),
-});
-export type EnvEngineInfoType = z.infer;
+const envEngineInfoSchema = z
+ .object({
+ uuid: engineIdSchema,
+ host: z.string(),
+ name: z.string(),
+ executionEnabled: z.boolean(),
+ executionArgs: z.array(z.string()),
+ })
+ .and(
+ z.union([
+ // エンジンをパス指定する場合
+ z.object({
+ type: z.literal("path").default("path"),
+ executionFilePath: z.string(),
+ path: z.string().optional(),
+ }),
+ // VVPPダウンロードする場合
+ z.object({
+ type: z.literal("downloadVvpp"),
+ latestUrl: z.string(),
+ }),
+ ]),
+ );
+type EnvEngineInfoType = z.infer;
/** .envからデフォルトエンジン情報を読み込む */
export function loadEnvEngineInfos(): EnvEngineInfoType[] {
diff --git a/src/domain/defaultEngine/latetDefaultEngine.ts b/src/domain/defaultEngine/latetDefaultEngine.ts
index 62c95534c8..f07aaaeb87 100644
--- a/src/domain/defaultEngine/latetDefaultEngine.ts
+++ b/src/domain/defaultEngine/latetDefaultEngine.ts
@@ -5,7 +5,7 @@
import { z } from "zod";
/** パッケージ情報のスキーマ */
-const engineVariantSchema = z.object({
+const packageInfoSchema = z.object({
version: z.string(),
packages: z
.object({
@@ -16,28 +16,29 @@ const engineVariantSchema = z.object({
})
.array(),
});
+export type PackageInfo = z.infer;
/** デフォルトエンジンの最新情報のスキーマ */
const latestDefaultEngineInfoSchema = z.object({
formatVersion: z.number(),
windows: z.object({
x64: z.object({
- CPU: engineVariantSchema,
- "GPU/CPU": engineVariantSchema,
+ CPU: packageInfoSchema,
+ "GPU/CPU": packageInfoSchema,
}),
}),
macos: z.object({
x64: z.object({
- CPU: engineVariantSchema,
+ CPU: packageInfoSchema,
}),
arm64: z.object({
- CPU: engineVariantSchema,
+ CPU: packageInfoSchema,
}),
}),
linux: z.object({
x64: z.object({
- CPU: engineVariantSchema,
- "GPU/CPU": engineVariantSchema,
+ CPU: packageInfoSchema,
+ "GPU/CPU": packageInfoSchema,
}),
}),
});
@@ -47,3 +48,32 @@ export const fetchLatestDefaultEngineInfo = async (url: string) => {
const response = await fetch(url);
return latestDefaultEngineInfoSchema.parse(await response.json());
};
+
+/**
+ * 実行環境に合うパッケージを取得する。GPU版があればGPU版を返す。
+ * TODO: どのデバイス版にするかはユーザーが選べるようにするべき。
+ */
+export const getSuitablePackageInfo = (
+ updateInfo: z.infer,
+): PackageInfo => {
+ const platform = process.platform;
+ const arch = process.arch;
+
+ if (platform === "win32") {
+ if (arch === "x64") {
+ return updateInfo.windows.x64["GPU/CPU"];
+ }
+ } else if (platform === "darwin") {
+ if (arch === "x64") {
+ return updateInfo.macos.x64.CPU;
+ } else if (arch === "arm64") {
+ return updateInfo.macos.arm64.CPU;
+ }
+ } else if (platform === "linux") {
+ if (arch === "x64") {
+ return updateInfo.linux.x64["GPU/CPU"];
+ }
+ }
+
+ throw new Error(`Unsupported platform: ${platform} ${arch}`);
+};
diff --git a/src/domain/frontend/log.ts b/src/domain/frontend/log.ts
index 2aa6cdfbd2..4cb2aa8598 100644
--- a/src/domain/frontend/log.ts
+++ b/src/domain/frontend/log.ts
@@ -2,13 +2,21 @@
// TODO: window.backendをDIできるようにする
export function createLogger(scope: string) {
const createInner =
- (method: "logInfo" | "logError" | "logWarn") =>
+ (
+ method: "logInfo" | "logWarn" | "logError",
+ fallbackMethod: "info" | "warn" | "error",
+ ) =>
(...args: unknown[]) => {
+ if (window.backend == undefined) {
+ // eslint-disable-next-line no-console
+ console[fallbackMethod](...args);
+ return;
+ }
window.backend[method](`[${scope}]`, ...args);
};
return {
- info: createInner("logInfo"),
- error: createInner("logError"),
- warn: createInner("logWarn"),
+ info: createInner("logInfo", "info"),
+ warn: createInner("logWarn", "warn"),
+ error: createInner("logError", "error"),
};
}
diff --git a/src/domain/hotkeyAction.ts b/src/domain/hotkeyAction.ts
new file mode 100644
index 0000000000..2b62282072
--- /dev/null
+++ b/src/domain/hotkeyAction.ts
@@ -0,0 +1,199 @@
+import { z } from "zod";
+import { isMac } from "@/helpers/platform";
+
+const hotkeyCombinationSchema = z.string().brand("HotkeyCombination");
+export type HotkeyCombination = z.infer;
+export const HotkeyCombination = (
+ hotkeyCombination: string,
+): HotkeyCombination => hotkeyCombinationSchema.parse(hotkeyCombination);
+
+// 共通のアクション名
+export const actionPostfixSelectNthCharacter = "番目のキャラクターを選択";
+
+export const hotkeyActionNameSchema = z.enum([
+ "音声書き出し",
+ "選択音声を書き出し",
+ "音声を繋げて書き出し",
+ "再生/停止",
+ "連続再生/停止",
+ "アクセント欄を表示",
+ "イントネーション欄を表示",
+ "長さ欄を表示",
+ "テキスト欄を追加",
+ "テキスト欄を複製",
+ "テキスト欄を削除",
+ "テキスト欄からフォーカスを外す",
+ "テキスト欄にフォーカスを戻す",
+ "元に戻す",
+ "やり直す",
+ "新規プロジェクト",
+ "プロジェクトを名前を付けて保存",
+ "プロジェクトを上書き保存",
+ "プロジェクトを読み込む",
+ "テキストを読み込む",
+ "全体のイントネーションをリセット",
+ "選択中のアクセント句のイントネーションをリセット",
+ "コピー",
+ "切り取り",
+ "貼り付け",
+ "すべて選択",
+ "選択解除",
+ "全セルを選択",
+ `1${actionPostfixSelectNthCharacter}`,
+ `2${actionPostfixSelectNthCharacter}`,
+ `3${actionPostfixSelectNthCharacter}`,
+ `4${actionPostfixSelectNthCharacter}`,
+ `5${actionPostfixSelectNthCharacter}`,
+ `6${actionPostfixSelectNthCharacter}`,
+ `7${actionPostfixSelectNthCharacter}`,
+ `8${actionPostfixSelectNthCharacter}`,
+ `9${actionPostfixSelectNthCharacter}`,
+ `10${actionPostfixSelectNthCharacter}`,
+ "全画面表示を切り替え",
+ "拡大",
+ "縮小",
+ "拡大率のリセット",
+]);
+export type HotkeyActionNameType = z.infer;
+
+export const hotkeySettingSchema = z.object({
+ action: hotkeyActionNameSchema,
+ combination: hotkeyCombinationSchema,
+});
+export type HotkeySettingType = z.infer;
+
+// ホットキーを追加したときは設定のマイグレーションが必要
+export const defaultHotkeySettings: HotkeySettingType[] = [
+ {
+ action: "音声書き出し",
+ combination: HotkeyCombination(!isMac ? "Ctrl E" : "Meta E"),
+ },
+ {
+ action: "選択音声を書き出し",
+ combination: HotkeyCombination("E"),
+ },
+ {
+ action: "音声を繋げて書き出し",
+ combination: HotkeyCombination(""),
+ },
+ {
+ action: "再生/停止",
+ combination: HotkeyCombination("Space"),
+ },
+ {
+ action: "連続再生/停止",
+ combination: HotkeyCombination("Shift Space"),
+ },
+ {
+ action: "アクセント欄を表示",
+ combination: HotkeyCombination("1"),
+ },
+ {
+ action: "イントネーション欄を表示",
+ combination: HotkeyCombination("2"),
+ },
+ {
+ action: "長さ欄を表示",
+ combination: HotkeyCombination("3"),
+ },
+ {
+ action: "テキスト欄を追加",
+ combination: HotkeyCombination("Shift Enter"),
+ },
+ {
+ action: "テキスト欄を複製",
+ combination: HotkeyCombination(!isMac ? "Ctrl D" : "Meta D"),
+ },
+ {
+ action: "テキスト欄を削除",
+ combination: HotkeyCombination("Shift Delete"),
+ },
+ {
+ action: "テキスト欄からフォーカスを外す",
+ combination: HotkeyCombination("Escape"),
+ },
+ {
+ action: "テキスト欄にフォーカスを戻す",
+ combination: HotkeyCombination("Enter"),
+ },
+ {
+ action: "元に戻す",
+ combination: HotkeyCombination(!isMac ? "Ctrl Z" : "Meta Z"),
+ },
+ {
+ action: "やり直す",
+ combination: HotkeyCombination(!isMac ? "Ctrl Y" : "Shift Meta Z"),
+ },
+ {
+ action: "拡大",
+ combination: HotkeyCombination(""),
+ },
+ {
+ action: "縮小",
+ combination: HotkeyCombination(""),
+ },
+ {
+ action: "拡大率のリセット",
+ combination: HotkeyCombination(""),
+ },
+ {
+ action: "新規プロジェクト",
+ combination: HotkeyCombination(!isMac ? "Ctrl N" : "Meta N"),
+ },
+ {
+ action: "全画面表示を切り替え",
+ combination: HotkeyCombination(!isMac ? "F11" : "Ctrl Meta F"),
+ },
+ {
+ action: "プロジェクトを名前を付けて保存",
+ combination: HotkeyCombination(!isMac ? "Ctrl Shift S" : "Shift Meta S"),
+ },
+ {
+ action: "プロジェクトを上書き保存",
+ combination: HotkeyCombination(!isMac ? "Ctrl S" : "Meta S"),
+ },
+ {
+ action: "プロジェクトを読み込む",
+ combination: HotkeyCombination(!isMac ? "Ctrl O" : "Meta O"),
+ },
+ {
+ action: "テキストを読み込む",
+ combination: HotkeyCombination(""),
+ },
+ {
+ action: "全体のイントネーションをリセット",
+ combination: HotkeyCombination(!isMac ? "Ctrl G" : "Meta G"),
+ },
+ {
+ action: "選択中のアクセント句のイントネーションをリセット",
+ combination: HotkeyCombination("R"),
+ },
+ {
+ action: "コピー",
+ combination: HotkeyCombination(!isMac ? "Ctrl C" : "Meta C"),
+ },
+ {
+ action: "切り取り",
+ combination: HotkeyCombination(!isMac ? "Ctrl X" : "Meta X"),
+ },
+ {
+ action: "貼り付け",
+ combination: HotkeyCombination(!isMac ? "Ctrl V" : "Meta V"),
+ },
+ {
+ action: "すべて選択",
+ combination: HotkeyCombination(!isMac ? "Ctrl A" : "Meta A"),
+ },
+ {
+ action: "選択解除",
+ combination: HotkeyCombination("Escape"),
+ },
+ ...Array.from({ length: 10 }, (_, index) => {
+ const roleKey = index == 9 ? 0 : index + 1;
+ return {
+ action:
+ `${index + 1}${actionPostfixSelectNthCharacter}` as HotkeyActionNameType,
+ combination: HotkeyCombination(`${!isMac ? "Ctrl" : "Meta"} ${roleKey}`),
+ };
+ }),
+];
diff --git a/src/fonts/material-symbols-outlined-regular.woff2 b/src/fonts/material-symbols-outlined-regular.woff2
new file mode 100644
index 0000000000..9dcd2356db
Binary files /dev/null and b/src/fonts/material-symbols-outlined-regular.woff2 differ
diff --git a/src/helpers/map.ts b/src/helpers/map.ts
deleted file mode 100644
index def8bbf1cc..0000000000
--- a/src/helpers/map.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-export function mapNullablePipe(
- source: T | undefined,
- fn1: (_: NonNullable) => U1 | undefined,
-): U1 | undefined;
-export function mapNullablePipe(
- source: T | undefined,
- fn1: (_: NonNullable) => U1 | undefined,
- fn2: (_: NonNullable) => U2 | undefined,
-): U2 | undefined;
-export function mapNullablePipe(
- source: T | undefined,
- fn1: (_: NonNullable) => U1 | undefined,
- fn2: (_: NonNullable) => U2 | undefined,
- fn3: (_: NonNullable) => U3 | undefined,
-): U3 | undefined;
-/**
- * 一連の関数を実行する。途中でundefinedかnullを返すとその後undefinedを返す。
- */
-// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
-export function mapNullablePipe(source: any, ...fn: Function[]) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return fn.reduce((prev, curr) => {
- if (prev == undefined) {
- return undefined;
- }
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return curr(prev);
- }, source);
-}
-
-export const nullableToDefault = (
- defaultValue: T,
- maybeValue: T | undefined,
-): T => {
- if (maybeValue == undefined) {
- return defaultValue;
- }
- return maybeValue;
-};
diff --git a/src/helpers/textWidth.ts b/src/helpers/textWidth.ts
new file mode 100644
index 0000000000..a4eefb23a7
--- /dev/null
+++ b/src/helpers/textWidth.ts
@@ -0,0 +1,44 @@
+/**
+ * 文字列の描画幅を予測するための関数。
+ */
+import { z } from "zod";
+
+let textWidthTempCanvas: HTMLCanvasElement | undefined;
+let textWidthTempContext: CanvasRenderingContext2D | undefined;
+
+const textWidthCacheKeySchema = z.string().brand("TextWidthCacheKey");
+type TextWidthCacheKey = z.infer;
+
+export type FontSpecification = {
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: string;
+};
+const getTextWidthCacheKey = (text: string, font: FontSpecification) =>
+ textWidthCacheKeySchema.parse(
+ `${text}-${font.fontFamily}-${font.fontWeight}-${font.fontSize}`,
+ );
+
+const textWidthCache = new Map();
+/**
+ * 特定のフォントでの文字列の描画幅を取得する。
+ * @see https://stackoverflow.com/a/21015393
+ */
+export function predictTextWidth(text: string, font: FontSpecification) {
+ const key = getTextWidthCacheKey(text, font);
+ const maybeCached = textWidthCache.get(key);
+ if (maybeCached != undefined) {
+ return maybeCached;
+ }
+ if (!textWidthTempCanvas) {
+ textWidthTempCanvas = document.createElement("canvas");
+ textWidthTempContext = textWidthTempCanvas.getContext("2d") ?? undefined;
+ }
+ if (!textWidthTempContext) {
+ throw new Error("Failed to get 2d context");
+ }
+ textWidthTempContext.font = `${font.fontWeight} ${font.fontSize}px ${font.fontFamily}`;
+ const metrics = textWidthTempContext.measureText(text);
+ textWidthCache.set(key, metrics.width);
+ return metrics.width;
+}
diff --git a/src/mock/engineMock/README.md b/src/mock/engineMock/README.md
new file mode 100644
index 0000000000..166e4043db
--- /dev/null
+++ b/src/mock/engineMock/README.md
@@ -0,0 +1,44 @@
+# エンジンモックのドキュメント
+
+## 概要
+
+通信を介さずに音声合成できるエンジンのモックです。
+
+同じ入力には同じ出力を返し、別の入力には別の出力を返すようになっています。
+また出力を見たときにUIや処理の実装の異常に気付けるように、ある程度直感に合う出力を返すよう努力されています。
+
+例:音量を下げると音声が小さくなる、音程と周波数が一致する、など。
+
+モックの実装は気軽に破壊的変更をしても問題ありません。
+
+## ビルド戦略
+
+ブラウザ版でも使えるようにすべく、ソフトウェアにも組み込める形で実装されています。
+ビルド時のモックエンジンの取り扱いポリシーはこんな感じです。
+
+- 重い処理が一切実行されないようにする
+ - 辞書の初期化、画像の読み込みなど
+- なるべく重いファイルはビルドに含まれないようにする
+ - 形態素解析の辞書ファイルやダミー画像など
+
+## ファイル構成
+
+- `talkModelMock.ts`
+ - トーク用の音声クエリを作るまでの処理周り
+- `singModelMock.ts`
+ - ソング用の音声クエリを作るまでの処理周り
+- `audioQueryMock.ts`
+ - 音声クエリ周り
+- `synthesisMock.ts`
+ - 音声波形の合成周り
+- `characterResourceMock.ts`
+ - キャラ名や画像などのリソース周り
+- `phonemeMock.ts`
+ - 音素周り
+- `manifestMock.ts`
+ - エンジンのマニフェスト周り
+
+## kuromoji.jsについて
+
+本家kuromoji.jsはパス操作周りでエラーが起こるので、フォーク版を使っています。
+mock用途以外にkuromoji.jsを使う予定はなく、もし動かなくなった際は依存を外すことも検討します。
diff --git a/tests/e2e/browser/assets/icon_1.png b/src/mock/engineMock/assets/icon_1.png
similarity index 100%
rename from tests/e2e/browser/assets/icon_1.png
rename to src/mock/engineMock/assets/icon_1.png
diff --git a/tests/e2e/browser/assets/icon_2.png b/src/mock/engineMock/assets/icon_2.png
similarity index 100%
rename from tests/e2e/browser/assets/icon_2.png
rename to src/mock/engineMock/assets/icon_2.png
diff --git a/tests/e2e/browser/assets/icon_3.png b/src/mock/engineMock/assets/icon_3.png
similarity index 100%
rename from tests/e2e/browser/assets/icon_3.png
rename to src/mock/engineMock/assets/icon_3.png
diff --git a/tests/e2e/browser/assets/icon_4.png b/src/mock/engineMock/assets/icon_4.png
similarity index 100%
rename from tests/e2e/browser/assets/icon_4.png
rename to src/mock/engineMock/assets/icon_4.png
diff --git a/tests/e2e/browser/assets/portrait_1.png b/src/mock/engineMock/assets/portrait_1.png
similarity index 100%
rename from tests/e2e/browser/assets/portrait_1.png
rename to src/mock/engineMock/assets/portrait_1.png
diff --git a/tests/e2e/browser/assets/portrait_2.png b/src/mock/engineMock/assets/portrait_2.png
similarity index 100%
rename from tests/e2e/browser/assets/portrait_2.png
rename to src/mock/engineMock/assets/portrait_2.png
diff --git a/tests/e2e/browser/assets/portrait_3.png b/src/mock/engineMock/assets/portrait_3.png
similarity index 100%
rename from tests/e2e/browser/assets/portrait_3.png
rename to src/mock/engineMock/assets/portrait_3.png
diff --git a/tests/e2e/browser/assets/portrait_4.png b/src/mock/engineMock/assets/portrait_4.png
similarity index 100%
rename from tests/e2e/browser/assets/portrait_4.png
rename to src/mock/engineMock/assets/portrait_4.png
diff --git a/src/mock/engineMock/audioQueryMock.ts b/src/mock/engineMock/audioQueryMock.ts
new file mode 100644
index 0000000000..8a4c4dda33
--- /dev/null
+++ b/src/mock/engineMock/audioQueryMock.ts
@@ -0,0 +1,195 @@
+/**
+ * AudioQueryとFrameAudioQueryのモック。
+ * VOICEVOX ENGINEリポジトリの処理とほぼ同じ。
+ */
+
+import { AccentPhrase, AudioQuery, FrameAudioQuery, Mora } from "@/openapi";
+
+function generateSilenceMora(length: number): Mora {
+ return {
+ text: " ",
+ vowel: "sil",
+ vowelLength: length,
+ pitch: 0.0,
+ };
+}
+
+function toFlattenMoras(accentPhrases: AccentPhrase[]): Mora[] {
+ let moras: Mora[] = [];
+ accentPhrases.forEach((accentPhrase) => {
+ moras = moras.concat(accentPhrase.moras);
+ if (accentPhrase.pauseMora) {
+ moras.push(accentPhrase.pauseMora);
+ }
+ });
+ return moras;
+}
+
+function toFlattenPhonemes(moras: Mora[]): string[] {
+ const phonemes: string[] = [];
+ for (const mora of moras) {
+ if (mora.consonant) {
+ phonemes.push(mora.consonant);
+ }
+ phonemes.push(mora.vowel);
+ }
+ return phonemes;
+}
+
+/** 前後の無音モーラを追加する */
+function applyPrePostSilence(moras: Mora[], query: AudioQuery): Mora[] {
+ const preSilenceMoras = [generateSilenceMora(query.prePhonemeLength)];
+ const postSilenceMoras = [generateSilenceMora(query.postPhonemeLength)];
+ return preSilenceMoras.concat(moras).concat(postSilenceMoras);
+}
+
+/** 無音時間を置き換える */
+function applyPauseLength(moras: Mora[], query: AudioQuery): Mora[] {
+ if (query.pauseLength != undefined) {
+ for (const mora of moras) {
+ if (mora.vowel == "pau") {
+ mora.vowelLength = query.pauseLength;
+ }
+ }
+ }
+ return moras;
+}
+
+/** 無音時間スケールを適用する */
+function applyPauseLengthScale(moras: Mora[], query: AudioQuery): Mora[] {
+ if (query.pauseLengthScale != undefined) {
+ for (const mora of moras) {
+ if (mora.vowel == "pau") {
+ mora.vowelLength *= query.pauseLengthScale;
+ }
+ }
+ }
+ return moras;
+}
+
+/** 話速スケールを適用する */
+function applySpeedScale(moras: Mora[], query: AudioQuery): Mora[] {
+ for (const mora of moras) {
+ mora.vowelLength /= query.speedScale;
+ if (mora.consonantLength) {
+ mora.consonantLength /= query.speedScale;
+ }
+ }
+ return moras;
+}
+
+/** 音高スケールを適用する */
+function applyPitchScale(moras: Mora[], query: AudioQuery): Mora[] {
+ for (const mora of moras) {
+ mora.pitch *= 2 ** query.pitchScale;
+ }
+ return moras;
+}
+
+/** 抑揚スケールを適用する */
+function applyIntonationScale(moras: Mora[], query: AudioQuery): Mora[] {
+ const voiced = moras.filter((mora) => mora.pitch > 0);
+ if (voiced.length == 0) {
+ return moras;
+ }
+
+ const meanF0 =
+ voiced.reduce((sum, mora) => sum + mora.pitch, 0) / voiced.length;
+ for (const mora of voiced) {
+ mora.pitch = (mora.pitch - meanF0) * query.intonationScale + meanF0;
+ }
+ return moras;
+}
+
+/** 疑問文の最後に音高の高いモーラを追加する */
+function applyInterrogativeUpspeak(accentPhrases: Array) {
+ accentPhrases.forEach((accentPhrase) => {
+ const moras = accentPhrase.moras;
+ if (
+ moras.length > 0 &&
+ accentPhrase.isInterrogative &&
+ moras[moras.length - 1].pitch > 0
+ ) {
+ const lastMora = moras[moras.length - 1];
+ const upspeakMora: Mora = {
+ text: "ー",
+ vowel: lastMora.vowel,
+ vowelLength: 0.15,
+ pitch: lastMora.pitch + 0.3,
+ };
+ accentPhrase.moras.push(upspeakMora);
+ }
+ });
+}
+
+function secondToFrame(second: number): number {
+ const FRAME_RATE = 24000 / 256;
+ return Math.round(second * FRAME_RATE);
+}
+
+/** モーラや音素ごとのフレーム数を数える */
+function countFramePerUnit(moras: Mora[]): {
+ framePerPhoneme: number[];
+ framePerMora: number[];
+} {
+ const framePerPhoneme: number[] = [];
+ const framePerMora: number[] = [];
+
+ for (const mora of moras) {
+ const vowelFrames = secondToFrame(mora.vowelLength);
+ const consonantFrames = mora.consonantLength
+ ? secondToFrame(mora.consonantLength)
+ : 0;
+ const moraFrames = vowelFrames + consonantFrames;
+
+ if (mora.consonant) {
+ framePerPhoneme.push(consonantFrames);
+ }
+ framePerPhoneme.push(vowelFrames);
+ framePerMora.push(moraFrames);
+ }
+
+ return { framePerPhoneme, framePerMora };
+}
+
+/** AudioQueryを適当にFrameAudioQueryに変換する */
+export function audioQueryToFrameAudioQueryMock(
+ audioQuery: AudioQuery,
+ { enableInterrogativeUpspeak }: { enableInterrogativeUpspeak: boolean },
+): FrameAudioQuery {
+ const accentPhrases = audioQuery.accentPhrases;
+
+ if (enableInterrogativeUpspeak) {
+ applyInterrogativeUpspeak(accentPhrases);
+ }
+
+ let moras = toFlattenMoras(accentPhrases);
+ moras = applyPrePostSilence(moras, audioQuery);
+ moras = applyPauseLength(moras, audioQuery);
+ moras = applyPauseLengthScale(moras, audioQuery);
+ moras = applySpeedScale(moras, audioQuery);
+ moras = applyPitchScale(moras, audioQuery);
+ moras = applyIntonationScale(moras, audioQuery);
+
+ const { framePerPhoneme, framePerMora } = countFramePerUnit(moras);
+
+ const f0 = moras.flatMap((mora, i) =>
+ Array(framePerMora[i]).fill(
+ mora.pitch == 0 ? 0 : Math.exp(mora.pitch),
+ ),
+ );
+ const volume = Array(f0.length).fill(audioQuery.volumeScale);
+ const phonemes = toFlattenPhonemes(moras).map((phoneme, i) => ({
+ phoneme,
+ frameLength: framePerPhoneme[i],
+ }));
+
+ return {
+ f0,
+ volume,
+ phonemes,
+ volumeScale: audioQuery.volumeScale,
+ outputSamplingRate: audioQuery.outputSamplingRate,
+ outputStereo: audioQuery.outputStereo,
+ };
+}
diff --git a/src/mock/engineMock/characterResourceMock.ts b/src/mock/engineMock/characterResourceMock.ts
new file mode 100644
index 0000000000..400fcd67de
--- /dev/null
+++ b/src/mock/engineMock/characterResourceMock.ts
@@ -0,0 +1,139 @@
+/**
+ * キャラクター情報を作るモック。
+ * なんとなくVOICEVOX ENGINEリポジトリのモック実装と揃えている。
+ */
+
+import { Speaker, SpeakerInfo } from "@/openapi";
+
+/** 立ち絵のURLを得る */
+async function getPortraitUrl(characterIndex: number) {
+ const portraits = Object.values(
+ import.meta.glob<{ default: string }>("./assets/portrait_*.png"),
+ );
+ return (await portraits[characterIndex]()).default;
+}
+
+/** アイコンのURLを得る */
+async function getIconUrl(characterIndex: number) {
+ const icons = Object.values(
+ import.meta.glob<{ default: string }>("./assets/icon_*.png"),
+ );
+ return (await icons[characterIndex]()).default;
+}
+
+const baseCharactersMock = [
+ // トーク2つ・ハミング2つ
+ {
+ name: "dummy1",
+ styles: [
+ { name: "style0", id: 0 },
+ { name: "style1", id: 2 },
+ { name: "style2", id: 4, type: "frame_decode" },
+ { name: "style3", id: 6, type: "frame_decode" },
+ ],
+ speakerUuid: "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff",
+ version: "mock",
+ },
+ // トーク2つ・ハミング1つ・ソング1つ
+ {
+ name: "dummy2",
+ styles: [
+ { name: "style0", id: 1 },
+ { name: "style1", id: 3 },
+ { name: "style2", id: 5, type: "frame_decode" },
+ { name: "style3", id: 7, type: "sing" },
+ ],
+ speakerUuid: "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9",
+ version: "mock",
+ },
+ // トーク1つ
+ {
+ name: "dummy3",
+ styles: [{ name: "style0", id: 8, type: "talk" }],
+ speakerUuid: "35b2c544-660e-401e-b503-0e14c635303a",
+ version: "mock",
+ },
+ // ソング1つ
+ {
+ name: "dummy4",
+ styles: [{ name: "style0", id: 9, type: "sing" }],
+ speakerUuid: "b1a81618-b27b-40d2-b0ea-27a9ad408c4b",
+ version: "mock",
+ },
+] satisfies Speaker[];
+
+/** 全てのキャラクターを返すモック */
+export function getCharactersMock(): Speaker[] {
+ return baseCharactersMock;
+}
+
+/** 喋れるキャラクターを返すモック */
+export function getSpeakersMock(): Speaker[] {
+ return (
+ baseCharactersMock
+ // スタイルをトークのみに絞り込む
+ .map((character) => ({
+ ...character,
+ styles: character.styles.filter(
+ (style) => style.type == undefined || style.type == "talk",
+ ),
+ }))
+ // 1つもスタイルがないキャラクターを除外
+ .filter((character) => character.styles.length > 0)
+ );
+}
+
+/* 歌えるキャラクターを返すモック */
+export function getSingersMock(): Speaker[] {
+ return (
+ baseCharactersMock
+ // スタイルをソングのみに絞り込む
+ .map((character) => ({
+ ...character,
+ styles: character.styles.filter(
+ (style) => style.type == "frame_decode" || style.type == "sing",
+ ),
+ }))
+ // 1つもスタイルがないキャラクターを除外
+ .filter((character) => character.styles.length > 0)
+ );
+}
+
+/** キャラクターの追加情報を返すモック。 */
+export async function getCharacterInfoMock(
+ speakerUuid: string,
+): Promise {
+ // NOTE: 画像のURLを得るために必要
+ const characterIndex = baseCharactersMock.findIndex(
+ (speaker) => speaker.speakerUuid === speakerUuid,
+ );
+ if (characterIndex === -1) {
+ throw new Error(`Character not found: ${speakerUuid}`);
+ }
+
+ const styleIds = baseCharactersMock[characterIndex].styles.map(
+ (style) => style.id,
+ );
+
+ return {
+ policy: `Dummy policy for ${speakerUuid}`,
+ portrait: await getPortraitUrl(characterIndex),
+ styleInfos: await Promise.all(
+ styleIds.map(async (id) => ({
+ id,
+ icon: await getIconUrl(characterIndex),
+ voiceSamples: [],
+ })),
+ ),
+ };
+}
+
+/**
+ * 喋れるキャラクターの追加情報を返すモック。
+ * 本当は喋れるスタイルのみでフィルタリングすべき。
+ */
+export async function getSpeakerInfoMock(
+ speakerUuid: string,
+): Promise {
+ return getCharacterInfoMock(speakerUuid);
+}
diff --git a/src/mock/engineMock/index.ts b/src/mock/engineMock/index.ts
new file mode 100644
index 0000000000..96d8ac01fa
--- /dev/null
+++ b/src/mock/engineMock/index.ts
@@ -0,0 +1,216 @@
+import { audioQueryToFrameAudioQueryMock } from "./audioQueryMock";
+import { getEngineManifestMock } from "./manifestMock";
+import {
+ getSingersMock,
+ getSpeakerInfoMock,
+ getSpeakersMock,
+} from "./characterResourceMock";
+import { synthesisFrameAudioQueryMock } from "./synthesisMock";
+import {
+ replaceLengthMock,
+ replacePitchMock,
+ textToActtentPhrasesMock,
+} from "./talkModelMock";
+import {
+ notesAndFramePhonemesAndPitchToVolumeMock,
+ notesAndFramePhonemesToPitchMock,
+ notesToFramePhonemesMock,
+} from "./singModelMock";
+
+import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy";
+import {
+ AccentPhrase,
+ AccentPhrasesAccentPhrasesPostRequest,
+ AudioQuery,
+ AudioQueryAudioQueryPostRequest,
+ DefaultApiInterface,
+ EngineManifest,
+ FrameAudioQuery,
+ FrameSynthesisFrameSynthesisPostRequest,
+ MoraDataMoraDataPostRequest,
+ SingerInfoSingerInfoGetRequest,
+ SingFrameAudioQuerySingFrameAudioQueryPostRequest,
+ SingFrameVolumeSingFrameVolumePostRequest,
+ Speaker,
+ SpeakerInfo,
+ SpeakerInfoSpeakerInfoGetRequest,
+ SupportedDevicesInfo,
+ SynthesisSynthesisPostRequest,
+ UserDictWord,
+} from "@/openapi";
+
+/**
+ * エンジンのOpenAPIの関数群のモック。
+ * 実装されていない関数もある。
+ */
+export function createOpenAPIEngineMock(): DefaultApiInterface {
+ const mockApi: Partial = {
+ async versionVersionGet(): Promise {
+ return "mock";
+ },
+
+ // メタ情報
+ async engineManifestEngineManifestGet(): Promise {
+ return getEngineManifestMock();
+ },
+
+ async supportedDevicesSupportedDevicesGet(): Promise {
+ return { cpu: true, cuda: false, dml: false };
+ },
+
+ // キャラクター情報
+ async isInitializedSpeakerIsInitializedSpeakerGet(): Promise {
+ return true;
+ },
+
+ async initializeSpeakerInitializeSpeakerPost(): Promise {
+ return;
+ },
+
+ async speakersSpeakersGet(): Promise {
+ return getSpeakersMock();
+ },
+
+ async speakerInfoSpeakerInfoGet(
+ payload: SpeakerInfoSpeakerInfoGetRequest,
+ ): Promise {
+ return getSpeakerInfoMock(payload.speakerUuid);
+ },
+
+ async singersSingersGet(): Promise {
+ return getSingersMock();
+ },
+
+ async singerInfoSingerInfoGet(
+ paload: SingerInfoSingerInfoGetRequest,
+ ): Promise {
+ return getSpeakerInfoMock(paload.speakerUuid);
+ },
+
+ // トーク系
+ async audioQueryAudioQueryPost(
+ payload: AudioQueryAudioQueryPostRequest,
+ ): Promise {
+ const accentPhrases = await textToActtentPhrasesMock(
+ payload.text,
+ payload.speaker,
+ );
+
+ return {
+ accentPhrases,
+ speedScale: 1.0,
+ pitchScale: 0,
+ intonationScale: 1.0,
+ volumeScale: 1.0,
+ prePhonemeLength: 0.1,
+ postPhonemeLength: 0.1,
+ outputSamplingRate: getEngineManifestMock().defaultSamplingRate,
+ outputStereo: false,
+ };
+ },
+
+ async accentPhrasesAccentPhrasesPost(
+ payload: AccentPhrasesAccentPhrasesPostRequest,
+ ): Promise {
+ if (payload.isKana == true)
+ throw new Error("AquesTalk風記法は未対応です");
+
+ const accentPhrases = await textToActtentPhrasesMock(
+ payload.text,
+ payload.speaker,
+ );
+ return accentPhrases;
+ },
+
+ async moraDataMoraDataPost(
+ payload: MoraDataMoraDataPostRequest,
+ ): Promise {
+ const accentPhrase = cloneWithUnwrapProxy(payload.accentPhrase);
+ replaceLengthMock(accentPhrase, payload.speaker);
+ replacePitchMock(accentPhrase, payload.speaker);
+ return accentPhrase;
+ },
+
+ async synthesisSynthesisPost(
+ payload: SynthesisSynthesisPostRequest,
+ ): Promise {
+ const frameAudioQuery = audioQueryToFrameAudioQueryMock(
+ payload.audioQuery,
+ {
+ enableInterrogativeUpspeak:
+ payload.enableInterrogativeUpspeak ?? false,
+ },
+ );
+ const buffer = synthesisFrameAudioQueryMock(
+ frameAudioQuery,
+ payload.speaker,
+ );
+ return new Blob([buffer], { type: "audio/wav" });
+ },
+
+ // ソング系
+ async singFrameAudioQuerySingFrameAudioQueryPost(
+ payload: SingFrameAudioQuerySingFrameAudioQueryPostRequest,
+ ): Promise {
+ const { score, speaker: styleId } = cloneWithUnwrapProxy(payload);
+
+ const phonemes = notesToFramePhonemesMock(score.notes, styleId);
+ const f0 = notesAndFramePhonemesToPitchMock(
+ score.notes,
+ phonemes,
+ styleId,
+ );
+ const volume = notesAndFramePhonemesAndPitchToVolumeMock(
+ score.notes,
+ phonemes,
+ f0,
+ styleId,
+ );
+
+ return {
+ f0,
+ volume,
+ phonemes,
+ volumeScale: 1.0,
+ outputSamplingRate: getEngineManifestMock().defaultSamplingRate,
+ outputStereo: false,
+ };
+ },
+
+ async singFrameVolumeSingFrameVolumePost(
+ payload: SingFrameVolumeSingFrameVolumePostRequest,
+ ): Promise> {
+ const {
+ speaker: styleId,
+ bodySingFrameVolumeSingFrameVolumePost: { score, frameAudioQuery },
+ } = cloneWithUnwrapProxy(payload);
+
+ const volume = notesAndFramePhonemesAndPitchToVolumeMock(
+ score.notes,
+ frameAudioQuery.phonemes,
+ frameAudioQuery.f0,
+ styleId,
+ );
+ return volume;
+ },
+
+ async frameSynthesisFrameSynthesisPost(
+ payload: FrameSynthesisFrameSynthesisPostRequest,
+ ): Promise {
+ const { speaker: styleId, frameAudioQuery } =
+ cloneWithUnwrapProxy(payload);
+ const buffer = synthesisFrameAudioQueryMock(frameAudioQuery, styleId);
+ return new Blob([buffer], { type: "audio/wav" });
+ },
+
+ // 辞書系
+ async getUserDictWordsUserDictGet(): Promise<{
+ [key: string]: UserDictWord;
+ }> {
+ // ダミーで空の辞書を返す
+ return {};
+ },
+ };
+
+ return mockApi satisfies Partial as DefaultApiInterface;
+}
diff --git a/src/mock/engineMock/manifestMock.ts b/src/mock/engineMock/manifestMock.ts
new file mode 100644
index 0000000000..4a32cf957b
--- /dev/null
+++ b/src/mock/engineMock/manifestMock.ts
@@ -0,0 +1,36 @@
+/**
+ * エンジンマニフェストのモック。
+ */
+
+import { EngineManifest } from "@/openapi";
+
+/** エンジンマニフェストを返すモック */
+export function getEngineManifestMock() {
+ return {
+ manifestVersion: "0.13.1",
+ name: "DUMMY Engine",
+ brandName: "DUMMY",
+ uuid: "c7b58856-bd56-4aa1-afb7-b8415f824b06",
+ url: "not_found",
+ icon: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjWHpl7X8AB24DJsTeKbEAAAAASUVORK5CYII=", // 1pxの画像
+ defaultSamplingRate: 24000,
+ frameRate: 93.75,
+ termsOfService: "not_found",
+ updateInfos: [],
+ dependencyLicenses: [],
+ supportedVvlibManifestVersion: undefined,
+ supportedFeatures: {
+ adjustMoraPitch: true,
+ adjustPhonemeLength: true,
+ adjustSpeedScale: true,
+ adjustPitchScale: true,
+ adjustIntonationScale: true,
+ adjustVolumeScale: true,
+ interrogativeUpspeak: false,
+ synthesisMorphing: false,
+ sing: true,
+ manageLibrary: false,
+ returnResourceUrl: true,
+ },
+ } satisfies EngineManifest;
+}
diff --git a/src/mock/engineMock/phonemeMock.ts b/src/mock/engineMock/phonemeMock.ts
new file mode 100644
index 0000000000..d128b1648e
--- /dev/null
+++ b/src/mock/engineMock/phonemeMock.ts
@@ -0,0 +1,168 @@
+/** カタカナを音素に変換する */
+export const moraToPhonemes: {
+ [key: string]: [string | undefined, string];
+} = {
+ ヴォ: ["v", "o"],
+ ヴェ: ["v", "e"],
+ ヴィ: ["v", "i"],
+ ヴァ: ["v", "a"],
+ ヴ: ["v", "u"],
+ ン: [undefined, "N"],
+ ワ: ["w", "a"],
+ ロ: ["r", "o"],
+ レ: ["r", "e"],
+ ル: ["r", "u"],
+ リョ: ["ry", "o"],
+ リュ: ["ry", "u"],
+ リャ: ["ry", "a"],
+ リェ: ["ry", "e"],
+ リ: ["r", "i"],
+ ラ: ["r", "a"],
+ ヨ: ["y", "o"],
+ ユ: ["y", "u"],
+ ヤ: ["y", "a"],
+ モ: ["m", "o"],
+ メ: ["m", "e"],
+ ム: ["m", "u"],
+ ミョ: ["my", "o"],
+ ミュ: ["my", "u"],
+ ミャ: ["my", "a"],
+ ミェ: ["my", "e"],
+ ミ: ["m", "i"],
+ マ: ["m", "a"],
+ ポ: ["p", "o"],
+ ボ: ["b", "o"],
+ ホ: ["h", "o"],
+ ペ: ["p", "e"],
+ ベ: ["b", "e"],
+ ヘ: ["h", "e"],
+ プ: ["p", "u"],
+ ブ: ["b", "u"],
+ フォ: ["f", "o"],
+ フェ: ["f", "e"],
+ フィ: ["f", "i"],
+ ファ: ["f", "a"],
+ フ: ["f", "u"],
+ ピョ: ["py", "o"],
+ ピュ: ["py", "u"],
+ ピャ: ["py", "a"],
+ ピェ: ["py", "e"],
+ ピ: ["p", "i"],
+ ビョ: ["by", "o"],
+ ビュ: ["by", "u"],
+ ビャ: ["by", "a"],
+ ビェ: ["by", "e"],
+ ビ: ["b", "i"],
+ ヒョ: ["hy", "o"],
+ ヒュ: ["hy", "u"],
+ ヒャ: ["hy", "a"],
+ ヒェ: ["hy", "e"],
+ ヒ: ["h", "i"],
+ パ: ["p", "a"],
+ バ: ["b", "a"],
+ ハ: ["h", "a"],
+ ノ: ["n", "o"],
+ ネ: ["n", "e"],
+ ヌ: ["n", "u"],
+ ニョ: ["ny", "o"],
+ ニュ: ["ny", "u"],
+ ニャ: ["ny", "a"],
+ ニェ: ["ny", "e"],
+ ニ: ["n", "i"],
+ ナ: ["n", "a"],
+ ドゥ: ["d", "u"],
+ ド: ["d", "o"],
+ トゥ: ["t", "u"],
+ ト: ["t", "o"],
+ デョ: ["dy", "o"],
+ デュ: ["dy", "u"],
+ デャ: ["dy", "a"],
+ デェ: ["dy", "e"],
+ ディ: ["d", "i"],
+ デ: ["d", "e"],
+ テョ: ["ty", "o"],
+ テュ: ["ty", "u"],
+ テャ: ["ty", "a"],
+ ティ: ["t", "i"],
+ テ: ["t", "e"],
+ ツォ: ["ts", "o"],
+ ツェ: ["ts", "e"],
+ ツィ: ["ts", "i"],
+ ツァ: ["ts", "a"],
+ ツ: ["ts", "u"],
+ ッ: [undefined, "cl"],
+ チョ: ["ch", "o"],
+ チュ: ["ch", "u"],
+ チャ: ["ch", "a"],
+ チェ: ["ch", "e"],
+ チ: ["ch", "i"],
+ ダ: ["d", "a"],
+ タ: ["t", "a"],
+ ゾ: ["z", "o"],
+ ソ: ["s", "o"],
+ ゼ: ["z", "e"],
+ セ: ["s", "e"],
+ ズィ: ["z", "i"],
+ ズ: ["z", "u"],
+ スィ: ["s", "i"],
+ ス: ["s", "u"],
+ ジョ: ["j", "o"],
+ ジュ: ["j", "u"],
+ ジャ: ["j", "a"],
+ ジェ: ["j", "e"],
+ ジ: ["j", "i"],
+ ショ: ["sh", "o"],
+ シュ: ["sh", "u"],
+ シャ: ["sh", "a"],
+ シェ: ["sh", "e"],
+ シ: ["sh", "i"],
+ ザ: ["z", "a"],
+ サ: ["s", "a"],
+ ゴ: ["g", "o"],
+ コ: ["k", "o"],
+ ゲ: ["g", "e"],
+ ケ: ["k", "e"],
+ グヮ: ["gw", "a"],
+ グ: ["g", "u"],
+ クヮ: ["kw", "a"],
+ ク: ["k", "u"],
+ ギョ: ["gy", "o"],
+ ギュ: ["gy", "u"],
+ ギャ: ["gy", "a"],
+ ギェ: ["gy", "e"],
+ ギ: ["g", "i"],
+ キョ: ["ky", "o"],
+ キュ: ["ky", "u"],
+ キャ: ["ky", "a"],
+ キェ: ["ky", "e"],
+ キ: ["k", "i"],
+ ガ: ["g", "a"],
+ カ: ["k", "a"],
+ オ: [undefined, "o"],
+ エ: [undefined, "e"],
+ ウォ: ["w", "o"],
+ ウェ: ["w", "e"],
+ ウィ: ["w", "i"],
+ ウ: [undefined, "u"],
+ イェ: ["y", "e"],
+ イ: [undefined, "i"],
+ ア: [undefined, "a"],
+ ヴョ: ["by", "o"],
+ ヴュ: ["by", "u"],
+ ヴャ: ["by", "a"],
+ ヲ: [undefined, "o"],
+ ヱ: [undefined, "e"],
+ ヰ: [undefined, "i"],
+ ヮ: ["w", "a"],
+ ョ: ["y", "o"],
+ ュ: ["y", "u"],
+ ヅ: ["z", "u"],
+ ヂ: ["j", "i"],
+ ヶ: ["k", "e"],
+ ャ: ["y", "a"],
+ ォ: [undefined, "o"],
+ ェ: [undefined, "e"],
+ ゥ: [undefined, "u"],
+ ィ: [undefined, "i"],
+ ァ: [undefined, "a"],
+};
diff --git a/src/mock/engineMock/singModelMock.ts b/src/mock/engineMock/singModelMock.ts
new file mode 100644
index 0000000000..7005f8586e
--- /dev/null
+++ b/src/mock/engineMock/singModelMock.ts
@@ -0,0 +1,168 @@
+/**
+ * ソング系の構造体を作るモック。
+ */
+
+import { moraToPhonemes } from "./phonemeMock";
+import { convertHiraToKana } from "@/domain/japanese";
+import { Note, FramePhoneme } from "@/openapi";
+
+function noteNumberToFrequency(noteNumber: number) {
+ return 440 * Math.pow(2, (noteNumber - 69) / 12);
+}
+
+/** アルファベット文字列を適当な0~1の適当な数値に変換する */
+function alphabetsToNumber(text: string): number {
+ const codes = text.split("").map((c) => c.charCodeAt(0));
+ const sum = codes.reduce((a, b) => a + b);
+ return (sum % 256) / 256;
+}
+
+/** 0.01~0.25になるように適当な長さを決める */
+function phonemeToLengthMock(phoneme: string): number {
+ return alphabetsToNumber(phoneme) * 0.24 + 0.01;
+}
+
+/** 揺れ幅が-30cent~30centになるように適当なピッチを決める */
+function phonemeAndKeyToPitchMock(phoneme: string, key: number): number {
+ const base = noteNumberToFrequency(key);
+ const shift = (-30 + 60 * alphabetsToNumber(phoneme)) / 1200;
+ return base * Math.pow(2, shift);
+}
+
+/** 0.8~1.0になるような適当な音量を決める */
+function phonemeAndPitchToVolumeMock(phoneme: string, pitch: number): number {
+ const minPitch = noteNumberToFrequency(1);
+ const maxPitch = noteNumberToFrequency(128);
+ const normalized = (pitch - minPitch) / (maxPitch - minPitch);
+ return 0.75 + normalized * 0.2 + alphabetsToNumber(phoneme) * 0.05;
+}
+
+/**
+ * ノートから音素と適当な音素長を作成する。
+ * 母音の開始位置をノートの開始位置は一致させ、子音は前のノートに食い込むようにする。
+ */
+export function notesToFramePhonemesMock(
+ notes: Note[],
+ styleId: number,
+): FramePhoneme[] {
+ const framePhonemes: FramePhoneme[] = [];
+ for (const note of notes) {
+ const noteId = note.id;
+
+ // 休符の場合はノートの長さ
+ if (note.key == undefined && note.lyric == "") {
+ framePhonemes.push({
+ noteId,
+ phoneme: "pau",
+ frameLength: note.frameLength,
+ });
+ continue;
+ }
+
+ const phonemes = moraToPhonemes[convertHiraToKana(note.lyric)];
+ if (phonemes == undefined)
+ throw new Error(`音素に変換できません: ${note.lyric}`);
+
+ const [consonant, vowel] = phonemes;
+
+ if (consonant != undefined) {
+ // 子音は適当な長さ
+ let consonantLength = phonemeToLengthMock(consonant);
+
+ // 別の歌手で同じにならないように適当に値をずらす
+ consonantLength += styleId * 0.03;
+
+ // 子音の長さが前のノートの長さ以上になる場合、子音の長さをノートの半分にする
+ const beforeFramePhoneme = framePhonemes[framePhonemes.length - 1];
+ if (beforeFramePhoneme.frameLength < consonantLength) {
+ consonantLength = beforeFramePhoneme.frameLength / 2;
+ }
+
+ // 整数値にする
+ consonantLength = Math.max(Math.round(consonantLength), 1);
+
+ // 子音は前のノートに食い込む
+ beforeFramePhoneme.frameLength -= consonantLength;
+ framePhonemes.push({
+ noteId,
+ phoneme: consonant,
+ frameLength: consonantLength,
+ });
+ }
+
+ // 母音はノートの長さ
+ const vowelLength = note.frameLength;
+ framePhonemes.push({ noteId, phoneme: vowel, frameLength: vowelLength });
+ }
+
+ return framePhonemes;
+}
+
+/** ノートと音素長から適当なピッチを作成する */
+export function notesAndFramePhonemesToPitchMock(
+ notes: Note[],
+ framePhonemes: FramePhoneme[],
+ styleId: number,
+): number[] {
+ // 製品版エンジンへの特別対応の都合でstyleId=6000が来ることがあるので特別処理
+ styleId %= 6000;
+
+ return framePhonemes.flatMap((phoneme, i) => {
+ let pitch;
+
+ // 休符の場合は0
+ if (phoneme.phoneme == "pau") {
+ pitch = 0;
+ } else {
+ // IDが同じノートを探す
+ const note = notes
+ .filter((note) => note.id != undefined)
+ .find((note) => note.id == phoneme.noteId);
+ if (note == undefined)
+ throw new Error(
+ `ノートが見つかりません: index=${i} phoneme=${phoneme.phoneme}`,
+ );
+
+ if (note.key != undefined) {
+ pitch = phonemeAndKeyToPitchMock(phoneme.phoneme, note.key);
+
+ // 別の歌手で同じにならないように適当に値をずらす
+ pitch *= 1 + styleId * 0.03;
+ } else {
+ pitch = 0;
+ }
+ }
+
+ return Array(phoneme.frameLength).fill(pitch);
+ });
+}
+
+/**
+ * ノートと音素長とピッチから適当な音量を作成する。
+ * ピッチが高いほど音量が大きくなるようにする。
+ * NOTE: ノートは一旦無視している。
+ */
+export function notesAndFramePhonemesAndPitchToVolumeMock(
+ notes: Note[],
+ framePhonemes: FramePhoneme[],
+ f0: number[],
+ styleId: number,
+): number[] {
+ const phonemePerFrame = framePhonemes.flatMap((phoneme) =>
+ Array(phoneme.frameLength).fill(phoneme.phoneme),
+ );
+
+ return Array(f0.length)
+ .fill(-1)
+ .map((_, i) => {
+ const phoneme = phonemePerFrame[i];
+ const pitch = f0[i];
+
+ let volume = phonemeAndPitchToVolumeMock(phoneme, pitch);
+
+ // 別の歌手で同じにならないように適当に値をずらす
+ volume *= 1 - styleId * 0.03;
+
+ return volume;
+ });
+}
diff --git a/src/mock/engineMock/synthesisMock.ts b/src/mock/engineMock/synthesisMock.ts
new file mode 100644
index 0000000000..60cb9570ac
--- /dev/null
+++ b/src/mock/engineMock/synthesisMock.ts
@@ -0,0 +1,254 @@
+/**
+ * 音声合成するモック。
+ * 音高と音量はそれっぽい音を合成する。
+ * 音素は適当に別々の電子音にする。
+ */
+
+import { FrameAudioQuery } from "@/openapi";
+import { generateWavFileData } from "@/sing/fileDataGenerator";
+import { applyGaussianFilter } from "@/sing/utility";
+
+/** 0~1を返す疑似乱数生成器 */
+function Random(seed: number = 0) {
+ // 線形合同法
+ const a = 1664525;
+ const c = 1013904223;
+ const m = 2 ** 31;
+
+ return () => {
+ seed = (a * seed + c) % m;
+ return seed / m;
+ };
+}
+
+/** 波形の種類 */
+const waveTypes = ["sine", "square", "noise", "silence"] as const;
+type WaveType = (typeof waveTypes)[number];
+
+/** サイン波などを生成する */
+function generateWave(
+ f0: Array,
+ volume: Array,
+ frameRate: number,
+ sampleRate: number,
+ type: WaveType,
+) {
+ const duration = f0.length / frameRate;
+ const samplesPerOriginal = sampleRate / frameRate;
+ const wave = new Float32Array(sampleRate * duration);
+
+ const seed =
+ Math.round(f0.concat(volume).reduce((acc, v) => acc + v, 0)) % 2 ** 31; // そこそこ被らないシード値
+ const random = Random(seed);
+ let phase = 0;
+ for (let frameIndex = 0; frameIndex < f0.length; frameIndex++) {
+ const freq = f0[frameIndex];
+ const vol = volume[frameIndex];
+ const omega = (2 * Math.PI * freq) / sampleRate;
+
+ for (let i = 0; i < samplesPerOriginal; i++) {
+ const sampleIndex = frameIndex * samplesPerOriginal + i;
+ switch (type) {
+ case "sine":
+ wave[sampleIndex] = Math.sin(phase);
+ break;
+ case "square":
+ wave[sampleIndex] = (phase / Math.PI) % 2 < 1 ? 1 : -1;
+ break;
+ case "noise":
+ wave[sampleIndex] = random() * 2 - 1;
+ break;
+ case "silence":
+ wave[sampleIndex] = 0;
+ break;
+ }
+ wave[sampleIndex] *= vol;
+
+ phase += omega;
+ if (phase > 2 * Math.PI) {
+ phase -= 2 * Math.PI;
+ }
+ }
+ }
+
+ return wave;
+}
+
+/**
+ * 音素ごとの特徴。
+ * FIXME: できるならデバッグしやすいようそれっぽい音に近づけたい。
+ */
+const phonemeFeatures = {
+ 有声母音: ["a", "i", "u", "e", "o", "N"],
+ 無声母音: ["A", "I", "U", "E", "O"],
+ 無音: ["sil", "pau", "cl"],
+ 有声子音: [
+ "b",
+ "by",
+ "d",
+ "dy",
+ "g",
+ "gw",
+ "gy",
+ "j",
+ "m",
+ "my",
+ "n",
+ "ny",
+ "r",
+ "ry",
+ "v",
+ "w",
+ "y",
+ "z",
+ ],
+ 無声子音: [
+ "ch",
+ "f",
+ "h",
+ "hy",
+ "k",
+ "kw",
+ "ky",
+ "p",
+ "py",
+ "s",
+ "sh",
+ "t",
+ "ts",
+ "ty",
+ ],
+};
+
+/** 音素ごとの波形の配合率を適当に決める */
+function getWaveRate(phoneme: string): { [key in WaveType]: number } {
+ const waveRate: { [key in WaveType]: number } = {
+ sine: 0,
+ square: 0,
+ noise: 0,
+ silence: 0,
+ };
+
+ // 無音ならほぼ無音
+ if (phonemeFeatures.無音.includes(phoneme)) {
+ const index = phonemeFeatures.無音.indexOf(phoneme);
+ waveRate.noise = ((index + 1) % 30) / 30;
+ return waveRate;
+ }
+
+ // 有声母音ならノイズなし
+ if (phonemeFeatures.有声母音.includes(phoneme)) {
+ const rate =
+ phonemeFeatures.有声母音.indexOf(phoneme) /
+ (phonemeFeatures.有声母音.length - 1);
+ waveRate.sine = 1 - rate;
+ waveRate.square = rate;
+ return waveRate;
+ }
+
+ // 無声母音ならノイズ多め
+ if (phonemeFeatures.無声母音.includes(phoneme)) {
+ const rate =
+ phonemeFeatures.無声母音.indexOf(phoneme) /
+ (phonemeFeatures.無声母音.length - 1);
+ waveRate.sine = (1 - rate) * 0.1;
+ waveRate.square = rate * 0.1;
+ waveRate.noise = 0.3;
+ return waveRate;
+ }
+
+ // 有声子音ならノイズ少なめ
+ if (phonemeFeatures.有声子音.includes(phoneme)) {
+ const rate =
+ phonemeFeatures.有声子音.indexOf(phoneme) /
+ (phonemeFeatures.有声子音.length - 1);
+ waveRate.sine = (1 - rate) * 0.7;
+ waveRate.square = rate * 0.7;
+ waveRate.noise = 0.2;
+ return waveRate;
+ }
+
+ // 無声子音ならノイズ多めで音量小さい
+ if (phonemeFeatures.無声子音.includes(phoneme)) {
+ const rate =
+ phonemeFeatures.無声子音.indexOf(phoneme) /
+ (phonemeFeatures.無声子音.length - 1);
+ waveRate.sine = (1 - rate) * 0.1;
+ waveRate.square = rate * 0.1;
+ waveRate.noise = 0.1;
+ return waveRate;
+ }
+
+ throw new Error(`未対応の音素: ${phoneme}`);
+}
+
+/**
+ * FrameAudioQueryから適当に音声合成する。
+ * いろんな波形を作り、音素ごとに波形の配合率を変える。
+ */
+export function synthesisFrameAudioQueryMock(
+ frameAudioQuery: FrameAudioQuery,
+ styleId: number,
+): Uint8Array {
+ const sampleRate = frameAudioQuery.outputSamplingRate;
+ const samplePerFrame = 256;
+ const frameRate = sampleRate / samplePerFrame;
+
+ const _generateWave = (type: WaveType) =>
+ generateWave(
+ frameAudioQuery.f0,
+ frameAudioQuery.volume,
+ frameRate,
+ sampleRate,
+ type,
+ );
+ const waves: { [key in WaveType]: Float32Array } = {
+ sine: _generateWave("sine"),
+ square: _generateWave("square"),
+ noise: _generateWave("noise"),
+ silence: _generateWave("silence"),
+ };
+
+ // フレームごとの音声波形の配分率
+ const waveRatesPerFrame = frameAudioQuery.phonemes.flatMap((phoneme) => {
+ const waveRate = getWaveRate(phoneme.phoneme);
+ return Array<{ [key in WaveType]: number }>(phoneme.frameLength).fill(
+ waveRate,
+ );
+ });
+
+ // サンプルごとの配分率
+ // 耳が痛くならないように10msほどの移動平均を取る
+ const calcWaveRate = (type: WaveType) => {
+ const waveRate = waveRatesPerFrame.flatMap((o) =>
+ Array(samplePerFrame).fill(o[type]),
+ );
+ applyGaussianFilter(waveRate, (sampleRate * 0.01) / 3);
+ return waveRate;
+ };
+ const waveRates = Object.fromEntries(
+ waveTypes.map((type) => [type, calcWaveRate(type)]),
+ ) as { [key in WaveType]: number[] };
+
+ // 波形を合成。
+ // 念の為に-1~1に丸め、音量を1/10にする。
+ // 話者ごとに同じにならないように適当に値をずらす
+ const wave = new Float32Array(frameAudioQuery.f0.length * samplePerFrame);
+ for (let i = 0; i < wave.length; i++) {
+ let sample = waveTypes.reduce((acc, type) => {
+ return acc + waves[type][i] * waveRates[type][i];
+ }, 0);
+ sample += (styleId % 977) / 977 / 20; // 977は適当な素数
+ wave[i] = Math.min(Math.max(sample, -1), 1) / 10;
+ }
+
+ // Blobに変換
+ const numberOfChannels = frameAudioQuery.outputStereo ? 2 : 1;
+ const buffer = generateWavFileData({
+ sampleRate,
+ length: wave.length,
+ numberOfChannels,
+ getChannelData: () => wave,
+ });
+ return buffer;
+}
diff --git a/src/mock/engineMock/talkModelMock.ts b/src/mock/engineMock/talkModelMock.ts
new file mode 100644
index 0000000000..bcaaac3920
--- /dev/null
+++ b/src/mock/engineMock/talkModelMock.ts
@@ -0,0 +1,234 @@
+/**
+ * トーク系の構造体を作るモック。
+ */
+
+import { builder, IpadicFeatures, Tokenizer } from "kuromoji";
+import { moraToPhonemes } from "./phonemeMock";
+import { moraPattern } from "@/domain/japanese";
+import { AccentPhrase, Mora } from "@/openapi";
+
+/** Nodeとして動いてほしいかを判定する */
+const isNode =
+ // window.documentがなければNode
+ typeof window == "undefined" ||
+ typeof window.document == "undefined" ||
+ // happy-domのときはNode
+ typeof (window as { happyDOM?: unknown }).happyDOM != "undefined";
+
+let _tokenizer: Tokenizer | undefined;
+
+/** kuromoji用の辞書のパスを取得する */
+function getDicPath() {
+ if (isNode) {
+ return "node_modules/kuromoji/dict";
+ } else {
+ return "https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict";
+ }
+}
+
+/** テキストをトークン列に変換するトークナイザーを取得する */
+async function createOrGetTokenizer() {
+ if (_tokenizer != undefined) {
+ return _tokenizer;
+ }
+
+ return new Promise>((resolve, reject) => {
+ builder({
+ dicPath: getDicPath(),
+ nodeOrBrowser: isNode ? "node" : "browser",
+ }).build((err: Error, tokenizer: Tokenizer) => {
+ if (err) {
+ reject(err);
+ } else {
+ _tokenizer = tokenizer;
+ resolve(tokenizer);
+ }
+ });
+ });
+}
+
+/** アルファベット文字列を適当な0~1の適当な数値に変換する */
+function alphabetsToNumber(text: string): number {
+ const codes = text.split("").map((c) => c.charCodeAt(0));
+ const sum = codes.reduce((a, b) => a + b);
+ return (sum % 256) / 256;
+}
+
+/** 0.01~0.25になるように適当な長さを決める */
+function phonemeToLengthMock(phoneme: string): number {
+ return alphabetsToNumber(phoneme) * 0.24 + 0.01;
+}
+
+/** 3~5になるように適当なピッチを決める */
+function phonemeToPitchMock(phoneme: string): number {
+ return (1 - alphabetsToNumber(phoneme)) * 2 + 3;
+}
+
+/** カタカナテキストをモーラに変換する */
+function textToMoraMock(text: string): Mora {
+ const phonemes = moraToPhonemes[text];
+ if (phonemes == undefined) throw new Error(`モーラに変換できません: ${text}`);
+
+ return {
+ text,
+ consonant: phonemes[0],
+ consonantLength: phonemes[0] == undefined ? undefined : 0,
+ vowel: phonemes[1],
+ vowelLength: 0,
+ pitch: 0,
+ };
+}
+
+/**
+ * カタカナテキストを適当なアクセント句に変換する。
+ * アクセント位置は適当に決める。
+ */
+function textToAccentPhraseMock(text: string): AccentPhrase {
+ const moras: Mora[] = [...text.matchAll(moraPattern)].map((m) =>
+ textToMoraMock(m[0]),
+ );
+ const alphabets = moras.map((m) => (m.consonant ?? "") + m.vowel).join("");
+ const accent =
+ 1 + Math.round(alphabetsToNumber(alphabets) * (moras.length - 1));
+ return { moras, accent };
+}
+
+/**
+ * アクセント句内のモーラの長さを適当に代入する。
+ * 最後のモーラだけ長くする。
+ */
+export function replaceLengthMock(
+ accentPhrases: AccentPhrase[],
+ styleId: number,
+) {
+ for (const accentPhrase of accentPhrases) {
+ for (let i = 0; i < accentPhrase.moras.length; i++) {
+ const mora = accentPhrase.moras[i];
+
+ // 最後のモーラだけ長く
+ const offset = i == accentPhrase.moras.length - 1 ? 0.05 : 0;
+
+ if (mora.consonant != undefined)
+ mora.consonantLength =
+ (phonemeToLengthMock(mora.consonant) + offset) / 5;
+ mora.vowelLength = phonemeToLengthMock(mora.vowel) + offset;
+ }
+ }
+
+ // 別のアクセント句や話者で同じにならないように適当に値をずらす
+ for (let i = 0; i < accentPhrases.length; i++) {
+ const diff = i * 0.01 + styleId * 0.03;
+ const accentPhrase = accentPhrases[i];
+ for (const mora of accentPhrase.moras) {
+ if (mora.consonantLength != undefined) mora.consonantLength += diff;
+ mora.vowelLength += diff;
+ }
+ if (accentPhrase.pauseMora != undefined) {
+ accentPhrase.pauseMora.vowelLength += diff;
+ }
+ }
+}
+
+/**
+ * アクセント句内のモーラのピッチを適当に代入する。
+ * アクセント位置のモーラだけ高くする。
+ */
+export function replacePitchMock(
+ accentPhrases: AccentPhrase[],
+ styleId: number,
+) {
+ for (const accentPhrase of accentPhrases) {
+ for (let i = 0; i < accentPhrase.moras.length; i++) {
+ const mora = accentPhrase.moras[i];
+
+ // 無声化している場合はピッチを0にする
+ if (mora.vowel == "U") {
+ mora.pitch = 0;
+ continue;
+ }
+
+ // アクセント位置のモーラだけ高く
+ const offset = i == accentPhrase.accent ? 0.3 : 0;
+
+ const phoneme = (mora.consonant ?? "") + mora.vowel[1];
+ mora.pitch = phonemeToPitchMock(phoneme) + offset;
+ }
+ }
+
+ // 別のアクセント句や話者で同じにならないように適当に値をずらす
+ for (let i = 0; i < accentPhrases.length; i++) {
+ const diff = i * 0.01 + styleId * 0.03;
+ const accentPhrase = accentPhrases[i];
+ for (const mora of accentPhrase.moras) {
+ if (mora.pitch > 0) mora.pitch += diff;
+ }
+ }
+}
+
+/**
+ * テキストを適当なアクセント句に分割する。
+ * 助詞ごとに区切る。記号ごとに無音を入れる。
+ * 無音で終わるアクセント句の最後のモーラが「す」「つ」の場合は無声化する。
+ */
+export async function textToActtentPhrasesMock(text: string, styleId: number) {
+ const accentPhrases: AccentPhrase[] = [];
+
+ // トークンに分割
+ const tokenizer = await createOrGetTokenizer();
+ const tokens = tokenizer.tokenize(text);
+
+ let textPhrase = "";
+ for (const token of tokens) {
+ // 記号の場合は無音を入れて区切る
+ if (token.pos == "記号") {
+ if (textPhrase.length == 0) continue;
+
+ const accentPhrase = textToAccentPhraseMock(textPhrase);
+ accentPhrase.pauseMora = {
+ text: "、",
+ vowel: "pau",
+ vowelLength: 1 - 1 / (accentPhrases.length + 1),
+ pitch: 0,
+ };
+ accentPhrases.push(accentPhrase);
+ textPhrase = "";
+ continue;
+ }
+
+ // 記号以外は連結
+ if (token.reading == undefined)
+ throw new Error(`発音がないトークン: ${token.surface_form}`);
+ textPhrase += token.reading;
+
+ // 助詞の場合は区切る
+ if (token.pos == "助詞") {
+ accentPhrases.push(textToAccentPhraseMock(textPhrase));
+ textPhrase = "";
+ }
+ }
+ if (textPhrase != "") {
+ accentPhrases.push(textToAccentPhraseMock(textPhrase));
+ }
+
+ // 最後のアクセント句の無音をなくす
+ if (accentPhrases.length > 0) {
+ const lastPhrase = accentPhrases[accentPhrases.length - 1];
+ lastPhrase.pauseMora = undefined;
+ }
+
+ // 無音のあるアクセント句を無声化
+ for (const phrase of accentPhrases) {
+ if (phrase.pauseMora == undefined) continue;
+ const lastMora = phrase.moras[phrase.moras.length - 1];
+ if (lastMora.text == "ス" || lastMora.text == "ツ") {
+ lastMora.vowel = "U";
+ lastMora.pitch = 0;
+ }
+ }
+
+ // 長さとピッチを代入
+ replaceLengthMock(accentPhrases, styleId);
+ replacePitchMock(accentPhrases, styleId);
+
+ return accentPhrases;
+}
diff --git a/src/plugins/hotkeyPlugin.ts b/src/plugins/hotkeyPlugin.ts
index c187075ef7..d436cf07d9 100644
--- a/src/plugins/hotkeyPlugin.ts
+++ b/src/plugins/hotkeyPlugin.ts
@@ -13,7 +13,7 @@ import {
HotkeyActionNameType,
HotkeyCombination,
HotkeySettingType,
-} from "@/type/preload";
+} from "@/domain/hotkeyAction";
import { createLogger } from "@/domain/frontend/log";
const hotkeyManagerKey = "hotkeyManager";
diff --git a/src/sing/domain.ts b/src/sing/domain.ts
index 4915a4f3c6..bbd4482977 100644
--- a/src/sing/domain.ts
+++ b/src/sing/domain.ts
@@ -19,7 +19,7 @@ export type MeasuresBeats = {
beats: number;
};
-const BEAT_TYPES = [2, 4, 8, 16];
+export const BEAT_TYPES = [2, 4, 8, 16, 32];
const MIN_BPM = 40;
const MAX_SNAP_TYPE = 32;
diff --git a/src/sing/fileDataGenerator.ts b/src/sing/fileDataGenerator.ts
index 360513d2c4..032095f562 100644
--- a/src/sing/fileDataGenerator.ts
+++ b/src/sing/fileDataGenerator.ts
@@ -3,7 +3,12 @@ import { isVowel } from "./domain";
import { Encoding as EncodingType } from "@/type/preload";
import { FramePhoneme } from "@/openapi";
-export function generateWavFileData(audioBuffer: AudioBuffer) {
+export function generateWavFileData(
+ audioBuffer: Pick<
+ AudioBuffer,
+ "sampleRate" | "length" | "numberOfChannels" | "getChannelData"
+ >,
+) {
const bytesPerSample = 4; // Float32
const formatCode = 3; // WAVE_FORMAT_IEEE_FLOAT
diff --git a/src/sing/viewHelper.ts b/src/sing/viewHelper.ts
index 0f86b0d1ed..fade4738cb 100644
--- a/src/sing/viewHelper.ts
+++ b/src/sing/viewHelper.ts
@@ -144,6 +144,23 @@ export type PreviewMode =
| "DRAW_PITCH"
| "ERASE_PITCH";
+// マウスダウン時の振る舞い
+export const mouseDownBehaviorSchema = z.enum([
+ "IGNORE",
+ "DESELECT_ALL",
+ "ADD_NOTE",
+ "START_RECT_SELECT",
+ "DRAW_PITCH",
+ "ERASE_PITCH",
+]);
+export type MouseDownBehavior = z.infer;
+
+// ダブルクリック時の振る舞い
+export const mouseDoubleClickBehaviorSchema = z.enum(["IGNORE", "ADD_NOTE"]);
+export type MouseDoubleClickBehavior = z.infer<
+ typeof mouseDoubleClickBehaviorSchema
+>;
+
export function getButton(event: MouseEvent): MouseButton {
// macOSの場合、Ctrl+クリックは右クリック
if (isMac && event.button === 0 && event.ctrlKey) {
@@ -157,3 +174,14 @@ export function getButton(event: MouseEvent): MouseButton {
return "OTHER_BUTTON";
}
}
+
+// カーソルの状態
+export const cursorStateSchema = z.enum([
+ "UNSET",
+ "DRAW",
+ "MOVE",
+ "EW_RESIZE",
+ "CROSSHAIR",
+ "ERASE",
+]);
+export type CursorState = z.infer;
diff --git a/src/store/audio.ts b/src/store/audio.ts
index 2d802665ee..f93c7d2f91 100644
--- a/src/store/audio.ts
+++ b/src/store/audio.ts
@@ -1,4 +1,3 @@
-import path from "path";
import Encoding from "encoding-japanese";
import { createUILockAction, withProgress } from "./ui";
import {
@@ -64,6 +63,7 @@ import { uuid4 } from "@/helpers/random";
import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy";
import { UnreachableError } from "@/type/utility";
import { errorToMessage } from "@/helpers/errorHelper";
+import path from "@/helpers/path";
function generateAudioKey() {
return AudioKey(uuid4());
@@ -570,9 +570,10 @@ export const audioStore = createPartialStore({
audioKeys,
});
await actions
- .INITIALIZE_ENGINE_SPEAKER({
+ .INITIALIZE_ENGINE_CHARACTER({
engineId,
styleId,
+ uiLock: true,
})
.finally(() => {
mutations.SET_AUDIO_KEYS_WITH_INITIALIZING_SPEAKER({
diff --git a/src/store/audioPlayer.ts b/src/store/audioPlayer.ts
index cc0e5bbed7..2ce64210b9 100644
--- a/src/store/audioPlayer.ts
+++ b/src/store/audioPlayer.ts
@@ -77,7 +77,6 @@ export const audioPlayerStore = createPartialStore({
};
audioElement.addEventListener("canplay", stop);
void showAlertDialog({
- type: "error",
title: "エラー",
message: "再生デバイスが見つかりません",
});
diff --git a/src/store/engine.ts b/src/store/engine.ts
index 47f013266c..b478eb98e2 100644
--- a/src/store/engine.ts
+++ b/src/store/engine.ts
@@ -330,23 +330,29 @@ export const engineStore = createPartialStore({
},
},
- INITIALIZE_ENGINE_SPEAKER: {
+ INITIALIZE_ENGINE_CHARACTER: {
/**
- * 指定した話者(スタイルID)に対してエンジン側の初期化を行い、即座に音声合成ができるようにする。
+ * 指定したキャラクター(スタイルID)に対してエンジン側の初期化を行い、即座に音声合成ができるようにする。
*/
- async action({ actions }, { engineId, styleId }) {
- await actions.ASYNC_UI_LOCK({
- callback: () =>
- actions
- .INSTANTIATE_ENGINE_CONNECTOR({
- engineId,
- })
- .then((instance) =>
- instance.invoke("initializeSpeakerInitializeSpeakerPost")({
- speaker: styleId,
- }),
- ),
- });
+ async action({ actions }, { engineId, styleId, uiLock }) {
+ const requestEngineToInitializeCharacter = () =>
+ actions
+ .INSTANTIATE_ENGINE_CONNECTOR({
+ engineId,
+ })
+ .then((instance) =>
+ instance.invoke("initializeSpeakerInitializeSpeakerPost")({
+ speaker: styleId,
+ }),
+ );
+
+ if (uiLock) {
+ await actions.ASYNC_UI_LOCK({
+ callback: requestEngineToInitializeCharacter,
+ });
+ } else {
+ await requestEngineToInitializeCharacter();
+ }
},
},
VALIDATE_ENGINE_DIR: {
diff --git a/src/store/project.ts b/src/store/project.ts
index c119ff6c85..ebeb21d0fd 100755
--- a/src/store/project.ts
+++ b/src/store/project.ts
@@ -24,6 +24,7 @@ import { EditorType } from "@/type/preload";
import { IsEqual } from "@/type/utility";
import {
showAlertDialog,
+ showMessageDialog,
showQuestionDialog,
} from "@/components/Dialog/Dialog";
import { uuid4 } from "@/helpers/random";
@@ -226,7 +227,6 @@ export const projectStore = createPartialStore({
return err.message;
})();
await showAlertDialog({
- type: "error",
title: "エラー",
message: `プロジェクトファイルの読み込みに失敗しました。\n${message}`,
});
@@ -270,7 +270,7 @@ export const projectStore = createPartialStore({
context.state.projectFilePath &&
context.state.projectFilePath != filePath
) {
- await showAlertDialog({
+ await showMessageDialog({
type: "info",
title: "保存",
message: `編集中のプロジェクトが ${filePath} に切り替わりました。`,
@@ -327,7 +327,6 @@ export const projectStore = createPartialStore({
return err.message;
})();
await showAlertDialog({
- type: "error",
title: "エラー",
message: `プロジェクトファイルの保存に失敗しました。\n${message}`,
});
@@ -348,13 +347,16 @@ export const projectStore = createPartialStore({
if (additionalMessage) {
message += "\n" + additionalMessage;
}
- message += "\n変更を保存しますか?";
const result: number = await showQuestionDialog({
- type: "info",
- title: "警告",
+ type: "warning",
+ title: "プロジェクトを保存しますか?",
message,
- buttons: ["キャンセル", "破棄", "保存"],
+ buttons: [
+ "キャンセル",
+ { text: "破棄する", color: "warning" },
+ { text: "保存する", color: "primary" },
+ ],
cancel: 0,
});
if (result == 2) {
diff --git a/src/store/setting.ts b/src/store/setting.ts
index ba9c53548d..6f14d6c9af 100644
--- a/src/store/setting.ts
+++ b/src/store/setting.ts
@@ -7,7 +7,6 @@ import {
showQuestionDialog,
} from "@/components/Dialog/Dialog";
import {
- HotkeySettingType,
SavingSetting,
ExperimentalSettingType,
ToolbarSettingType,
@@ -16,6 +15,7 @@ import {
RootMiscSettingType,
} from "@/type/preload";
import { IsEqual } from "@/type/utility";
+import { HotkeySettingType } from "@/domain/hotkeyAction";
export const settingStoreState: SettingStoreState = {
openedEditor: undefined,
@@ -386,7 +386,6 @@ export const settingStore = createPartialStore({
// FIXME: useGpu設定を保存してからエンジン起動を試すのではなく、逆にしたい
if (!result.success && useGpu) {
await showAlertDialog({
- type: "error",
title: "GPUモードに変更できませんでした",
message:
"GPUモードでエンジンを起動できなかったためCPUモードに戻します",
diff --git a/src/store/singing.ts b/src/store/singing.ts
index 0181fb24f7..9ef184486a 100644
--- a/src/store/singing.ts
+++ b/src/store/singing.ts
@@ -130,15 +130,6 @@ type SnapshotForPhraseRender = Readonly<{
editorFrameRate: number;
}>;
-/**
- * フレーズレンダリングのコンテキスト
- */
-type PhraseRenderContext = Readonly<{
- snapshot: SnapshotForPhraseRender;
- trackId: TrackId;
- phraseKey: PhraseKey;
-}>;
-
type PhraseRenderStageId =
| "queryGeneration"
| "singingPitchGeneration"
@@ -157,19 +148,27 @@ type PhraseRenderStage = Readonly<{
* @param context コンテキスト
* @returns 実行が必要かどうかのブール値
*/
- shouldBeExecuted: (context: PhraseRenderContext) => Promise;
+ needsExecution: (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => Promise;
/**
* 前回の処理結果を削除する。
* @param context コンテキスト
*/
- deleteExecutionResult: (context: PhraseRenderContext) => void;
+ deleteExecutionResult: (phraseKey: PhraseKey) => void;
/**
* ステージの処理を実行する。
* @param context コンテキスト
*/
- execute: (context: PhraseRenderContext) => Promise;
+ execute: (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => Promise;
}>;
/**
@@ -222,51 +221,6 @@ type SingingVoiceSource = Readonly<{
queryForSingingVoiceSynthesis: EditorFrameAudioQuery;
}>;
-/**
- * フレーズレンダラー。
- * 各フレーズごとに、ステージを進めながらレンダリング処理を行う。
- * レンダリングが必要かどうかの判定やキャッシュの作成も行う。
- */
-type PhraseRenderer = Readonly<{
- /**
- * 一番最初のステージのIDを返す。
- * 一度もレンダリングを行っていないフレーズは、
- * この(一番最初の)ステージからレンダリング処理を開始する必要がある。
- * @returns ステージID
- */
- getFirstRenderStageId: () => PhraseRenderStageId;
-
- /**
- * レンダリングが必要なフレーズかどうかを判断し、
- * レンダリングが必要であればどのステージから開始されるべきかを判断して、そのステージのIDを返す。
- * レンダリングが必要ない場合、undefinedが返される。
- * @param snapshot スナップショット
- * @param trackId トラックID
- * @param phraseKey フレーズキー
- * @returns ステージID または undefined
- */
- determineStartStage: (
- snapshot: SnapshotForPhraseRender,
- trackId: TrackId,
- phraseKey: PhraseKey,
- ) => Promise;
-
- /**
- * 指定されたフレーズのレンダリング処理を、指定されたステージから開始する。
- * レンダリング処理を開始する前に、前回のレンダリング処理結果の削除が行われる。
- * @param snapshot スナップショット
- * @param trackId トラックID
- * @param phraseKey フレーズキー
- * @param startStageId 開始ステージID
- */
- render: (
- snapshot: SnapshotForPhraseRender,
- trackId: TrackId,
- phraseKey: PhraseKey,
- startStageId: PhraseRenderStageId,
- ) => Promise;
-}>;
-
/**
* リクエスト用のノーツ(と休符)を作成する。
*/
@@ -786,6 +740,8 @@ export const singingStoreState: SingingStoreState = {
sequencerZoomY: 0.75,
sequencerSnapType: 16,
sequencerEditTarget: "NOTE",
+ sequencerNoteTool: "EDIT_FIRST",
+ sequencerPitchTool: "DRAW",
_selectedNoteIds: new Set(),
nowPlaying: false,
volume: 0,
@@ -830,7 +786,11 @@ export const singingStore = createPartialStore({
// 指定されたstyleIdに対して、エンジン側の初期化を行う
const isInitialized = await actions.IS_INITIALIZED_ENGINE_SPEAKER(singer);
if (!isInitialized) {
- await actions.INITIALIZE_ENGINE_SPEAKER(singer);
+ await actions.INITIALIZE_ENGINE_CHARACTER({
+ engineId: singer.engineId,
+ styleId: singer.styleId,
+ uiLock: false,
+ });
}
},
},
@@ -1144,6 +1104,17 @@ export const singingStore = createPartialStore({
},
},
+ DESELECT_NOTES: {
+ mutation(state, { noteIds }: { noteIds: NoteId[] }) {
+ for (const noteId of noteIds) {
+ state._selectedNoteIds.delete(noteId);
+ }
+ },
+ async action({ mutations }, { noteIds }: { noteIds: NoteId[] }) {
+ mutations.DESELECT_NOTES({ noteIds });
+ },
+ },
+
DESELECT_ALL_NOTES: {
mutation(state) {
state.editingLyricNoteId = undefined;
@@ -1413,7 +1384,7 @@ export const singingStore = createPartialStore({
state.tempos,
state.timeSignatures,
state.tpqn,
- ) + 1,
+ ) + 8,
);
},
},
@@ -1448,6 +1419,24 @@ export const singingStore = createPartialStore({
},
},
+ SET_SEQUENCER_NOTE_TOOL: {
+ mutation(state, { sequencerNoteTool }) {
+ state.sequencerNoteTool = sequencerNoteTool;
+ },
+ async action({ mutations }, { sequencerNoteTool }) {
+ mutations.SET_SEQUENCER_NOTE_TOOL({ sequencerNoteTool });
+ },
+ },
+
+ SET_SEQUENCER_PITCH_TOOL: {
+ mutation(state, { sequencerPitchTool }) {
+ state.sequencerPitchTool = sequencerPitchTool;
+ },
+ async action({ mutations }, { sequencerPitchTool }) {
+ mutations.SET_SEQUENCER_PITCH_TOOL({ sequencerPitchTool });
+ },
+ },
+
TICK_TO_SECOND: {
getter: (state) => (position) => {
return tickToSecond(position, state.tempos, state.tpqn);
@@ -1700,7 +1689,6 @@ export const singingStore = createPartialStore({
const sinkId = device === "default" ? "" : device;
audioContext.setSinkId(sinkId).catch((err: unknown) => {
void showAlertDialog({
- type: "error",
title: "エラー",
message: "再生デバイスが見つかりません",
});
@@ -1718,6 +1706,32 @@ export const singingStore = createPartialStore({
const lastRestDurationSeconds = 0.5; // TODO: 設定できるようにする
const fadeOutDurationSeconds = 0.15; // TODO: 設定できるようにする
+ /**
+ * レンダリング中に変更される可能性のあるデータのコピーを作成する。
+ */
+ const createSnapshot = () => {
+ return {
+ tpqn: state.tpqn,
+ tempos: cloneWithUnwrapProxy(state.tempos),
+ tracks: cloneWithUnwrapProxy(state.tracks),
+ trackOverlappingNoteIds: new Map(
+ [...state.tracks.keys()].map((trackId) => [
+ trackId,
+ getters.OVERLAPPING_NOTE_IDS(trackId),
+ ]),
+ ),
+ engineFrameRates: new Map(
+ Object.entries(state.engineManifests).map(
+ ([engineId, engineManifest]) => [
+ engineId as EngineId,
+ engineManifest.frameRate,
+ ],
+ ),
+ ),
+ editorFrameRate: state.editorFrameRate,
+ } as const;
+ };
+
const calcPhraseFirstRestDuration = (
prevPhraseLastNote: Note | undefined,
phraseFirstNote: Note,
@@ -1778,14 +1792,14 @@ export const singingStore = createPartialStore({
);
};
- const searchPhrases = async (
+ const generatePhrases = async (
notes: Note[],
tempos: Tempo[],
tpqn: number,
phraseFirstRestMinDurationSeconds: number,
trackId: TrackId,
) => {
- const foundPhrases = new Map();
+ const generatedPhrases = new Map();
let phraseNotes: Note[] = [];
let prevPhraseLastNote: Note | undefined = undefined;
@@ -1822,7 +1836,7 @@ export const singingStore = createPartialStore({
startTime: phraseStartTime,
trackId,
});
- foundPhrases.set(phraseKey, {
+ generatedPhrases.set(phraseKey, {
firstRestDuration: phraseFirstRestDuration,
notes: phraseNotes,
startTime: phraseStartTime,
@@ -1836,26 +1850,28 @@ export const singingStore = createPartialStore({
}
}
}
- return foundPhrases;
+ return generatedPhrases;
};
const generateQuerySource = (
- context: PhraseRenderContext,
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
): QuerySource => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
throw new Error("track.singer is undefined.");
}
const engineFrameRate = getOrThrow(
- context.snapshot.engineFrameRates,
+ snapshot.engineFrameRates,
track.singer.engineId,
);
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
return {
engineId: track.singer.engineId,
engineFrameRate,
- tpqn: context.snapshot.tpqn,
- tempos: context.snapshot.tempos,
+ tpqn: snapshot.tpqn,
+ tempos: snapshot.tempos,
firstRestDuration: phrase.firstRestDuration,
notes: phrase.notes,
keyRangeAdjustment: track.keyRangeAdjustment,
@@ -1924,30 +1940,38 @@ export const singingStore = createPartialStore({
const queryGenerationStage: PhraseRenderStage = {
id: "queryGeneration",
- shouldBeExecuted: async (context: PhraseRenderContext) => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ needsExecution: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
return false;
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseQueryKey = phrase.queryKey;
- const querySource = generateQuerySource(context);
+ const querySource = generateQuerySource(trackId, phraseKey, snapshot);
const queryKey = await calculateQueryKey(querySource);
return phraseQueryKey == undefined || phraseQueryKey !== queryKey;
},
- deleteExecutionResult: (context: PhraseRenderContext) => {
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ deleteExecutionResult: (phraseKey: PhraseKey) => {
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseQueryKey = phrase.queryKey;
if (phraseQueryKey != undefined) {
mutations.DELETE_PHRASE_QUERY({ queryKey: phraseQueryKey });
mutations.SET_QUERY_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
queryKey: undefined,
});
}
},
- execute: async (context: PhraseRenderContext) => {
- const querySource = generateQuerySource(context);
+ execute: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const querySource = generateQuerySource(trackId, phraseKey, snapshot);
const queryKey = await calculateQueryKey(querySource);
let query = queryCache.get(queryKey);
@@ -1960,27 +1984,26 @@ export const singingStore = createPartialStore({
queryCache.set(queryKey, query);
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseQueryKey = phrase.queryKey;
if (phraseQueryKey != undefined) {
throw new Error("The previous query has not been removed.");
}
mutations.SET_PHRASE_QUERY({ queryKey, query });
- mutations.SET_QUERY_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
- queryKey,
- });
+ mutations.SET_QUERY_KEY_TO_PHRASE({ phraseKey, queryKey });
},
};
const generateSingingPitchSource = (
- context: PhraseRenderContext,
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
): SingingPitchSource => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
throw new Error("track.singer is undefined.");
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseQueryKey = phrase.queryKey;
if (phraseQueryKey == undefined) {
throw new Error("phraseQueryKey is undefined.");
@@ -1991,8 +2014,8 @@ export const singingStore = createPartialStore({
return {
engineId: track.singer.engineId,
engineFrameRate: query.frameRate,
- tpqn: context.snapshot.tpqn,
- tempos: context.snapshot.tempos,
+ tpqn: snapshot.tpqn,
+ tempos: snapshot.tempos,
firstRestDuration: phrase.firstRestDuration,
notes: phrase.notes,
keyRangeAdjustment: track.keyRangeAdjustment,
@@ -2009,14 +2032,22 @@ export const singingStore = createPartialStore({
const singingPitchGenerationStage: PhraseRenderStage = {
id: "singingPitchGeneration",
- shouldBeExecuted: async (context: PhraseRenderContext) => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ needsExecution: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
return false;
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingPitchKey = phrase.singingPitchKey;
- const singingPitchSource = generateSingingPitchSource(context);
+ const singingPitchSource = generateSingingPitchSource(
+ trackId,
+ phraseKey,
+ snapshot,
+ );
const singingPitchKey =
await calculateSingingPitchKey(singingPitchSource);
return (
@@ -2024,21 +2055,29 @@ export const singingStore = createPartialStore({
phraseSingingPitchKey !== singingPitchKey
);
},
- deleteExecutionResult: (context: PhraseRenderContext) => {
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ deleteExecutionResult: (phraseKey: PhraseKey) => {
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingPitchKey = phrase.singingPitchKey;
if (phraseSingingPitchKey != undefined) {
mutations.DELETE_PHRASE_SINGING_PITCH({
singingPitchKey: phraseSingingPitchKey,
});
mutations.SET_SINGING_PITCH_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
singingPitchKey: undefined,
});
}
},
- execute: async (context: PhraseRenderContext) => {
- const singingPitchSource = generateSingingPitchSource(context);
+ execute: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const singingPitchSource = generateSingingPitchSource(
+ trackId,
+ phraseKey,
+ snapshot,
+ );
const singingPitchKey =
await calculateSingingPitchKey(singingPitchSource);
@@ -2051,27 +2090,29 @@ export const singingStore = createPartialStore({
singingPitchCache.set(singingPitchKey, singingPitch);
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingPitchKey = phrase.singingPitchKey;
if (phraseSingingPitchKey != undefined) {
throw new Error("The previous singing pitch has not been removed.");
}
mutations.SET_PHRASE_SINGING_PITCH({ singingPitchKey, singingPitch });
mutations.SET_SINGING_PITCH_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
singingPitchKey,
});
},
};
const generateSingingVolumeSource = (
- context: PhraseRenderContext,
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
): SingingVolumeSource => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
throw new Error("track.singer is undefined.");
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseQueryKey = phrase.queryKey;
if (phraseQueryKey == undefined) {
throw new Error("phraseQueryKey is undefined.");
@@ -2083,13 +2124,13 @@ export const singingStore = createPartialStore({
clonedQuery,
phrase.startTime,
track.pitchEditData,
- context.snapshot.editorFrameRate,
+ snapshot.editorFrameRate,
);
return {
engineId: track.singer.engineId,
engineFrameRate: query.frameRate,
- tpqn: context.snapshot.tpqn,
- tempos: context.snapshot.tempos,
+ tpqn: snapshot.tpqn,
+ tempos: snapshot.tempos,
firstRestDuration: phrase.firstRestDuration,
notes: phrase.notes,
keyRangeAdjustment: track.keyRangeAdjustment,
@@ -2140,36 +2181,52 @@ export const singingStore = createPartialStore({
const singingVolumeGenerationStage: PhraseRenderStage = {
id: "singingVolumeGeneration",
- shouldBeExecuted: async (context: PhraseRenderContext) => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ needsExecution: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
return false;
}
- const singingVolumeSource = generateSingingVolumeSource(context);
+ const singingVolumeSource = generateSingingVolumeSource(
+ trackId,
+ phraseKey,
+ snapshot,
+ );
const singingVolumeKey =
await calculateSingingVolumeKey(singingVolumeSource);
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingVolumeKey = phrase.singingVolumeKey;
return (
phraseSingingVolumeKey == undefined ||
phraseSingingVolumeKey !== singingVolumeKey
);
},
- deleteExecutionResult: (context: PhraseRenderContext) => {
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ deleteExecutionResult: (phraseKey: PhraseKey) => {
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingVolumeKey = phrase.singingVolumeKey;
if (phraseSingingVolumeKey != undefined) {
mutations.DELETE_PHRASE_SINGING_VOLUME({
singingVolumeKey: phraseSingingVolumeKey,
});
mutations.SET_SINGING_VOLUME_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
singingVolumeKey: undefined,
});
}
},
- execute: async (context: PhraseRenderContext) => {
- const singingVolumeSource = generateSingingVolumeSource(context);
+ execute: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const singingVolumeSource = generateSingingVolumeSource(
+ trackId,
+ phraseKey,
+ snapshot,
+ );
const singingVolumeKey =
await calculateSingingVolumeKey(singingVolumeSource);
@@ -2182,7 +2239,7 @@ export const singingStore = createPartialStore({
singingVolumeCache.set(singingVolumeKey, singingVolume);
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingVolumeKey = phrase.singingVolumeKey;
if (phraseSingingVolumeKey != undefined) {
throw new Error(
@@ -2194,20 +2251,22 @@ export const singingStore = createPartialStore({
singingVolume,
});
mutations.SET_SINGING_VOLUME_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
singingVolumeKey,
});
},
};
const generateSingingVoiceSource = (
- context: PhraseRenderContext,
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
): SingingVoiceSource => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
throw new Error("track.singer is undefined.");
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseQueryKey = phrase.queryKey;
const phraseSingingVolumeKey = phrase.singingVolumeKey;
if (phraseQueryKey == undefined) {
@@ -2227,7 +2286,7 @@ export const singingStore = createPartialStore({
clonedQuery,
phrase.startTime,
track.pitchEditData,
- context.snapshot.editorFrameRate,
+ snapshot.editorFrameRate,
);
clonedQuery.volume = clonedSingingVolume;
return {
@@ -2267,34 +2326,50 @@ export const singingStore = createPartialStore({
const singingVoiceSynthesisStage: PhraseRenderStage = {
id: "singingVoiceSynthesis",
- shouldBeExecuted: async (context: PhraseRenderContext) => {
- const track = getOrThrow(context.snapshot.tracks, context.trackId);
+ needsExecution: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const track = getOrThrow(snapshot.tracks, trackId);
if (track.singer == undefined) {
return false;
}
- const singingVoiceSource = generateSingingVoiceSource(context);
+ const singingVoiceSource = generateSingingVoiceSource(
+ trackId,
+ phraseKey,
+ snapshot,
+ );
const singingVoiceKey =
await calculateSingingVoiceKey(singingVoiceSource);
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingVoiceKey = phrase.singingVoiceKey;
return (
phraseSingingVoiceKey == undefined ||
phraseSingingVoiceKey !== singingVoiceKey
);
},
- deleteExecutionResult: (context: PhraseRenderContext) => {
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ deleteExecutionResult: (phraseKey: PhraseKey) => {
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingVoiceKey = phrase.singingVoiceKey;
if (phraseSingingVoiceKey != undefined) {
phraseSingingVoices.delete(phraseSingingVoiceKey);
mutations.SET_SINGING_VOICE_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
singingVoiceKey: undefined,
});
}
},
- execute: async (context: PhraseRenderContext) => {
- const singingVoiceSource = generateSingingVoiceSource(context);
+ execute: async (
+ trackId: TrackId,
+ phraseKey: PhraseKey,
+ snapshot: SnapshotForPhraseRender,
+ ) => {
+ const singingVoiceSource = generateSingingVoiceSource(
+ trackId,
+ phraseKey,
+ snapshot,
+ );
const singingVoiceKey =
await calculateSingingVoiceKey(singingVoiceSource);
@@ -2307,19 +2382,20 @@ export const singingStore = createPartialStore({
singingVoiceCache.set(singingVoiceKey, singingVoice);
}
- const phrase = getOrThrow(state.phrases, context.phraseKey);
+ const phrase = getOrThrow(state.phrases, phraseKey);
const phraseSingingVoiceKey = phrase.singingVoiceKey;
if (phraseSingingVoiceKey != undefined) {
throw new Error("The previous singing voice has not been removed.");
}
phraseSingingVoices.set(singingVoiceKey, singingVoice);
mutations.SET_SINGING_VOICE_KEY_TO_PHRASE({
- phraseKey: context.phraseKey,
+ phraseKey,
singingVoiceKey,
});
},
};
+ // NOTE: ステージは実行順で保持
const stages: readonly PhraseRenderStage[] = [
queryGenerationStage,
singingPitchGenerationStage,
@@ -2327,53 +2403,6 @@ export const singingStore = createPartialStore({
singingVoiceSynthesisStage,
];
- const phraseRenderer: PhraseRenderer = {
- getFirstRenderStageId: () => {
- return stages[0].id;
- },
- determineStartStage: async (
- snapshot: SnapshotForPhraseRender,
- trackId: TrackId,
- phraseKey: PhraseKey,
- ) => {
- const context: PhraseRenderContext = {
- snapshot,
- trackId,
- phraseKey,
- };
- for (const stage of stages) {
- if (await stage.shouldBeExecuted(context)) {
- return stage.id;
- }
- }
- return undefined;
- },
- render: async (
- snapshot: SnapshotForPhraseRender,
- trackId: TrackId,
- phraseKey: PhraseKey,
- startStageId: PhraseRenderStageId,
- ) => {
- const context: PhraseRenderContext = {
- snapshot,
- trackId,
- phraseKey,
- };
- const startStageIndex = stages.findIndex((value) => {
- return value.id === startStageId;
- });
- if (startStageIndex === -1) {
- throw new Error("Stage not found.");
- }
- for (let i = stages.length - 1; i >= startStageIndex; i--) {
- stages[i].deleteExecutionResult(context);
- }
- for (let i = startStageIndex; i < stages.length; i++) {
- await stages[i].execute(context);
- }
- },
- };
-
// NOTE: 型推論でawaitの前か後かが考慮されないので、関数を介して取得する(型がbooleanになるようにする)
const startRenderingRequested = () => state.startRenderingRequested;
const stopRenderingRequested = () => state.stopRenderingRequested;
@@ -2398,94 +2427,98 @@ export const singingStore = createPartialStore({
const render = async () => {
const firstRestMinDurationSeconds = 0.12;
-
- // レンダリング中に変更される可能性のあるデータのコピー
- const snapshot = {
- tpqn: state.tpqn,
- tempos: cloneWithUnwrapProxy(state.tempos),
- tracks: cloneWithUnwrapProxy(state.tracks),
- trackOverlappingNoteIds: new Map(
- [...state.tracks.keys()].map((trackId) => [
- trackId,
- getters.OVERLAPPING_NOTE_IDS(trackId),
- ]),
- ),
- engineFrameRates: new Map(
- Object.entries(state.engineManifests).map(
- ([engineId, engineManifest]) => [
- engineId as EngineId,
- engineManifest.frameRate,
- ],
- ),
- ),
- editorFrameRate: state.editorFrameRate,
- } as const;
+ const snapshot = createSnapshot();
const renderStartStageIds = new Map();
- // フレーズを更新する
-
- const foundPhrases = new Map();
+ // 重なっているノートを削除する
+ const filteredTrackNotes = new Map();
for (const [trackId, track] of snapshot.tracks) {
- // 重なっているノートを削除する
const overlappingNoteIds = getOrThrow(
snapshot.trackOverlappingNoteIds,
trackId,
);
- const notes = track.notes.filter(
+ const filteredNotes = track.notes.filter(
(value) => !overlappingNoteIds.has(value.id),
);
- const phrases = await searchPhrases(
- notes,
+ filteredTrackNotes.set(trackId, filteredNotes);
+ }
+
+ // ノーツからフレーズを生成する
+ const generatedPhrases = new Map();
+ for (const trackId of snapshot.tracks.keys()) {
+ const filteredNotes = getOrThrow(filteredTrackNotes, trackId);
+ const phrases = await generatePhrases(
+ filteredNotes,
snapshot.tempos,
snapshot.tpqn,
firstRestMinDurationSeconds,
trackId,
);
- for (const [phraseHash, phrase] of phrases) {
- foundPhrases.set(phraseHash, phrase);
+ for (const [phraseKey, phrase] of phrases) {
+ generatedPhrases.set(phraseKey, phrase);
}
}
- const phrases = new Map();
+ const mergedPhrases = new Map();
+ const newlyCreatedPhraseKeys = new Set();
const disappearedPhraseKeys = new Set();
+ // 新しく作られたフレーズとstateにある既存のフレーズをマージする
+ // 新しく作られたフレーズと無くなったフレーズのキーのリスト(Set)も作成する
for (const phraseKey of state.phrases.keys()) {
- if (!foundPhrases.has(phraseKey)) {
- // 無くなったフレーズの場合
+ if (!generatedPhrases.has(phraseKey)) {
disappearedPhraseKeys.add(phraseKey);
}
}
- for (const [phraseKey, foundPhrase] of foundPhrases) {
- // 新しいフレーズまたは既存のフレーズの場合
+ for (const [phraseKey, generatedPhrase] of generatedPhrases) {
const existingPhrase = state.phrases.get(phraseKey);
- const phrase =
- existingPhrase == undefined
- ? foundPhrase
- : cloneWithUnwrapProxy(existingPhrase);
+ const isNewlyCreated = existingPhrase == undefined;
+ const phrase = isNewlyCreated
+ ? generatedPhrase
+ : cloneWithUnwrapProxy(existingPhrase);
+
+ mergedPhrases.set(phraseKey, phrase);
+ if (isNewlyCreated) {
+ newlyCreatedPhraseKeys.add(phraseKey);
+ }
+ }
+
+ // 各フレーズのレンダリング開始ステージを決定する
+ // 新しいフレーズの場合は最初からレンダリングする
+ // phrase.stateがCOULD_NOT_RENDERだった場合は最初からレンダリングし直す
+ for (const [phraseKey, phrase] of mergedPhrases) {
+ const trackId = phrase.trackId;
+ const track = getOrThrow(snapshot.tracks, trackId);
+ const isNewlyCreated = newlyCreatedPhraseKeys.has(phraseKey);
+
+ if (track.singer == undefined) {
+ continue;
+ }
+ if (isNewlyCreated || phrase.state === "COULD_NOT_RENDER") {
+ renderStartStageIds.set(phraseKey, stages[0].id);
+ } else {
+ for (const stage of stages) {
+ if (await stage.needsExecution(trackId, phraseKey, snapshot)) {
+ renderStartStageIds.set(phraseKey, stage.id);
+ break;
+ }
+ }
+ }
+ }
+
+ // phrase.stateを更新する
+ for (const [phraseKey, phrase] of mergedPhrases) {
const track = getOrThrow(snapshot.tracks, phrase.trackId);
if (track.singer == undefined) {
phrase.state = "SINGER_IS_NOT_SET";
} else {
- // 新しいフレーズの場合は最初からレンダリングする
- // phrase.stateがCOULD_NOT_RENDERだった場合は最初からレンダリングし直す
- // 既存のフレーズの場合は適切なレンダリング開始ステージを決定する
- const renderStartStageId =
- existingPhrase == undefined || phrase.state === "COULD_NOT_RENDER"
- ? phraseRenderer.getFirstRenderStageId()
- : await phraseRenderer.determineStartStage(
- snapshot,
- foundPhrase.trackId,
- phraseKey,
- );
- if (renderStartStageId == undefined) {
- phrase.state = "PLAYABLE";
- } else {
- renderStartStageIds.set(phraseKey, renderStartStageId);
+ if (renderStartStageIds.has(phraseKey)) {
phrase.state = "WAITING_TO_BE_RENDERED";
+ } else {
+ phrase.state = "PLAYABLE";
}
}
- phrases.set(phraseKey, phrase);
}
// 無くなったフレーズのシーケンスを削除する
@@ -2496,12 +2529,13 @@ export const singingStore = createPartialStore({
}
}
- mutations.SET_PHRASES({ phrases });
+ // state.phrasesを更新する
+ mutations.SET_PHRASES({ phrases: mergedPhrases });
logger.info("Phrases updated.");
- // 各フレーズのレンダリングを行う
-
+ // シンガーが設定されていないフレーズとレンダリング未完了のフレーズが
+ // プレビュー音で再生されるようにする
for (const [phraseKey, phrase] of state.phrases.entries()) {
if (
phrase.state === "SINGER_IS_NOT_SET" ||
@@ -2530,6 +2564,8 @@ export const singingStore = createPartialStore({
mutations.SET_SEQUENCE_ID_TO_PHRASE({ phraseKey, sequenceId });
}
}
+
+ // 各フレーズのレンダリングを行い、レンダリングされた音声が再生されるようにする
const phrasesToBeRendered = new Map(
[...state.phrases.entries()].filter(([, phrase]) => {
return phrase.state === "WAITING_TO_BE_RENDERED";
@@ -2552,12 +2588,20 @@ export const singingStore = createPartialStore({
try {
// フレーズのレンダリングを行う
- await phraseRenderer.render(
- snapshot,
- phrase.trackId,
- phraseKey,
- getOrThrow(renderStartStageIds, phraseKey),
- );
+ const trackId = phrase.trackId;
+ const startStageId = getOrThrow(renderStartStageIds, phraseKey);
+ const startStageIndex = stages.findIndex((value) => {
+ return value.id === startStageId;
+ });
+ if (startStageIndex === -1) {
+ throw new Error("Stage not found.");
+ }
+ for (let i = stages.length - 1; i >= startStageIndex; i--) {
+ stages[i].deleteExecutionResult(phraseKey);
+ }
+ for (let i = startStageIndex; i < stages.length; i++) {
+ await stages[i].execute(trackId, phraseKey, snapshot);
+ }
// シーケンスが存在する場合、シーケンスを削除する
const phraseSequenceId = getPhraseSequenceId(phraseKey);
@@ -3847,16 +3891,6 @@ export const singingCommandStore = transformCommandStore(
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.");
}
@@ -3899,9 +3933,6 @@ export const singingCommandStore = transformCommandStore(
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.");
}
diff --git a/src/store/type.ts b/src/store/type.ts
index cec2b0de12..e9ad37d141 100644
--- a/src/store/type.ts
+++ b/src/store/type.ts
@@ -26,7 +26,6 @@ import {
DefaultStyleId,
AcceptRetrieveTelemetryStatus,
AcceptTermsStatus,
- HotkeySettingType,
MoraDataType,
SavingSetting,
ThemeConf,
@@ -60,7 +59,7 @@ import {
TextDialogResult,
NotifyAndNotShowAgainButtonOption,
LoadingScreenOption,
- AlertDialogOptions,
+ MessageDialogOptions,
ConfirmDialogOptions,
WarningDialogOptions,
} from "@/components/Dialog/Dialog";
@@ -72,6 +71,7 @@ import {
timeSignatureSchema,
trackSchema,
} from "@/domain/project/schema";
+import { HotkeySettingType } from "@/domain/hotkeyAction";
/**
* エディタ用のAudioQuery
@@ -838,8 +838,15 @@ const phraseKeySchema = z.string().brand<"PhraseKey">();
export type PhraseKey = z.infer;
export const PhraseKey = (id: string): PhraseKey => phraseKeySchema.parse(id);
+// 編集対象 ノート or ピッチ
+// ボリュームを足すのであれば"VOLUME"を追加する
export type SequencerEditTarget = "NOTE" | "PITCH";
+// ノート編集ツール
+export type NoteEditTool = "SELECT_FIRST" | "EDIT_FIRST";
+// ピッチ編集ツール
+export type PitchEditTool = "DRAW" | "ERASE";
+
export type TrackParameters = {
gain: boolean;
pan: boolean;
@@ -874,6 +881,8 @@ export type SingingStoreState = {
sequencerZoomY: number;
sequencerSnapType: number;
sequencerEditTarget: SequencerEditTarget;
+ sequencerNoteTool: NoteEditTool;
+ sequencerPitchTool: PitchEditTool;
_selectedNoteIds: Set;
editingLyricNoteId?: NoteId;
nowPlaying: boolean;
@@ -983,6 +992,11 @@ export type SingingStoreTypes = {
action({ trackId }: { trackId: TrackId }): void;
};
+ DESELECT_NOTES: {
+ mutation: { noteIds: NoteId[] };
+ action(payload: { noteIds: NoteId[] }): void;
+ };
+
DESELECT_ALL_NOTES: {
mutation: undefined;
action(): void;
@@ -1118,6 +1132,16 @@ export type SingingStoreTypes = {
action(payload: { editTarget: SequencerEditTarget }): void;
};
+ SET_SEQUENCER_NOTE_TOOL: {
+ mutation: { sequencerNoteTool: NoteEditTool };
+ action(payload: { sequencerNoteTool: NoteEditTool }): void;
+ };
+
+ SET_SEQUENCER_PITCH_TOOL: {
+ mutation: { sequencerPitchTool: PitchEditTool };
+ action(payload: { sequencerPitchTool: PitchEditTool }): void;
+ };
+
SET_IS_DRAG: {
mutation: { isDrag: boolean };
action(payload: { isDrag: boolean }): void;
@@ -1623,8 +1647,12 @@ export type EngineStoreTypes = {
action(payload: { engineId: EngineId; styleId: StyleId }): Promise;
};
- INITIALIZE_ENGINE_SPEAKER: {
- action(payload: { engineId: EngineId; styleId: StyleId }): void;
+ INITIALIZE_ENGINE_CHARACTER: {
+ action(payload: {
+ engineId: EngineId;
+ styleId: StyleId;
+ uiLock: boolean;
+ }): void;
};
VALIDATE_ENGINE_DIR: {
@@ -1949,7 +1977,6 @@ export type UiStoreState = {
} & DialogStates;
export type DialogStates = {
- isHelpDialogOpen: boolean;
isSettingDialogOpen: boolean;
isCharacterOrderDialogOpen: boolean;
isDefaultStyleSelectDialogOpen: boolean;
@@ -2016,7 +2043,7 @@ export type UiStoreTypes = {
};
SHOW_ALERT_DIALOG: {
- action(payload: AlertDialogOptions): TextDialogResult;
+ action(payload: MessageDialogOptions): TextDialogResult;
};
SHOW_CONFIRM_DIALOG: {
@@ -2100,6 +2127,18 @@ export type UiStoreTypes = {
getter: boolean;
};
+ ZOOM_IN: {
+ action(): void;
+ };
+
+ ZOOM_OUT: {
+ action(): void;
+ };
+
+ ZOOM_RESET: {
+ action(): void;
+ };
+
CHECK_EDITED_AND_NOT_SAVE: {
action(
obj:
diff --git a/src/store/ui.ts b/src/store/ui.ts
index aec24a5b47..76558dbcf6 100644
--- a/src/store/ui.ts
+++ b/src/store/ui.ts
@@ -14,7 +14,7 @@ import {
import { createPartialStore } from "./vuex";
import { ActivePointScrollMode } from "@/type/preload";
import {
- AlertDialogOptions,
+ MessageDialogOptions,
ConfirmDialogOptions,
WarningDialogOptions,
LoadingScreenOption,
@@ -68,7 +68,6 @@ export const uiStoreState: UiStoreState = {
reloadingLock: false,
inheritAudioInfo: true,
activePointScrollMode: "OFF",
- isHelpDialogOpen: false,
isSettingDialogOpen: false,
isHotkeySettingDialogOpen: false,
isToolbarSettingDialogOpen: false,
@@ -203,7 +202,7 @@ export const uiStore = createPartialStore({
},
SHOW_ALERT_DIALOG: {
- action: createUILockAction(async (_, payload: AlertDialogOptions) => {
+ action: createUILockAction(async (_, payload: MessageDialogOptions) => {
return await showAlertDialog(payload);
}),
},
@@ -395,6 +394,27 @@ export const uiStore = createPartialStore({
},
},
+ /** UIの拡大 */
+ ZOOM_IN: {
+ action() {
+ window.backend.zoomIn();
+ },
+ },
+
+ /** UIの縮小 */
+ ZOOM_OUT: {
+ action() {
+ window.backend.zoomOut();
+ },
+ },
+
+ /** UIの拡大率のリセット */
+ ZOOM_RESET: {
+ action() {
+ window.backend.zoomReset();
+ },
+ },
+
CHECK_EDITED_AND_NOT_SAVE: {
/**
* プロジェクトファイル未保存の場合、保存するかどうかを確認する。
diff --git a/src/store/utility.ts b/src/store/utility.ts
index 5f0ac52677..3334fa44fd 100644
--- a/src/store/utility.ts
+++ b/src/store/utility.ts
@@ -1,6 +1,7 @@
import * as diff from "fast-array-diff";
import {
CharacterInfo,
+ PresetSliderKey,
StyleInfo,
StyleType,
ToolbarButtonTagType,
@@ -40,14 +41,23 @@ export function sanitizeFileName(fileName: string): string {
return fileName.replace(sanitizer, "");
}
+type SliderParameter = {
+ max: () => number;
+ min: () => number;
+ step: () => number;
+ scrollStep: () => number;
+ scrollMinStep?: () => number;
+};
+
/**
* AudioInfoコンポーネントに表示されるパラメータ
+ * TODO: src/domain/talk.ts辺りに切り出す
*/
-export const SLIDER_PARAMETERS = {
+export const SLIDER_PARAMETERS: Record = {
/**
* 話速パラメータの定義
*/
- SPEED: {
+ speedScale: {
max: () => 2,
min: () => 0.5,
step: () => 0.01,
@@ -57,7 +67,7 @@ export const SLIDER_PARAMETERS = {
/**
* 音高パラメータの定義
*/
- PITCH: {
+ pitchScale: {
max: () => 0.15,
min: () => -0.15,
step: () => 0.01,
@@ -66,7 +76,7 @@ export const SLIDER_PARAMETERS = {
/**
* 抑揚パラメータの定義
*/
- INTONATION: {
+ intonationScale: {
max: () => 2,
min: () => 0,
step: () => 0.01,
@@ -76,7 +86,7 @@ export const SLIDER_PARAMETERS = {
/**
* 音量パラメータの定義
*/
- VOLUME: {
+ volumeScale: {
max: () => 2,
min: () => 0,
step: () => 0.01,
@@ -84,19 +94,19 @@ export const SLIDER_PARAMETERS = {
scrollMinStep: () => 0.01,
},
/**
- * 開始無音パラメータの定義
+ * 文内無音(倍率)パラメータの定義
*/
- PRE_PHONEME_LENGTH: {
- max: () => 1.5,
+ pauseLengthScale: {
+ max: () => 2,
min: () => 0,
step: () => 0.01,
scrollStep: () => 0.1,
scrollMinStep: () => 0.01,
},
/**
- * 終了無音パラメータの定義
+ * 開始無音パラメータの定義
*/
- POST_PHONEME_LENGTH: {
+ prePhonemeLength: {
max: () => 1.5,
min: () => 0,
step: () => 0.01,
@@ -104,10 +114,10 @@ export const SLIDER_PARAMETERS = {
scrollMinStep: () => 0.01,
},
/**
- * 文内無音(倍率)パラメータの定義
+ * 終了無音パラメータの定義
*/
- PAUSE_LENGTH_SCALE: {
- max: () => 2,
+ postPhonemeLength: {
+ max: () => 1.5,
min: () => 0,
step: () => 0.01,
scrollStep: () => 0.1,
@@ -116,7 +126,7 @@ export const SLIDER_PARAMETERS = {
/**
* モーフィングレートパラメータの定義
*/
- MORPHING_RATE: {
+ morphingRate: {
max: () => 1,
min: () => 0,
step: () => 0.01,
diff --git a/src/styles/_index.scss b/src/styles/_index.scss
index 877a5e8c59..ae0b795b5e 100644
--- a/src/styles/_index.scss
+++ b/src/styles/_index.scss
@@ -273,3 +273,17 @@ img {
width: 700px;
max-width: 80vw;
}
+
+.material-symbols-outlined {
+ font-family: "Material Symbols Outlined";
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px; /* Preferred icon size */
+ display: inline-block;
+ line-height: 1;
+ text-transform: none;
+ letter-spacing: normal;
+ word-wrap: normal;
+ white-space: nowrap;
+ direction: ltr;
+}
diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss
index 2911455b92..de14765b4f 100644
--- a/src/styles/fonts.scss
+++ b/src/styles/fonts.scss
@@ -11,3 +11,11 @@
font-weight: bold;
}
+
+@font-face {
+ font-display: block;
+ font-family: "Material Symbols Outlined";
+ font-style: normal;
+ font-weight: 400;
+ src: url("../fonts/material-symbols-outlined-regular.woff2") format("woff2");
+}
diff --git a/src/styles/v2/colors.scss b/src/styles/v2/colors.scss
index 94dc830f0f..d68687ce67 100644
--- a/src/styles/v2/colors.scss
+++ b/src/styles/v2/colors.scss
@@ -12,6 +12,7 @@ $primitive-red: #d04756;
// ライトテーマの色
:root[is-dark-theme="false"] {
--color-v2-background: #{color.adjust($primitive-primary, $lightness: 25%)};
+ --color-v2-background-alt: #{color.adjust($primitive-white, $lightness: -5%)};
--color-v2-background-drawer: #{rgba(color.adjust($primitive-primary, $lightness: 20%), 0.75)};
--color-v2-surface: #{$primitive-white};
@@ -48,6 +49,7 @@ $primitive-red: #d04756;
// ダークテーマの色
:root[is-dark-theme="true"] {
--color-v2-background: #{$primitive-black};
+ --color-v2-background-alt: #{color.adjust($primitive-black, $lightness: 2%)};
--color-v2-background-drawer: #{rgba($primitive-black, 0.75)};
--color-v2-surface: #{color.adjust($primitive-black, $lightness: 3%)};
@@ -82,6 +84,7 @@ $primitive-red: #d04756;
}
$background: var(--color-v2-background);
+$background-alt: var(--color-v2-background-alt);
$background-drawer: var(--color-v2-background-drawer);
$surface: var(--color-v2-surface);
diff --git a/src/styles/v2/cursor.scss b/src/styles/v2/cursor.scss
index b706c98022..ccf9027d5e 100644
--- a/src/styles/v2/cursor.scss
+++ b/src/styles/v2/cursor.scss
@@ -20,6 +20,6 @@
.cursor-draw {
cursor:
- url("/draw-cursor.png") 2 30,
+ url("/draw-cursor.png") 2 2,
auto;
-}
\ No newline at end of file
+}
diff --git a/src/styles/v2/variables.scss b/src/styles/v2/variables.scss
index 7c50a0b682..abe056c97a 100644
--- a/src/styles/v2/variables.scss
+++ b/src/styles/v2/variables.scss
@@ -23,6 +23,7 @@ $radius-2: calc(var(--radius-basis) * 2);
$transition-duration: 100ms;
+$z-index-scrollbar: 100;
$z-index-fullscreen-dialog: 1000;
$z-index-dropdown: 1010;
$z-index-fixed: 1020;
@@ -34,4 +35,5 @@ $z-index-sing-note: 10;
$z-index-sing-note-lyric: 20;
$z-index-sing-pitch: 30;
$z-index-sing-playhead: 40;
-$z-index-sing-lyric-input: 50;
+$z-index-sing-tool-palette: 50;
+$z-index-sing-lyric-input: 60;
diff --git a/src/type/ipc.ts b/src/type/ipc.ts
index e0af36fddb..d4c64162fe 100644
--- a/src/type/ipc.ts
+++ b/src/type/ipc.ts
@@ -5,7 +5,6 @@ import {
EngineId,
EngineInfo,
EngineSettingType,
- HotkeySettingType,
MessageBoxReturnValue,
NativeThemeType,
TextAsset,
@@ -13,6 +12,7 @@ import {
} from "@/type/preload";
import { AltPortInfos } from "@/store/type";
import { Result } from "@/type/result";
+import { HotkeySettingType } from "@/domain/hotkeyAction";
/**
* invoke, handle
@@ -118,7 +118,27 @@ export type IpcIHData = {
return: void;
};
- MAXIMIZE_WINDOW: {
+ TOGGLE_MAXIMIZE_WINDOW: {
+ args: [];
+ return: void;
+ };
+
+ TOGGLE_FULLSCREEN: {
+ args: [];
+ return: void;
+ };
+
+ ZOOM_IN: {
+ args: [];
+ return: void;
+ };
+
+ ZOOM_OUT: {
+ args: [];
+ return: void;
+ };
+
+ ZOOM_RESET: {
args: [];
return: void;
};
@@ -158,11 +178,6 @@ export type IpcIHData = {
return: HotkeySettingType[];
};
- GET_DEFAULT_HOTKEY_SETTINGS: {
- args: [];
- return: HotkeySettingType[];
- };
-
GET_DEFAULT_TOOLBAR_SETTING: {
args: [];
return: ToolbarSettingType;
diff --git a/src/type/preload.ts b/src/type/preload.ts
index d576228a51..24e4afc361 100644
--- a/src/type/preload.ts
+++ b/src/type/preload.ts
@@ -2,18 +2,16 @@ import { z } from "zod";
import { IpcSOData } from "./ipc";
import { AltPortInfos } from "@/store/type";
import { Result } from "@/type/result";
-import { isMac } from "@/helpers/platform";
+import {
+ HotkeySettingType,
+ hotkeySettingSchema,
+ defaultHotkeySettings,
+} from "@/domain/hotkeyAction";
const urlStringSchema = z.string().url().brand("URL");
export type UrlString = z.infer;
export const UrlString = (url: string): UrlString => urlStringSchema.parse(url);
-const hotkeyCombinationSchema = z.string().brand("HotkeyCombination");
-export type HotkeyCombination = z.infer;
-export const HotkeyCombination = (
- hotkeyCombination: string,
-): HotkeyCombination => hotkeyCombinationSchema.parse(hotkeyCombination);
-
export const engineIdSchema = z.string().brand<"EngineId">();
export type EngineId = z.infer;
export const EngineId = (id: string): EngineId => engineIdSchema.parse(id);
@@ -51,129 +49,6 @@ export const trackIdSchema = z.string().brand<"TrackId">();
export type TrackId = z.infer;
export const TrackId = (id: string): TrackId => trackIdSchema.parse(id);
-// 共通のアクション名
-export const actionPostfixSelectNthCharacter = "番目のキャラクターを選択";
-
-// ホットキーを追加したときは設定のマイグレーションが必要
-export const defaultHotkeySettings: HotkeySettingType[] = [
- {
- action: "音声書き出し",
- combination: HotkeyCombination(!isMac ? "Ctrl E" : "Meta E"),
- },
- {
- action: "選択音声を書き出し",
- combination: HotkeyCombination("E"),
- },
- {
- action: "音声を繋げて書き出し",
- combination: HotkeyCombination(""),
- },
- {
- action: "再生/停止",
- combination: HotkeyCombination("Space"),
- },
- {
- action: "連続再生/停止",
- combination: HotkeyCombination("Shift Space"),
- },
- {
- action: "アクセント欄を表示",
- combination: HotkeyCombination("1"),
- },
- {
- action: "イントネーション欄を表示",
- combination: HotkeyCombination("2"),
- },
- {
- action: "長さ欄を表示",
- combination: HotkeyCombination("3"),
- },
- {
- action: "テキスト欄を追加",
- combination: HotkeyCombination("Shift Enter"),
- },
- {
- action: "テキスト欄を複製",
- combination: HotkeyCombination(!isMac ? "Ctrl D" : "Meta D"),
- },
- {
- action: "テキスト欄を削除",
- combination: HotkeyCombination("Shift Delete"),
- },
- {
- action: "テキスト欄からフォーカスを外す",
- combination: HotkeyCombination("Escape"),
- },
- {
- action: "テキスト欄にフォーカスを戻す",
- combination: HotkeyCombination("Enter"),
- },
- {
- action: "元に戻す",
- combination: HotkeyCombination(!isMac ? "Ctrl Z" : "Meta Z"),
- },
- {
- action: "やり直す",
- combination: HotkeyCombination(!isMac ? "Ctrl Y" : "Shift Meta Z"),
- },
- {
- action: "新規プロジェクト",
- combination: HotkeyCombination(!isMac ? "Ctrl N" : "Meta N"),
- },
- {
- action: "プロジェクトを名前を付けて保存",
- combination: HotkeyCombination(!isMac ? "Ctrl Shift S" : "Shift Meta S"),
- },
- {
- action: "プロジェクトを上書き保存",
- combination: HotkeyCombination(!isMac ? "Ctrl S" : "Meta S"),
- },
- {
- action: "プロジェクトを読み込む",
- combination: HotkeyCombination(!isMac ? "Ctrl O" : "Meta O"),
- },
- {
- action: "テキストを読み込む",
- combination: HotkeyCombination(""),
- },
- {
- action: "全体のイントネーションをリセット",
- combination: HotkeyCombination(!isMac ? "Ctrl G" : "Meta G"),
- },
- {
- action: "選択中のアクセント句のイントネーションをリセット",
- combination: HotkeyCombination("R"),
- },
- {
- action: "コピー",
- combination: HotkeyCombination(!isMac ? "Ctrl C" : "Meta C"),
- },
- {
- action: "切り取り",
- combination: HotkeyCombination(!isMac ? "Ctrl X" : "Meta X"),
- },
- {
- action: "貼り付け",
- combination: HotkeyCombination(!isMac ? "Ctrl V" : "Meta V"),
- },
- {
- action: "すべて選択",
- combination: HotkeyCombination(!isMac ? "Ctrl A" : "Meta A"),
- },
- {
- action: "選択解除",
- combination: HotkeyCombination("Escape"),
- },
- ...Array.from({ length: 10 }, (_, index) => {
- const roleKey = index == 9 ? 0 : index + 1;
- return {
- action:
- `${index + 1}${actionPostfixSelectNthCharacter}` as HotkeyActionNameType,
- combination: HotkeyCombination(`${!isMac ? "Ctrl" : "Meta"} ${roleKey}`),
- };
- }),
-];
-
export const defaultToolbarButtonSetting: ToolbarSettingType = [
"PLAY_CONTINUOUSLY",
"STOP",
@@ -237,7 +112,11 @@ export interface Sandbox {
}): void;
closeWindow(): void;
minimizeWindow(): void;
- maximizeWindow(): void;
+ toggleMaximizeWindow(): void;
+ toggleFullScreen(): void;
+ zoomIn(): void;
+ zoomOut(): void;
+ zoomReset(): void;
logError(...params: unknown[]): void;
logWarn(...params: unknown[]): void;
logInfo(...params: unknown[]): void;
@@ -248,7 +127,6 @@ export interface Sandbox {
hotkeySettings(newData?: HotkeySettingType): Promise;
checkFileExists(file: string): Promise;
changePinWindow(): void;
- getDefaultHotkeySettings(): Promise;
getDefaultToolbarSetting(): Promise;
setNativeTheme(source: NativeThemeType): void;
vuexReady(): void;
@@ -393,6 +271,9 @@ export type Preset = {
postPhonemeLength: number;
morphingInfo?: MorphingInfo;
};
+export type PresetSliderKey =
+ | keyof Omit
+ | "morphingRate";
export type MorphingInfo = {
rate: number;
@@ -417,55 +298,6 @@ export type MorphableTargetInfoTable = Record<
>
>;
-export const hotkeyActionNameSchema = z.enum([
- "音声書き出し",
- "選択音声を書き出し",
- "音声を繋げて書き出し",
- "再生/停止",
- "連続再生/停止",
- "アクセント欄を表示",
- "イントネーション欄を表示",
- "長さ欄を表示",
- "テキスト欄を追加",
- "テキスト欄を複製",
- "テキスト欄を削除",
- "テキスト欄からフォーカスを外す",
- "テキスト欄にフォーカスを戻す",
- "元に戻す",
- "やり直す",
- "新規プロジェクト",
- "プロジェクトを名前を付けて保存",
- "プロジェクトを上書き保存",
- "プロジェクトを読み込む",
- "テキストを読み込む",
- "全体のイントネーションをリセット",
- "選択中のアクセント句のイントネーションをリセット",
- "コピー",
- "切り取り",
- "貼り付け",
- "すべて選択",
- "選択解除",
- "全セルを選択",
- `1${actionPostfixSelectNthCharacter}`,
- `2${actionPostfixSelectNthCharacter}`,
- `3${actionPostfixSelectNthCharacter}`,
- `4${actionPostfixSelectNthCharacter}`,
- `5${actionPostfixSelectNthCharacter}`,
- `6${actionPostfixSelectNthCharacter}`,
- `7${actionPostfixSelectNthCharacter}`,
- `8${actionPostfixSelectNthCharacter}`,
- `9${actionPostfixSelectNthCharacter}`,
- `10${actionPostfixSelectNthCharacter}`,
-]);
-
-export type HotkeyActionNameType = z.infer;
-
-export const hotkeySettingSchema = z.object({
- action: hotkeyActionNameSchema,
- combination: hotkeyCombinationSchema,
-});
-export type HotkeySettingType = z.infer;
-
export type HotkeyReturnType =
| void
| boolean
diff --git "a/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts" "b/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts"
index 4852e41cc2..b18d149670 100644
--- "a/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts"
+++ "b/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts"
@@ -10,8 +10,29 @@ test("アクセント分割したらアクセント区間が増える", async ({
await page.locator(".audio-cell input").first().fill("こんにちは");
await page.locator(".audio-cell input").first().press("Enter");
await page.waitForTimeout(500);
- expect(await page.locator(".mora-table").count()).toBe(1);
+ expect(await page.locator(".accent-phrase").count()).toBe(1);
await (await page.locator(".splitter-cell").all())[1].click();
await page.waitForTimeout(500);
- expect(await page.locator(".mora-table").count()).toBe(2);
+ expect(await page.locator(".accent-phrase").count()).toBe(2);
+});
+
+test("アクセントの読み部分をクリックすると読みを変更できる", async ({
+ page,
+}) => {
+ await navigateToMain(page);
+
+ await page.getByRole("textbox", { name: "1行目" }).click();
+ await page.getByRole("textbox", { name: "1行目" }).fill("テストです");
+ await page.getByRole("textbox", { name: "1行目" }).press("Enter");
+ const accentPhrase = page.locator(".accent-phrase");
+ await expect(accentPhrase).toHaveText("テストデス");
+
+ await expect(page.locator(".text-cell").first()).toBeVisible();
+ await page.locator(".text-cell").first().click();
+ const input = page.getByLabel("1番目のアクセント区間の読み");
+ await input.evaluate((node) => console.log(node.outerHTML));
+ expect(await input.inputValue()).toBe("テストデス");
+ await input.fill("テストテスト");
+ await input.press("Enter");
+ await expect(accentPhrase).toHaveText("テストテスト");
});
diff --git "a/tests/e2e/browser/\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.spec.ts" "b/tests/e2e/browser/\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.spec.ts"
index d47c880093..485eb7fb66 100644
--- "a/tests/e2e/browser/\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.spec.ts"
+++ "b/tests/e2e/browser/\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.spec.ts"
@@ -20,6 +20,7 @@ let speakerImages:
/**
* 差し替え用の立ち絵・アイコンを取得する。
+ * TODO: エンジンモックを使ってこのコードを削除する。
*/
async function getSpeakerImages(): Promise<
{
@@ -28,7 +29,10 @@ async function getSpeakerImages(): Promise<
}[]
> {
if (!speakerImages) {
- const assetsPath = path.resolve(__dirname, "assets");
+ const assetsPath = path.resolve(
+ __dirname,
+ "../../../src/mock/engineMock/assets",
+ );
const images = await fs.readdir(assetsPath);
const icons = images.filter((image) => image.startsWith("icon"));
icons.sort(
diff --git "a/tests/e2e/browser/\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.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" "b/tests/e2e/browser/\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.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png"
index d198a700e3..32da40c0dd 100644
Binary files "a/tests/e2e/browser/\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.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" and "b/tests/e2e/browser/\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.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" differ
diff --git "a/tests/e2e/browser/\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.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" "b/tests/e2e/browser/\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.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png"
index 0ac99a0157..9ba500101c 100644
Binary files "a/tests/e2e/browser/\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.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" and "b/tests/e2e/browser/\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.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" differ
diff --git "a/tests/e2e/browser/\343\203\204\343\203\274\343\203\253\343\203\220\343\203\274\343\202\253\343\202\271\343\202\277\343\203\236\343\202\244\343\202\272\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts" "b/tests/e2e/browser/\343\203\204\343\203\274\343\203\253\343\203\220\343\203\274\343\202\253\343\202\271\343\202\277\343\203\236\343\202\244\343\202\272\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts"
index 0a9bffd775..ec6ccef32a 100644
--- "a/tests/e2e/browser/\343\203\204\343\203\274\343\203\253\343\203\220\343\203\274\343\202\253\343\202\271\343\202\277\343\203\236\343\202\244\343\202\272\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts"
+++ "b/tests/e2e/browser/\343\203\204\343\203\274\343\203\253\343\203\220\343\203\274\343\202\253\343\202\271\343\202\277\343\203\236\343\202\244\343\202\272\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts"
@@ -62,7 +62,7 @@ test("ツールバーのカスタマイズでボタンを追加でき、デフ
.count(),
).toBe(1);
await page.getByText("デフォルトに戻す").click();
- await page.locator(".q-card").getByText("はい").click();
+ await page.locator(".q-card").getByText("デフォルトに戻す").click();
await page.getByText("保存", { exact: true }).click();
expect(
await page
diff --git "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts" "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts"
index 4f3863a1fa..a79efcf022 100644
--- "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts"
+++ "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts"
@@ -118,6 +118,7 @@ test("複数選択:AudioInfo操作", async ({ page }) => {
for (const parameter of parameters) {
const input = parameter.locator("label input");
+ if (await input.isDisabled()) continue;
await input.fill("2");
await page.waitForTimeout(100);
}
diff --git "a/tests/e2e/browser/\350\252\277\346\225\264\347\265\220\346\236\234.spec.ts" "b/tests/e2e/browser/\350\252\277\346\225\264\347\265\220\346\236\234.spec.ts"
index e63fa59bfb..f31e9bf26f 100644
--- "a/tests/e2e/browser/\350\252\277\346\225\264\347\265\220\346\236\234.spec.ts"
+++ "b/tests/e2e/browser/\350\252\277\346\225\264\347\265\220\346\236\234.spec.ts"
@@ -8,7 +8,7 @@ test.beforeEach(gotoHome);
* @param page
*/
async function getSliderValues(page: Page) {
- const moraTables = await page.locator(".mora-table").all();
+ const moraTables = await page.locator(".accent-phrase").all();
return await Promise.all(
moraTables.map(async (moraTable) => {
const sliders = await moraTable.locator(".q-slider").all();
@@ -70,7 +70,7 @@ test("実験的機能:調整結果の保持", async ({ page }) => {
[6.5, 6.5, 6.5, 6.5],
]);
// 読点が追加されていることを確認
- const firstAccentPhrase = page.locator(".mora-table").first();
+ const firstAccentPhrase = page.locator(".accent-phrase").first();
expect(await firstAccentPhrase.getByText("、").isVisible()).toBeTruthy();
// 句読点(pauseMora)だけの変更/削除:句読点部分以外は変わらない
diff --git "a/tests/e2e/browser/\351\237\263\345\243\260\343\203\221\343\203\251\343\203\241\343\203\274\343\202\277.spec.ts" "b/tests/e2e/browser/\351\237\263\345\243\260\343\203\221\343\203\251\343\203\241\343\203\274\343\202\277.spec.ts"
index 002d41f635..01145b34a5 100644
--- "a/tests/e2e/browser/\351\237\263\345\243\260\343\203\221\343\203\251\343\203\241\343\203\274\343\202\277.spec.ts"
+++ "b/tests/e2e/browser/\351\237\263\345\243\260\343\203\221\343\203\251\343\203\241\343\203\274\343\202\277.spec.ts"
@@ -34,6 +34,7 @@ test("音声パラメータ引き継ぎの設定", async ({ page }) => {
// パラメータを引き継がないことの確認
await page.locator(".audio-cell input").first().click();
await page.getByRole("button").filter({ hasText: "add" }).click();
+ await inputTag.waitFor();
await validateValue(inputTag, "1.00");
// スライダーからパラメータの変更ができるかどうかを確認
diff --git a/tests/e2e/electron/example.spec.ts b/tests/e2e/electron/example.spec.ts
index 9829fc75c0..c2616c1f2c 100644
--- a/tests/e2e/electron/example.spec.ts
+++ b/tests/e2e/electron/example.spec.ts
@@ -42,7 +42,7 @@ test.beforeEach(async () => {
test("起動したら「利用規約に関するお知らせ」が表示される", async () => {
const app = await electron.launch({
- args: ["."],
+ args: ["--no-sandbox", "."], // NOTE: --no-sandbox はUbuntu 24.04で動かすのに必要
timeout: process.env.CI ? 0 : 60000,
});
diff --git "a/tests/e2e/storybook/\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.spec.mts" "b/tests/e2e/storybook/\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.spec.mts"
index 460a7f7cd9..b5a1b7c3cd 100644
--- "a/tests/e2e/storybook/\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.spec.mts"
+++ "b/tests/e2e/storybook/\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.spec.mts"
@@ -1,10 +1,17 @@
-// 起動中のStorybookで様々なStoryを表示し、スクリーンショットを撮って比較するVRT。
-// テスト自体はend-to-endではないが、Playwrightを使う関係でe2eディレクトリ内でテストしている。
+/*
+ * 起動中のStorybookで様々なStoryを表示し、スクリーンショットを撮って比較するVRT。
+ * テスト自体はend-to-endではないが、Playwrightを使う関係でe2eディレクトリ内でテストしている。
+ *
+ * --update-snapshotsをつけてPlaywrightを実行するとスクリーンショットが更新される。
+ * 同時に、Storyが消えたスクリーンショットの削除も行う。
+ */
+import fs from "fs/promises";
+import path from "path";
import { test, expect, Locator } from "@playwright/test";
import z from "zod";
// Storybook 8.3.5時点でのindex.jsonのスキーマ。
-// もしスキーマが変わってテストが通らなくなった場合は、このスキーマを修正する。
+// もしスキーマが変わってテストが通らなくなった場合は、このスキーマを修正すること。
const storybookIndexSchema = z.object({
v: z.literal(5),
entries: z.record(
@@ -19,12 +26,19 @@ const storybookIndexSchema = z.object({
});
type StorybookIndex = z.infer;
type Story = StorybookIndex["entries"][string];
+type Theme = "light" | "dark";
+
+const toSnapshotFileName = (story: Story, theme: Theme) =>
+ `${story.id}-${theme}.png`;
// テスト対象のStory一覧を取得する。
// play-fnが付いているStoryはUnit Test用Storyとみなしてスクリーンショットを撮らない
const getStoriesToTest = (index: StorybookIndex) =>
Object.values(index.entries).filter(
- (entry) => entry.type === "story" && !entry.tags.includes("play-fn"),
+ (entry) =>
+ entry.type === "story" &&
+ !entry.tags.includes("play-fn") &&
+ !entry.tags.includes("skip-screenshot"),
);
let index: StorybookIndex;
@@ -52,14 +66,11 @@ for (const story of currentStories) {
for (const [story, stories] of Object.entries(allStories)) {
test.describe(story, () => {
for (const story of stories) {
- if (story.tags.includes("skip-screenshot")) {
- continue;
- }
test.describe(story.name, () => {
for (const [theme, name] of [
["light", "ライト"],
["dark", "ダーク"],
- ]) {
+ ] as const) {
test(`テーマ:${name}`, async ({ page }) => {
test.skip(
process.platform !== "win32",
@@ -95,7 +106,7 @@ for (const [story, stories] of Object.entries(allStories)) {
elementToScreenshot = root;
}
await expect(elementToScreenshot).toHaveScreenshot(
- `${story.id}-${theme}.png`,
+ toSnapshotFileName(story, theme),
);
});
}
@@ -103,3 +114,33 @@ for (const [story, stories] of Object.entries(allStories)) {
}
});
}
+
+test("スクリーンショットの一覧に過不足が無い", async () => {
+ test.skip(process.platform !== "win32", "Windows以外のためスキップします");
+ const screenshotFiles = await fs.readdir(test.info().snapshotDir);
+ const screenshotPaths = screenshotFiles.map((file) =>
+ path.join(test.info().snapshotDir, file),
+ );
+
+ const expectedScreenshots = currentStories.flatMap((story) =>
+ (["light", "dark"] as const).map((theme) =>
+ test.info().snapshotPath(toSnapshotFileName(story, theme)),
+ ),
+ );
+
+ screenshotPaths.sort();
+ expectedScreenshots.sort();
+
+ // update-snapshotsが指定されていたら、余分なスクリーンショットを削除する。
+ // 指定されていなかったら、スクリーンショットの一覧が一致していることを確認する。
+ if (test.info().config.updateSnapshots === "all") {
+ for (const screenshot of screenshotPaths) {
+ if (!expectedScreenshots.includes(screenshot)) {
+ await fs.unlink(screenshot);
+ console.log(`Deleted: ${path.basename(screenshot)}`);
+ }
+ }
+ } else {
+ expect(screenshotPaths).toEqual(expectedScreenshots);
+ }
+});
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-basecheckbox--default-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-basecheckbox--default-dark-storybook-win32.png"
deleted file mode 100644
index e5600783b2..0000000000
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-basecheckbox--default-dark-storybook-win32.png" and /dev/null differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-basecheckbox--default-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-basecheckbox--default-light-storybook-win32.png"
deleted file mode 100644
index 3ffcb50ccb..0000000000
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-basecheckbox--default-light-storybook-win32.png" and /dev/null differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--default-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--default-dark-storybook-win32.png"
new file mode 100644
index 0000000000..40d858a05f
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--default-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--default-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--default-light-storybook-win32.png"
new file mode 100644
index 0000000000..a79bc9a826
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--default-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--disabled-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--disabled-dark-storybook-win32.png"
new file mode 100644
index 0000000000..f7e35bb00e
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--disabled-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--disabled-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--disabled-light-storybook-win32.png"
new file mode 100644
index 0000000000..31441b7c5c
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-base-baseiconbutton--disabled-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-dark-storybook-win32.png"
index 74f55fe1c3..2decf9d002 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-dark-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-light-storybook-win32.png"
index 6146a13dac..72b25c70ae 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-light-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-dark-storybook-win32.png"
index e646a172b9..46764f3653 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-dark-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-light-storybook-win32.png"
index 6f7c7d1cad..d74da8b3bb 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-light-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-messagedialog--opened-multiline-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-button-color-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-button-color-dark-storybook-win32.png"
new file mode 100644
index 0000000000..c019c761c5
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-button-color-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-button-color-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-button-color-light-storybook-win32.png"
new file mode 100644
index 0000000000..301d31a46c
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-button-color-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-dark-storybook-win32.png"
index d83270aaee..3231bd996a 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-dark-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-light-storybook-win32.png"
index b8beef9a97..450d0e03a3 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-light-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-dark-storybook-win32.png"
index bf8b55bc2e..3787836561 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-dark-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-light-storybook-win32.png"
index 39d5c7917e..56ba595ce4 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-light-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-dialog-textdialog-questiondialog--opened-multiline-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--change-opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--change-opened-dark-storybook-win32.png"
new file mode 100644
index 0000000000..9a7606c113
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--change-opened-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--change-opened-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--change-opened-light-storybook-win32.png"
new file mode 100644
index 0000000000..2296f994b1
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--change-opened-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--create-opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--create-opened-dark-storybook-win32.png"
new file mode 100644
index 0000000000..45cdf78ae8
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--create-opened-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--create-opened-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--create-opened-light-storybook-win32.png"
new file mode 100644
index 0000000000..6cc152bf7d
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-tempochangedialog--create-opened-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--change-opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--change-opened-dark-storybook-win32.png"
new file mode 100644
index 0000000000..a602d170c0
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--change-opened-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--change-opened-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--change-opened-light-storybook-win32.png"
new file mode 100644
index 0000000000..c513d89fe0
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--change-opened-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--create-opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--create-opened-dark-storybook-win32.png"
new file mode 100644
index 0000000000..f98beaf90d
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--create-opened-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--create-opened-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--create-opened-light-storybook-win32.png"
new file mode 100644
index 0000000000..7cb7a8895f
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-changevaluedialog-timesignaturechangedialog--create-opened-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-dark-storybook-win32.png"
index 002b9f7081..371515e711 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-dark-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-light-storybook-win32.png"
index 2fca489555..517e91bbed 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-light-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--default-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--with-time-signature-change-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--with-time-signature-change-dark-storybook-win32.png"
new file mode 100644
index 0000000000..e846496c94
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--with-time-signature-change-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--with-time-signature-change-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--with-time-signature-change-light-storybook-win32.png"
new file mode 100644
index 0000000000..7b70a2800a
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencergrid--with-time-signature-change-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-dark-storybook-win32.png"
index 3a3f4c6c7a..d9bd5289d8 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-dark-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-light-storybook-win32.png"
index 87c6d0aad9..227b1735c0 100644
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-light-storybook-win32.png" and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-storybook-win32.png"
deleted file mode 100644
index 38d7351162..0000000000
Binary files "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--default-storybook-win32.png" and /dev/null differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--dense-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--dense-dark-storybook-win32.png"
new file mode 100644
index 0000000000..4c92c33a0b
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--dense-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--dense-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--dense-light-storybook-win32.png"
new file mode 100644
index 0000000000..52168d3efa
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--dense-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-bpm-change-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-bpm-change-dark-storybook-win32.png"
new file mode 100644
index 0000000000..70b2dfe918
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-bpm-change-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-bpm-change-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-bpm-change-light-storybook-win32.png"
new file mode 100644
index 0000000000..bededfc443
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-bpm-change-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-offset-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-offset-dark-storybook-win32.png"
new file mode 100644
index 0000000000..85b09c1763
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-offset-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-offset-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-offset-light-storybook-win32.png"
new file mode 100644
index 0000000000..d12cbfde46
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-offset-light-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-time-signature-change-dark-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-time-signature-change-dark-storybook-win32.png"
new file mode 100644
index 0000000000..a5510d4b5e
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-time-signature-change-dark-storybook-win32.png" differ
diff --git "a/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-time-signature-change-light-storybook-win32.png" "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-time-signature-change-light-storybook-win32.png"
new file mode 100644
index 0000000000..32195209fe
Binary files /dev/null and "b/tests/e2e/storybook/\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.spec.mts-snapshots/components-sing-sequencerruler--with-time-signature-change-light-storybook-win32.png" differ
diff --git a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap
new file mode 100644
index 0000000000..27c1b4d8f6
--- /dev/null
+++ b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap
@@ -0,0 +1,256 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`0.13.0からマイグレーションできる 1`] = `
+{
+ "acceptRetrieveTelemetry": "Accepted",
+ "acceptTerms": "Accepted",
+ "activePointScrollMode": "OFF",
+ "confirmedTips": {
+ "engineStartedOnAltPort": false,
+ "notifyOnGenerate": false,
+ "tweakableSliderByScroll": false,
+ },
+ "currentTheme": "Default",
+ "defaultPresetKeys": {},
+ "defaultStyleIds": [],
+ "editorFont": "default",
+ "enableMemoNotation": false,
+ "enableMultiEngine": false,
+ "enablePreset": false,
+ "enableRubyNotation": false,
+ "engineSettings": {
+ "074fc39e-678b-4c13-8916-ffca8d505d1d": {
+ "outputSamplingRate": "engineDefault",
+ "useGpu": false,
+ },
+ },
+ "experimentalSetting": {
+ "enableInterrogativeUpspeak": false,
+ "enableMorphing": false,
+ "enableMultiSelect": false,
+ "shouldKeepTuningOnTextChange": false,
+ },
+ "hotkeySettings": [
+ {
+ "action": "音声書き出し",
+ "combination": "Ctrl E",
+ },
+ {
+ "action": "選択音声を書き出し",
+ "combination": "E",
+ },
+ {
+ "action": "音声を繋げて書き出し",
+ "combination": "",
+ },
+ {
+ "action": "再生/停止",
+ "combination": "Space",
+ },
+ {
+ "action": "連続再生/停止",
+ "combination": "Shift Space",
+ },
+ {
+ "action": "アクセント欄を表示",
+ "combination": "1",
+ },
+ {
+ "action": "イントネーション欄を表示",
+ "combination": "2",
+ },
+ {
+ "action": "長さ欄を表示",
+ "combination": "3",
+ },
+ {
+ "action": "テキスト欄を追加",
+ "combination": "Shift Enter",
+ },
+ {
+ "action": "テキスト欄を複製",
+ "combination": "Ctrl D",
+ },
+ {
+ "action": "テキスト欄を削除",
+ "combination": "Shift Delete",
+ },
+ {
+ "action": "テキスト欄からフォーカスを外す",
+ "combination": "Escape",
+ },
+ {
+ "action": "テキスト欄にフォーカスを戻す",
+ "combination": "Enter",
+ },
+ {
+ "action": "元に戻す",
+ "combination": "Ctrl Z",
+ },
+ {
+ "action": "やり直す",
+ "combination": "Ctrl Y",
+ },
+ {
+ "action": "拡大",
+ "combination": "",
+ },
+ {
+ "action": "縮小",
+ "combination": "",
+ },
+ {
+ "action": "拡大率のリセット",
+ "combination": "",
+ },
+ {
+ "action": "新規プロジェクト",
+ "combination": "Ctrl N",
+ },
+ {
+ "action": "全画面表示を切り替え",
+ "combination": "F11",
+ },
+ {
+ "action": "プロジェクトを名前を付けて保存",
+ "combination": "Ctrl Shift S",
+ },
+ {
+ "action": "プロジェクトを上書き保存",
+ "combination": "Ctrl S",
+ },
+ {
+ "action": "プロジェクトを読み込む",
+ "combination": "Ctrl O",
+ },
+ {
+ "action": "テキストを読み込む",
+ "combination": "",
+ },
+ {
+ "action": "全体のイントネーションをリセット",
+ "combination": "Ctrl G",
+ },
+ {
+ "action": "選択中のアクセント句のイントネーションをリセット",
+ "combination": "R",
+ },
+ {
+ "action": "コピー",
+ "combination": "Ctrl C",
+ },
+ {
+ "action": "切り取り",
+ "combination": "Ctrl X",
+ },
+ {
+ "action": "貼り付け",
+ "combination": "Ctrl V",
+ },
+ {
+ "action": "すべて選択",
+ "combination": "Ctrl A",
+ },
+ {
+ "action": "選択解除",
+ "combination": "",
+ },
+ {
+ "action": "1番目のキャラクターを選択",
+ "combination": "Ctrl 1",
+ },
+ {
+ "action": "2番目のキャラクターを選択",
+ "combination": "Ctrl 2",
+ },
+ {
+ "action": "3番目のキャラクターを選択",
+ "combination": "Ctrl 3",
+ },
+ {
+ "action": "4番目のキャラクターを選択",
+ "combination": "Ctrl 4",
+ },
+ {
+ "action": "5番目のキャラクターを選択",
+ "combination": "Ctrl 5",
+ },
+ {
+ "action": "6番目のキャラクターを選択",
+ "combination": "Ctrl 6",
+ },
+ {
+ "action": "7番目のキャラクターを選択",
+ "combination": "Ctrl 7",
+ },
+ {
+ "action": "8番目のキャラクターを選択",
+ "combination": "Ctrl 8",
+ },
+ {
+ "action": "9番目のキャラクターを選択",
+ "combination": "Ctrl 9",
+ },
+ {
+ "action": "10番目のキャラクターを選択",
+ "combination": "Ctrl 0",
+ },
+ ],
+ "inheritAudioInfo": true,
+ "openedEditor": "talk",
+ "playheadPositionDisplayFormat": "MINUTES_SECONDS",
+ "presets": {
+ "items": {},
+ "keys": [],
+ },
+ "recentlyUsedProjects": [],
+ "registeredEngineDirs": [],
+ "savingSetting": {
+ "audioOutputDevice": "default",
+ "avoidOverwrite": false,
+ "exportLab": false,
+ "exportText": false,
+ "fileEncoding": "UTF-8",
+ "fileNamePattern": "",
+ "fixedExportDir": "",
+ "fixedExportEnabled": false,
+ "outputStereo": false,
+ "songTrackFileNamePattern": "",
+ },
+ "shouldApplyDefaultPresetOnVoiceChanged": false,
+ "showAddAudioItemButton": true,
+ "showSingCharacterPortrait": true,
+ "showTextLineNumber": false,
+ "splitTextWhenPaste": "PERIOD_AND_NEW_LINE",
+ "splitterPosition": {},
+ "toolbarSetting": [
+ "PLAY_CONTINUOUSLY",
+ "STOP",
+ "EXPORT_AUDIO_SELECTED",
+ "EMPTY",
+ "UNDO",
+ "REDO",
+ ],
+ "undoableTrackOperations": {
+ "panAndGain": true,
+ "soloAndMute": true,
+ },
+ "userCharacterOrder": [
+ "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff",
+ "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9",
+ "35b2c544-660e-401e-b503-0e14c635303a",
+ "3474ee95-c274-47f9-aa1a-8322163d96f1",
+ "b1a81618-b27b-40d2-b0ea-27a9ad408c4b",
+ "c30dc15a-0992-4f8d-8bb8-ad3b314e6a6f",
+ "e5020595-5c5d-4e87-b849-270a518d0dcf",
+ "4f51116a-d9ee-4516-925d-21f183e2afad",
+ "8eaad775-3119-417e-8cf4-2a10bfd592c8",
+ "481fb609-6446-4870-9f46-90c4dd623403",
+ "9f3ee141-26ad-437e-97bd-d22298d02ad2",
+ "1a17ca16-7ee5-4ea5-b191-2f02ace24d21",
+ "67d5d8da-acd7-4207-bb10-b5542d3a663b",
+ "0f56c2f2-644c-49c9-8989-94e11f7129d0",
+ "044830d2-f23b-44d6-ac0d-b5d733caa900",
+ ],
+}
+`;
diff --git a/tests/unit/backend/common/configManager.spec.ts b/tests/unit/backend/common/configManager.spec.ts
index a24cfc0b5f..d1109ada57 100644
--- a/tests/unit/backend/common/configManager.spec.ts
+++ b/tests/unit/backend/common/configManager.spec.ts
@@ -1,5 +1,6 @@
import pastConfigs from "./pastConfigs";
import configBugDefaultPreset1996 from "./pastConfigs/0.19.1-bug_default_preset.json";
+import { isMac } from "@/helpers/platform";
import { BaseConfigManager } from "@/backend/common/ConfigManager";
import { configSchema } from "@/type/preload";
@@ -85,6 +86,13 @@ for (const [version, data] of pastConfigs) {
const configManager = new TestConfigManager();
await configManager.initialize();
expect(configManager).toBeTruthy();
+
+ // マイグレーション後のデータが正しいことをスナップショットで確認
+ // NOTE: Macはショートカットキーが異なるためパス
+ // TODO: ConfigManagerにOSを引数指定できるようにしてテストを分ける
+ if (!isMac) {
+ expect(configManager.getAll()).toMatchSnapshot();
+ }
});
}
diff --git a/tests/unit/lib/hotkeyManager.spec.ts b/tests/unit/lib/hotkeyManager.spec.ts
index 57c1997d10..2cc1e72020 100644
--- a/tests/unit/lib/hotkeyManager.spec.ts
+++ b/tests/unit/lib/hotkeyManager.spec.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { HotkeyManager, HotkeysJs, HotkeyAction } from "@/plugins/hotkeyPlugin";
-import { HotkeyCombination, HotkeySettingType } from "@/type/preload";
+import { HotkeyCombination, HotkeySettingType } from "@/domain/hotkeyAction";
type DummyHotkeysJs = HotkeysJs & {
registeredHotkeys: {
diff --git a/tests/unit/lib/map.spec.ts b/tests/unit/lib/map.spec.ts
deleted file mode 100644
index 5f891c28c2..0000000000
--- a/tests/unit/lib/map.spec.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { mapNullablePipe, nullableToDefault } from "@/helpers/map";
-
-type DummyType = {
- outerValue?: {
- innerValue?: string;
- };
-};
-
-describe("mapUndefinedPipe", () => {
- it("値をunwrap出来る", () => {
- const key = "test";
- const innerValue = "value";
- const value: DummyType = {
- outerValue: {
- innerValue,
- },
- };
- const map = new Map([[key, value]]);
- expect(
- mapNullablePipe(
- map.get(key),
- (v) => v.outerValue,
- (v) => v.innerValue,
- ),
- ).toEqual(innerValue);
- });
-
- it("途中でundefinedを返すとその後undefinedを返す", () => {
- const key = "test";
- const value: DummyType = {
- outerValue: {
- innerValue: undefined,
- },
- };
- const map = new Map([[key, value]]);
- expect(
- mapNullablePipe(
- map.get(key),
- (v) => v.outerValue,
- (v) => v.innerValue,
- ),
- ).toBeUndefined();
- });
-});
-
-describe("undefinedToDefault", () => {
- it("値がある時はそのまま返す", () => {
- const actualValue = "value";
- expect(nullableToDefault("test", actualValue)).toEqual(actualValue);
- });
-
- it("値がない時はdefaultValueを返す", () => {
- const defaultValue = "test";
- expect(nullableToDefault(defaultValue, undefined)).toEqual(defaultValue);
- });
-});
diff --git a/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap b/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap
new file mode 100644
index 0000000000..2905c56cb3
--- /dev/null
+++ b/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,176 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`createOpenAPIEngineMock > audioQueryAudioQueryPost 1`] = `
+{
+ "accentPhrases": [
+ {
+ "accent": 5,
+ "moras": [
+ {
+ "consonant": "k",
+ "consonantLength": 0.0220625,
+ "pitch": 4.7734375,
+ "text": "コ",
+ "vowel": "o",
+ "vowelLength": 0.1140625,
+ },
+ {
+ "consonant": undefined,
+ "consonantLength": undefined,
+ "pitch": 3.609375,
+ "text": "ン",
+ "vowel": "N",
+ "vowelLength": 0.08312499999999999,
+ },
+ {
+ "consonant": "n",
+ "consonantLength": 0.022625,
+ "pitch": 4.75,
+ "text": "ニ",
+ "vowel": "i",
+ "vowelLength": 0.10843749999999999,
+ },
+ {
+ "consonant": "ch",
+ "consonantLength": 0.0400625,
+ "pitch": 4.0234375,
+ "text": "チ",
+ "vowel": "i",
+ "vowelLength": 0.10843749999999999,
+ },
+ {
+ "consonant": "h",
+ "consonantLength": 0.0315,
+ "pitch": 4.796875,
+ "text": "ハ",
+ "vowel": "a",
+ "vowelLength": 0.1509375,
+ },
+ ],
+ "pauseMora": undefined,
+ },
+ ],
+ "intonationScale": 1,
+ "outputSamplingRate": 24000,
+ "outputStereo": false,
+ "pitchScale": 0,
+ "postPhonemeLength": 0.1,
+ "prePhonemeLength": 0.1,
+ "speedScale": 1,
+ "volumeScale": 1,
+}
+`;
+
+exports[`createOpenAPIEngineMock > frameSynthesisFrameSynthesisPost 1`] = `"394cfbc01397e0b6fcc3433d9537aa850e6131f7d89048da6889e7375fe03a24"`;
+
+exports[`createOpenAPIEngineMock > singFrameAudioQuerySingFrameAudioQueryPost 1`] = `
+{
+ "f0": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 46.17422889791779,
+ 46.08055781881967,
+ 46.08055781881967,
+ 46.16797823967303,
+ 46.18048040243588,
+ 46.18048040243588,
+ 82.27312267254713,
+ 82.21745071316357,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ],
+ "outputSamplingRate": 24000,
+ "outputStereo": false,
+ "phonemes": [
+ {
+ "frameLength": 9,
+ "noteId": "a",
+ "phoneme": "pau",
+ },
+ {
+ "frameLength": 1,
+ "noteId": "b",
+ "phoneme": "t",
+ },
+ {
+ "frameLength": 2,
+ "noteId": "b",
+ "phoneme": "e",
+ },
+ {
+ "frameLength": 1,
+ "noteId": "c",
+ "phoneme": "s",
+ },
+ {
+ "frameLength": 2,
+ "noteId": "c",
+ "phoneme": "u",
+ },
+ {
+ "frameLength": 1,
+ "noteId": "d",
+ "phoneme": "t",
+ },
+ {
+ "frameLength": 1,
+ "noteId": "d",
+ "phoneme": "o",
+ },
+ {
+ "frameLength": 10,
+ "noteId": "e",
+ "phoneme": "pau",
+ },
+ ],
+ "volume": [
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7732211475542967,
+ 0.7702900494608796,
+ 0.7702900494608796,
+ 0.7730257409255916,
+ 0.7734165541957456,
+ 0.7734165541957456,
+ 0.7737647610411076,
+ 0.7727873601766926,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ 0.7635414345273557,
+ ],
+ "volumeScale": 1,
+}
+`;
+
+exports[`createOpenAPIEngineMock > synthesisSynthesisPost 1`] = `"23f4b910863418a7188648f7c5226a0f02b9d067b964be1690b69b1e9ffde7bc"`;
+
+exports[`createOpenAPIEngineMock > versionVersionGet 1`] = `"mock"`;
diff --git a/tests/unit/mock/engineMock/index.spec.ts b/tests/unit/mock/engineMock/index.spec.ts
new file mode 100644
index 0000000000..5855815692
--- /dev/null
+++ b/tests/unit/mock/engineMock/index.spec.ts
@@ -0,0 +1,68 @@
+import { hash } from "../../utils";
+import { createOpenAPIEngineMock } from "@/mock/engineMock";
+
+describe("createOpenAPIEngineMock", () => {
+ const mock = createOpenAPIEngineMock();
+
+ it("versionVersionGet", async () => {
+ const response = await mock.versionVersionGet();
+ expect(response).toMatchSnapshot();
+ });
+
+ it("audioQueryAudioQueryPost", async () => {
+ const response = await mock.audioQueryAudioQueryPost({
+ text: "こんにちは",
+ speaker: 0,
+ });
+ expect(response).toMatchSnapshot();
+ });
+
+ it("synthesisSynthesisPost", async () => {
+ const audioQuery = await mock.audioQueryAudioQueryPost({
+ text: "こんにちは",
+ speaker: 0,
+ });
+ const response = await mock.synthesisSynthesisPost({
+ audioQuery,
+ speaker: 0,
+ });
+ expect(await hash(await response.arrayBuffer())).toMatchSnapshot();
+ });
+
+ it("singFrameAudioQuerySingFrameAudioQueryPost", async () => {
+ const response = await mock.singFrameAudioQuerySingFrameAudioQueryPost({
+ speaker: 0,
+ score: {
+ notes: [
+ { id: "a", key: undefined, frameLength: 10, lyric: "" },
+ { id: "b", key: 30, frameLength: 3, lyric: "て" },
+ { id: "c", key: 30, frameLength: 3, lyric: "す" },
+ { id: "d", key: 40, frameLength: 1, lyric: "と" },
+ { id: "e", key: undefined, frameLength: 10, lyric: "" },
+ ],
+ },
+ });
+ expect(response).toMatchSnapshot();
+ });
+
+ it("frameSynthesisFrameSynthesisPost", async () => {
+ const frameAudioQuery =
+ await mock.singFrameAudioQuerySingFrameAudioQueryPost({
+ speaker: 0,
+ score: {
+ notes: [
+ { id: "a", key: undefined, frameLength: 10, lyric: "" },
+ { id: "b", key: 30, frameLength: 3, lyric: "て" },
+ { id: "c", key: 30, frameLength: 3, lyric: "す" },
+ { id: "d", key: 40, frameLength: 1, lyric: "と" },
+ { id: "e", key: undefined, frameLength: 10, lyric: "" },
+ ],
+ },
+ });
+ const response = await mock.frameSynthesisFrameSynthesisPost({
+ frameAudioQuery,
+ speaker: 0,
+ });
+ expect(await hash(await response.arrayBuffer())).toMatchSnapshot();
+ });
+});
diff --git a/tests/unit/utils.ts b/tests/unit/utils.ts
index 3bc85e6dd9..2c89db5c50 100644
--- a/tests/unit/utils.ts
+++ b/tests/unit/utils.ts
@@ -3,7 +3,7 @@ import { Component } from "vue";
// QPageContainerとQLayoutで囲うためのヘルパー関数。
// QPageはQLayout > QPageContainer > QPageの構造にしないとエラーになるため必要。
-export const wrapQPage = (page: Component) => {
+export function wrapQPage(page: Component) {
return {
template: `
@@ -18,4 +18,11 @@ export const wrapQPage = (page: Component) => {
QLayout,
},
};
-};
+}
+
+/** バイナリからSHA-256ハッシュを計算する */
+export async function hash(data: ArrayBuffer): Promise {
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+}