Web Audio API : 高效播放 PCM 流
Web Audio API : efficiently play a PCM stream
这里有个问题:
- 我的 JS 应用程序接收原始 PCM 数据(通过 WebRTC 数据通道),
- 采样率为88200(我可以在另一端轻松将其更改为44100),
- 数据已正确编码为 4 字节浮点 [-1, 1] 小端样本,
- 数据以 512 个样本(512*4 字节)的块形式到达,
- 数据可以随时开始到达,它可以持续任何时间,它可能停止,它可能恢复。
- 目标是渲染声音。
我做的是:
var samples = []; // each element of this array stores a chunk of 512 samples
var audioCtx = new AudioContext();
var source = audioCtx.createBufferSource();
source.buffer = audioCtx.createBuffer(1, 512, 88200);
// bufferSize is 512 because it is the size of chunks
var scriptNode = audioCtx.createScriptProcessor(512, 1, 1);
scriptNode.onaudioprocess = function(audioProcessingEvent) {
// play a chunk if there is at least one.
if (samples.length > 0) {
audioProcessingEvent.outputBuffer.copyToChannel(samples.shift(), 0, 0);
}
};
source.connect(scriptNode);
scriptNode.connect(audioCtx.destination);
source.start();
peerConnection.addEventListener("datachannel", function(e) {
e.channel.onmessage = function(m) {
var values = new Float32Array(m.data);
samples.push(values);
};
);
有几个问题:
audioProcessingEvent.outputBuffer.sampleRate
总是 48000
。显然它不依赖于 source
的比特率,我找不到将其设置为 88200
、44100
或任何其他值的方法。声音以不断增长的延迟呈现。
ScriptProcessorNode
已弃用。
- 就处理器而言,这是一种非常昂贵的方法。
提前感谢您的任何建议!
你想要一个 AudioBuffer.
您可以直接从 TypedArray 将原始 PCM 数据复制到其通道中。
您可以指定其 sampleRate
,AudioContext 将负责重新采样以匹配声卡的设置。
但是要注意,每个块 2048 字节意味着每个块只代表 5ms 的音频数据 @88Khz:我们传递一个 Float32Array,所以字节大小是 4 和 2048 / 4 / 88200 = ±0.0058s.
您可能想要增加它,并实施一些缓冲策略。
这是一个小演示,作为将块数据存储到缓冲区 Float32Array 中的概念证明。
const min_sample_duration = 2; // sec
const sample_rate = 88200; // Hz
// how much data is needed to play for at least min_sample_duration
const min_sample_size = min_sample_duration * sample_rate;
const fetching_interval = 100; // ms
// you'll probably want this much bigger
let chunk_size = 2048; // bytes
const log = document.getElementById( 'log' );
const btn = document.getElementById( 'btn' );
btn.onclick = e => {
let stopped = false;
let is_reading = false;
const ctx = new AudioContext();
// to control output volume
const gain = ctx.createGain();
gain.gain.value = 0.01;
gain.connect( ctx.destination );
// this will get updated at every new fetch
let fetched_data = new Float32Array( 0 );
// keep it accessible so we can stop() it
let active_node;
// let's begin
periodicFetch();
// UI update
btn.textContent = "stop";
btn.onclick = e => {
stopped = true;
if( active_node ) { active_node.stop(0); }
};
oninput = handleUIEvents;
// our fake fetcher, calls itself every 50ms
function periodicFetch() {
// data from server (here just some noise)
const noise = Float32Array.from( { length: chunk_size / 4 }, _ => (Math.random() * 1) - 0.5 );
// we concatenate the data just fetched with what we have already buffered
fetched_data = concatFloat32Arrays( fetched_data, noise );
// for demo only
log.textContent = "buffering: " + fetched_data.length + '/ ' + min_sample_size;
if( !stopped ) {
// do it again
setTimeout( periodicFetch , fetching_interval );
}
// if we are not actively reading and have fetched enough
if( !is_reading && fetched_data.length > min_sample_size ) {
readingLoop(); // start reading
}
}
function readingLoop() {
if( stopped || fetched_data.length < min_sample_size ) {
is_reading = false;
return;
}
// let the world know we are actively reading
is_reading = true;
// create a new AudioBuffer
const aud_buf = ctx.createBuffer( 1, fetched_data.length, sample_rate );
// copy our fetched data to its first channel
aud_buf.copyToChannel( fetched_data, 0 );
// clear the buffered data
fetched_data = new Float32Array( 0 );
// the actual player
active_node = ctx.createBufferSource();
active_node.buffer = aud_buf;
active_node.onended = readingLoop; // in case we buffered enough while playing
active_node.connect( gain );
active_node.start( 0 );
}
function handleUIEvents( evt ) {
const type = evt.target.name;
const value = evt.target.value;
switch( type ) {
case "chunk-size":
chunk_size = +value;
break;
case "volume":
gain.gain.value = +value;
break;
}
}
};
// helpers
function concatFloat32Arrays( arr1, arr2 ) {
if( !arr1 || !arr1.length ) {
return arr2 && arr2.slice();
}
if( !arr2 || !arr2.length ) {
return arr1 && arr1.slice();
}
const out = new Float32Array( arr1.length + arr2.length );
out.set( arr1 );
out.set( arr2, arr1.length );
return out;
}
label { display: block }
<button id="btn">start</button>
<pre id="log"></pre>
<div>
<label>Output volume:<input type="range" name="volume" min="0" max="0.5" step="0.01" value="0.01"></label>
</div>
<div>
Size of each chunk fetched:
<label><input type="radio" name="chunk-size" value="2048" checked>2048 bytes (OP's current)</label>
<label><input type="radio" name="chunk-size" value="35280">35280 bytes (barely enough for 0.1s interval)</label>
<label><input type="radio" name="chunk-size" value="44100">44100 bytes (enough for 0.1s interval)</label>
</div>
这里有个问题:
- 我的 JS 应用程序接收原始 PCM 数据(通过 WebRTC 数据通道),
- 采样率为88200(我可以在另一端轻松将其更改为44100),
- 数据已正确编码为 4 字节浮点 [-1, 1] 小端样本,
- 数据以 512 个样本(512*4 字节)的块形式到达,
- 数据可以随时开始到达,它可以持续任何时间,它可能停止,它可能恢复。
- 目标是渲染声音。
我做的是:
var samples = []; // each element of this array stores a chunk of 512 samples
var audioCtx = new AudioContext();
var source = audioCtx.createBufferSource();
source.buffer = audioCtx.createBuffer(1, 512, 88200);
// bufferSize is 512 because it is the size of chunks
var scriptNode = audioCtx.createScriptProcessor(512, 1, 1);
scriptNode.onaudioprocess = function(audioProcessingEvent) {
// play a chunk if there is at least one.
if (samples.length > 0) {
audioProcessingEvent.outputBuffer.copyToChannel(samples.shift(), 0, 0);
}
};
source.connect(scriptNode);
scriptNode.connect(audioCtx.destination);
source.start();
peerConnection.addEventListener("datachannel", function(e) {
e.channel.onmessage = function(m) {
var values = new Float32Array(m.data);
samples.push(values);
};
);
有几个问题:
audioProcessingEvent.outputBuffer.sampleRate
总是48000
。显然它不依赖于source
的比特率,我找不到将其设置为88200
、44100
或任何其他值的方法。声音以不断增长的延迟呈现。ScriptProcessorNode
已弃用。- 就处理器而言,这是一种非常昂贵的方法。
提前感谢您的任何建议!
你想要一个 AudioBuffer.
您可以直接从 TypedArray 将原始 PCM 数据复制到其通道中。
您可以指定其 sampleRate
,AudioContext 将负责重新采样以匹配声卡的设置。
但是要注意,每个块 2048 字节意味着每个块只代表 5ms 的音频数据 @88Khz:我们传递一个 Float32Array,所以字节大小是 4 和 2048 / 4 / 88200 = ±0.0058s.
您可能想要增加它,并实施一些缓冲策略。
这是一个小演示,作为将块数据存储到缓冲区 Float32Array 中的概念证明。
const min_sample_duration = 2; // sec
const sample_rate = 88200; // Hz
// how much data is needed to play for at least min_sample_duration
const min_sample_size = min_sample_duration * sample_rate;
const fetching_interval = 100; // ms
// you'll probably want this much bigger
let chunk_size = 2048; // bytes
const log = document.getElementById( 'log' );
const btn = document.getElementById( 'btn' );
btn.onclick = e => {
let stopped = false;
let is_reading = false;
const ctx = new AudioContext();
// to control output volume
const gain = ctx.createGain();
gain.gain.value = 0.01;
gain.connect( ctx.destination );
// this will get updated at every new fetch
let fetched_data = new Float32Array( 0 );
// keep it accessible so we can stop() it
let active_node;
// let's begin
periodicFetch();
// UI update
btn.textContent = "stop";
btn.onclick = e => {
stopped = true;
if( active_node ) { active_node.stop(0); }
};
oninput = handleUIEvents;
// our fake fetcher, calls itself every 50ms
function periodicFetch() {
// data from server (here just some noise)
const noise = Float32Array.from( { length: chunk_size / 4 }, _ => (Math.random() * 1) - 0.5 );
// we concatenate the data just fetched with what we have already buffered
fetched_data = concatFloat32Arrays( fetched_data, noise );
// for demo only
log.textContent = "buffering: " + fetched_data.length + '/ ' + min_sample_size;
if( !stopped ) {
// do it again
setTimeout( periodicFetch , fetching_interval );
}
// if we are not actively reading and have fetched enough
if( !is_reading && fetched_data.length > min_sample_size ) {
readingLoop(); // start reading
}
}
function readingLoop() {
if( stopped || fetched_data.length < min_sample_size ) {
is_reading = false;
return;
}
// let the world know we are actively reading
is_reading = true;
// create a new AudioBuffer
const aud_buf = ctx.createBuffer( 1, fetched_data.length, sample_rate );
// copy our fetched data to its first channel
aud_buf.copyToChannel( fetched_data, 0 );
// clear the buffered data
fetched_data = new Float32Array( 0 );
// the actual player
active_node = ctx.createBufferSource();
active_node.buffer = aud_buf;
active_node.onended = readingLoop; // in case we buffered enough while playing
active_node.connect( gain );
active_node.start( 0 );
}
function handleUIEvents( evt ) {
const type = evt.target.name;
const value = evt.target.value;
switch( type ) {
case "chunk-size":
chunk_size = +value;
break;
case "volume":
gain.gain.value = +value;
break;
}
}
};
// helpers
function concatFloat32Arrays( arr1, arr2 ) {
if( !arr1 || !arr1.length ) {
return arr2 && arr2.slice();
}
if( !arr2 || !arr2.length ) {
return arr1 && arr1.slice();
}
const out = new Float32Array( arr1.length + arr2.length );
out.set( arr1 );
out.set( arr2, arr1.length );
return out;
}
label { display: block }
<button id="btn">start</button>
<pre id="log"></pre>
<div>
<label>Output volume:<input type="range" name="volume" min="0" max="0.5" step="0.01" value="0.01"></label>
</div>
<div>
Size of each chunk fetched:
<label><input type="radio" name="chunk-size" value="2048" checked>2048 bytes (OP's current)</label>
<label><input type="radio" name="chunk-size" value="35280">35280 bytes (barely enough for 0.1s interval)</label>
<label><input type="radio" name="chunk-size" value="44100">44100 bytes (enough for 0.1s interval)</label>
</div>