| // META: global=window,dedicatedworker |
| // META: script=/webcodecs/utils.js |
| // META: variant=?adts_aac |
| // META: variant=?mp4_aac |
| // META: variant=?mp3 |
| // META: variant=?opus |
| // META: variant=?pcm_alaw |
| // META: variant=?pcm_ulaw |
| // META: variant=?pcm_u8 |
| // META: variant=?pcm_s16 |
| // META: variant=?pcm_s24 |
| // META: variant=?pcm_s32 |
| // META: variant=?pcm_f32 |
| // META: variant=?flac |
| // META: variant=?vorbis |
| |
| const ADTS_AAC_DATA = { |
| src: 'sfx.adts', |
| config: { |
| codec: 'mp4a.40.2', |
| sampleRate: 48000, |
| numberOfChannels: 1, |
| }, |
| chunks: [ |
| {offset: 0, size: 248}, {offset: 248, size: 280}, {offset: 528, size: 258}, |
| {offset: 786, size: 125}, {offset: 911, size: 230}, |
| {offset: 1141, size: 148}, {offset: 1289, size: 224}, |
| {offset: 1513, size: 166}, {offset: 1679, size: 216}, |
| {offset: 1895, size: 183} |
| ], |
| duration: 24000 |
| }; |
| |
| const MP3_DATA = { |
| src: 'sfx.mp3', |
| config: { |
| codec: 'mp3', |
| sampleRate: 48000, |
| numberOfChannels: 1, |
| }, |
| chunks: [ |
| {offset: 333, size: 288}, {offset: 621, size: 288}, |
| {offset: 909, size: 288}, {offset: 1197, size: 288}, |
| {offset: 1485, size: 288}, {offset: 1773, size: 288}, |
| {offset: 2061, size: 288}, {offset: 2349, size: 288}, |
| {offset: 2637, size: 288}, {offset: 2925, size: 288} |
| ], |
| duration: 24000 |
| }; |
| |
| const MP4_AAC_DATA = { |
| src: 'sfx-aac.mp4', |
| config: { |
| codec: 'mp4a.40.2', |
| sampleRate: 48000, |
| numberOfChannels: 1, |
| description: {offset: 2552, size: 5}, |
| }, |
| chunks: [ |
| {offset: 44, size: 241}, |
| {offset: 285, size: 273}, |
| {offset: 558, size: 251}, |
| {offset: 809, size: 118}, |
| {offset: 927, size: 223}, |
| {offset: 1150, size: 141}, |
| {offset: 1291, size: 217}, |
| {offset: 1508, size: 159}, |
| {offset: 1667, size: 209}, |
| {offset: 1876, size: 176}, |
| ], |
| duration: 21333 |
| }; |
| |
| const OPUS_DATA = { |
| src: 'sfx-opus.ogg', |
| config: { |
| codec: 'opus', |
| sampleRate: 48000, |
| numberOfChannels: 1, |
| description: {offset: 28, size: 19}, |
| }, |
| chunks: [ |
| {offset: 185, size: 450}, {offset: 635, size: 268}, |
| {offset: 903, size: 285}, {offset: 1188, size: 296}, |
| {offset: 1484, size: 287}, {offset: 1771, size: 308}, |
| {offset: 2079, size: 289}, {offset: 2368, size: 286}, |
| {offset: 2654, size: 296}, {offset: 2950, size: 294} |
| ], |
| duration: 20000 |
| }; |
| |
| const FLAC_DATA = { |
| src: 'sfx.flac', |
| config: { |
| codec: 'flac', |
| sampleRate: 48000, |
| numberOfChannels: 1, |
| description: { offset: 0, size: 8287 } |
| }, |
| chunks: [ |
| { offset: 8288, size: 2276 }, |
| { offset: 10564, size: 2038 }, |
| { offset: 12602, size: 521 }, |
| ], |
| duration: 20000 |
| }; |
| |
| function pcm(codec, dataOffset) { |
| return { |
| src: `sfx-${codec}.wav`, |
| config: { |
| codec: codec, |
| sampleRate: 48000, |
| numberOfChannels: 1, |
| }, |
| |
| // Chunk are arbitrary and will be generated lazily |
| chunks: [], |
| offset: dataOffset, |
| duration: 0 |
| } |
| } |
| |
| const PCM_ULAW_DATA = pcm("ulaw", 0x5c); |
| const PCM_ALAW_DATA = pcm("alaw", 0x5c); |
| const PCM_U8_DATA = pcm("pcm-u8", 0x4e); |
| const PCM_S16_DATA = pcm("pcm-s16", 0x4e); |
| const PCM_S24_DATA = pcm("pcm-s24", 0x66); |
| const PCM_S32_DATA = pcm("pcm-s32", 0x66); |
| const PCM_F32_DATA = pcm("pcm-f32", 0x72); |
| |
| const VORBIS_DATA = { |
| src: 'sfx-vorbis.ogg', |
| config: { |
| codec: 'vorbis', |
| description: [ |
| 2, |
| 30, |
| 62, |
| {offset: 28, size: 30}, |
| {offset: 101, size: 62}, |
| {offset: 163, size: 3771} |
| ], |
| numberOfChannels: 1, |
| sampleRate: 48000, |
| }, |
| chunks: [ |
| {offset: 3968, size: 44}, {offset: 4012, size: 21}, |
| {offset: 4033, size: 57}, {offset: 4090, size: 37}, |
| {offset: 4127, size: 37}, {offset: 4164, size: 107}, |
| {offset: 4271, size: 172} |
| ], |
| duration: 21333 |
| }; |
| |
| // Allows mutating `callbacks` after constructing the AudioDecoder, wraps calls |
| // in t.step(). |
| function createAudioDecoder(t, callbacks) { |
| return new AudioDecoder({ |
| output(frame) { |
| if (callbacks && callbacks.output) { |
| t.step(() => callbacks.output(frame)); |
| } else { |
| t.unreached_func('unexpected output()'); |
| } |
| }, |
| error(e) { |
| if (callbacks && callbacks.error) { |
| t.step(() => callbacks.error(e)); |
| } else { |
| t.unreached_func('unexpected error()'); |
| } |
| } |
| }); |
| } |
| |
| // Create a view of an ArrayBuffer. |
| function view(buffer, {offset, size}) { |
| return new Uint8Array(buffer, offset, size); |
| } |
| |
| let CONFIG = null; |
| let CHUNK_DATA = null; |
| let CHUNKS = null; |
| promise_setup(async () => { |
| const data = { |
| '?adts_aac': ADTS_AAC_DATA, |
| '?mp3': MP3_DATA, |
| '?mp4_aac': MP4_AAC_DATA, |
| '?opus': OPUS_DATA, |
| '?pcm_alaw': PCM_ALAW_DATA, |
| '?pcm_ulaw': PCM_ULAW_DATA, |
| '?pcm_u8': PCM_U8_DATA, |
| '?pcm_s16': PCM_S16_DATA, |
| '?pcm_s24': PCM_S24_DATA, |
| '?pcm_s32': PCM_S32_DATA, |
| '?pcm_f32': PCM_F32_DATA, |
| '?flac': FLAC_DATA, |
| '?vorbis': VORBIS_DATA, |
| }[location.search]; |
| |
| // Don't run any tests if the codec is not supported. |
| assert_equals("function", typeof AudioDecoder.isConfigSupported); |
| let supported = false; |
| try { |
| const support = await AudioDecoder.isConfigSupported({ |
| codec: data.config.codec, |
| sampleRate: data.config.sampleRate, |
| numberOfChannels: data.config.numberOfChannels |
| }); |
| supported = support.supported; |
| } catch (e) { |
| } |
| assert_implements_optional(supported, data.config.codec + ' unsupported'); |
| |
| // Fetch the media data and prepare buffers. |
| const response = await fetch(data.src); |
| const buf = await response.arrayBuffer(); |
| |
| CONFIG = {...data.config}; |
| if (data.config.description) { |
| // The description for decoding vorbis is expected to be in Xiph extradata format. |
| // https://w3c.github.io/webcodecs/vorbis_codec_registration.html#audiodecoderconfig-description |
| if (Array.isArray(data.config.description)) { |
| const length = data.config.description.reduce((sum, value) => sum + ((typeof value === 'number') ? 1 : value.size), 0); |
| const description = new Uint8Array(length); |
| |
| data.config.description.reduce((offset, value) => { |
| if (typeof value === 'number') { |
| description[offset] = value; |
| |
| return offset + 1; |
| } |
| |
| description.set(view(buf, value), offset); |
| |
| return offset + value.size; |
| }, 0); |
| |
| CONFIG.description = description; |
| } else { |
| CONFIG.description = view(buf, data.config.description); |
| } |
| } |
| |
| CHUNK_DATA = []; |
| // For PCM, split in chunks of 1200 bytes and compute the rest |
| if (data.chunks.length == 0) { |
| let offset = data.offset; |
| // 1200 is divisible by 2 and 3 and is a plausible packet length |
| // for PCM: this means that there won't be samples split in two packet |
| let PACKET_LENGTH = 1200; |
| let bytesPerSample = 0; |
| switch (data.config.codec) { |
| case "pcm-s16": bytesPerSample = 2; break; |
| case "pcm-s24": bytesPerSample = 3; break; |
| case "pcm-s32": bytesPerSample = 4; break; |
| case "pcm-f32": bytesPerSample = 4; break; |
| default: bytesPerSample = 1; break; |
| } |
| while (offset < buf.byteLength) { |
| let size = Math.min(buf.byteLength - offset, PACKET_LENGTH); |
| assert_equals(size % bytesPerSample, 0); |
| CHUNK_DATA.push(view(buf, {offset, size})); |
| offset += size; |
| } |
| data.duration = 1000 * 1000 * PACKET_LENGTH / data.config.sampleRate / bytesPerSample; |
| } else { |
| CHUNK_DATA = data.chunks.map((chunk, i) => view(buf, chunk)); |
| } |
| |
| CHUNKS = CHUNK_DATA.map((encodedData, i) => new EncodedAudioChunk({ |
| type: 'key', |
| timestamp: i * data.duration, |
| duration: data.duration, |
| data: encodedData |
| })); |
| }); |
| |
| promise_test(t => { |
| return AudioDecoder.isConfigSupported(CONFIG); |
| }, 'Test isConfigSupported()'); |
| |
| promise_test(t => { |
| // Define a valid config that includes a hypothetical 'futureConfigFeature', |
| // which is not yet recognized by the User Agent. |
| const validConfig = { |
| ...CONFIG, |
| futureConfigFeature: 'foo', |
| }; |
| |
| // The UA will evaluate validConfig as being "valid", ignoring the |
| // `futureConfigFeature` it doesn't recognize. |
| return AudioDecoder.isConfigSupported(validConfig).then((decoderSupport) => { |
| // AudioDecoderSupport must contain the following properites. |
| assert_true(decoderSupport.hasOwnProperty('supported')); |
| assert_true(decoderSupport.hasOwnProperty('config')); |
| |
| // AudioDecoderSupport.config must not contain unrecognized properties. |
| assert_false(decoderSupport.config.hasOwnProperty('futureConfigFeature')); |
| |
| // AudioDecoderSupport.config must contiain the recognized properties. |
| assert_equals(decoderSupport.config.codec, validConfig.codec); |
| assert_equals(decoderSupport.config.sampleRate, validConfig.sampleRate); |
| assert_equals( |
| decoderSupport.config.numberOfChannels, validConfig.numberOfChannels); |
| |
| if (validConfig.description) { |
| // The description must be copied. |
| assert_false( |
| decoderSupport.config.description === validConfig.description, |
| 'description is unique'); |
| assert_array_equals( |
| new Uint8Array(decoderSupport.config.description, 0), |
| new Uint8Array(validConfig.description, 0), 'description'); |
| } else { |
| assert_false( |
| decoderSupport.config.hasOwnProperty('description'), 'description'); |
| } |
| }); |
| }, 'Test that AudioDecoder.isConfigSupported() returns a parsed configuration'); |
| |
| promise_test(async t => { |
| const decoder = createAudioDecoder(t); |
| decoder.configure(CONFIG); |
| assert_equals(decoder.state, 'configured', 'state'); |
| }, 'Test configure()'); |
| |
| promise_test(t => { |
| const decoder = createAudioDecoder(t); |
| return testClosedCodec(t, decoder, CONFIG, CHUNKS[0]); |
| }, 'Verify closed AudioDecoder operations'); |
| |
| promise_test(async t => { |
| const callbacks = {}; |
| const decoder = createAudioDecoder(t, callbacks); |
| |
| let outputs = 0; |
| callbacks.output = frame => { |
| outputs++; |
| frame.close(); |
| }; |
| |
| decoder.configure(CONFIG); |
| CHUNKS.forEach(chunk => { |
| decoder.decode(chunk); |
| }); |
| |
| await decoder.flush(); |
| assert_equals(outputs, CONFIG.codec === 'vorbis' ? CHUNKS.length - 1 : CHUNKS.length, 'outputs'); |
| }, 'Test decoding'); |
| |
| promise_test(async t => { |
| const callbacks = {}; |
| const decoder = createAudioDecoder(t, callbacks); |
| |
| let outputs = 0; |
| callbacks.output = frame => { |
| if (outputs === 0) { |
| assert_equals(frame.timestamp, -42); |
| } |
| outputs++; |
| frame.close(); |
| }; |
| |
| decoder.configure(CONFIG); |
| decoder.decode(new EncodedAudioChunk( |
| {type: 'key', timestamp: -42, data: CHUNK_DATA[0]})); |
| decoder.decode(new EncodedAudioChunk( |
| {type: 'key', timestamp: CHUNKS[0].duration - 42, data: CHUNK_DATA[1]})); |
| |
| await decoder.flush(); |
| assert_equals(outputs, CONFIG.codec === 'vorbis' ? 1 : 2, 'outputs'); |
| }, 'Test decoding a with a negative timestamp'); |
| |
| promise_test(async t => { |
| const callbacks = {}; |
| const decoder = createAudioDecoder(t, callbacks); |
| |
| let outputs = 0; |
| callbacks.output = frame => { |
| if (outputs === 0) { |
| assert_equals(frame.timestamp, 42); |
| } |
| outputs++; |
| frame.close(); |
| }; |
| |
| decoder.configure(CONFIG); |
| decoder.decode(new EncodedAudioChunk( |
| {type: 'key', timestamp: 42, data: CHUNK_DATA[0]})); |
| decoder.decode(new EncodedAudioChunk( |
| {type: 'key', timestamp: CHUNKS[0].duration + 42, data: CHUNK_DATA[1]})); |
| |
| await decoder.flush(); |
| assert_equals(outputs, CONFIG.codec === 'vorbis' ? 1 : 2, 'outputs'); |
| }, 'Test decoding a with a positive timestamp'); |
| |
| promise_test(async t => { |
| const callbacks = {}; |
| const decoder = createAudioDecoder(t, callbacks); |
| |
| let outputs = 0; |
| callbacks.output = frame => { |
| outputs++; |
| frame.close(); |
| }; |
| |
| decoder.configure(CONFIG); |
| decoder.decode(CHUNKS[0]); |
| decoder.decode(CHUNKS[1]); |
| |
| await decoder.flush(); |
| assert_equals(outputs, CONFIG.codec === 'vorbis' ? 1 : 2, 'outputs'); |
| |
| decoder.decode(CHUNKS[2]); |
| await decoder.flush(); |
| assert_equals(outputs, CONFIG.codec === 'vorbis' ? 2 : 3, 'outputs'); |
| }, 'Test decoding after flush'); |
| |
| promise_test(async t => { |
| const callbacks = {}; |
| const decoder = createAudioDecoder(t, callbacks); |
| |
| decoder.configure(CONFIG); |
| decoder.decode(CHUNKS[0]); |
| decoder.decode(CHUNKS[1]); |
| const flushDone = decoder.flush(); |
| |
| // Wait for the first output, then reset. |
| let outputs = 0; |
| await new Promise(resolve => { |
| callbacks.output = frame => { |
| outputs++; |
| assert_equals(outputs, 1, 'outputs'); |
| decoder.reset(); |
| frame.close(); |
| resolve(); |
| }; |
| }); |
| |
| // 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 => { |
| const callbacks = {}; |
| const decoder = createAudioDecoder(t, callbacks); |
| |
| // No decodes yet. |
| assert_equals(decoder.decodeQueueSize, 0); |
| |
| decoder.configure(CONFIG); |
| |
| // Still no decodes. |
| assert_equals(decoder.decodeQueueSize, 0); |
| |
| let lastDequeueSize = Infinity; |
| decoder.ondequeue = () => { |
| assert_greater_than(lastDequeueSize, 0, "Dequeue event after queue empty"); |
| assert_greater_than(lastDequeueSize, decoder.decodeQueueSize, |
| "Dequeue event without decreased queue size"); |
| lastDequeueSize = decoder.decodeQueueSize; |
| }; |
| |
| for (let chunk of CHUNKS) |
| decoder.decode(chunk); |
| |
| assert_greater_than_equal(decoder.decodeQueueSize, 0); |
| assert_less_than_equal(decoder.decodeQueueSize, CHUNKS.length); |
| |
| await decoder.flush(); |
| // We can guarantee that all decodes are processed after a flush. |
| assert_equals(decoder.decodeQueueSize, 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 decodes. |
| lastDequeueSize = Infinity; |
| |
| for (let chunk of CHUNKS) |
| decoder.decode(chunk); |
| |
| assert_greater_than_equal(decoder.decodeQueueSize, 0); |
| decoder.reset(); |
| assert_equals(decoder.decodeQueueSize, 0); |
| }, 'AudioDecoder decodeQueueSize test'); |