Spaces:
Running
Running
| <html lang="en"> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Local AI (JS only)</title> | |
| <style> | |
| body { font-family: system-ui, sans-serif; max-width: 820px; margin: 24px auto; padding: 0 12px; } | |
| textarea { width: 100%; min-height: 140px; } | |
| button, select { padding: 10px 14px; margin: 6px 6px 0 0; } | |
| .row { margin: 16px 0; } | |
| #log { white-space: pre-wrap; font: 13px/1.4 monospace; background:#f6f6f6; padding:12px; border-radius:8px; } | |
| #out { min-height: 48px; } | |
| </style> | |
| <h2>Local AI on your device (JS only)</h2> | |
| <div class="row"> | |
| <label>Task: | |
| <select id="task"> | |
| <option value="sentiment">Sentiment (DistilBERT)</option> | |
| <option value="summarize">Summarize (T5-small)</option> | |
| <option value="whisper">Transcribe (Whisper tiny.en)</option> | |
| </select> | |
| </label> | |
| <button id="initBtn">Load model</button> | |
| </div> | |
| <div class="row" id="textRow"> | |
| <textarea id="text" placeholder="Type or paste text…"></textarea> | |
| <button id="runBtn" disabled>Run</button> | |
| </div> | |
| <div class="row" id="audioRow" style="display:none"> | |
| <button id="recBtn" disabled>🎙️ Start / Stop Recording</button> | |
| <button id="transcribeBtn" disabled>Transcribe</button> | |
| </div> | |
| <h3>Output</h3> | |
| <div id="out"></div> | |
| <h3>Log</h3> | |
| <div id="log"></div> | |
| <script type="module"> | |
| import { pipeline, read_audio } from "https://cdn.jsdelivr.net/npm/@xenova/transformers"; | |
| const $ = (id) => document.getElementById(id); | |
| const log = (s) => $('log').textContent += s + "\n"; | |
| const out = (html) => $('out').innerHTML = html; | |
| let runner = null; | |
| // --- Recording state --- | |
| let audioCtx = null, processor = null, inputNode = null, stream = null; | |
| let pcmChunks = []; // Float32 chunks | |
| let wavBlob = null; // built on stop | |
| function enableTextUI(en) { $('runBtn').disabled = !en; } | |
| function enableAudioUI(en) { $('recBtn').disabled = !en; $('transcribeBtn').disabled = true; } | |
| function toggleTaskUI() { | |
| const isWhisper = ($('task').value === 'whisper'); | |
| $('textRow').style.display = isWhisper ? 'none' : ''; | |
| $('audioRow').style.display = isWhisper ? '' : 'none'; | |
| $('log').textContent = ''; out(''); | |
| enableTextUI(false); enableAudioUI(false); | |
| } | |
| $('task').addEventListener('change', toggleTaskUI); | |
| toggleTaskUI(); | |
| // --- Robust progress (0–1 or 0–100) --- | |
| let lastPct = -1, lastTime = 0; | |
| function progressLogger(p) { | |
| let pct = null; | |
| if (p && typeof p.progress === 'number') pct = (p.progress <= 1 ? p.progress * 100 : p.progress); | |
| else if (p && p.loaded && p.total) pct = (p.loaded / p.total) * 100; | |
| if (pct == null) return; | |
| pct = Math.max(0, Math.min(100, Math.round(pct))); | |
| const now = performance.now(); | |
| if (pct !== lastPct && (now - lastTime > 120)) { log(`Download: ${pct}%`); lastPct = pct; lastTime = now; } | |
| } | |
| // --- Load model --- | |
| $('initBtn').onclick = async () => { | |
| try { | |
| $('log').textContent = ''; out(''); | |
| runner = null; enableTextUI(false); enableAudioUI(false); | |
| lastPct = -1; lastTime = 0; | |
| const task = $('task').value; | |
| log('Loading… (first time may download model to cache)'); | |
| if (task === 'sentiment') { | |
| runner = await pipeline("text-classification", | |
| "Xenova/distilbert-base-uncased-finetuned-sst-2-english", | |
| { progress_callback: progressLogger }); | |
| log('Model ready ✅'); enableTextUI(true); | |
| } else if (task === 'summarize') { | |
| runner = await pipeline("summarization", "Xenova/t5-small", | |
| { progress_callback: progressLogger }); | |
| log('Model ready ✅'); enableTextUI(true); | |
| } else { | |
| runner = await pipeline("automatic-speech-recognition", | |
| "Xenova/whisper-tiny.en", | |
| { progress_callback: progressLogger, chunk_length_s: 15, stride_length_s: 2 }); | |
| log('Model ready ✅'); enableAudioUI(true); | |
| } | |
| } catch (e) { | |
| log('Error loading model: ' + (e?.message ?? e)); | |
| } | |
| }; | |
| // --- Run text tasks --- | |
| $('runBtn').onclick = async () => { | |
| if (!runner) return log('Load a model first.'); | |
| const task = $('task').value, txt = $('text').value.trim(); | |
| if (!txt) return out('<i>Enter some text.</i>'); | |
| out('Running…'); | |
| try { | |
| if (task === 'sentiment') { | |
| const res = await runner(txt); | |
| out(`<pre>${JSON.stringify(res, null, 2)}</pre>`); | |
| } else { | |
| // T5: "summarize: " + chunking | |
| const MAX = 2000; const chunks = []; | |
| for (let i = 0; i < txt.length; i += MAX) chunks.push(txt.slice(i, i + MAX)); | |
| const parts = []; | |
| for (const c of chunks) { | |
| const r = await runner(`summarize: ${c}`, { max_new_tokens: 120 }); | |
| parts.push(Array.isArray(r) ? r[0]?.summary_text : r?.summary_text); | |
| } | |
| out(`<div><b>Summary:</b><br>${parts.join(' ')}</div>`); | |
| } | |
| } catch (e) { out(''); log('Run error: ' + (e?.message ?? e)); } | |
| }; | |
| // --- Record PCM, then encode WAV (so read_audio can decode) --- | |
| $('recBtn').onclick = async () => { | |
| try { | |
| if (processor) { | |
| // Stop recording | |
| processor.disconnect(); inputNode.disconnect(); | |
| if (stream) stream.getTracks().forEach(t => t.stop()); | |
| const rate = audioCtx.sampleRate; | |
| wavBlob = encodeWAV(pcmChunks, rate); // audio/wav | |
| // reset | |
| pcmChunks = []; | |
| if (audioCtx) { try { await audioCtx.close(); } catch(_){} } | |
| audioCtx = null; processor = null; inputNode = null; stream = null; | |
| $('recBtn').textContent = '🎙️ Start / Stop Recording'; | |
| $('transcribeBtn').disabled = false; | |
| out('Recording stopped. Tap Transcribe.'); | |
| return; | |
| } | |
| // Start | |
| stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| inputNode = audioCtx.createMediaStreamSource(stream); | |
| processor = audioCtx.createScriptProcessor(4096, 1, 1); | |
| processor.onaudioprocess = e => { | |
| const ch = e.inputBuffer.getChannelData(0); | |
| pcmChunks.push(new Float32Array(ch)); // copy | |
| }; | |
| inputNode.connect(processor); | |
| processor.connect(audioCtx.destination); | |
| $('recBtn').textContent = '⏹️ Stop'; | |
| $('transcribeBtn').disabled = true; | |
| out('Recording… speak now.'); | |
| } catch (e) { | |
| log('Mic error (use http://localhost & allow mic): ' + e.name + ' - ' + e.message); | |
| } | |
| }; | |
| function encodeWAV(chunks, sampleRate) { | |
| const length = chunks.reduce((a, b) => a + b.length, 0); | |
| const buffer = new ArrayBuffer(44 + length * 2); | |
| const view = new DataView(buffer); | |
| const write = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o+i, s.charCodeAt(i)); }; | |
| write(0, 'RIFF'); view.setUint32(4, 36 + length * 2, true); write(8, 'WAVE'); write(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); write(36, 'data'); view.setUint32(40, length * 2, true); | |
| let offset = 44; | |
| for (const chunk of chunks) for (let i = 0; i < chunk.length; i++, offset += 2) { | |
| const s = Math.max(-1, Math.min(1, chunk[i])); | |
| view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); | |
| } | |
| return new Blob([buffer], { type: 'audio/wav' }); | |
| } | |
| // --- Transcribe (read_audio(URL, 16000) → Float32Array → pipeline) --- | |
| $('transcribeBtn').onclick = async () => { | |
| if (!runner) return log('Load Whisper tiny.en first.'); | |
| if (!wavBlob) return log('No audio recorded.'); | |
| out('Transcribing…'); | |
| try { | |
| const url = URL.createObjectURL(wavBlob); | |
| const audio = await read_audio(url, 16000); // returns Float32Array at 16kHz | |
| URL.revokeObjectURL(url); | |
| const result = await runner(audio); | |
| out(`<div><b>Transcript:</b><br>${result.text}</div>`); | |
| } catch (e) { | |
| out(''); log('ASR error: ' + (e?.message ?? e)); | |
| } finally { | |
| wavBlob = null; $('transcribeBtn').disabled = true; | |
| } | |
| }; | |
| // Env info | |
| log('Secure origin required for mic: use http://localhost'); | |
| log('WebGPU available: ' + (!!navigator.gpu)); | |
| if ('deviceMemory' in navigator) log('deviceMemory (GB bucket): ' + navigator.deviceMemory); | |
| </script> | |
| </html> | |