diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue
index f814b1549b..32ca183f20 100644
--- a/src/components/Sing/ScoreSequencer.vue
+++ b/src/components/Sing/ScoreSequencer.vue
@@ -17,6 +17,7 @@
:class="{
'edit-note': editTarget === 'NOTE',
'edit-pitch': editTarget === 'PITCH',
+ 'edit-volume': editTarget === 'VOLUME',
previewing: nowPreviewing,
[cursorClass]: true,
}"
@@ -84,6 +85,17 @@
:offsetY="scrollY"
:previewPitchEdit
/>
+
(undefined);
-const prevCursorPos = { frame: 0, frequency: 0 }; // 前のカーソル位置
+// ボリューム編集のプレビュー
+const previewVolumeEdit = ref<
+ | { type: "draw"; data: number[]; startFrame: number }
+ | { type: "erase"; startFrame: number; frameLength: number }
+ | undefined
+>(undefined);
+const prevCursorPos = { frame: 0, frequency: 0, volume: 0 }; // 前のカーソル位置
// 歌詞を編集中のノート
const editingLyricNote = computed(() => {
@@ -729,6 +750,112 @@ const previewErasePitch = () => {
setCursorState(CursorState.ERASE);
};
+// ボリュームを描く処理を行う
+const previewDrawVolume = () => {
+ if (previewVolumeEdit.value == undefined) {
+ throw new Error("previewVolumeEdit.value is undefined.");
+ }
+ if (previewVolumeEdit.value.type !== "draw") {
+ throw new Error("previewVolumeEdit.value.type is not draw.");
+ }
+ const frameRate = editorFrameRate.value;
+ const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value;
+ const cursorTicks = baseXToTick(cursorBaseX, tpqn.value);
+ const cursorSeconds = tickToSecond(cursorTicks, tempos.value, tpqn.value);
+ const cursorFrame = Math.round(cursorSeconds * frameRate);
+ const cursorDecibel = viewYToDecibel(cursorY.value);
+ const cursorVolume = decibelToLinear(cursorDecibel);
+ if (cursorFrame < 0) {
+ return;
+ }
+ const tempVolumeEdit = {
+ ...previewVolumeEdit.value,
+ data: [...previewVolumeEdit.value.data],
+ };
+
+ if (cursorFrame < tempVolumeEdit.startFrame) {
+ const numOfFramesToUnshift = tempVolumeEdit.startFrame - cursorFrame;
+ tempVolumeEdit.data = new Array(numOfFramesToUnshift)
+ .fill(0)
+ .concat(tempVolumeEdit.data);
+ tempVolumeEdit.startFrame = cursorFrame;
+ }
+
+ const lastFrame = tempVolumeEdit.startFrame + tempVolumeEdit.data.length - 1;
+ if (cursorFrame > lastFrame) {
+ const numOfFramesToPush = cursorFrame - lastFrame;
+ tempVolumeEdit.data = tempVolumeEdit.data.concat(
+ new Array(numOfFramesToPush).fill(0),
+ );
+ }
+
+ if (cursorFrame === prevCursorPos.frame) {
+ const i = cursorFrame - tempVolumeEdit.startFrame;
+ tempVolumeEdit.data[i] = cursorVolume;
+ } else if (cursorFrame < prevCursorPos.frame) {
+ for (let i = cursorFrame; i <= prevCursorPos.frame; i++) {
+ tempVolumeEdit.data[i - tempVolumeEdit.startFrame] = Math.exp(
+ linearInterpolation(
+ cursorFrame,
+ Math.log(cursorVolume),
+ prevCursorPos.frame,
+ Math.log(prevCursorPos.volume),
+ i,
+ ),
+ );
+ }
+ } else {
+ for (let i = prevCursorPos.frame; i <= cursorFrame; i++) {
+ tempVolumeEdit.data[i - tempVolumeEdit.startFrame] = Math.exp(
+ linearInterpolation(
+ prevCursorPos.frame,
+ Math.log(prevCursorPos.volume),
+ cursorFrame,
+ Math.log(cursorVolume),
+ i,
+ ),
+ );
+ }
+ }
+
+ previewVolumeEdit.value = tempVolumeEdit;
+ prevCursorPos.frame = cursorFrame;
+ prevCursorPos.volume = cursorVolume;
+ setCursorState(CursorState.DRAW);
+};
+
+const previewEraseVolume = () => {
+ if (previewVolumeEdit.value == undefined) {
+ throw new Error("previewVolumeEdit.value is undefined.");
+ }
+ if (previewVolumeEdit.value.type !== "erase") {
+ throw new Error("previewVolumeEdit.value.type is not erase.");
+ }
+ const frameRate = editorFrameRate.value;
+ const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value;
+ const cursorTicks = baseXToTick(cursorBaseX, tpqn.value);
+ const cursorSeconds = tickToSecond(cursorTicks, tempos.value, tpqn.value);
+ const cursorFrame = Math.round(cursorSeconds * frameRate);
+ if (cursorFrame < 0) {
+ return;
+ }
+ const tempVolumeEdit = { ...previewVolumeEdit.value };
+
+ if (tempVolumeEdit.startFrame > cursorFrame) {
+ tempVolumeEdit.frameLength += tempVolumeEdit.startFrame - cursorFrame;
+ tempVolumeEdit.startFrame = cursorFrame;
+ }
+
+ const lastFrame = tempVolumeEdit.startFrame + tempVolumeEdit.frameLength - 1;
+ if (lastFrame < cursorFrame) {
+ tempVolumeEdit.frameLength += cursorFrame - lastFrame;
+ }
+
+ previewVolumeEdit.value = tempVolumeEdit;
+ prevCursorPos.frame = cursorFrame;
+ setCursorState(CursorState.ERASE);
+};
+
const preview = () => {
if (executePreviewProcess.value) {
if (previewMode.value === "ADD_NOTE") {
@@ -749,6 +876,12 @@ const preview = () => {
if (previewMode.value === "ERASE_PITCH") {
previewErasePitch();
}
+ if (previewMode.value === "DRAW_VOLUME") {
+ previewDrawVolume();
+ }
+ if (previewMode.value === "ERASE_VOLUME") {
+ previewEraseVolume();
+ }
executePreviewProcess.value = false;
}
previewRequestId = requestAnimationFrame(preview);
@@ -880,6 +1013,30 @@ const startPreview = (event: MouseEvent, mode: PreviewMode, note?: Note) => {
}
prevCursorPos.frame = cursorFrame;
prevCursorPos.frequency = cursorFrequency;
+ } else if (editTarget.value === "VOLUME") {
+ const frameRate = editorFrameRate.value;
+ const cursorTicks = baseXToTick(cursorBaseX, tpqn.value);
+ const cursorSeconds = tickToSecond(cursorTicks, tempos.value, tpqn.value);
+ const cursorFrame = Math.round(cursorSeconds * frameRate);
+ const cursorNoteNumber = baseYToNoteNumber(cursorBaseY, false);
+ const cursorFrequency = noteNumberToFrequency(cursorNoteNumber);
+ if (mode === "DRAW_VOLUME") {
+ previewVolumeEdit.value = {
+ type: "draw",
+ data: [cursorFrequency],
+ startFrame: cursorFrame,
+ };
+ } else if (mode === "ERASE_VOLUME") {
+ previewVolumeEdit.value = {
+ type: "erase",
+ startFrame: cursorFrame,
+ frameLength: 1,
+ };
+ } else {
+ throw new Error("Unknown preview mode.");
+ }
+ prevCursorPos.frame = cursorFrame;
+ prevCursorPos.frequency = cursorFrequency;
} else {
throw new ExhaustiveError(editTarget.value);
}
@@ -948,6 +1105,33 @@ const endPreview = () => {
throw new ExhaustiveError(previewPitchEditType);
}
previewPitchEdit.value = undefined;
+ } else if (previewStartEditTarget === "VOLUME") {
+ if (previewVolumeEdit.value == undefined) {
+ throw new Error("previewVolumeEdit.value is undefined.");
+ }
+ const previewVolumeEditType = previewVolumeEdit.value.type;
+ if (previewVolumeEditType === "draw") {
+ // カーソルを動かさずにマウスのボタンを離したときに1フレームのみの変更になり、
+ // 1フレームの変更はピッチ編集ラインとして表示されないので、無視する
+ if (previewVolumeEdit.value.data.length >= 2) {
+ // 平滑化を行う
+ const data = [...previewVolumeEdit.value.data];
+ void store.actions.COMMAND_SET_VOLUME_EDIT_DATA({
+ volumeArray: data,
+ startFrame: previewVolumeEdit.value.startFrame,
+ trackId: selectedTrackId.value,
+ });
+ }
+ } else if (previewVolumeEditType === "erase") {
+ void store.actions.COMMAND_ERASE_VOLUME_EDIT_DATA({
+ startFrame: previewVolumeEdit.value.startFrame,
+ frameLength: previewVolumeEdit.value.frameLength,
+ trackId: selectedTrackId.value,
+ });
+ } else {
+ throw new ExhaustiveError(previewVolumeEditType);
+ }
+ previewVolumeEdit.value = undefined;
} else {
throw new ExhaustiveError(previewStartEditTarget);
}
@@ -1027,6 +1211,14 @@ const onMouseDown = (event: MouseEvent) => {
startPreview(event, "DRAW_PITCH");
}
}
+ } else if (editTarget.value === "VOLUME") {
+ if (mouseButton === "LEFT_BUTTON") {
+ if (isOnCommandOrCtrlKeyDown(event)) {
+ startPreview(event, "ERASE_VOLUME");
+ } else {
+ startPreview(event, "DRAW_VOLUME");
+ }
+ }
} else {
throw new ExhaustiveError(editTarget.value);
}
@@ -1589,6 +1781,7 @@ const contextMenuData = computed
(() => {
pointer-events: none;
}
+.sequencer-volume,
.sequencer-pitch {
grid-row: 2;
grid-column: 2;
diff --git a/src/components/Sing/SequencerNote.vue b/src/components/Sing/SequencerNote.vue
index 849559b0c3..f7a0dff36e 100644
--- a/src/components/Sing/SequencerNote.vue
+++ b/src/components/Sing/SequencerNote.vue
@@ -149,6 +149,9 @@ const editTargetIsNote = computed(() => {
const editTargetIsPitch = computed(() => {
return state.sequencerEditTarget === "PITCH";
});
+const editTargetIsVolume = computed(() => {
+ return state.sequencerEditTarget === "VOLUME";
+});
const hasOverlappingError = computed(() => {
return props.isOverlapping && !props.isPreview;
});
@@ -174,12 +177,14 @@ const classes = computed(() => {
return {
"edit-note": editTargetIsNote.value, // ノート編集モード
"edit-pitch": editTargetIsPitch.value, // ピッチ編集モード
+ "edit-volume": editTargetIsVolume.value, // ボリューム編集モード
selected: props.isSelected, // このノートが選択中
preview: props.isPreview, // なんらかのプレビュー中
"preview-lyric": props.previewLyric != undefined, // 歌詞プレビュー中
overlapping: hasOverlappingError.value, // ノートが重なっている
"invalid-phrase": hasPhraseError.value, // フレーズ生成エラー
"below-pitch": editTargetIsPitch.value, // ピッチ編集中
+ "below-volume": editTargetIsVolume.value, // ボリューム編集中
adding: props.isPreview && props.previewMode === "ADD_NOTE", // ノート追加中
"resizing-right":
props.isPreview && props.previewMode === "RESIZE_NOTE_RIGHT", // 右リサイズ中
@@ -381,6 +386,29 @@ const onLeftEdgeMouseDown = (event: MouseEvent) => {
}
}
+ // 右リサイズ中
+ &.resizing-right {
+ .note-edge.right {
+ background-color: var(--scheme-color-sing-note-bar-selected-border);
+ }
+ }
+
+ // 左リサイズ中
+ &.resizing-left {
+ .note-edge.left {
+ background-color: var(--scheme-color-sing-note-bar-selected-border);
+ }
+ }
+
+ // 歌詞プレビュー中
+ &.preview-lyric {
+ .note-bar {
+ background-color: var(--scheme-color-sing-note-bar-preview-container);
+ border-color: var(--scheme-color-sing-note-bar-preview-border);
+ outline-color: var(--scheme-color-sing-note-bar-preview-outline);
+ }
+ }
+
// エラー状態
&.overlapping,
&.invalid-phrase {
@@ -423,7 +451,7 @@ const onLeftEdgeMouseDown = (event: MouseEvent) => {
}
/* ピッチ編集モード */
-.note.edit-pitch {
+.note.edit-pitch.edit-volume {
// ノートバー
.note-bar {
background-color: var(--scheme-color-sing-note-bar-below-pitch-container);
@@ -500,7 +528,7 @@ const onLeftEdgeMouseDown = (event: MouseEvent) => {
}
// ピッチ編集モード
-.note-lyric.edit-pitch {
+.note-lyric.edit-pitch.edit-volume {
color: oklch(from var(--scheme-color-on-surface-variant) l c h / 0.8);
z-index: vars.$z-index-sing-note-lyric;
@include text-outline(var(--scheme-color-surface-variant));
diff --git a/src/components/Sing/SequencerVolume.vue b/src/components/Sing/SequencerVolume.vue
new file mode 100644
index 0000000000..180b90bd8b
--- /dev/null
+++ b/src/components/Sing/SequencerVolume.vue
@@ -0,0 +1,494 @@
+
+
+
+
+
+
+
diff --git a/src/components/Sing/ToolBar/EditTargetSwicher.vue b/src/components/Sing/ToolBar/EditTargetSwicher.vue
index 99b0371975..82225185cd 100644
--- a/src/components/Sing/ToolBar/EditTargetSwicher.vue
+++ b/src/components/Sing/ToolBar/EditTargetSwicher.vue
@@ -37,6 +37,25 @@
ピッチ編集
{{ !isMac ? "Ctrl" : "Cmd" }}+クリックで消去
+
+
+
+
+
+ ボリューム倍率編集
{{ !isMac ? "Ctrl" : "Cmd" }}+クリックで消去
+
+
diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts
index eba32d5989..b8933e76ee 100644
--- a/src/domain/project/schema.ts
+++ b/src/domain/project/schema.ts
@@ -92,6 +92,7 @@ export const trackSchema = z.object({
volumeRangeAdjustment: z.number(), // 声量調整量
notes: z.array(noteSchema),
pitchEditData: z.array(z.number()), // 値の単位はHzで、データが無いところはVALUE_INDICATING_NO_DATAの値
+ volumeEditData: z.array(z.number()), // 値の単位はdBで、データが無いところはVALUE_INDICATING_NO_DATAの値
solo: z.boolean(),
mute: z.boolean(),
diff --git a/src/sing/domain.ts b/src/sing/domain.ts
index faf7230042..82973b88c8 100644
--- a/src/sing/domain.ts
+++ b/src/sing/domain.ts
@@ -375,6 +375,7 @@ export function createDefaultTrack(): Track {
volumeRangeAdjustment: 0,
notes: [],
pitchEditData: [],
+ volumeEditData: [],
solo: false,
mute: false,
@@ -417,6 +418,14 @@ export function isValidPitchEditData(pitchEditData: number[]) {
);
}
+export function isValidVolumeEditData(volumeEditData: number[]) {
+ return volumeEditData.every(
+ (value) =>
+ Number.isFinite(value) &&
+ (value > 0 || value === VALUE_INDICATING_NO_DATA),
+ );
+}
+
export const calculatePhraseKey = async (phraseSource: PhraseSource) => {
const hash = await calculateHash(phraseSource);
return PhraseKey(hash);
@@ -534,6 +543,45 @@ export function applyPitchEdit(
}
}
+export function applyVolumeEdit(
+ phraseQuery: EditorFrameAudioQuery,
+ phraseStartTime: number,
+ volumeEditData: number[],
+ editorFrameRate: number,
+) {
+ // フレーズのクエリのフレームレートとエディターのフレームレートが一致しない場合はエラー
+ // TODO: 補間するようにする
+ if (phraseQuery.frameRate !== editorFrameRate) {
+ throw new Error(
+ "The frame rate between the phrase query and the editor does not match.",
+ );
+ }
+ const volume = phraseQuery.volume;
+ const phonemes = phraseQuery.phonemes;
+
+ // 各フレームの音素の配列を生成する
+ const framePhonemes = convertToFramePhonemes(phonemes);
+ if (volume.length !== framePhonemes.length) {
+ throw new Error("volume.length and framePhonemes.length do not match.");
+ }
+
+ // 歌い方の開始フレームと終了フレームを計算する
+ const phraseQueryFrameLength = volume.length;
+ const phraseQueryStartFrame = Math.round(
+ phraseStartTime * phraseQuery.frameRate,
+ );
+ const phraseQueryEndFrame = phraseQueryStartFrame + phraseQueryFrameLength;
+
+ // ボリューム編集をvolumeに適用する
+ const startFrame = Math.max(0, phraseQueryStartFrame);
+ const endFrame = Math.min(volumeEditData.length, phraseQueryEndFrame);
+ for (let i = startFrame; i < endFrame; i++) {
+ if (volumeEditData[i] !== VALUE_INDICATING_NO_DATA) {
+ volume[i - phraseQueryStartFrame] = volumeEditData[i];
+ }
+ }
+}
+
/**
* 文字列をモーラと非モーラに分割する。長音は展開される。連続する非モーラはまとめる。
* 例:"カナー漢字" -> ["カ", "ナ", "ア", "漢字"]
diff --git a/src/sing/viewHelper.ts b/src/sing/viewHelper.ts
index 0f86b0d1ed..e007aa7522 100644
--- a/src/sing/viewHelper.ts
+++ b/src/sing/viewHelper.ts
@@ -6,6 +6,9 @@ import { isMac } from "@/helpers/platform";
const BASE_X_PER_QUARTER_NOTE = 120;
const BASE_Y_PER_SEMITONE = 30;
+const PIXELS_PER_DECIBEL = 6;
+const DECIBEL_VIEW_OFFSET = 100;
+
export const ZOOM_X_MIN = 0.15;
export const ZOOM_X_MAX = 2;
export const ZOOM_X_STEP = 0.05;
@@ -40,6 +43,14 @@ export function baseYToNoteNumber(baseY: number, integer = true) {
: 127.5 - baseY / BASE_Y_PER_SEMITONE;
}
+export function viewYToDecibel(viewY: number) {
+ return -((viewY - DECIBEL_VIEW_OFFSET) / PIXELS_PER_DECIBEL);
+}
+
+export function decibelToViewY(decibel: number) {
+ return -decibel * PIXELS_PER_DECIBEL + DECIBEL_VIEW_OFFSET;
+}
+
export function getPitchFromNoteNumber(noteNumber: number) {
const mapPitches = [
"C",
@@ -133,6 +144,20 @@ export async function calculatePitchDataHash(pitchData: PitchData) {
return pitchDataHashSchema.parse(hash);
}
+export type VolumeData = {
+ readonly ticksArray: number[];
+ readonly data: number[];
+};
+
+const VolumeDataHashSchema = z.string().brand<"VolumeDataHash">();
+
+export type VolumeDataHash = z.infer;
+
+export async function calculateVolumeDataHash(volumeData: VolumeData) {
+ const hash = await calculateHash(volumeData);
+ return VolumeDataHashSchema.parse(hash);
+}
+
export type MouseButton = "LEFT_BUTTON" | "RIGHT_BUTTON" | "OTHER_BUTTON";
export type PreviewMode =
@@ -142,7 +167,9 @@ export type PreviewMode =
| "RESIZE_NOTE_RIGHT"
| "RESIZE_NOTE_LEFT"
| "DRAW_PITCH"
- | "ERASE_PITCH";
+ | "ERASE_PITCH"
+ | "DRAW_VOLUME"
+ | "ERASE_VOLUME";
export function getButton(event: MouseEvent): MouseButton {
// macOSの場合、Ctrl+クリックは右クリック
diff --git a/src/store/singing.ts b/src/store/singing.ts
index 611fe2ce22..28f9632b73 100644
--- a/src/store/singing.ts
+++ b/src/store/singing.ts
@@ -70,6 +70,7 @@ import {
tickToSecond,
VALUE_INDICATING_NO_DATA,
isValidPitchEditData,
+ isValidVolumeEditData,
calculatePhraseKey,
isValidTempos,
isValidTimeSignatures,
@@ -87,6 +88,7 @@ import {
shouldPlayTracks,
decibelToLinear,
applyPitchEdit,
+ applyVolumeEdit,
} from "@/sing/domain";
import { getOverlappingNoteIds } from "@/sing/storeHelper";
import {
@@ -1214,6 +1216,60 @@ export const singingStore = createPartialStore({
},
},
+ SET_VOLUME_EDIT_DATA: {
+ // ボリューム編集データをセットする。
+ // track.volumeEditDataの長さが足りない場合は、伸長も行う。
+ mutation(state, { volumeArray, startFrame, trackId }) {
+ const track = getOrThrow(state.tracks, trackId);
+ const volumeEditData = track.volumeEditData;
+ const tempData = [...volumeEditData];
+ const endFrame = startFrame + volumeArray.length;
+ if (tempData.length < endFrame) {
+ const valuesToPush = new Array(endFrame - tempData.length).fill(
+ VALUE_INDICATING_NO_DATA,
+ );
+ tempData.push(...valuesToPush);
+ }
+ tempData.splice(startFrame, volumeArray.length, ...volumeArray);
+ track.volumeEditData = tempData;
+ },
+ async action({ actions, mutations }, { volumeArray, startFrame, trackId }) {
+ if (startFrame < 0) {
+ throw new Error("startFrame must be greater than or equal to 0.");
+ }
+ if (!isValidVolumeEditData(volumeArray)) {
+ throw new Error("The volume edit data is invalid.");
+ }
+ mutations.SET_VOLUME_EDIT_DATA({ volumeArray, startFrame, trackId });
+
+ void actions.RENDER();
+ },
+ },
+
+ ERASE_VOLUME_EDIT_DATA: {
+ mutation(state, { startFrame, frameLength, trackId }) {
+ const track = getOrThrow(state.tracks, trackId);
+ const volumeEditData = track.volumeEditData;
+ const tempData = [...volumeEditData];
+ const endFrame = Math.min(startFrame + frameLength, tempData.length);
+ tempData.fill(VALUE_INDICATING_NO_DATA, startFrame, endFrame);
+ track.volumeEditData = tempData;
+ },
+ },
+
+ CLEAR_VOLUME_EDIT_DATA: {
+ // ボリューム編集データを失くす。
+ mutation(state, { trackId }) {
+ const track = getOrThrow(state.tracks, trackId);
+ track.volumeEditData = [];
+ },
+ async action({ actions, mutations }, { trackId }) {
+ mutations.CLEAR_VOLUME_EDIT_DATA({ trackId });
+
+ void actions.RENDER();
+ },
+ },
+
SET_PHRASES: {
mutation(state, { phrases }) {
state.phrases = phrases;
@@ -2058,6 +2114,12 @@ export const singingStore = createPartialStore({
track.pitchEditData,
context.snapshot.editorFrameRate,
);
+ applyVolumeEdit(
+ clonedQuery,
+ phrase.startTime,
+ track.volumeEditData,
+ context.snapshot.editorFrameRate,
+ );
return {
engineId: track.singer.engineId,
engineFrameRate: query.frameRate,
@@ -2203,6 +2265,12 @@ export const singingStore = createPartialStore({
context.snapshot.editorFrameRate,
);
clonedQuery.volume = clonedSingingVolume;
+ applyVolumeEdit(
+ clonedQuery,
+ phrase.startTime,
+ track.volumeEditData,
+ context.snapshot.editorFrameRate,
+ );
return {
singer: track.singer,
queryForSingingVoiceSynthesis: clonedQuery,
@@ -3414,6 +3482,54 @@ export const singingCommandStore = transformCommandStore(
void actions.RENDER();
},
},
+ COMMAND_SET_VOLUME_EDIT_DATA: {
+ mutation(draft, { volumeArray, startFrame, trackId }) {
+ singingStore.mutations.SET_VOLUME_EDIT_DATA(draft, {
+ volumeArray,
+ startFrame,
+ trackId,
+ });
+ },
+ action({ mutations, actions }, { volumeArray, startFrame, trackId }) {
+ if (startFrame < 0) {
+ throw new Error("startFrame must be greater than or equal to 0.");
+ }
+ if (!isValidVolumeEditData(volumeArray)) {
+ throw new Error("The volume edit data is invalid.");
+ }
+ mutations.COMMAND_SET_VOLUME_EDIT_DATA({
+ volumeArray,
+ startFrame,
+ trackId,
+ });
+
+ void actions.RENDER();
+ },
+ },
+ COMMAND_ERASE_VOLUME_EDIT_DATA: {
+ mutation(draft, { startFrame, frameLength, trackId }) {
+ singingStore.mutations.ERASE_VOLUME_EDIT_DATA(draft, {
+ startFrame,
+ frameLength,
+ trackId,
+ });
+ },
+ action({ mutations, actions }, { startFrame, frameLength, trackId }) {
+ if (startFrame < 0) {
+ throw new Error("startFrame must be greater than or equal to 0.");
+ }
+ if (frameLength < 1) {
+ throw new Error("frameLength must be at least 1.");
+ }
+ mutations.COMMAND_ERASE_VOLUME_EDIT_DATA({
+ startFrame,
+ frameLength,
+ trackId,
+ });
+
+ void actions.RENDER();
+ },
+ },
COMMAND_INSERT_EMPTY_TRACK: {
mutation(draft, { trackId, track, prevTrackId }) {
diff --git a/src/store/type.ts b/src/store/type.ts
index eebfd77fcc..8fd7976510 100644
--- a/src/store/type.ts
+++ b/src/store/type.ts
@@ -838,7 +838,7 @@ const phraseKeySchema = z.string().brand<"PhraseKey">();
export type PhraseKey = z.infer;
export const PhraseKey = (id: string): PhraseKey => phraseKeySchema.parse(id);
-export type SequencerEditTarget = "NOTE" | "PITCH";
+export type SequencerEditTarget = "NOTE" | "PITCH" | "VOLUME";
export type TrackParameters = {
gain: boolean;
@@ -1006,6 +1006,24 @@ export type SingingStoreTypes = {
action(payload: { trackId: TrackId }): void;
};
+ SET_VOLUME_EDIT_DATA: {
+ mutation: { volumeArray: number[]; startFrame: number; trackId: TrackId };
+ action(payload: {
+ volumeArray: number[];
+ startFrame: number;
+ trackId: TrackId;
+ }): void;
+ };
+
+ ERASE_VOLUME_EDIT_DATA: {
+ mutation: { startFrame: number; frameLength: number; trackId: TrackId };
+ };
+
+ CLEAR_VOLUME_EDIT_DATA: {
+ mutation: { trackId: TrackId };
+ action(payload: { trackId: TrackId }): void;
+ };
+
SET_PHRASES: {
mutation: { phrases: Map };
};
@@ -1409,6 +1427,24 @@ export type SingingCommandStoreTypes = {
}): void;
};
+ COMMAND_SET_VOLUME_EDIT_DATA: {
+ mutation: { volumeArray: number[]; startFrame: number; trackId: TrackId };
+ action(payload: {
+ volumeArray: number[];
+ startFrame: number;
+ trackId: TrackId;
+ }): void;
+ };
+
+ COMMAND_ERASE_VOLUME_EDIT_DATA: {
+ mutation: { startFrame: number; frameLength: number; trackId: TrackId };
+ action(payload: {
+ startFrame: number;
+ frameLength: number;
+ trackId: TrackId;
+ }): void;
+ };
+
COMMAND_INSERT_EMPTY_TRACK: {
mutation: {
trackId: TrackId;