LOG ENTRY / post
楽器ラボ 統合版 — 5つの物理モデル楽器を1枚にまとめて曲を組む
楽器ラボ 統合版 — 5つの物理モデル楽器を1枚にまとめて曲を組む
→ 楽器ラボ 統合版
5 つのラボ(piano-lab / guitar-lab / bass-lab / drums-lab / violin-lab)を 1 枚にまとめ、同時に鳴らして曲が組めるところまで。既定の曲はバラード。
1. どうやって同居させたか
各ラボの *_dsp.js はそのまま置いておき、ビルド時に 2 か所だけ読み替えて 1 つのワークレットに並べています。
extends AudioWorkletProcessor → extends InstBase
registerProcessor('piano-processor', PianoProcessor) → INST_CLASS.piano = PianoProcessor
InstBase は port.postMessage をハブに横流しするだけのクラス。単体ラボのコードは 1 行も分岐していません。5 楽器のモデル・DSP を通しても名前の衝突はゼロでした(PN_ GT_ BS_ DRUM_ VN_ の接頭辞で分けてあったのが効いた)。
HubProcessor は各楽器を 128 サンプルのスクラッチに描かせ、トラックごとのゲイン・定パワーパン・リバーブ送りを掛けて足し、マスターでバスコンプとリミッタを通します。
発音のタイミング(ここが肝)
予約イベントはハブが 1 か所で持ち、予約時刻でブロックを割ってから各楽器に配ります。
while (pos < N) {
while (q.length && q[0].at <= frame + pos) deliver(q.shift());
let end = N;
if (q.length) { const nxt = q[0].at - frame; if (nxt > pos && nxt < end) end = nxt; }
renderSeg(L, R, pos, end - pos); // 楽器を部分ブロックで進める
pos = end;
}
こうすると、自前のキューを持たない piano / guitar もドラムと同じ精度で鳴ります。テストで確認していること:
- 予約を 37 サンプルずらすと、ドラムもベースもピアノも発音位置がちょうど 37 サンプル動く(ブロック境界にまとめていたら 0 か 128 になる)
- ドラムとギターの立ち上がりのずれは 0.35 ms
なおピアノだけは 85 サンプル(1.8ms)遅れて音が出ます。これはバグではなくハンマーが弦に届くまでの時間で、実機と同じです。
鳴っていない楽器は計算しない
出力が 0.4 秒無音のままなら、その楽器の process() を呼ぶのをやめます。メッセージが来たら復帰。ドラムだけ叩いているときは残り 4 楽器が完全に休みます。
負荷に応じた自動の音質調整
5 楽器同時はやはり重いので、ハブが負荷を見て段を落とします(ドラム単体ラボと同じ仕組みを全体に広げたもの)。
| 段 | シンバルのモード密度 K | ピアノの最大同時発音 | シンバル総モード数 | ドラムの実時間比 |
|---|---|---|---|---|
| 最高 | 7 | 24 声 | 498 | 3.4× |
| 高 | 5 | 18 声 | 365 | 5.3× |
| 中 | 3.5 | 12 声 | 263 | 7.1× |
| 軽量 | 2.5 | 8 声 | 191 | 8.9× |
判定の規則(2026-09-03 に直したもの。経緯は 6 章「20 秒くらいで重くなる」):
- 下げる: 負荷 52% 超またはピーク 76% 超が 0.4 秒続いたら即 1 段。以後 1.2 秒だけ様子見
- 戻す: 再生停止中に負荷 20% 未満が 5 秒続いたら 1 段。起動時の段(端末ごとの基準)より上には決して戻さない
- 再生中は戻さない。「自動」を手で入れ直したときは、そのとき選んでいる段が基準になる
- 互換モード(ScriptProcessor)で起動したときは基準を「中」以下にし、3D も切る(音声が主スレッドで動くため)
マスター欄で切ることもできます。
CPU(Node 実測・リアルタイム比。大きいほど余裕)
| ドラムのみ | 3.8× |
| ドラム+ベース | 3.1× |
| ピアノ和音のみ | 8.8× |
| 5 楽器 | 2.1× |
2. 3D — WebGL コンテキストは 1 つだけ
5 つのシーンクラスに opts を足して、レンダラと環境マップを共有できるようにしました(単体ラボは従来どおり自前で作ります)。
- 単体表示: 選んだ楽器を画面いっぱいに
- バンド全体: 同じキャンバスを
setScissorTestでタイルに区切り、5 つのシーン × 5 つのカメラを 1 つのコンテキストで描く
楽器のシーンは初めて表示するときに作ります(起動を軽くするため)。バンド表示では、鳴っていない楽器は形状の更新(弦の波形など)を飛ばして描画だけします。視点操作は指の乗っているタイルのカメラに効きます。
3. シーケンサー
音程 × ステップの格子。ドラムはキットの行、旋律楽器は調の音だけが行に並びます(半音・長調・短調・ドリアン・ペンタ・ブルース)。
連続したマスは 1 つの長い音として鳴ります。打ち直さずに伸ばすので、バイオリンやピアノで自然なフレーズになります。
曲は「1 小節あたりのステップ数(12/16/24/32)× 小節数」。格子は 1 小節ずつ表示し、◀▶ で小節を送るか「追従」で再生位置を追いかけます。
プリセット曲(すべて実測)
| 小節 | テンポ | 長さ | ピーク | RMS | クレスト | DSP 負荷 | |
|---|---|---|---|---|---|---|---|
| ロック | 24 | 104 | 0:55 | 0.78 | 0.057 | 13.7 | 23% |
| バラード | 20 | 70 | 1:09 | 0.76 | 0.054 | 14.1 | 16% |
| ファンク | 24 | 108 | 0:53 | 0.75 | 0.063 | 11.9 | 21% |
| ワルツ | 24 | 160 | 0:54 | 0.71 | 0.058 | 12.4 | 14% |
| 5楽器そろい踏み | 24 | 96 | 1:00 | 0.77 | 0.096 | 8.0 | 13% |
曲は音符を直接並べるのではなく、コード進行とドラムパターンから組み立てる小さな DSLで書いています(Ctx() の d() = ドラムを 'x...x...' の文字列で、c() = 和音、mel() = 旋律)。60 秒の曲でもソースは 20 行前後で済み、進行を変えれば全体が付いてきます。
序奏 → A → B → 終わり の区分けを入れてあり、楽器が順に足されていきます(ロックならバイオリンは 12 小節目から)。
音域から外れた音はオクターブで畳みます。最初は切り詰めていたのですが、ロックの C(MIDI 24)がベースの下限 E1 に丸められて「C のはずが E」になり、和声が壊れていました。
4. ミキサーと音のバランス
各楽器の内部リバーブは切って、ハブの共有ルームに送っています。同じ部屋で鳴っているように聞こえるのはそのためで、リバーブが 10 個ぶん動かずに済むという実利もあります。
既定のバランス(2 小節のバンド演奏で測った各楽器の RMS):
| ゲイン | パン | 残響送り | 実測 RMS | |
|---|---|---|---|---|
| ピアノ | 0.46 | −0.18 | 0.28 | −31.7 dB |
| ギター | 0.42 | +0.26 | 0.22 | −32.4 dB |
| ベース | 1.05 | 0.00 | 0.10 | −30.5 dB |
| ドラム | 0.67 | +0.05 | 0.16 | −32.8 dB |
| バイオリン | 0.28 | −0.30 | 0.34 | −31.0 dB |
5 本が 2.3 dB の幅に収まり、全体でピーク 0.66・RMS 0.083・クレスト 7.9。
5. 演奏面とつまみ
タブで楽器を切り替えると、演奏面(鍵盤/指板/パッド/バイオリンの指板)と 3D と詳細つまみが一緒に切り替わります。つまみは各ラボと同じ物理量が全部(ピアノ 10・ギター 12・ベース 11・ドラム 4・バイオリン 8)。ギターとベースはエフェクターのトグルも。
MIDI ノート → 各楽器のメッセージへの変換は hub_params.js が吸収しています(ピアノは 1〜127、ギター・ベースは 0〜1 の振幅、ドラムは GM 準拠のノート番号、バイオリンは弓圧と弓速)。
6. 引っかかったこと
ベロシティの流儀が楽器ごとに違う
ピアノの noteOn(vel) は MIDI の 1〜127、ギター・ベースの amp は 0〜1、ドラムの vel も 0〜1、バイオリンはニュートン単位の弓圧。最初 vel:0.8 をピアノに渡して「音が 40 dB 小さい」と悩みました。統合するときは、こういう単位の違いを 1 か所(hub_params.js)に閉じ込めるのが正解でした。
起動直後に −52 dBFS の小さなクリックが出る
ギターの Drive(非対称の真空管モデル)が入力ゼロでも DC を出し、DC ブロッカーが 15 サンプルかけて落とすため。単体では気になりませんが、統合版でミュートのテストをすると「ミュートしたのに 2.6e-3 出ている」と見えます。実害はないのでそのままにし、テストのほうを直しました。
重かったのは音ではなく描画コールだった
「重い」と報告を受けて計り直したところ、DSP は 30% 前後で余裕があり、詰まっていたのは主スレッドでした。犯人は描画コールの数で、ピアノのシーンだけで 1 フレーム 365 コール(ハンマー 88 個+ダンパー 88 個+鍵盤 88 組)。バンド表示ではこれが 5 面ぶん走ります。
入れた対策と実測(ヘッドレス・モバイル幅):
| fps | |
|---|---|
| 対策前 | 17 |
| 3D 通常(30fps 上限・バンドは 1 フレーム 2 面ずつ) | 23〜37 |
| 3D 軽量(15fps・1 面ずつ・ピアノのアクションを隠す) | 31〜46 |
| 3D オフ | 60 |
- 3D の描画に上限 fps を設ける。楽器のアニメーションは 30fps で十分で、rAF ごとに描く必要はない
- バンド表示は順ぐりに 2 面ずつ。
autoClearを切ってあるので、描き直さない面は前の絵がそのまま残る setDetail()を各シーンに用意し、軽量時はピアノのアクションを隠す(365 → 117 コール、21fps → 46fps)- スマホでは最初から 1 段落として起動する。「重くなってから下げる」だと、下がるまでの数秒が必ず途切れる
- fps が 20 を切ったら 3D を自動で 1 段落とす(手で選んだあとは触らない)
教訓: 3D が重いときは、まず三角形の数ではなく描画コールの数を見ること。ピアノは 8,671 三角形しかないのに 365 コールで、ドラム(29,154 三角形・86 コール)よりずっと重かった。
つまみを動かすと音が止まっていた(オーディオスレッドを塞ぐメッセージ)
「DSP がかなり落ちてた(音が途切れた)」という報告を受けて、UI から飛ぶメッセージ 1 つ 1 つが何 ms オーディオスレッドを止めるかを測ったところ:
| メッセージ | 前 | 後 |
|---|---|---|
| ドラム全体のチューニング(7個) | 68.8 ms | 0.06 ms |
| ドラム 1 個のチューニング | 16.1 ms | — |
| 音質の段を変える | 1.76 ms | 0.01 ms |
| ギター cfg(rebuild) | 0.94 ms | 0.77 ms(+指を離してから送る) |
レンダー量子は 128 サンプル=2.67 ms しかありません。69 ms 掛かる処理を 1 回走らせれば 26 ブロック分まるごと落ち、スライダーは 1 秒に何十回も input を投げるので、触っているあいだ音が止まりっぱなしになります。
原因は「モード表の作り直し」でした。太鼓のチューニングは dr.build() でベッセル零点からモードを組み直し、音質の段の変更は cymbalModes()(内部で数千回ループ)でシンバルを組み直していました。どちらも初期化のつもりで書いた関数が、演奏中の経路に入っていたわけです(バイオリンの designDispersion とまったく同じ間違い)。
直したのは 3 か所:
- チューニングは係数を掛け直すだけにする。膜のモード周波数は張力の平方根に比例して全部が同じ比で動くので、零点を求め直す必要はない。
ModalArray.setPitch(r, fs)は減衰(a2)をそのままに a1 だけ書き換える O(モード数)。実測で +7 半音が 254.5Hz(理論 254.7)と 0.1% 以内。 - 音質の段は「上から何本まで回すか」を変えるだけにする。モード列は周波数順なので、上限を下げる=高域を捨てる。
ModalArray.setLimit(frac)は 1 行。鳴っている最中に切ると段差が出るので、静かなときか次の打撃で反映する。 - 作り直しを伴うつまみは指を離してから送る(160ms のデバウンス)。
教訓(再): 「設計関数」を演奏経路に入れない。今回はさらに一歩進めて、UI から送れるメッセージ全部の所要時間を測るテストを入れました(どのメッセージも 1ms 未満)。この種のバグは音を聞かないと気づけないので、数値で縛るのが唯一の防御です。
「演奏が進むとフリーズ気味」— 解像度の自動調整が逆に働いていた
まず音の側を疑って 70 秒の曲をオフラインで通し、5 秒ごとのブロックあたり計算時間を並べましたがずっと平坦(20〜23%)。ピアノの声数も予約の数も溜まっていない。となると主スレッドです。
ブラウザで 60 秒回して長いタスク(50ms 超)を数えると、3D「軽量」で 8 秒に 34〜39 回、合計 2 秒ぶん主スレッドが止まっていました。原因は自分で入れた仕組みの組み合わせ:
- 3D を間引くようにした(15fps 上限)
- 解像度の自動調整は rAF の間隔 で「速いか遅いか」を判断していた
- 間引くと rAF の間隔は短く見える → 「速い」と誤解 → 解像度を上げ続ける → 実際に描く 1 回が 50ms 超の長いタスクになる
さらに直すときにもう一段ありました。performance.now() で renderer.render() を挟んで測っても 1〜2ms しか出ない。GL 命令は即座に返り、GPU 側の時間は次のフレームの確定に乗るからです。JS の中からは 3D の重さが見えない。
解: 「3D を描いた直後のフレーム間隔」と「描かなかったフレーム間隔」の差を 3D 1 回の実コストとして使う(observe(dt) → adapt())。コストが 14ms を超えたら解像度を下げ、最低(0.4)まで下げても 24ms 超なら描く回数そのものを減らす(5fps まで)。軽ければ戻す。
| 3D | 前 | 後 |
|---|---|---|
| 軽量 | 33fps・長いタスク 98 回/8秒 | 55〜60fps・0〜2 回 |
| 通常(ピアノ 365 コール) | 23fps・25 回 | 50〜56fps・4〜8 回(描画は 5fps・解像度 0.4 に自動で落ち着く) |
本物の GPU がある端末ではコストがずっと小さく測れるので、もっと高い回数・解像度に落ち着くはずです。
あわせて、タブが裏に回ったあとの復帰も直しました。タイマーが 1 秒に 1 回に絞られ、戻った瞬間に溜まったぶんが一気に「今」へ流し込まれて音の壁になるので、0.6 秒以上遅れていたらそのぶんは捨てて現在位置から続けます。
シンバルの減衰しきったモードを外す
ModalArray に「活きているモード数」を持たせ、末尾から縮められるようにしました(シンバルのモード列は周波数順で、高い方ほど早く消える)。連打しているときは効きませんが、尾を引いている間は効きます。打撃のたびに wake() で全部戻します。
ヘッドレス環境の測定は当てにならない(再)
バイオリンのピチカートと同じ問題で、ドラムのパッドを叩くテストが「鳴っていない」と誤判定しました。この環境は主スレッドが遅く、setTimeout のループが 1 秒近くかかるため、速く減衰する音の立ち上がりを取り逃がします。ワークレット側のピークホールド(outPk)を state で受けて測るのが正解。
「20 秒くらいで重くなり、途切れ途切れになっていく」— 自動調整が段を上げていた
ローカルで長く弾いてもらったときの報告。オフラインでもブラウザでも 150 秒回して溜まるものは何もなかった(DSP 負荷は横ばい、ヒープ・DOM・予約数も一定)ので、端末の熱を疑いかけましたが、GPT-5.6 に見てもらった版が正しく指摘していました。自動の音質調整そのものが原因です。
- スマホは起動時に段「高」から始める
- 段を変えると 80 回 × 0.2 秒 = 16 秒、何も判定しない
- 16 秒後、負荷が 30% 未満なら 1 段上げる → 「最高」へ
- 「最高」はその端末には重い。しかし上げた直後の 16 秒はまた判定しない → 途切れっぱなし
- ようやく下がる → 16 秒後にまた上がる、の繰り返し
つまり「20 秒くらいで」は 16 秒のヒステリシスそのもので、「だんだん悪くなる」は上がった段で熱を持つぶんでした。ヘッドレスの実測でも 15 秒の時点で q1 → q0 に上がっていたのに、負荷が上がっても十分低かったので見過ごしていました。
直したこと(GPT-5.6 版の考えを取り込み、こちらのソースとテストに合わせて入れ直した):
- 危険側は速く、安全側は遅く。下げるのは 0.4 秒連続で熱ければ即、戻すのは停止中に 5 秒安定してから
- 起動時の段より上には戻さない(
baseline)。端末に合った段で起動しているのだから、そこが上限 - 再生中は戻さない(
transportメッセージで再生状態をワークレットが知る) - ワークレット側の GC を減らす: 表示していない楽器は状態レポートを組み立てもしない(
reportEnabled。以前はハブで捨てていたが、1024 点の波形を 20Hz で切り出す配列生成は残っていた)。サブブロックごとのsubarrayもやめ、HUB_RENDER_Nで長さを渡して同じ配列を使い回す - 互換モードのバッファ不足を修正。ScriptProcessor は 2048 サンプル単位で来るのに楽器のスクラッチが 256 しかなく、そこでは NaN が出ていた(= 無音)。最大側で確保
- 音声負荷が 58% を超えたら 3D を先に 1 段落とす(同じ CPU を取り合うので)。3D の自動復帰も起動時の段まで
再発防止のテスト(test_hub.mjs): 再生中は負荷 10% が 60 秒続いても段を上げない/停止中でも基準より上へ戻さない/重くなったら 0.4 秒で下がる/2048 サンプル単位でも NaN にならず 128 単位と同じ音量。
教訓: 「安全側へ戻す」条件を「危険側へ進む」条件と同じ速さにしてはいけない。しかも「戻す」の上限は、その端末で実際に問題なく動いた段を超えてはいけない。
診断用の負荷ログ
「重いとき(負荷ログ)」欄に、1 秒ごとの DSP 負荷(ピーク付き)・楽器ごとの内訳・fps・cpu 比(同じ計算を毎回走らせた所要時間を、これまでの最短で割ったもの)を記録し、コピーできるようにしました。cpu 比だけが上がっていくなら端末のクロック低下、dsp% だけならコード側、と切り分けられます。
7. 音に集中モード
ヘッダの「♪ 音に集中」で、音声スレッド以外をほぼ何もしない状態にします。
- 3D・計器・演奏面・格子・タブのメーターの描画を止める(通常画面は
display:none) - ワークレットから主スレッドへの状態報告を 20Hz → 4Hz に(
rateメッセージ)。楽器ごとの状態報告は止める(watch: '') - 画面は曲名・経過時間・小節・5 楽器のレベル・DSP 負荷だけを 1 秒に 4 回更新
- 再生/停止と曲の切り替えはそこからできる。「画面を戻す」で元の状態(3D の段も)に復帰
主スレッドの長いタスク(50ms 超)は通常画面で 8 秒に 2 回 → 集中モードで 0。スマホでは主スレッドが空いたぶん CPU と熱の余裕が音声スレッドに回り、音質の自動調整が「最高」まで上がりやすくなります。
8. 動作の軽さ(UI)
3D の切り替えは 3D 画面のすぐ上(カメラ切替の並びの左端)に置いてあります。タップするたびに 通常 → 軽量 → オフ と回り、いまどれなのかがボタン自体に出ます。オフのときは画面に案内が出て、その場所を演奏面と格子にゆずります。
左レール先頭の「動作の軽さ」でも同じ操作ができます。
- 3D: 通常(30fps)/軽量(15fps・1 面ずつ・低詳細)/オフ(音はそのまま鳴る)
- 音質: 最高/高/中/軽量(シンバルのモード密度とピアノの声数)+自動調整のオン・オフ
スマホや 4 コア以下の端末は、起動時に 3D 軽量・音質「高」から始まります。
9. 入れていないもの
セクション構成の編集(プリセット曲は序奏/A/B/終を持つが UI からは組めない)、トラックごとのテンポ変化、外部への書き出し(WAV/MIDI)、和音の自動生成、ミキサーのセンド EQ。