Spaces:
Runtime error
Runtime error
| <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 • 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> | |