Skip to content

Commit

Permalink
ソング:ノート追加のプレビュー処理をステートパターンで実装 (VOICEVOX#2171)
Browse files Browse the repository at this point in the history
* ステートマシンを追加

* IdleStateとAddNoteStateを追加

* noImplicitOverrideを有効にするとエラーが出るので無効にして、抽象クラスをインターフェースにした

* メソッドの引数のところを分割代入に変更

* StateをIStateに変更し、StatesをStateに変更

* ファイルを移動、ファイル名を変更

* Dispatcherを無くした

* ドキュメントコメントを追加

* 現在使われていないことをコメントで説明

* 初期ステートのonEnterを呼んでいなかったので修正

* コンポーザブル化した

* sequencerBodyは使用していなかったので削除

* 修正

* nextStateをインスタンス変数からローカル変数にする

* innerContextとしてまとめた
  • Loading branch information
sigprogramming authored Aug 8, 2024
1 parent b159696 commit 521a8c0
Show file tree
Hide file tree
Showing 3 changed files with 383 additions and 0 deletions.
269 changes: 269 additions & 0 deletions src/sing/stateMachine/sequencerStateMachine.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
readonly editTarget: ComputedRef<SequencerEditTarget>;
};

type Refs = {
readonly nowPreviewing: Ref<boolean>;
readonly previewNotes: Ref<Note[]>;
readonly guideLineTicks: Ref<number>;
};

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<State, Input, Context> {
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<State, Input, Context> {
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<NoteId, Note>();
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<Note[]>([]),
guideLineTicks: ref(0),
};
const stateMachine = new StateMachine<State, Input, Context>(
new IdleState(),
{
...computedRefs,
...refs,
storeActions,
},
);
return {
stateMachine,
nowPreviewing: computed(() => refs.nowPreviewing.value),
previewNotes: computed(() => refs.previewNotes.value),
guideLineTicks: computed(() => refs.guideLineTicks.value),
};
};
101 changes: 101 additions & 0 deletions src/sing/stateMachine/stateMachineBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* このファイルのコードは実装中で、現在使われていません。
* issue: https://github.com/VOICEVOX/voicevox/issues/2041
*/

/**
* ステートマシンのステートを表すインターフェース。
*
* @template State このインターフェースを実装するステートの型。
* @template Input ステートが処理する入力の型。
* @template Context ステート間で共有されるコンテキストの型。
*/
export interface IState<
State extends IState<State, Input, Context>,
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<State, Input, Context>,
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);
}
}
}
13 changes: 13 additions & 0 deletions src/sing/viewHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

0 comments on commit 521a8c0

Please sign in to comment.