ピアノアプリにベロシティ(強弱)を実装するまで

| 開発記録 | ピアノ

タグ: #Flutter #オーディオ #UX

鍵盤を強くタップしたら大きな音、優しく押したら小さな音。「あって当然」のように見える機能を、画面タッチだけのスマホで再現するまでの試行錯誤を記録します。

なぜ今ベロシティなのか

zumec apps のピアノアプリは長く「鍵盤をタップすれば均一な音量で鳴る」シンプルな設計でした。シンプルさは武器ですが、レビューでよく頂くのが「もう少し強弱がつけられたら…」という声でした。リアルなピアノに近づけたい、でも触ったら全部同じ音量なのは寂しい、というジレンマです。

純関数とPianoConfig定数の分離

最初にやったのは、ベロシティ計算ロジックを純関数として切り出すことでした。タッチ座標、押下速度、対応デバイスの pressure range(pressureMin/pressureMax)など、入力となる値を引数で受け取り、0〜127の MIDI ベロシティ値を返す形にしました。

PianoConfig という定数クラスにマジックナンバーを集約し、調整値を一箇所で管理できるようにしました。「指の腹で押した時」「爪先で叩いた時」のしきい値が散らばらないようにしたかったのです。

実際の calculateVelocity はおおよそ次のような形です。すべての入力を引数で受け取り、戻り値は 0〜127 の整数。状態を持たないので、テストが書きやすく、デバイス差吸収もこの関数の中で完結します。

/// 押下情報から MIDI ベロシティ (0..127) を返す純関数。
int calculateVelocity({
  required double pressure,
  required double pressureMin,
  required double pressureMax,
  required double normalizedY, // 鍵盤上端=0.0, 下端=1.0
  required bool isStylus,
}) {
  // pressure 非対応デバイスは Y 座標フォールバック
  final hasPressure = (pressureMax - pressureMin).abs() > 0.001;
  if (!hasPressure || !isStylus) {
    final yVelocity = (1.0 - normalizedY).clamp(0.0, 1.0);
    return _scaleToMidi(yVelocity);
  }

  final range = pressureMax - pressureMin;
  final ratio = ((pressure - pressureMin) / range).clamp(0.0, 1.0);
  return _scaleToMidi(ratio);
}

int _scaleToMidi(double ratio) {
  const minVel = PianoConfig.minVelocity; // 例: 24
  const maxVel = PianoConfig.maxVelocity; // 127
  return (minVel + (maxVel - minVel) * ratio).round();
}

デバイス判定の落とし穴

苦戦したのが、すべての iOS デバイスが pressure 入力を持つわけではないという現実です。3D Touch 廃止以降のデバイスでは pressure が常に一定値を返してきます。これを「強くタップしている」と誤判定すると、軽く触っただけで爆音が鳴ります。

対策として、pressureMin == pressureMax なら pressure 非対応とみなし、その場合は Y 座標で代替する方式に切り替えました。鍵盤の上端をタップすれば強く、下端なら弱く鳴る、という挙動です。

stylus と通常タッチの混在問題

Apple Pencil 対応端末では、stylus(ペン)と通常タッチが混在します。当初は両方とも pressure を信用して処理していましたが、リリース直後に「鍵盤を軽くタップしたのに最大音量で鳴る」というクラッシュ報告が複数届きました。

緊急修正でやったのは、通常タッチは強制的に Y 座標フォールバック、stylus のみ pressure を信用する切り分けです。コミットメッセージにも「緊急修正」と書いてあるくらい、優先度高く対応しました。

バックグラウンド遷移での音停止

ベロシティ実装と同時期に、バックグラウンド遷移時の stuck notes(鳴りっぱなし)も問題になりました。panicStopAll というメソッドを AudioService に追加し、アプリが背景に回ったタイミングで全ての鍵盤を解放するようにしました。

設定 UI と多言語対応

ベロシティは好みが分かれる機能なので、設定ダイアログで ON/OFF を切り替えられるようにしました。これを 14 言語分用意するのが地味に大変で、各言語ファイルに「Velocity」相当の訳語を入れていく作業を黙々と続けました。

学び

単純に「ベロシティ対応しました」と一行で書けてしまう機能ですが、実際は「デバイス差」「入力種別の差」「バックグラウンド挙動」「UI と多言語化」と、見えにくい仕事の積み重ねでした。ピアノアプリのリアルさは、こういう泥臭い実装が支えているのだと改めて感じます。