From 89695e8f3c7dde6244e74955d5b992e045a77c65 Mon Sep 17 00:00:00 2001 From: Romot Date: Tue, 1 Oct 2024 19:16:40 +0900 Subject: [PATCH 01/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=82=92?= =?UTF-8?q?=E8=A9=A6=E8=A1=8C=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 168 +++++++++++++++++++ src/components/Sing/SequencerRuler.vue | 46 ++++- src/composables/useLoopControl.ts | 65 +++++++ src/sing/audioRendering.ts | 48 +++++- src/store/singing.ts | 73 ++++++++ src/store/type.ts | 27 +++ 6 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 src/components/Sing/SequencerLoopControl.vue create mode 100644 src/composables/useLoopControl.ts diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue new file mode 100644 index 0000000000..94be57271e --- /dev/null +++ b/src/components/Sing/SequencerLoopControl.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/components/Sing/SequencerRuler.vue b/src/components/Sing/SequencerRuler.vue index 9ddae01849..202933e1a7 100644 --- a/src/components/Sing/SequencerRuler.vue +++ b/src/components/Sing/SequencerRuler.vue @@ -1,5 +1,10 @@ diff --git a/src/composables/useLoopControl.ts b/src/composables/useLoopControl.ts new file mode 100644 index 0000000000..2fcfb0be8b --- /dev/null +++ b/src/composables/useLoopControl.ts @@ -0,0 +1,65 @@ +// useLoopControl.ts +import { computed } from "vue"; +import { useStore } from "@/store"; +import { tickToSecond } from "@/sing/domain"; + +export function useLoopControl() { + const store = useStore(); + + const isLoopEnabled = computed({ + get: () => store.state.isLoopEnabled, + set: (value) => store.commit("SET_LOOP_ENABLED", { isLoopEnabled: value }), + }); + + const loopStartTick = computed({ + get: () => store.state.loopStartTick, + set: (value) => store.commit("SET_LOOP_START", { loopStartTick: value }), + }); + + const loopEndTick = computed({ + get: () => store.state.loopEndTick, + set: (value) => store.commit("SET_LOOP_END", { loopEndTick: value }), + }); + + const setLoopEnabled = (value: boolean) => { + void store.dispatch("SET_LOOP_ENABLED", { isLoopEnabled: value }); + }; + + const setLoopRange = (startTick: number, endTick: number) => { + void store.dispatch("SET_LOOP_RANGE", { + loopStartTick: startTick, + loopEndTick: endTick, + }); + }; + + const loopStartTime = computed(() => + tickToSecond(loopStartTick.value, store.state.tempos, store.state.tpqn), + ); + + const loopEndTime = computed(() => + tickToSecond(loopEndTick.value, store.state.tempos, store.state.tpqn), + ); + + const handleLoop = (currentTime: number) => { + if (!isLoopEnabled.value || loopEndTick.value <= 0) return currentTime; + + if (currentTime >= loopEndTime.value) { + return ( + loopStartTime.value + + ((currentTime - loopStartTime.value) % + (loopEndTime.value - loopStartTime.value)) + ); + } + + return currentTime; + }; + + return { + isLoopEnabled, + loopStartTick, + loopEndTick, + setLoopEnabled, + setLoopRange, + handleLoop, + }; +} diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index c96ef4d531..86ce4956a9 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -2,8 +2,10 @@ import { noteNumberToFrequency, decibelToLinear, linearToDecibel, + tickToSecond, } from "@/sing/domain"; import { Timer } from "@/sing/utility"; +import { Tempo } from "@/store/type"; const getEarliestSchedulableContextTime = (audioContext: BaseAudioContext) => { const renderQuantumSize = 128; @@ -68,6 +70,24 @@ export class Transport { private startTime = 0; private schedulers = new Map(); + // ループ設定 + // TODO: いったん動作するようにする + private isLoopEnabled = false; + private loopStartTime = 0; + private loopEndTime = 0; + + setLoopSettings( + isLoopEnabled: boolean, + startTick: number, + endTick: number, + tempos: Tempo[], + tpqn: number, + ) { + this.isLoopEnabled = isLoopEnabled; + this.loopStartTime = tickToSecond(startTick, tempos, tpqn); + this.loopEndTime = tickToSecond(endTick, tempos, tpqn); + } + get state() { return this._state; } @@ -144,8 +164,29 @@ export class Transport { private schedule(contextTime: number) { // 再生位置を計算 const elapsedTime = contextTime - this.startContextTime; - const time = this.startTime + elapsedTime; - + let time = this.startTime + elapsedTime; + + // ループ処理 + // TODO: いったん動作するようにする + if (this.isLoopEnabled && time >= this.loopEndTime) { + const loopDuration = this.loopEndTime - this.loopStartTime; + // おそらくscheduleAheadTimeを考慮する必要ある + time = this.loopStartTime + ((time - this.loopStartTime) % loopDuration); + this.startTime = time; + this.startContextTime = contextTime; + // スケジューラーをループ後に初期化して再スケジューリングする...ダメな気がする + // うまく使い回すほうがよさそうだが、動作があまり理解できていない... + // クリアしないとループ中に行った変更が反映されないように思えるが違う? + this.schedulers.forEach((scheduler) => { + scheduler.stop(contextTime); + }); + this.schedulers.clear(); + this.sequences.forEach((sequence) => { + const scheduler = this.createScheduler(sequence); + scheduler.start(contextTime, time); + this.schedulers.set(sequence, scheduler); + }); + } // シーケンスの削除を反映 const removedSequences: Sequence[] = []; this.schedulers.forEach((scheduler, sequence) => { @@ -157,7 +198,6 @@ export class Transport { removedSequences.forEach((sequence) => { this.schedulers.delete(sequence); }); - // シーケンスの追加を反映 this.sequences.forEach((sequence) => { if (!this.schedulers.has(sequence)) { @@ -166,10 +206,10 @@ export class Transport { this.schedulers.set(sequence, scheduler); } }); - this.schedulers.forEach((scheduler) => { scheduler.schedule(time + this.scheduleAheadTime); }); + this._time = time; } /** diff --git a/src/store/singing.ts b/src/store/singing.ts index d35e61994f..d38f89f682 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -482,6 +482,9 @@ export const singingStoreState: SingingStoreState = { nowAudioExporting: false, cancellationOfAudioExportRequested: false, isSongSidebarOpen: false, + isLoopEnabled: true, + loopStartTick: 0, + loopEndTick: 0, }; export const singingStore = createPartialStore({ @@ -1162,6 +1165,14 @@ export const singingStore = createPartialStore({ } mutations.SET_PLAYBACK_STATE({ nowPlaying: true }); + transport.setLoopSettings( + state.isLoopEnabled, + state.loopStartTick, + state.loopEndTick, + state.tempos, + state.tpqn, + ); + transport.start(); animationTimer.start(() => { playheadPosition.value = getters.GET_PLAYHEAD_POSITION(); @@ -2443,6 +2454,68 @@ export const singingStore = createPartialStore({ return Math.max(1, lastNoteEndTime + 1); }, }, + + SET_LOOP_ENABLED: { + mutation(state, { isLoopEnabled }) { + state.isLoopEnabled = isLoopEnabled; + }, + action({ mutations }, { isLoopEnabled }) { + mutations.SET_LOOP_ENABLED({ isLoopEnabled }); + }, + }, + + SET_LOOP_RANGE: { + mutation(state, { loopStartTick, loopEndTick }) { + if (loopStartTick >= loopEndTick) { + throw new Error("Loop start must be before loop end"); + } + state.loopStartTick = loopStartTick; + state.loopEndTick = loopEndTick; + }, + action({ mutations, state }, { loopStartTick, loopEndTick }) { + mutations.SET_LOOP_RANGE({ loopStartTick, loopEndTick }); + if (transport) { + // TODO: いったん動作するようにする + transport.setLoopSettings( + true, + loopStartTick, + loopEndTick, + state.tempos, + state.tpqn, + ); + } + }, + }, + + SET_LOOP_START: { + mutation(state, { loopStartTick }) { + if (loopStartTick >= state.loopEndTick) { + throw new Error("Loop start must be before loop end"); + } + state.loopStartTick = loopStartTick; + }, + action({ mutations }, { loopStartTick }) { + mutations.SET_LOOP_START({ loopStartTick }); + }, + }, + + SET_LOOP_END: { + mutation(state, { loopEndTick }) { + if (loopEndTick <= state.loopStartTick) { + throw new Error("Loop end must be after loop start"); + } + state.loopEndTick = loopEndTick; + }, + action({ mutations }, { loopEndTick }) { + mutations.SET_LOOP_END({ loopEndTick }); + }, + }, + + TOGGLE_LOOP: { + action({ state, mutations }) { + mutations.SET_LOOP_ENABLED({ isLoopEnabled: !state.isLoopEnabled }); + }, + }, }); export const singingCommandStoreState: SingingCommandStoreState = {}; diff --git a/src/store/type.ts b/src/store/type.ts index 4aab3297ca..d57eb0a7bc 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -840,6 +840,9 @@ export type SingingStoreState = { nowAudioExporting: boolean; cancellationOfAudioExportRequested: boolean; isSongSidebarOpen: boolean; + isLoopEnabled: boolean; + loopStartTick: number; + loopEndTick: number; }; export type SingingStoreTypes = { @@ -1264,6 +1267,30 @@ export type SingingStoreTypes = { SYNC_TRACKS_AND_TRACK_CHANNEL_STRIPS: { action(): 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; + }; + + SET_LOOP_START: { + mutation: { loopStartTick: number }; + action(payload: { loopStartTick: number }): void; + }; + + SET_LOOP_END: { + mutation: { loopEndTick: number }; + action(payload: { loopEndTick: number }): void; + }; + + TOGGLE_LOOP: { + action(): void; + }; }; export type SingingCommandStoreState = { From 728b3b81a4b902797b47ef7749d7c29d08475df1 Mon Sep 17 00:00:00 2001 From: Romot Date: Wed, 2 Oct 2024 02:05:28 +0900 Subject: [PATCH 02/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=82=A8?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=81=AE=E5=9F=BA=E6=9C=AC=E7=9A=84=E3=81=AA?= =?UTF-8?q?=E5=8B=95=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerGrid.vue | 35 ++++ src/components/Sing/SequencerLoopControl.vue | 182 +++++++++++-------- src/components/Sing/SequencerRuler.vue | 52 +++++- 3 files changed, 188 insertions(+), 81 deletions(-) diff --git a/src/components/Sing/SequencerGrid.vue b/src/components/Sing/SequencerGrid.vue index 41f2e16697..ade917792e 100644 --- a/src/components/Sing/SequencerGrid.vue +++ b/src/components/Sing/SequencerGrid.vue @@ -83,6 +83,27 @@ :y2="gridHeight" class="sequencer-grid-measure-line" /> + + + + + + + @@ -91,8 +112,10 @@ import { computed } from "vue"; import { useStore } from "@/store"; import { keyInfos, getKeyBaseHeight, tickToBaseX } from "@/sing/viewHelper"; import { getMeasureDuration, getNoteDuration } from "@/sing/domain"; +import { useLoopControl } from "@/composables/useLoopControl"; const store = useStore(); +const { isLoopEnabled, loopStartTick, loopEndTick } = useLoopControl(); const tpqn = computed(() => store.state.tpqn); const timeSignatures = computed(() => store.state.timeSignatures); const zoomX = computed(() => store.state.sequencerZoomX); @@ -165,6 +188,13 @@ const snapLinePositions = computed(() => { return (currentTick / measureTicks) * measureWidth.value; }); }); +// ループのX座標を計算 +const loopStartX = computed(() => { + return tickToBaseX(loopStartTick.value, tpqn.value) * zoomX.value; +}); +const loopEndX = computed(() => { + return tickToBaseX(loopEndTick.value, tpqn.value) * zoomX.value; +}); diff --git a/src/components/Sing/SequencerRuler.vue b/src/components/Sing/SequencerRuler.vue index 202933e1a7..8b67ad5163 100644 --- a/src/components/Sing/SequencerRuler.vue +++ b/src/components/Sing/SequencerRuler.vue @@ -7,7 +7,7 @@ > @@ -31,7 +31,35 @@ /> - + + + + + + + + + - + @@ -72,6 +100,7 @@ import { useStore } from "@/store"; import { getMeasureDuration, getTimeSignaturePositions } from "@/sing/domain"; import { baseXToTick, tickToBaseX } from "@/sing/viewHelper"; import SequencerLoopControl from "@/components/Sing/SequencerLoopControl.vue"; +import { useLoopControl } from "@/composables/useLoopControl"; // ループコントロールのインポート const props = withDefaults( defineProps<{ @@ -85,6 +114,7 @@ const props = withDefaults( ); const store = useStore(); +const { isLoopEnabled, loopStartTick, loopEndTick } = useLoopControl(); // ループ情報の取得 const state = store.state; const height = ref(40); const playheadTicks = ref(0); @@ -112,7 +142,7 @@ const endTicks = computed(() => { (props.numMeasures - lastTs.measureNumber + 1) ); }); -const width = computed(() => { +const gridWidth = computed(() => { return tickToBaseX(endTicks.value, tpqn.value) * zoomX.value; }); const measureInfos = computed(() => { @@ -145,6 +175,14 @@ const playheadX = computed(() => { return Math.floor(baseX * zoomX.value); }); +// ループのX座標を計算 +const loopStartX = computed(() => { + return tickToBaseX(loopStartTick.value, tpqn.value) * zoomX.value; +}); +const loopEndX = computed(() => { + return tickToBaseX(loopEndTick.value, tpqn.value) * zoomX.value; +}); + const sequencerRuler = ref(null); const loopControl = ref | null>(null); @@ -162,7 +200,6 @@ const onClick = (event: MouseEvent) => { const onContextMenu = (event: MouseEvent) => { event.preventDefault(); - loopControl.value?.toggleLoop(event.offsetX); }; let resizeObserver: ResizeObserver | undefined; @@ -270,4 +307,9 @@ watch(zoomX, (newZoom, oldZoom) => { height: 1px; background-color: var(--scheme-color-sing-ruler-border); } + +.sequencer-ruler-loop-mask { + fill: var(--scheme-color-sing-surface-container); + opacity: 0.3; +} From c954ae0479aac701a4f641a67f03942f1c9eed36 Mon Sep 17 00:00:00 2001 From: Romot Date: Wed, 2 Oct 2024 09:30:44 +0900 Subject: [PATCH 03/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=82=BA=E3=83=BC=E3=83=A0=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerGrid.vue | 8 ++-- src/components/Sing/SequencerLoopControl.vue | 13 ++++--- src/components/Sing/SequencerRuler.vue | 39 ++++++-------------- 3 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/components/Sing/SequencerGrid.vue b/src/components/Sing/SequencerGrid.vue index ade917792e..ecd5ef6c1a 100644 --- a/src/components/Sing/SequencerGrid.vue +++ b/src/components/Sing/SequencerGrid.vue @@ -84,7 +84,7 @@ class="sequencer-grid-measure-line" /> - + { } .sequencer-grid-loop-mask { - fill: var(--scheme-color-sing-surface-container); - opacity: 0.3; + position: relative; + fill: var(--scheme-color-scrim); + opacity: 0.16; + pointer-events: none; } .edit-pitch { diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index cbc3c10e47..998309d45b 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -52,7 +52,7 @@
+
{ + if (!isLoopEnabled.value) return; + event.preventDefault(); const target = event.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const x = event.clientX - rect.left + props.offset; @@ -103,7 +103,7 @@ const snapToGrid = (tick: number): number => { }; const startDragging = (target: "start" | "end", event: MouseEvent) => { - event.preventDefault(); + if (!isLoopEnabled.value) return; isDragging.value = true; dragTarget.value = target; dragStartX.value = event.clientX; @@ -184,15 +184,24 @@ onUnmounted(() => { height: 24px; pointer-events: auto; cursor: pointer; + + &.disabled { + opacity: 0.38; + pointer-events: none; + cursor: default; + } } .loop-range { fill: var(--scheme-color-primary); } +.disabled .loop-range { + fill: var(--scheme-color-outline); +} + .loop-handle { fill: var(--scheme-color-primary); - pointer-events: none; stroke: var(--scheme-color-primary); stroke-width: 2; stroke-linejoin: round; @@ -203,10 +212,20 @@ onUnmounted(() => { } } +.disabled .loop-handle { + fill: var(--scheme-color-outline); + stroke: var(--scheme-color-outline); +} + .loop-drag-area { height: 28px; fill: transparent; cursor: ew-resize; pointer-events: all; } + +.disabled .loop-drag-area { + pointer-events: none; + cursor: default; +} diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index 8eaef4a4a6..bdde5cbecb 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -114,6 +114,15 @@ icon="stop" @click="stop" /> +
{{ playheadPositionMinSecStr }}
@@ -168,6 +177,7 @@ import { computed, watch, ref, onMounted, onUnmounted } from "vue"; import EditTargetSwicher from "./EditTargetSwicher.vue"; import { useStore } from "@/store"; +import { useLoopControl } from "@/composables/useLoopControl"; import { getSnapTypes, @@ -426,6 +436,11 @@ const goToZero = () => { void store.dispatch("SET_PLAYHEAD_POSITION", { position: 0 }); }; +const { isLoopEnabled, setLoopEnabled } = useLoopControl(); +const toggleLoop = () => { + setLoopEnabled(!isLoopEnabled.value); +}; + const volume = computed({ get() { return store.state.volume * 100; @@ -717,6 +732,15 @@ onUnmounted(() => { } } +.sing-playback-loop { + margin-left: 4px; + &.q-btn--active, + &.loop-enabled { + color: var(--scheme-color-primary); + background: var(--scheme-color-secondary-container); + } +} + .sing-playhead-position { align-items: center; display: flex; From eb14753383e01c12c0121dce8ceaff9a7ad514e5 Mon Sep 17 00:00:00 2001 From: Romot Date: Mon, 7 Oct 2024 22:35:56 +0900 Subject: [PATCH 09/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=83=9C?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=82=92=E8=AA=BF=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/ToolBar/ToolBar.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index bdde5cbecb..b2be429aea 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -733,12 +733,16 @@ onUnmounted(() => { } .sing-playback-loop { - margin-left: 4px; + margin-left: 6px; &.q-btn--active, &.loop-enabled { color: var(--scheme-color-primary); background: var(--scheme-color-secondary-container); } + + :deep(.q-icon) { + font-size: 20px; + } } .sing-playhead-position { From ddeeaf22a81d6222f828efadee322d19ad229918 Mon Sep 17 00:00:00 2001 From: Romot Date: Wed, 16 Oct 2024 01:17:33 +0900 Subject: [PATCH 10/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E7=84=A1?= =?UTF-8?q?=E5=8A=B9=E6=99=82=E3=82=82=E7=AF=84=E5=9B=B2=E3=81=AF=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 7c1c9dbf05..e5baed3f40 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -1,8 +1,5 @@ @@ -291,8 +301,22 @@ onUnmounted(() => { background-color: var(--scheme-color-sing-ruler-border); } +.sequencer-ruler-loop-mask-container { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + .sequencer-ruler-loop-mask { fill: var(--scheme-color-scrim); +} + +:root[is-dark-theme="false"] .sequencer-ruler-loop-mask { + opacity: 0.12; +} + +:root[is-dark-theme="true"] .sequencer-ruler-loop-mask { opacity: 0.38; } diff --git a/src/store/singing.ts b/src/store/singing.ts index a33febd26c..798f5e3cfc 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -2483,10 +2483,7 @@ export const singingStore = createPartialStore({ state.loopStartTick = loopStartTick; state.loopEndTick = loopEndTick; }, - async action( - { mutations, state, actions }, - { loopStartTick, loopEndTick }, - ) { + async action({ mutations, state }, { loopStartTick, loopEndTick }) { if (!transport) { throw new Error("transport is undefined."); } @@ -2504,7 +2501,7 @@ export const singingStore = createPartialStore({ ); // ループの開始位置に移動する - await actions.SET_PLAYHEAD_POSITION({ position: loopStartTick }); + // await actions.SET_PLAYHEAD_POSITION({ position: state.loopStartTick }); }, }, @@ -2515,7 +2512,7 @@ export const singingStore = createPartialStore({ } state.loopStartTick = loopStartTick; }, - action({ mutations, state }, { loopStartTick }) { + async action({ mutations, state }, { loopStartTick }) { if (!transport) { throw new Error("transport is undefined."); } From f594378dc0fe8b79ac576ee2e00ea52f86160c2d Mon Sep 17 00:00:00 2001 From: Romot Date: Fri, 18 Oct 2024 21:41:18 +0900 Subject: [PATCH 13/24] =?UTF-8?q?=E5=8B=95=E4=BD=9C=E3=81=8A=E3=82=88?= =?UTF-8?q?=E3=81=B3=E8=A1=A8=E7=A4=BA=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerGrid.vue | 4 +- src/components/Sing/SequencerLoopControl.vue | 133 +++++++++++++------ src/components/Sing/SequencerRuler.vue | 11 +- src/composables/useLoopControl.ts | 78 +++++------ src/store/singing.ts | 56 -------- src/store/type.ts | 14 -- 6 files changed, 141 insertions(+), 155 deletions(-) diff --git a/src/components/Sing/SequencerGrid.vue b/src/components/Sing/SequencerGrid.vue index 87ee030ffb..dce86f95f5 100644 --- a/src/components/Sing/SequencerGrid.vue +++ b/src/components/Sing/SequencerGrid.vue @@ -255,11 +255,11 @@ const loopEndX = computed(() => { } :root[is-dark-theme="false"] .sequencer-grid-loop-mask { - opacity: 0.08; + opacity: 0.05; } :root[is-dark-theme="true"] .sequencer-grid-loop-mask { - opacity: 0.38; + opacity: 0.24; } .edit-pitch { diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 0881138fd3..e79ea6d1db 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -22,7 +22,7 @@ :width="props.width" height="12" class="loop-area" - @mousedown.stop="addLoop($event)" + @mousedown.stop="onLoopAreaMouseDown" @mouseup.stop /> @@ -32,7 +32,15 @@ :width="loopEndX - loopStartX" :height="8" class="loop-range" - @click.stop="toggleLoop" + @click.stop="onLoopRangeClick" + /> +
@@ -214,17 +257,25 @@ onUnmounted(() => { &:not(.cursor-ew-resize) { cursor: pointer; } + + &.loop-dragging .loop-range-visible { + fill: var(--scheme-color-outline-variant); + } } .loop-area { - fill: var(--scheme-color-sing-ruler-surface); + fill: transparent; } .loop-range { + fill: transparent; +} + +.loop-range-visible { fill: var(--scheme-color-primary-fixed-dim); } -.loop-disabled .loop-range { +.loop-disabled .loop-range-visible { fill: var(--scheme-color-outline); } diff --git a/src/components/Sing/SequencerRuler.vue b/src/components/Sing/SequencerRuler.vue index 605b01558a..52be0a56cc 100644 --- a/src/components/Sing/SequencerRuler.vue +++ b/src/components/Sing/SequencerRuler.vue @@ -62,7 +62,12 @@ - + { } :root[is-dark-theme="false"] .sequencer-ruler-loop-mask { - opacity: 0.12; + opacity: 0.08; } :root[is-dark-theme="true"] .sequencer-ruler-loop-mask { - opacity: 0.38; + opacity: 0.24; } diff --git a/src/composables/useLoopControl.ts b/src/composables/useLoopControl.ts index 2fcfb0be8b..346a99e9e5 100644 --- a/src/composables/useLoopControl.ts +++ b/src/composables/useLoopControl.ts @@ -1,4 +1,3 @@ -// useLoopControl.ts import { computed } from "vue"; import { useStore } from "@/store"; import { tickToSecond } from "@/sing/domain"; @@ -6,60 +5,61 @@ import { tickToSecond } from "@/sing/domain"; export function useLoopControl() { const store = useStore(); - const isLoopEnabled = computed({ - get: () => store.state.isLoopEnabled, - set: (value) => store.commit("SET_LOOP_ENABLED", { isLoopEnabled: value }), - }); + const isLoopEnabled = computed(() => store.state.isLoopEnabled); + const loopStartTick = computed(() => store.state.loopStartTick); + const loopEndTick = computed(() => store.state.loopEndTick); - const loopStartTick = computed({ - get: () => store.state.loopStartTick, - set: (value) => store.commit("SET_LOOP_START", { loopStartTick: value }), + const loopStartTime = computed(() => { + if (loopStartTick.value == null) return null; + return tickToSecond( + loopStartTick.value, + store.state.tempos, + store.state.tpqn, + ); }); - const loopEndTick = computed({ - get: () => store.state.loopEndTick, - set: (value) => store.commit("SET_LOOP_END", { loopEndTick: value }), + const loopEndTime = computed(() => { + if (loopEndTick.value == null) return null; + return tickToSecond( + loopEndTick.value, + store.state.tempos, + store.state.tpqn, + ); }); - const setLoopEnabled = (value: boolean) => { - void store.dispatch("SET_LOOP_ENABLED", { isLoopEnabled: value }); - }; - - const setLoopRange = (startTick: number, endTick: number) => { - void store.dispatch("SET_LOOP_RANGE", { - loopStartTick: startTick, - loopEndTick: endTick, - }); + const setLoopEnabled = async (value: boolean): Promise => { + try { + await store.dispatch("SET_LOOP_ENABLED", { isLoopEnabled: value }); + } catch (error) { + throw new Error("Failed to set loop enabled state"); + } }; - const loopStartTime = computed(() => - tickToSecond(loopStartTick.value, store.state.tempos, store.state.tpqn), - ); - - const loopEndTime = computed(() => - tickToSecond(loopEndTick.value, store.state.tempos, store.state.tpqn), - ); - - const handleLoop = (currentTime: number) => { - if (!isLoopEnabled.value || loopEndTick.value <= 0) return currentTime; - - if (currentTime >= loopEndTime.value) { - return ( - loopStartTime.value + - ((currentTime - loopStartTime.value) % - (loopEndTime.value - loopStartTime.value)) - ); + const setLoopRange = async ( + startTick: number, + endTick: number, + ): Promise => { + if (startTick < 0 || endTick < startTick) { + throw new Error("Invalid loop range"); } - return currentTime; + try { + await store.dispatch("SET_LOOP_RANGE", { + loopStartTick: startTick, + loopEndTick: endTick, + }); + } catch (error) { + throw new Error("Failed to set loop range"); + } }; return { isLoopEnabled, loopStartTick, loopEndTick, + loopStartTime, + loopEndTime, setLoopEnabled, setLoopRange, - handleLoop, }; } diff --git a/src/store/singing.ts b/src/store/singing.ts index 798f5e3cfc..ced84efef4 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -2499,62 +2499,6 @@ export const singingStore = createPartialStore({ state.tempos, state.tpqn, ); - - // ループの開始位置に移動する - // await actions.SET_PLAYHEAD_POSITION({ position: state.loopStartTick }); - }, - }, - - SET_LOOP_START: { - mutation(state, { loopStartTick }) { - if (loopStartTick >= state.loopEndTick) { - throw new Error("Loop start must be before loop end"); - } - state.loopStartTick = loopStartTick; - }, - async action({ mutations, state }, { loopStartTick }) { - if (!transport) { - throw new Error("transport is undefined."); - } - mutations.SET_LOOP_START({ loopStartTick }); - - transport.loopStartTime = tickToSecond( - state.loopStartTick, - state.tempos, - state.tpqn, - ); - }, - }, - - SET_LOOP_END: { - mutation(state, { loopEndTick }) { - if (loopEndTick <= state.loopStartTick) { - throw new Error("Loop end must be after loop start"); - } - state.loopEndTick = loopEndTick; - }, - async action({ mutations, state }, { loopEndTick }) { - if (!transport) { - throw new Error("transport is undefined."); - } - mutations.SET_LOOP_END({ loopEndTick }); - - transport.loopEndTime = tickToSecond( - state.loopEndTick, - state.tempos, - state.tpqn, - ); - }, - }, - - TOGGLE_LOOP: { - action({ state, mutations }) { - if (!transport) { - throw new Error("transport is undefined."); - } - mutations.SET_LOOP_ENABLED({ isLoopEnabled: !state.isLoopEnabled }); - - transport.loop = state.isLoopEnabled; }, }, }); diff --git a/src/store/type.ts b/src/store/type.ts index d57eb0a7bc..db4e174731 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -1277,20 +1277,6 @@ export type SingingStoreTypes = { mutation: { loopStartTick: number; loopEndTick: number }; action(payload: { loopStartTick: number; loopEndTick: number }): void; }; - - SET_LOOP_START: { - mutation: { loopStartTick: number }; - action(payload: { loopStartTick: number }): void; - }; - - SET_LOOP_END: { - mutation: { loopEndTick: number }; - action(payload: { loopEndTick: number }): void; - }; - - TOGGLE_LOOP: { - action(): void; - }; }; export type SingingCommandStoreState = { From e9a9800f873074de4ba5cac39c0d95d3f72af3cd Mon Sep 17 00:00:00 2001 From: Romot Date: Sat, 19 Oct 2024 02:20:12 +0900 Subject: [PATCH 14/24] =?UTF-8?q?=E5=8B=95=E4=BD=9C=E3=81=8A=E3=82=88?= =?UTF-8?q?=E3=81=B3CSS=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 124 ++++++++++++++----- src/components/Sing/ToolBar/ToolBar.vue | 2 +- src/styles/v2/sing-colors.scss | 8 ++ 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index e79ea6d1db..335563633e 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -2,12 +2,13 @@
+
@@ -83,6 +87,9 @@ import { useLoopControl } from "@/composables/useLoopControl"; import { useCursorState, CursorState } from "@/composables/useCursorState"; import { tickToBaseX, baseXToTick } from "@/sing/viewHelper"; import { getNoteDuration } from "@/sing/domain"; +import ContextMenu, { + ContextMenuItemData, +} from "@/components/Menu/ContextMenu.vue"; const props = defineProps<{ width: number; @@ -133,10 +140,10 @@ const adjustedHeight = computed(() => ); const onLoopAreaMouseDown = (event: MouseEvent) => { + if (event.button !== 0 || (event.ctrlKey && event.button === 0)) return; if (isDragging.value) { void stopDragging(); } - if (event.button !== 0) return; const target = event.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const x = event.clientX - rect.left + props.offset; @@ -163,8 +170,14 @@ const onEndHandleMouseDown = (event: MouseEvent) => { startDragging("end", event); }; +const onHandleDoubleClick = () => { + // ハンドルのダブルクリックでループを0地点に設定する + void setLoopRange(0, 0); +}; + // ドラッグ開始処理 const startDragging = (target: "start" | "end", event: MouseEvent) => { + if (event.button !== 0) return; isDragging.value = true; dragTarget.value = target; dragStartX.value = event.clientX; @@ -178,6 +191,7 @@ const startDragging = (target: "start" | "end", event: MouseEvent) => { // ドラッグ中処理 const onDrag = (event: MouseEvent) => { if (!isDragging.value || !dragTarget.value) return; + if (event.button !== 0) return; // ドラッグ中のX座標 const dx = event.clientX - dragStartX.value; @@ -241,6 +255,34 @@ const stopDragging = async () => { window.removeEventListener("mouseup", stopDragging); }; +const onContextMenu = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; +const contextMenu = ref>(); +const contextMenuData = computed(() => { + return [ + { + type: "button", + label: isLoopEnabled.value ? "ループ無効" : "ループ有効", + onClick: () => { + contextMenu.value?.hide(); + void setLoopEnabled(!isLoopEnabled.value); + }, + disableWhenUiLocked: true, + }, + { + type: "button", + label: "ループを0地点にリセット", + onClick: () => { + contextMenu.value?.hide(); + void setLoopRange(0, 0); + }, + disableWhenUiLocked: true, + }, + ]; +}); + onUnmounted(() => { setCursorState(CursorState.UNSET); }); @@ -253,52 +295,78 @@ onUnmounted(() => { left: 0; width: 100%; pointer-events: auto; + cursor: pointer; - &:not(.cursor-ew-resize) { - cursor: pointer; + &.cursor-ew-resize { + cursor: ew-resize; } - &.loop-dragging .loop-range-visible { - fill: var(--scheme-color-outline-variant); + // ホバー時のループエリア + &:hover .loop-area { + fill: var(--scheme-color-sing-loop-area); } } +// ループエリア .loop-area { fill: transparent; + transition: fill 0.1s ease-out; } +// ループ範囲 .loop-range { - fill: transparent; -} - -.loop-range-visible { - fill: var(--scheme-color-primary-fixed-dim); -} + &-area { + fill: transparent; + } -.loop-disabled .loop-range-visible { fill: var(--scheme-color-outline); + opacity: 1; } +// ループハンドル .loop-handle { - fill: var(--scheme-color-primary-fixed-dim); - stroke: var(--scheme-color-primary-fixed-dim); - stroke-width: 2; + fill: var(--scheme-color-outline); + stroke: var(--scheme-color-outline); + stroke-width: 0; stroke-linejoin: round; - &.loop-handle-disabled { - fill: var(--scheme-color-secondary); - stroke: var(--scheme-color-secondary); + &-no-length { + opacity: 0.5; } } -.loop-disabled .loop-handle { - fill: var(--scheme-color-outline); - stroke: var(--scheme-color-outline); -} - +// ドラッグエリア .loop-drag-area { fill: transparent; cursor: ew-resize; pointer-events: all; } + +// ドラッグ中の状態 +.loop-dragging { + .loop-area { + fill: var(--scheme-color-sing-loop-area); + } + + .loop-range { + opacity: 0.6; + } +} + +// ループが有効な状態 +.loop-enabled { + .loop-range { + fill: var(--scheme-color-primary-fixed-dim); + } + + .loop-handle { + fill: var(--scheme-color-primary-fixed-dim); + stroke: var(--scheme-color-primary-fixed-dim); + + &-no-length { + fill: var(--scheme-color-outline); + stroke: var(--scheme-color-outline); + } + } +} diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index b2be429aea..ae0ef298e9 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -438,7 +438,7 @@ const goToZero = () => { const { isLoopEnabled, setLoopEnabled } = useLoopControl(); const toggleLoop = () => { - setLoopEnabled(!isLoopEnabled.value); + void setLoopEnabled(!isLoopEnabled.value); }; const volume = computed({ diff --git a/src/styles/v2/sing-colors.scss b/src/styles/v2/sing-colors.scss index 96c4189d30..ab741b62d8 100644 --- a/src/styles/v2/sing-colors.scss +++ b/src/styles/v2/sing-colors.scss @@ -408,6 +408,10 @@ SASSのmapなどで構造化+mixin一括処理などで処理可能ですが、 --scheme-color-sing-shadow-note: oklch( var(--lr-84) var(--neutral-variant-c) var(--neutral-variant-h) ); + + --scheme-color-sing-loop-area: oklch( + var(--lr-86) var(--neutral-variant-c) var(--neutral-variant-h) + ); } /* ダークテーマ */ @@ -671,4 +675,8 @@ SASSのmapなどで構造化+mixin一括処理などで処理可能ですが、 --scheme-color-sing-shadow-note: oklch( var(--lr-40) var(--neutral-variant-c) var(--neutral-variant-h) ); + + --scheme-color-sing-loop-area: oklch( + var(--lr-34) var(--neutral-variant-c) var(--neutral-variant-h) + ); } From 950d1155d1e158a10df6e67e222d7308be0b2fec Mon Sep 17 00:00:00 2001 From: Romot Date: Sat, 19 Oct 2024 11:09:58 +0900 Subject: [PATCH 15/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=82=A8?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=81=AE=E8=A6=8B=E3=81=9F=E7=9B=AE=E8=AA=BF?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 60 ++++++++++---------- src/components/Sing/ToolBar/ToolBar.vue | 4 +- src/styles/v2/sing-colors.scss | 4 +- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 335563633e..cd803f66f2 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -22,6 +22,8 @@ y="0" :width="props.width" height="12" + rx="6" + ry="6" class="loop-area" @mousedown.stop="onLoopAreaMouseDown" @mouseup.stop @@ -30,30 +32,34 @@ { // ループ範囲 .loop-range { + fill: var(--scheme-color-outline); + opacity: 1; + &-area { fill: transparent; } - - fill: var(--scheme-color-outline); - opacity: 1; } // ループハンドル .loop-handle { fill: var(--scheme-color-outline); stroke: var(--scheme-color-outline); - stroke-width: 0; + stroke-width: 2px; stroke-linejoin: round; + stroke-linecap: round; + + &:hover { + fill: var(--scheme-color-secondary-container); + } &-no-length { - opacity: 0.5; + fill: var(--scheme-color-outline); + stroke: var(--scheme-color-outline); } } @@ -341,18 +353,6 @@ onUnmounted(() => { cursor: ew-resize; pointer-events: all; } - -// ドラッグ中の状態 -.loop-dragging { - .loop-area { - fill: var(--scheme-color-sing-loop-area); - } - - .loop-range { - opacity: 0.6; - } -} - // ループが有効な状態 .loop-enabled { .loop-range { @@ -362,11 +362,13 @@ onUnmounted(() => { .loop-handle { fill: var(--scheme-color-primary-fixed-dim); stroke: var(--scheme-color-primary-fixed-dim); + } +} - &-no-length { - fill: var(--scheme-color-outline); - stroke: var(--scheme-color-outline); - } +// ドラッグ中の状態 +.loop-dragging { + .loop-area { + opacity: 0.6; } } diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index ae0ef298e9..b00d49f2dd 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -736,12 +736,12 @@ onUnmounted(() => { margin-left: 6px; &.q-btn--active, &.loop-enabled { - color: var(--scheme-color-primary); + color: var(--scheme-color-on-secondary-container); background: var(--scheme-color-secondary-container); } :deep(.q-icon) { - font-size: 20px; + font-size: 18px; } } diff --git a/src/styles/v2/sing-colors.scss b/src/styles/v2/sing-colors.scss index ab741b62d8..e049e49517 100644 --- a/src/styles/v2/sing-colors.scss +++ b/src/styles/v2/sing-colors.scss @@ -410,7 +410,7 @@ SASSのmapなどで構造化+mixin一括処理などで処理可能ですが、 ); --scheme-color-sing-loop-area: oklch( - var(--lr-86) var(--neutral-variant-c) var(--neutral-variant-h) + var(--lr-84) var(--neutral-variant-c) var(--neutral-variant-h) ); } @@ -677,6 +677,6 @@ SASSのmapなどで構造化+mixin一括処理などで処理可能ですが、 ); --scheme-color-sing-loop-area: oklch( - var(--lr-34) var(--neutral-variant-c) var(--neutral-variant-h) + var(--lr-36) var(--neutral-variant-c) var(--neutral-variant-h) ); } From a357ceaf3b475e2e04141468bab72dd55bbf0096 Mon Sep 17 00:00:00 2001 From: Romot Date: Sat, 19 Oct 2024 20:15:49 +0900 Subject: [PATCH 16/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AF?= =?UTF-8?q?=E3=83=87=E3=83=95=E3=82=A9=E3=83=AB=E3=83=88=E3=81=A7=E3=82=AA?= =?UTF-8?q?=E3=83=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/singing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/singing.ts b/src/store/singing.ts index ced84efef4..97f3663b41 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -482,7 +482,7 @@ export const singingStoreState: SingingStoreState = { nowAudioExporting: false, cancellationOfAudioExportRequested: false, isSongSidebarOpen: false, - isLoopEnabled: true, + isLoopEnabled: false, loopStartTick: 0, loopEndTick: 0, }; From 5266acb1cb7fa33bcb984f2430bb8e8bd3defa57 Mon Sep 17 00:00:00 2001 From: Romot Date: Mon, 21 Oct 2024 19:31:33 +0900 Subject: [PATCH 17/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E3=81=A8=E8=BF=BD=E5=8A=A0=E3=83=A1=E3=83=8B=E3=83=A5?= =?UTF-8?q?=E3=83=BC(=E4=BB=95=E6=8E=9B=E3=81=8B=E3=82=8A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 83 +++++++++++++++++--- src/domain/project/schema.ts | 7 ++ src/sing/domain.ts | 15 ++++ src/store/project.ts | 17 ++++ src/store/singing.ts | 7 +- src/store/type.ts | 3 + 6 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index cd803f66f2..43a3f6be57 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -4,6 +4,7 @@ :class="{ 'loop-enabled': isLoopEnabled, 'loop-dragging': isDragging, + 'loop-no-length': loopStartTick === loopEndTick, [cursorClass]: true, }" :style="{ height: adjustedHeight + 'px' }" @@ -53,6 +54,7 @@ class="loop-handle loop-start-handle" :class="{ 'loop-handle-no-length': loopStartTick === loopEndTick }" vector-effect="non-scaling-stroke" + @mousedown.stop="onStartHandleMouseDown" /> (null); const dragStartX = ref(0); // ドラッグ開始時のハンドル位置 const dragStartHandleX = ref(0); +// コンテキストメニューの表示位置 +const contextMenuPosition = ref(null); // ドラッグエリアの高さ // ドラッグ中の操作を容易にするためループ高さをルーラーと同一にする const adjustedHeight = computed(() => @@ -190,8 +195,8 @@ const startDragging = (target: "start" | "end", event: MouseEvent) => { dragStartHandleX.value = target === "start" ? loopStartX.value : loopEndX.value; setCursorState(CursorState.EW_RESIZE); - window.addEventListener("mousemove", onDrag); - window.addEventListener("mouseup", stopDragging); + window.addEventListener("mousemove", onDrag, true); + window.addEventListener("mouseup", stopDragging, true); }; // ドラッグ中処理 @@ -242,6 +247,7 @@ const onDrag = (event: MouseEvent) => { // ドラッグ終了処理 const stopDragging = async () => { + if (!isDragging.value) return; // ドラッグでループ範囲を設定していた場合にplayheadをループの開始位置に移動する const isPlayheadToLoopStart = isDragging.value && loopStartTick.value !== loopEndTick.value; @@ -257,13 +263,27 @@ const stopDragging = async () => { isDragging.value = false; dragTarget.value = null; setCursorState(CursorState.UNSET); - window.removeEventListener("mousemove", onDrag); - window.removeEventListener("mouseup", stopDragging); + window.removeEventListener("mousemove", onDrag, true); + window.removeEventListener("mouseup", stopDragging, true); +}; + +// コンテキストメニュー位置に1小節のループ範囲を作成する +const addOneMeasureLoop = (x: number) => { + const timeSignature = store.state.timeSignatures[0]; + const oneMeasureTicks = + getNoteDuration(timeSignature.beatType, tpqn.value) * timeSignature.beats; + const baseX = (props.offset + x) / sequencerZoomX.value; + const cursorTick = baseXToTick(baseX, tpqn.value); + const startTick = snapToGrid(cursorTick); + const endTick = snapToGrid(startTick + oneMeasureTicks); + void setLoopRange(startTick, endTick); }; const onContextMenu = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + contextMenuPosition.value = event.clientX - rect.left; }; const contextMenu = ref>(); const contextMenuData = computed(() => { @@ -279,11 +299,24 @@ const contextMenuData = computed(() => { }, { type: "button", - label: "ループを0地点にリセット", + label: "ループ範囲を作成", + onClick: () => { + contextMenu.value?.hide(); + if (contextMenuPosition.value) { + addOneMeasureLoop(contextMenuPosition.value); + } + }, + disabled: !contextMenuPosition.value, + disableWhenUiLocked: true, + }, + { + type: "button", + label: "ループ範囲を削除", onClick: () => { contextMenu.value?.hide(); void setLoopRange(0, 0); }, + disabled: loopStartTick.value === loopEndTick.value, disableWhenUiLocked: true, }, ]; @@ -291,6 +324,8 @@ const contextMenuData = computed(() => { onUnmounted(() => { setCursorState(CursorState.UNSET); + window.removeEventListener("mousemove", onDrag, true); + window.removeEventListener("mouseup", stopDragging, true); }); @@ -337,10 +372,6 @@ onUnmounted(() => { stroke-linejoin: round; stroke-linecap: round; - &:hover { - fill: var(--scheme-color-secondary-container); - } - &-no-length { fill: var(--scheme-color-outline); stroke: var(--scheme-color-outline); @@ -366,9 +397,43 @@ onUnmounted(() => { } // ドラッグ中の状態 +// TODO: 色や表示など仮 .loop-dragging { .loop-area { opacity: 0.6; } + + .loop-range { + fill: var(--scheme-color-outline); + opacity: 0.38; + } + + .loop-handle { + fill: var(--scheme-color-tertiary-fixed); + stroke: var(--scheme-color-tertiary-fixed); + } +} + +// TODO: 仮: 削除の動作をシミュレート +.loop-no-length:not(.loop-dragging) { + .loop-range { + display: none; + } + + .loop-handle { + display: none; + } + + .loop-drag-area { + display: none; + } +} + +.loop-dragging.loop-no-length { + .loop-handle { + fill: var(--scheme-color-outline); + stroke: var(--scheme-color-outline); + opacity: 0.38; + } } diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts index c23a6812f1..86ae436992 100644 --- a/src/domain/project/schema.ts +++ b/src/domain/project/schema.ts @@ -98,6 +98,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(), @@ -113,6 +119,7 @@ export const projectSchema = z.object({ timeSignatures: z.array(timeSignatureSchema), tracks: z.record(trackIdSchema, trackSchema), trackOrder: z.array(trackIdSchema), + loop: loopSchema, }), }); diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 9acfef90c6..d07cdb046d 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -9,6 +9,7 @@ import { PhraseKey, Track, EditorFrameAudioQuery, + Loop, } from "@/store/type"; import { FramePhoneme } from "@/openapi"; import { TrackId } from "@/type/preload"; @@ -341,6 +342,20 @@ export function createDefaultTrack(): Track { }; } +export function createDefaultLoop(): Loop { + const defaultEndTick = getMeasureDuration( + DEFAULT_BEATS, + DEFAULT_BEAT_TYPE, + DEFAULT_TPQN, + ); + console.log("defaultEndTick", defaultEndTick); + return { + isLoopEnabled: false, + startTick: 0, + endTick: defaultEndTick, + }; +} + export function getSnapTypes(tpqn: number) { return getRepresentableNoteTypes(tpqn).filter((value) => { return value <= MAX_SNAP_TYPE; diff --git a/src/store/project.ts b/src/store/project.ts index a8188729a7..f3bf6a424d 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -22,6 +22,7 @@ import { createDefaultTempo, createDefaultTimeSignature, createDefaultTrack, + createDefaultLoop, DEFAULT_TPQN, } from "@/sing/domain"; import { EditorType } from "@/type/preload"; @@ -124,6 +125,14 @@ export const projectStore = createPartialStore({ await context.actions.SET_TIME_SIGNATURES({ timeSignatures: [createDefaultTimeSignature(1)], }); + const defaultLoop = createDefaultLoop(); + await context.actions.SET_LOOP_ENABLED({ + isLoopEnabled: defaultLoop.isLoopEnabled, + }); + await context.actions.SET_LOOP_RANGE({ + loopStartTick: defaultLoop.startTick, + loopEndTick: defaultLoop.endTick, + }); const trackId = TrackId(crypto.randomUUID()); await context.actions.SET_TRACKS({ tracks: new Map([[trackId, createDefaultTrack()]]), @@ -302,6 +311,9 @@ export const projectStore = createPartialStore({ timeSignatures, tracks, trackOrder, + isLoopEnabled, + loopStartTick, + loopEndTick, } = context.state; const projectData: LatestProjectType = { appVersion: appInfos.version, @@ -315,6 +327,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 97f3663b41..b0eeda4d94 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -77,6 +77,7 @@ import { createDefaultTrack, createDefaultTempo, createDefaultTimeSignature, + createDefaultLoop, isValidNotes, isValidTrack, SEQUENCER_MIN_NUM_MEASURES, @@ -482,9 +483,9 @@ export const singingStoreState: SingingStoreState = { nowAudioExporting: false, cancellationOfAudioExportRequested: false, isSongSidebarOpen: false, - isLoopEnabled: false, - loopStartTick: 0, - loopEndTick: 0, + isLoopEnabled: createDefaultLoop().isLoopEnabled, + loopStartTick: createDefaultLoop().startTick, + loopEndTick: createDefaultLoop().endTick, }; export const singingStore = createPartialStore({ diff --git a/src/store/type.ts b/src/store/type.ts index db4e174731..f2faf5637e 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -70,6 +70,7 @@ import { tempoSchema, timeSignatureSchema, trackSchema, + loopSchema, } from "@/domain/project/schema"; /** @@ -737,6 +738,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" From b1bfd40644ef07379c87392ea1464ac5a4e3cfd7 Mon Sep 17 00:00:00 2001 From: Romot Date: Wed, 23 Oct 2024 03:20:27 +0900 Subject: [PATCH 18/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=83=9C?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=81=A7=E5=88=9D=E6=9C=9F=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/ToolBar/ToolBar.vue | 32 ++++++++++++++++++++++--- src/domain/project/index.ts | 11 +++++++++ src/domain/project/schema.ts | 6 ++++- src/sing/domain.ts | 14 ----------- src/store/project.ts | 17 ++++++------- src/store/singing.ts | 7 +++--- 6 files changed, 55 insertions(+), 32 deletions(-) diff --git a/src/components/Sing/ToolBar/ToolBar.vue b/src/components/Sing/ToolBar/ToolBar.vue index a9ea7b3a82..1b7112b68c 100644 --- a/src/components/Sing/ToolBar/ToolBar.vue +++ b/src/components/Sing/ToolBar/ToolBar.vue @@ -190,6 +190,7 @@ import { import CharacterMenuButton from "@/components/Sing/CharacterMenuButton/MenuButton.vue"; import { useHotkeyManager } from "@/plugins/hotkeyPlugin"; import { SequencerEditTarget } from "@/store/type"; +import { getNoteDuration } from "@/sing/domain"; const store = useStore(); @@ -431,9 +432,34 @@ const goToZero = () => { void store.dispatch("SET_PLAYHEAD_POSITION", { position: 0 }); }; -const { isLoopEnabled, setLoopEnabled } = useLoopControl(); -const toggleLoop = () => { - void setLoopEnabled(!isLoopEnabled.value); +const { + isLoopEnabled, + setLoopEnabled, + setLoopRange, + loopStartTick, + loopEndTick, +} = useLoopControl(); + +const toggleLoop = async () => { + // ループが存在しない場合は見える範囲の1小節でループを作成 + if (loopStartTick.value === loopEndTick.value) { + const sequencerBodyElement = document.querySelector(".sequencer-body"); + if (!sequencerBodyElement) return; + const currentPosition = store.getters.GET_PLAYHEAD_POSITION(); + const timeSignature = store.state.timeSignatures[0]; + const oneMeasureTicks = + getNoteDuration(timeSignature.beatType, store.state.tpqn) * + timeSignature.beats; + const measureNumber = Math.floor(currentPosition / oneMeasureTicks); + const startTick = measureNumber * oneMeasureTicks; + const endTick = startTick + oneMeasureTicks; + + await setLoopRange(startTick, endTick); + await setLoopEnabled(true); + } else { + // すでにループが存在する場合は単純に有効/無効を切り替え + await setLoopEnabled(!isLoopEnabled.value); + } }; const volume = computed({ diff --git a/src/domain/project/index.ts b/src/domain/project/index.ts index 3f541af269..995d144c8e 100644 --- a/src/domain/project/index.ts +++ b/src/domain/project/index.ts @@ -302,6 +302,17 @@ export const migrateProjectFileObject = async ( projectData.song.trackOrder = Object.keys(newTracks); } + // FIXME: 0.21.0 のマイグレーション + //if (semver.satisfies(projectAppVersion, "<0.21.0", semverSatisfiesOptions)) { + if (!("loop" in projectData.song)) { + projectData.song.loop = { + startTick: 0, + endTick: 0, + isLoopEnabled: false, + }; + } + //} + // Validation check // トークはvalidateTalkProjectで検証する // ソングはSET_SCOREの中の`isValidScore`関数で検証される diff --git a/src/domain/project/schema.ts b/src/domain/project/schema.ts index 86ae436992..a4d499ec39 100644 --- a/src/domain/project/schema.ts +++ b/src/domain/project/schema.ts @@ -119,7 +119,11 @@ export const projectSchema = z.object({ timeSignatures: z.array(timeSignatureSchema), tracks: z.record(trackIdSchema, trackSchema), trackOrder: z.array(trackIdSchema), - loop: loopSchema, + 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 d07cdb046d..e7c008f398 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -342,20 +342,6 @@ export function createDefaultTrack(): Track { }; } -export function createDefaultLoop(): Loop { - const defaultEndTick = getMeasureDuration( - DEFAULT_BEATS, - DEFAULT_BEAT_TYPE, - DEFAULT_TPQN, - ); - console.log("defaultEndTick", defaultEndTick); - return { - isLoopEnabled: false, - startTick: 0, - endTick: defaultEndTick, - }; -} - export function getSnapTypes(tpqn: number) { return getRepresentableNoteTypes(tpqn).filter((value) => { return value <= MAX_SNAP_TYPE; diff --git a/src/store/project.ts b/src/store/project.ts index e927cb3933..e8103b617f 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -22,7 +22,6 @@ import { createDefaultTempo, createDefaultTimeSignature, createDefaultTrack, - createDefaultLoop, DEFAULT_TPQN, } from "@/sing/domain"; import { EditorType } from "@/type/preload"; @@ -62,7 +61,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 }); @@ -76,6 +75,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({ @@ -129,14 +133,6 @@ export const projectStore = createPartialStore({ await context.actions.SET_TIME_SIGNATURES({ timeSignatures: [createDefaultTimeSignature(1)], }); - const defaultLoop = createDefaultLoop(); - await context.actions.SET_LOOP_ENABLED({ - isLoopEnabled: defaultLoop.isLoopEnabled, - }); - await context.actions.SET_LOOP_RANGE({ - loopStartTick: defaultLoop.startTick, - loopEndTick: defaultLoop.endTick, - }); const trackId = TrackId(crypto.randomUUID()); await context.actions.SET_TRACKS({ tracks: new Map([[trackId, createDefaultTrack()]]), @@ -159,6 +155,7 @@ export const projectStore = createPartialStore({ if (characterInfos == undefined) throw new Error("characterInfos == undefined"); + console.log(migrateProjectFileObject); const parsedProjectData = await migrateProjectFileObject(projectData, { fetchMoraData: (payload) => actions.FETCH_MORA_DATA(payload), voices: characterInfos.flatMap((characterInfo) => diff --git a/src/store/singing.ts b/src/store/singing.ts index 30d895bccc..e149356749 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -78,7 +78,6 @@ import { createDefaultTrack, createDefaultTempo, createDefaultTimeSignature, - createDefaultLoop, isValidNotes, isValidTrack, SEQUENCER_MIN_NUM_MEASURES, @@ -475,9 +474,9 @@ export const singingStoreState: SingingStoreState = { nowAudioExporting: false, cancellationOfAudioExportRequested: false, isSongSidebarOpen: false, - isLoopEnabled: createDefaultLoop().isLoopEnabled, - loopStartTick: createDefaultLoop().startTick, - loopEndTick: createDefaultLoop().endTick, + isLoopEnabled: false, + loopStartTick: 0, + loopEndTick: 0, }; export const singingStore = createPartialStore({ From 88f27624e89afbeb3a423ae86bd1a3b502850a63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Oct 2024 18:27:13 +0000 Subject: [PATCH 19/24] =?UTF-8?q?=EF=BC=88=E3=82=B9=E3=83=8A=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88=E3=82=92=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...347\224\273\351\235\242-browser-win32.png" | Bin 26781 -> 26382 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 d198a700e3931b43d918243d43c2c51173f6320e..34a6edc55a04b74eb295a4826dc9460e63e0ed4e 100644 GIT binary patch literal 26382 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 From 334908e94a9d8432b77ca51e4021ecbbc2c91e25 Mon Sep 17 00:00:00 2001 From: Romot Date: Fri, 25 Oct 2024 00:44:49 +0900 Subject: [PATCH 20/24] =?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?=E5=87=A6=E7=90=86=E3=81=AE=E6=9C=80=E9=81=A9=E5=8C=96(RequestA?= =?UTF-8?q?nimationFrame=E3=81=AE=E5=88=A9=E7=94=A8=E3=81=AA=E3=81=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 371 +++++++++++-------- src/composables/useLoopControl.ts | 50 ++- src/domain/project/index.ts | 5 +- src/sing/domain.ts | 21 +- src/store/project.ts | 3 +- src/store/singing.ts | 61 ++- src/store/type.ts | 19 + 7 files changed, 349 insertions(+), 181 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 43a3f6be57..18f553878e 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -2,9 +2,9 @@
@@ -25,7 +25,7 @@ height="12" rx="6" ry="6" - class="loop-area" + class="loop-background" @mousedown.stop="onLoopAreaMouseDown" @mouseup.stop /> @@ -50,17 +50,21 @@ /> @@ -70,7 +74,7 @@ y="0" width="16" height="16" - class="loop-drag-area" + class="loop-drag-area loop-drag-area-start" @mousedown.stop="onStartHandleMouseDown" @dblclick.stop="onHandleDoubleClick" /> @@ -80,7 +84,7 @@ y="0" width="16" height="16" - class="loop-drag-area" + class="loop-drag-area loop-drag-area-end" @mousedown.stop="onEndHandleMouseDown" @dblclick.stop="onHandleDoubleClick" /> @@ -95,7 +99,6 @@ import { useStore } from "@/store"; import { useLoopControl } from "@/composables/useLoopControl"; import { useCursorState, CursorState } from "@/composables/useCursorState"; import { tickToBaseX, baseXToTick } from "@/sing/viewHelper"; -import { getNoteDuration } from "@/sing/domain"; import ContextMenu, { ContextMenuItemData, } from "@/components/Menu/ContextMenu.vue"; @@ -113,6 +116,9 @@ const { loopEndTick, setLoopEnabled, setLoopRange, + clearLoopRange, + snapToGrid, + addOneMeasureLoop, } = useLoopControl(); const { setCursorState, cursorClass } = useCursorState(); @@ -121,35 +127,60 @@ const DRAGGING_HEIGHT = props.height; // ドラッグ中でないループ高さ const DEFAULT_HEIGHT = 16; +// FIXME: 計算値をcomposableに移動し、コンポーネントから分離したい +// 以下のような要素は広く使われると思われるためループ実装においてはcomposableに移動しない const tpqn = computed(() => store.state.tpqn); const sequencerZoomX = computed(() => store.state.sequencerZoomX); -const sequencerSnapType = computed(() => store.state.sequencerSnapType); + +// ドラッグ中のループ範囲を保持 +const previewLoopStartTick = ref(loopStartTick.value); +const previewLoopEndTick = ref(loopEndTick.value); + +// 現在のループ範囲 +const currentLoopStartTick = computed(() => + isDragging.value ? previewLoopStartTick.value : loopStartTick.value, +); +const currentLoopEndTick = computed(() => + isDragging.value ? previewLoopEndTick.value : loopEndTick.value, +); // ループ開始X座標 const loopStartX = computed( - () => tickToBaseX(loopStartTick.value, tpqn.value) * sequencerZoomX.value, + () => + tickToBaseX(currentLoopStartTick.value, tpqn.value) * sequencerZoomX.value, ); // ループ終了X座標 const loopEndX = computed( - () => tickToBaseX(loopEndTick.value, tpqn.value) * sequencerZoomX.value, + () => + tickToBaseX(currentLoopEndTick.value, tpqn.value) * sequencerZoomX.value, ); -// ドラッグ中かどうか +const offset = computed(() => props.offset); + +// ドラッグ関連の状態と処理 +// FIXME: ドラッグ関連の状態と処理をcomposableに移動したい const isDragging = ref(false); -// ドラッグ中のハンドル const dragTarget = ref<"start" | "end" | null>(null); -// ドラッグ開始時のX座標 const dragStartX = ref(0); -// ドラッグ開始時のハンドル位置 const dragStartHandleX = ref(0); -// コンテキストメニューの表示位置 -const contextMenuPosition = ref(null); +let lastMouseEvent: MouseEvent | null = null; + // ドラッグエリアの高さ -// ドラッグ中の操作を容易にするためループ高さをルーラーと同一にする const adjustedHeight = computed(() => isDragging.value ? DRAGGING_HEIGHT : DEFAULT_HEIGHT, ); +// ループが空かどうか +const isEmpty = computed( + () => currentLoopStartTick.value === currentLoopEndTick.value, +); + +// プレビュー中かどうか +const executePreviewProcess = ref(false); +// RequestAnimationFrameのID +let previewRequestId: number | null = null; + +// イベントハンドラ const onLoopAreaMouseDown = (event: MouseEvent) => { if (event.button !== 0 || (event.ctrlKey && event.button === 0)) return; if (isDragging.value) { @@ -158,19 +189,18 @@ const onLoopAreaMouseDown = (event: MouseEvent) => { const target = event.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const x = event.clientX - rect.left + props.offset; - const tick = snapToGrid(baseXToTick(x / sequencerZoomX.value, tpqn.value)); - void setLoopRange(tick, tick); + const tick = snapToGrid( + baseXToTick(x / sequencerZoomX.value, tpqn.value), + tpqn.value, + ); + previewLoopStartTick.value = tick; + previewLoopEndTick.value = tick; startDragging("end", event); }; // ループエリアのクリック(ループの有無を切り替える) -const onLoopRangeClick = () => { - void setLoopEnabled(!isLoopEnabled.value); -}; - -const snapToGrid = (tick: number): number => { - const snapInterval = getNoteDuration(sequencerSnapType.value, tpqn.value); - return Math.round(tick / snapInterval) * snapInterval; +const onLoopRangeClick = async () => { + await setLoopEnabled(!isLoopEnabled.value); }; const onStartHandleMouseDown = (event: MouseEvent) => { @@ -182,8 +212,7 @@ const onEndHandleMouseDown = (event: MouseEvent) => { }; const onHandleDoubleClick = () => { - // ハンドルのダブルクリックでループを0地点に設定する - void setLoopRange(0, 0); + void clearLoopRange(); }; // ドラッグ開始処理 @@ -194,89 +223,122 @@ const startDragging = (target: "start" | "end", event: MouseEvent) => { dragStartX.value = event.clientX; dragStartHandleX.value = target === "start" ? loopStartX.value : loopEndX.value; + + // ドラッグ開始時に現行のループ範囲をプレビュー用にコピー + previewLoopStartTick.value = loopStartTick.value; + previewLoopEndTick.value = loopEndTick.value; + setCursorState(CursorState.EW_RESIZE); - window.addEventListener("mousemove", onDrag, true); + executePreviewProcess.value = true; + lastMouseEvent = event; + if (previewRequestId == null) { + previewRequestId = requestAnimationFrame(preview); + } + window.addEventListener("mousemove", onMouseMove, true); window.addEventListener("mouseup", stopDragging, true); }; -// ドラッグ中処理 -const onDrag = (event: MouseEvent) => { - if (!isDragging.value || !dragTarget.value) return; - if (event.button !== 0) return; +// マウス移動処理 +const onMouseMove = (event: MouseEvent) => { + if (!isDragging.value) return; + lastMouseEvent = event; + executePreviewProcess.value = true; +}; - // ドラッグ中のX座標 - const dx = event.clientX - dragStartX.value; - // ドラッグ中のハンドル位置 - const newX = dragStartHandleX.value + dx; - // ドラッグ中の基準tick - const baseTick = baseXToTick(newX / sequencerZoomX.value, tpqn.value); - // ドラッグ中の新しいtick(スナップされたtick) - const newTick = Math.max(0, snapToGrid(baseTick)); +// プレビュー処理 +const preview = () => { + if (executePreviewProcess.value && lastMouseEvent) { + executePreviewProcess.value = false; + const event = lastMouseEvent; + + // ドラッグ中のX座標 + const dx = event.clientX - dragStartX.value; + // ドラッグ中のハンドル位置 + const newX = dragStartHandleX.value + dx; + // ドラッグ中の基準tick + const baseTick = baseXToTick(newX / sequencerZoomX.value, tpqn.value); + // ドラッグ中の新しいtick(スナップされたtick) + const newTick = Math.max(0, snapToGrid(baseTick, tpqn.value)); - try { - // 開始ハンドルのドラッグ - if (dragTarget.value === "start") { - if (newTick <= loopEndTick.value) { - void setLoopRange(newTick, loopEndTick.value); - } else { - // 開始ハンドルが終了ハンドルを超えた場合、開始と終了を入れ替える - void setLoopRange(loopEndTick.value, newTick); - dragTarget.value = "end"; - dragStartX.value = event.clientX; - dragStartHandleX.value = - tickToBaseX(loopEndTick.value, tpqn.value) * sequencerZoomX.value; + try { + // 開始ハンドルのドラッグ + if (dragTarget.value === "start") { + if (newTick <= previewLoopEndTick.value) { + previewLoopStartTick.value = newTick; + } else { + // 開始ハンドルが終了ハンドルを超えた場合、開始と終了を入れ替える + previewLoopStartTick.value = previewLoopEndTick.value; + previewLoopEndTick.value = newTick; + dragTarget.value = "end"; + dragStartX.value = event.clientX; + dragStartHandleX.value = + tickToBaseX(previewLoopEndTick.value, tpqn.value) * + sequencerZoomX.value; + } } - } - // 終了ハンドルのドラッグ - if (dragTarget.value === "end") { - if (newTick >= loopStartTick.value) { - // 終了ハンドルが開始ハンドルを下回った場合、開始と終了を入れ替える - void setLoopRange(loopStartTick.value, newTick); - } else { - void setLoopRange(newTick, loopStartTick.value); - dragTarget.value = "start"; - dragStartX.value = event.clientX; - dragStartHandleX.value = - tickToBaseX(loopStartTick.value, tpqn.value) * sequencerZoomX.value; + // 終了ハンドルのドラッグ + else if (dragTarget.value === "end") { + if (newTick >= previewLoopStartTick.value) { + previewLoopEndTick.value = newTick; + } else { + // 終了ハンドルが開始ハンドルを下回った場合、開始と終了を入れ替える + previewLoopEndTick.value = previewLoopStartTick.value; + previewLoopStartTick.value = newTick; + dragTarget.value = "start"; + dragStartX.value = event.clientX; + dragStartHandleX.value = + tickToBaseX(previewLoopStartTick.value, tpqn.value) * + sequencerZoomX.value; + } } + } catch (error) { + console.error("Failed to update loop range", error); } - } catch (error) { - throw new Error("Failed to set loop range"); + } + + if (isDragging.value) { + previewRequestId = requestAnimationFrame(preview); + } else { + previewRequestId = null; } }; // ドラッグ終了処理 const stopDragging = async () => { if (!isDragging.value) return; - // ドラッグでループ範囲を設定していた場合にplayheadをループの開始位置に移動する - const isPlayheadToLoopStart = - isDragging.value && loopStartTick.value !== loopEndTick.value; - if (isPlayheadToLoopStart) { - try { - await store.dispatch("SET_PLAYHEAD_POSITION", { - position: loopStartTick.value, - }); - } catch (error) { - throw new Error("Failed to move playhead"); - } - } isDragging.value = false; dragTarget.value = null; + executePreviewProcess.value = false; setCursorState(CursorState.UNSET); - window.removeEventListener("mousemove", onDrag, true); + window.removeEventListener("mousemove", onMouseMove, true); window.removeEventListener("mouseup", stopDragging, true); + + if (previewRequestId != null) { + cancelAnimationFrame(previewRequestId); + previewRequestId = null; + } + + try { + await setLoopRange(previewLoopStartTick.value, previewLoopEndTick.value); + const isPlayheadToLoopStart = + previewLoopStartTick.value !== previewLoopEndTick.value; + if (isPlayheadToLoopStart) { + try { + await store.dispatch("SET_PLAYHEAD_POSITION", { + position: previewLoopStartTick.value, + }); + } catch (error) { + console.error("Failed to move playhead", error); + } + } + } catch (error) { + console.error("Failed to set loop range", error); + } }; // コンテキストメニュー位置に1小節のループ範囲を作成する -const addOneMeasureLoop = (x: number) => { - const timeSignature = store.state.timeSignatures[0]; - const oneMeasureTicks = - getNoteDuration(timeSignature.beatType, tpqn.value) * timeSignature.beats; - const baseX = (props.offset + x) / sequencerZoomX.value; - const cursorTick = baseXToTick(baseX, tpqn.value); - const startTick = snapToGrid(cursorTick); - const endTick = snapToGrid(startTick + oneMeasureTicks); - void setLoopRange(startTick, endTick); +const handleAddOneMeasureLoop = (x: number) => { + addOneMeasureLoop(x, props.offset, tpqn.value, sequencerZoomX.value); }; const onContextMenu = (event: MouseEvent) => { @@ -286,6 +348,7 @@ const onContextMenu = (event: MouseEvent) => { contextMenuPosition.value = event.clientX - rect.left; }; const contextMenu = ref>(); +const contextMenuPosition = ref(null); const contextMenuData = computed(() => { return [ { @@ -302,11 +365,11 @@ const contextMenuData = computed(() => { label: "ループ範囲を作成", onClick: () => { contextMenu.value?.hide(); - if (contextMenuPosition.value) { - addOneMeasureLoop(contextMenuPosition.value); + if (contextMenuPosition.value != null) { + handleAddOneMeasureLoop(contextMenuPosition.value); } }, - disabled: !contextMenuPosition.value, + disabled: contextMenuPosition.value == null, disableWhenUiLocked: true, }, { @@ -314,9 +377,9 @@ const contextMenuData = computed(() => { label: "ループ範囲を削除", onClick: () => { contextMenu.value?.hide(); - void setLoopRange(0, 0); + void clearLoopRange(); }, - disabled: loopStartTick.value === loopEndTick.value, + disabled: isEmpty.value, disableWhenUiLocked: true, }, ]; @@ -324,8 +387,11 @@ const contextMenuData = computed(() => { onUnmounted(() => { setCursorState(CursorState.UNSET); - window.removeEventListener("mousemove", onDrag, true); + window.removeEventListener("mousemove", onMouseMove, true); window.removeEventListener("mouseup", stopDragging, true); + if (previewRequestId != null) { + cancelAnimationFrame(previewRequestId); + } }); @@ -343,13 +409,56 @@ onUnmounted(() => { } // ホバー時のループエリア - &:hover .loop-area { + &:hover .loop-background { fill: var(--scheme-color-sing-loop-area); } + + &.is-enabled { + .loop-range { + fill: var(--scheme-color-primary-fixed-dim); + } + + .loop-handle { + fill: var(--scheme-color-primary-fixed-dim); + stroke: var(--scheme-color-primary-fixed-dim); + } + } + + &.is-dragging { + .loop-background { + opacity: 0.6; + } + + .loop-range { + fill: var(--scheme-color-outline); + opacity: 0.38; + } + + .loop-handle { + fill: var(--scheme-color-tertiary-fixed); + stroke: var(--scheme-color-tertiary-fixed); + } + } + + &.is-empty:not(.is-dragging) { + .loop-range, + .loop-handle, + .loop-drag-area { + display: none; + } + } + + &.is-dragging.is-empty { + .loop-handle { + fill: var(--scheme-color-outline); + stroke: var(--scheme-color-outline); + opacity: 0.38; + } + } } // ループエリア -.loop-area { +.loop-background { fill: transparent; transition: fill 0.1s ease-out; } @@ -372,7 +481,7 @@ onUnmounted(() => { stroke-linejoin: round; stroke-linecap: round; - &-no-length { + &.is-empty { fill: var(--scheme-color-outline); stroke: var(--scheme-color-outline); } @@ -384,56 +493,4 @@ onUnmounted(() => { cursor: ew-resize; pointer-events: all; } -// ループが有効な状態 -.loop-enabled { - .loop-range { - fill: var(--scheme-color-primary-fixed-dim); - } - - .loop-handle { - fill: var(--scheme-color-primary-fixed-dim); - stroke: var(--scheme-color-primary-fixed-dim); - } -} - -// ドラッグ中の状態 -// TODO: 色や表示など仮 -.loop-dragging { - .loop-area { - opacity: 0.6; - } - - .loop-range { - fill: var(--scheme-color-outline); - opacity: 0.38; - } - - .loop-handle { - fill: var(--scheme-color-tertiary-fixed); - stroke: var(--scheme-color-tertiary-fixed); - } -} - -// TODO: 仮: 削除の動作をシミュレート -.loop-no-length:not(.loop-dragging) { - .loop-range { - display: none; - } - - .loop-handle { - display: none; - } - - .loop-drag-area { - display: none; - } -} - -.loop-dragging.loop-no-length { - .loop-handle { - fill: var(--scheme-color-outline); - stroke: var(--scheme-color-outline); - opacity: 0.38; - } -} diff --git a/src/composables/useLoopControl.ts b/src/composables/useLoopControl.ts index 346a99e9e5..3ea0078f73 100644 --- a/src/composables/useLoopControl.ts +++ b/src/composables/useLoopControl.ts @@ -1,6 +1,7 @@ import { computed } from "vue"; import { useStore } from "@/store"; -import { tickToSecond } from "@/sing/domain"; +import { tickToSecond, getNoteDuration } from "@/sing/domain"; +import { baseXToTick } from "@/sing/viewHelper"; export function useLoopControl() { const store = useStore(); @@ -29,9 +30,11 @@ export function useLoopControl() { const setLoopEnabled = async (value: boolean): Promise => { try { - await store.dispatch("SET_LOOP_ENABLED", { isLoopEnabled: value }); + await store.dispatch("COMMAND_SET_LOOP_ENABLED", { + isLoopEnabled: value, + }); } catch (error) { - throw new Error("Failed to set loop enabled state"); + throw new Error("Failed to set loop enabled state", { cause: error }); } }; @@ -39,20 +42,46 @@ export function useLoopControl() { startTick: number, endTick: number, ): Promise => { - if (startTick < 0 || endTick < startTick) { - throw new Error("Invalid loop range"); - } - try { - await store.dispatch("SET_LOOP_RANGE", { + await store.dispatch("COMMAND_SET_LOOP_RANGE", { loopStartTick: startTick, loopEndTick: endTick, }); } catch (error) { - throw new Error("Failed to set loop range"); + throw new Error("Failed to set loop range", { cause: error }); + } + }; + + const clearLoopRange = async (): Promise => { + try { + await store.dispatch("COMMAND_CLEAR_LOOP_RANGE"); + } catch (error) { + throw new Error("Failed to clear loop range", { cause: error }); } }; + const snapToGrid = (tick: number, tpqn: number): number => { + const sequencerSnapType = store.state.sequencerSnapType; + const snapInterval = getNoteDuration(sequencerSnapType, tpqn); + return Math.round(tick / snapInterval) * snapInterval; + }; + + const addOneMeasureLoop = ( + x: number, + offset: number, + tpqn: number, + zoomX: number, + ) => { + const timeSignature = store.state.timeSignatures[0]; + const oneMeasureTicks = + getNoteDuration(timeSignature.beatType, tpqn) * timeSignature.beats; + const baseX = (offset + x) / zoomX; + const cursorTick = baseXToTick(baseX, tpqn); + const startTick = snapToGrid(cursorTick, tpqn); + const endTick = snapToGrid(startTick + oneMeasureTicks, tpqn); + void setLoopRange(startTick, endTick); + }; + return { isLoopEnabled, loopStartTick, @@ -61,5 +90,8 @@ export function useLoopControl() { loopEndTime, setLoopEnabled, setLoopRange, + clearLoopRange, + snapToGrid, + addOneMeasureLoop, }; } diff --git a/src/domain/project/index.ts b/src/domain/project/index.ts index 995d144c8e..01fef65eec 100644 --- a/src/domain/project/index.ts +++ b/src/domain/project/index.ts @@ -302,8 +302,9 @@ export const migrateProjectFileObject = async ( projectData.song.trackOrder = Object.keys(newTracks); } - // FIXME: 0.21.0 のマイグレーション - //if (semver.satisfies(projectAppVersion, "<0.21.0", semverSatisfiesOptions)) { + // FIXME: 0.22.0 のマイグレーション(おそらく) + // よく把握できていないため保留 + //if (semver.satisfies(projectAppVersion, "<0.22.0", semverSatisfiesOptions)) { if (!("loop" in projectData.song)) { projectData.song.loop = { startTick: 0, diff --git a/src/sing/domain.ts b/src/sing/domain.ts index e7c008f398..b1b36195f7 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -9,7 +9,6 @@ import { PhraseKey, Track, EditorFrameAudioQuery, - Loop, } from "@/store/type"; import { FramePhoneme } from "@/openapi"; import { TrackId } from "@/type/preload"; @@ -565,3 +564,23 @@ export const shouldPlayTracks = (tracks: Map): Set => { .map(([trackId]) => trackId), ); }; + +/** + * ループ範囲が有効かどうかを判定する + * @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 e8103b617f..c851bdce41 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -61,7 +61,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 }); diff --git a/src/store/singing.ts b/src/store/singing.ts index e149356749..efb34348a3 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -84,6 +84,7 @@ import { getNumMeasures, isTracksEmpty, shouldPlayTracks, + isValidLoopRange, } from "@/sing/domain"; import { FrequentlyUpdatedState, @@ -2453,18 +2454,15 @@ export const singingStore = createPartialStore({ }, SET_LOOP_ENABLED: { - mutation(state, { isLoopEnabled }: { isLoopEnabled: boolean }) { + mutation(state, { isLoopEnabled }) { state.isLoopEnabled = isLoopEnabled; }, - action( - { mutations, state }, - { isLoopEnabled }: { isLoopEnabled: boolean }, - ) { + async action({ mutations }, { isLoopEnabled }) { if (!transport) { - throw new Error("transport is undefined."); + throw new Error("transport is undefined"); } mutations.SET_LOOP_ENABLED({ isLoopEnabled }); - transport.loop = state.isLoopEnabled; + transport.loop = isLoopEnabled; }, }, @@ -2473,24 +2471,35 @@ export const singingStore = createPartialStore({ state.loopStartTick = loopStartTick; state.loopEndTick = loopEndTick; }, - async action({ mutations, state }, { loopStartTick, loopEndTick }) { + async action({ state, mutations }, { loopStartTick, loopEndTick }) { if (!transport) { - throw new Error("transport is undefined."); + 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( - state.loopStartTick, + loopStartTick, state.tempos, state.tpqn, ); transport.loopEndTime = tickToSecond( - state.loopEndTick, + loopEndTick, state.tempos, state.tpqn, ); }, }, + + CLEAR_LOOP_RANGE: { + action({ mutations }) { + mutations.SET_LOOP_RANGE({ loopStartTick: 0, loopEndTick: 0 }); + }, + }, }); export const singingCommandStoreState: SingingCommandStoreState = {}; @@ -3011,6 +3020,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 6a68c598cc..f166cbe2fb 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -1307,6 +1307,10 @@ export type SingingStoreTypes = { mutation: { loopStartTick: number; loopEndTick: number }; action(payload: { loopStartTick: number; loopEndTick: number }): void; }; + + CLEAR_LOOP_RANGE: { + action(): void; + }; }; export type SingingCommandStoreState = { @@ -1467,6 +1471,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 918eb32498d476f5dd406cfedaa5600a999195fa Mon Sep 17 00:00:00 2001 From: Romot Date: Fri, 25 Oct 2024 01:53:32 +0900 Subject: [PATCH 21/24] =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=83=E3=82=AF?= =?UTF-8?q?=E6=99=82=E3=81=AE=E5=88=9D=E6=9C=9F=E4=BD=8D=E7=BD=AE=E3=81=AE?= =?UTF-8?q?=E8=AA=A4=E3=82=8A=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 10 ++++------ src/composables/useLoopControl.ts | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 18f553878e..e89d618d7f 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -189,10 +189,8 @@ const onLoopAreaMouseDown = (event: MouseEvent) => { const target = event.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const x = event.clientX - rect.left + props.offset; - const tick = snapToGrid( - baseXToTick(x / sequencerZoomX.value, tpqn.value), - tpqn.value, - ); + const tick = snapToGrid(baseXToTick(x / sequencerZoomX.value, tpqn.value)); + void setLoopRange(tick, tick); previewLoopStartTick.value = tick; previewLoopEndTick.value = tick; startDragging("end", event); @@ -211,6 +209,7 @@ const onEndHandleMouseDown = (event: MouseEvent) => { startDragging("end", event); }; +// ハンドルのダブルクリック(ループ範囲を削除する) const onHandleDoubleClick = () => { void clearLoopRange(); }; @@ -230,7 +229,6 @@ const startDragging = (target: "start" | "end", event: MouseEvent) => { setCursorState(CursorState.EW_RESIZE); executePreviewProcess.value = true; - lastMouseEvent = event; if (previewRequestId == null) { previewRequestId = requestAnimationFrame(preview); } @@ -258,7 +256,7 @@ const preview = () => { // ドラッグ中の基準tick const baseTick = baseXToTick(newX / sequencerZoomX.value, tpqn.value); // ドラッグ中の新しいtick(スナップされたtick) - const newTick = Math.max(0, snapToGrid(baseTick, tpqn.value)); + const newTick = Math.max(0, snapToGrid(baseTick)); try { // 開始ハンドルのドラッグ diff --git a/src/composables/useLoopControl.ts b/src/composables/useLoopControl.ts index 3ea0078f73..4d2bfeb5f0 100644 --- a/src/composables/useLoopControl.ts +++ b/src/composables/useLoopControl.ts @@ -60,9 +60,9 @@ export function useLoopControl() { } }; - const snapToGrid = (tick: number, tpqn: number): number => { + const snapToGrid = (tick: number): number => { const sequencerSnapType = store.state.sequencerSnapType; - const snapInterval = getNoteDuration(sequencerSnapType, tpqn); + const snapInterval = getNoteDuration(sequencerSnapType, store.state.tpqn); return Math.round(tick / snapInterval) * snapInterval; }; @@ -77,8 +77,8 @@ export function useLoopControl() { getNoteDuration(timeSignature.beatType, tpqn) * timeSignature.beats; const baseX = (offset + x) / zoomX; const cursorTick = baseXToTick(baseX, tpqn); - const startTick = snapToGrid(cursorTick, tpqn); - const endTick = snapToGrid(startTick + oneMeasureTicks, tpqn); + const startTick = snapToGrid(cursorTick); + const endTick = snapToGrid(startTick + oneMeasureTicks); void setLoopRange(startTick, endTick); }; From dad6414d986e631dbeb55c5034683e9766d53ae8 Mon Sep 17 00:00:00 2001 From: Romot Date: Fri, 25 Oct 2024 11:23:59 +0900 Subject: [PATCH 22/24] =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=82=BA=E8=AA=BF?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 83 +++++++------------- 1 file changed, 30 insertions(+), 53 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index e89d618d7f..4a28ebed3c 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -22,7 +22,7 @@ x="0" y="0" :width="props.width" - height="12" + height="16" rx="6" ry="6" class="loop-background" @@ -30,17 +30,9 @@ @mouseup.stop /> - - - - - - + @@ -128,7 +100,7 @@ const DRAGGING_HEIGHT = props.height; const DEFAULT_HEIGHT = 16; // FIXME: 計算値をcomposableに移動し、コンポーネントから分離したい -// 以下のような要素は広く使われると思われるためループ実装においてはcomposableに移動しない +// 以下のような要素は広使われると思われるためループ実装においてはcomposableに移動しない const tpqn = computed(() => store.state.tpqn); const sequencerZoomX = computed(() => store.state.sequencerZoomX); @@ -209,11 +181,6 @@ const onEndHandleMouseDown = (event: MouseEvent) => { startDragging("end", event); }; -// ハンドルのダブルクリック(ループ範囲を削除する) -const onHandleDoubleClick = () => { - void clearLoopRange(); -}; - // ドラッグ開始処理 const startDragging = (target: "start" | "end", event: MouseEvent) => { if (event.button !== 0) return; @@ -334,7 +301,7 @@ const stopDragging = async () => { } }; -// コンテキストメニュー位置に1小節のループ範囲を作成する +// コンキストメニュー位置に1小節のループ範囲を作成する const handleAddOneMeasureLoop = (x: number) => { addOneMeasureLoop(x, props.offset, tpqn.value, sequencerZoomX.value); }; @@ -401,6 +368,7 @@ onUnmounted(() => { width: 100%; pointer-events: auto; cursor: pointer; + z-index: 100; &.cursor-ew-resize { cursor: ew-resize; @@ -424,6 +392,7 @@ onUnmounted(() => { &.is-dragging { .loop-background { + background: var(--scheme-color-secondary-container); opacity: 0.6; } @@ -453,6 +422,19 @@ onUnmounted(() => { opacity: 0.38; } } + + &:not(.is-dragging) { + .loop-handle { + &:hover, + &-start:hover, + &-end:hover { + fill: var(--scheme-color-primary-fixed); + outline: 2px solid + oklch(from var(--scheme-color-primary-fixed) l c h / 0.5); + outline-offset: 1px; + } + } + } } // ループエリア @@ -474,21 +456,16 @@ onUnmounted(() => { // ループハンドル .loop-handle { fill: var(--scheme-color-outline); - stroke: var(--scheme-color-outline); - stroke-width: 2px; - stroke-linejoin: round; - stroke-linecap: round; + cursor: ew-resize; + border-radius: 1px 1px 3px 3px; &.is-empty { fill: var(--scheme-color-outline); - stroke: var(--scheme-color-outline); } } -// ドラッグエリア .loop-drag-area { fill: transparent; cursor: ew-resize; - pointer-events: all; } From 3e86177cec40cd0c1d328b560638cd6a60a738cd Mon Sep 17 00:00:00 2001 From: Romot Date: Fri, 25 Oct 2024 18:32:18 +0900 Subject: [PATCH 23/24] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E3=81=AE=E5=8B=95=E4=BD=9C=E3=82=92=E9=81=A9=E5=88=87?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/SequencerLoopControl.vue | 45 ++++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 4a28ebed3c..48b7c1659a 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -32,9 +32,9 @@ { + // 左クリック以外は無視 if (event.button !== 0 || (event.ctrlKey && event.button === 0)) return; - if (isDragging.value) { - void stopDragging(); + + // プレビューを停止 + executePreviewProcess.value = false; + if (previewRequestId != null) { + cancelAnimationFrame(previewRequestId); + previewRequestId = null; } + + // クリック位置の計算 const target = event.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); - const x = event.clientX - rect.left + props.offset; + const clickX = event.clientX - rect.left; + const x = clickX + props.offset; const tick = snapToGrid(baseXToTick(x / sequencerZoomX.value, tpqn.value)); - void setLoopRange(tick, tick); + // プレビュー用のループ範囲を設定 previewLoopStartTick.value = tick; previewLoopEndTick.value = tick; + // ループ範囲を設定 + void setLoopRange(tick, tick); + // ドラッグ開始 startDragging("end", event); }; @@ -183,18 +194,23 @@ const onEndHandleMouseDown = (event: MouseEvent) => { // ドラッグ開始処理 const startDragging = (target: "start" | "end", event: MouseEvent) => { + // 左クリック以外は無視 if (event.button !== 0) return; + // ドラッグ開始 isDragging.value = true; dragTarget.value = target; dragStartX.value = event.clientX; dragStartHandleX.value = target === "start" ? loopStartX.value : loopEndX.value; - // ドラッグ開始時に現行のループ範囲をプレビュー用にコピー + // ドラッグ開始時に現行のループ範囲をプレビューにコピー previewLoopStartTick.value = loopStartTick.value; previewLoopEndTick.value = loopEndTick.value; + // カーソルを変更 setCursorState(CursorState.EW_RESIZE); + // プレビュー開始 + lastMouseEvent = event; executePreviewProcess.value = true; if (previewRequestId == null) { previewRequestId = requestAnimationFrame(preview); @@ -257,7 +273,7 @@ const preview = () => { } } } catch (error) { - console.error("Failed to update loop range", error); + throw new Error("Failed to update loop range", { cause: error }); } } @@ -284,7 +300,10 @@ const stopDragging = async () => { } try { + // ループ範囲を設定 await setLoopRange(previewLoopStartTick.value, previewLoopEndTick.value); + // 再生ヘッドがループ開始位置にあるか + // FIXME: usePlayheadPosition実装が完了したら移動 const isPlayheadToLoopStart = previewLoopStartTick.value !== previewLoopEndTick.value; if (isPlayheadToLoopStart) { @@ -293,11 +312,11 @@ const stopDragging = async () => { position: previewLoopStartTick.value, }); } catch (error) { - console.error("Failed to move playhead", error); + throw new Error("Failed to move playhead", { cause: error }); } } } catch (error) { - console.error("Failed to set loop range", error); + throw new Error("Failed to set loop range", { cause: error }); } }; @@ -368,7 +387,7 @@ onUnmounted(() => { width: 100%; pointer-events: auto; cursor: pointer; - z-index: 100; + z-index: 1; &.cursor-ew-resize { cursor: ew-resize; @@ -446,7 +465,6 @@ onUnmounted(() => { // ループ範囲 .loop-range { fill: var(--scheme-color-outline); - opacity: 1; &-area { fill: transparent; @@ -457,7 +475,6 @@ onUnmounted(() => { .loop-handle { fill: var(--scheme-color-outline); cursor: ew-resize; - border-radius: 1px 1px 3px 3px; &.is-empty { fill: var(--scheme-color-outline); From c0b9e63dbdbee84ffa007fd860b62c5a3e52ae12 Mon Sep 17 00:00:00 2001 From: Romot Date: Fri, 25 Oct 2024 22:47:11 +0900 Subject: [PATCH 24/24] =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=82=BA=E8=AA=BF?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/ScoreSequencer.vue | 2 +- src/components/Sing/SequencerLoopControl.vue | 108 ++++++++++++------ .../Sing/SequencerRuler/Presentation.vue | 10 +- 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 13e7bdb82d..ff8c6cbbbc 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -1554,7 +1554,7 @@ const contextMenuData = computed(() => { .score-sequencer { backface-visibility: hidden; display: grid; - grid-template-rows: 40px 1fr; + grid-template-rows: 48px 1fr; grid-template-columns: 48px 1fr; } diff --git a/src/components/Sing/SequencerLoopControl.vue b/src/components/Sing/SequencerLoopControl.vue index 48b7c1659a..e342c2601d 100644 --- a/src/components/Sing/SequencerLoopControl.vue +++ b/src/components/Sing/SequencerLoopControl.vue @@ -31,35 +31,58 @@ /> - + + + + - + + + +
@@ -285,7 +308,7 @@ const preview = () => { }; // ドラッグ終了処理 -const stopDragging = async () => { +const stopDragging = () => { if (!isDragging.value) return; isDragging.value = false; dragTarget.value = null; @@ -301,14 +324,14 @@ const stopDragging = async () => { try { // ループ範囲を設定 - await setLoopRange(previewLoopStartTick.value, previewLoopEndTick.value); + void setLoopRange(previewLoopStartTick.value, previewLoopEndTick.value); // 再生ヘッドがループ開始位置にあるか // FIXME: usePlayheadPosition実装が完了したら移動 const isPlayheadToLoopStart = previewLoopStartTick.value !== previewLoopEndTick.value; if (isPlayheadToLoopStart) { try { - await store.dispatch("SET_PLAYHEAD_POSITION", { + void store.dispatch("SET_PLAYHEAD_POSITION", { position: previewLoopStartTick.value, }); } catch (error) { @@ -387,7 +410,7 @@ onUnmounted(() => { width: 100%; pointer-events: auto; cursor: pointer; - z-index: 1; + z-index: 100; &.cursor-ew-resize { cursor: ew-resize; @@ -400,7 +423,11 @@ onUnmounted(() => { &.is-enabled { .loop-range { - fill: var(--scheme-color-primary-fixed-dim); + fill: color-mix( + in oklch, + var(--scheme-color-primary-fixed-dim) 40%, + var(--scheme-color-sing-loop-area) + ); } .loop-handle { @@ -409,20 +436,29 @@ onUnmounted(() => { } } + &:not(.is-enabled):not(.is-dragging) { + .loop-range { + opacity: 0.6; + } + + .loop-handle { + opacity: 0.6; + } + } + &.is-dragging { .loop-background { background: var(--scheme-color-secondary-container); - opacity: 0.6; + opacity: 0.4; } .loop-range { - fill: var(--scheme-color-outline); - opacity: 0.38; + opacity: 0.6; } .loop-handle { - fill: var(--scheme-color-tertiary-fixed); - stroke: var(--scheme-color-tertiary-fixed); + fill: var(--scheme-color-primary-fixed); + stroke: var(--scheme-color-primary-fixed); } } @@ -481,7 +517,13 @@ onUnmounted(() => { } } -.loop-drag-area { +.loop-handle-group:hover { + .loop-handle { + fill: var(--scheme-color-primary-fixed); + } +} + +.loop-handle-drag-area { fill: transparent; cursor: ew-resize; } diff --git a/src/components/Sing/SequencerRuler/Presentation.vue b/src/components/Sing/SequencerRuler/Presentation.vue index ddc58a9a32..d3d1b7a683 100644 --- a/src/components/Sing/SequencerRuler/Presentation.vue +++ b/src/components/Sing/SequencerRuler/Presentation.vue @@ -25,7 +25,7 @@ :key="n" :x1="beatWidth * n" :x2="beatWidth * n" - y1="28" + y1="36" :y2="height" class="sequencer-ruler-beat-line" /> @@ -38,7 +38,7 @@ :key="measureInfo.number" :x1="measureInfo.x - offset" :x2="measureInfo.x - offset" - y1="20" + y1="28" :y2="height" class="sequencer-ruler-measure-line" :class="{ 'first-measure-line': measureInfo.number === 1 }" @@ -49,7 +49,7 @@ :key="measureInfo.number" font-size="12" :x="measureInfo.x - offset + 4" - y="34" + y="44" class="sequencer-ruler-measure-number" > {{ measureInfo.number }} @@ -120,7 +120,7 @@ const emit = defineEmits<{ deselectAllNotes: []; }>(); -const height = ref(40); +const height = ref(56); const beatsPerMeasure = computed(() => { return props.timeSignatures[0].beats; }); @@ -232,7 +232,7 @@ onUnmounted(() => { .sequencer-ruler { background: var(--scheme-color-sing-ruler-surface); - height: 40px; + height: 56px; position: relative; overflow: hidden; }