Skip to content

Instantly share code, notes, and snippets.

@UCIS
Created June 17, 2020 18:50
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save UCIS/845eda1755d38eddfc3f0f99268c27da to your computer and use it in GitHub Desktop.
Save UCIS/845eda1755d38eddfc3f0f99268c27da to your computer and use it in GitHub Desktop.
Opus/WebM player in JavaScript
function OpusWebmPacker() {
var channels = 2;
var sample_rate = 48000;
var position = 0;
var packets = [];
var buffer = new Uint8Array(4 + 1275);
var buffer_offset = 0;
function Concat() {
var i, l = 0, a;
for (i = 0; i < arguments.length; i++) l += arguments[i].byteLength;
a = new Uint8Array (l);
for (i = 0, l = 0; i < arguments.length; l += arguments[i].byteLength, i++) a.set(arguments[i], l);
return a;
}
function EncodeID(id) {
if ((id & 0xFF000000) != 0) return new Uint8Array([(id >>> 24) & 0xFF, (id >>> 16) & 0xFF, (id >>> 8) & 0xFF, id & 0xFF]);
if ((id & 0xFF0000) != 0) return new Uint8Array([(id >>> 16) & 0xFF, (id >>> 8) & 0xFF, id & 0xFF]);
if ((id & 0xFF00) != 0) return new Uint8Array([(id >>> 8) & 0xFF, id & 0xFF]);
if ((id & 0xFF) != 0) return new Uint8Array([id & 0xFF]);
throw 'InvalidOperationException';
}
function EncodeLength(value) {
if (value <= 0x7F) return new Uint8Array([(0x80 | (value & 0x7F))]);
if (value <= 0x3FFF) return new Uint8Array([(0x40 | ((value >> 8) & 0x3F)), value & 0xFF]);
return new Uint8Array([0x08, (value >>> 24) & 0xFF, (value >>> 16) & 0xFF, (value >>> 8) & 0xFF, value & 0xFF]);
}
function EncodeUInt(value) {
if (value <= 0xFF) return new Uint8Array([value & 0xFF]);
if (value <= 0xFFFF) return new Uint8Array([(value >>> 8) & 0xFF, value & 0xFF]);
if (value <= 0xFFFFFF) return new Uint8Array([(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]);
return new Uint8Array([(value >>> 24) & 0xFF, (value >>> 16) & 0xFF, (value >>> 8) & 0xFF, value & 0xFF]);
var b = new DataView(new ArrayBuffer(4));
b.setUint32(0, value, false);
return b;
}
function MakeElement(id, data) {
data = new Uint8Array(data);
return Concat(EncodeID(id), EncodeLength(data.byteLength), data);
}
function MakeInfiniteElement(id, data) {
return Concat(EncodeID(id), EncodeLength(0xFFFFFFFF), data);
}
function MakeMaster(id) {
return MakeElement(id, Concat.apply(null, Array.prototype.slice.call(arguments, 1)));
}
function MakeInfiniteMaster(id, parts) {
return MakeInfiniteElement(id, Concat.apply(null, Array.prototype.slice.call(arguments, 1)));
}
function MakeUInt(id, value) {
return MakeElement(id, EncodeUInt(value));
}
function MakeString(id, value) {
return MakeUnicode(id, value);
}
function MakeUnicode(id, value) {
return MakeElement(id, (new TextEncoder()).encode(value));
}
function MakeFloat(id, value) {
var b = new DataView(new ArrayBuffer(4));
b.setFloat32(0, value, false);
return MakeElement(id, new Uint8Array(b.buffer));
}
function BuildHeader(channels, originalSampleRate) {
return Concat(
MakeMaster(0x1A45DFA3, //EBML
MakeUInt(0x4286, 1), //EBMLVersion
MakeUInt(0x42F7, 1), //EBMLReadVersion
MakeUInt(0x42F2, 4), //EBMLMaxIDLength
MakeUInt(0x42F3, 8), //EBMLMaxSizeLength
MakeString(0x4282, "webm"), //DocType
MakeUInt(0x4287, 4), //DocTypeVersion
MakeUInt(0x4285, 2) //DocTypeReadVersion
),
MakeInfiniteMaster(0x18538067, //Segment
MakeMaster(0x1549A966, //Info
MakeUInt(0x2AD7B1, 1000000), //TimestampScale = 1000000000/1000000=1ms
MakeUnicode(0x4D80, "URadioServer"), //MuxingApp
MakeUnicode(0x5741, "URadioServer"), //WritingApp
),
MakeMaster(0x1654AE6B, //Tracks
MakeMaster(0xAE, //TrackEntry
MakeUInt(0xD7, 1), //TrackNumber
MakeUInt(0x73C5, 1), //TrackUID
MakeUInt(0x9C, 0), //FlagLacing
MakeString(0x22B59C, "und"), //Language
MakeString(0x86, "A_OPUS"), //CodecID
MakeUInt(0x56AA, 6500000), //CodecDelay
MakeUInt(0x56BB, 80000000), //SeekPreRoll
MakeUInt(0x83, 2), //TrackType
MakeMaster(0xE1, //Audio
MakeFloat(0xB5, 48000), //SamplingFrequency
MakeUInt(0x9F, channels) //Channels
),
MakeElement(0x63A2, //CodecPrivate
new Uint8Array([
'O'.charCodeAt(0), 'p'.charCodeAt(0), 'u'.charCodeAt(0), 's'.charCodeAt(0), 'H'.charCodeAt(0), 'e'.charCodeAt(0), 'a'.charCodeAt(0), 'd'.charCodeAt(0),
1, channels & 0xFF, 0x38, 0x01,
(originalSampleRate >>> 0) & 0xFF, (originalSampleRate >>> 8) & 0xFF, (originalSampleRate >>> 16) & 0xFF, (originalSampleRate >>> 24) & 0xFF,
0, 0, 0
])
)
)
)
)
);
}
function Feed(data) {
data = new Uint8Array(data);
var copy_length, packet_length, num_samples;
while (data.length > 0) {
copy_length = Math.min(data.length, buffer.length - buffer_offset);
buffer.set(data.subarray(0, copy_length), buffer_offset);
buffer_offset += copy_length;
data = data.subarray(copy_length);
if (buffer_offset >= 4) {
packet_length = buffer[0] | (buffer[1] << 8);
num_samples = buffer[2] | (buffer[3] << 8);
if (packet_length > 1275 || (num_samples != 120 && num_samples != 240 && num_samples != 960 && num_samples != 1920 && num_samples != 2880)) {
buffer.set(buffer.subarray(1));
buffer_offset--;
continue;
}
packet_length += 4;
if (buffer_offset >= packet_length) {
packets.push(MakeMaster(0x1F43B675, //Cluster
MakeUInt(0xE7, position), //Timestamp
MakeElement(0xA3, //SimpleBlock
Concat(
EncodeLength(1), //Track Number
new Uint8Array([0, 0]), //Relative timestamp
new Uint8Array([0x80]), //Flags
buffer.subarray(4, packet_length)
)
)
));
position += num_samples * 1000 / 48000;
if (packet_length < buffer_offset) buffer.set(buffer.subarray(packet_length));
buffer_offset -= packet_length;
}
}
}
}
function GetBufferLength() {
return packets.length * 1275 + buffer_offset; //estimated buffer length, just to prevent overflow
}
function GetFrame() {
var frame = packets.shift();
return frame ? { data: frame } : null;
}
packets.push(BuildHeader(2, 48000));
return { Feed: Feed, GetBufferLength: GetBufferLength, GetFrame: GetFrame };
}
function AudioPlayer_MSE(receiver) {
var player = null, sourceBuffer = null, queue = [], socket = null, synchronizer = null;
var panner = null;
var audioContext = new (window.AudioContext || window.webkitAudioContext || Object)();
var websocket = window.WebSocket || window.MozWebSocket;
function sourceOpen() {
window.URL.revokeObjectURL(player.src);
if (streamType == 'opus') sourceBuffer = this.addSourceBuffer('audio/webm; codecs="opus"');
else sourceBuffer = this.addSourceBuffer('audio/mpeg');
sourceBuffer.addEventListener('updateend', sourceFeedBuffer);
sourceFeedBuffer();
}
function sourceFeedBuffer() {
if (!sourceBuffer || sourceBuffer.updating) return;
var frame;
if (synchronizer && (frame = synchronizer.GetFrame())) sourceBuffer.appendBuffer(frame.data);
else if (queue.length) sourceBuffer.appendBuffer(queue.shift());
}
function start(url) {
if (!player) {
player = document.createElement("audio");
player.controls = true;
document.getElementById('mpcontainer').style.display = '';
document.getElementById('mpcontainer').style.visibility = '';
document.getElementById('mpcontainer').appendChild(player);
if (audioContext.createMediaElementSource && audioContext.createStereoPanner) {
var playerSource = audioContext.createMediaElementSource(player);
panner = audioContext.createStereoPanner();
playerSource.connect(panner);
panner.connect(audioContext.destination);
}
}
if (socket) socket.close();
queue = [];
synchronizer = null;
var source = new MediaSource();
source.addEventListener('sourceopen', sourceOpen, false);
player.src = window.URL.createObjectURL(source);
if (streamType == 'opus') synchronizer = new OpusWebmPacker();
else if (window.navigator.userAgent.indexOf("Edge") > -1) synchronizer = new MP3FrameSynchronizer();
else synchronizer = null;
socket = new websocket(url, 'binary');
socket.binaryType = 'arraybuffer';
socket.onclose = function(e) { console.log([ 'wsaudio closed', e ]); };
socket.onopen = function() { console.log('wsaudio open'); };
socket.onerror = function(e) { console.log([ 'wsaudio error', e ]); };
socket.onmessage = function(e) {
if (synchronizer) {
if (synchronizer.GetBufferLength() < 102400) synchronizer.Feed(e.data);
} else {
if (queue.length < 25) queue.push(e.data);
}
sourceFeedBuffer();
};
player.play();
};
var obj = {};
obj.play = function(ws_url, type) {
if (source == null) obj.stop();
else {
if (type == 'opus') streamType = 'opus'; else streamType = 'audio/mpeg';
start(ws_url);
}
};
obj.stop = function() {
if (socket) socket.close();
socket = null;
queue = [];
if (player) player.pause();
if (player) player.parentNode.removeChild(player);
player = null;
sourceBuffer = null;
};
obj.setVolume = function(vol) {
if (player) player.volume = Math.min(1, vol / 100);
};
if (audioContext.createMediaElementSource && audioContext.createStereoPanner) {
obj.setBalance = function(value) {
if (panner) panner.pan.value = Math.min(1, Math.max(-1, value / 100));
};
}
return obj;
}
@guest271314
Copy link

What is the expected input to OpusWebmPacker? Is there a complete working example online?

@UCIS
Copy link
Author

UCIS commented Jan 4, 2021

What is the expected input to OpusWebmPacker? Is there a complete working example online?

The code was taken directly from another project. This part is currently being tested in a dev version of www.globaltuners.com (requires registration to access the streams).

The OpusWebmPacker.Feed function takes (chunks of a stream of) OPUS frames prefixed with a 4 byte header: 16 bit little endian length of the OPUS data and 16 bit little endian number of audio samples contained in the packet.

This can be simplified since websockets provide packet boundaries, but the stream distribution in the existing project does not support this so frame boundaries need to be reconstructed and the stream may need to be synchronized by skipping invalid data. The number of samples is needed to reconstruct the relative timestamp of the frame, this can probably be extracted from the OPUS frame data, but that would be more complicated that just including it in the header.

@guest271314
Copy link

A publicly disclosed working version would be helpful, see https://bugs.chromium.org/p/chromium/issues/detail?id=1161429

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment