Created
June 17, 2020 18:50
-
-
Save UCIS/845eda1755d38eddfc3f0f99268c27da to your computer and use it in GitHub Desktop.
Opus/WebM player in JavaScript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
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
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.