gnumanth's picture
FastAPI + WebSocket streaming with newspaper UI
ea31d8c verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Daily Transcript</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Source+Serif+Pro:wght@400;600&family=IBM+Plex+Mono&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f5f0;
--text: #1a1a1a;
--text-light: #666;
--border: #1a1a1a;
--accent: #1a1a1a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Source Serif Pro', Georgia, serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
/* Newspaper Header */
.masthead {
text-align: center;
padding: 30px 20px 20px;
border-bottom: 3px double var(--border);
margin-bottom: 20px;
}
.masthead h1 {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(2.5rem, 8vw, 4.5rem);
font-weight: 900;
letter-spacing: -0.02em;
text-transform: uppercase;
margin-bottom: 5px;
}
.masthead .tagline {
font-style: italic;
color: var(--text-light);
font-size: 1rem;
margin-bottom: 10px;
}
.masthead .edition {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.75rem;
color: var(--text-light);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 8px 0;
margin-top: 15px;
display: flex;
justify-content: space-between;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* Main Content */
.container {
max-width: 800px;
margin: 0 auto;
padding: 0 20px 40px;
}
/* Status Bar */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ccc;
border: 1px solid var(--border);
}
.status-dot.connected {
background: #1a1a1a;
animation: pulse 2s infinite;
}
.status-dot.recording {
background: #1a1a1a;
animation: pulse 0.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Transcript Area */
.transcript-section {
margin-bottom: 40px;
}
.section-header {
font-family: 'Playfair Display', Georgia, serif;
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
border-bottom: 2px solid var(--border);
padding-bottom: 5px;
margin-bottom: 20px;
}
.transcript-box {
min-height: 200px;
padding: 30px;
background: #fff;
border: 1px solid var(--border);
position: relative;
}
.transcript-box::before {
content: '"';
font-family: 'Playfair Display', Georgia, serif;
font-size: 4rem;
position: absolute;
top: 10px;
left: 20px;
color: #ddd;
line-height: 1;
}
.transcript-text {
font-family: 'Source Serif Pro', Georgia, serif;
font-size: 1.5rem;
line-height: 1.8;
text-align: justify;
hyphens: auto;
padding-left: 40px;
}
.transcript-text.placeholder {
color: var(--text-light);
font-style: italic;
}
.transcript-text .cursor {
display: inline-block;
width: 2px;
height: 1.2em;
background: var(--text);
margin-left: 2px;
animation: blink 1s infinite;
vertical-align: text-bottom;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Controls */
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 30px;
}
.btn {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.85rem;
padding: 15px 40px;
border: 2px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.2s ease;
}
.btn:hover {
background: var(--text);
color: var(--bg);
}
.btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn.primary {
background: var(--text);
color: var(--bg);
}
.btn.primary:hover {
background: var(--bg);
color: var(--text);
}
.btn.recording {
background: var(--text);
color: var(--bg);
animation: pulse 0.5s infinite;
}
/* Footer */
.footer {
text-align: center;
padding: 20px;
border-top: 1px solid var(--border);
margin-top: 40px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.75rem;
color: var(--text-light);
}
/* Latency display */
.latency {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
color: var(--text-light);
text-align: right;
margin-top: 10px;
}
/* Responsive */
@media (max-width: 600px) {
.masthead h1 {
font-size: 2rem;
}
.transcript-text {
font-size: 1.2rem;
padding-left: 30px;
}
.transcript-box::before {
font-size: 3rem;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body>
<header class="masthead">
<h1>The Daily Transcript</h1>
<p class="tagline">All the Words That's Fit to Transcribe</p>
<div class="edition">
<span id="date"></span>
<span id="session-id">Connecting...</span>
<span id="time"></span>
</div>
</header>
<main class="container">
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Connecting...</span>
</div>
<div id="latency-display"></div>
</div>
<section class="transcript-section">
<h2 class="section-header">Live Transcription</h2>
<div class="transcript-box">
<p class="transcript-text placeholder" id="transcript">
Press the button below to begin recording. Your words will appear here as you speak.
</p>
</div>
<div class="latency" id="latency-info"></div>
</section>
<div class="controls">
<button class="btn primary" id="record-btn" disabled>Start Recording</button>
<button class="btn" id="clear-btn">Clear</button>
</div>
<footer class="footer">
<p>Powered by NVIDIA Nemotron ASR &bull; Real-time Speech Recognition</p>
</footer>
</main>
<script>
// Elements
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const transcriptEl = document.getElementById('transcript');
const recordBtn = document.getElementById('record-btn');
const clearBtn = document.getElementById('clear-btn');
const latencyInfo = document.getElementById('latency-info');
const sessionIdEl = document.getElementById('session-id');
const dateEl = document.getElementById('date');
const timeEl = document.getElementById('time');
const latencyDisplay = document.getElementById('latency-display');
// State
let ws = null;
let audioContext = null;
let mediaStream = null;
let processor = null;
let isRecording = false;
let currentTranscript = '';
// Update date/time
function updateDateTime() {
const now = new Date();
dateEl.textContent = now.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
timeEl.textContent = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
updateDateTime();
setInterval(updateDateTime, 1000);
// Connect WebSocket
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws/transcribe`);
ws.onopen = () => {
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'ready':
statusDot.className = 'status-dot connected';
statusText.textContent = 'Ready';
sessionIdEl.textContent = `Session: ${data.session_id}`;
recordBtn.disabled = false;
break;
case 'transcript':
currentTranscript = data.text;
updateTranscript();
if (data.latency_ms) {
latencyDisplay.textContent = `${Math.round(data.latency_ms)}ms`;
}
break;
case 'error':
statusText.textContent = `Error: ${data.message}`;
break;
case 'reset_ack':
currentTranscript = '';
updateTranscript();
break;
}
};
ws.onclose = () => {
statusDot.className = 'status-dot';
statusText.textContent = 'Disconnected';
recordBtn.disabled = true;
// Reconnect after 2 seconds
setTimeout(connect, 2000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
// Update transcript display
function updateTranscript() {
if (currentTranscript) {
transcriptEl.className = 'transcript-text';
transcriptEl.innerHTML = currentTranscript + (isRecording ? '<span class="cursor"></span>' : '');
} else {
transcriptEl.className = 'transcript-text placeholder';
transcriptEl.textContent = 'Press the button below to begin recording. Your words will appear here as you speak.';
}
}
// Start recording
async function startRecording() {
try {
// Get microphone access
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
}
});
// Create audio context
audioContext = new AudioContext({ sampleRate: 16000 });
const source = audioContext.createMediaStreamSource(mediaStream);
// Create script processor for capturing audio
processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
if (ws && ws.readyState === WebSocket.OPEN) {
const inputData = e.inputBuffer.getChannelData(0);
// Convert float32 to int16
const int16Data = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
int16Data[i] = Math.max(-32768, Math.min(32767, inputData[i] * 32768));
}
ws.send(int16Data.buffer);
}
};
source.connect(processor);
processor.connect(audioContext.destination);
isRecording = true;
recordBtn.textContent = 'Stop Recording';
recordBtn.className = 'btn recording';
statusDot.className = 'status-dot recording';
statusText.textContent = 'Recording...';
updateTranscript();
} catch (error) {
console.error('Error starting recording:', error);
statusText.textContent = 'Microphone access denied';
}
}
// Stop recording
function stopRecording() {
if (processor) {
processor.disconnect();
processor = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
isRecording = false;
recordBtn.textContent = 'Start Recording';
recordBtn.className = 'btn primary';
statusDot.className = 'status-dot connected';
statusText.textContent = 'Ready';
updateTranscript();
}
// Clear transcript
function clearTranscript() {
currentTranscript = '';
updateTranscript();
latencyDisplay.textContent = '';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reset' }));
}
}
// Event listeners
recordBtn.addEventListener('click', () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
});
clearBtn.addEventListener('click', clearTranscript);
// Start connection
connect();
</script>
</body>
</html>