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
+
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();
+}