mirror of
https://github.com/google/bumble.git
synced 2026-04-16 00:25:31 +00:00
366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const channelUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/channel";
|
|
let channelSocket;
|
|
let connectionText;
|
|
let codecText;
|
|
let packetsReceivedText;
|
|
let bytesReceivedText;
|
|
let bitrateText;
|
|
let streamStateText;
|
|
let connectionStateText;
|
|
let controlsDiv;
|
|
let audioOnButton;
|
|
let audioDecoder;
|
|
let audioCodec;
|
|
let audioContext;
|
|
let audioAnalyzer;
|
|
let audioFrequencyBinCount;
|
|
let audioFrequencyData;
|
|
let nextAudioStartPosition = 0;
|
|
let audioStartTime = 0;
|
|
let packetsReceived = 0;
|
|
let bytesReceived = 0;
|
|
let audioState = "stopped";
|
|
let streamState = "IDLE";
|
|
let audioSupportMessageText;
|
|
let fftCanvas;
|
|
let fftCanvasContext;
|
|
let bandwidthCanvas;
|
|
let bandwidthCanvasContext;
|
|
let bandwidthBinCount;
|
|
let bandwidthBins = [];
|
|
let bitrateSamples = [];
|
|
|
|
const FFT_WIDTH = 800;
|
|
const FFT_HEIGHT = 256;
|
|
const BANDWIDTH_WIDTH = 500;
|
|
const BANDWIDTH_HEIGHT = 100;
|
|
const BITRATE_WINDOW = 30;
|
|
|
|
function init() {
|
|
initUI();
|
|
initAudioContext();
|
|
initAnalyzer();
|
|
|
|
connect();
|
|
}
|
|
|
|
function initUI() {
|
|
controlsDiv = document.getElementById("controlsDiv");
|
|
controlsDiv.style.visibility = "hidden";
|
|
connectionText = document.getElementById("connectionText");
|
|
audioOnButton = document.getElementById("audioOnButton");
|
|
codecText = document.getElementById("codecText");
|
|
packetsReceivedText = document.getElementById("packetsReceivedText");
|
|
bytesReceivedText = document.getElementById("bytesReceivedText");
|
|
bitrateText = document.getElementById("bitrate");
|
|
streamStateText = document.getElementById("streamStateText");
|
|
connectionStateText = document.getElementById("connectionStateText");
|
|
audioSupportMessageText = document.getElementById("audioSupportMessageText");
|
|
|
|
audioOnButton.onclick = () => startAudio();
|
|
|
|
setConnectionText("");
|
|
|
|
requestAnimationFrame(onAnimationFrame);
|
|
}
|
|
|
|
function initAudioContext() {
|
|
audioContext = new AudioContext();
|
|
audioContext.onstatechange = () => console.log("AudioContext state:", audioContext.state);
|
|
}
|
|
|
|
function initAnalyzer() {
|
|
fftCanvas = document.getElementById("fftCanvas");
|
|
fftCanvas.width = FFT_WIDTH
|
|
fftCanvas.height = FFT_HEIGHT
|
|
fftCanvasContext = fftCanvas.getContext('2d');
|
|
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
|
|
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
|
|
|
|
bandwidthCanvas = document.getElementById("bandwidthCanvas");
|
|
bandwidthCanvas.width = BANDWIDTH_WIDTH
|
|
bandwidthCanvas.height = BANDWIDTH_HEIGHT
|
|
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
|
|
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
|
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
|
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
|
|
bandwidthBins = [];
|
|
bitrateSamples = [];
|
|
|
|
audioAnalyzer = audioContext.createAnalyser();
|
|
audioAnalyzer.fftSize = 128;
|
|
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
|
|
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
|
|
|
|
audioAnalyzer.connect(audioContext.destination)
|
|
}
|
|
|
|
function setConnectionText(message) {
|
|
connectionText.innerText = message;
|
|
if (message.length == 0) {
|
|
connectionText.style.display = "none";
|
|
} else {
|
|
connectionText.style.display = "inline-block";
|
|
}
|
|
}
|
|
|
|
function setStreamState(state) {
|
|
streamState = state;
|
|
streamStateText.innerText = streamState;
|
|
}
|
|
|
|
function onAnimationFrame() {
|
|
// FFT
|
|
if (audioAnalyzer !== undefined) {
|
|
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
|
|
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
|
|
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
|
|
const barCount = audioFrequencyBinCount;
|
|
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
|
|
for (let bar = 0; bar < barCount; bar++) {
|
|
const barHeight = audioFrequencyData[bar];
|
|
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
|
|
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
|
|
}
|
|
}
|
|
|
|
// Bandwidth
|
|
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
|
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
|
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
|
|
for (let t = 0; t < bandwidthBins.length; t++) {
|
|
const bytesReceived = bandwidthBins[t]
|
|
const lineHeight = (bytesReceived / 1000) * BANDWIDTH_HEIGHT;
|
|
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
|
|
}
|
|
|
|
// Display again at the next frame
|
|
requestAnimationFrame(onAnimationFrame);
|
|
}
|
|
|
|
async function startAudio() {
|
|
try {
|
|
console.log("starting audio...");
|
|
audioOnButton.disabled = true;
|
|
audioState = "starting";
|
|
audioContext.resume();
|
|
console.log("audio started");
|
|
audioState = "playing";
|
|
} catch(error) {
|
|
console.error(`play failed: ${error}`);
|
|
audioState = "stopped";
|
|
audioOnButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
function onDecodedAudio(audioData) {
|
|
const bufferSource = audioContext.createBufferSource()
|
|
|
|
const now = audioContext.currentTime;
|
|
let nextAudioStartTime = audioStartTime + (nextAudioStartPosition / audioData.sampleRate);
|
|
if (nextAudioStartTime < now) {
|
|
console.log("starting new audio time base")
|
|
audioStartTime = now;
|
|
nextAudioStartTime = now;
|
|
nextAudioStartPosition = 0;
|
|
} else {
|
|
console.log(`audio buffer scheduled in ${nextAudioStartTime - now}`)
|
|
}
|
|
|
|
const audioBuffer = audioContext.createBuffer(
|
|
audioData.numberOfChannels,
|
|
audioData.numberOfFrames,
|
|
audioData.sampleRate
|
|
);
|
|
|
|
for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
|
|
audioData.copyTo(
|
|
audioBuffer.getChannelData(channel),
|
|
{
|
|
planeIndex: channel,
|
|
format: "f32-planar"
|
|
}
|
|
)
|
|
}
|
|
|
|
bufferSource.buffer = audioBuffer;
|
|
bufferSource.connect(audioAnalyzer)
|
|
bufferSource.start(nextAudioStartTime);
|
|
nextAudioStartPosition += audioData.numberOfFrames;
|
|
}
|
|
|
|
function onCodecError(error) {
|
|
console.log("Codec error:", error)
|
|
}
|
|
|
|
async function onAudioPacket(packet) {
|
|
packetsReceived += 1;
|
|
packetsReceivedText.innerText = packetsReceived;
|
|
bytesReceived += packet.byteLength;
|
|
bytesReceivedText.innerText = bytesReceived;
|
|
|
|
bandwidthBins[bandwidthBins.length] = packet.byteLength;
|
|
if (bandwidthBins.length > bandwidthBinCount) {
|
|
bandwidthBins.shift();
|
|
}
|
|
bitrateSamples[bitrateSamples.length] = {ts: Date.now(), bytes: packet.byteLength}
|
|
if (bitrateSamples.length > BITRATE_WINDOW) {
|
|
bitrateSamples.shift();
|
|
}
|
|
if (bitrateSamples.length >= 2) {
|
|
const windowBytes = bitrateSamples.reduce((accumulator, x) => accumulator + x.bytes, 0) - bitrateSamples[0].bytes;
|
|
const elapsed = bitrateSamples[bitrateSamples.length-1].ts - bitrateSamples[0].ts;
|
|
const bitrate = Math.floor(8 * windowBytes / elapsed)
|
|
bitrateText.innerText = `${bitrate} kb/s`
|
|
}
|
|
|
|
if (audioState == "stopped") {
|
|
return;
|
|
}
|
|
|
|
if (audioDecoder === undefined) {
|
|
let audioConfig;
|
|
if (audioCodec == 'aac') {
|
|
audioConfig = {
|
|
codec: 'mp4a.40.2',
|
|
sampleRate: 44100, // ignored
|
|
numberOfChannels: 2, // ignored
|
|
}
|
|
} else if (audioCodec == 'opus') {
|
|
audioConfig = {
|
|
codec: 'opus',
|
|
sampleRate: 48000, // ignored
|
|
numberOfChannels: 2, // ignored
|
|
}
|
|
}
|
|
audioDecoder = new AudioDecoder({ output: onDecodedAudio, error: onCodecError });
|
|
audioDecoder.configure(audioConfig)
|
|
}
|
|
|
|
const encodedAudio = new EncodedAudioChunk({
|
|
type: "key",
|
|
data: packet,
|
|
timestamp: 0,
|
|
transfer: [packet],
|
|
});
|
|
|
|
audioDecoder.decode(encodedAudio);
|
|
}
|
|
|
|
function onChannelOpen() {
|
|
console.log('channel OPEN');
|
|
setConnectionText("");
|
|
controlsDiv.style.visibility = "visible";
|
|
|
|
// Handshake with the backend.
|
|
sendMessage({
|
|
type: "hello"
|
|
});
|
|
}
|
|
|
|
function onChannelClose() {
|
|
console.log('channel CLOSED');
|
|
setConnectionText("Connection to CLI app closed, restart it and reload this page.");
|
|
controlsDiv.style.visibility = "hidden";
|
|
}
|
|
|
|
function onChannelError(error) {
|
|
console.log(`channel ERROR: ${error}`);
|
|
setConnectionText(`Connection to CLI app error ({${error}}), restart it and reload this page.`);
|
|
controlsDiv.style.visibility = "hidden";
|
|
}
|
|
|
|
function onChannelMessage(message) {
|
|
if (typeof message.data === 'string' || message.data instanceof String) {
|
|
// JSON message.
|
|
const jsonMessage = JSON.parse(message.data);
|
|
console.log(`channel MESSAGE: ${message.data}`);
|
|
|
|
// Dispatch the message.
|
|
const handlerName = `on${jsonMessage.type.charAt(0).toUpperCase()}${jsonMessage.type.slice(1)}Message`
|
|
const handler = messageHandlers[handlerName];
|
|
if (handler !== undefined) {
|
|
const params = jsonMessage.params;
|
|
if (params === undefined) {
|
|
params = {};
|
|
}
|
|
handler(params);
|
|
} else {
|
|
console.warn(`unhandled message: ${jsonMessage.type}`)
|
|
}
|
|
} else {
|
|
// BINARY audio data.
|
|
onAudioPacket(message.data);
|
|
}
|
|
}
|
|
|
|
async function onHelloMessage(params) {
|
|
codecText.innerText = params.codec;
|
|
|
|
if (params.codec == "aac" || params.codec == "opus") {
|
|
audioCodec = params.codec
|
|
audioSupportMessageText.innerText = "";
|
|
audioSupportMessageText.style.display = "none";
|
|
} else {
|
|
audioOnButton.disabled = true;
|
|
audioSupportMessageText.innerText = "Only AAC and Opus can be played, audio will be disabled";
|
|
audioSupportMessageText.style.display = "inline-block";
|
|
}
|
|
|
|
if (params.streamState) {
|
|
setStreamState(params.streamState);
|
|
}
|
|
}
|
|
|
|
function onStartMessage(params) {
|
|
setStreamState("STARTED");
|
|
}
|
|
|
|
function onStopMessage(params) {
|
|
setStreamState("STOPPED");
|
|
}
|
|
|
|
function onSuspendMessage(params) {
|
|
setStreamState("SUSPENDED");
|
|
}
|
|
|
|
function onConnectionMessage(params) {
|
|
connectionStateText.innerText = `CONNECTED: ${params.peer_name} (${params.peer_address})`;
|
|
}
|
|
|
|
function onDisconnectionMessage(params) {
|
|
connectionStateText.innerText = "DISCONNECTED";
|
|
}
|
|
|
|
function sendMessage(message) {
|
|
channelSocket.send(JSON.stringify(message));
|
|
}
|
|
|
|
function connect() {
|
|
console.log("connecting to CLI app");
|
|
|
|
channelSocket = new WebSocket(channelUrl);
|
|
channelSocket.binaryType = "arraybuffer";
|
|
channelSocket.onopen = onChannelOpen;
|
|
channelSocket.onclose = onChannelClose;
|
|
channelSocket.onerror = onChannelError;
|
|
channelSocket.onmessage = onChannelMessage;
|
|
}
|
|
|
|
const messageHandlers = {
|
|
onHelloMessage,
|
|
onStartMessage,
|
|
onStopMessage,
|
|
onSuspendMessage,
|
|
onConnectionMessage,
|
|
onDisconnectionMessage
|
|
}
|
|
|
|
window.onload = (event) => {
|
|
init();
|
|
}
|
|
|
|
}()); |