diff --git a/src/sing/stateMachine/sequencerStateMachine.ts b/src/sing/stateMachine/sequencerStateMachine.ts index 9fe51406ff..eb0c999806 100644 --- a/src/sing/stateMachine/sequencerStateMachine.ts +++ b/src/sing/stateMachine/sequencerStateMachine.ts @@ -4,12 +4,12 @@ */ import { computed, ComputedRef, ref, Ref } from "vue"; +import { clamp } from "../utility"; import { IState, StateMachine } from "@/sing/stateMachine/stateMachineBase"; import { getButton, getDoremiFromNoteNumber, isSelfEventTarget, - keyInfos, PREVIEW_SOUND_DURATION, } from "@/sing/viewHelper"; import { Note, SequencerEditTarget } from "@/store/type"; @@ -72,7 +72,7 @@ type StoreActions = { type Context = ComputedRefs & Refs & { readonly storeActions: StoreActions }; -type State = IdleState | AddNoteState | MoveNoteState; +type State = IdleState | AddNoteState | MoveNoteState | ResizeNoteLeftState; const getGuideLineTicks = ( cursorPos: PositionOnSequencer, @@ -166,13 +166,6 @@ class IdleState implements IState { return; } if (mouseButton === "LEFT_BUTTON") { - if ( - input.cursorPos.ticks < 0 || - input.cursorPos.noteNumber < 0 || - input.cursorPos.noteNumber > 127 - ) { - return; - } context.storeActions.deselectAllNotes(); const addNoteState = new AddNoteState( input.cursorPos, @@ -256,9 +249,9 @@ class AddNoteState implements IState { const guideLineTicks = getGuideLineTicks(this.cursorPosAtStart, context); const noteToAdd = { id: NoteId(crypto.randomUUID()), - position: guideLineTicks, + position: Math.max(0, guideLineTicks), duration: context.snapTicks.value, - noteNumber: this.cursorPosAtStart.noteNumber, + noteNumber: clamp(this.cursorPosAtStart.noteNumber, 0, 127), lyric: getDoremiFromNoteNumber(this.cursorPosAtStart.noteNumber), }; @@ -390,28 +383,19 @@ class MoveNoteState implements IState { const editedNotes = new Map(); for (const note of previewNotes) { - const targetNoteAtStart = targetNotesAtStart.get(note.id); - if (!targetNoteAtStart) { - throw new Error("targetNoteAtStart is undefined."); - } - const position = targetNoteAtStart.position + movingTicks; - const noteNumber = targetNoteAtStart.noteNumber + movingSemitones; + const targetNoteAtStart = getOrThrow(targetNotesAtStart, note.id); + const position = Math.max(0, targetNoteAtStart.position + movingTicks); + const noteNumber = clamp( + targetNoteAtStart.noteNumber + movingSemitones, + 0, + 127, + ); + if (note.position !== position || note.noteNumber !== noteNumber) { editedNotes.set(note.id, { ...note, noteNumber, position }); } } - for (const note of editedNotes.values()) { - if (note.noteNumber < 0 || note.noteNumber >= keyInfos.length) { - // MIDIキー範囲外へはドラッグしない - return; - } - if (note.position < 0) { - // 左端より前はドラッグしない - return; - } - } - if (editedNotes.size !== 0) { context.previewNotes.value = previewNotes.map((value) => { return editedNotes.get(value.id) ?? value; @@ -505,6 +489,163 @@ class MoveNoteState implements IState { } } +class ResizeNoteLeftState implements IState { + readonly id = "resizeNoteLeft"; + + private readonly cursorPosAtStart: PositionOnSequencer; + private readonly targetTrackId: TrackId; + private readonly targetNoteIds: Set; + private readonly mouseDownNoteId: NoteId; + + private currentCursorPos: PositionOnSequencer; + + private innerContext: + | { + targetNotesAtStart: Map; + previewRequestId: number; + executePreviewProcess: boolean; + edited: boolean; + guideLineTicksAtStart: number; + } + | undefined; + + constructor( + cursorPosAtStart: PositionOnSequencer, + targetTrackId: TrackId, + targetNoteIds: Set, + mouseDownNoteId: NoteId, + ) { + if (!targetNoteIds.has(mouseDownNoteId)) { + throw new Error("mouseDownNoteId is not included in targetNoteIds."); + } + this.cursorPosAtStart = cursorPosAtStart; + this.targetTrackId = targetTrackId; + this.targetNoteIds = targetNoteIds; + this.mouseDownNoteId = mouseDownNoteId; + + this.currentCursorPos = cursorPosAtStart; + } + + private previewResizeLeft(context: Context) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const snapTicks = context.snapTicks.value; + const previewNotes = context.previewNotes.value; + const targetNotesAtStart = this.innerContext.targetNotesAtStart; + const mouseDownNote = getOrThrow(targetNotesAtStart, this.mouseDownNoteId); + const dragTicks = this.currentCursorPos.ticks - this.cursorPosAtStart.ticks; + const notePos = mouseDownNote.position; + const newNotePos = + Math.round((notePos + dragTicks) / snapTicks) * snapTicks; + const movingTicks = newNotePos - notePos; + + const editedNotes = new Map(); + for (const note of previewNotes) { + const targetNoteAtStart = getOrThrow(targetNotesAtStart, note.id); + const notePos = targetNoteAtStart.position; + const noteEndPos = + targetNoteAtStart.position + targetNoteAtStart.duration; + const position = clamp(notePos + movingTicks, 0, noteEndPos - snapTicks); + const duration = noteEndPos - position; + + if (note.position !== position || note.duration !== duration) { + editedNotes.set(note.id, { ...note, position, duration }); + } + } + if (editedNotes.size !== 0) { + context.previewNotes.value = previewNotes.map((value) => { + return editedNotes.get(value.id) ?? value; + }); + this.innerContext.edited = true; + } + + context.guideLineTicks.value = newNotePos; + } + + onEnter(context: Context) { + const guideLineTicks = getGuideLineTicks(this.cursorPosAtStart, context); + const targetNotesArray = context.notesInSelectedTrack.value.filter( + (value) => this.targetNoteIds.has(value.id), + ); + const targetNotesMap = new Map(); + for (const targetNote of targetNotesArray) { + targetNotesMap.set(targetNote.id, targetNote); + } + + context.previewNotes.value = [...targetNotesArray]; + context.nowPreviewing.value = true; + + const previewIfNeeded = () => { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + if (this.innerContext.executePreviewProcess) { + this.previewResizeLeft(context); + this.innerContext.executePreviewProcess = false; + } + this.innerContext.previewRequestId = + requestAnimationFrame(previewIfNeeded); + }; + const previewRequestId = requestAnimationFrame(previewIfNeeded); + + this.innerContext = { + targetNotesAtStart: targetNotesMap, + executePreviewProcess: false, + previewRequestId, + edited: false, + guideLineTicksAtStart: guideLineTicks, + }; + } + + process({ + input, + setNextState, + }: { + input: Input; + context: Context; + setNextState: (nextState: State) => void; + }) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const mouseButton = getButton(input.mouseEvent); + if (input.targetArea === "SequencerBody") { + if (input.mouseEvent.type === "mousemove") { + this.currentCursorPos = input.cursorPos; + this.innerContext.executePreviewProcess = true; + } else if ( + input.mouseEvent.type === "mouseup" && + mouseButton === "LEFT_BUTTON" + ) { + setNextState(new IdleState()); + } + } + } + + onExit(context: Context) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const previewNotes = context.previewNotes.value; + const previewNoteIds = previewNotes.map((value) => value.id); + + cancelAnimationFrame(this.innerContext.previewRequestId); + + context.storeActions.commandUpdateNotes(previewNotes, this.targetTrackId); + context.storeActions.selectNotes(previewNoteIds); + + if (previewNotes.length === 1) { + context.storeActions.playPreviewSound( + previewNotes[0].noteNumber, + PREVIEW_SOUND_DURATION, + ); + } + context.previewNotes.value = []; + context.nowPreviewing.value = false; + } +} + export const useSequencerStateMachine = ( computedRefs: ComputedRefs, storeActions: StoreActions, diff --git a/src/sing/utility.ts b/src/sing/utility.ts index 1836d37b18..09331ac601 100644 --- a/src/sing/utility.ts +++ b/src/sing/utility.ts @@ -3,6 +3,15 @@ export function round(value: number, digits: number) { return Math.round(value * powerOf10) / powerOf10; } +export function clamp(value: number, min: number, max: number) { + if (min > max) { + throw new Error( + `Invalid range: min (${min}) cannot be greater than max (${max}).`, + ); + } + return Math.min(max, Math.max(min, value)); +} + export function getLast(array: T[]) { if (array.length === 0) { throw new Error("array.length is 0.");