| // META: global=window | |
| // META: script=/webcodecs/utils.js | |
| // Merge all audio buffers into a new big one with all the data. | |
| function join_audio_data(audio_data_array) { | |
| assert_greater_than_equal(audio_data_array.length, 0); | |
| let total_frames = 0; | |
| let base_buffer = audio_data_array[0]; | |
| for (const data of audio_data_array) { | |
| assert_not_equals(data, null); | |
| assert_equals(data.sampleRate, base_buffer.sampleRate); | |
| assert_equals(data.numberOfChannels, base_buffer.numberOfChannels); | |
| assert_equals(data.format, base_buffer.format); | |
| total_frames += data.numberOfFrames; | |
| } | |
| assert_true(base_buffer.format == 'f32' || base_buffer.format == 'f32-planar'); | |
| if (base_buffer.format == 'f32') | |
| return join_interleaved_data(audio_data_array, total_frames); | |
| // The format is 'FLTP'. | |
| return join_planar_data(audio_data_array, total_frames); | |
| } | |
| function join_interleaved_data(audio_data_array, total_frames) { | |
| let base_data = audio_data_array[0]; | |
| let channels = base_data.numberOfChannels; | |
| let total_samples = total_frames * channels; | |
| let result = new Float32Array(total_samples); | |
| let copy_dest = new Float32Array(base_data.numberOfFrames * channels); | |
| // Copy all the interleaved data. | |
| let position = 0; | |
| for (const data of audio_data_array) { | |
| let samples = data.numberOfFrames * channels; | |
| if (copy_dest.length < samples) | |
| copy_dest = new Float32Array(samples); | |
| data.copyTo(copy_dest, {planeIndex: 0}); | |
| result.set(copy_dest, position); | |
| position += samples; | |
| } | |
| assert_equals(position, total_samples); | |
| return result; | |
| } | |
| function join_planar_data(audio_data_array, total_frames) { | |
| let base_frames = audio_data_array[0].numberOfFrames; | |
| let channels = audio_data_array[0].numberOfChannels; | |
| let result = new Float32Array(total_frames*channels); | |
| let copyDest = new Float32Array(base_frames); | |
| // Merge all samples and lay them out according to the FLTP memory layout. | |
| let position = 0; | |
| for (let ch = 0; ch < channels; ch++) { | |
| for (const data of audio_data_array) { | |
| data.copyTo(copyDest, { planeIndex: ch}); | |
| result.set(copyDest, position); | |
| position += data.numberOfFrames; | |
| } | |
| } | |
| assert_equals(position, total_frames * channels); | |
| return result; | |
| } | |
| promise_test(async t => { | |
| let sample_rate = 48000; | |
| let total_duration_s = 1; | |
| let data_count = 10; | |
| let outputs = []; | |
| let init = { | |
| error: e => { | |
| assert_unreached("error: " + e); | |
| }, | |
| output: chunk => { | |
| outputs.push(chunk); | |
| } | |
| }; | |
| let encoder = new AudioEncoder(init); | |
| assert_equals(encoder.state, "unconfigured"); | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: 2, | |
| bitrate: 256000 //256kbit | |
| }; | |
| encoder.configure(config); | |
| let timestamp_us = 0; | |
| let data_duration_s = total_duration_s / data_count; | |
| let data_length = data_duration_s * config.sampleRate; | |
| for (let i = 0; i < data_count; i++) { | |
| let data = make_audio_data(timestamp_us, config.numberOfChannels, | |
| config.sampleRate, data_length); | |
| encoder.encode(data); | |
| data.close(); | |
| timestamp_us += data_duration_s * 1_000_000; | |
| } | |
| await encoder.flush(); | |
| encoder.close(); | |
| assert_greater_than_equal(outputs.length, data_count); | |
| assert_equals(outputs[0].timestamp, 0, "first chunk timestamp"); | |
| let total_encoded_duration = 0 | |
| for (chunk of outputs) { | |
| assert_greater_than(chunk.byteLength, 0); | |
| assert_greater_than_equal(timestamp_us, chunk.timestamp); | |
| assert_greater_than(chunk.duration, 0); | |
| total_encoded_duration += chunk.duration; | |
| } | |
| // The total duration might be padded with silence. | |
| assert_greater_than_equal( | |
| total_encoded_duration, total_duration_s * 1_000_000); | |
| }, 'Simple audio encoding'); | |
| promise_test(async t => { | |
| let outputs = 0; | |
| let init = getDefaultCodecInit(t); | |
| let firstOutput = new Promise(resolve => { | |
| init.output = (chunk, metadata) => { | |
| outputs++; | |
| assert_equals(outputs, 1, 'outputs'); | |
| encoder.reset(); | |
| resolve(); | |
| }; | |
| }); | |
| let encoder = new AudioEncoder(init); | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: 48000, | |
| numberOfChannels: 2, | |
| bitrate: 256000 // 256kbit | |
| }; | |
| encoder.configure(config); | |
| let frame_count = 1024; | |
| let frame1 = make_audio_data( | |
| 0, config.numberOfChannels, config.sampleRate, frame_count); | |
| let frame2 = make_audio_data( | |
| frame_count / config.sampleRate, config.numberOfChannels, | |
| config.sampleRate, frame_count); | |
| t.add_cleanup(() => { | |
| frame1.close(); | |
| frame2.close(); | |
| }); | |
| encoder.encode(frame1); | |
| encoder.encode(frame2); | |
| const flushDone = encoder.flush(); | |
| // Wait for the first output, then reset. | |
| await firstOutput; | |
| // Flush should have been synchronously rejected. | |
| await promise_rejects_dom(t, 'AbortError', flushDone); | |
| assert_equals(outputs, 1, 'outputs'); | |
| }, 'Test reset during flush'); | |
| promise_test(async t => { | |
| let sample_rate = 48000; | |
| let total_duration_s = 1; | |
| let data_count = 10; | |
| let outputs = []; | |
| let init = { | |
| error: e => { | |
| assert_unreached('error: ' + e); | |
| }, | |
| output: chunk => { | |
| outputs.push(chunk); | |
| } | |
| }; | |
| let encoder = new AudioEncoder(init); | |
| assert_equals(encoder.state, 'unconfigured'); | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: 2, | |
| bitrate: 256000 // 256kbit | |
| }; | |
| encoder.configure(config); | |
| let timestamp_us = -10000; | |
| let data = make_audio_data( | |
| timestamp_us, config.numberOfChannels, config.sampleRate, 10000); | |
| encoder.encode(data); | |
| data.close(); | |
| await encoder.flush(); | |
| encoder.close(); | |
| assert_greater_than_equal(outputs.length, 1); | |
| assert_equals(outputs[0].timestamp, -10000, 'first chunk timestamp'); | |
| for (chunk of outputs) { | |
| assert_greater_than(chunk.byteLength, 0); | |
| assert_greater_than_equal(chunk.timestamp, timestamp_us); | |
| } | |
| }, 'Encode audio with negative timestamp'); | |
| async function checkEncodingError(t, config, good_data, bad_data) { | |
| let support = await AudioEncoder.isConfigSupported(config); | |
| assert_true(support.supported) | |
| config = support.config; | |
| const callbacks = {}; | |
| let errors = 0; | |
| let gotError = new Promise(resolve => callbacks.error = e => { | |
| errors++; | |
| resolve(e); | |
| }); | |
| let outputs = 0; | |
| callbacks.output = chunk => { | |
| outputs++; | |
| }; | |
| let encoder = new AudioEncoder(callbacks); | |
| encoder.configure(config); | |
| for (let data of good_data) { | |
| encoder.encode(data); | |
| data.close(); | |
| } | |
| await encoder.flush(); | |
| let txt_config = "sampleRate: " + config.sampleRate | |
| + " numberOfChannels: " + config.numberOfChannels; | |
| assert_equals(errors, 0, txt_config); | |
| assert_greater_than(outputs, 0); | |
| outputs = 0; | |
| encoder.encode(bad_data); | |
| await promise_rejects_dom(t, 'EncodingError', encoder.flush().catch((e) => { | |
| assert_equals(errors, 1); | |
| throw e; | |
| })); | |
| assert_equals(outputs, 0); | |
| let e = await gotError; | |
| assert_true(e instanceof DOMException); | |
| assert_equals(e.name, 'EncodingError'); | |
| assert_equals(encoder.state, 'closed', 'state'); | |
| } | |
| function channelNumberVariationTests() { | |
| let sample_rate = 48000; | |
| for (let channels = 1; channels <= 2; channels++) { | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: channels, | |
| bitrate: 128000 | |
| }; | |
| let ts = 0; | |
| let length = sample_rate / 10; | |
| let data1 = make_audio_data(ts, channels, sample_rate, length); | |
| ts += Math.floor(data1.duration / 1000000); | |
| let data2 = make_audio_data(ts, channels, sample_rate, length); | |
| ts += Math.floor(data2.duration / 1000000); | |
| let bad_data = make_audio_data(ts, channels + 1, sample_rate, length); | |
| promise_test( | |
| async t => checkEncodingError(t, config, [data1, data2], bad_data), | |
| 'Channel number variation: ' + channels); | |
| } | |
| } | |
| channelNumberVariationTests(); | |
| function sampleRateVariationTests() { | |
| let channels = 1 | |
| for (let sample_rate = 3000; sample_rate < 96000; sample_rate += 10000) { | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: channels, | |
| bitrate: 128000 | |
| }; | |
| let ts = 0; | |
| let length = sample_rate / 10; | |
| let data1 = make_audio_data(ts, channels, sample_rate, length); | |
| ts += Math.floor(data1.duration / 1000000); | |
| let data2 = make_audio_data(ts, channels, sample_rate, length); | |
| ts += Math.floor(data2.duration / 1000000); | |
| let bad_data = make_audio_data(ts, channels, sample_rate + 333, length); | |
| promise_test( | |
| async t => checkEncodingError(t, config, [data1, data2], bad_data), | |
| 'Sample rate variation: ' + sample_rate); | |
| } | |
| } | |
| sampleRateVariationTests(); | |
| promise_test(async t => { | |
| let sample_rate = 48000; | |
| let total_duration_s = 1; | |
| let data_count = 10; | |
| let input_data = []; | |
| let output_data = []; | |
| let decoder_init = { | |
| error: t.unreached_func("Decode error"), | |
| output: data => { | |
| output_data.push(data); | |
| } | |
| }; | |
| let decoder = new AudioDecoder(decoder_init); | |
| let encoder_init = { | |
| error: t.unreached_func("Encoder error"), | |
| output: (chunk, metadata) => { | |
| let config = metadata.decoderConfig; | |
| if (config) | |
| decoder.configure(config); | |
| decoder.decode(chunk); | |
| } | |
| }; | |
| let encoder = new AudioEncoder(encoder_init); | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: 2, | |
| bitrate: 256000, //256kbit | |
| }; | |
| encoder.configure(config); | |
| let timestamp_us = 0; | |
| const data_duration_s = total_duration_s / data_count; | |
| const data_length = data_duration_s * config.sampleRate; | |
| for (let i = 0; i < data_count; i++) { | |
| let data = make_audio_data(timestamp_us, config.numberOfChannels, | |
| config.sampleRate, data_length); | |
| input_data.push(data); | |
| encoder.encode(data); | |
| timestamp_us += data_duration_s * 1_000_000; | |
| } | |
| await encoder.flush(); | |
| encoder.close(); | |
| await decoder.flush(); | |
| decoder.close(); | |
| let total_input = join_audio_data(input_data); | |
| let frames_per_plane = total_input.length / config.numberOfChannels; | |
| let total_output = join_audio_data(output_data); | |
| let base_input = input_data[0]; | |
| let base_output = output_data[0]; | |
| // TODO: Convert formats to simplify conversions, once | |
| // https://github.com/w3c/webcodecs/issues/232 is resolved. | |
| assert_equals(base_input.format, "f32-planar"); | |
| assert_equals(base_output.format, "f32"); | |
| assert_equals(base_output.numberOfChannels, config.numberOfChannels); | |
| assert_equals(base_output.sampleRate, sample_rate); | |
| // Output can be slightly longer that the input due to padding | |
| assert_greater_than_equal(total_output.length, total_input.length); | |
| // Compare waveform before and after encoding | |
| for (let channel = 0; channel < base_input.numberOfChannels; channel++) { | |
| let plane_start = channel * frames_per_plane; | |
| let input_plane = total_input.slice( | |
| plane_start, plane_start + frames_per_plane); | |
| for (let i = 0; i < base_input.numberOfFrames; i += 10) { | |
| // Instead of de-interleaving the data, directly look into |total_output| | |
| // for the sample we are interested in. | |
| let ouput_index = i * base_input.numberOfChannels + channel; | |
| // Checking only every 10th sample to save test time in slow | |
| // configurations like MSAN etc. | |
| assert_approx_equals( | |
| input_plane[i], total_output[ouput_index], 0.5, | |
| 'Difference between input and output is too large.' + | |
| ' index: ' + i + ' channel: ' + channel + | |
| ' input: ' + input_plane[i] + | |
| ' output: ' + total_output[ouput_index]); | |
| } | |
| } | |
| }, 'Encoding and decoding'); | |
| promise_test(async t => { | |
| let output_count = 0; | |
| let encoder_config = { | |
| codec: 'opus', | |
| sampleRate: 24000, | |
| numberOfChannels: 1, | |
| bitrate: 96000 | |
| }; | |
| let decoder_config = null; | |
| let init = { | |
| error: t.unreached_func("Encoder error"), | |
| output: (chunk, metadata) => { | |
| let config = metadata.decoderConfig; | |
| // Only the first invocation of the output callback is supposed to have | |
| // a |config| in it. | |
| output_count++; | |
| if (output_count == 1) { | |
| assert_equals(typeof config, "object"); | |
| decoder_config = config; | |
| } else { | |
| assert_equals(config, undefined); | |
| } | |
| } | |
| }; | |
| let encoder = new AudioEncoder(init); | |
| encoder.configure(encoder_config); | |
| let large_data = make_audio_data(0, encoder_config.numberOfChannels, | |
| encoder_config.sampleRate, encoder_config.sampleRate); | |
| encoder.encode(large_data); | |
| await encoder.flush(); | |
| // Large data produced more than one output, and we've got decoder_config | |
| assert_greater_than(output_count, 1); | |
| assert_not_equals(decoder_config, null); | |
| assert_equals(decoder_config.codec, encoder_config.codec); | |
| assert_equals(decoder_config.sampleRate, encoder_config.sampleRate); | |
| assert_equals(decoder_config.numberOfChannels, encoder_config.numberOfChannels); | |
| // Check that description start with 'Opus' | |
| let extra_data = new Uint8Array(decoder_config.description); | |
| assert_equals(extra_data[0], 0x4f); | |
| assert_equals(extra_data[1], 0x70); | |
| assert_equals(extra_data[2], 0x75); | |
| assert_equals(extra_data[3], 0x73); | |
| decoder_config = null; | |
| output_count = 0; | |
| encoder_config.bitrate = 256000; | |
| encoder.configure(encoder_config); | |
| encoder.encode(large_data); | |
| await encoder.flush(); | |
| // After reconfiguring encoder should produce decoder config again | |
| assert_greater_than(output_count, 1); | |
| assert_not_equals(decoder_config, null); | |
| assert_not_equals(decoder_config.description, null); | |
| encoder.close(); | |
| }, "Emit decoder config and extra data."); | |
| promise_test(async t => { | |
| let sample_rate = 48000; | |
| let total_duration_s = 1; | |
| let data_count = 100; | |
| let init = getDefaultCodecInit(t); | |
| init.output = (chunk, metadata) => {} | |
| let encoder = new AudioEncoder(init); | |
| // No encodes yet. | |
| assert_equals(encoder.encodeQueueSize, 0); | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: 2, | |
| bitrate: 256000 //256kbit | |
| }; | |
| encoder.configure(config); | |
| // Still no encodes. | |
| assert_equals(encoder.encodeQueueSize, 0); | |
| let datas = []; | |
| let timestamp_us = 0; | |
| let data_duration_s = total_duration_s / data_count; | |
| let data_length = data_duration_s * config.sampleRate; | |
| for (let i = 0; i < data_count; i++) { | |
| let data = make_audio_data(timestamp_us, config.numberOfChannels, | |
| config.sampleRate, data_length); | |
| datas.push(data); | |
| timestamp_us += data_duration_s * 1_000_000; | |
| } | |
| let lastDequeueSize = Infinity; | |
| encoder.ondequeue = () => { | |
| assert_greater_than(lastDequeueSize, 0, "Dequeue event after queue empty"); | |
| assert_greater_than(lastDequeueSize, encoder.encodeQueueSize, | |
| "Dequeue event without decreased queue size"); | |
| lastDequeueSize = encoder.encodeQueueSize; | |
| }; | |
| for (let data of datas) | |
| encoder.encode(data); | |
| assert_greater_than_equal(encoder.encodeQueueSize, 0); | |
| assert_less_than_equal(encoder.encodeQueueSize, data_count); | |
| await encoder.flush(); | |
| // We can guarantee that all encodes are processed after a flush. | |
| assert_equals(encoder.encodeQueueSize, 0); | |
| // Last dequeue event should fire when the queue is empty. | |
| assert_equals(lastDequeueSize, 0); | |
| // Reset this to Infinity to track the decline of queue size for this next | |
| // batch of encodes. | |
| lastDequeueSize = Infinity; | |
| for (let data of datas) { | |
| encoder.encode(data); | |
| data.close(); | |
| } | |
| assert_greater_than_equal(encoder.encodeQueueSize, 0); | |
| encoder.reset(); | |
| assert_equals(encoder.encodeQueueSize, 0); | |
| }, 'encodeQueueSize test'); | |
| const testOpusEncoderConfigs = [ | |
| { | |
| comment: 'Empty Opus config', | |
| opus: {}, | |
| }, | |
| { | |
| comment: 'Opus with frameDuration', | |
| opus: {frameDuration: 2500}, | |
| }, | |
| { | |
| comment: 'Opus with complexity', | |
| opus: {complexity: 10}, | |
| }, | |
| { | |
| comment: 'Opus with useinbandfec', | |
| opus: { | |
| packetlossperc: 15, | |
| useinbandfec: true, | |
| }, | |
| }, | |
| { | |
| comment: 'Opus with usedtx', | |
| opus: {usedtx: true}, | |
| }, | |
| { | |
| comment: 'Opus mixed parameters', | |
| opus: { | |
| frameDuration: 40000, | |
| complexity: 0, | |
| packetlossperc: 10, | |
| useinbandfec: true, | |
| usedtx: true, | |
| }, | |
| } | |
| ]; | |
| testOpusEncoderConfigs.forEach(entry => { | |
| promise_test(async t => { | |
| let sample_rate = 48000; | |
| let total_duration_s = 0.5; | |
| let data_count = 10; | |
| let outputs = []; | |
| let init = { | |
| error: e => { | |
| assert_unreached('error: ' + e); | |
| }, | |
| output: chunk => { | |
| outputs.push(chunk); | |
| } | |
| }; | |
| let encoder = new AudioEncoder(init); | |
| assert_equals(encoder.state, 'unconfigured'); | |
| let config = { | |
| codec: 'opus', | |
| sampleRate: sample_rate, | |
| numberOfChannels: 2, | |
| bitrate: 256000, // 256kbit | |
| opus: entry.opus, | |
| }; | |
| encoder.configure(config); | |
| let timestamp_us = 0; | |
| let data_duration_s = total_duration_s / data_count; | |
| let data_length = data_duration_s * config.sampleRate; | |
| for (let i = 0; i < data_count; i++) { | |
| let data = make_audio_data( | |
| timestamp_us, config.numberOfChannels, config.sampleRate, | |
| data_length); | |
| encoder.encode(data); | |
| data.close(); | |
| timestamp_us += data_duration_s * 1_000_000; | |
| } | |
| // Encoders might output an extra buffer of silent padding. | |
| let padding_us = data_duration_s * 1_000_000; | |
| await encoder.flush(); | |
| encoder.close(); | |
| assert_greater_than_equal(outputs.length, data_count); | |
| assert_equals(outputs[0].timestamp, 0, 'first chunk timestamp'); | |
| let total_encoded_duration = 0 | |
| for (chunk of outputs) { | |
| assert_greater_than(chunk.byteLength, 0, 'chunk byteLength'); | |
| assert_greater_than_equal( | |
| timestamp_us + padding_us, chunk.timestamp, 'chunk timestamp'); | |
| assert_greater_than(chunk.duration, 0, 'chunk duration'); | |
| total_encoded_duration += chunk.duration; | |
| } | |
| // The total duration might be padded with silence. | |
| assert_greater_than_equal( | |
| total_encoded_duration, total_duration_s * 1_000_000); | |
| }, 'Test encoding Opus with additional parameters: ' + entry.comment); | |
| }); |