Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VoiceSwitch β Yoruba KWS Model Test</title> | |
| <style> | |
| :root { | |
| --bg: #0f172a; | |
| --surface: #1e293b; | |
| --border: #334155; | |
| --text: #e2e8f0; | |
| --text-dim: #94a3b8; | |
| --accent: #38bdf8; | |
| --accent-glow: rgba(56,189,248,0.3); | |
| --success: #4ade80; | |
| --warning: #fbbf24; | |
| --error: #f87171; | |
| --light-on: #fbbf24; | |
| --light-off: #64748b; | |
| --fan-on: #60a5fa; | |
| --fan-off: #475569; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| padding: 2rem 1rem; | |
| } | |
| .container { | |
| max-width: 720px; | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| } | |
| /* Header */ | |
| .header { | |
| text-align: center; | |
| padding: 1.5rem 0 0.5rem; | |
| } | |
| .header h1 { | |
| font-size: 1.75rem; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, var(--accent), #818cf8); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 0.25rem; | |
| } | |
| .header .subtitle { | |
| font-size: 0.875rem; | |
| color: var(--text-dim); | |
| } | |
| .header .badge { | |
| display: inline-block; | |
| margin-top: 0.5rem; | |
| padding: 0.2rem 0.75rem; | |
| border-radius: 99px; | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| background: rgba(56,189,248,0.15); | |
| color: var(--accent); | |
| } | |
| /* Status */ | |
| .status-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.75rem 1rem; | |
| background: var(--surface); | |
| border-radius: 0.75rem; | |
| border: 1px solid var(--border); | |
| font-size: 0.875rem; | |
| } | |
| .status-dot { | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .status-dot.connected { background: var(--success); box-shadow: 0 0 6px var(--success); } | |
| .status-dot.disconnected { background: var(--error); } | |
| .status-dot.checking { background: var(--warning); animation: pulse 1s infinite; } | |
| @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } | |
| /* Main card */ | |
| .card { | |
| background: var(--surface); | |
| border-radius: 1rem; | |
| border: 1px solid var(--border); | |
| padding: 1.5rem; | |
| } | |
| /* Recording area */ | |
| .record-section { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| .record-btn { | |
| width: 96px; | |
| height: 96px; | |
| border-radius: 50%; | |
| border: 4px solid var(--border); | |
| background: var(--surface); | |
| cursor: pointer; | |
| position: relative; | |
| transition: all 0.2s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .record-btn:hover { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 24px var(--accent-glow); | |
| } | |
| .record-btn.recording { | |
| border-color: var(--error); | |
| background: rgba(248,113,113,0.1); | |
| box-shadow: 0 0 32px rgba(248,113,113,0.4); | |
| animation: record-pulse 1.5s infinite; | |
| } | |
| @keyframes record-pulse { | |
| 0%,100% { box-shadow: 0 0 16px rgba(248,113,113,0.3); } | |
| 50% { box-shadow: 0 0 48px rgba(248,113,113,0.6); } | |
| } | |
| .record-btn .mic-icon { | |
| font-size: 2rem; | |
| transition: transform 0.2s; | |
| } | |
| .record-btn.recording .mic-icon { | |
| transform: scale(1.15); | |
| } | |
| .record-label { | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| color: var(--text-dim); | |
| } | |
| /* Timer */ | |
| .timer { | |
| font-size: 1.25rem; | |
| font-variant-numeric: tabular-nums; | |
| font-family: 'JetBrains Mono', 'Fira Code', monospace; | |
| color: var(--text); | |
| } | |
| /* Waveform canvas */ | |
| .waveform-container { | |
| width: 100%; | |
| height: 80px; | |
| background: rgba(0,0,0,0.3); | |
| border-radius: 0.5rem; | |
| overflow: hidden; | |
| border: 1px solid var(--border); | |
| } | |
| .waveform-container canvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* Prediction result */ | |
| .prediction { | |
| text-align: center; | |
| padding: 1rem; | |
| border-radius: 0.75rem; | |
| background: rgba(0,0,0,0.2); | |
| border: 1px solid var(--border); | |
| transition: all 0.3s ease; | |
| } | |
| .prediction.active { | |
| border-color: var(--success); | |
| box-shadow: 0 0 20px rgba(74,222,128,0.2); | |
| } | |
| .prediction .command-label { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .prediction .confidence { | |
| font-size: 2rem; | |
| font-weight: 800; | |
| font-family: 'JetBrains Mono', monospace; | |
| } | |
| .prediction .action-hint { | |
| font-size: 0.75rem; | |
| color: var(--text-dim); | |
| margin-top: 0.25rem; | |
| } | |
| /* Probability bars */ | |
| .probs { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .prob-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| } | |
| .prob-label { | |
| width: 90px; | |
| text-align: right; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| flex-shrink: 0; | |
| } | |
| .prob-bar-outer { | |
| flex: 1; | |
| height: 24px; | |
| background: rgba(0,0,0,0.3); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .prob-bar-inner { | |
| height: 100%; | |
| border-radius: 4px; | |
| transition: width 0.3s ease, background 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| padding-left: 8px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| font-family: 'JetBrains Mono', monospace; | |
| min-width: fit-content; | |
| } | |
| .prob-bar-inner.silence { background: #475569; color: #cbd5e1; } | |
| .prob-bar-inner.light-on { background: #f59e0b; color: #1a1a1a; } | |
| .prob-bar-inner.light-off { background: #64748b; color: #e2e8f0; } | |
| .prob-bar-inner.fan-on { background: #3b82f6; color: #fff; } | |
| .prob-bar-inner.fan-off { background: #334155; color: #94a3b8; } | |
| /* History */ | |
| .history { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| font-size: 0.8rem; | |
| } | |
| .history-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 0.4rem 0.5rem; | |
| border-bottom: 1px solid var(--border); | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.75rem; | |
| } | |
| .history-item:last-child { border-bottom: none; } | |
| .history-label { | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| } | |
| .history-conf { color: var(--text-dim); } | |
| /* Command list */ | |
| .command-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0.5rem; | |
| } | |
| .command-chip { | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 0.5rem; | |
| background: rgba(0,0,0,0.2); | |
| border: 1px solid var(--border); | |
| font-size: 0.8rem; | |
| text-align: center; | |
| } | |
| .command-chip .yoruba { | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| } | |
| .command-chip .english { | |
| font-size: 0.7rem; | |
| color: var(--text-dim); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <!-- Header --> | |
| <div class="header"> | |
| <h1>VoiceSwitch</h1> | |
| <p class="subtitle">Yoruba Keyword Spotting β Model Test Console</p> | |
| <span class="badge" id="model-badge">Loading model...</span> | |
| </div> | |
| <!-- Server status --> | |
| <div class="status-bar"> | |
| <span class="status-dot checking" id="status-dot"></span> | |
| <span id="status-text">Checking server connection...</span> | |
| </div> | |
| <!-- Recording card --> | |
| <div class="card"> | |
| <div class="record-section"> | |
| <div class="record-label" id="record-label">Tap to record</div> | |
| <button class="record-btn" id="record-btn" title="Record 1-second audio"> | |
| <span class="mic-icon">π€</span> | |
| </button> | |
| <div class="timer" id="timer">1.0s</div> | |
| <div class="waveform-container"> | |
| <canvas id="waveform"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Prediction card --> | |
| <div class="card"> | |
| <h3 style="margin-bottom:0.75rem; font-size:0.9rem; color:var(--text-dim);">Latest Prediction</h3> | |
| <div class="prediction" id="prediction"> | |
| <div class="command-label">β</div> | |
| <div class="confidence">β</div> | |
| <div class="action-hint">Say "Tan ina" or "Pa ina"</div> | |
| </div> | |
| </div> | |
| <!-- Probability bars --> | |
| <div class="card"> | |
| <h3 style="margin-bottom:0.75rem; font-size:0.9rem; color:var(--text-dim);">Class Probabilities</h3> | |
| <div class="probs" id="prob-bars"> | |
| <!-- Filled by JS --> | |
| </div> | |
| </div> | |
| <!-- Supported commands --> | |
| <div class="card"> | |
| <h3 style="margin-bottom:0.75rem; font-size:0.9rem; color:var(--text-dim);">Supported Commands (Bilingual)</h3> | |
| <div class="command-grid"> | |
| <div class="command-chip" style="border-color:rgba(251,191,36,0.3);"> | |
| <div class="yoruba">Tan ina</div> | |
| <div class="english">Light ON (Yoruba)</div> | |
| </div> | |
| <div class="command-chip" style="border-color:rgba(251,191,36,0.3);"> | |
| <div class="yoruba">Light on</div> | |
| <div class="english">Light ON (English)</div> | |
| </div> | |
| <div class="command-chip" style="border-color:rgba(100,116,139,0.3);"> | |
| <div class="yoruba">Pa ina</div> | |
| <div class="english">Light OFF (Yoruba)</div> | |
| </div> | |
| <div class="command-chip" style="border-color:rgba(100,116,139,0.3);"> | |
| <div class="yoruba">Light off</div> | |
| <div class="english">Light OFF (English)</div> | |
| </div> | |
| </div> | |
| <div style="margin-top:0.75rem; padding:0.5rem; border-radius:0.5rem; background:rgba(56,189,248,0.1); font-size:0.75rem; color:var(--text-dim); text-align:center;"> | |
| Trained on 1,594 clips: 845 Yoruba + 672 English TTS (17 voices) | |
| </div> | |
| </div> | |
| <!-- History --> | |
| <div class="card"> | |
| <h3 style="margin-bottom:0.75rem; font-size:0.9rem; color:var(--text-dim);">Prediction History</h3> | |
| <div class="history" id="history"> | |
| <div style="color:var(--text-dim); text-align:center; padding:1rem;">No predictions yet</div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================== | |
| // VoiceSwitch Test UI β JavaScript | |
| // ============================== | |
| const SERVER = window.location.origin; | |
| const SAMPLE_RATE = 16000; | |
| const DURATION_SEC = 1.0; | |
| const RECORD_SAMPLES = SAMPLE_RATE * DURATION_SEC; | |
| // State | |
| let isRecording = false; | |
| let audioContext = null; | |
| let mediaStream = null; | |
| let analyserNode = null; | |
| let recordingTimer = null; | |
| let recordingStart = 0; | |
| let predictionHistory = []; | |
| // DOM elements | |
| const statusDot = document.getElementById('status-dot'); | |
| const statusText = document.getElementById('status-text'); | |
| const modelBadge = document.getElementById('model-badge'); | |
| const recordBtn = document.getElementById('record-btn'); | |
| const recordLabel = document.getElementById('record-label'); | |
| const timerEl = document.getElementById('timer'); | |
| const waveformCanvas = document.getElementById('waveform'); | |
| const predictionDiv = document.getElementById('prediction'); | |
| const probBars = document.getElementById('prob-bars'); | |
| const historyDiv = document.getElementById('history'); | |
| // Initialize prob bars | |
| const LABELS = ['silence', 'unknown', 'light_on', 'light_off']; | |
| const COLORS = { silence: '#475569', unknown: '#78716c', light_on: '#f59e0b', light_off: '#64748b' }; | |
| function initProbBars() { | |
| probBars.innerHTML = LABELS.map(label => ` | |
| <div class="prob-row"> | |
| <span class="prob-label">${label.replace('_',' ')}</span> | |
| <div class="prob-bar-outer"> | |
| <div class="prob-bar-inner ${label}" id="bar-${label}" style="width:0%">0%</div> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| initProbBars(); | |
| // Waveform visualization | |
| let waveformData = new Float32Array(RECORD_SAMPLES); | |
| let waveformIndex = 0; | |
| function drawWaveform() { | |
| const canvas = waveformCanvas; | |
| const ctx = canvas.getContext('2d'); | |
| const w = canvas.parentElement.clientWidth; | |
| const h = 80; | |
| canvas.width = w * 2; | |
| canvas.height = h * 2; | |
| ctx.scale(2, 2); | |
| ctx.fillStyle = 'rgba(15,23,42,0.8)'; | |
| ctx.fillRect(0, 0, w, h); | |
| if (waveformIndex === 0) { | |
| // Draw idle line | |
| ctx.strokeStyle = '#334155'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, h/2); | |
| ctx.lineTo(w, h/2); | |
| ctx.stroke(); | |
| return; | |
| } | |
| // Draw waveform | |
| ctx.strokeStyle = '#38bdf8'; | |
| ctx.lineWidth = 1.5; | |
| ctx.shadowColor = 'rgba(56,189,248,0.5)'; | |
| ctx.shadowBlur = 4; | |
| ctx.beginPath(); | |
| const samples = waveformIndex; | |
| const step = Math.max(1, Math.floor(samples / w)); | |
| for (let i = 0; i < w; i++) { | |
| const srcIdx = Math.floor(i * step); | |
| const val = waveformData[srcIdx] || 0; | |
| const x = i; | |
| const y = (h / 2) + (val * h * 0.45); | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| } | |
| drawWaveform(); | |
| // Audio setup | |
| async function setupAudio() { | |
| if (audioContext) return true; | |
| try { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| sampleRate: SAMPLE_RATE, | |
| channelCount: 1, | |
| echoCancellation: false, | |
| noiseSuppression: false, | |
| autoGainControl: false, | |
| } | |
| }); | |
| const source = audioContext.createMediaStreamSource(mediaStream); | |
| analyserNode = audioContext.createAnalyser(); | |
| analyserNode.fftSize = 256; | |
| analyserNode.smoothingTimeConstant = 0; | |
| source.connect(analyserNode); | |
| console.log(`Audio setup: ${audioContext.sampleRate}Hz`); | |
| return true; | |
| } catch (e) { | |
| console.error('Audio setup failed:', e); | |
| alert('Microphone access required. Please allow microphone access and reload.'); | |
| return false; | |
| } | |
| } | |
| // Recording | |
| let audioChunks = []; | |
| let mediaRecorder = null; | |
| async function startRecording() { | |
| if (!await setupAudio()) return; | |
| waveformIndex = 0; | |
| waveformData = new Float32Array(RECORD_SAMPLES); | |
| audioChunks = []; | |
| // Use MediaRecorder for WAV capture | |
| mediaRecorder = new MediaRecorder(mediaStream, { | |
| mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=pcm') | |
| ? 'audio/webm;codecs=pcm' : 'audio/webm' | |
| }); | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) audioChunks.push(e.data); | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| sendAudioForPrediction(blob); | |
| }; | |
| mediaRecorder.start(100); // Collect data every 100ms | |
| recordingStart = Date.now(); | |
| isRecording = true; | |
| recordBtn.classList.add('recording'); | |
| recordLabel.textContent = 'Recording...'; | |
| timerEl.textContent = '1.0s'; | |
| // Visual countdown | |
| recordingTimer = setInterval(() => { | |
| const elapsed = (Date.now() - recordingStart) / 1000; | |
| const remaining = Math.max(0, DURATION_SEC - elapsed); | |
| timerEl.textContent = remaining.toFixed(1) + 's'; | |
| // Update waveform preview from analyser | |
| if (analyserNode) { | |
| const freqData = new Uint8Array(analyserNode.frequencyBinCount); | |
| analyserNode.getByteTimeDomainData(freqData); | |
| for (let i = 0; i < Math.min(freqData.length, RECORD_SAMPLES - waveformIndex); i++) { | |
| waveformData[waveformIndex + i] = (freqData[i] - 128) / 128; | |
| } | |
| waveformIndex = Math.min(RECORD_SAMPLES, waveformIndex + freqData.length); | |
| drawWaveform(); | |
| } | |
| if (elapsed >= DURATION_SEC) { | |
| stopRecording(); | |
| } | |
| }, 50); | |
| } | |
| function stopRecording() { | |
| if (!isRecording) return; | |
| clearInterval(recordingTimer); | |
| isRecording = false; | |
| recordBtn.classList.remove('recording'); | |
| recordLabel.textContent = 'Processing...'; | |
| timerEl.textContent = 'β'; | |
| if (mediaRecorder && mediaRecorder.state === 'recording') { | |
| mediaRecorder.stop(); | |
| } | |
| } | |
| recordBtn.addEventListener('mousedown', startRecording); | |
| recordBtn.addEventListener('mouseup', stopRecording); | |
| recordBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); }); | |
| // Touch support | |
| recordBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); }); | |
| recordBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); }); | |
| // Keyboard: hold space to record | |
| document.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space' && !isRecording && document.activeElement === document.body) { | |
| e.preventDefault(); | |
| startRecording(); | |
| } | |
| }); | |
| document.addEventListener('keyup', (e) => { | |
| if (e.code === 'Space' && isRecording) { | |
| e.preventDefault(); | |
| stopRecording(); | |
| } | |
| }); | |
| // Send audio to server | |
| async function sendAudioForPrediction(blob) { | |
| recordLabel.textContent = 'Predicting...'; | |
| // Convert webm to WAV using AudioContext | |
| try { | |
| const arrayBuffer = await blob.arrayBuffer(); | |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); | |
| // Get mono audio data | |
| const channelData = audioBuffer.getChannelData(0); | |
| // Resample to 16kHz if needed | |
| let samples; | |
| if (audioBuffer.sampleRate !== SAMPLE_RATE) { | |
| samples = resample(channelData, audioBuffer.sampleRate, SAMPLE_RATE); | |
| } else { | |
| samples = channelData; | |
| } | |
| // Convert to WAV | |
| const wavBlob = encodeWAV(samples, SAMPLE_RATE); | |
| // Send to server | |
| const formData = new FormData(); | |
| formData.append('audio', wavBlob, 'recording.wav'); | |
| const response = await fetch(`${SERVER}/predict`, { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Server error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| displayPrediction(result); | |
| // Also draw the recorded waveform | |
| waveformData = new Float32Array(RECORD_SAMPLES); | |
| const n = Math.min(samples.length, RECORD_SAMPLES); | |
| waveformData.set(samples.slice(0, n)); | |
| waveformIndex = n; | |
| drawWaveform(); | |
| recordLabel.textContent = 'Tap to record'; | |
| } catch (err) { | |
| console.error('Prediction error:', err); | |
| recordLabel.textContent = 'Error β tap to retry'; | |
| predictionDiv.innerHTML = `<div class="command-label" style="color:var(--error)">Error</div> | |
| <div class="action-hint">${err.message}</div>`; | |
| } | |
| } | |
| // Display prediction | |
| function displayPrediction(result) { | |
| const { label, confidence, probabilities } = result; | |
| // Update main prediction | |
| const colorClass = label.replace('_', '-'); | |
| predictionDiv.className = `prediction ${label !== 'silence' && label !== 'unknown' ? 'active' : ''}`; | |
| predictionDiv.innerHTML = ` | |
| <div class="command-label" style="color:${COLORS[label] || '#e2e8f0'}">${label.replace('_', ' ')}</div> | |
| <div class="confidence">${(confidence * 100).toFixed(1)}%</div> | |
| <div class="action-hint">${getActionHint(label)}</div> | |
| `; | |
| // Update probability bars | |
| for (const lbl of LABELS) { | |
| const prob = (probabilities[lbl] || 0) * 100; | |
| const bar = document.getElementById(`bar-${lbl}`); | |
| if (bar) { | |
| bar.style.width = Math.max(prob, 2) + '%'; | |
| bar.textContent = prob >= 5 ? prob.toFixed(1) + '%' : ''; | |
| } | |
| } | |
| // Add to history | |
| const now = new Date(); | |
| const timeStr = now.toLocaleTimeString(); | |
| predictionHistory.unshift({ time: timeStr, label, confidence }); | |
| if (predictionHistory.length > 20) predictionHistory.pop(); | |
| historyDiv.innerHTML = predictionHistory.map((h, i) => ` | |
| <div class="history-item" style="${i === 0 ? 'background:rgba(56,189,248,0.1);' : ''}"> | |
| <span>${h.time}</span> | |
| <span class="history-label" style="color:${COLORS[h.label] || '#e2e8f0'}">${h.label.replace('_', ' ')}</span> | |
| <span class="history-conf">${(h.confidence * 100).toFixed(1)}%</span> | |
| </div> | |
| `).join(''); | |
| } | |
| function getActionHint(label) { | |
| switch (label) { | |
| case 'light_on': return 'π‘ Relay β LIGHT ON'; | |
| case 'light_off': return 'π Relay β LIGHT OFF'; | |
| case 'silence': return 'π€« No speech detected'; | |
| case 'unknown': return 'β Unrecognized speech'; | |
| default: return ''; | |
| } | |
| } | |
| // WAV encoding | |
| function encodeWAV(samples, sampleRate) { | |
| const buffer = new ArrayBuffer(44 + samples.length * 2); | |
| const view = new DataView(buffer); | |
| function writeString(offset, str) { | |
| for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)); | |
| } | |
| writeString(0, 'RIFF'); | |
| view.setUint32(4, 36 + samples.length * 2, true); | |
| writeString(8, 'WAVE'); | |
| writeString(12, 'fmt '); | |
| view.setUint32(16, 16, true); | |
| view.setUint16(20, 1, true); | |
| view.setUint16(22, 1, true); | |
| view.setUint32(24, sampleRate, true); | |
| view.setUint32(28, sampleRate * 2, true); | |
| view.setUint16(32, 2, true); | |
| view.setUint16(34, 16, true); | |
| writeString(36, 'data'); | |
| view.setUint32(40, samples.length * 2, true); | |
| for (let i = 0; i < samples.length; i++) { | |
| const s = Math.max(-1, Math.min(1, samples[i])); | |
| view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true); | |
| } | |
| return new Blob([buffer], { type: 'audio/wav' }); | |
| } | |
| // Simple resampler | |
| function resample(audio, fromRate, toRate) { | |
| const ratio = fromRate / toRate; | |
| const newLength = Math.round(audio.length / ratio); | |
| const result = new Float32Array(newLength); | |
| for (let i = 0; i < newLength; i++) { | |
| const srcIndex = i * ratio; | |
| const srcFloor = Math.floor(srcIndex); | |
| const srcCeil = Math.min(srcFloor + 1, audio.length - 1); | |
| const t = srcIndex - srcFloor; | |
| result[i] = audio[srcFloor] * (1 - t) + audio[srcCeil] * t; | |
| } | |
| return result; | |
| } | |
| // Check server health on load | |
| async function checkHealth() { | |
| try { | |
| const resp = await fetch(`${SERVER}/health`); | |
| const data = await resp.json(); | |
| statusDot.className = 'status-dot connected'; | |
| statusText.textContent = `Server connected β Model: ${data.model} (${data.params.toLocaleString()} params)`; | |
| modelBadge.textContent = `${data.params.toLocaleString()} params Β· ${data.labels.length} classes`; | |
| } catch (e) { | |
| statusDot.className = 'status-dot disconnected'; | |
| statusText.textContent = 'Server not connected β start with: python test_server.py'; | |
| modelBadge.textContent = 'Offline'; | |
| // Retry in 3s | |
| setTimeout(checkHealth, 3000); | |
| } | |
| } | |
| checkHealth(); | |
| </script> | |
| </body> | |
| </html> | |