From 3e03d1552369185918140072ad99ffe358437669 Mon Sep 17 00:00:00 2001 From: Alvaro Velad Galvan Date: Sun, 25 Jun 2023 18:02:43 +0200 Subject: [PATCH] feat: Add EC3 transmuxer --- build/types/transmuxer | 2 + lib/transmuxer/ec3.js | 103 +++++++++++ lib/transmuxer/ec3_transmuxer.js | 208 ++++++++++++++++++++++ shaka-player.uncompiled.js | 2 + test/transmuxer/transmuxer_integration.js | 24 +++ 5 files changed, 339 insertions(+) create mode 100644 lib/transmuxer/ec3.js create mode 100644 lib/transmuxer/ec3_transmuxer.js diff --git a/build/types/transmuxer b/build/types/transmuxer index 83b14994fd8..2010dcb323a 100644 --- a/build/types/transmuxer +++ b/build/types/transmuxer @@ -4,6 +4,8 @@ +../../lib/transmuxer/ac3.js +../../lib/transmuxer/ac3_transmuxer.js +../../lib/transmuxer/adts.js ++../../lib/transmuxer/ec3.js ++../../lib/transmuxer/ec3_transmuxer.js +../../lib/transmuxer/mp3_transmuxer.js +../../lib/transmuxer/mpeg_audio.js +../../lib/transmuxer/muxjs_transmuxer.js diff --git a/lib/transmuxer/ec3.js b/lib/transmuxer/ec3.js new file mode 100644 index 00000000000..657ec01402a --- /dev/null +++ b/lib/transmuxer/ec3.js @@ -0,0 +1,103 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.Ec3'); + +goog.require('shaka.util.ExpGolomb'); + + +/** + * EC3 utils + */ +shaka.transmuxer.Ec3 = class { + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {?{sampleRate: number, channelCount: number, + * audioConfig: !Uint8Array, frameLength: number}} + */ + static parseFrame(data, offset) { + if (offset + 8 > data.length) { + // not enough bytes left + return null; + } + + if (!shaka.transmuxer.Ec3.probe(data, offset)) { + return null; + } + + const gb = new shaka.util.ExpGolomb(data.subarray(offset + 2)); + // Skip stream_type + gb.skipBits(2); + // Skip sub_stream_id + gb.skipBits(3); + const frameLength = (gb.readBits(11) + 1) << 1; + let samplingRateCode = gb.readBits(2); + let sampleRate = null; + let numBlocksCode = null; + if (samplingRateCode == 0x03) { + samplingRateCode = gb.readBits(2); + sampleRate = [24000, 22060, 16000][samplingRateCode]; + numBlocksCode = 3; + } else { + sampleRate = [48000, 44100, 32000][samplingRateCode]; + numBlocksCode = gb.readBits(2); + } + + const channelMode = gb.readBits(3); + const lowFrequencyEffectsChannelOn = gb.readBits(1); + const bitStreamIdentification = gb.readBits(5); + + if (offset + frameLength > data.byteLength) { + return null; + } + + const channelsMap = [2, 1, 2, 3, 3, 4, 4, 5]; + + const numBlocksMap = [1, 2, 3, 6]; + + const numBlocks = numBlocksMap[numBlocksCode]; + + const dataRateSub = + Math.floor((frameLength * sampleRate) / (numBlocks * 16)); + + const config = new Uint8Array([ + ((dataRateSub & 0x1FE0) >> 5), + ((dataRateSub & 0x001F) << 3), // num_ind_sub = zero + (sampleRate << 6) | (bitStreamIdentification << 1) | (0 << 0), + (0 << 7) | (0 << 4) | + (channelMode << 1) | (lowFrequencyEffectsChannelOn << 0), + (0 << 5) | (0 << 1) | (0 << 0), + ]); + + return { + sampleRate: sampleRate, + channelCount: channelsMap[channelMode] + lowFrequencyEffectsChannelOn, + audioConfig: config, + frameLength: frameLength, + }; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {boolean} + */ + static probe(data, offset) { + // search 16-bit 0x0B77 syncword + const syncword = (data[offset] << 8) | (data[offset + 1] << 0); + if (syncword === 0x0B77) { + return true; + } else { + return false; + } + } +}; + +/** + * @const {number} + */ +shaka.transmuxer.Ec3.EC3_SAMPLES_PER_FRAME = 1536; diff --git a/lib/transmuxer/ec3_transmuxer.js b/lib/transmuxer/ec3_transmuxer.js new file mode 100644 index 00000000000..6e90758ddc4 --- /dev/null +++ b/lib/transmuxer/ec3_transmuxer.js @@ -0,0 +1,208 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.Ec3Transmuxer'); + +goog.require('shaka.media.Capabilities'); +goog.require('shaka.transmuxer.Ec3'); +goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.Id3Utils'); +goog.require('shaka.util.ManifestParserUtils'); +goog.require('shaka.util.Mp4Generator'); +goog.require('shaka.util.Uint8ArrayUtils'); + + +/** + * @implements {shaka.extern.Transmuxer} + * @export + */ +shaka.transmuxer.Ec3Transmuxer = class { + /** + * @param {string} mimeType + */ + constructor(mimeType) { + /** @private {string} */ + this.originalMimeType_ = mimeType; + + /** @private {number} */ + this.frameIndex_ = 0; + + /** @private {!Map.} */ + this.initSegments = new Map(); + } + + + /** + * @override + * @export + */ + destroy() { + this.initSegments.clear(); + } + + + /** + * Check if the mime type and the content type is supported. + * @param {string} mimeType + * @param {string=} contentType + * @return {boolean} + * @override + * @export + */ + isSupported(mimeType, contentType) { + const Capabilities = shaka.media.Capabilities; + + if (!this.isEc3Container_(mimeType)) { + return false; + } + const ContentType = shaka.util.ManifestParserUtils.ContentType; + return Capabilities.isTypeSupported( + this.convertCodecs(ContentType.AUDIO, mimeType)); + } + + + /** + * Check if the mimetype is 'audio/ec3'. + * @param {string} mimeType + * @return {boolean} + * @private + */ + isEc3Container_(mimeType) { + return mimeType.toLowerCase().split(';')[0] == 'audio/ec3'; + } + + + /** + * @override + * @export + */ + convertCodecs(contentType, mimeType) { + if (this.isEc3Container_(mimeType)) { + return 'audio/mp4; codecs="ec-3"'; + } + return mimeType; + } + + + /** + * @override + * @export + */ + getOrginalMimeType() { + return this.originalMimeType_; + } + + + /** + * @override + * @export + */ + transmux(data, stream, reference, duration) { + const Ec3 = shaka.transmuxer.Ec3; + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + + const uint8ArrayData = shaka.util.BufferUtils.toUint8(data); + + const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData); + let offset = id3Data.length; + for (; offset < uint8ArrayData.length; offset++) { + if (Ec3.probe(uint8ArrayData, offset)) { + break; + } + } + + let timestamp = reference.endTime * 1000; + + const frames = shaka.util.Id3Utils.getID3Frames(id3Data); + if (frames.length && reference) { + const metadataTimestamp = frames.find((frame) => { + return frame.description === + 'com.apple.streaming.transportStreamTimestamp'; + }); + if (metadataTimestamp) { + timestamp = /** @type {!number} */(metadataTimestamp.data); + } + } + + /** @type {number} */ + let sampleRate = 0; + + /** @type {!Uint8Array} */ + let audioConfig = new Uint8Array([]); + + /** @type {!Array.} */ + const samples = []; + + while (offset < uint8ArrayData.length) { + const frame = Ec3.parseFrame(uint8ArrayData, offset); + if (!frame) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED)); + } + stream.audioSamplingRate = frame.sampleRate; + stream.channelsCount = frame.channelCount; + sampleRate = frame.sampleRate; + audioConfig = frame.audioConfig; + + const frameData = uint8ArrayData.subarray( + offset, offset + frame.frameLength); + + samples.push({ + data: frameData, + size: frame.frameLength, + duration: Ec3.EC3_SAMPLES_PER_FRAME, + cts: 0, + flags: { + isLeading: 0, + isDependedOn: 0, + hasRedundancy: 0, + degradPrio: 0, + dependsOn: 2, + isNonSync: 0, + }, + }); + offset += frame.frameLength; + } + /** @type {number} */ + const baseMediaDecodeTime = Math.floor(timestamp * sampleRate / 1000); + + /** @type {shaka.util.Mp4Generator.StreamInfo} */ + const streamInfo = { + timescale: sampleRate, + duration: duration, + videoNalus: [], + audioConfig: audioConfig, + data: { + sequenceNumber: this.frameIndex_, + baseMediaDecodeTime: baseMediaDecodeTime, + samples: samples, + }, + stream: stream, + }; + const mp4Generator = new shaka.util.Mp4Generator(streamInfo); + let initSegment; + if (!this.initSegments.has(stream.id)) { + initSegment = mp4Generator.initSegment(); + this.initSegments.set(stream.id, initSegment); + } else { + initSegment = this.initSegments.get(stream.id); + } + const segmentData = mp4Generator.segmentData(); + + this.frameIndex_++; + const transmuxData = Uint8ArrayUtils.concat(initSegment, segmentData); + return Promise.resolve(transmuxData); + } +}; + +shaka.transmuxer.TransmuxerEngine.registerTransmuxer( + 'audio/ec3', + () => new shaka.transmuxer.Ec3Transmuxer('audio/ec3'), + shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK); diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index f3fba3735c5..29b207e1fcf 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -69,6 +69,8 @@ goog.require('shaka.transmuxer.AacTransmuxer'); goog.require('shaka.transmuxer.Ac3'); goog.require('shaka.transmuxer.Ac3Transmuxer'); goog.require('shaka.transmuxer.ADTS'); +goog.require('shaka.transmuxer.Ec3'); +goog.require('shaka.transmuxer.Ec3Transmuxer'); goog.require('shaka.transmuxer.Mp3Transmuxer'); goog.require('shaka.transmuxer.MpegAudio'); goog.require('shaka.transmuxer.TransmuxerEngine'); diff --git a/test/transmuxer/transmuxer_integration.js b/test/transmuxer/transmuxer_integration.js index ffc784a972e..94347b974f0 100644 --- a/test/transmuxer/transmuxer_integration.js +++ b/test/transmuxer/transmuxer_integration.js @@ -145,6 +145,30 @@ describe('Transmuxer Player', () => { await player.unload(); }); + it('raw EC3', async () => { + if (!MediaSource.isTypeSupported('audio/mp4; codecs="ec-3"')) { + return; + } + + // eslint-disable-next-line max-len + const url = 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/a3/prog_index.m3u8'; + + await player.load(url, /* startTime= */ null, + /* mimeType= */ undefined); + video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 15 seconds, but stop early if the video ends. If it takes + // longer than 45 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 15, 45); + + await player.unload(); + }); + it('muxed H.264+AAC in TS', async () => { // eslint-disable-next-line max-len const url = 'https://cf-sf-video.wmspanel.com/local/raw/BigBuckBunny_320x180.mp4/playlist.m3u8';