Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>nlp-asr-kit demo</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 } | |
| :root { | |
| --bg: #0f0f0f; --bg2: #1a1a1a; --bg3: #242424; | |
| --border: #2e2e2e; --text: #e4e4e4; --muted: #777; | |
| --accent: #7c6af7; --green: #5dd6a4; --red: #f97066; --amber: #f9a820; | |
| } | |
| body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; | |
| font-size:15px; line-height:1.6; min-height:100dvh; display:flex; flex-direction:column } | |
| header { padding:1rem 1.5rem; border-bottom:1px solid var(--border); | |
| display:flex; align-items:center; gap:.75rem } | |
| header h1 { font-size:1rem; font-weight:600 } | |
| .tag { font-size:11px; background:#7c6af722; color:var(--accent); | |
| border:1px solid #7c6af744; border-radius:4px; padding:2px 8px } | |
| main { flex:1; display:grid; grid-template-columns:260px 1fr; gap:1.5rem; | |
| max-width:1080px; margin:0 auto; width:100%; padding:1.5rem } | |
| .card { background:var(--bg2); border:1px solid var(--border); border-radius:10px; padding:1rem } | |
| .card h2 { font-size:11px; text-transform:uppercase; letter-spacing:.07em; | |
| color:var(--muted); margin-bottom:.75rem } | |
| .left { display:flex; flex-direction:column; gap:1rem } | |
| .right { display:flex; flex-direction:column; gap:1rem } | |
| /* Status */ | |
| .status-bar { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:.5rem } | |
| .sdot { width:6px; height:6px; border-radius:50%; background:var(--muted); flex-shrink:0 } | |
| .sdot.on { background:var(--green); animation:blink .8s ease infinite } | |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} } | |
| /* Model pills */ | |
| .model-row { display:flex; justify-content:space-between; align-items:center; font-size:13px; margin-bottom:.4rem } | |
| .pill { font-size:11px; padding:2px 8px; border-radius:20px; font-weight:500 } | |
| .pill.ok { background:#5dd6a422; color:var(--green) } | |
| .pill.loading { background:#f9a82022; color:var(--amber) } | |
| .pill.err { background:#f9706622; color:var(--red) } | |
| .pill.idle { background:#ffffff11; color:var(--muted) } | |
| /* Tasks */ | |
| .task-list { display:flex; flex-direction:column; gap:3px } | |
| .task-btn { background:transparent; border:1px solid var(--border); border-radius:7px; | |
| padding:.4rem .7rem; color:var(--text); text-align:left; cursor:pointer; | |
| font-size:12px; transition:all .15s } | |
| .task-btn:hover { border-color:var(--accent) } | |
| .task-btn.active { background:#7c6af722; border-color:var(--accent); color:var(--accent) } | |
| /* Buttons */ | |
| .btn { width:100%; padding:.65rem; background:var(--accent); color:#fff; | |
| border:none; border-radius:8px; font-size:13px; cursor:pointer; | |
| font-weight:600; transition:opacity .15s; margin-top:.5rem } | |
| .btn:disabled { opacity:.4; cursor:not-allowed } | |
| /* Record */ | |
| .record-wrap { display:flex; flex-direction:column; align-items:center; gap:.65rem } | |
| #record-btn { width:64px; height:64px; border-radius:50%; border:2px solid var(--accent); | |
| background:transparent; cursor:pointer; position:relative; transition:all .2s } | |
| #record-btn:hover:not(:disabled) { background:#7c6af711 } | |
| #record-btn:disabled { opacity:.35; cursor:not-allowed } | |
| #record-btn.rec { border-color:var(--red); animation:pulse 1.2s ease infinite } | |
| .dot { width:22px; height:22px; border-radius:50%; background:var(--accent); | |
| position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); transition:all .2s } | |
| #record-btn.rec .dot { background:var(--red); border-radius:3px; width:18px; height:18px } | |
| @keyframes pulse { 0%,100%{box-shadow:0 0 0 0 #f9706640} 50%{box-shadow:0 0 0 10px transparent} } | |
| .rec-label { font-size:12px; color:var(--muted); text-align:center } | |
| .vol-bar { height:3px; background:var(--bg3); border-radius:2px; width:100%; overflow:hidden } | |
| .vol-fill { height:100%; background:var(--green); border-radius:2px; width:0; transition:width .05s } | |
| /* Output */ | |
| .out-box { background:var(--bg2); border:1px solid var(--border); border-radius:10px; padding:1rem } | |
| .out-box h2 { font-size:11px; text-transform:uppercase; letter-spacing:.07em; | |
| color:var(--muted); margin-bottom:.6rem } | |
| .out-text { font-size:14px; white-space:pre-wrap; line-height:1.75; min-height:52px } | |
| .out-text.ph { color:var(--muted); font-style:italic } | |
| .json-out { font-family:monospace; font-size:12px; background:var(--bg3); | |
| border-radius:7px; padding:.75rem; color:#a8d8a8; overflow-x:auto } | |
| .err-box { background:#f9706611; border:1px solid #f9706633; border-radius:8px; | |
| padding:.75rem 1rem; font-size:13px; color:var(--red); | |
| font-family:monospace; margin-top:.5rem; display:none } | |
| .err-box.show { display:block } | |
| textarea { width:100%; background:var(--bg3); border:1px solid var(--border); | |
| border-radius:7px; padding:.5rem .65rem; color:var(--text); | |
| font-size:12px; font-family:inherit; resize:vertical; min-height:52px; | |
| outline:none; transition:border-color .15s } | |
| textarea:focus { border-color:var(--accent) } | |
| /* API config */ | |
| .api-input { width:100%; background:var(--bg3); border:1px solid var(--border); | |
| border-radius:7px; padding:.4rem .65rem; color:var(--text); | |
| font-size:12px; outline:none; transition:border-color .15s } | |
| .api-input:focus { border-color:var(--accent) } | |
| @media (max-width:640px) { main { grid-template-columns:1fr; padding:1rem } } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>nlp-asr-kit</h1> | |
| <span class="tag">Python API Β· Whisper + SmolLM2</span> | |
| </header> | |
| <main> | |
| <div class="left"> | |
| <!-- API Config --> | |
| <div class="card"> | |
| <h2>API endpoint</h2> | |
| <input class="api-input" id="api-url" value="http://localhost:8000" placeholder="http://your-server:8000"> | |
| <button class="btn" id="check-btn" style="margin-top:.5rem;font-size:12px">Check connection</button> | |
| <div style="margin-top:.5rem"> | |
| <div class="model-row"><span>Whisper ASR</span><span class="pill idle" id="asr-pill">β</span></div> | |
| <div class="model-row"><span>LLM</span><span class="pill idle" id="llm-pill">β</span></div> | |
| </div> | |
| </div> | |
| <!-- Task --> | |
| <div class="card"> | |
| <h2>Task</h2> | |
| <div class="task-list" id="task-list"></div> | |
| </div> | |
| <!-- Custom instruction --> | |
| <div class="card"> | |
| <h2>Custom instruction</h2> | |
| <textarea id="custom-inst" placeholder="Optional: e.g. reply in Hindi, extract only names..."></textarea> | |
| </div> | |
| <!-- Record --> | |
| <div class="card"> | |
| <h2>Record</h2> | |
| <div class="record-wrap"> | |
| <button id="record-btn" disabled><div class="dot"></div></button> | |
| <span class="rec-label" id="rec-label">Check connection first</span> | |
| <div class="vol-bar"><div class="vol-fill" id="vol-fill"></div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="right"> | |
| <div class="status-bar"> | |
| <div class="sdot" id="sdot"></div> | |
| <span id="stext">Ready</span> | |
| </div> | |
| <div class="out-box"> | |
| <h2>Transcript</h2> | |
| <div class="out-text ph" id="transcript-out">Your spoken words will appear here...</div> | |
| </div> | |
| <div class="out-box" style="flex:2"> | |
| <h2>LLM output Β· <span id="task-label" style="color:var(--accent)">β</span></h2> | |
| <div id="result-out"><div class="out-text ph">Result will appear here after recording...</div></div> | |
| <div id="run-err" class="err-box"></div> | |
| </div> | |
| <div class="card" style="font-size:12px;color:var(--muted);line-height:1.8"> | |
| <h2>Setup</h2> | |
| <b style="color:var(--text)">1. Install & run:</b><br> | |
| <code style="background:var(--bg3);padding:2px 6px;border-radius:4px;color:var(--green)">pip install -r requirements.txt</code><br> | |
| <code style="background:var(--bg3);padding:2px 6px;border-radius:4px;color:var(--green)">uvicorn app.main:app --host 0.0.0.0 --port 8000</code><br><br> | |
| <b style="color:var(--text)">2. Open this file in browser, set API URL, click Check connection.</b> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| const API_URL = () => document.getElementById('api-url').value.replace(/\/$/, '') | |
| const $ = id => document.getElementById(id) | |
| let recording = false | |
| let mediaRecorder = null | |
| let audioChunks = [] | |
| let activeTask = 'summarize' | |
| // ββ Tasks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const TASKS = [ | |
| { key: 'transcribe', label: 'Transcribe' }, | |
| { key: 'summarize', label: 'Summarize' }, | |
| { key: 'action_items', label: 'Extract action items' }, | |
| { key: 'intent', label: 'Command intent' }, | |
| { key: 'qa', label: 'Answer question' }, | |
| { key: 'correct', label: 'Grammar correction' }, | |
| { key: 'translate', label: 'Translate to English' }, | |
| { key: 'sentiment', label: 'Sentiment' }, | |
| { key: 'title', label: 'Generate title' }, | |
| ] | |
| const taskList = $('task-list') | |
| TASKS.forEach(t => { | |
| const btn = document.createElement('button') | |
| btn.className = 'task-btn' + (t.key === activeTask ? ' active' : '') | |
| btn.textContent = t.label | |
| btn.onclick = () => { | |
| activeTask = t.key | |
| taskList.querySelectorAll('.task-btn').forEach(b => b.classList.remove('active')) | |
| btn.classList.add('active') | |
| $('task-label').textContent = t.label | |
| } | |
| taskList.appendChild(btn) | |
| }) | |
| $('task-label').textContent = 'Summarize' | |
| // ββ Check connection βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| $('check-btn').onclick = async () => { | |
| setStatus('Checking connection...', true) | |
| try { | |
| const res = await fetch(`${API_URL()}/health`) | |
| const data = await res.json() | |
| if (data.models_loaded) { | |
| setPill('asr', 'ok', 'Ready β') | |
| setPill('llm', 'ok', 'Ready β') | |
| $('record-btn').disabled = false | |
| $('rec-label').textContent = 'Click to record' | |
| setStatus('Connected β models ready') | |
| } else { | |
| setPill('asr', 'loading', 'Loading...') | |
| setPill('llm', 'loading', 'Loading...') | |
| setStatus('Server found but models still loading...') | |
| } | |
| } catch (e) { | |
| setPill('asr', 'err', 'Error') | |
| setPill('llm', 'err', 'Error') | |
| setStatus('Cannot reach API β is the server running?') | |
| } | |
| } | |
| // ββ Record βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| $('record-btn').onclick = async () => { | |
| if (recording) { | |
| mediaRecorder?.stop() | |
| return | |
| } | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) | |
| // Volume meter | |
| const ctx = new AudioContext() | |
| const source = ctx.createMediaStreamSource(stream) | |
| const analyser = ctx.createAnalyser() | |
| analyser.fftSize = 512 | |
| source.connect(analyser) | |
| const buf = new Uint8Array(analyser.frequencyBinCount) | |
| const volTick = () => { | |
| if (!recording) return | |
| analyser.getByteTimeDomainData(buf) | |
| const vol = Math.max(...Array.from(buf).map(v => Math.abs(v - 128))) / 128 | |
| $('vol-fill').style.width = (vol * 100) + '%' | |
| requestAnimationFrame(volTick) | |
| } | |
| audioChunks = [] | |
| mediaRecorder = new MediaRecorder(stream) | |
| mediaRecorder.ondataavailable = e => audioChunks.push(e.data) | |
| mediaRecorder.onstop = async () => { | |
| recording = false | |
| $('record-btn').classList.remove('rec') | |
| $('rec-label').textContent = 'Processing...' | |
| $('vol-fill').style.width = '0' | |
| stream.getTracks().forEach(t => t.stop()) | |
| ctx.close() | |
| await sendAudio() | |
| } | |
| mediaRecorder.start() | |
| recording = true | |
| $('record-btn').classList.add('rec') | |
| $('rec-label').textContent = 'Recording... tap to stop' | |
| $('transcript-out').textContent = '' | |
| $('transcript-out').classList.add('ph') | |
| $('result-out').innerHTML = '<div class="out-text ph">Listening...</div>' | |
| $('run-err').classList.remove('show') | |
| setStatus('Recording...', true) | |
| volTick() | |
| // Auto-stop at 30s | |
| setTimeout(() => { if (recording) mediaRecorder?.stop() }, 30000) | |
| } catch (e) { | |
| setStatus('Microphone error: ' + e.message) | |
| } | |
| } | |
| async function sendAudio() { | |
| const blob = new Blob(audioChunks, { type: 'audio/webm' }) | |
| const form = new FormData() | |
| form.append('audio', blob, 'recording.webm') | |
| form.append('task', activeTask) | |
| form.append('custom_instruction', $('custom-inst').value.trim()) | |
| setStatus('Transcribing...', true) | |
| try { | |
| const res = await fetch(`${API_URL()}/api/transcribe`, { method: 'POST', body: form }) | |
| if (!res.ok) throw new Error(`Server error: ${res.status}`) | |
| const data = await res.json() | |
| $('transcript-out').textContent = data.transcript | |
| $('transcript-out').classList.remove('ph') | |
| renderResult(data) | |
| setStatus('Done β') | |
| } catch (e) { | |
| $('run-err').textContent = e.message | |
| $('run-err').classList.add('show') | |
| setStatus('Error β see details below') | |
| } finally { | |
| $('rec-label').textContent = 'Click to record again' | |
| } | |
| } | |
| // ββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderResult(data) { | |
| const parsed = data.parsed | |
| if (parsed?.type === 'json') { | |
| $('result-out').innerHTML = `<pre class="json-out">${esc(JSON.stringify(parsed.value, null, 2))}</pre>` | |
| } else if (parsed?.type === 'list') { | |
| const items = (parsed.value || []).map(i => `<li style="margin-bottom:.3rem">${esc(i)}</li>`).join('') | |
| $('result-out').innerHTML = `<ol style="padding-left:1.4rem;font-size:14px;line-height:1.8">${items}</ol>` | |
| } else { | |
| $('result-out').innerHTML = `<div class="out-text">${esc(data.raw || '')}</div>` | |
| } | |
| } | |
| // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setStatus(msg, active = false) { | |
| $('stext').textContent = msg | |
| $('sdot').classList.toggle('on', active) | |
| } | |
| function setPill(id, state, label) { | |
| const el = $(id + '-pill') | |
| el.className = 'pill ' + state | |
| el.textContent = label | |
| } | |
| function esc(s) { | |
| return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') | |
| } | |
| </script> | |
| </body> | |
| </html> | |