Go + Pion + libopus. One guest, live audio both ways, anonymous chat. README for this version was not preserved; see the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
8.2 KiB
JavaScript
169 lines
8.2 KiB
JavaScript
'use strict';
|
|
const token = location.pathname.split('/').filter(Boolean)[1] || '';
|
|
const log = $('#log');
|
|
let ws = null, everOpen = false, finished = false;
|
|
let pc = null, stream = null, actx = null, micAn = null, showAn = null, muted = false;
|
|
|
|
function fatal(msg){
|
|
finished = true;
|
|
$('#fatal').textContent = msg; $('#fatal').classList.remove('hidden');
|
|
$('#conn').textContent = 'ended'; $('#conn').className = 'pill bad';
|
|
hangup(false);
|
|
for (const el of document.querySelectorAll('button, textarea, input, select')) el.disabled = true;
|
|
}
|
|
function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
|
|
|
|
function connect(){
|
|
ws = new WebSocket(wsURL('/ws?t=' + encodeURIComponent(token)));
|
|
ws.onopen = () => {
|
|
everOpen = true;
|
|
$('#conn').textContent = 'connected'; $('#conn').className = 'pill ok';
|
|
const n = $('#name').value.trim(); if (n) send({t:'name', name:n});
|
|
};
|
|
ws.onclose = () => {
|
|
if (finished) return;
|
|
if (!everOpen) { fatal('This invite link is not active. Ask the host for a new one.'); return; }
|
|
$('#conn').textContent = 'reconnecting…'; $('#conn').className = 'pill bad';
|
|
setTimeout(connect, 2000);
|
|
};
|
|
ws.onmessage = async ev => {
|
|
const m = JSON.parse(ev.data);
|
|
switch (m.t) {
|
|
case 'welcome': case 'history':
|
|
log.textContent = ''; (m.history || []).forEach(x => renderMsg(log, x, 'guest')); break;
|
|
case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break;
|
|
case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break;
|
|
case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break;
|
|
case 'error': $('#callstate').textContent = m.text; hangup(false); break;
|
|
case 'callended': hangup(false); $('#callstate').textContent = 'The host ended the audio. You can join again.'; break;
|
|
case 'ended': fatal(m.reason || 'The session has ended.'); break;
|
|
}
|
|
};
|
|
}
|
|
|
|
let flashT = null;
|
|
function flashTitle(){
|
|
if (flashT) return; const base = document.title; let on = false;
|
|
flashT = setInterval(() => { document.title = (on = !on) ? '💬 New message' : base; }, 1000);
|
|
const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); };
|
|
document.addEventListener('visibilitychange', stop);
|
|
}
|
|
|
|
// ---- devices
|
|
async function listDevices(){
|
|
const devs = await navigator.mediaDevices.enumerateDevices();
|
|
const fill = (sel, kind, saved) => {
|
|
const cur = sel.value || saved || '';
|
|
sel.length = 1;
|
|
devs.filter(d => d.kind === kind && d.deviceId !== 'default' && d.deviceId !== 'communications')
|
|
.forEach((d, i) => { const o = new Option(d.label || (kind + ' ' + (i+1)), d.deviceId); sel.add(o); });
|
|
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
|
|
};
|
|
fill($('#mic'), 'audioinput', store('gl_mic'));
|
|
if ('setSinkId' in HTMLMediaElement.prototype) fill($('#out'), 'audiooutput', store('gl_out'));
|
|
else $('#outwrap').classList.add('hidden');
|
|
}
|
|
navigator.mediaDevices?.addEventListener?.('devicechange', listDevices);
|
|
$('#mic').onchange = () => store('gl_mic', $('#mic').value);
|
|
$('#out').onchange = async () => {
|
|
store('gl_out', $('#out').value);
|
|
try { await $('#player').setSinkId($('#out').value); } catch(e) {}
|
|
};
|
|
|
|
// ---- call
|
|
function munge(sdp){
|
|
// Ask for stereo, full-band, high-bitrate Opus in both directions.
|
|
const m = sdp.match(/a=rtpmap:(\d+) opus\/48000/i); if (!m) return sdp;
|
|
const pt = m[1];
|
|
return sdp.replace(new RegExp('a=fmtp:' + pt + ' (.*)'), (line, p) =>
|
|
'a=fmtp:' + pt + ' ' + p.split(';').filter(x => !/^(stereo|sprop-stereo|maxaveragebitrate|cbr)=/.test(x)).join(';') +
|
|
';stereo=1;sprop-stereo=1;maxaveragebitrate=128000');
|
|
}
|
|
|
|
async function join(){
|
|
if (!window.RTCPeerConnection || !navigator.mediaDevices) { $('#callstate').textContent = 'This browser cannot do live audio. Please use Chrome, Edge, Firefox or Safari.'; return; }
|
|
$('#join').disabled = true; $('#callstate').textContent = 'Asking for your microphone…';
|
|
const hp = $('#headphones').checked;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({audio: {
|
|
deviceId: $('#mic').value ? {exact: $('#mic').value} : undefined,
|
|
echoCancellation: !hp, noiseSuppression: false, autoGainControl: false,
|
|
channelCount: {ideal: 1}, sampleRate: {ideal: 48000}
|
|
}});
|
|
} catch (e) {
|
|
$('#join').disabled = false;
|
|
$('#callstate').textContent = 'Microphone not available: ' + (e.message || e.name) + '. Check your browser\'s permission and try again.';
|
|
return;
|
|
}
|
|
listDevices().catch(()=>{});
|
|
actx = actx || new AudioContext();
|
|
if (actx.state === 'suspended') await actx.resume();
|
|
micAn = actx.createAnalyser(); micAn.fftSize = 1024;
|
|
actx.createMediaStreamSource(stream).connect(micAn);
|
|
|
|
pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]});
|
|
const tr = pc.addTransceiver(stream.getAudioTracks()[0], {direction: 'sendrecv', streams: [stream]});
|
|
pc.onicecandidate = e => { if (e.candidate) send({t:'candidate', candidate: e.candidate.toJSON()}); };
|
|
pc.ontrack = e => {
|
|
const s = e.streams[0] || new MediaStream([e.track]);
|
|
const p = $('#player'); p.srcObject = s;
|
|
if ($('#out').value && p.setSinkId) p.setSinkId($('#out').value).catch(()=>{});
|
|
p.play().catch(()=>{});
|
|
showAn = actx.createAnalyser(); showAn.fftSize = 1024;
|
|
actx.createMediaStreamSource(s).connect(showAn);
|
|
};
|
|
pc.onconnectionstatechange = () => {
|
|
const s = pc ? pc.connectionState : 'closed';
|
|
const label = {connecting:'Connecting audio…', connected:'You are live with the host. 🎙', disconnected:'Audio interrupted - trying to recover…', failed:'Audio connection failed.'}[s];
|
|
if (label) $('#callstate').textContent = label;
|
|
if (s === 'connected') {
|
|
const p = tr.sender.getParameters(); p.encodings = p.encodings && p.encodings.length ? p.encodings : [{}];
|
|
p.encodings[0].maxBitrate = 128000; tr.sender.setParameters(p).catch(()=>{});
|
|
}
|
|
if (s === 'failed') { hangup(false); $('#callstate').textContent = 'Audio connection failed. Press Join to try again - if it keeps failing, tell the host in chat.'; }
|
|
};
|
|
const offer = await pc.createOffer();
|
|
offer.sdp = munge(offer.sdp);
|
|
await pc.setLocalDescription(offer);
|
|
send({t:'offer', sdp: pc.localDescription.sdp});
|
|
$('#callstate').textContent = 'Connecting audio…';
|
|
$('#join').classList.add('hidden'); $('#join').disabled = false;
|
|
$('#mute').classList.remove('hidden'); $('#leave').classList.remove('hidden');
|
|
for (const s of ['#mic', '#headphones']) $(s).disabled = true;
|
|
}
|
|
|
|
function hangup(tell){
|
|
if (tell) send({t:'hangup'});
|
|
if (pc) { pc.close(); pc = null; }
|
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
|
micAn = showAn = null; muted = false;
|
|
$('#player').srcObject = null;
|
|
$('#mute').textContent = 'Mute mic';
|
|
$('#join').classList.remove('hidden'); $('#mute').classList.add('hidden'); $('#leave').classList.add('hidden');
|
|
for (const s of ['#mic', '#headphones']) $(s).disabled = false;
|
|
$('#callstate').textContent = 'Not connected to audio.';
|
|
}
|
|
|
|
$('#join').onclick = join;
|
|
$('#leave').onclick = () => hangup(true);
|
|
$('#mute').onclick = () => {
|
|
if (!stream) return; muted = !muted;
|
|
stream.getAudioTracks().forEach(t => t.enabled = !muted);
|
|
$('#mute').textContent = muted ? 'Unmute mic' : 'Mute mic';
|
|
$('#mute').classList.toggle('danger', muted);
|
|
};
|
|
|
|
// ---- meters
|
|
const buf = new Float32Array(1024);
|
|
function peak(an){ if (!an) return 0; an.getFloatTimeDomainData(buf); let p = 0; for (const v of buf) { const a = Math.abs(v); if (a > p) p = a; } return p; }
|
|
(function loop(){ setMeter($('#mMic'), muted ? 0 : peak(micAn)); setMeter($('#mShow'), peak(showAn)); requestAnimationFrame(loop); })();
|
|
|
|
// ---- name + chat
|
|
$('#name').value = store('gl_name') || '';
|
|
$('#name').addEventListener('change', () => { const n = $('#name').value.trim(); store('gl_name', n); send({t:'name', name:n}); });
|
|
chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t}));
|
|
window.addEventListener('beforeunload', () => { if (pc) send({t:'hangup'}); });
|
|
|
|
if (!token) fatal('This link is incomplete. Ask the host for your invite link.');
|
|
else { connect(); if (navigator.mediaDevices) listDevices().catch(()=>{}); }
|