diff --git a/src/sing/stateMachine/sequencerStateMachine.ts b/src/sing/stateMachine/sequencerStateMachine.ts new file mode 100644 index 0000000000..7091f1bf3d --- /dev/null +++ b/src/sing/stateMachine/sequencerStateMachine.ts @@ -0,0 +1,269 @@ +/** + * このファイルのコードは実装中で、現在使われていません。 + * issue: https://github.com/VOICEVOX/voicevox/issues/2041 + */ + +import { computed, ComputedRef, ref, Ref } from "vue"; +import { IState, StateMachine } from "@/sing/stateMachine/stateMachineBase"; +import { + getButton, + getDoremiFromNoteNumber, + isSelfEventTarget, + PREVIEW_SOUND_DURATION, +} from "@/sing/viewHelper"; +import { Note, SequencerEditTarget } from "@/store/type"; +import { NoteId } from "@/type/preload"; + +export type PositionOnSequencer = { + readonly ticks: number; + readonly noteNumber: number; +}; + +type Input = + | { + readonly targetArea: "SequencerBody"; + readonly mouseEvent: MouseEvent; + readonly cursorPos: PositionOnSequencer; + } + | { + readonly targetArea: "Note"; + readonly mouseEvent: MouseEvent; + readonly cursorPos: PositionOnSequencer; + readonly note: Note; + } + | { + readonly targetArea: "NoteLeftEdge"; + readonly mouseEvent: MouseEvent; + readonly cursorPos: PositionOnSequencer; + readonly note: Note; + } + | { + readonly targetArea: "NoteRightEdge"; + readonly mouseEvent: MouseEvent; + readonly cursorPos: PositionOnSequencer; + readonly note: Note; + }; + +type ComputedRefs = { + readonly snapTicks: ComputedRef; + readonly editTarget: ComputedRef; +}; + +type Refs = { + readonly nowPreviewing: Ref; + readonly previewNotes: Ref; + readonly guideLineTicks: Ref; +}; + +type StoreActions = { + readonly deselectAllNotes: () => void; + readonly commandAddNotes: (notes: Note[]) => void; + readonly selectNotes: (noteIds: NoteId[]) => void; + readonly playPreviewSound: (noteNumber: number, duration?: number) => void; +}; + +type Context = ComputedRefs & Refs & { readonly storeActions: StoreActions }; + +type State = IdleState | AddNoteState; + +const getGuideLineTicks = ( + cursorPos: PositionOnSequencer, + context: Context, +) => { + const cursorTicks = cursorPos.ticks; + const snapTicks = context.snapTicks.value; + // NOTE: 入力を補助する線の判定の境目はスナップ幅の3/4の位置 + return Math.round(cursorTicks / snapTicks - 0.25) * snapTicks; +}; + +class IdleState implements IState { + readonly id = "idle"; + + onEnter() {} + + process({ + input, + context, + setNextState, + }: { + input: Input; + context: Context; + setNextState: (nextState: State) => void; + }) { + const mouseButton = getButton(input.mouseEvent); + if (input.targetArea === "SequencerBody") { + context.guideLineTicks.value = getGuideLineTicks( + input.cursorPos, + context, + ); + if (input.mouseEvent.type === "mousedown") { + // TODO: メニューが表示されている場合はメニュー非表示のみ行いたい + if (context.editTarget.value === "NOTE") { + if (!isSelfEventTarget(input.mouseEvent)) { + return; + } + if (mouseButton === "LEFT_BUTTON") { + if ( + input.cursorPos.ticks < 0 || + input.cursorPos.noteNumber < 0 || + input.cursorPos.noteNumber > 127 + ) { + return; + } + setNextState(new AddNoteState(input.cursorPos)); + } + } + } + } + } + + onExit() {} +} + +class AddNoteState implements IState { + readonly id = "addNote"; + + private readonly cursorPosAtStart: PositionOnSequencer; + + private currentCursorPos: PositionOnSequencer; + private innerContext: + | { + noteToAdd: Note; + previewRequestId: number; + executePreviewProcess: boolean; + } + | undefined; + + constructor(cursorPosAtStart: PositionOnSequencer) { + this.cursorPosAtStart = cursorPosAtStart; + this.currentCursorPos = cursorPosAtStart; + } + + private previewAdd(context: Context) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const noteToAdd = this.innerContext.noteToAdd; + const snapTicks = context.snapTicks.value; + const dragTicks = this.currentCursorPos.ticks - this.cursorPosAtStart.ticks; + const noteDuration = Math.round(dragTicks / snapTicks) * snapTicks; + const noteEndPos = noteToAdd.position + noteDuration; + const previewNotes = context.previewNotes.value; + + const editedNotes = new Map(); + for (const note of previewNotes) { + const duration = Math.max(snapTicks, noteDuration); + if (note.duration !== duration) { + editedNotes.set(note.id, { ...note, duration }); + } + } + if (editedNotes.size !== 0) { + context.previewNotes.value = previewNotes.map((value) => { + return editedNotes.get(value.id) ?? value; + }); + } + context.guideLineTicks.value = noteEndPos; + } + + onEnter(context: Context) { + context.storeActions.deselectAllNotes(); + + const guideLineTicks = getGuideLineTicks(this.cursorPosAtStart, context); + const noteToAdd = { + id: NoteId(crypto.randomUUID()), + position: guideLineTicks, + duration: context.snapTicks.value, + noteNumber: this.cursorPosAtStart.noteNumber, + lyric: getDoremiFromNoteNumber(this.cursorPosAtStart.noteNumber), + }; + + context.previewNotes.value = [noteToAdd]; + context.nowPreviewing.value = true; + + const preview = () => { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + if (this.innerContext.executePreviewProcess) { + this.previewAdd(context); + this.innerContext.executePreviewProcess = false; + } + this.innerContext.previewRequestId = requestAnimationFrame(preview); + }; + const previewRequestId = requestAnimationFrame(preview); + + this.innerContext = { + noteToAdd, + executePreviewProcess: false, + previewRequestId, + }; + } + + 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") { + if (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.commandAddNotes(context.previewNotes.value); + context.storeActions.selectNotes(previewNoteIds); + if (previewNotes.length === 1) { + context.storeActions.playPreviewSound( + previewNotes[0].noteNumber, + PREVIEW_SOUND_DURATION, + ); + } + context.nowPreviewing.value = false; + } +} + +export const useSequencerStateMachine = ( + computedRefs: ComputedRefs, + storeActions: StoreActions, +) => { + const refs: Refs = { + nowPreviewing: ref(false), + previewNotes: ref([]), + guideLineTicks: ref(0), + }; + const stateMachine = new StateMachine( + new IdleState(), + { + ...computedRefs, + ...refs, + storeActions, + }, + ); + return { + stateMachine, + nowPreviewing: computed(() => refs.nowPreviewing.value), + previewNotes: computed(() => refs.previewNotes.value), + guideLineTicks: computed(() => refs.guideLineTicks.value), + }; +}; diff --git a/src/sing/stateMachine/stateMachineBase.ts b/src/sing/stateMachine/stateMachineBase.ts new file mode 100644 index 0000000000..6d6c5a09ac --- /dev/null +++ b/src/sing/stateMachine/stateMachineBase.ts @@ -0,0 +1,101 @@ +/** + * このファイルのコードは実装中で、現在使われていません。 + * issue: https://github.com/VOICEVOX/voicevox/issues/2041 + */ + +/** + * ステートマシンのステートを表すインターフェース。 + * + * @template State このインターフェースを実装するステートの型。 + * @template Input ステートが処理する入力の型。 + * @template Context ステート間で共有されるコンテキストの型。 + */ +export interface IState< + State extends IState, + Input, + Context, +> { + /** + * 入力を処理し、必要に応じて次のステートを設定する。 + * + * @param payload `input`、`context`、`setNextState`関数を含むペイロード。 + */ + process(payload: { + input: Input; + context: Context; + setNextState: (nextState: State) => void; + }): void; + + /** + * ステートに入ったときに呼び出される。 + * + * @param context ステート間で共有されるコンテキスト。 + */ + onEnter(context: Context): void; + + /** + * ステートから出るときに呼び出される。 + * + * @param context ステート間で共有されるコンテキスト。 + */ + onExit(context: Context): void; +} + +/** + * ステートマシンを表すクラス。 + * + * @template State ステートマシンのステートの型。 + * @template Input ステートが処理する入力の型。 + * @template Context ステート間で共有されるコンテキストの型。 + */ +export class StateMachine< + State extends IState, + Input, + Context, +> { + private readonly context: Context; + + private currentState: State; + + /** + * @param initialState ステートマシンの初期ステート。 + * @param context ステート間で共有されるコンテキスト。 + */ + constructor(initialState: State, context: Context) { + this.context = context; + this.currentState = initialState; + + this.currentState.onEnter(this.context); + } + + /** + * ステートマシンの現在のステートを返す。 + * + * @returns 現在のステート。 + */ + getCurrentState() { + return this.currentState; + } + + /** + * 現在のステートを使用して入力を処理し、必要に応じてステートの遷移を行う。 + * + * @param input 処理する入力。 + */ + process(input: Input) { + let nextState: State | undefined = undefined; + const setNextState = (arg: State) => { + nextState = arg; + }; + this.currentState.process({ + input, + context: this.context, + setNextState, + }); + if (nextState != undefined) { + this.currentState.onExit(this.context); + this.currentState = nextState; + this.currentState.onEnter(this.context); + } + } +} diff --git a/src/sing/viewHelper.ts b/src/sing/viewHelper.ts index b20366eaa9..0665a0bd12 100644 --- a/src/sing/viewHelper.ts +++ b/src/sing/viewHelper.ts @@ -149,3 +149,16 @@ export function getButton(event: MouseEvent): MouseButton { return "OTHER_BUTTON"; } } + +export const getXInBorderBox = (clientX: number, element: HTMLElement) => { + return clientX - element.getBoundingClientRect().left; +}; + +export const getYInBorderBox = (clientY: number, element: HTMLElement) => { + return clientY - element.getBoundingClientRect().top; +}; + +/** 直接イベントが来ているかどうか */ +export const isSelfEventTarget = (event: UIEvent) => { + return event.target === event.currentTarget; +};