voiceswitch / test_ui.html
Jaykay73's picture
Upload 7 files
4e181c9 verified
Raw
History Blame Contribute Delete
21.8 kB
<!DOCTYPE html>
<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>