サスティンペダルを CC#64 イベントとして録音再生する仕組み

| 開発記録 | ピアノ

タグ: #MIDI #Flutter #Drift

ピアノ録音でペダル操作を含めて再現するには、押した瞬間と離した瞬間をタイムラインに記録する必要があります。MIDI でいう CC#64 をどう設計したかをまとめます。

ペダルなしの録音は、ペダルなしのピアノ

録音機能つきピアノアプリでは、長らく「鍵盤を押した・離した」だけを記録していました。ところがピアノ演奏の表現力の半分はペダルが担っているといっても過言ではなく、ペダル抜きの録音はどうしても「ぶつ切り」の印象になります。

そこで、サスティンペダル(CC#64 相当)の操作を録音タイムラインに統合する設計に踏み切りました。

DB スキーマ v6: CcEvents テーブルの追加

録音データを保存する Drift の DB スキーマに、CcEvents テーブルを追加しました。

  • recording_id: どの録音セッションに属するか
  • timestamp_ms: 録音開始からの相対時刻
  • cc_number: 64 = サスティン
  • value: 0〜127(実質的に 0 か 127 だが将来拡張用に幅を持たせた)

Drift のテーブル定義はおおよそ次のような形です。recording_id で録音ごとに引けるよう外部キーとインデックスを張っています。

@DataClassName('CcEventEntity')
class CcEvents extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get recordingId =>
      integer().references(Recordings, #id, onDelete: KeyAction.cascade)();
  IntColumn get timestampMs => integer()();
  IntColumn get ccNumber => integer()(); // 64 = sustain
  IntColumn get value => integer().withDefault(const Constant(0))();

  @override
  List<Set<Column>> get uniqueKeys => [
        {recordingId, timestampMs, ccNumber},
      ];
}

スキーマ変更はマイグレーション v5 → v6 で行いました。既存ユーザーの録音データは KeyPress イベントしか持たないので、再生時は CcEvents が無くても問題なく動くようにしてあります。

RecordingService に recordSustainChange を追加

ペダルの ON/OFF が切り替わったタイミングで、RecordingService.recordSustainChange() が呼ばれて DB に書き込まれます。WidgetRef を引き回す必要があるため、addSustainPointer / removeSustainPointer の引数にもこれを追加しました。

ここで地味に悩んだのが、ペダルを長押し中に複数回イベントを発火させないことでした。押し続けている間は ON 状態が持続しているだけなので、ON のエッジと OFF のエッジだけを記録する方針にしました。

再生時のタイムライン統合

再生時は、KeyPress イベントと CcEvent を時刻順にマージして、ひとつのタイムラインとして再生します。

[ KeyPress(C4, t=100ms) ]
[ CcEvent(64, ON, t=120ms) ]
[ KeyPress(E4, t=200ms) ]
[ CcEvent(64, OFF, t=400ms) ]

この順で AudioService に渡せば、ペダル ON 中の音はホールドされ、OFF のタイミングで一斉に解放されます。

タイムラインのマージは KeyPress と CcEvent を時刻順に並べて単一の PlaybackEvent ストリームに正規化します。再生エンジン側は CC か Note かを意識せず、ただ来たイベントを順番に流すだけにできるのがポイントです。

Iterable<PlaybackEvent> mergeTimeline(
  List<KeyPressEntity> keys,
  List<CcEventEntity> ccs,
) sync* {
  final events = <PlaybackEvent>[
    ...keys.map((k) => PlaybackEvent.note(k.timestampMs, k.note, k.velocity)),
    ...ccs.map((c) => PlaybackEvent.cc(c.timestampMs, c.ccNumber, c.value)),
  ]..sort((a, b) {
      final byTime = a.timestampMs.compareTo(b.timestampMs);
      // 同時刻なら CC を先に流して、ノートに確実に反映させる
      if (byTime != 0) return byTime;
      return a.isCc ? -1 : 1;
    });
  yield* events;
}

停止時のサスティン同期

難所が、再生途中で停止された時の挙動でした。サスティン ON のまま停止すると、AudioService 側は音を持ち続けてしまいます。対策として、停止時に強制的に CC#64 = OFF を送ってからストップする処理を入れました。CC 適用の順序保証も合わせて入れて、停止イベントが先に処理されてしまうケースを防いでいます。

グリッサンド中のサスティン

リリース後に「グリッサンドしている最中にペダルを踏んでも反映されない」という報告が入り、ペダル状態が連続演奏中にも追従するよう修正しました。これは v2.3.0 のアップデートで盛り込んでいます。

終わりに

ペダル対応で録音の表現力は明らかに広がりましたが、それを支えているのは「DB に CC#64 をどう載せるか」「タイムラインをどうマージするか」「停止時にどう同期するか」という設計の積み重ねでした。仕様書には一行「サスティンペダル対応」と書くだけのところに、こんなに考えることがあるのかと自分でも驚きます。