録音・テイク・コンピング
このページは、ライブの音をプロジェクトへ取り込み、いくつもの不完全な演奏を 1 本の良い演奏に仕上げる方法を扱います。 録音は、ライブの音声(ボーカル、ギターの DI(アンプをマイクで拾わず直結)、シンセの演奏など)を、保存・編集・配置できるバッファへ取り込む作業です。コンピング(compositing の略)はその後の工程で、同じパートを複数のテイクとして録り、各テイクの良い瞬間をつなぎ合わせて 1 本の決定版を組み立てます。何枚かの集合写真から、それぞれの一番良い表情を選んで 1 枚の完璧な集合写真を作るようなものです。
libsonare はこれを協調する 2 つの層に分けています。リアルタイムエンジンはライブのキャプチャ経路(アーミング、キャプチャバッファ、入力モニタリング、パンチイン/アウト=選んだ範囲だけを録り直し、テイクの残りには触れない)を担います。プロジェクトモデルはその結果を担い、テイクとコンプセグメントを持つクリップを、編集コンパイラが 1 本の連続した演奏としてレンダリングします。
このページで身につくこと
このページを読むと、次のことができるようになります。
- エンジンのキャプチャバッファをサイズ指定してアームし、キャプチャソースを選び、録音した音声を読み戻す。
- 録音オフセットで往復レイテンシを補償し、入力モニタリングで自分の演奏を聞く。
- パンチイン/アウトで選んだ範囲だけを録音する。
bindMicrophoneInputでブラウザのマイクを開き、正しく後始末する。addLoopRecordingTakesでキャプチャしたループをテイクに分割し、setClipTakes/setClipCompSegmentsでテイクをまたいでコンピングする。splitSilenceCommonWithReportで複数の自由テイクに共通するカット位置を見つけ、remixAlignedIntervalsで全チャンネルを同じフレームで切り、リファレンスに対してずれるテイクをalignTakeToReferenceでワープして合わせる。waveformPeaksとwaveformPeakPyramidで録音波形を描画する。
2 つのクロック、2 つの層
キャプチャ経路は音声スレッド上でサンプル単位で動きます。プロジェクトは PPQ(音楽的位置)で動き、PPQ の 1 単位は 4 分音符 1 つ分です。つまり 120 BPM では ppq: 1 が 0.5 秒です。録音はサンプルで行い、結果は拍で記述します。この 2 つを取り違えなければ、以降の内容はすべて素直に理解できます。
テイクとコンプ
テイクは 1 回分の録音です。コンプは、複数テイクの良い部分をつなげた決定版です。libsonare では元のテイクをクリップ内に残し、コンプセグメントが PPQ 範囲ごとに「どのテイクを鳴らすか」だけを選びます。概念はテイクとコンピングを参照してください。
パイプライン内での録音の位置
録音で音声を取り込み、編集でタイミングとピッチを直し、ミキシングで複数トラックのバランスを取り、マスタリングでステレオ結果を磨きます。本ページは最初の工程と、テイクの山を 1 つの使えるクリップに変えるコンピング作業を扱い、その後は プロジェクト編集 へ引き継ぎます。
マイクからコンプ済みクリップまで
録音はリアルタイム層とプロジェクト層をまたぎます。エンジンがサンプルをキャプチャし、プロジェクトがそのサンプルをテイクとコンプ指示として保持します。この 2 つの層は capturedAudio() という単一の受け渡し地点でつながっており、ここがオーディオスレッドからメイン/制御スレッドへ切り替えるべき地点でもあります。
音声コールバック内で Project の編集メソッドを呼ばないでください。まずリアルタイムエンジンで録音し、テイクが終わってからメイン/制御スレッドでプロジェクトを更新します。
キャプチャ経路
リアルタイムエンジンは、事前に確保したキャプチャバッファへ録音します。バッファを確保し、かつキャプチャをアームするまでは何も記録されません。アームしている間、各 process(...) ブロックがバッファへ追記します。
エンジンは Node でも動くため、以下のキャプチャ経路はブラウザの外でも検証できます。ブラウザが必要なのはマイクの節だけです。
import { init, RealtimeEngine } from '@libraz/libsonare';
await init();
const sampleRate = 48000;
const engine = new RealtimeEngine(sampleRate, /* maxBlockSize */ 128);
try {
engine.setCaptureBuffer(/* numChannels */ 2, /* capacityFrames */ sampleRate * 10); // 10 秒分
engine.armCapture(); // 次に処理するブロックから追記を開始
engine.play();
// ブロック単位でエンジンを処理する(実アプリでは音声コールバック内)
engine.process([blockL, blockR]);
const status = engine.captureStatus();
// { capturedFrames, overflowCount, armed, punchEnabled, source, recordOffsetSamples }
const channels = engine.capturedAudio(); // Float32Array[] — チャンネルごとに 1 本
} finally {
engine.destroy(); // WASM ハンドルは GC されない — 必ず解放する
}import libsonare as sonare
engine = sonare.RealtimeEngine(sample_rate=48000.0, max_block_size=128)
try:
engine.set_capture_buffer(2, 48000 * 10) # num_channels, capacity_frames: 10 秒分
engine.arm_capture()
engine.play()
engine.process([block_l, block_r]) # 実アプリでは音声コールバック内
status = engine.capture_status()
# status.captured_frames, overflow_count, armed, punch_enabled, source, record_offset_samples
channels = engine.captured_audio() # list[list[float]]、チャンネルごとに 1 本
finally:
engine.close()エンジンは必ず解放する
RealtimeEngine はすべての embind オブジェクトと同様、JavaScript の GC では回収できない WASM ヒープハンドルを保持します。finally ブロックで engine.destroy() を呼んでください。ハンドルをリークすると、長時間のセッションで WASM メモリが徐々に枯渇します。
キャプチャバッファのサイズ指定
setCaptureBuffer(numChannels, capacityFrames) は、音声スレッドがテイク途中で確保しないように録音領域を事前確保します。想定する最長テイクに合わせて capacityFrames = 秒数 * sampleRate でサイズを決めてください。テイクが容量を超えると、エンジンは追記を止め、捨てたフレーム数を captureStatus().overflowCount に数えます。これが 0 でなければ容量不足なので、より大きなバッファを確保してください。
アーミングとキャプチャステータス
armCapture(armed?) は録音を切り替えます。引数なし(または true)でアーム、armCapture(false) で、これまでの内容を捨てずに追記だけ止めます。resetCapture() は記録済みフレームを 0 に戻し、次のテイクをまっさらから始めます。進捗はいつでも captureStatus() で読めます。
| フィールド | 意味 |
|---|---|
capturedFrames | これまでに記録したフレーム数 |
overflowCount | バッファが満杯で捨てたフレーム数(0 が健全) |
armed | 現在追記中かどうか |
punchEnabled | パンチイン/アウト範囲が有効かどうか |
source | 'output' か 'input' — 何をキャプチャしているか |
recordOffsetSamples | 有効な録音オフセット補償 |
キャプチャソース: 出力バスかライブ入力か
setCaptureSource('output' | 'input') は、エンジンが何を録音するかを選びます。
'output'(既定)はエンジンのレンダリング済み出力バス、つまり実際に聞こえる音(再生中のクリップや楽器を含む)をキャプチャします。アレンジのライブ演奏をバウンスするのに使います。'input'はprocess(...)に渡した生の音声、つまりマイクや楽器からのライブ信号をキャプチャします。新しいパートを録るのに使います。
録音オフセット補償
ライブモニタリング経路にはレイテンシがあります。演奏者がクリックを聞いて音を出す頃には、エンジンはすでに先へ進んでいます。setRecordOffsetSamples(offsetSamples) は、process へ渡すストリーム上でキャプチャ範囲をずらします。正の値はキャプチャを遅らせます。パンチ範囲が [start, end) のとき、録音されるのは [start + offsetSamples, end + offsetSamples) です。演奏者の音が往復レイテンシぶん遅れてエンジンに届く場合は、そのレイテンシと同じ正のオフセットを指定します。有効な値は captureStatus().recordOffsetSamples に反映されます。ループ内でも各パスは途切れません。折り返しのあとオフセット以内に届いた入力は、前のパスの末尾に取り込まれます。
パンチ範囲がなければ効果なし
オフセットが作用するのはパンチ範囲に対してなので、範囲がなければ何も変わりません。
入力モニタリング
setInputMonitor(enabled, gain?) は、演奏者が自分の音を聞けるように、ライブ入力をエンジンの出力へ混ぜるかどうかを決めます。setInputMonitor(true, 0.5) は入力を半分のレベルで通し、setInputMonitor(false) は無音で録音します(二重モニタリングのレイテンシを避けるため、演奏者がハードウェア側でモニタリングする場合に便利です)。モニタリングはキャプチャソースとは独立しているので、入力をモニタリングしながら出力バスをキャプチャする、あるいはその逆も可能です。
パンチイン/アウト
パンチ録音は、選んだタイムライン範囲の内側だけでキャプチャをアームし、その外側にある確定テイクには手を触れません。punchEnabled が true の間は、トランスポート(動いている再生位置)が [startSample, endSample) の内側にあるときだけ追記します。
| 表記 | 呼び出し |
|---|---|
| サンプル | setCapturePunch({ startSample, endSample, enabled? }) |
| 秒 | setCapturePunch({ startSec, endSec })。エンジンのサンプルレートで最も近いサンプルに丸められます |
| 位置引数 | setCapturePunch(startSample, endSample, enabled?) |
| Python | set_capture_punch(start_sample, end_sample, enabled=True)、またはキーワード専用の start_sec / end_sec |
境界ごとにどちらか一方の表記を使い、両方の境界を指定してください。負の境界、片方の境界への両表記の併用、境界の欠落、有効なまま長さ 0 の範囲は RangeError になります(Python は SonareValueError)。enabled: false で範囲を解除し、自由録音へ戻ります。resetCapture() はバッファを空にしますが、パンチ範囲は残します。
パートを録ってから読み戻す
const engine = new RealtimeEngine(48000, 128);
try {
engine.setCaptureBuffer(1, 48000 * 8); // モノラル、最大 8 秒
engine.setCaptureSource('input'); // ミックスではなくライブ信号を録音
engine.setCapturePunch({ startSec: 0, endSec: 8 });
engine.setRecordOffsetSamples(256); // 入力が約 256 フレーム遅れて届くので、256 フレーム遅らせて録る
engine.setInputMonitor(true, 1.0); // 演奏者に自分の音を聞かせる
engine.armCapture();
engine.play();
// ... engine.process([micBlock]) へライブ入力ブロックを渡す ...
const [mono] = engine.capturedAudio(); // 録音したテイクが Float32Array で得られる
} finally {
engine.destroy();
}ブラウザのマイク入力
ブラウザでは、bindMicrophoneInput(audioContext, engine, options?) が getUserMedia のマイクストリームをリアルタイムエンジンノードへ配線し、後でクローズできるバインディングを返します。これはブラウザ専用で、ライブの AudioContext と WebAudio グラフが必要です。SharedArrayBuffer なしで動きます(COOP/COEP ヘッダー不要)。
// ブラウザ専用 — ライブの AudioContext を持つページ内で動作する
const binding = await bindMicrophoneInput(audioContext, engineNode, {
// MediaStreamConstraints のフィールドはそのまま getUserMedia へ渡される
audio: { echoCancellation: false, noiseSuppression: false, channelCount: 1 },
// すでに開いたストリームを再利用することもできる
// stream: existingMediaStream,
stopTracksOnClose: true, // 既定値。クローズ時にマイクトラックを停止する
});
// binding.stream -> ライブの MediaStream
// binding.source -> エンジンへ接続する MediaStreamAudioSourceNode
// テイクが終わったら
binding.close(); // ソースを切断する(stopTracksOnClose ならトラックも停止)オプションオブジェクトは MediaStreamConstraints を継承しているので、getUserMedia に渡せる制約(echoCancellation・noiseSuppression・channelCount、デバイスの deviceId などを持つ audio オブジェクト)はそのまま通ります。追加の 2 フィールドが libsonare 独自です。
stream— 新規に許可を求める代わりに、すでに取得済みのMediaStreamを再利用します。stopTracksOnClose— 既定はtrueです。true のとき、binding.close()が背後のマイクトラックも停止し、OS の録音インジケーターを消します。自分で所有するstreamを渡し、他所でも使い続けたい場合はfalseにしてください。
終わったらバインディングをクローズする
binding.close() はソースノードを切断し、マイク音声がエンジンへ送られるのを止めます。bindMicrophoneInput には必ず close() を組にしてください(通常はユーザーが録音を止めたとき、またはコンポーネントのアンマウント時)。既定ではマイクトラックも停止するため OS の録音インジケーターが消えます。同じストリームをアプリの別部分で使い続ける場合だけ stopTracksOnClose: false を渡してください。
周辺の AudioWorklet 配線(エンジンノードの構築、worklet プロセッサの登録、SAB(SharedArrayBuffer)なしのリアルタイム経路)は リアルタイムストリーミング を参照してください。外部マイクではなくシンセやサンプラーを録音へ入れたい場合は、内蔵シンセサイザー(NativeSynth)と SoundFont プレイヤー、ライブ演奏で鳴らすなら MIDI 入力 を参照してください。
ループ録音からテイクへ
ループ録音は、演奏しながら同じ音楽的範囲を何度も繰り返し、1 周ごとに 1 つのテイクをキャプチャします。Project.addLoopRecordingTakes(desc) は、全周分を連結したキャプチャ音声を受け取り、1 本の新しいクリップ上の周回ごとのテイクへ分割します。
import { init, Project } from '@libraz/libsonare';
await init();
const project = new Project();
try {
const sampleRate = 48000;
project.setSampleRate(sampleRate);
const trackId = project.addTrack({ kind: 'audio', name: 'vocal' });
// 120 BPM での 4 分音符 4 つ分のループは 4 * (60 / 120) = 2 秒。
// したがって 3 周には 3 * 2 秒の音声が必要。
const loopLengthQuarters = 4;
const passes = 3;
const passSamples = Math.round((loopLengthQuarters * 60 / 120) * sampleRate); // 96000
const recorded = new Float32Array(passes * passSamples); // 連結した周回(audioChannels が 2 以上ならインターリーブ、またはチャンネルごとに 1 本の配列)
const result = project.addLoopRecordingTakes({
trackId,
startPpq: 0,
loopLengthPpq: loopLengthQuarters, // ループ長(4 分音符単位)
audio: recorded,
audioChannels: 1,
audioSampleRate: sampleRate,
});
// result -> { clipId, takeCount: 3 }
} finally {
project.delete();
}import libsonare as sonare
import numpy as np
sample_rate = 48000
with sonare.Project() as project:
project.set_sample_rate(sample_rate)
track_id = project.add_track("audio", "vocal")
# 120 BPM での 4 分音符 4 つ分のループは 2 秒。3 周には 3 * 2 秒の音声が必要。
loop_length_quarters = 4.0
passes = 3
pass_samples = round(loop_length_quarters * 60 / 120 * sample_rate) # 96000
recorded = np.zeros(passes * pass_samples, dtype=np.float32) # 連結した周回
clip_id, take_count = project.add_loop_recording_takes(
track_id, 0.0, loop_length_quarters, recorded,
audio_channels=1, audio_sample_rate=sample_rate, partial_tail="keep",
) # take_count == 3Node はブラウザのタブと同じ呼び出しです。
主張するテイク数に見合う音声を渡す
loopLengthPpq は 4 分音符単位なので、その長さはテンポに依存します。4 分音符 4 つ分のループは 120 BPM で 2 秒、60 BPM では 4 秒です。先にプロジェクトのテンポを設定し、そこから周回ごとのサンプル数を求め、passes * passSamples フレームを渡してください。これより少ないと、期待より少ないテイクしか得られません。
インターリーブでもプレーナーでも渡せる
audio はどちらの形も受け付けます。単一の Float32Array は、audioChannels が 2 以上のとき L, R, L, R, … と並ぶインターリーブ形式として読まれます。Float32Array の配列(チャンネルごとに 1 本で、長さはすべて同じ。engine.capturedAudio() が返す形)はプレーナー形式として読まれ、配列の長さがチャンネル数になって audioChannels は無視されます。Python も、チャンネルが先頭軸の 2 次元配列や、同じ長さのチャンネルごとのシーケンスを同様に受け付けます。
この呼び出しは、ループの各周回を別テイクとして持つクリップを 1 本追加し、アクティブテイクを最新(最後)の周回に設定します。キャプチャした音声がループ境界ちょうどで終わっていないと、最後の周回は短い部分テイクになります。部分テイクは残りますが、アクティブにはなりません。直前の完全なテイクがアクティブのままで、部分テイクはプロジェクト JSON の takes のエントリに "partial": true を持ち、これは往復保存されます。1 ループに満たないキャプチャは部分テイクだけになり、そのテイクがアクティブのままです。残りが 1 フレーム以下なら、テイクは作られません。
partialTail: 'drop'(Python は partial_tail="drop")を渡すと、部分テイクを作りません。既定は 'keep' です。上の例で、完全な 2 周回に 30000 フレームを足すと takeCount: 3 で、テイク 2 がアクティブ、テイク 3 が部分テイクになります。'drop' なら takeCount: 2 です。ここからテイクをまたいでコンピングします。
テイクとコンプセグメント
クリップは、順序づけられたテイクのリストに加えて、アクティブテイク ID(コンプの指定がないとき再生されるテイク)とコンプセグメントのリスト(PPQ 範囲ごとに、そこで再生するテイクを指す)を持てます。どちらも取り消し可能な編集で、どちらも JSON でラウンドトリップします。
// takes: 各テイクは安定した id を持つ。sourceOffsetPpq はテイクのソースをクリップ下でずらす。
project.setClipTakes(clipId, [
{ id: 1, name: 'take A' }, // sourceId なし: クリップ自身のソースを再生
{ id: 2, sourceId: takeBSourceId, name: 'take B' }, // 別の録音。これがないと両テイクが同じ音声を再生する
], /* activeTakeId */ 2);
// comp segments: 各 PPQ 範囲が 1 つのテイクを選ぶ。Aメロは take A、サビは take B。
project.setClipCompSegments(clipId, [
{ startPpq: 0, endPpq: 2, takeId: 1 },
{ startPpq: 2, endPpq: 4, takeId: 2 },
]);# takes: 各テイクは安定した id を持つ。source_offset_ppq はテイクのソースをクリップ下でずらす。
project.set_clip_takes(clip_id, [
{"id": 1, "name": "take A"}, # source_id なし: クリップ自身のソースを再生
{"id": 2, "source_id": take_b_source_id, "name": "take B"}, # 別の録音
], 2) # active_take_id
# comp segments: 各 PPQ 範囲が 1 つのテイクを選ぶ。Aメロは take A、サビは take B。
project.set_clip_comp_segments(clip_id, [
{"start_ppq": 0.0, "end_ppq": 2.0, "take_id": 1},
{"start_ppq": 2.0, "end_ppq": 4.0, "take_id": 2},
])ProjectClipTake は { id, sourceId?, sourceOffsetPpq?, name? }、ProjectClipCompSegment は { startPpq, endPpq, takeId?, crossfadePpq? } です。テイク id は一意でなければならず、コンプセグメントが参照する 0 以外の takeId は存在するテイクを参照しなければなりません。0 以外の参照が不正だと例外を投げるので、不正な編集はクリップを壊すのではなく、はっきり失敗します。セグメントは順序どおりで、重ならず、クリップの内側に収まり、それぞれ正の長さを持つ必要があります。takeId が 0(または省略時)のセグメントは意図的な例外で、そのリージョンではクリップのアクティブテイク(ベースソース)にフォールバックします。id を名指ししなくても、セグメントをアクティブテイクのままにできます。
継ぎ目をなじませる
2 つのテイクは別々の信号なので、コンプの継ぎ目では波形が食い違ったぶんだけ段差ができます。crossfadePpq は、セグメントをその手前の部分に重ねてフェードインさせます。切り替えはセグメントの開始位置でちょうど完了し、クリップの長さは動きません。フェードは等電力で、2 つのテイクのゲインの 2 乗の和が 1 になります。2 つの信号が無関係ならレベルは一定に保たれ、別々に録ったテイク同士はふつうこれに当たります。一方、ほぼ同じ素材の継ぎ目(同じテイク同士や、ぴったり重ねたダブリング)では、フェードの中央でレベルが最大で約 3 dB 上がります。理由はフェードカーブとクロスフェードで説明しています。
project.setClipCompSegments(clipId, [
{ startPpq: 0, endPpq: 2, takeId: 1 },
{ startPpq: 2, endPpq: 4, takeId: 2, crossfadePpq: 0.25 }, // 4 分音符ぶんかけて入れ替える
]);既定の 0 は突き合わせ(バットジョイン)で、セグメント同士は重ならずにつながります。クロスフェードは、自身のセグメントと、手前の部分のうちその直前の区間の両方に収まる長さでなければなりません。先頭のセグメントには手前がないので、0 しか指定できません。この限度を超える値を渡すと、setClipCompSegments は丸めずに InvalidParameter を投げます。
3 番目の引数 activeTakeId は省略可能で、既定値は 0 です。0 を渡す(または省略する)と、クリップはアクティブテイクで上書きせず、ベースソースをそのまま再生し続けます。テイクは定義してあるが、既定ではそのどれにも元のクリップ音声を置き換えさせたくないときに便利です。0 以外の activeTakeId は、渡したテイクのいずれかの id と一致しなければなりません。テイク id 自体は必ず 0 以外なので、0 は明確に「アクティブテイクなし」を意味します。
コンピングは他のクリップ編集とも安全に組み合わせられます。splitClip やトリムは、テイク・各テイクの sourceOffsetPpq・該当範囲のコンプセグメントを分割後のクリップへ引き継ぐため、コンプ済みクリップを切ってもコンプは失われません。唯一拒否される組み合わせは、クリップを分割するコンプセグメントとループモードの併用です。ループクリップは 1 つの連続した範囲を繰り返すものなので、両者が同時に成立する編集は、ループできないコンプを作る代わりに拒否されます。
アクティブテイクとコンプセグメント
アクティブテイクは、コンプセグメントがないときに再生される唯一のテイクです(まだ試聴している段階で便利です)。activeTakeId: 0(または省略時)はアクティブテイクなしを意味し、コンプされていない位置ではクリップのベースソースが再生されます。コンプセグメントはこれを範囲ごとに上書きします。セグメントが位置を覆っていれば、その takeId が優先され、どのセグメントの外側でもアクティブテイクが埋めます。コンプはアクティブテイクを置き換えるのではなく、その上にセグメントを足して組み立ててください。
編集コンパイラがコンプをどうレンダリングするか
プロジェクトを compile()(またはレンダリング)すると、編集コンパイラはクリップのタイムラインをたどり、各位置についてどのテイクの音声を読むかを解決します。その位置を覆うコンプセグメントがあればそのテイク、なければアクティブテイクです。各テイクのソースはそれぞれの sourceOffsetPpq で読まれるので、少し遅れて録れたテイクも、録り直さずに位置へ寄せられます。こうしてコンパイル済みのタイムラインは、多数のテイクから組み立てた 1 本の継ぎ目ない演奏として再生されます。コンプはビューであり、元のテイクはそのまま残って後で組み直せます。
JSON ラウンドトリップ
project.toJson() はテイク、アクティブテイク、コンプセグメントをシリアライズします("takes"・"active_take_id"・"comp_segments")。Project.fromJson(json) がそれらを復元します。コンピングはテイク上の非破壊メタデータなので、プロジェクトを保存して読み直しても、すべての別テイクとコンプが保たれます。セッションをまたいでコンプを練り続けられます。
2 つのテイクをコンプして保存
project.setClipTakes(clipId, [
{ id: 1, name: 'take A' },
{ id: 2, name: 'take B' },
], 1);
project.setClipCompSegments(clipId, [
{ startPpq: 0, endPpq: 2, takeId: 1 }, // 前半は take A
{ startPpq: 2, endPpq: 4, takeId: 2 }, // 後半は take B
]);
const json = project.toJson(); // "takes" と "comp_segments" を含む
const restored = Project.fromJson(json); // コンプはラウンドトリップで保たれる
restored.delete();自由テイクのコンピング: 共通のカット位置と位置合わせ
ループテイクはグリッドに乗って届きます。どの周回も同じ PPQ 範囲を覆うので、addLoopRecordingTakes は長さだけで分割できます。別々のパスとして録ったテイク、つまりガイドボーカルや伴奏に合わせて、そのつど頭からパートを通したものはそうなりません。ライブで歌ったテイクはリファレンスに対して、ここでは少し前に、あそこでは少し後ろにずれるので、1 つのオフセットでは合わせられず、コンピングにはどのテイクも同じ位置で切る作業が要ります。シグネチャは JavaScript API のページにあります。この節が扱うのは、呼び出しの順序と戻り値の読み方です。
| 状況 | 呼び出し | 結果 |
|---|---|---|
| テイクが揃っている(ループの周回、クリックに合わせて録ったテイク) | splitSilenceCommonWithReport のあと remixAlignedIntervals | 共通のカット位置と、全チャンネルで同じフレーム |
| 1 つのテイクがリファレンスに対してずれる | alignTakeToReference のあと setWarpMap | ワープアンカー。コンプはプロジェクトのタイムライン上で切る |
| どちらか分からない | report.silenceCeilingDb を topDb と見比べる | テイクが共有するカット位置の表 |
テイクが共有するカット位置
同じパートのテイクどうしが共有するのは音ではなく無音です。あるテイクがたまたま静かになる場所で別のテイクがまだ歌っているなら、そこで切ってはいけません。splitSilenceCommon({ signals }) は全テイクをまとめて受け取り、各テイクの鳴っている区間の和集合を返します。形は splitSilence が 1 本の信号に対して返すのと同じフラットな [start0, end0, start1, end1, …] の Int32Array で、接する区間は結合されます。したがって、返された区間どうしの隙間はすべてのテイクで同時に無音であり、隙間に置いたカットはどのテイクでもフレーズの途中には落ちません。長さの違うテイクにパディングは要りません。短いテイクは自分の終端より先には何も寄与せず、それはそこで無音であるのと同じです。信号が 1 本なら答えは splitSilence とまったく同じです。実際に切るのは呼び出し側で、配列をスライスするだけです。
splitSilenceCommonWithReport は同一の区間に加えて report を返します。レポートがあるのは、全体を覆う区間 1 つという答えが 3 つの異なる状況に対応し、区間リストではそれらを区別できないからです。
import { splitSilenceCommonWithReport } from '@libraz/libsonare';
const topDb = 60; // 既定値。フレームは、そのテイク自身のピークからこれだけ下にあるとき無音とみなす
const { intervals, report } = splitSilenceCommonWithReport({
signals: [takeA, takeB, takeC], // テイクごとに 1 本の Float32Array、各 1 チャンネル
topDb,
});
// intervals -> Int32Array [start0, end0, start1, end1, ...]。すべてのテイクで有効
// report -> { silenceCeilingDb, maxSignalIntervals, minSignalIntervals }silenceCeilingDb(すべてのテイクにまだ無音が残る最大の topDb)を、渡した topDb と見比べてください。
silenceCeilingDb | 意味 | 対処 |
|---|---|---|
0 に近い | 鳴りやまないテイクがあり、どのしきい値でもそこに隙間は開かない | 無音以外のものからカット位置を取る(マーカーや拍) |
topDb より低い | しきい値が緩すぎて、これらのテイクにある静けさが見えていない | 報告された ceiling を下回る topDb で再実行する。その値なら切れる |
topDb 以上で、なお区間が 1 つ | この設定ではどのテイクも静かになるが、同じ場所では決してならない | テイクどうしのタイミングが合っていない。先に位置合わせする(下記) |
2 つの区間数が表すのは形であって原因ではありません。一度鳴って止まるテイクは、無音のないテイクとまったく同じく 1 と数えられます。そのため、どれかのテイクに内部の隙間があるか(maxSignalIntervals >= 2)と、テイクどうしで分かれ方がどれほど違うかを見るのに使い、判定は ceiling に委ねてください。全体が無音のテイクではピークがゼロで比が定まらないため、silenceCeilingDb は 0 です。3 つの数値はすべて区間と同じ RMS パスから得られるので、別の測定を記述することはありません。素の splitSilenceCommon は切るだけの呼び出し側のために残っています。両エントリポイントは既定値(topDb 60、frameLength 2048、hopLength 512)と拒否条件を共有し、空の signals リストや空のテイクはどちらからも例外になります。
全チャンネルを同じフレームで切る
モノラルテイクなら、各区間について take.subarray(start, end) するだけで仕事は終わりです。ステレオテイクには手順がもう 1 つあります。remix はスライスがクリックで始まらないようにカット位置をゼロクロスへ吸着させられますが、吸着は信号ごとの判断です。チャンネルごとに実行すると各チャンネルが別のフレームへ吸着し、カットのたびにステレオ像が離れていきます。remixAlignedIntervals は吸着後のカット集合を1 つのチャンネルから、切らずに解決し、入力区間ごとにクランプ済みの (start, end) 対を 1 つ返すので、同じフレームを全チャンネルに適用できます。
import { remixAlignedIntervals } from '@libraz/libsonare';
const cuts = remixAlignedIntervals({
samples: takeLeft, // 1 チャンネルだけから解決する
intervals, // 上の分割で得た共通区間
sampleRate, // 検証されるので、実際のレートを渡す
alignZeros: true, // ここでは既定。remix 自体の既定は false
});
for (let i = 0; i < cuts.length; i += 2) {
const left = takeLeft.subarray(cuts[i], cuts[i + 1]);
const right = takeRight.subarray(cuts[i], cuts[i + 1]); // 同一のフレーム
}スライスが消えないよう 2 つのガードがあります。符号の変化がまったくないチャンネル(無音、DC オフセット、あらゆる定数)は吸着させず、内容はあったのに吸着で空になってしまうスライスは吸着前の境界を保ちます。alignZeros: false なら、対は単に入力をバッファへクランプしたものです。この呼び出しが消費するものに注意してください。無音分割で得た、テイク自身のバッファ内のサンプル位置で表した区間であって、下の位置合わせで得るアンカーではありません。あれはプロジェクトのタイムラインを記述するものです。
ずれるテイクを位置合わせする
レポートの ceiling が topDb 以上なのに区間が 1 つしか返らないとき、各テイクは静かになるものの一緒には静かにならず、テイクどうしのタイミングが合っていません。それはしきい値では直せません。alignTakeToReference はリファレンスとテイクそれぞれのクロマグラム(12 の音高クラスの時間変化)を測定し、両者を動的時間伸縮(DTW。テイクの各瞬間とリファレンスの各瞬間を最も安いコストで対応づける経路を探し、どちらかが待つことも許す手法)で位置合わせして、その結果をプロジェクトのワープアンカー、つまり setWarpMap が受け取る { warpSample, sourceSample } の対として返します。向きは、ソースがそのテイクであるクリップ向けにすでに整っています。warpSample はリファレンスのタイムライン上の位置、sourceSample はそれに対応するテイク内の位置です。録音そのものには触れず、クリップがそれをリファレンスの下で伸縮して再生します。
import { alignTakeToReference, Project } from '@libraz/libsonare';
const { anchors, alignment } = alignTakeToReference({
reference, // Float32Array: ガイドテイク、またはテイクを歌ったときの伴奏
take, // Float32Array: その下へ収めるテイク
sampleRate, // リファレンスのレート
// takeSampleRate: 44100, // テイクのレートが異なるときに指定。既定は sampleRate
// hopLength: 512, // クロマのホップ。小さいほど多くのフレームを測定し、アンカーが増える
// binsPerOctave: 12, // 正の 12 の倍数。レートに対して確認済みでなければ触らない
});
// alignment -> { meanResidualFrames, referenceFrames, takeFrames } — 記述的な値で、失敗を表すことはない
const project = new Project();
try {
project.setSampleRate(sampleRate);
const trackId = project.addTrack({ kind: 'audio', name: 'take 2' });
const clipId = project.addClip({ trackId, lengthPpq: 8, audio: take, audioSampleRate: sampleRate });
project.setWarpMap({ id: 1, name: 'take 2 under guide', anchors });
project.setClipWarpRef(clipId, 1);
project.setClipWarpMode(clipId, 'time-stretch'); // アンカーに従い、ピッチは保つ
} finally {
project.delete();
}ワープマップはクリップに属するので、位置合わせしたテイクはそれぞれ自分のクリップと自分のマップを持ちます。全部がリファレンスの下で同じタイミングで再生されるようになったら、その共有タイムライン上で splitClip とトリムでコンプを切ります。'time-stretch' のクリップは次の音声ブロックから新しいマップを反映し、焼き直しは要りません。アンカーはワープマップが求める狭義単調増加の形へすでに縮約されています。DTW の経路は 2 つの録音の速度が違う箇所で片方の軸を止めたままにするので、座標が並ぶ区間はその中央のアンカーで代表されます。したがって結果はそのまま setWarpMap へ渡せます。
alignment は、その組み合わせがどれほど整っていたかを表します。これらの値が呼び出しを失敗させることはなく、何を許容するかは呼び出し側が自分のしきい値で決めます。
takeFrames / referenceFramesは、アンカーが表す全体のレート差です。meanResidualFramesは、経路が自身の対角トレンドから絶対値でどれだけ外れたかの平均です。テイクがリファレンスとどれほど食い違うかの粗い目安であって、誤差の上界ではありません。
何がどう拒否されるか
位置合わせできないテイクはエラーとして報告され、使えないマップが返されることはありません。拒否する層は 2 つあり、どちらの層で拒否されたかによって、エラーから分かることが変わります。
- 測定の前に、フィールド名つきで。 空のバッファ(
reference must not be empty、take must not be empty)、非有限のサンプル(reference contains NaN or Inf、take contains NaN or Inf)、[8000, 384000]の整数でないsampleRateやtakeSampleRate、整数でないhopLengthやbinsPerOctaveは、いずれも問題のフィールドを名指しするRangeErrorを投げます。数値ですらない値はTypeErrorを投げます。 - 測定から、
SonareErrorのInvalidParameterとして。 負のhopLength、負か 12 の倍数でないbinsPerOctave(各音高クラスは整数個の CQT ビン(CQT は定 Q 変換で、対数間隔のビンを持つスペクトル)の平均なので、13 や 18 には畳み込み先がありません)、相異なるアンカーが 2 個得られない組み合わせ(どちらかの録音がクロマ 1 フレームに満たないとそうなります)。コアは引数を名指しせずに条件を報告します。
12 の倍数であることは必要条件であって十分条件ではありません。使える最も細かい binsPerOctave はサンプルレートに依存し、レートが上がっても細かくはならないので、実行するレートでより細かいグリッドを確認済みでなければ既定のままにしてください。どちらのフィールドも、省略するか 0 を書くとライブラリの値が選ばれます。
サンプルレートについては次のとおりです。
sampleRateはリファレンスのレート、takeSampleRate(Python はtake_sample_rate、C はsonare_align_take_to_reference_ex)はテイクのレートです。両者が異なるときは、測定の前にテイクをリファレンスのグリッドへリサンプルします。- 2 本のアンカー軸はどちらもリファレンスのレートのサンプルで返り、
hopLengthもそのレートです。 - プロジェクトはワープアンカーをプロジェクトのレートのサンプルとして読むので、
setWarpMapの前にwarpSampleとsourceSampleの両方へ「プロジェクトのレート ÷ リファレンスのレート」を掛けてください(上の例では両者が等しい)。CLI 版のsonare project align-takesはリファレンスと各テイクをファイルから読み、レートの異なるテイクも整列し、アンカーをプロジェクトのレートで書き出します。
録音波形を描く
テイクを描くとき、全サンプルをプロットはしません。音声をバケットごとの min/max 対へ縮約し、それを塗りつぶしのエンベロープとして描きます。waveformPeaks(samples, channels, options?) がインターリーブ音声からこの縮約を行います。
import { init, waveformPeaks, waveformPeakPyramid } from '@libraz/libsonare';
await init();
// インターリーブステレオ: L, R, L, R, ...
const interleaved = new Float32Array([
-1.0, 0.5, 0.25, -0.25, 0.75, 0.1, -0.5, -0.75, 0.0, 0.9,
]);
const report = waveformPeaks(interleaved, 2, { samplesPerBucket: 2 });
// report = {
// channels: 2,
// bucketCount: 3,
// samplesPerBucket: 2,
// min: Float32Array [ -1, -0.5, 0, -0.25, -0.75, 0.9 ], // チャンネルメジャー
// max: Float32Array [ 0.25, 0.75, 0, 0.5, 0.1, 0.9 ],
// }min と max の配列はチャンネルメジャーです。チャンネル 0 の全バケット、続いてチャンネル 1 の全バケットの順に並びます。channels と bucketCount を使えば、チャンネル c のバケット b は report.min[c * report.bucketCount + b] で参照できます。samplesPerBucket を省略すると、バケットあたり 512 フレームが既定値になります。
ズーム可能な表示には、waveformPeakPyramid(samples, channels, options?) がレポート 1 つではなくズームレベルごとに 1 つずつレポートを返します。
const pyramid = waveformPeakPyramid(interleaved, 2, { samplesPerBucketLevels: [2, 4] });
// pyramid.length === 2
// pyramid[0].samplesPerBucket === 2 (細かい — バケットが多くズームイン)
// pyramid[1].samplesPerBucket === 4 (粗い — バケットが少なくズームアウト)各レベルは完全な WaveformPeaksReport です。オプションなしの場合、ピラミッドは標準のズームセット [512, 1024, 2048, 4096] フレーム/バケットを使うので、ユーザーのズームに合わせて、毎フレームのピーク再計算なしに詳細度を切り替えられます。
キャプチャ音声から描く
capturedAudio() はプレーナーなチャンネル(それぞれ 1 本の Float32Array)を返しますが、waveformPeaks はインターリーブ入力を求めます。モノラルテイクなら、その 1 チャンネルを channels: 1 でそのまま渡してください。ステレオなら、先に 2 チャンネルをインターリーブするか、チャンネルごとにピークを求めて各レーンを別々に描いてください。
レシピ
出力バスをキャプチャして描く
アレンジのライブ演奏を録り、描画用の波形へ縮約します。
const engine = new RealtimeEngine(48000, 128);
try {
engine.setCaptureBuffer(2, 48000 * 30); // ステレオ、最大 30 秒
engine.setCaptureSource('output'); // レンダリング済みのミックスをキャプチャ
engine.armCapture();
engine.play();
// ... engine.process([...]) で演奏を進める ...
const [left] = engine.capturedAudio(); // 1 チャンネルを描く
const peaks = waveformPeaks(left, 1); // 既定で 512 フレームのバケット
// peaks.min / peaks.max -> キャンバス用エンベロープ
} finally {
engine.destroy();
}テイクをキャプチャしてコンプし終えれば、それは他のオーディオと同じようにプロジェクトのクリップとして存在します。ここからは プロジェクト編集 でアレンジ・トリミングし、仕上がったアレンジを プロジェクトのバウンス でファイルへ変換します。
関連
- リアルタイムストリーミング — エンジンノード、AudioWorklet ブリッジ、SAB なしのリアルタイム経路
- プロジェクト編集 — クリップ、PPQ、フェード、ワープ、コンプをレンダリングする編集コンパイラ
- JavaScript API —
alignTakeToReference、remixAlignedIntervals、splitSilenceCommonWithReportのシグネチャ - MIDI 入力 — 録音しながら楽器をライブで鳴らす
- 内蔵シンセサイザー · SoundFont プレイヤー — エンジンへ録音するソース
- プロジェクトのバウンス — 録音し終えたアレンジをオフラインでレンダリングする