FIELD NOTES / 2026

HUMAN × AI
OBSERVATION LOG

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

InstBaseport.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ピアノの最大同時発音シンバル総モード数ドラムの実時間比
最高724 声4983.4×
518 声3655.3×
3.512 声2637.1×
軽量2.58 声1918.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 負荷
ロック241040:550.780.05713.723%
バラード20701:090.760.05414.116%
ファンク241080:530.750.06311.921%
ワルツ241600:540.710.05812.414%
5楽器そろい踏み24961:000.770.0968.013%

曲は音符を直接並べるのではなく、コード進行とドラムパターンから組み立てる小さな 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.180.28−31.7 dB
ギター0.42+0.260.22−32.4 dB
ベース1.050.000.10−30.5 dB
ドラム0.67+0.050.16−32.8 dB
バイオリン0.28−0.300.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、ギター・ベースの amp0〜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 ms0.06 ms
ドラム 1 個のチューニング16.1 ms
音質の段を変える1.76 ms0.01 ms
ギター cfg(rebuild)0.94 ms0.77 ms(+指を離してから送る)

レンダー量子は 128 サンプル=2.67 ms しかありません。69 ms 掛かる処理を 1 回走らせれば 26 ブロック分まるごと落ち、スライダーは 1 秒に何十回も input を投げるので、触っているあいだ音が止まりっぱなしになります。

原因は「モード表の作り直し」でした。太鼓のチューニングは dr.build() でベッセル零点からモードを組み直し、音質の段の変更は cymbalModes()(内部で数千回ループ)でシンバルを組み直していました。どちらも初期化のつもりで書いた関数が、演奏中の経路に入っていたわけです(バイオリンの designDispersion とまったく同じ間違い)。

直したのは 3 か所:

  1. チューニングは係数を掛け直すだけにする。膜のモード周波数は張力の平方根に比例して全部が同じ比で動くので、零点を求め直す必要はない。ModalArray.setPitch(r, fs) は減衰(a2)をそのままに a1 だけ書き換える O(モード数)。実測で +7 半音が 254.5Hz(理論 254.7)と 0.1% 以内。
  2. 音質の段は「上から何本まで回すか」を変えるだけにする。モード列は周波数順なので、上限を下げる=高域を捨てる。ModalArray.setLimit(frac) は 1 行。鳴っている最中に切ると段差が出るので、静かなときか次の打撃で反映する。
  3. 作り直しを伴うつまみは指を離してから送る(160ms のデバウンス)。

教訓(再): 「設計関数」を演奏経路に入れない。今回はさらに一歩進めて、UI から送れるメッセージ全部の所要時間を測るテストを入れました(どのメッセージも 1ms 未満)。この種のバグは音を聞かないと気づけないので、数値で縛るのが唯一の防御です。

「演奏が進むとフリーズ気味」— 解像度の自動調整が逆に働いていた

まず音の側を疑って 70 秒の曲をオフラインで通し、5 秒ごとのブロックあたり計算時間を並べましたがずっと平坦(20〜23%)。ピアノの声数も予約の数も溜まっていない。となると主スレッドです。

ブラウザで 60 秒回して長いタスク(50ms 超)を数えると、3D「軽量」で 8 秒に 34〜39 回、合計 2 秒ぶん主スレッドが止まっていました。原因は自分で入れた仕組みの組み合わせ:

  1. 3D を間引くようにした(15fps 上限)
  2. 解像度の自動調整は rAF の間隔 で「速いか遅いか」を判断していた
  3. 間引くと 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 に見てもらった版が正しく指摘していました。自動の音質調整そのものが原因です。

  1. スマホは起動時に段「高」から始める
  2. 段を変えると 80 回 × 0.2 秒 = 16 秒、何も判定しない
  3. 16 秒後、負荷が 30% 未満なら 1 段上げる → 「最高」へ
  4. 「最高」はその端末には重い。しかし上げた直後の 16 秒はまた判定しない → 途切れっぱなし
  5. ようやく下がる → 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。