From 5b4e401a0d4531e9877363ba31f53175e5c5680a Mon Sep 17 00:00:00 2001 From: Romot Date: Mon, 20 Jan 2025 12:47:17 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sing/SequencerRuler/LoopLane.vue | 517 ++++++++++++++++++ src/domain/project/schema.ts | 11 + src/sing/domain.ts | 20 + src/store/project.ts | 15 +- src/store/singing.ts | 99 +++- src/store/type.ts | 35 ++ 6 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 src/components/Sing/SequencerRuler/LoopLane.vue diff --git a/src/components/Sing/SequencerRuler/LoopLane.vue b/src/components/Sing/SequencerRuler/LoopLane.vue new file mode 100644 index 0000000000..af592e45cd --- /dev/null +++ b/src/components/Sing/SequencerRuler/LoopLane.vue @@ -0,0 +1,517 @@ + + + + + diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts index eba32d5989..4b397ef861 100644 --- a/src/domain/project/schema.ts +++ b/src/domain/project/schema.ts @@ -99,6 +99,12 @@ export const trackSchema = z.object({ pan: z.number(), }); +export const loopSchema = z.object({ + isLoopEnabled: z.boolean(), + startTick: z.number(), // ループ開始ティック + endTick: z.number(), // ループ終了ティック +}); + // プロジェクトファイルのスキーマ export const projectSchema = z.object({ appVersion: z.string(), @@ -114,6 +120,11 @@ export const projectSchema = z.object({ timeSignatures: z.array(timeSignatureSchema), tracks: z.record(trackIdSchema, trackSchema), trackOrder: z.array(trackIdSchema), + loop: z.object({ + startTick: z.number(), + endTick: z.number(), + isLoopEnabled: z.boolean(), + }), }), }); diff --git a/src/sing/domain.ts b/src/sing/domain.ts index bbd4482977..94a3d4b7ee 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1025,3 +1025,23 @@ export const shouldPlayTracks = (tracks: Map): Set => { export function snapTicksToGrid(ticks: number, snapTicks: number): number { return Math.round(ticks / snapTicks) * snapTicks; } + +/* + * ループ範囲が有効かどうかを判定する + * @param startTick ループ開始位置(tick) + * @param endTick ループ終了位置(tick) + * @returns ループ範囲が有効な場合はtrue + */ +export const isValidLoopRange = ( + startTick: number, + endTick: number, +): boolean => { + return ( + // 負の値は許容しない + startTick >= 0 && + endTick >= 0 && + // 整数である必要がある + Number.isInteger(startTick) && + Number.isInteger(endTick) + ); +}; diff --git a/src/store/project.ts b/src/store/project.ts index 3e5a0862d4..6d10d46a01 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -59,7 +59,7 @@ const applySongProjectToStore = async ( actions: DotNotationDispatch, songProject: LatestProjectType["song"], ) => { - const { tpqn, tempos, timeSignatures, tracks, trackOrder } = songProject; + const { tpqn, tempos, timeSignatures, tracks, trackOrder, loop } = songProject; await actions.SET_TPQN({ tpqn }); await actions.SET_TEMPOS({ tempos }); @@ -73,6 +73,11 @@ const applySongProjectToStore = async ( }), ), }); + await actions.SET_LOOP_ENABLED({ isLoopEnabled: loop.isLoopEnabled }); + await actions.SET_LOOP_RANGE({ + loopStartTick: loop.startTick, + loopEndTick: loop.endTick, + }); }; export const projectStore = createPartialStore({ @@ -295,6 +300,9 @@ export const projectStore = createPartialStore({ timeSignatures, tracks, trackOrder, + isLoopEnabled, + loopStartTick, + loopEndTick, } = context.state; const projectData: LatestProjectType = { appVersion: appInfos.version, @@ -308,6 +316,11 @@ export const projectStore = createPartialStore({ timeSignatures, tracks: Object.fromEntries(tracks), trackOrder, + loop: { + isLoopEnabled, + startTick: loopStartTick, + endTick: loopEndTick, + }, }, }; diff --git a/src/store/singing.ts b/src/store/singing.ts index 5df2810978..189e2e200f 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -93,6 +93,7 @@ import { toEntirePhonemeTimings, adjustPhonemeTimingsAndPhraseEndFrames, phonemeTimingsToPhonemes, + isValidLoopRange, } from "@/sing/domain"; import { getOverlappingNoteIds } from "@/sing/storeHelper"; import { @@ -104,7 +105,7 @@ import { round, } from "@/sing/utility"; import { getWorkaroundKeyRangeAdjustment } from "@/sing/workaroundKeyRangeAdjustment"; -import { createLogger } from "@/helpers/log"; +import { createLogger } from "@/domain/frontend/log"; import { noteSchema } from "@/domain/project/schema"; import { getOrThrow } from "@/helpers/mapHelper"; import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; @@ -758,6 +759,9 @@ export const singingStoreState: SingingStoreState = { exportState: "NOT_EXPORTING", cancellationOfExportRequested: false, isSongSidebarOpen: false, + isLoopEnabled: false, + loopStartTick: 0, + loopEndTick: 0, }; export const singingStore = createPartialStore({ @@ -1488,6 +1492,21 @@ export const singingStore = createPartialStore({ } mutations.SET_PLAYBACK_STATE({ nowPlaying: true }); + // TODO: 以下の処理(ループの設定)は再生開始時に毎回行う必要はないので、 + // ソングエディタ初期化時に1回だけ行うようにする + // NOTE: 初期化のactionを作った方が良いかも + transport.loop = state.isLoopEnabled; + transport.loopStartTime = tickToSecond( + state.loopStartTick, + state.tempos, + state.tpqn, + ); + transport.loopEndTime = tickToSecond( + state.loopEndTick, + state.tempos, + state.tpqn, + ); + transport.start(); animationTimer.start(() => { playheadPosition.value = getters.SECOND_TO_TICK(transport.time); @@ -3448,6 +3467,54 @@ export const singingStore = createPartialStore({ }, }, + SET_LOOP_ENABLED: { + mutation(state, { isLoopEnabled }) { + state.isLoopEnabled = isLoopEnabled; + }, + async action({ mutations }, { isLoopEnabled }) { + if (!transport) { + throw new Error("transport is undefined"); + } + mutations.SET_LOOP_ENABLED({ isLoopEnabled }); + transport.loop = isLoopEnabled; + }, + }, + + SET_LOOP_RANGE: { + mutation(state, { loopStartTick, loopEndTick }) { + state.loopStartTick = loopStartTick; + state.loopEndTick = loopEndTick; + }, + async action({ state, mutations }, { loopStartTick, loopEndTick }) { + if (!transport) { + throw new Error("transport is undefined"); + } + + if (!isValidLoopRange(loopStartTick, loopEndTick)) { + throw new Error("The loop range is invalid."); + } + + mutations.SET_LOOP_RANGE({ loopStartTick, loopEndTick }); + + transport.loopStartTime = tickToSecond( + loopStartTick, + state.tempos, + state.tpqn, + ); + transport.loopEndTime = tickToSecond( + loopEndTick, + state.tempos, + state.tpqn, + ); + }, + }, + + CLEAR_LOOP_RANGE: { + action({ mutations }) { + mutations.SET_LOOP_RANGE({ loopStartTick: 0, loopEndTick: 0 }); + }, + }, + EXPORT_SONG_PROJECT: { action: createUILockAction( async ( @@ -4049,6 +4116,36 @@ export const singingCommandStore = transformCommandStore( }, ), }, + COMMAND_SET_LOOP_ENABLED: { + mutation(draft, { isLoopEnabled }) { + singingStore.mutations.SET_LOOP_ENABLED(draft, { isLoopEnabled }); + }, + action({ mutations }, { isLoopEnabled }) { + mutations.COMMAND_SET_LOOP_ENABLED({ isLoopEnabled }); + }, + }, + COMMAND_SET_LOOP_RANGE: { + mutation(draft, { loopStartTick, loopEndTick }) { + singingStore.mutations.SET_LOOP_RANGE(draft, { + loopStartTick, + loopEndTick, + }); + }, + action({ mutations }, { loopStartTick, loopEndTick }) { + mutations.COMMAND_SET_LOOP_RANGE({ loopStartTick, loopEndTick }); + }, + }, + COMMAND_CLEAR_LOOP_RANGE: { + mutation(draft) { + singingStore.mutations.SET_LOOP_RANGE(draft, { + loopStartTick: 0, + loopEndTick: 0, + }); + }, + action({ mutations }) { + mutations.COMMAND_CLEAR_LOOP_RANGE(); + }, + }, }), "song", ); diff --git a/src/store/type.ts b/src/store/type.ts index a57e243881..a6115340e9 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -70,6 +70,7 @@ import { tempoSchema, timeSignatureSchema, trackSchema, + loopSchema, } from "@/domain/project/schema"; import { HotkeySettingType } from "@/domain/hotkeyAction"; import { @@ -760,6 +761,8 @@ export type Singer = z.infer; export type Track = z.infer; +export type Loop = z.infer; + export type PhraseState = | "SINGER_IS_NOT_SET" | "WAITING_TO_BE_RENDERED" @@ -907,6 +910,9 @@ export type SingingStoreState = { exportState: SongExportState; cancellationOfExportRequested: boolean; isSongSidebarOpen: boolean; + isLoopEnabled: boolean; + loopStartTick: number; + loopEndTick: number; }; export type SingingStoreTypes = { @@ -1381,6 +1387,20 @@ export type SingingStoreTypes = { action(payload: { device: string }): void; }; + SET_LOOP_ENABLED: { + mutation: { isLoopEnabled: boolean }; + action(payload: { isLoopEnabled: boolean }): void; + }; + + SET_LOOP_RANGE: { + mutation: { loopStartTick: number; loopEndTick: number }; + action(payload: { loopStartTick: number; loopEndTick: number }): void; + }; + + CLEAR_LOOP_RANGE: { + action(): void; + }; + EXPORT_SONG_PROJECT: { action(payload: { fileType: ExportSongProjectFileType; @@ -1547,6 +1567,21 @@ export type SingingCommandStoreTypes = { trackIndexes: number[]; }): void; }; + + COMMAND_SET_LOOP_ENABLED: { + mutation: { isLoopEnabled: boolean }; + action(payload: { isLoopEnabled: boolean }): void; + }; + + COMMAND_SET_LOOP_RANGE: { + mutation: { loopStartTick: number; loopEndTick: number }; + action(payload: { loopStartTick: number; loopEndTick: number }): void; + }; + + COMMAND_CLEAR_LOOP_RANGE: { + mutation: undefined; + action(): void; + }; }; /* From c16da4b074dadceba529c899b08c1b5b5801fb85 Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Thu, 3 Oct 2024 01:28:29 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/useLoopControl.ts | 1 + src/sing/audioRendering.ts | 122 +++++++++++++++++++++++++++--- src/store/project.ts | 3 +- 3 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 src/composables/useLoopControl.ts diff --git a/src/composables/useLoopControl.ts b/src/composables/useLoopControl.ts new file mode 100644 index 0000000000..41ea66f287 --- /dev/null +++ b/src/composables/useLoopControl.ts @@ -0,0 +1 @@ +// storeに依存しない \ No newline at end of file diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index c96ef4d531..911ed36b5f 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -56,6 +56,10 @@ interface EventScheduler { * 再生、停止、再生位置の変更などの機能を提供します。 */ export class Transport { + loop = false; + loopStartTime = 0; + loopEndTime = 0; + private readonly audioContext: AudioContext; private readonly timer: Timer; private readonly scheduleAheadTime: number; @@ -67,6 +71,12 @@ export class Transport { private startContextTime = 0; private startTime = 0; private schedulers = new Map(); + private scheduledContextTime = 0; + private uncompletedLoopInfos: { + readonly contextTime: number; + readonly timeBeforeLoop: number; + readonly timeAfterLoop: number; + }[] = []; get state() { return this._state; @@ -77,10 +87,8 @@ export class Transport { */ get time() { if (this._state === "started") { - // 再生中の場合は、現在時刻から再生位置を計算する const contextTime = this.audioContext.currentTime; - const elapsedTime = contextTime - this.startContextTime; - this._time = this.startTime + elapsedTime; + this._time = this.calcTime(contextTime); } return this._time; } @@ -113,13 +121,38 @@ export class Transport { this.audioContext = audioContext; this.scheduleAheadTime = scheduleAheadTime; this.timer = new Timer(lookahead * 1000); + this.timer.start(() => { if (this._state === "started") { - this.schedule(this.audioContext.currentTime); + this.scheduleEvents(this.audioContext.currentTime); } }); } + /** + * 再生位置を計算します。再生中にのみ使用可能です。 + * @param contextTime コンテキスト時刻(この時刻から再生位置を計算) + * @returns 計算された再生位置(秒) + */ + private calcTime(contextTime: number) { + if (this._state !== "started") { + throw new Error("This method can only be used during playback."); + } + if (contextTime >= this.startContextTime) { + const elapsedTime = contextTime - this.startContextTime; + return this.startTime + elapsedTime; + } + while (this.uncompletedLoopInfos.length !== 0) { + const loopInfo = this.uncompletedLoopInfos[0]; + if (contextTime < loopInfo.contextTime) { + const timeUntilLoop = loopInfo.contextTime - contextTime; + return loopInfo.timeBeforeLoop - timeUntilLoop; + } + this.uncompletedLoopInfos.shift(); + } + throw new Error("Loop events are not scheduled correctly."); + } + /** * スケジューラーを作成します。 * @param sequence スケジューラーでスケジューリングを行うシーケンス @@ -138,13 +171,15 @@ export class Transport { } /** - * スケジューリングを行います。 + * シーケンスのイベントのスケジューリングを行います。 * @param contextTime スケジューリングを行う時刻(コンテキスト時刻) */ - private schedule(contextTime: number) { - // 再生位置を計算 - const elapsedTime = contextTime - this.startContextTime; - const time = this.startTime + elapsedTime; + private scheduleSequenceEvents(contextTime: number) { + if (contextTime < this.startContextTime) { + // NOTE: ループ未完了の場合にここに来る + return; + } + const time = this.calcTime(contextTime); // シーケンスの削除を反映 const removedSequences: Sequence[] = []; @@ -167,11 +202,73 @@ export class Transport { } }); + // スケジューリングを行う this.schedulers.forEach((scheduler) => { scheduler.schedule(time + this.scheduleAheadTime); }); } + /** + * ループイベントのスケジューリングを行います。 + * @param contextTime スケジューリングを行う時刻(コンテキスト時刻) + */ + private scheduleLoopEvents(contextTime: number) { + if ( + !this.loop || + this.loopEndTime <= this.loopStartTime || + this.startTime >= this.loopEndTime + ) { + return; + } + + const timeUntilLoop = this.loopEndTime - this.startTime; + let contextTimeToLoop = this.startContextTime + timeUntilLoop; + if (contextTimeToLoop < this.scheduledContextTime) { + return; + } + if (contextTimeToLoop < contextTime) { + contextTimeToLoop = contextTime; + } + + const loopDuration = this.loopEndTime - this.loopStartTime; + + while (contextTimeToLoop < contextTime + this.scheduleAheadTime) { + this.uncompletedLoopInfos.push({ + contextTime: contextTimeToLoop, + timeBeforeLoop: this.loopEndTime, + timeAfterLoop: this.loopStartTime, + }); + + this.startContextTime = contextTimeToLoop; + this.startTime = this.loopStartTime; + + this.schedulers.forEach((value) => { + value.stop(contextTimeToLoop); + }); + this.schedulers.clear(); + + this.sequences.forEach((sequence) => { + const scheduler = this.createScheduler(sequence); + scheduler.start(contextTimeToLoop, this.loopStartTime); + scheduler.schedule(this.loopStartTime + this.scheduleAheadTime); + this.schedulers.set(sequence, scheduler); + }); + + contextTimeToLoop += loopDuration; + } + } + + /** + * イベントのスケジューリングを行います。 + * @param contextTime スケジューリングを行う時刻(コンテキスト時刻) + */ + private scheduleEvents(contextTime: number) { + this.scheduleSequenceEvents(contextTime); + this.scheduleLoopEvents(contextTime); + + this.scheduledContextTime = contextTime + this.scheduleAheadTime; + } + /** * シーケンスを追加します。再生中に追加した場合は、次のスケジューリングで反映されます。 * @param sequence 追加するシーケンス @@ -205,8 +302,10 @@ export class Transport { this.startContextTime = contextTime; this.startTime = this._time; + this.scheduledContextTime = contextTime; + this.uncompletedLoopInfos = []; - this.schedule(contextTime); + this.scheduleEvents(contextTime); } /** @@ -217,8 +316,7 @@ export class Transport { const contextTime = this.audioContext.currentTime; // 停止する前に再生位置を更新する - const elapsedTime = contextTime - this.startContextTime; - this._time = this.startTime + elapsedTime; + this._time = this.calcTime(contextTime); this._state = "stopped"; diff --git a/src/store/project.ts b/src/store/project.ts index 6d10d46a01..651edb74d4 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -59,7 +59,8 @@ const applySongProjectToStore = async ( actions: DotNotationDispatch, songProject: LatestProjectType["song"], ) => { - const { tpqn, tempos, timeSignatures, tracks, trackOrder, loop } = songProject; + const { tpqn, tempos, timeSignatures, tracks, trackOrder, loop } = + songProject; await actions.SET_TPQN({ tpqn }); await actions.SET_TEMPOS({ tempos }); From 63bfded8daf58c2c37d29d6d1d922a463199098e Mon Sep 17 00:00:00 2001 From: Romot Date: Tue, 21 Jan 2025 05:00:43 +0900 Subject: [PATCH 03/13] =?UTF-8?q?LoopLane=E3=82=92=E3=83=9E=E3=83=BC?= =?UTF-8?q?=E3=82=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sing/SequencerRuler/Container.vue | 44 ++++++++- .../Sing/SequencerRuler/LoopLane.vue | 92 +++++++++---------- .../Sing/SequencerRuler/Presentation.vue | 32 ++++++- src/composables/useLoopControl.ts | 1 - src/store/singing.ts | 2 +- 5 files changed, 119 insertions(+), 52 deletions(-) delete mode 100644 src/composables/useLoopControl.ts diff --git a/src/components/Sing/SequencerRuler/Container.vue b/src/components/Sing/SequencerRuler/Container.vue index 07111d281f..625fcb7ce8 100644 --- a/src/components/Sing/SequencerRuler/Container.vue +++ b/src/components/Sing/SequencerRuler/Container.vue @@ -9,12 +9,19 @@ :uiLocked :playheadTicks :sequencerSnapType + :isLoopEnabled + :loopStartTick + :loopEndTick @update:playheadTicks="updatePlayheadTicks" @removeTempo="removeTempo" @removeTimeSignature="removeTimeSignature" @setTempo="setTempo" @setTimeSignature="setTimeSignature" @deselectAllNotes="deselectAllNotes" + @setLoopEnabled="setLoopEnabled" + @setLoopRange="setLoopRange" + @clearLoopRange="clearLoopRange" + @addOneMeasureLoop="addOneMeasureLoop" /> @@ -23,12 +30,14 @@ import { computed } from "vue"; import Presentation from "./Presentation.vue"; import { useStore } from "@/store"; import { Tempo, TimeSignature } from "@/store/type"; +import { snapTicksToGrid, getNoteDuration } from "@/sing/domain"; +import { baseXToTick } from "@/sing/viewHelper"; defineOptions({ name: "SequencerRuler", }); -withDefaults( +const props = withDefaults( defineProps<{ offset: number; numMeasures: number; @@ -50,6 +59,10 @@ const sequencerSnapType = computed(() => store.state.sequencerSnapType); const playheadTicks = computed(() => store.getters.PLAYHEAD_POSITION); +const isLoopEnabled = computed(() => store.state.isLoopEnabled); +const loopStartTick = computed(() => store.state.loopStartTick); +const loopEndTick = computed(() => store.state.loopEndTick); + const updatePlayheadTicks = (ticks: number) => { void store.actions.SET_PLAYHEAD_POSITION({ position: ticks }); }; @@ -78,4 +91,33 @@ const removeTimeSignature = (measureNumber: number) => { measureNumber, }); }; + +const setLoopEnabled = (enabled: boolean) => { + void store.actions.COMMAND_SET_LOOP_ENABLED({ isLoopEnabled: enabled }); +}; + +const setLoopRange = (start: number, end: number) => { + void store.actions.COMMAND_SET_LOOP_RANGE({ + loopStartTick: start, + loopEndTick: end, + }); +}; + +const clearLoopRange = () => { + void store.actions.COMMAND_CLEAR_LOOP_RANGE(); +}; + +const addOneMeasureLoop = (clickX: number) => { + const timeSignature = store.state.timeSignatures[0]; + const oneMeasureTicks = + getNoteDuration(timeSignature.beatType, tpqn.value) * timeSignature.beats; + const baseX = (props.offset + clickX) / sequencerZoomX.value; + const cursorTick = baseXToTick(baseX, tpqn.value); + const startTick = snapTicksToGrid(cursorTick, oneMeasureTicks); + const endTick = snapTicksToGrid(startTick + oneMeasureTicks, oneMeasureTicks); + void store.actions.COMMAND_SET_LOOP_RANGE({ + loopStartTick: startTick, + loopEndTick: endTick, + }); +}; diff --git a/src/components/Sing/SequencerRuler/LoopLane.vue b/src/components/Sing/SequencerRuler/LoopLane.vue index af592e45cd..e04bc607e8 100644 --- a/src/components/Sing/SequencerRuler/LoopLane.vue +++ b/src/components/Sing/SequencerRuler/LoopLane.vue @@ -1,6 +1,6 @@ diff --git a/src/components/Sing/SequencerRuler/GridLane/Container.vue b/src/components/Sing/SequencerRuler/GridLane/Container.vue new file mode 100644 index 0000000000..7844304dbe --- /dev/null +++ b/src/components/Sing/SequencerRuler/GridLane/Container.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/Sing/SequencerRuler/GridLane/Presentation.vue b/src/components/Sing/SequencerRuler/GridLane/Presentation.vue new file mode 100644 index 0000000000..bff0aabbde --- /dev/null +++ b/src/components/Sing/SequencerRuler/GridLane/Presentation.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/src/components/Sing/SequencerRuler/LoopLane/Presentation.vue b/src/components/Sing/SequencerRuler/LoopLane/Presentation.vue index 3d9a8fef44..1c06c659d8 100644 --- a/src/components/Sing/SequencerRuler/LoopLane/Presentation.vue +++ b/src/components/Sing/SequencerRuler/LoopLane/Presentation.vue @@ -7,6 +7,7 @@ 'is-empty': isEmpty, [cursorClass]: true, }" + :style="{ width: `${width}px` }" @click.stop @contextmenu.prevent="handleContextMenu" > @@ -95,6 +96,10 @@ import ContextMenu, { ContextMenuItemData, } from "@/components/Menu/ContextMenu/Presentation.vue"; +defineOptions({ + name: "LoopLanePresentation", +}); + defineProps<{ width: number; offset: number; diff --git a/src/components/Sing/SequencerRuler/LoopLane/index.stories.ts b/src/components/Sing/SequencerRuler/LoopLane/index.stories.ts new file mode 100644 index 0000000000..ff3e60e47a --- /dev/null +++ b/src/components/Sing/SequencerRuler/LoopLane/index.stories.ts @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/vue3"; +import { fn } from "@storybook/test"; + +import Presentation from "./Presentation.vue"; + +const meta: Meta = { + component: Presentation, + args: { + width: 1000, + offset: 0, + loopStartX: 100, + loopEndX: 300, + isLoopEnabled: true, + isDragging: false, + isEmpty: false, + cursorClass: "", + contextMenuData: [], + onLoopAreaMouseDown: fn(), + onLoopRangeClick: fn(), + onLoopRangeDoubleClick: fn(), + onStartHandleMouseDown: fn(), + onEndHandleMouseDown: fn(), + onContextMenu: fn(), + }, + render: (args) => ({ + components: { Presentation }, + setup() { + return { args }; + }, + template: `
`, + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "デフォルト", + args: {}, +}; + +export const Disabled: Story = { + name: "無効状態", + args: { + isLoopEnabled: false, + }, +}; + +export const Empty: Story = { + name: "空の状態", + args: { + isEmpty: true, + loopStartX: 0, + loopEndX: 0, + }, +}; + +export const DraggingEnabled: Story = { + name: "ドラッグ中(有効)", + args: { + isLoopEnabled: true, + isDragging: true, + cursorClass: "cursor-ew-resize", + }, +}; + +export const DraggingDisabled: Story = { + name: "ドラッグ中(無効)", + args: { + isLoopEnabled: false, + isDragging: true, + cursorClass: "cursor-ew-resize", + }, +}; + +export const LongLoop: Story = { + name: "長いループ範囲", + args: { + loopStartX: 100, + loopEndX: 800, + }, +}; + +export const ShortLoop: Story = { + name: "短いループ範囲", + args: { + loopStartX: 100, + loopEndX: 150, + }, +}; diff --git a/src/components/Sing/SequencerRuler/Presentation.vue b/src/components/Sing/SequencerRuler/Presentation.vue index c13edddb36..d388038b88 100644 --- a/src/components/Sing/SequencerRuler/Presentation.vue +++ b/src/components/Sing/SequencerRuler/Presentation.vue @@ -5,133 +5,25 @@ @click="onClick" @contextmenu="onContextMenu" > - - - - - - - - - - - - - - - - {{ measureInfo.number }} - - - - +
+ /> diff --git a/src/components/Sing/SequencerRuler/ValueMarkerLane/Container.vue b/src/components/Sing/SequencerRuler/ValueMarkerLane/Container.vue new file mode 100644 index 0000000000..549963c7de --- /dev/null +++ b/src/components/Sing/SequencerRuler/ValueMarkerLane/Container.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/components/Sing/SequencerRuler/ValueMarkerLane/Presentation.vue b/src/components/Sing/SequencerRuler/ValueMarkerLane/Presentation.vue new file mode 100644 index 0000000000..eb1d56d66d --- /dev/null +++ b/src/components/Sing/SequencerRuler/ValueMarkerLane/Presentation.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap b/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap index 87315b839a..e5cd8d76de 100644 --- a/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap +++ b/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap @@ -61,7 +61,7 @@ exports[`createOpenAPIEngineMock > audioQueryAudioQueryPost 1`] = ` } `; -exports[`createOpenAPIEngineMock > frameSynthesisFrameSynthesisPost 1`] = `"394cfbc01397e0b6fcc3433d9537aa850e6131f7d89048da6889e7375fe03a24"`; +exports[`createOpenAPIEngineMock > frameSynthesisFrameSynthesisPost 1`] = `"ee4783d9a5336de861eba3b49702d18110927f4339e7a40257cc6717e84d6a3d"`; exports[`createOpenAPIEngineMock > singFrameAudioQuerySingFrameAudioQueryPost 1`] = ` { From 426cf3d7d68c2c7013003ec352b515d06a93e770 Mon Sep 17 00:00:00 2001 From: Hiroshiba Date: Tue, 28 Jan 2025 01:44:59 +0900 Subject: [PATCH 09/13] fix: update snapshot for frameSynthesisFrameSynthesisPost test --- tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap b/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap index e5cd8d76de..87315b839a 100644 --- a/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap +++ b/tests/unit/mock/engineMock/__snapshots__/index.spec.ts.snap @@ -61,7 +61,7 @@ exports[`createOpenAPIEngineMock > audioQueryAudioQueryPost 1`] = ` } `; -exports[`createOpenAPIEngineMock > frameSynthesisFrameSynthesisPost 1`] = `"ee4783d9a5336de861eba3b49702d18110927f4339e7a40257cc6717e84d6a3d"`; +exports[`createOpenAPIEngineMock > frameSynthesisFrameSynthesisPost 1`] = `"394cfbc01397e0b6fcc3433d9537aa850e6131f7d89048da6889e7375fe03a24"`; exports[`createOpenAPIEngineMock > singFrameAudioQuerySingFrameAudioQueryPost 1`] = ` { From 88822b2c8cc1d0af720c4ad0fa5b1ffcda701bbc Mon Sep 17 00:00:00 2001 From: Romot Date: Tue, 28 Jan 2025 19:45:59 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=E3=83=AA=E3=83=95=E3=82=A1=E3=82=AF?= =?UTF-8?q?=E3=82=BF=E3=83=AA=E3=83=B3=E3=82=B0=E3=81=8A=E3=82=88=E3=81=B3?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88?= =?UTF-8?q?=E5=88=86=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sing/SequencerRuler/Container.vue | 123 +++--- .../SequencerRuler/GridLane/Container.vue | 38 +- .../SequencerRuler/GridLane/Presentation.vue | 30 +- .../SequencerRuler/LoopLane/Container.vue | 125 +++--- .../SequencerRuler/LoopLane/index.stories.ts | 3 + .../Sing/SequencerRuler/Presentation.vue | 94 ++--- .../ValueChangesLane/Container.vue | 357 ++++++++++++++++++ .../ValueChangesLane/Presentation.vue | 241 ++++++++++++ .../ValueMarkerLane/Container.vue | 100 ----- .../ValueMarkerLane/Presentation.vue | 191 ---------- .../Sing/SequencerRuler/index.stories.ts | 24 +- src/composables/useSequencerRuler.ts | 85 +++++ 12 files changed, 890 insertions(+), 521 deletions(-) create mode 100644 src/components/Sing/SequencerRuler/ValueChangesLane/Container.vue create mode 100644 src/components/Sing/SequencerRuler/ValueChangesLane/Presentation.vue delete mode 100644 src/components/Sing/SequencerRuler/ValueMarkerLane/Container.vue delete mode 100644 src/components/Sing/SequencerRuler/ValueMarkerLane/Presentation.vue create mode 100644 src/composables/useSequencerRuler.ts diff --git a/src/components/Sing/SequencerRuler/Container.vue b/src/components/Sing/SequencerRuler/Container.vue index 564cdef48e..dcfeab2fb1 100644 --- a/src/components/Sing/SequencerRuler/Container.vue +++ b/src/components/Sing/SequencerRuler/Container.vue @@ -1,42 +1,40 @@ @@ -44,12 +42,10 @@ import { computed } from "vue"; import Presentation from "./Presentation.vue"; import GridLaneContainer from "./GridLane/Container.vue"; -import ValueMarkerLaneContainer from "./ValueMarkerLane/Container.vue"; +import ValueChangesLaneContainer from "./ValueChangesLane/Container.vue"; import LoopLaneContainer from "./LoopLane/Container.vue"; import { useStore } from "@/store"; -import { Tempo, TimeSignature } from "@/store/type"; -import { tickToBaseX } from "@/sing/viewHelper"; -import { getTimeSignaturePositions, getMeasureDuration } from "@/sing/domain"; +import { useSequencerRuler } from "@/composables/useSequencerRuler"; defineOptions({ name: "SequencerRuler", @@ -69,14 +65,25 @@ const props = withDefaults( const store = useStore(); const tpqn = computed(() => store.state.tpqn); -const tempos = computed(() => store.state.tempos); const timeSignatures = computed(() => store.state.timeSignatures); +const tempos = computed(() => store.state.tempos); 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); +const uiLocked = computed(() => store.getters.UI_LOCKED); +// ルーラーおよび内部レーンで共通化した計算ロジック +const { width, playheadX, getSnappedTickFromOffsetX } = useSequencerRuler({ + offset: computed(() => props.offset), + numMeasures: computed(() => props.numMeasures), + tpqn, + timeSignatures, + sequencerZoomX, + playheadTicks, + sequencerSnapType, +}); + +// NOTE: usePlayheadPositionができたら再生ヘッド周辺を置き換える const updatePlayheadTicks = (ticks: number) => { void store.actions.SET_PLAYHEAD_POSITION({ position: ticks }); }; @@ -84,46 +91,4 @@ 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, - }); -}; - -const width = computed(() => { - const lastTs = timeSignatures.value[timeSignatures.value.length - 1]; - const tsPositions = getTimeSignaturePositions( - timeSignatures.value, - tpqn.value, - ); - const lastTsPosition = tsPositions[tsPositions.length - 1]; - const measureDuration = getMeasureDuration( - lastTs.beats, - lastTs.beatType, - tpqn.value, - ); - const endTicks = - lastTsPosition + - measureDuration * (props.numMeasures - lastTs.measureNumber + 1); - return tickToBaseX(endTicks, tpqn.value) * sequencerZoomX.value; -}); diff --git a/src/components/Sing/SequencerRuler/GridLane/Container.vue b/src/components/Sing/SequencerRuler/GridLane/Container.vue index 7844304dbe..e22543bb25 100644 --- a/src/components/Sing/SequencerRuler/GridLane/Container.vue +++ b/src/components/Sing/SequencerRuler/GridLane/Container.vue @@ -1,20 +1,46 @@ diff --git a/src/components/Sing/SequencerRuler/GridLane/Presentation.vue b/src/components/Sing/SequencerRuler/GridLane/Presentation.vue index bff0aabbde..27fe1d201e 100644 --- a/src/components/Sing/SequencerRuler/GridLane/Presentation.vue +++ b/src/components/Sing/SequencerRuler/GridLane/Presentation.vue @@ -61,7 +61,7 @@ import { computed, ref } from "vue"; import { TimeSignature } from "@/store/type"; import { useSequencerGrid } from "@/composables/useSequencerGridPattern"; -import { getMeasureDuration, getTimeSignaturePositions } from "@/sing/domain"; +import { getMeasureDuration } from "@/sing/domain"; import { tickToBaseX } from "@/sing/viewHelper"; defineOptions({ @@ -74,28 +74,13 @@ const props = defineProps<{ numMeasures: number; timeSignatures: TimeSignature[]; offset: number; + width: number; + endTicks: number; + tsPositions: number[]; }>(); const height = ref(40); -const tsPositions = computed(() => { - return getTimeSignaturePositions(props.timeSignatures, props.tpqn); -}); - -const endTicks = computed(() => { - const lastTs = props.timeSignatures[props.timeSignatures.length - 1]; - const lastTsPosition = tsPositions.value[tsPositions.value.length - 1]; - return ( - lastTsPosition + - getMeasureDuration(lastTs.beats, lastTs.beatType, props.tpqn) * - (props.numMeasures - lastTs.measureNumber + 1) - ); -}); - -const width = computed(() => { - return tickToBaseX(endTicks.value, props.tpqn) * props.sequencerZoomX; -}); - const gridPatterns = useSequencerGrid({ timeSignatures: computed(() => props.timeSignatures), tpqn: computed(() => props.tpqn), @@ -112,9 +97,9 @@ const measureInfos = computed(() => { ); const nextTsPosition = i !== props.timeSignatures.length - 1 - ? tsPositions.value[i + 1] - : endTicks.value; - const start = tsPositions.value[i]; + ? props.tsPositions[i + 1] + : props.endTicks; + const start = props.tsPositions[i]; const end = nextTsPosition; const numMeasures = Math.floor((end - start) / measureDuration); return Array.from({ length: numMeasures }, (_, index) => { @@ -144,6 +129,7 @@ const measureInfos = computed(() => { stroke: var(--scheme-color-sing-ruler-measure-line); stroke-width: 1px; + // NOTE: 最初の小節線を非表示。必要に応じて再表示・位置合わせする &.first-measure-line { stroke: var(--scheme-color-sing-ruler-surface); } diff --git a/src/components/Sing/SequencerRuler/LoopLane/Container.vue b/src/components/Sing/SequencerRuler/LoopLane/Container.vue index 000fc63a50..9246f6cca4 100644 --- a/src/components/Sing/SequencerRuler/LoopLane/Container.vue +++ b/src/components/Sing/SequencerRuler/LoopLane/Container.vue @@ -1,16 +1,14 @@