From 82ad234480397483df03823f96c61cfa5ace64fb Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Thu, 5 Feb 2026 15:52:54 -0700 Subject: [PATCH] Add browser call-in page and update host dashboard for browser callers Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 5 + frontend/call-in.html | 155 +++++++++++++++++++++++++++ frontend/index.html | 2 +- frontend/js/app.js | 18 ++-- frontend/js/call-in.js | 232 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 frontend/call-in.html create mode 100644 frontend/js/call-in.js diff --git a/backend/main.py b/backend/main.py index 5115b9c..7c4aa7e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -416,6 +416,11 @@ async def index(): return FileResponse(frontend_dir / "index.html") +@app.get("/call-in") +async def call_in_page(): + return FileResponse(frontend_dir / "call-in.html") + + # --- Request Models --- class ChatRequest(BaseModel): diff --git a/frontend/call-in.html b/frontend/call-in.html new file mode 100644 index 0000000..b662c22 --- /dev/null +++ b/frontend/call-in.html @@ -0,0 +1,155 @@ + + + + + + Call In - Luke at the Roost + + + +
+

Luke at the Roost

+

Call in to the show

+ +
+ +
+ + + + +
+
Status
+
Connecting...
+
+ +
+
+
+
+ + + + diff --git a/frontend/index.html b/frontend/index.html index 9486d56..18d0f15 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -53,7 +53,7 @@
-

Incoming Calls

+

Incoming Calls Call-in page

No callers waiting
diff --git a/frontend/js/app.js b/frontend/js/app.js index 475a5ac..a883053 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -874,31 +874,31 @@ function renderQueue(queue) { const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; return `
- ${caller.phone} + ${caller.name} waiting ${waitStr} - - + +
`; }).join(''); } -async function takeCall(callSid) { +async function takeCall(callerId) { try { - const res = await fetch(`/api/queue/take/${callSid}`, { method: 'POST' }); + const res = await fetch(`/api/queue/take/${callerId}`, { method: 'POST' }); const data = await res.json(); if (data.status === 'on_air') { showRealCaller(data.caller); - log(`${data.caller.name} (${data.caller.phone}) is on air — Channel ${data.caller.channel}`); + log(`${data.caller.name} is on air — Channel ${data.caller.channel}`); } } catch (err) { log('Failed to take call: ' + err.message); } } -async function dropCall(callSid) { +async function dropCall(callerId) { try { - await fetch(`/api/queue/drop/${callSid}`, { method: 'POST' }); + await fetch(`/api/queue/drop/${callerId}`, { method: 'POST' }); fetchQueue(); } catch (err) { log('Failed to drop call: ' + err.message); @@ -932,7 +932,7 @@ function updateActiveCallIndicator() { function showRealCaller(callerInfo) { const nameEl = document.getElementById('real-caller-name'); const chEl = document.getElementById('real-caller-channel'); - if (nameEl) nameEl.textContent = `${callerInfo.name} (${callerInfo.phone})`; + if (nameEl) nameEl.textContent = callerInfo.name; if (chEl) chEl.textContent = `Ch ${callerInfo.channel}`; document.getElementById('real-caller-info')?.classList.remove('hidden'); diff --git a/frontend/js/call-in.js b/frontend/js/call-in.js new file mode 100644 index 0000000..dbf4990 --- /dev/null +++ b/frontend/js/call-in.js @@ -0,0 +1,232 @@ +/** + * Call-In Page — Browser WebSocket audio streaming + * Captures mic via AudioWorklet, sends Int16 PCM 16kHz mono over WebSocket. + * Receives Int16 PCM 16kHz mono back for playback. + */ + +let ws = null; +let audioCtx = null; +let micStream = null; +let workletNode = null; +let nextPlayTime = 0; +let callerId = null; + +const callBtn = document.getElementById('call-btn'); +const hangupBtn = document.getElementById('hangup-btn'); +const statusEl = document.getElementById('status'); +const statusText = document.getElementById('status-text'); +const nameInput = document.getElementById('caller-name'); +const micMeter = document.getElementById('mic-meter'); +const micMeterFill = document.getElementById('mic-meter-fill'); + +callBtn.addEventListener('click', startCall); +hangupBtn.addEventListener('click', hangUp); +nameInput.addEventListener('keydown', e => { + if (e.key === 'Enter') startCall(); +}); + +async function startCall() { + const name = nameInput.value.trim() || 'Anonymous'; + callBtn.disabled = true; + setStatus('Connecting...', false); + + try { + // Get mic access + micStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 } + }); + + // Set up AudioContext + audioCtx = new AudioContext({ sampleRate: 48000 }); + + // Register worklet processor inline via blob + const processorCode = ` +class CallerProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.buffer = []; + this.targetSamples = 4096; // ~256ms at 16kHz + } + process(inputs) { + const input = inputs[0][0]; + if (!input) return true; + + // Downsample from sampleRate to 16000 + const ratio = sampleRate / 16000; + for (let i = 0; i < input.length; i += ratio) { + const idx = Math.floor(i); + if (idx < input.length) { + this.buffer.push(input[idx]); + } + } + + if (this.buffer.length >= this.targetSamples) { + const chunk = this.buffer.splice(0, this.targetSamples); + const int16 = new Int16Array(chunk.length); + for (let i = 0; i < chunk.length; i++) { + const s = Math.max(-1, Math.min(1, chunk[i])); + int16[i] = s < 0 ? s * 32768 : s * 32767; + } + this.port.postMessage(int16.buffer, [int16.buffer]); + } + return true; + } +} +registerProcessor('caller-processor', CallerProcessor); +`; + const blob = new Blob([processorCode], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); + await audioCtx.audioWorklet.addModule(blobUrl); + URL.revokeObjectURL(blobUrl); + + // Connect mic to worklet + const source = audioCtx.createMediaStreamSource(micStream); + workletNode = new AudioWorkletNode(audioCtx, 'caller-processor'); + + // Connect WebSocket + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${proto}//${location.host}/api/caller/stream`); + ws.binaryType = 'arraybuffer'; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'join', name })); + }; + + ws.onmessage = (event) => { + if (typeof event.data === 'string') { + handleControlMessage(JSON.parse(event.data)); + } else { + handleAudioData(event.data); + } + }; + + ws.onclose = () => { + setStatus('Disconnected', false); + cleanup(); + }; + + ws.onerror = () => { + setStatus('Connection error', false); + cleanup(); + }; + + // Forward mic audio to WebSocket + workletNode.port.onmessage = (e) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(e.data); + } + }; + + source.connect(workletNode); + // Don't connect worklet to destination — we don't want to hear our own mic + + // Show mic meter + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + startMicMeter(analyser); + + // UI + nameInput.disabled = true; + hangupBtn.style.display = 'block'; + + } catch (err) { + console.error('Call error:', err); + setStatus('Failed: ' + err.message, false); + callBtn.disabled = false; + cleanup(); + } +} + +function handleControlMessage(msg) { + if (msg.status === 'queued') { + callerId = msg.caller_id; + setStatus(`Waiting in queue (position ${msg.position})...`, false); + } else if (msg.status === 'on_air') { + setStatus('ON AIR', true); + nextPlayTime = audioCtx.currentTime; + } else if (msg.status === 'disconnected') { + setStatus('Disconnected', false); + cleanup(); + } +} + +function handleAudioData(buffer) { + if (!audioCtx) return; + + const int16 = new Int16Array(buffer); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / 32768; + } + + const audioBuf = audioCtx.createBuffer(1, float32.length, 16000); + audioBuf.copyToChannel(float32, 0); + + const source = audioCtx.createBufferSource(); + source.buffer = audioBuf; + source.connect(audioCtx.destination); + + const now = audioCtx.currentTime; + if (nextPlayTime < now) { + nextPlayTime = now; + } + source.start(nextPlayTime); + nextPlayTime += audioBuf.duration; +} + +function hangUp() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + setStatus('Disconnected', false); + cleanup(); +} + +function cleanup() { + if (workletNode) { + workletNode.disconnect(); + workletNode = null; + } + if (micStream) { + micStream.getTracks().forEach(t => t.stop()); + micStream = null; + } + if (audioCtx) { + audioCtx.close().catch(() => {}); + audioCtx = null; + } + ws = null; + callerId = null; + callBtn.disabled = false; + nameInput.disabled = false; + hangupBtn.style.display = 'none'; + micMeter.classList.remove('visible'); +} + +function setStatus(text, isOnAir) { + statusEl.classList.add('visible'); + statusText.textContent = text; + if (isOnAir) { + statusEl.classList.add('on-air'); + } else { + statusEl.classList.remove('on-air'); + } +} + +function startMicMeter(analyser) { + micMeter.classList.add('visible'); + const data = new Uint8Array(analyser.frequencyBinCount); + + function update() { + if (!analyser || !audioCtx) return; + analyser.getByteFrequencyData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) sum += data[i]; + const avg = sum / data.length; + const pct = Math.min(100, (avg / 128) * 100); + micMeterFill.style.width = pct + '%'; + requestAnimationFrame(update); + } + update(); +}