Skip to content

ライブ MIDI 入力と Web MIDI ​

ライブ MIDI 入力は、リアルタイムエンジンを演奏できる楽器に変えます。USB キーボードで押した鍵がノートオンイベントになり、エンジンはそれをバインド済みのシンセサイザーへ送り、次の音声ブロックで音が鳴ります。ファイルもオフラインレンダーも要りません。

libsonare の RealtimeEngine は、トランスポート・クリップ・オートメーションと同じリアルタイム安全な入口からライブ MIDI を受け取ります。ブラウザでは、小さな Web MIDI ブリッジ(bindWebMidi)が、プラットフォームの MIDI ポートをそのエンジンへ直接つなぎます。

最初に選ぶ入口は 2 つです。

手元にあるものまず使うもの
アプリ内のオンスクリーンキーボードやシーケンサーのステップ即時の pushMidiNoteOn / pushMidiNoteOff 呼び出し
USB キーボードや外部コントローラーbindWebMidi。内部ではライブ入力ソース用の呼び出しを使います

MIDI デスティネーション

MIDI デスティネーションはスピーカー出力ではありません。エンジン内部の楽器スロットです。MIDI イベントは 0 のようなデスティネーション ID に送られ、その ID にバインドされた楽器が実際の音を決めます。

このページの位置づけ

本ページはコントローラーからエンジンを演奏する話です。ノートが届く楽器のバインドは 内蔵シンセサイザー(NativeSynth。パッチ駆動のシンセサイザー)と SoundFont プレイヤー(GS/GM の .sf2 再生)を参照してください。演奏をタイムラインへ録音するには 録音とテイク を参照します。マイク音声の入力は別経路です。最後の節を参照してください。 MIDI が初めてなら、まず MIDI の基礎 を参照してください。

ライブ MIDI の流れ ​

ブラウザが MIDI バイトを受け取り、bindWebMidi がエンジンイベントへ変換し、デスティネーションの楽器が process(...) の中で音声を作ります。以下の矢印はそれぞれ、生の鍵盤入力からサンプルデータに至る経路上の 1 ホップであり、ループラベルは各ノートで繰り返される部分を示します。

USB キーボードから音声出力まで
LOOP · MIDI メッセージごとノートオン /ノートオフ / CCpushMidiInput*(サンプル時刻)デスティネーションID でルーティングprocess() 内で発音USB キーボードbindWebMidiRealtimeEngine楽器(デスティネーション 0)音声出力ブロック
エンジンより左側はイベントごとに 1 回、右側は音声ブロックごとに 1 回だけ実行されます。

音が出ないときは、ブラウザ権限、bindWebMidi の入力一覧、デスティネーション ID、バインド済み楽器、AudioWorklet/出力配線の順に確認してください。

MIDI デスティネーションモデル ​

エンジンはノートを直接鳴らすのではなく、MIDI デスティネーションへ送り、各デスティネーションには楽器がバインドされています。デスティネーションは小さな整数 ID(既定は 0)で識別します。楽器を一度バインドすれば、その ID 宛のライブイベントやスケジュール済みクリップはすべてその楽器でレンダリングされます。

デスティネーションには 3 種類の楽器を置けます。

バインド方法楽器参照
setBuiltinInstrument(config, destinationId)内蔵の波形シンセ(データ不要の最下層)—
setSynthInstrument(patch, destinationId)パッチ駆動の内蔵シンセサイザー(NativeSynth)内蔵シンセサイザー
setSf2Instrument(config, destinationId)GS 互換の SoundFont プレイヤーSoundFont プレイヤー

MIDI 2.0 と MPE 表現

MPE はゾーンの中で鳴っているノートごとに別のチャンネルを割り当てるので、和音の 1 音だけにベンドやプレッシャーをかけられます。ゾーン、マネージャーチャンネルとメンバーチャンネル、UMP のグループは MIDI の基礎で説明しています。

内蔵の波形シンセ(setBuiltinInstrument)、NativeSynth(setSynthInstrument)、SoundFont プレイヤー(setSf2Instrument)は、MIDI 2.0 のチャンネルボイス UMP を値の幅を落とさずに受け取ります。ノートベロシティ、コントローラー、プレッシャー、ピッチベンドは MIDI 2.0 メッセージから渡された分解能を保ちます。内蔵シンセと NativeSynth はチャンネルプレッシャー、ポリフォニックキープレッシャー、ノート単位のピッチベンドを適用します。SoundFont プレイヤーもフル幅のノートベロシティ、チャンネルプレッシャー、ピッチベンド、MIDI 2.0 のノート単位ピッチを適用しますが、ポリフォニックキープレッシャーは意図的に無視します。そのため MPE スタイルのメンバーチャンネルで各プレイヤーへチャンネル表現を送れますが、SoundFont ではポリフォニックプレッシャー表現を使えません。

内蔵シンセでは、MPE のマネージャーチャンネルに送ったゾーン全体向けのコマンド(CC64 サステインなど)がゾーン内のすべてのメンバーチャンネルに届きます。

NativeSynth と SoundFont プレイヤーのベンドレンジは RPN 0 で設定し、内蔵シンセは MPE ゾーンの設定を使います。汎用の高分解能 RPN/NRPN と 14 ビット CC のパラメータマッピングは、後述するエンジンの bindMidiCcBinding(...) 層にあります。NativeSynth のベンドと表現の詳細は 内蔵シンセサイザー を参照してください。

typescript
import { init, RealtimeEngine } from '@libraz/libsonare';

await init();

const engine = new RealtimeEngine(48000, /* maxBlockSize */ 128);

// デスティネーション 0 → NativeSynth プリセット(synthPresetNames() 参照)
engine.setSynthInstrument('saw-lead', 0);

// 複数のデスティネーションを同時に動かし、それぞれに楽器を持たせられる
engine.setSf2Instrument({ gain: 1 }, 1);

clearMidiInstrument(destinationId) で 1 つのバインドを解除し、midiInstrumentCount() で現在の数を確認できます。複数デスティネーションを使えば、1 つのエンジンに重ねたリグ(0 にリードシンセ、1 にドラム、など)を持たせられます。

トランスポートが走っている間にデスティネーションを再バインドすると、鳴っていた楽器は 10 ms でフェードアウトし、新しい楽器は既定のチャンネル状態から始まります。プログラムやバンクは、再バインドの後で pushMidiProgram を使って送り直してください。リアルタイムエンジンも参照してください。

ライブイベントのキューイング ​

ライブイベントは同期実行ではなくキューイングされます。各呼び出しは、イベントを発火させるサンプル位置をエンジンへ渡します。次の process(...) ブロックが、そのブロックで処理すべきイベントをすべて消費します。イベントは「メッセージが届いた時」ではなく指定したフレームで処理されるため、タイミングが正確になります。

キューイングの経路は 2 つあり、デスティネーションごとにどちらかを選びます。目安として、イベントを自分のコードで生成する場合(シーケンサーのステップ、オンスクリーンキーボード)は即時コマンドを使い、ハードウェアキーボードのように外部から独自のタイムスタンプ付きでイベントが届く場合(bindWebMidi 経由)は入力ソースを使います。後者のレーンは、Web MIDI ブリッジが必要とするポートごとのタイムスタンプを運ぶためです。

呼び出し対象タイミング引数備考
pushMidiNoteOn / pushMidiNoteOff / pushMidiCc1 つのデスティネーションrenderFrame?省略すると、できるだけ早く発音します
pushMidiProgram1 つのデスティネーションrenderFrame?MIDI 2.0 のプログラムチェンジ 1 件。後述
pushMidiSysex1 つのデスティネーションrenderFrame?0xF0 から 0xF7 までの完全なフレーム(1〜512 バイト)
pushMidiUmp1 つのデスティネーションrenderFrame?1〜4 ワード。MT 0x4 は値の幅を保ち、MT 0x3 / 0x5 は拒否します
pushMidiPanicすべてのデスティネーションrenderFrame?発音中のノートをすべて解放します
pushMidiInput*(NoteOn、NoteOff、Cc、PitchBend、ChannelPressure、PolyPressure、Program、Ump)入力ソースoffsetInBlockSamplessetMidiInputSource(destinationId) が必要。Web MIDI ブリッジが使うレーンです
  • 即時エンジンコマンド — pushMidiNoteOn / pushMidiNoteOff / pushMidiCc はそれぞれ destinationId と省略可能な renderFrame を取り、省略すると「できるだけ早く」発音します。pushMidiPanic(renderFrame?) は省略可能な renderFrame のみを取り、すべてのデスティネーションの発音中ノートを一括で解放します。
  • 拒否される値 — ライブ push はすべて、MIDI の範囲外の値を、引数名と範囲を示すエラーで拒否します(channel must be in [0, 15]、bend14 must be in [0, 16383] など)。デスティネーション向けの push も入力ソース向けの push も同じで、Node と WASM のパッケージのクラスは RangeError(生の embind エンジンは CppException)、Python は SonareValueError、C は InvalidParameter です。いずれも呼び出した関数名が先頭に付きます。負の renderFrame も同様に拒否されます。「指定なし」は引数の省略で表し、-1 では表しません。
  • エンジン側のライブ入力ソース — setMidiInputSource(destinationId) で専用の入力レーンを開き、pushMidiInputNoteOn / pushMidiInputNoteOff / pushMidiInputCc で offsetInBlockSamples タイムスタンプ付きのイベントを送ります。pushMidiInputPitchBend、pushMidiInputChannelPressure、pushMidiInputPolyPressure、pushMidiInputProgram、pushMidiInputUmp で表現情報、プログラムチェンジ、完全な UMP パケットも送れます。入力ソースが無効のときの push は InvalidState、入力キューが満杯のときは OutOfMemory で失敗します。入力ソースを含まない WASM ビルドでは NotSupported が投げられます。
  • ライブ SysEx — pushMidiSysex(destinationId, data, renderFrame?) は、デスティネーションへ完全な SysEx(System Exclusive。メーカー独自の自由形式メッセージ)フレームをキューイングします。data は先頭の 0xF0 と末尾の 0xF7 を含む完全なメッセージ(1〜512 バイト)で、renderFrame はほかの pushMidi* 呼び出しと同じく、省略すると即時になります。主な用途は、再生を止めずに、ライブの SF2 バインド済みデスティネーションへ GS/GM リセットや GS インサーションエフェクト(EFX)の選択を届けることです。そのバイト列が何を選ぶかは SoundFont プレイヤー を参照してください。
  • UMP メッセージ — pushMidiUmp(destinationId, words, renderFrame?) は、1〜4 個の 32 ビットワードからなる配列または Uint32Array を最上位ワードから順に受け取ります。個数は先頭ワードのメッセージ種別と一致させます。MIDI 2.0 チャンネルボイス(MT 0x4)は値の幅を保ったまま処理し、SysEx7/データメッセージ(MT 0x3/0x5)は拒否するため pushMidiSysex を使います。SonareEngine の worklet ファサードも pushMidiUmp(trackId, words, renderFrame?) で同じ完全なパケットを受け付けます(従来の 1 ワードの数値指定も使えます)。pushMidiInputUmp(words, offsetInBlockSamples = 0) はエンジン側のライブ入力ソースへ送ります。

同じ SysEx 呼び出しはどのバインディングにもあり、名前の付け方だけが変わります。data は先頭の 0xF0 と末尾の 0xF7 を含む完全なフレーム(1〜512 バイト)で、最後の引数はほかの pushMidi* 呼び出しと同じ省略可能なレンダーフレームです(省略、Python では None で即時)。「即時」を -1 で表すのは C ABI だけです。

ライブ SysEx のペイロードはキューへ入れる前に制御スレッドでデスティネーションの楽器が準備します。準備を拒否された場合は、拒否された引数として報告され(RangeError。C は InvalidParameter)、同じペイロードを再試行しても解決しません。setMidiClips はスケジュールするすべての SysEx を公開前に準備するため、1 つでも拒否されると、直前に受理されたスケジュールを保持します。デスティネーションの再バインドでも、スケジュール済みおよびすでにキューへ入った SysEx の準備を先に行います。拒否された場合(InvalidState)は以前の楽器バインドと保留中の処理を保持します。その後の prepare() でスケジュール済みペイロードを再確認し、バインド済みの楽器が拒否した場合はスケジュールをクリアして InvalidState を報告します。

typescript
engine.pushMidiSysex(/* destinationId */ 0, gsResetOrEfxBytes);
typescript
// ブラウザと同じ形。data は Buffer か Uint8Array。
engine.pushMidiSysex(0, gsResetOrEfxBytes);
python
# data は bytes / bytearray / memoryview。render_frame=None(既定)が即時。
engine.push_midi_sysex(0, gs_reset_or_efx_bytes)
c
/* size は 1〜512。C ABI では render_frame -1 が即時のまま。 */
sonare_engine_push_midi_sysex(engine, /* destination_id */ 0,
                              data, size, /* render_frame */ -1);
typescript
// 即時経路: 次のブロック先頭でノートを発火
engine.pushMidiNoteOn(/* destinationId */ 0, /* group */ 0, /* channel */ 0, /* note */ 60, /* velocity */ 100);
engine.pushMidiCc(0, 0, 0, /* controller */ 1, /* value */ 64);
engine.pushMidiNoteOff(0, 0, 0, 60, 0);

// スケジュール指定: 同じ呼び出しにブロック内相対のレンダーフレームを渡します。
engine.pushMidiNoteOn(0, 0, 0, 64, 100, /* renderFrame */ 64);

// 入力ソース経路(bindWebMidi が内部で使う経路)
engine.setMidiInputSource(0);
engine.pushMidiInputNoteOn(/* group */ 0, /* channel */ 0, 60, 100, /* offsetInBlockSamples */ 0);
engine.pushMidiInputCc(0, 0, 1, 64, 0);
engine.pushMidiInputNoteOff(0, 0, 60, 0, 0);
// engine.midiInputPendingCount()  -> 次の process() ブロックを待つイベント数

group と channel は MIDI ニブル(0..15)、note・velocity・controller・value は 7 ビット(0..127)です。ベロシティ 0 のノートオンは、MIDI 仕様どおりノートオフとして扱われます。

プログラムチェンジに従う受信側 ​

pushMidiProgram(destinationId, group, channel, program, bankValid?, bankMsb?, bankLsb?, renderFrame?) と pushMidiInputProgram(group, channel, program, bankValid?, bankMsb?, bankLsb?, offsetInBlockSamples?)(Python は push_midi_program、push_midi_input_program、C は sonare_engine_push_midi_program、sonare_engine_push_midi_input_program)は、ノートや CC と同じタイミングで MIDI 2.0 のプログラムチェンジを 1 件キューに入れます。バンクは省略可能で、bankValid が true のときだけ適用されます。bankValid が false のまま bankMsb / bankLsb に 0 以外を渡すと拒否されます。SonareEngine の worklet ファサードは pushMidiProgram(trackId, group, channel, program, bankValid?, bankMsb?, bankLsb?, renderFrame?) です。

メッセージは受信側自身のプログラム受信条件に従うため、実際に反映するのは一部の受信側だけです。

受信側プログラムチェンジに従うか
SoundFont プレイヤー従う(パートの GS 受信スイッチ(プログラムチェンジ・バンクセレクト)がオフでない限り)
useGmPrograms: true でバインドした NativeSynth(Python は use_gm_programs)従う。バンクとプログラムから各チャンネルの音色を決める
GM キットを鳴らす NativeSynth(drum-kit プリセット、または gmKit をオンにした打楽器パッチ)従う。プログラムが全チャンネルで GS ドラムセットの選択になる
内蔵シンセ、ほかの NativeSynth パッチ、GS のプログラムチェンジ受信スイッチをオフにしたパート、MPE のメンバーチャンネル受け付けたうえで無視する

MIDI CC をエンジンパラメータへバインドする ​

CC は二役を果たせます。楽器へ届くと同時に、エンジンのオートメーションパラメータも動かせます。bindMidiCc(channel, controller, paramId, options) は、コントローラーの 7 ビット値を登録済みパラメータの [minValue, maxValue] へマッピングし、その間も CC はデスティネーション楽器へ流れ続けます。

typescript
// CC で動かしたいパラメータを登録し、モジュレーションホイール(CC1)を割り当てる
engine.addParameter({ id: 42, name: 'cutoff', minValue: 0, maxValue: 1, defaultValue: 0.5 });
engine.bindMidiCc(/* channel */ 0, /* controller */ 1, /* paramId */ 42, { minValue: 0, maxValue: 1 });

// engine.midiCcBindingCount()  -> 1
// engine.clearMidiCcBindings() -> すべてのマッピングを削除

高分解能のコントローラーには、記述子を渡す bindMidiCcBinding(...) を使います。kind はコントローラーの形式と、読まれるフィールドを決めます。

kind形式使うフィールド
07 ビット CCccNumber
114 ビットの CC ペアccNumber、ccLsbNumber
2RPNselectorMsb、selectorLsb
3NRPNselectorMsb、selectorLsb

channel: 255 にすると、すべての MIDI チャンネルに一致します。

typescript
engine.bindMidiCcBinding({
  kind: 3, // NRPN
  channel: 0,
  ccNumber: 99,
  selectorMsb: 1,
  selectorLsb: 2,
  paramId: 42,
  minValue: 0,
  maxValue: 1,
});

addParameter は unit(表示用の文字列)・rtSafe・defaultCurve も受け取ります。ここで効いてくるのは rtSafe(既定は true)です。これは、音声スレッドが再生中にそのパラメータを変更してよいかを宣言します。演奏中に鳴らしながら CC バインドで動かしたいものは true のままにしてください。rtSafe: false で登録すると、オートメーションからでもバインド済み CC からでも、直接の setParameter(...) 呼び出しからでも、そのパラメータへのライブ書き込みは、トランスポートの再生中・停止中を問わずすべて拒否されます。

CC ラーンのワークフロー

オフラインで「ツマミを動かし、どの CC が動いたか取り込む」流れの Project.midiCcLearn は、ライブエンジンではなく、取り込んだ ProjectMidiEvent データを対象とします。CC をラーンしてオートメーションにするを参照してください。

デバイスのジェスチャーを表現軸に割り当てる ​

コントローラープロファイルは、デスティネーション上のインストゥルメントにとってジェスチャーが何を意味するかを定めます。たとえば CC2 をブレスの強さ、チャンネルプレッシャーをラウドネス、ピッチベンドをセント単位のピッチオフセットとして扱います。エンジンのパラメータを動かす bindMidiCc とは別物で、プロファイルはインストゥルメント自身の表現軸を動かします。

  • controllerProfileNames() はプリセット名 'gm'、'breath'、'breath-aftertouch'、'mpe' を返します。
  • engine.setControllerProfile(destinationId, name) はデスティネーションのプロファイルをプリセットで置き換え、各チャンネルに蓄積された軸の値を消去します。未知の名前、インストゥルメントのないデスティネーション、プロファイルを持たないインストゥルメントでは例外になります。
  • engine.bindController(destinationId, binding) は ControllerBinding を 1 つ追加します。input('control-change'、'channel-pressure'、'poly-pressure'、'pitch-bend'、'velocity')、index(CC 番号。'control-change' のときだけ使用)、axis('excitation'、'position'、'brightness'、'morph'、'loudness'、'pitch-cents'、'vibrato-depth'。'none' は拒否)、省略可能な lo、hi、curve で指定します。lo > hi でジェスチャーが反転し、curve の既定は線形の 1 です。
  • clearControllerBindings(destinationId)、controllerBindingCount(destinationId)、setControllerVelocityMeaningful(destinationId, meaningful) が残りの操作です。デバイスが一定のベロシティを送る場合は最後のものを false にすると、割り当てた軸だけがダイナミクスを担います。
  • setControllerNoteTracking(destinationId, dimension, tracking) は、1 つのチャンネルで複数のノートが鳴っているとき、チャンネル宛ての値がどのノートに届くかを選びます。dimension は MPE_DIMENSIONS('bend'、'pressure'、'timbre')、tracking は NOTE_TRACKINGS('last'、'lowest'、'highest'、'all')の値です。効くのは MPE ゾーン内で、1 チャンネルが複数ノートを保持しているときだけです。
typescript
import { controllerProfileNames } from '@libraz/libsonare';

controllerProfileNames();                         // ['gm', 'breath', 'breath-aftertouch', 'mpe']
engine.setControllerProfile(0, 'breath');         // ブレス CC が励起の強さを動かす
engine.bindController(0, { input: 'channel-pressure', axis: 'loudness', lo: 0.2, hi: 1 });
engine.controllerBindingCount(0);                 // 5

Python ではキーワード専用引数の bind_controller(destination_id, input=..., index=0, axis=..., lo=0.0, hi=1.0, curve=1.0)、set_controller_profile、clear_controller_bindings、controller_binding_count、set_controller_velocity_meaningful、set_controller_note_tracking、モジュール関数の controller_profile_names() を使います。各音色がどの軸を読むかは 物理モデル にあります。

ノートを残さず MIDI FX を差し替える ​

各デスティネーションは 1 つの MIDI FX インサートを持てます。イベントストリームへの非破壊な変換(トランスポーズ、チャンネルフィルタ、ベロシティカーブなど)を JSON で設定します。

typescript
// 入力されたノートをすべて 1 オクターブ上げる
engine.setMidiFx(/* destinationId */ 0, JSON.stringify({ transpose_semitones: 12 }));
engine.clearMidiFx(0);   // 指定したデスティネーションのみ解除(ID 省略時は 0)
typescript
engine.setMidiFx(0, JSON.stringify({ transpose_semitones: 12 }));
engine.clearMidiFx(0);   // destinationId 省略時は 0
python
engine.set_midi_fx(0, '{"transpose_semitones": 12}')
engine.clear_midi_fx(0)   # destination_id 省略時は 0
c
sonare_engine_set_midi_fx(engine, /* destination_id */ 0,
                          "{\"transpose_semitones\": 12}");
sonare_engine_clear_midi_fx(engine, /* destination_id */ 0);

設定 JSON は bakeMidiFx と同じスキーマです。各ステージはそのパラメータをキーにするので、ステージのキーを含めれば有効になり、省けばスキップされます。主なキーは transpose_semitones、velocity_scale / velocity_offset / velocity_gamma、quantize_ppq / quantize_strength、chord_intervals、arpeggiator_intervals / arpeggiator_step_ppq / arpeggiator_gate_ppq です。キーの全一覧は MIDI-FX チェーンの焼き込みを参照してください。

setMidiFx は楽器のボイスをリセットせずにインサートをその場で置き換えますが、すでに FX があるデスティネーションへの差し替えは、そのデスティネーションの鳴っているノートを解放します。FX の設定が変わったデスティネーションを、音声スレッドが次のブロックでフラッシュ(all-notes-off)するためです。鍵を押したまま FX を変える場合の注意点が 2 つあります。

  • そのデスティネーションにまだアクティブな FX が無い状態での最初の setMidiFx 呼び出しは、何もフラッシュしません。置き換える対象の設定がまだ存在しないためです。
  • それ以降の差し替えは、鳴っているノートを自動的に解放するため、クリアのためのパニックは要りません。差し替えで鳴っているノートを途中で切りたくない場合は、サステイン中ではなく、ノートとノートの間で差し替えてください。

この呼び出しは制御スレッドから不変の設定を発行し、音声スレッドが次のブロック境界で採用するため、エンジンの処理中にも実行できます。

MIDI パニックとスタックノート復帰 ​

スタックノートは、対応するノートオフが届かなかったノートオンです。ケーブルを抜いた、Bluetooth パケットを落とした、FX の差し替えがオフを飲み込んだ、などが原因です。対処は MIDI パニック、すなわち鳴っている全ボイスを解放する all-notes-off です。

typescript
engine.pushMidiPanic();   // 即時。renderFrame を渡せばスケジュールも可能

パニックはリアルタイム安全で軽量です。楽器 UI の見える位置に「パニック」ボタンを置いて割り当ててください。Web MIDI ブリッジは切断時に自動パニックしません。ホットアンプラグを自分で扱う場合は、演奏中だったポートが消えたらパニックを送ってください。

負荷がかかってもノートオフは失われません。MIDI 出力が満杯で受け付けられなかったものは毎ブロック再送され、MIDI FX のキューが満杯のときはそのノートを即座に解放して保留中のノートオンを取り下げます。スケジュールされた MIDI は、ホストのブロックサイズによらず同じフレームに届きます。MIDI FX の出力、クリップ終端での解放、ループ折り返し後のイベントも同様です。

ブラウザの Web MIDI ブリッジ ​

ワイヤーフォーマットの用語

UMP(Universal MIDI Packet)は MIDI 2.0 のメッセージ形式です。ブリッジは従来の MIDI 1.0 バイトに加えてこれも受け付けます。SysEx(システムエクスクルーシブ)は自由形式でメーカー固有のメッセージで、GS Reset などに使われ、ブラウザでは使うのに別の権限が要ります。RPN/NRPN(登録/非登録パラメータナンバー)は CC を介して追加パラメータを指定するもので、たとえば RPN 0 はピッチベンドレンジを設定します。

ブラウザでは、bindWebMidi(engine, options) が配線を担います。MIDI アクセスを要求し、エンジンのライブ入力ソースを有効化し、一致する全入力ポートにリスナーを取り付け、受信バイト(ランニングステータスやデータが 1 バイトのメッセージを含む)を解析し、サンプルタイムスタンプ付きでエンジンへキューイングします。Uint32Array のデータ、または先頭値が 0xff より大きいデータは UMP パケット列として読み、各メッセージ種別に応じて分割します。エンジンが pushMidiInputUmp を持つ場合、1〜4 ワードの完全なチャンネルボイスパケットは MIDI 2.0 の値の幅を保持します。MT 0x3 と MT 0x5 のデータパケットは SysEx 用の経路で扱います。簡易メソッドしか持たないエンジンでは、MIDI 2.0 チャンネルボイスを対応する 7 ビットのノート/CC/プレッシャー、または 14 ビットのピッチベンドへ縮小します。

typescript
import { init, RealtimeEngine, isWebMidiAvailable, bindWebMidi } from '@libraz/libsonare';

await init();
if (!isWebMidiAvailable()) {
  // navigator.requestMIDIAccess が無い — オンスクリーンキーボードへフォールバック
}

const engine = new RealtimeEngine(48000, 128);
engine.setSynthInstrument('saw-lead', 0);

const binding = await bindWebMidi(engine, {
  destinationId: 0,        // 演奏するエンジン MIDI デスティネーション(既定 0)
  group: 0,                // MIDI 1.0 イベント用の UMP グループ(既定 0)
  // inputIds: ['<port-id>'],  // 特定ポートに限定。省略時は接続中の全ポート
  sysex: false,            // SysEx 対応アクセスを要求(既定 false)
  software: true,          // 対応環境ではソフトウェアポートを要求(既定 true)
  ccBindings: [
    { channel: 0, controller: 1, paramId: 42, options: { minValue: 0, maxValue: 1 } },
  ],
  // 任意: イベント時刻ごとに、ブロック内オフセット(サンプル数)を返す。
  // 計算できるのはレンダリングするスレッドだけ。「タイムスタンプからサンプルへの変換が重要な理由」を参照。
  // timestampToOffsetInBlockSamples: (eventTimeMs) => offset,
  onInputsChanged: (inputs) => {
    // ホットプラグ時、ヘルパーが一致ポートを再バインドした後に呼ばれる
    console.log('MIDI inputs:', inputs.map((i) => `${i.name} (${i.state})`));
  },
});

// binding.inputs()  -> WebMidiInputInfo[] { id, name, manufacturer, state }
// binding.access    -> 生の制御が必要なときの MIDIAccess オブジェクト

各オプションの役割は次のとおりです。

  • destinationId / group — ライブソースがイベントを送るエンジンデスティネーションと、MIDI 1.0 チャンネルボイスイベントに刻む UMP グループです。
  • inputIds — binding.inputs() から得た特定のポート ID にバインドを限定します。省略または空配列で接続中の全入力にバインドします。
  • sysex / software — navigator.requestMIDIAccess へそのまま渡されます。SysEx アクセスは通常、別の権限プロンプトを出します。software はプラットフォームが提供する場合にソフトウェアシンセのポートを要求します。
  • ccBindings — ポート接続より前に適用される bindMidiCc のマッピングです。最初のツマミ操作からすでにルーティング済みになります。対象パラメータは先に addParameter(...) で登録してください。
  • onInputsChanged — ホットプラグ(MIDIAccess の statechange)時、ヘルパーが一致ポートを再バインドした後に、最新のポート一覧とともに発火します。

タイムスタンプからサンプルへの変換が重要な理由 ​

ここには別々の 2 つのクロックがあります。Web MIDI は各メッセージをミリ秒の時刻(ページクロックの DOMHighResTimeStamp)付きで届けますが、エンジンはイベントをサンプルフレームでスケジュールします。timestampToOffsetInBlockSamples(eventTimeMs) はその両者を橋渡しし、メッセージ時刻をエンジンがキューイングする offsetInBlockSamples の値へ変換します。この値は絶対的な時計の上の位置ではなく、イベントを拾うレンダリングブロックの先頭からのオフセット(サンプル数)です。ブロックの終わりを超えるオフセットは、そのブロックの最後のフレームに切り詰められます。128 フレームのブロックで測ると、オフセット 40 ではノートがフレーム 41 から鳴り始めます。一方、ページクロックの時刻をそのままサンプル数に換算した値(10 秒後なら 480000)では、ブロックの最後のフレームまで何も鳴りません。速いパッセージのイベントがすべて同じフレームに重なってしまいます。

変換が正しければ、タイミングが詰まった箇所(和音や速いパッセージ)が、演奏したとおりの正確なフレームに配置されます。省略すると、すべてのイベントが次のブロックのサンプル 0 にキューイングされ、タイミングがブロックの境界に揃ってしまいます。48 kHz で 128 フレームなら 2.7 ms 刻み、512 フレームなら 10.7 ms 刻みです。気軽な演奏には十分ですが、速いパッセージでは聴き取れます。

オフセットを計算できるのは、レンダリングするスレッドだけです。 オフセットは、エンジンが次にレンダリングするブロックの先頭からの値で、それがどのブロックかを知っているのはレンダリングしているスレッドだけです。エンジンを AudioWorklet に置く通常のブラウザ構成では、メインスレッドには分かりません。post したメッセージが届くまでには時間がかかり、届いたときには、メインスレッドが想定していたブロックはもうレンダリングされています。そのオフセットは、イベントが実際に入るブロックの範囲を超えて最後のフレームに切り詰められ、上で述べたようにイベントが 1 点に固まります。そのため、メインスレッドでブロックの開始時刻を引いてはいけません。代わりに、メインスレッドが AudioContext.getOutputTimestamp()(同じ瞬間を 2 つの時計で表した contextTime と performanceTime を返します)で Web MIDI のタイムスタンプをコンテキスト時刻へ変換し、出力レイテンシと小さな固定のルックアヘッドを足して目標時刻がレンダーヘッドより先になるようにし、イベントと一緒に post します。workletはその時刻を含むブロックまでイベントを保留し、自身の currentTime と sampleRate から offsetInBlockSamples を計算します。固定のルックアヘッドは、一定の遅延を足す代わりにジッターをなくす取引です。次のレシピはこのとおりに動きます。

Web MIDI のタイムスタンプからブロック内オフセットへ
postMessageMIDIイベント(ページクロックの ms)メインスレッド: コンテキスト時刻 =getOutputTimestamp + レイテンシ + ルックアヘッドworklet: 該当ブロックまで保留し、currentTimeからオフセットを計算pushMidiInput*(offset)
オフセットは、次に来るブロックを知っているスレッドで計算します。

ライフサイクル ​

bindWebMidi は WebMidiBinding を返します。終わったら binding.close() を呼びます。statechange リスナーを外し、全ポートリスナーを切り離し、engine.clearMidiInputSource() を呼びます。エンジンは破棄しません。エンジンは engine.destroy() で別途解放してください。

ブラウザ対応 ​

Web MIDI の対応はまちまちなので、isWebMidiAvailable() で実行時に確認し、使えない場合はオンスクリーンキーボードなどへ切り替えてください。

  • Chrome・Edge(デスクトップ) — ホットプラグや SysEx(権限プロンプト付き)を含む Web MIDI に対応しています。主たる対象です。
  • Firefox — Web MIDI を提供しています。SysEx やアドオン要件は時期により変わってきたため、前提にせず機能検出してください。
  • Safari — 従来は navigator.requestMIDIAccess を公開していませんでした。対応は変化しているため、存在を前提にしないでください。常に isWebMidiAvailable() でゲートし、代替入力としてオンスクリーンキーボードを用意します。

レシピ: USB キーボードでブラウザのシンセを鳴らす ​

「キーボードを挿した」から「スピーカーから音が出る」までの、実装全体が見えるレシピです。処理が 2 つのスレッドにまたがるため、ファイルも 2 つになります。

  • 音声スレッド — AudioWorklet プロセッサがエンジンをホストし、レンダークォンタムごとにブロックをレンダリングします。エンジンはここに置く必要があります。メインスレッドのエンジンでは process(...) が音声コールバックから呼ばれないため、音が出ないままになります。
  • メインスレッド — Web MIDI アクセス(bindWebMidi)を持ち、すべてのイベントをノードのポート経由で worklet へ転送します。

代役オブジェクトが bindWebMidi を満たせる理由

bindWebMidi は、渡されたエンジンのライブ入力メソッドだけを呼びます。必須なのは setMidiInputSource、bindMidiCc、3 つの pushMidiInput* メソッド、close 時の clearMidiInputSource です。ピッチベンド、チャンネルプレッシャー、ポリプレッシャー、完全な UMP パケット用のメソッドがあれば、それらも使います。必須メソッドを実装したフォワーダーで各イベントを worklet のポートへ送れます。MIDI 2.0 の値の幅を保持する場合は、フォワーダーにも pushMidiInputUmp を実装します。省略すると 7 ビット/14 ビットの簡易メソッドへ縮小されます。

音声スレッド: worklet がエンジンをホストする ​

AudioWorkletGlobalScope は動的 import() を禁止しているため、通常のパッケージ入口は使えません(その init() は WASM モジュールを動的にインポートします)。代わりに Emscripten ファクトリ sonare.js を静的にインポートし、それが公開する低レベルのエンジンを呼び出します。この低レベル入口について知っておくことは次のとおりです。

  • worklet は .wasm のバイト列も fetch できません。メインスレッドで fetch して processorOptions 経由で渡します。
  • 生の embind の RealtimeEngine コンストラクタは 4 つの引数 (sampleRate, maxBlockSize, commandCapacity, telemetryCapacity) を取ります。高レベルの RealtimeEngine クラスは末尾の 2 つを既定値 1024 にするため、このページ前半の 2 引数の呼び出しが成立しますが、embind のクラスには既定引数がないので、worklet では 2 つを明示的に渡す必要があります。commandCapacity はキューイングした MIDI/オートメーションコマンドのリングバッファ容量、telemetryCapacity はテレメトリ/メーター読み出しの容量です。
  • 一部の引数順が通常の JS パッケージ入口と異なります。特に setSynthInstrument(destinationId, patch) と setSf2Instrument(destinationId, config) では、生のエンジンはデスティネーション ID が先頭です。
js
// synth-worklet.js — context.audioWorklet.addModule(...) で読み込む。
// sonare.js と sonare.wasm は @libraz/libsonare パッケージから静的アセットへ
// コピーしておく。import 指定子はworkletが解決できる URL であること。
import createModule from '/wasm/sonare.js';

const BLOCK = 128; // エンジンのスクラッチを準備するレンダークォンタムサイズ

// メッセージ種別 -> [エンジンのメソッド, ...引数順のメッセージフィールド]
const LIVE = {
  noteOn: ['pushMidiInputNoteOn', 'group', 'channel', 'note', 'velocity'],
  noteOff: ['pushMidiInputNoteOff', 'group', 'channel', 'note', 'velocity'],
  cc: ['pushMidiInputCc', 'group', 'channel', 'controller', 'value'],
  pitchBend: ['pushMidiInputPitchBend', 'group', 'channel', 'bend14'],
  channelPressure: ['pushMidiInputChannelPressure', 'group', 'channel', 'pressure'],
  polyPressure: ['pushMidiInputPolyPressure', 'group', 'channel', 'note', 'pressure'],
};

class KeyboardSynthProcessor extends AudioWorkletProcessor {
  constructor(options) {
    super();
    this.engine = null;
    this.channels = [];
    this.pending = []; // timed events waiting for their block
    this.port.onmessage = (event) => this.onMessage(event.data);

    createModule({
      wasmBinary: options.processorOptions.wasmBinary,
      locateFile: () => 'sonare.wasm', // workletからネットワークへは出ない
    })
      .then((mod) => {
        const engine = new mod.RealtimeEngine(sampleRate, BLOCK, 1024, 1024);
        engine.setSynthInstrument(0, 'saw-lead'); // ネイティブの順序: (destinationId, patch)
        engine.setMidiInputSource(0);
        // ゼロコピーのレンダー経路: 準備済みスクラッチを埋めて processPrepared を呼ぶ。
        engine.prepareChannels(2, BLOCK);
        this.channels = [engine.getChannelBuffer(0, BLOCK), engine.getChannelBuffer(1, BLOCK)];
        this.engine = engine;
        this.port.postMessage({ type: 'ready' });
      })
      .catch((err) => this.port.postMessage({ type: 'error', error: String(err) }));
  }

  onMessage(msg) {
    if (!this.engine) return;
    if (msg.type === 'panic') {
      this.engine.pushMidiPanic(); // フレームを省略すると即時
      return;
    }
    // 各イベントは、属するブロックがレンダリングされる番になるまで保留する。
    this.pending.push(msg);
    this.pending.sort((a, b) => a.time - b.time);
  }

  // `offset` はこれからレンダリングするブロックの先頭からのサンプル数。
  dispatch(msg, offset) {
    if (msg.type === 'ump') return this.engine.pushMidiInputUmp(msg.words, offset);
    const [method, ...names] = LIVE[msg.type];
    this.engine[method].call(this.engine, ...names.map((name) => msg[name]), offset);
  }

  process(_inputs, outputs) {
    const output = outputs[0];
    if (!output?.length) return true;
    if (!this.engine) {
      for (const channel of output) channel.fill(0);
      return true;
    }
    // WASM メモリの成長でヒープビューが切り離されたら取得し直す。
    if (this.channels[0].byteLength === 0) {
      this.channels = [this.engine.getChannelBuffer(0, BLOCK), this.engine.getChannelBuffer(1, BLOCK)];
    }
    const frames = Math.min(output[0].length, BLOCK);
    // currentTime はこのブロックの先頭フレームのコンテキスト時刻。
    const blockEnd = currentTime + frames / sampleRate;
    while (this.pending.length && this.pending[0].time < blockEnd) {
      const msg = this.pending.shift();
      const offset = Math.round((msg.time - currentTime) * sampleRate);
      this.dispatch(msg, Math.min(Math.max(offset, 0), frames - 1)); // 遅れたイベントはオフセット 0 で鳴る
    }
    // シンセはジェネレータなので、レンダー前に入力スクラッチをクリアする。
    for (const channel of this.channels) channel.fill(0, 0, frames);
    this.engine.processPrepared(frames);
    for (let ch = 0; ch < output.length; ch++) {
      output[ch].set((this.channels[ch] ?? this.channels[0]).subarray(0, frames));
    }
    return true;
  }
}

registerProcessor('keyboard-synth', KeyboardSynthProcessor);

メインスレッド: Web MIDI が worklet へ流し込む ​

ここに init() はありません。この構成では WASM は音声スレッドだけで動きます。メインスレッドは worklet を起動し、ライブ入力メソッドの呼び出しをポートへ送るフォワーダーを bindWebMidi に渡します。

typescript
import { bindWebMidi, isWebMidiAvailable, type RealtimeEngine } from '@libraz/libsonare';

async function startKeyboardSynth() {
  if (!isWebMidiAvailable()) {
    throw new Error('Web MIDI が使えません — オンスクリーンキーボードを使ってください');
  }

  // --- workletを起動(コンテキストが動くようユーザージェスチャーから呼ぶ) ---
  const context = new AudioContext({ latencyHint: 'interactive' });
  await context.audioWorklet.addModule('/synth-worklet.js');
  const wasmBinary = await (await fetch('/wasm/sonare.wasm')).arrayBuffer();
  const node = new AudioWorkletNode(context, 'keyboard-synth', {
    numberOfInputs: 0,
    numberOfOutputs: 1,
    outputChannelCount: [2],
    processorOptions: { wasmBinary },
  });
  node.connect(context.destination);
  await new Promise<void>((resolve, reject) => {
    node.port.onmessage = (event) =>
      event.data?.type === 'ready' ? resolve() : reject(new Error(event.data?.error));
  });

  // --- ページクロックの時刻 -> workletがイベントをレンダリングすべきコンテキスト時刻 ---
  const LOOKAHEAD_S = 0.03; // 固定の追加遅延。まだ遅れて鳴るノートがあるなら増やす
  const toContextTime = (eventMs: number) => {
    const { contextTime = 0, performanceTime = 0 } = context.getOutputTimestamp();
    // contextTime と performanceTime は、同じ瞬間を 2 つの時計で表した値。
    // レンダーヘッドはスピーカーより outputLatency ほど先を走っている。
    return contextTime + (eventMs - performanceTime) / 1000 + (context.outputLatency || 0) + LOOKAHEAD_S;
  };

  // --- フォワーダー: bindWebMidi が呼ぶエンジンメソッドをポートへ post する ---
  const forwarder = {
    setMidiInputSource: () => {}, // workletが起動時にデスティネーション 0 をバインド済み
    clearMidiInputSource: () => {
      node.port.postMessage({ type: 'panic' }); // close 時に押しっぱなしのノートを解放
    },
    bindMidiCc: () => {}, // このレシピでは CC からパラメータへのバインドは使わない
    pushMidiInputNoteOn: (group: number, channel: number, note: number, velocity: number, timeMs: number) =>
      node.port.postMessage({ type: 'noteOn', group, channel, note, velocity, time: toContextTime(timeMs) }),
    pushMidiInputNoteOff: (group: number, channel: number, note: number, velocity: number, timeMs: number) =>
      node.port.postMessage({ type: 'noteOff', group, channel, note, velocity, time: toContextTime(timeMs) }),
    pushMidiInputCc: (group: number, channel: number, controller: number, value: number, timeMs: number) =>
      node.port.postMessage({ type: 'cc', group, channel, controller, value, time: toContextTime(timeMs) }),
    pushMidiInputPitchBend: (group: number, channel: number, bend14: number, timeMs: number) =>
      node.port.postMessage({ type: 'pitchBend', group, channel, bend14, time: toContextTime(timeMs) }),
    pushMidiInputChannelPressure: (group: number, channel: number, pressure: number, timeMs: number) =>
      node.port.postMessage({ type: 'channelPressure', group, channel, pressure, time: toContextTime(timeMs) }),
    pushMidiInputPolyPressure: (group: number, channel: number, note: number, pressure: number, timeMs: number) =>
      node.port.postMessage({ type: 'polyPressure', group, channel, note, pressure, time: toContextTime(timeMs) }),
    pushMidiInputUmp: (words: Uint32Array | readonly number[], timeMs: number) =>
      node.port.postMessage({ type: 'ump', words, time: toContextTime(timeMs) }),
  };

  const binding = await bindWebMidi(forwarder as unknown as RealtimeEngine, {
    destinationId: 0,
    timestampToOffsetInBlockSamples: (eventTimeMs) => eventTimeMs, // ページクロックの時刻をそのまま渡す
    onInputsChanged: (inputs) =>
      console.log('keyboards:', inputs.map((i) => i.name).join(', ')),
  });

  // これで USB キーボードの鍵を押すと 'saw-lead' パッチが鳴ります。

  return {
    stop() {
      binding.close();      // MIDI ポートを切り離す(forwarder.clearMidiInputSource() が呼ばれる)
      node.disconnect();
      void context.close(); // workletと、その中のエンジンを破棄する
    },
  };
}

バインディングには、恒等変換の timestampToOffsetInBlockSamples: (eventTimeMs) => eventTimeMs を渡しています。フォワーダーはエンジンではないため、エンジンならオフセットを受け取る引数の位置でページクロックのタイムスタンプを受け取り、自分でコンテキスト時刻へ変換します。workletはそれをブロックごとの実際のオフセットに変えます。このオプションを省略するとフォワーダーには 0 が渡り、目標時刻が過去になって、すべてのイベントが次のブロックの先頭で鳴ります。気軽な演奏には十分ですが、上で述べたブロック単位の刻みが残ります。

ブラウザのジェスチャーと後始末

AudioContext はユーザージェスチャー(クリック)から生成/再開する必要があり、多くのブラウザはセキュアコンテキストからのみ MIDI アクセスを促します。bindWebMidi には必ず binding.close() を組み合わせ、ページ破棄時には AudioContext を close してください。このレシピでは、それが worklet とエンジンのネイティブメモリを解放する手段です。

ほかの実行環境では ​

ライブ MIDI のエンジン API はブラウザ専用ではありません。Node ネイティブと Python のバインディングは同じ RealtimeEngine 入力メソッドを公開します。ブラウザ固有なのは Web MIDI ブリッジ自体だけです(navigator.requestMIDIAccess に依存するため)。Python では名前は snake_case 慣習に従います。

python
import libsonare as sonare

engine = sonare.RealtimeEngine(sample_rate=48000.0, max_block_size=128)
try:
    engine.set_synth_instrument("saw-lead", destination_id=0)
    engine.set_midi_input_source(0)
    engine.push_midi_input_note_on(0, 0, 60, 100, 0)   # group, channel, note, velocity, offset_in_block_samples
    engine.push_midi_input_cc(0, 0, 1, 64, 0)
    engine.push_midi_input_note_off(0, 0, 60, 0, 0)
    out = engine.process([[0.0] * 128, [0.0] * 128])   # ノートが鳴れば非ゼロ
finally:
    engine.close()

これらのエンジンを実機から鳴らすには、プラットフォームのライブラリ(たとえば CoreMIDI や ALSA のバインディング)で MIDI を読み、同じ push_midi_input_* メソッドを呼びます。タイムスタンプからサンプルへの変換は、ブラウザの timestampToOffsetInBlockSamples と同様に自分で用意します。

実験的なネイティブ macOS バックエンド

C++ ソースビルドでは、既定で無効の CMake オプション BUILD_COREAUDIO・BUILD_COREMIDI・BUILD_AU_HOST でネイティブ macOS ホストバックエンド(CoreAudio 出力、CoreMIDI 入出力、Audio Unit 楽器ホスト)を有効化できます。これらは C-ABI を追加せず、公開パッケージ(npm/PyPI/WASM)にも含まれません。macOS 専用かつソースビルドでのオプトインで、今後変更される可能性があります。

マイク入力について ​

ライブ MIDI はコントロール入力です。エンジンに何を演奏するかを伝えます。音声入力(マイクや、インターフェース経由の楽器)は別経路です。bindMicrophoneInput(context, engine, options) が、取り込んだ音声をモニタリングと録音のためにエンジンへ送ります。両者は独立しており、同時に動かせます。録音とテイク を参照してください。

関連 ​