Skip to content

プロジェクトをオーディオへバウンス

バウンスは、アレンジ全体を 1 つの音声ファイルへ変換し、保存・再生・解析できるようにする操作です。 DAW を使ったことがあれば「エクスポート」や「レンダリング」のボタンにあたり、libsonare では Project.bounce* 一族が担います。レンダリングはオフライン(曲をライブ再生するのではなく、実時間より速く一括で処理します)かつ決定論的で、同じプロジェクトと同じオプションからは、ビット単位までまったく同一のサンプルが常に得られます。

Project はタイムライン上にトラックとクリップを保持します。オーディオクリップはすでにサンプルを持つため、そのままレンダリングされます。一方 MIDI クリップが持つのはイベント(ノートオン・ノートオフ)であって音そのものではないので、楽譜に演奏者が要るのと同じで、音にするにはインストゥルメントが必要です。つまりどのバウンスメソッドを選ぶかは、どのインストゥルメントで MIDI を鳴らすかという 1 つの問いに尽きます。

図は上から下へ読みます。オーディオクリップはそのまま流れ込み、MIDI クリップはまず楽器をバインドする必要があり、すべてがミキサーを通ってマスターへ合算されます。この一連の処理はオフラインで計算されるため、結果は毎回再現可能です。

位置は 4 分音符単位

プロジェクトの位置とクリップ長は PPQ で表します。これは 4 分音符を表す float です。ppq: 1 は 4 分音符 1 つ分です。120 BPM では 4 分音符が 0.5 秒なので、4 拍のクリップ(lengthPpq: 4)は 2 秒になります。setTempoSegments([{ startPpq: 0, bpm: 120 }]) でテンポを明示しておくと、バウンスの長さが予測しやすくなります。

バウンスは生クリップの加算ではなくミキサー全体を反映する

トラックは単純に加算されるわけではありません。各トラックはチャンネルストリップ(トリム、EQ、インサート、フェーダー、パン、センド、バス)を通ってシーンミキサーでレンダリングされます。バウンス結果はルーティング済みのマスターで、リアルタイム再生とまったく同じ出力です。

setClipGain(およびクリップフェード)が効くのはオーディオクリップのみで、バウンス時の MIDI クリップには反映されません。MIDI で駆動する楽器の音量は、トラックフェーダー/チャンネルストリップ(ミキサーシーン)で調整してください。詳しくはプロジェクト編集を参照してください。

このページで身につくこと

このページを読むと、次のことを実装できるようになります。

  • オーディオクリップのプロジェクトをプレーンな bounce でレンダリングできる。
  • 内蔵シンセ、NativeSynthSF2 プレイヤーを通して MIDI を音にできる。
  • NativeSynth のパッチをプリセット名・va: プレフィックス・パッチオブジェクトで指定できる。
  • Python から ExternalInstrument プロトコルで自前のインストゥルメントをホストできる。
  • validateMidiNotes で MIDI クリップを事前検査し、インストゥルメント未バインド時のコンパイル警告を読める。
  • レンダリング長をエンジンに自動導出させ、結果を WAV ファイルへ書き出せる。

適切なバウンスメソッドを選ぶ

まず上の行から検討し、MIDI により豊かなインストゥルメントが必要になったときだけ下の行へ進んでください。

あなたのプロジェクト使う API結果
オーディオクリップのみ(MIDI なし、または MIDI を無音にしたい)bounceルーティング済み音声。MIDI トラックは無音
MIDI をとにかく鳴らしたいbounceWithBuiltinInstrumentシンプルなオシレーターシンセ
本格的な楽器のキャラクターが必要な MIDIbounceWithSynthInstrumentフル NativeSynth(減算 / FM / モーダル / ピアノ …)
SoundFont で鳴らしたい MIDIbounceWithSf2InstrumentGS 互換 SF2 プレイヤー
自前の(Python)シンセで駆動する MIDIbounce_with_instrumentsPython 専用ホスト供給の ExternalInstrument

1 つの Project、すべての実行環境

同じ Project モデルと中核のバウンス挙動は、WASM/JS、Node ネイティブ、Python から使えます。名前は各言語の慣習に従います(bounceWithSynthInstrumentbounce_with_synth_instrument)。CLI では project bounceproject midi-render、SMF/MIDI 2.0 入出力としてプロジェクトワークフローを使えますが、出力先ごとの楽器バインドオプションがすべて配線されているわけではありません。アレンジメントモデル・コンパイラ・DSP は実行環境を問わず同一です。

プレーンバウンス: オーディオクリップ

bounce はプロジェクトをレンダリング可能なタイムラインへコンパイルし、インターリーブされた float 音声へオフラインレンダリングします。オーディオクリップはチャンネルストリップを通って鳴り、MIDI クリップはインストゥルメントがバインドされていないため無音でレンダリングされます。

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

await init();

const project = new Project();
try {
  project.setSampleRate(48000);
  project.setTempoSegments([{ startPpq: 0, bpm: 120 }]); // 長さを予測可能に

  const track = project.addTrack({ kind: 'audio', name: 'tone' });
  project.addClip({
    trackId: track,
    startPpq: 0,
    lengthPpq: 4,          // 4 分音符 4 つ = 120 BPM で 2 秒
    audio: monoSamples,    // Float32Array
    audioChannels: 1,
    audioSampleRate: 48000,
  });

  // インターリーブステレオ: [L0, R0, L1, R1, ...]
  const audio = project.bounce({ numChannels: 2, sampleRate: 48000 });
} finally {
  project.delete();        // WASM ハンドルは GC されない
}

Project は必ず解放する

Project はすべての embind オブジェクトと同様、JavaScript の GC では回収できない WASM ヒープハンドルを保持します。finally ブロックで project.delete() を呼んでください(Node は destroy() も可、Python は project.close())。ハンドルをリークすると、長時間のセッションで WASM メモリが徐々に枯渇します。

インターリーブ音声とは

「インターリーブ」とは、チャンネルがサンプルごとに 1 本の配列へ織り込まれていることを指します。すなわち [L0, R0, L1, R1, …] で、左サンプル・右サンプル・左・右、と続きます(対になる「プレーナー」は各チャンネルを別々の配列に保ちます)。WAV ライターはこの 1 本の配列を端から順にたどるため、N フレームの 2 チャンネルバウンスは 2 × N 個の float になります。

バウンスオプション

オプションオブジェクトの各フィールドは任意です。

オプション意味既定
totalFrames出力フレーム数でのレンダリング長自動導出(後述)
blockSizeレンダリングブロックサイズエンジン既定(128)
numChannels出力チャンネル数2
sampleRate出力サンプルレート(Hz)プロジェクトのサンプルレート
instrumentLatencySamplesコンパイラへ渡すホストインストゥルメントの PDC(プラグインディレイ補償)0

PDC(レイテンシ補償)とは

楽器やエフェクトの中には、数サンプルの「先読み」を必要とし、その分だけ音声を遅れて出すものがあります。PDC(プラグインディレイ補償)は、その楽器が何サンプル遅れるかをコンパイラへ伝え、エンジンが遅延分を戻して全トラックのタイミングをそろえられるようにします。楽器にレイテンシがなければ 0 のままで構いません。

決定性はライブ状態に依存しない

オフラインバウンスはレンダリング前に、スムージングされたゲインとエフェクトパラメータをすべて目標値へ整定(settle)します。そのため、フェーダーやフィルタースイープがレンダリング開始時にどこにあったかに結果は左右されません。ライブで操作した直後にバウンスしても、読み込み直後のプロジェクトからバウンスしたのと同じサンプルになります。

長さを省略する

totalFrames を省略(または <= 0)すると、レンダリング長はコンパイル済みタイムラインから自動導出されます。すなわちアレンジメントの音楽的な終端に、インストゥルメントのリリーステイルを加えた長さです。内容のあるプロジェクトはフレーム数を自分で計算しなくてもレンダリングでき、空のプロジェクトは空のバッファを返します。固定長のバッファが必要なときだけ totalFrames を渡してください。

内蔵シンセで MIDI をバウンスする

MIDI のみのプロジェクトをプレーンな bounce でバウンスすると無音になります。内蔵オシレーターシンセを通せば、1 回の呼び出しで音にできます。波形とエンベロープを選ぶバインディングを渡すか、既定のサイン波パッチなら {} を渡します。

typescript
const project = new Project();
try {
  project.setSampleRate(48000);
  project.setTempoSegments([{ startPpq: 0, bpm: 120 }]);

  const { clipId } = project.addMidiClip(0, 4);   // MIDI トラック + クリップ、4 拍長
  project.setMidiEvents(clipId, [
    Project.midiNoteOn(0, 0, 0, 60, 100),         // 0 拍目で C4
    Project.midiNoteOff(3, 0, 0, 60, 0),          // 3 拍目でリリース
  ]);

  // MIDI のみのプロジェクト -> 無音でないステレオ音声
  const audio = project.bounceWithBuiltinInstrument(
    { waveform: 'saw', gain: 0.5 },
    { numChannels: 2, sampleRate: 48000 },
  );
} finally {
  project.delete();
}

BuiltinSynthBindingwaveform'sine''saw''square''triangle')、gain、ADSR(attackMsdecayMssustainreleaseMs)、polyphony、そして 1 つの MIDI デスティネーションを指す destinationId を受け付けます。数値フィールドはすべて「0 / 省略で既定値を維持」なので、{} がそのまま使える既定パッチになります。複数の MIDI デスティネーションへ供給するにはバインディングの配列を渡します。明示的な空配列 [](または undefined / null)は何もバインドせず、無音でレンダリングします。

NativeSynth で MIDI をバウンスする

本格的な楽器のキャラクターが必要なら、bounceWithSynthInstrument が MIDI をフル NativeSynth へ通します。減算・FM・Karplus-Strong・モーダル・加算・パーカッション・ピアノの各エンジンに加え、リアリズムレイヤーまで使えます。インストゥルメントの指定方法は 3 通りです。

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

await init();
synthPresetNames();   // ['sine', 'saw-lead', 'square-lead', 'sub-bass', 'warm-pad', 'e-piano', 'bell', 'brass', ...]

// 1. プリセット名の文字列
const a = project.bounceWithSynthInstrument('saw-lead', { numChannels: 2, sampleRate: 48000 });

// 2. 同じプリセットを "va:" ルーティングプレフィックスつきで
const b = project.bounceWithSynthInstrument('va:saw-lead', { numChannels: 2, sampleRate: 48000 });

// 3. パッチオブジェクト: ベースプリセット + ラッパーセクションの上書き
const c = project.bounceWithSynthInstrument(
  { preset: 'warm-pad', filterCutoffHz: 1200, ampRelease: 0.6 },
  { numChannels: 2, sampleRate: 48000 },
);

有効な名前はマジック文字列をハードコードせず synthPresetNames() で取得してください。未知の名前は例外を投げます。パッチオブジェクトは preset ベース(preset 省略時は既定の減算パッチ)から始まり、ラッパーセクションを上書きします。全フィールドの一覧は NativeSynth を参照してください。複数のデスティネーションをバインドするには配列を渡します。空配列は何もバインドしません。

下のピアノロールはまさにこの呼び出しです。1 つの MIDI フレーズを bounceWithSynthInstrument に通し、プリセットを切り替えるたびに同じ音符が別の音色で鳴り直します。再生ヘッドはバウンスした音声に追従します。

MIDI · PIANO ROLLIDLE
MIDI のフレーズ — 同じ音符を、どの楽器でも

旋律・和音・低音の3声フレーズをピアノロールで表示します。音符はそのまま、楽器を切り替えると、まったく同じ MIDI をエンジンが別の内蔵音色でバウンスします。テンポを動かせばシーケンス全体が速く・遅くなります。再生でフレーズを試聴でき、再生ヘッドは音声に追従します。

楽器
テンポ
100 BPM

構造上、決定論的

プロジェクト・オプション・パッチを固定すれば、bounceWithSynthInstrument はビット単位で再現可能です。これはすべてのバウンスメソッドに共通で、だからこそバウンスをテストのスナップショットに採ったり、ハッシュでキャッシュしたりしても安全です。

SoundFont で MIDI をバウンスする

bounceWithSf2Instrument は、プロジェクトに読み込まれた SoundFont を供給源とする GS 互換 SoundFont プレイヤーで MIDI を鳴らします。先に .sf2 のバイト列を読み込み、それからバウンスします。

typescript
const sf2Bytes = new Uint8Array(await (await fetch('/piano.sf2')).arrayBuffer());
project.loadSoundFont(sf2Bytes);

// プレイヤーあたり 16 MIDI チャンネル、チャンネル 10 はバンク 128 のドラム、GS NRPN + SysEx に対応
const audio = project.bounceWithSf2Instrument(
  { gain: 0.5 },
  { numChannels: 2, sampleRate: 48000 },
);

General MIDI・バンク・ドラム

General MIDI(GM)は、どの SoundFont も対応づける標準の 128 音色セットです。これによりプログラム番号は、ファイルが違っても同じ種類の楽器を選びます。慣習として、チャンネル 10 はドラム用に予約され、バンク 128 として指定します。NRPN と SysEx は、より細かな調整やベンダー固有の調整のための追加 MIDI メッセージで、手作業で設定することはまれです。

SoundFont がカバーしないプログラム(SoundFont を 1 つも読み込まずにバウンスする場合を含む)は、内蔵シンセの GM バンクへフォールバックします。(channel, bank, program) ごとに 'sf2' で解決されるか 'synth' へフォールバックするかは、soundFontManifest() で確認できます。

Python: 自前のインストゥルメントをホストする

Python バインディングは、ExternalInstrument プロトコルで書いた任意のインストゥルメントをホストでき、bounce_with_instruments がそれをディスパッチします。必須なのは render だけで、prepareon_eventlatency_samples / tail_samples 属性は任意(ダックタイピング)です。

python
import numpy as np
import libsonare as sonare


class SineInstrument:
    """最小構成の外部インストゥルメント: 保持中のノートごとにサイン波 1 ボイス。"""

    latency_samples = 0      # コンパイラへ報告する PDC(プラグインディレイ補償)
    tail_samples = 4096      # 自動長バウンス向けのリリース/エフェクトテイル

    def prepare(self, sample_rate: float, max_block_size: int) -> None:
        self.sample_rate = sample_rate

    def on_event(self, destination_id: int, ump_words: tuple[int, ...], render_frame: int) -> None:
        # ディスパッチされた UMP ワード(ノートオン/オフ)を解釈してボイスを更新する。
        ...

    def render(self, channels: np.ndarray, num_frames: int) -> None:
        # channels はゼロ埋め済みの (num_channels, num_frames) float32 配列。
        # 自分の音声をここへ加算する。無関係なフレームを上書きしない。
        channels += 0.0


with sonare.Project.from_json(project_json) as project:
    audio = project.bounce_with_instruments(
        SineInstrument(),
        total_frames=0,            # 0 => 長さを自動導出(+ tail_samples)
        num_channels=2,
        sample_rate=48000,
    )                              # -> np.ndarray、形状 (frames, channels)

各コールバックはバウンスを呼び出したスレッド上で同期的に実行されるため、スレッド間で守るべき状態はありません。コールバック内で送出された例外は呼び出し側へ伝播します。ValueError は無音のドロップアウトではなく ValueError として表面化します。tail_samples は自動長バウンスを延長し、リバーブやリリースのテイルが切れないようにします。

複数の楽器を同時に鳴らすには、単一の楽器ではなく instruments=[(destination_id, instrument), ...] を渡します。各タプルが 1 つの楽器を 1 つの MIDI デスティネーションにバインドします。

バウンス前に MIDI を検証する

コンパイラは寛容で、インストゥルメント未バインドの MIDI クリップはエラーではなく非致命的な警告です。compile() の結果は hasTimeline: true のままで、診断には次のメッセージが含まれます。

project contains MIDI clips; bounce is silent unless an instrument is bound

このメッセージは、bounce からいずれかのインストゥルメントバウンスへ切り替える合図です。本当の問題(鳴りっぱなしのノート)を捕まえるには、各 MIDI クリップを validateMidiNotes で事前検査します。これはすべてのノートオンに対応するノートオフがあるかを確認します。

typescript
const report = project.validateMidiNotes(clipId);
// { ok: true, unmatchedNoteOns: 0, unmatchedNoteOffs: 0 }
if (!report.ok) {
  // 宙吊りのノートはレンダリング終了まで鳴り続ける — 先にイベントを直す
}

クリップイベントの確認と修復については プロジェクト編集 を参照してください。

バウンスを WAV ファイルへ書き出す

バウンスはプレーンな Float32Array(チャンネルでインターリーブ)です。ブラウザでは 16 ビット PCM の WAV にラップしてダウンロードさせます。

typescript
function exportWav(interleaved: Float32Array, sampleRate: number, numChannels: number): Blob {
  const bytesPerSample = 2;
  const dataBytes = interleaved.length * bytesPerSample;
  const buffer = new ArrayBuffer(44 + dataBytes);
  const view = new DataView(buffer);
  const writeStr = (offset: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
  };

  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + dataBytes, true);
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);                                   // PCM チャンクサイズ
  view.setUint16(20, 1, true);                                    // PCM フォーマット
  view.setUint16(22, numChannels, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
  view.setUint16(32, numChannels * bytesPerSample, true);
  view.setUint16(34, 8 * bytesPerSample, true);
  writeStr(36, 'data');
  view.setUint32(40, dataBytes, true);

  let offset = 44;
  for (let i = 0; i < interleaved.length; i++) {
    const s = Math.max(-1, Math.min(1, interleaved[i]));          // 量子化前にクランプ
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
    offset += bytesPerSample;
  }
  return new Blob([buffer], { type: 'audio/wav' });
}

const audio = project.bounceWithSynthInstrument('saw-lead', { numChannels: 2, sampleRate: 48000 });
const url = URL.createObjectURL(exportWav(audio, 48000, 2));
// url を <a download> に割り当ててクリックさせる

Python では CLI が WAV を書き出します(次節)。ライブラリから直接書く場合は、np.ndarraysoundfile や任意の WAV ライターへ渡してください。

Python CLI からバウンスする

Python パッケージには project サブコマンドが付属し、プロジェクト JSON を読み込んでコードを書かずに WAV へレンダリングできます。

bash
# プレーンバウンス(オーディオクリップ。MIDI トラックは無音)
sonare project bounce --in song.json -o master.wav --sample-rate 48000

# MIDI を NativeSynth の既定パッチで鳴らす
sonare project bounce --in song.json -o master.wav --synth

# MIDI を名前付き NativeSynth プリセットで鳴らす
sonare project bounce --in song.json -o master.wav --synth saw-lead

# 同等の専用 MIDI レンダラー
sonare midi-render --in song.json -o master.wav --synth saw-lead

# まず確認: コンパイル診断(インストゥルメント未バインド警告を含む)
sonare project compile --in song.json --json
sonare project synth-presets          # 有効な NativeSynth プリセット名を列挙

--synth フラグは値が任意です。省略すれば既定パッチ、値を渡せばプリセット名になります。SF2 とデスティネーション別のシンセ JSON は CLI には接続されていません。SoundFont を使うバウンスは Project API を使ってください。ほかに project newproject validateproject abi も使えます。

レシピ

MIDI のみのプロジェクトをダウンロード可能な WAV へ

インストゥルメントと書き出しを含む、MIDI からファイルまでの全経路です。

typescript
const project = new Project();
try {
  project.setSampleRate(48000);
  project.setTempoSegments([{ startPpq: 0, bpm: 120 }]);
  const { clipId } = project.addMidiClip(0, 4);
  project.setMidiEvents(clipId, [
    Project.midiNoteOn(0, 0, 0, 60, 100),
    Project.midiNoteOff(3, 0, 0, 60, 0),
  ]);
  const audio = project.bounceWithSynthInstrument('saw-lead', { numChannels: 2, sampleRate: 48000 });
  const url = URL.createObjectURL(exportWav(audio, 48000, 2));
} finally {
  project.delete();
}
出荷前に無音バウンスを捕まえる

コンパイルし、警告を読み、それからインストゥルメントを選びます。

typescript
const result = project.compile();
const hasMidiWarning = result.diagnostics.some((d) =>
  d.severity === 1 && d.message.includes('bounce is silent'),
);
const audio = hasMidiWarning
  ? project.bounceWithSynthInstrument('saw-lead', { numChannels: 2 })
  : project.bounce({ numChannels: 2 });

きれいなバウンスが得られれば、ミックスは完成です。各トラックがマスターへ届く前の音作りを調整したいなら ミキシングエンジン でチャンネルストリップを詰め、アレンジしてバウンスする素材をさらに録りたいなら 録音とテイク を参照してください。

関連