forked from VOICEVOX/voicevox
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ソング:ノート追加のプレビュー処理をステートパターンで実装 (VOICEVOX#2171)
* ステートマシンを追加 * IdleStateとAddNoteStateを追加 * noImplicitOverrideを有効にするとエラーが出るので無効にして、抽象クラスをインターフェースにした * メソッドの引数のところを分割代入に変更 * StateをIStateに変更し、StatesをStateに変更 * ファイルを移動、ファイル名を変更 * Dispatcherを無くした * ドキュメントコメントを追加 * 現在使われていないことをコメントで説明 * 初期ステートのonEnterを呼んでいなかったので修正 * コンポーザブル化した * sequencerBodyは使用していなかったので削除 * 修正 * nextStateをインスタンス変数からローカル変数にする * innerContextとしてまとめた
- Loading branch information
1 parent
b159696
commit 521a8c0
Showing
3 changed files
with
383 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters