Add browser call-in page and update host dashboard for browser callers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -416,6 +416,11 @@ async def index():
|
|||||||
return FileResponse(frontend_dir / "index.html")
|
return FileResponse(frontend_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/call-in")
|
||||||
|
async def call_in_page():
|
||||||
|
return FileResponse(frontend_dir / "call-in.html")
|
||||||
|
|
||||||
|
|
||||||
# --- Request Models ---
|
# --- Request Models ---
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
|
|||||||
155
frontend/call-in.html
Normal file
155
frontend/call-in.html
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Call In - Luke at the Roost</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--bg-light: #252547;
|
||||||
|
--accent: #e94560;
|
||||||
|
--text: #fff;
|
||||||
|
--text-muted: #888;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.call-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.call-btn:hover { opacity: 0.9; }
|
||||||
|
.call-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.hangup-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #c0392b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hangup-btn:hover { opacity: 0.9; }
|
||||||
|
.status {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status.visible { display: block; }
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.status-text {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.on-air .status-text {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
.mic-meter {
|
||||||
|
margin-top: 16px;
|
||||||
|
height: 6px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mic-meter.visible { display: block; }
|
||||||
|
.mic-meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #2ecc71;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.1s;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Luke at the Roost</h1>
|
||||||
|
<p class="subtitle">Call in to the show</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="caller-name" placeholder="Your name" maxlength="30" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="call-btn" class="call-btn">Call In</button>
|
||||||
|
<button id="hangup-btn" class="hangup-btn">Hang Up</button>
|
||||||
|
|
||||||
|
<div id="status" class="status">
|
||||||
|
<div class="status-label">Status</div>
|
||||||
|
<div id="status-text" class="status-text">Connecting...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mic-meter" class="mic-meter">
|
||||||
|
<div id="mic-meter-fill" class="mic-meter-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/call-in.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<!-- Call Queue -->
|
<!-- Call Queue -->
|
||||||
<section class="queue-section">
|
<section class="queue-section">
|
||||||
<h2>Incoming Calls</h2>
|
<h2>Incoming Calls <a href="/call-in" target="_blank" style="font-size:0.6em;font-weight:normal;color:var(--accent);">Call-in page</a></h2>
|
||||||
<div id="call-queue" class="call-queue">
|
<div id="call-queue" class="call-queue">
|
||||||
<div class="queue-empty">No callers waiting</div>
|
<div class="queue-empty">No callers waiting</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -874,31 +874,31 @@ function renderQueue(queue) {
|
|||||||
const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||||
return `
|
return `
|
||||||
<div class="queue-item">
|
<div class="queue-item">
|
||||||
<span class="queue-phone">${caller.phone}</span>
|
<span class="queue-name">${caller.name}</span>
|
||||||
<span class="queue-wait">waiting ${waitStr}</span>
|
<span class="queue-wait">waiting ${waitStr}</span>
|
||||||
<button class="queue-take-btn" onclick="takeCall('${caller.call_sid}')">Take Call</button>
|
<button class="queue-take-btn" onclick="takeCall('${caller.caller_id}')">Take Call</button>
|
||||||
<button class="queue-drop-btn" onclick="dropCall('${caller.call_sid}')">Drop</button>
|
<button class="queue-drop-btn" onclick="dropCall('${caller.caller_id}')">Drop</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function takeCall(callSid) {
|
async function takeCall(callerId) {
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
if (data.status === 'on_air') {
|
if (data.status === 'on_air') {
|
||||||
showRealCaller(data.caller);
|
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) {
|
} catch (err) {
|
||||||
log('Failed to take call: ' + err.message);
|
log('Failed to take call: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dropCall(callSid) {
|
async function dropCall(callerId) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/queue/drop/${callSid}`, { method: 'POST' });
|
await fetch(`/api/queue/drop/${callerId}`, { method: 'POST' });
|
||||||
fetchQueue();
|
fetchQueue();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('Failed to drop call: ' + err.message);
|
log('Failed to drop call: ' + err.message);
|
||||||
@@ -932,7 +932,7 @@ function updateActiveCallIndicator() {
|
|||||||
function showRealCaller(callerInfo) {
|
function showRealCaller(callerInfo) {
|
||||||
const nameEl = document.getElementById('real-caller-name');
|
const nameEl = document.getElementById('real-caller-name');
|
||||||
const chEl = document.getElementById('real-caller-channel');
|
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}`;
|
if (chEl) chEl.textContent = `Ch ${callerInfo.channel}`;
|
||||||
|
|
||||||
document.getElementById('real-caller-info')?.classList.remove('hidden');
|
document.getElementById('real-caller-info')?.classList.remove('hidden');
|
||||||
|
|||||||
232
frontend/js/call-in.js
Normal file
232
frontend/js/call-in.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user