ピアノアプリを二段鍵盤に対応させた話 — 同時押下の参照カウントとスクロール永続化

| 開発記録 | ピアノ

タグ: #Flutter #ピアノ #個人開発 #状態管理

ピアノアプリ v5.5.0 で要望の多かった二段鍵盤(上下2段表示)に対応しました。高さの等分割、同じ鍵盤を上下から同時に押せる問題の参照カウント解決、段ごとのスクロール位置永続化、切り替え時の音鳴りっぱなし対策まで、実装の裏側を記録します。

「鍵盤を2段にしてほしい」という要望

ピアノアプリに以前からいただいていた要望のひとつが「鍵盤を上下 2 段にしてほしい」でした。実際のピアノは 88 鍵が一列に並んでいますが、スマホやタブレットの横幅では一度に表示できる鍵盤数に限界があります。上下 2 段にすれば、画面に映る鍵盤が倍になり、両手で離れた音域を弾けるようになります。

先日リリースした v5.5.0 でこの二段鍵盤対応を入れたので、実装で考えたことを記録しておきます。

レイアウト: 高さの等分割と divider の扱い

まず単純な問題として、鍵盤エリアの高さをどう分けるか。間に 4px の区切り線(divider)を入れて、残りを上下で等分する方式にしました。

final perRowHeight =
    ((effectiveHeight - _kDualRowDividerHeight) / 2)
        .clamp(0.0, double.infinity);

最初の実装では Column に 2 段目を足しただけで画面からはみ出しました。固定の鍵盤高さを前提にしたコードが残っていたためで、LayoutBuilder で親の実制約を取って動的に分割する形に直しています。地味ですが、divider を各段のタッチ判定(Listener)の外側に置くのもポイントです。区切り線をタップしたときに鍵盤押下と誤判定されません。

同じ鍵盤を上下から同時に押せる問題

上下の段にはそれぞれ 88 鍵の全音域を割り当てました。つまり同じ「ド」が上の段にも下の段にもあります。すると、上段のドを押したまま下段の同じドを押し、片方だけ離す、という操作が起こり得ます。素朴に「離したら音を止める」と実装すると、まだ押されているのに音が消えてしまいます。

そこで鍵盤ごとに参照カウントを持たせました。

final Map<int, int> _keyPressCounts = {};
// 0 → 1 の遷移でのみ playKey
// 1 → 0 の遷移でのみ stopKey

カウントが 0 から 1 になった瞬間だけ発音し、1 から 0 に戻った瞬間だけ停止する。オーディオ層には一切手を入れず、UI 層の押下管理だけで解決できました。

段ごとに独立したスクロール位置と、その永続化

上下の段はそれぞれ独立にスクロールできます。上段は中央のド付近、下段は低音域、のような使い方を想定しているからです。となると、アプリを再起動したときに両方の位置を覚えていてほしい。

スクロール位置の保存は、ドラッグ中に毎フレーム DB に書き込むと I/O が無駄に走るので、500ms のデバウンスを入れました。最後の操作から 500ms 経ったら書き込む方式です。ただしデバウンスだけだと「操作直後にアプリを閉じた」ケースで保存が間に合わないため、バックグラウンド遷移時と dispose 時に未書き込み分を強制フラッシュしています。

リリース直後には「段を切り替えると下段のスクロール位置が上段の値で上書きされる」というバグも踏みました。上下の保存キーを分離して修正済みです(v5.5.0+57)。

段切り替え時の「鳴りっぱなし」対策

鍵盤を押したまま段数を切り替えると、押下中のポインタ情報が宙に浮いて音が鳴りっぱなしになります。これは切り替えイベントで全鍵盤を強制解放することで対処しました。UI 側のリスナーと状態管理側の両方に解放処理を入れる二重防御です。片方だけだと、Widget の破棄タイミングによってすり抜けるケースがありました。

まとめ

二段鍵盤は「鍵盤をもう一段描くだけ」に見えて、実際には同時押下の参照カウント、段ごとのスクロール永続化、切り替え時のクリーンアップと、状態管理の設計を問われる機能でした。久しぶりに歯ごたえのあるアップデートだったので、ぜひ 2 段モードで連弾風の演奏を試してみてください。