Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ソング:ノート追加のプレビュー処理をステートパターンで実装 #2171

Conversation

sigprogramming
Copy link
Contributor

内容

以下を行います。

  • ステートマシンを追加
  • IdleStateとAddNoteStateを追加
    • プレビュー処理の置き換えはまだ行っていません
    • IdleStateはAddNoteStateへの遷移のみ実装
  • getXInBorderBoxgetYInBorderBoxisSelfEventTargetをviewHelper.tsに移動

関連 Issue

その他

  • 引数が未使用だとエラーになるので、.eslintrc.jsを編集して、先頭に_がついている場合はエラーにならないようにしました
  • プレビュー処理の置き換えは、ステートを一通り実装してから行った方が良さそうだったので、このPRでは行っていません

@sigprogramming sigprogramming requested a review from a team as a code owner July 15, 2024 03:53
@sigprogramming sigprogramming requested review from Hiroshiba and removed request for a team July 15, 2024 03:53
Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

コンポーネントとステートマシンをどう切り分けていくかの設計、とても難しいけど面白い課題だなと感じました!!

ちょっと色々コメントしちゃいました 🙇
でも基本的には正解が全くわからないので、なんとなく今考えている方針をすり合わせつつ、手探りで探していくのがいいのかなと思ってます!
なので最初の認識(未定なのであれば未定だという認識)を揃えておいて、ガシガシ進んで行けるといいのかなと!

ということでちょっと方針について2点聞きたいことが・・・!!


多分全部のStateを実装した後に置き換える、という流れを取ると思ってます!
流れは良さそうなのですが、ステートマシン関係のコードは今使われていないということがコードからわからず、mainブランチを見た新規のコミッターが混乱しちゃうかもです。
各ファイルの一番上に「今は実装中なので現在使われていません」的な文言と、リファクタリングissueへのリンクを貼っておくのとかどうでしょう?


いくつか設計に関して、もし考えたことがあれば聞いてみたく、いくつか最初に認識合わせしておきたいです・・・!
(ちょっと長くなってしまいましたがすみません。。)

  1. 1つのステートマシンは1つのコンポーネントと対応する?

今回だとSequencerStateMachineはScoreSequencerに1:1対応していると思っています。
個人的には、ステートマシンは兄弟コンポーネントを跨がない(子コンポーネントにわたすのはOK)設計にするのが妥当かなぁと思ってます!
まあここ柔軟に設計は変わっていきそうですが、一旦思いを聞いてみたく。

  1. contextの中身の変更がStateMachine以外からもできるようになってるけど、意図した設計かどうか

多分ステートマシンのインスタンスを作る側でrefを作って投げる想定だと思います。
この形だとrefの中身を勝手に書き換えれちゃえそうです。
個人的には、それを承知で今の設計にするのが一旦良いと思ってます!

カプセル化するのであればコンポーザブル化するのも手かも。

こんな感じ?(Refを中で定義してComputedRefを返す)
// createStateMachineForSequencerの改変
export const useStateMachineForSequencer = () => {
  const nowPreviewing = Ref(false)
  // ほかも定義

  const context: Context = {
    nowPreviewing,
  }
  // dispatcherも似たように定義

  const stateMachine = new StateMachine<States, Input, Context, Dispatcher>(
    new IdleState(),
    context,
    dispatcher,
  );

  // get onlyで返す
  return {
    nowPreviewing: computed(() => nowPreviewing),
    // dispatcherは返さなくて良いはず。コールバック用途なら`onHoge`関数を受け取る形にとか?
    stateMachine
  }
};

  1. ステートマシン意外とのやり取り(Dispatcher)をイベント型にするか処理をDIする形にするか

たぶん一般的には、ステートマシンが扱う全部のコンテキストをContextに乗せるのが普通だと思うのですが、VOICEVOXは一部の処理がVuex管理になっているため外とのやり取りが発生すると思います!
この時コールバック方式(イベント型?onAtoBみたいな)にするか、今のプルリクエストみたいな形で関数を大量にDIするかの二択があると思うのですが、後者が一般的なのでしょうか・・・?
個人的にも今のプルリクエストの形の方が良いと思っていますが、何か情報お持ちであればと思ってお聞きしてみた次第です!

@sigprogramming
Copy link
Contributor Author

sigprogramming commented Jul 21, 2024

@Hiroshiba
レビューありがとうございます!

各ファイルの一番上に「今は実装中なので現在使われていません」的な文言と、リファクタリングissueへのリンクを貼っておくのとかどうでしょう?

ファイルの一番上にコメントを追加しました!
(マルチトラックのようにprojectブランチを作ってそちらに一旦マージも良いかもと思っています)

1つのステートマシンは1つのコンポーネントと対応する?

コンポーネントをv-if等で切り替える形ではなく、ステートマシンを使用してコンポーネントの振る舞いを切り替える形なので、1:1対応すると思います。
(ステートマシンのインスタンスは子コンポーネントにはわたさず、対応するコンポーネント内でのみ使用するのが良いと思います)

contextの中身の変更がStateMachine以外からもできるようになってるけど、意図した設計かどうか

とりあえずScoreSequencer.vueに置いてあるrefをそのままContextに入れていました。
確かにカプセル化した方が良さそうなので、いただいたコードを参考にコンポーザブル化を行いました!

ステートマシン意外とのやり取り(Dispatcher)をイベント型にするか処理をDIする形にするか

関数をDIする形が一般的かは分からないのですが、
IdleState、AddNoteStateでは、何か(遷移など)が起こってそれで関数が呼ばれるというより、プレビュー処理の流れで関数が呼ばれるので、後者の関数をDIする形が良いかなと思います。
(各ステートの処理はコンポーネントの処理の一部なので、storeのactionに依存しても良いという考え)

Comment on lines +58 to +65
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 };
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispatcherを無くして、storeActionsとしてContextに入れました。

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ただのコメントです)

computedRefsとrefsも{computed: ComputedRefs}などとしても良いかも?
どれがrefでどれがcomputedRefかぱっとわかりやすく、間違えてsetしようと思いづらくなりそう!
まあ今は把握しきれそうなので、もっと複雑化してきたときでも良さそう。

@Hiroshiba
Copy link
Member

なるほどです、もろもろ納得です!

マルチトラックのようにprojectブランチを作ってそちらに一旦マージも良いかもと思っています

これは完全にやりやすい方で、という感じです!
全然どっちでも良さそうですが・・・ブランチ分けてもいいかもですね!
使われていないコードがいっぱいある状況は、どうしても混乱を生みがちかもなので。

Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ほぼLGTMです!!

ブランチ作って良さそうであれば作ります!!ブランチ名はproject-song-sequence-statemachineとか?

専用のissueと、そのissue内に箇条書きのタスクリストがあると他の方も分かりやすいかもです。
新しくissue作るかによらず、後から見返しやすいよう、全プルリクが同じissueをリンクし続ける感じにしたみ。


いくつか、ここできそうだな~でも思いつかないな~っていう設計を書いてみました!
ただどんな実装が良いのか現状分からないので、まずは細かいこと気にせずにガシガシ実装が良さそう!

1.たぶんStateごとに、何のInput・次Stateが許されているか定義できるようにも作れる。
AddNoteStateはクリックを離すInputのみ受付可能、みたいなのを型で指定できる感じ。

今のコードだと全てのStateが、全てのStateに存在しうる全てのInput・Stateを想定しないといけないはず。
想定しないものが来た場合は何も処理しない、言い換えると想定外の処理が来ても気付けないはず。(・・・もしかしたらそういう設計が普通・・・?)
たぶん型の実装をうまいことやるとこの辺りも制限できる。

2.各Stateのインスタンス変数は、初期値は不要なはず。
でも今はcontextが渡ってくるonEnter内でprocessで使うデータをから情報を作ってインスタンス変数に代入している関係で、undefinableになってる。
存在しない型が存在している関係で、ちょっとミスが起こりやすいコードになってる。
AddNoteStatethis.noteToAddundefineなことはないはずだけど、undefinedが許されてる)

たーーーーーーぶんconstructeronEnter、もしくはonEnterprocessをくっつければ初期値不要にできる・・・はずだけど、どういう設計が良いのかわからない。。。
前者だとconstructerにcontextを渡す設計にすればOK。
後者だとonEnterの返り値でデータを返し、processに渡す設計にすればOK(型パズルがだいぶややこしい。僕の知識だとかなりきつい。。)

Comment on lines 91 to 105
process(input: Input) {
try {
this.currentState.process({
input,
context: this.context,
setNextState: this.setNextState,
});
if (this.nextState != undefined) {
this.currentState.onExit(this.context);
this.currentState = this.nextState;
this.currentState.onEnter(this.context);
}
} finally {
this.nextState = undefined;
}
Copy link
Member

@Hiroshiba Hiroshiba Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こんな感じにしちゃえばthis.setNextState()this.nextStateなくせるかもです!
(インスタンス変数this.nextStateをなくせるのはバグ減らす意味で結構大きそう?)

process(input: Input) {
  let nextState = undefined;
  this.currentState.process({
    input,
    context: this.context,
    setNextState: (s) => { nextState = s },
  });
  if (nextState != undefined) {
    this.currentState.onExit(this.context);
    this.currentState = nextState;
    this.currentState.onEnter(this.context);
  }
}

あるいはprocessState | undefinedreturnしてもらう手もあるかも・・・?
必ず同期的に処理してほしい(promise内でsetNextStateを呼ばないでほしい)気がするので、どっちかというとreturnのが良い・・・・・・・・・のかも・・・?(自信なし)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こちら変更しました!
State | undefinedをreturnしても良いかもですが、一旦今の形で実装できれば…!

@sigprogramming
Copy link
Contributor Author

ブランチ作って良さそうであれば作ります!!ブランチ名はproject-song-sequence-statemachineとか?

project-sequencer-statemachineが良いかなと思います、お願いします…!

たぶんStateごとに、何のInput・次Stateが許されているか定義できるようにも作れる

type-challengesをやってみようと思います…ひとまず今の形で一旦実装できれば…!

想定しないものが来た場合は何も処理しない、言い換えると想定外の処理が来ても気付けないはず

Stateのprocessメソッド内で無視するのではなく、無視する(または受付可能な)Inputを各ステートで型で定義しておくということでしょうか?
(ステートマシン自体は常時すべてのInputを受け付けることになると思います)

各Stateのインスタンス変数は、初期値は不要なはず

constructorとonEnterをくっつけるのが良いかもと思いましたが、以下の問題もあるかもです。

  • constructorの処理がステートに入ったときに行う処理であるということがコードから分からないかも
  • constructorでcontextを渡す形だと、constructorでのみcontextを使用するのか、ステートでずっと保持されるのかが分からないかも

@Hiroshiba
Copy link
Member

Hiroshiba commented Aug 3, 2024

project-sequencer-statemachineでブランチ作ってみました!
https://github.com/VOICEVOX/voicevox/tree/project-sequencer-statemachine

Stateのprocessメソッド内で無視するのではなく、無視する(または受付可能な)Inputを各ステートで型で定義しておくということでしょうか?
(ステートマシン自体は常時すべてのInputを受け付けることになると思います)

そういうイメージでした!
が、無視するinputを列挙するのは若干不毛な気もしますね・・・。
うーん、今のプルリクエストの形、つまり全部受け付けて特に書かれてないのは無視が良さそう!!

constructorとonEnterをくっつけるのが良いかもと思いましたが、以下の問題もあるかもです。

なーるほどです。たしかに課題もありそうに感じます。

別案として、onEnterが終わったあとにできるインスタンス変数をまとめちゃうとか・・・?

class AddNoteState implements IState<State, Input, Context> {
  private readonly cursorPosAtStart: PositionOnSequencer;

  private innerContext: { // process終わったあとにできるものもありえるなら、enteredContextとかのが良いかも
    noteToAdd: Note;
    previewRequestId: number;
    executePreviewProcess: boolean;
    currentCursorPos: PositionOnSequencer;
  } | undefined = undefined

  onEnter(context: Context) {
    // 色々処理する
    this.innerContext = {noteToAdd, previewRequestId, }
  }

  onExit(context: Context) {
    // 必要なのだけ展開する感じとか
    const {previewRequestId} = this.innerContext;
  }
}

個人的に初期値は結構ミスを誘発する(初期化忘れとか)ので、型で防げる言語では防ぎたい気持ちがちょっと強めかもです。
1回書けばずっと気にしなくて良くなるので・・・。
でもこだわり過ぎる場所でもない気もするので、超強い意見ではないです!

@sigprogramming
Copy link
Contributor Author

ブランチ作成ありがとうございます!

別案として、onEnterが終わったあとにできるインスタンス変数をまとめちゃうとか・・・?

インスタンス変数をinnerContextとしてまとめました!

@sigprogramming sigprogramming changed the base branch from main to project-sequencer-statemachine August 8, 2024 15:20
Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!!!

いくつかコメントしていますが、そのままマージでも!

| {
noteToAdd: Note;
previewRequestId: number;
executePreviewProcess: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should付けると更に意味がわかりやすいかも?

Suggested change
executePreviewProcess: boolean;
shouldExecutePreviewProcess: boolean;

Comment on lines +142 to +145
private previewAdd(context: Context) {
if (this.innerContext == undefined) {
throw new Error("innerContext is undefined.");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ただのコメントです)

undefinedではないinnerContextを引数に取る手もありそう!

@Hiroshiba
Copy link
Member

あ、ブランチ分かれているので変更したい点あれば次のコミットに含めていただければ良さそう!
ということでマージします!!

@Hiroshiba Hiroshiba merged commit 521a8c0 into VOICEVOX:project-sequencer-statemachine Aug 8, 2024
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants