| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Eburon ASR • Streaming Speech Recognition</title> |
| <style> |
| @import url("https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@300;400;600;700;800&display=swap"); |
| |
| html, |
| body { |
| font-family: "Source Sans 3", system-ui, -apple-system, Segoe UI, Roboto, sans-serif; |
| min-height: 100%; |
| background: |
| radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 28%), |
| radial-gradient(circle at top right, rgba(139, 92, 246, 0.14), transparent 24%), |
| linear-gradient(180deg, #07111f 0%, #0a1628 42%, #08111e 100%); |
| color: #e5eefb; |
| } |
| |
| .glass { |
| background: rgba(10, 18, 34, 0.72); |
| border: 1px solid rgba(148, 163, 184, 0.16); |
| box-shadow: |
| 0 10px 30px rgba(0, 0, 0, 0.28), |
| inset 0 1px 0 rgba(255, 255, 255, 0.03); |
| backdrop-filter: blur(14px); |
| -webkit-backdrop-filter: blur(14px); |
| } |
| |
| .brand-glow { |
| box-shadow: |
| 0 0 0 1px rgba(56, 189, 248, 0.15), |
| 0 0 40px rgba(56, 189, 248, 0.08), |
| 0 0 80px rgba(139, 92, 246, 0.06); |
| } |
| |
| .brand-gradient { |
| background: linear-gradient(135deg, #38bdf8 0%, #818cf8 50%, #c084fc 100%); |
| } |
| |
| .text-brand { |
| background: linear-gradient(135deg, #e0f2fe 0%, #bfdbfe 35%, #c4b5fd 100%); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| } |
| |
| .status-dot { |
| width: 0.65rem; |
| height: 0.65rem; |
| border-radius: 9999px; |
| display: inline-block; |
| } |
| |
| .scroll-soft::-webkit-scrollbar { |
| width: 10px; |
| } |
| |
| .scroll-soft::-webkit-scrollbar-track { |
| background: rgba(255, 255, 255, 0.03); |
| border-radius: 9999px; |
| } |
| |
| .scroll-soft::-webkit-scrollbar-thumb { |
| background: rgba(148, 163, 184, 0.26); |
| border-radius: 9999px; |
| } |
| </style> |
|
|
| <script src="css/tailwind-3.4.17.js"></script> |
| <script type="module"> |
| const MODEL_ID = "moshi_1b_en_fr_q4k"; |
| const WEIGHTS_URL = "https://huggingface.co/efficient-nlp/stt-1b-en_fr-quantized/resolve/main/model-q4k.gguf"; |
| const MIMI_URL = "https://huggingface.co/efficient-nlp/stt-1b-en_fr-quantized/resolve/main/mimi-pytorch-e351c8d8@125.safetensors"; |
| const TOKENIZER_URL = "https://huggingface.co/efficient-nlp/stt-1b-en_fr-quantized/resolve/main/tokenizer_en_fr_audio_8000.json"; |
| const CONFIG_URL = "https://huggingface.co/efficient-nlp/stt-1b-en_fr-quantized/resolve/main/config.json"; |
| |
| const moshiWorker = new Worker("./moshiWorker.js", { type: "module" }); |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| let audioStream = null; |
| let audioContext = null; |
| let processor = null; |
| let source = null; |
| let modelInitialized = false; |
| let pendingStart = false; |
| |
| let audioChunksProcessed = 0; |
| let sessionStartTime = 0; |
| |
| function updateStatusDiv(message) { |
| const statusEl = document.querySelector("#status-div"); |
| statusEl.textContent = message; |
| |
| const liveBadge = document.querySelector("#live-indicator"); |
| if (!liveBadge) return; |
| |
| if (message.toLowerCase().includes("listening")) { |
| liveBadge.className = "status-dot bg-emerald-400 animate-pulse"; |
| } else if (message.toLowerCase().includes("loading") || message.toLowerCase().includes("requesting")) { |
| liveBadge.className = "status-dot bg-amber-400 animate-pulse"; |
| } else if (message.toLowerCase().includes("error") || message.toLowerCase().includes("denied")) { |
| liveBadge.className = "status-dot bg-rose-400"; |
| } else { |
| liveBadge.className = "status-dot bg-sky-400"; |
| } |
| } |
| |
| function updateDiagnostics() { |
| const diagnostics = document.querySelector("#diagnostics"); |
| if (!diagnostics) return; |
| |
| const cpuCount = navigator.hardwareConcurrency || "unknown"; |
| |
| if (isRecording && sessionStartTime) { |
| const audioProcessed = audioChunksProcessed * (1024 / 24000); |
| const audioSessionDuration = (Date.now() - sessionStartTime) / 1000; |
| const realTimeFactor = audioSessionDuration > 0 ? (audioProcessed / audioSessionDuration) : 0; |
| |
| let factorColor = "text-rose-300"; |
| if (realTimeFactor >= 0.95) { |
| factorColor = "text-emerald-300"; |
| } else if (realTimeFactor >= 0.8) { |
| factorColor = "text-amber-300"; |
| } |
| |
| diagnostics.innerHTML = ` |
| <div class="grid grid-cols-1 sm:grid-cols-3 gap-3"> |
| <div class="rounded-xl bg-white/5 border border-white/10 px-4 py-3"> |
| <div class="text-[11px] uppercase tracking-[0.18em] text-slate-400">CPU Threads</div> |
| <div class="text-lg font-semibold text-slate-100">${cpuCount}</div> |
| </div> |
| <div class="rounded-xl bg-white/5 border border-white/10 px-4 py-3"> |
| <div class="text-[11px] uppercase tracking-[0.18em] text-slate-400">Real-time Factor</div> |
| <div class="text-lg font-semibold ${factorColor}">${realTimeFactor.toFixed(2)}x</div> |
| </div> |
| <div class="rounded-xl bg-white/5 border border-white/10 px-4 py-3"> |
| <div class="text-[11px] uppercase tracking-[0.18em] text-slate-400">Session Duration</div> |
| <div class="text-lg font-semibold text-slate-100">${audioSessionDuration.toFixed(1)}s</div> |
| </div> |
| </div> |
| `; |
| } else if (!sessionStartTime) { |
| diagnostics.innerHTML = ` |
| <div class="grid grid-cols-1 sm:grid-cols-3 gap-3"> |
| <div class="rounded-xl bg-white/5 border border-white/10 px-4 py-3"> |
| <div class="text-[11px] uppercase tracking-[0.18em] text-slate-400">CPU Threads</div> |
| <div class="text-lg font-semibold text-slate-100">${cpuCount}</div> |
| </div> |
| <div class="rounded-xl bg-white/5 border border-white/10 px-4 py-3"> |
| <div class="text-[11px] uppercase tracking-[0.18em] text-slate-400">Real-time Factor</div> |
| <div class="text-lg font-semibold text-slate-400">0.00x</div> |
| </div> |
| <div class="rounded-xl bg-white/5 border border-white/10 px-4 py-3"> |
| <div class="text-[11px] uppercase tracking-[0.18em] text-slate-400">Session Duration</div> |
| <div class="text-lg font-semibold text-slate-400">0.0s</div> |
| </div> |
| </div> |
| `; |
| } |
| } |
| |
| window.addEventListener("load", updateDiagnostics); |
| setInterval(updateDiagnostics, 200); |
| |
| function initializeModel() { |
| if (modelInitialized) return; |
| |
| const button = document.querySelector("#speech-button"); |
| button.disabled = true; |
| button.className = |
| "inline-flex items-center justify-center gap-2 rounded-2xl bg-slate-700/70 border border-slate-500/20 px-5 py-3 text-slate-400 font-semibold cursor-not-allowed shadow-lg"; |
| |
| moshiWorker.postMessage({ |
| command: "initialize", |
| weightsURL: WEIGHTS_URL, |
| modelID: MODEL_ID, |
| mimiURL: MIMI_URL, |
| tokenizerURL: TOKENIZER_URL, |
| configURL: CONFIG_URL, |
| }); |
| } |
| |
| moshiWorker.addEventListener("message", async (event) => { |
| const data = event.data; |
| if (data.status === "model_ready") { |
| modelInitialized = true; |
| updateStatusDiv("Model loaded • Eburon ASR is ready"); |
| |
| const button = document.querySelector("#speech-button"); |
| button.disabled = false; |
| button.className = |
| "inline-flex items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 via-indigo-500 to-violet-500 hover:opacity-95 px-5 py-3 text-white font-semibold shadow-[0_10px_30px_rgba(56,189,248,0.22)] transition"; |
| |
| if (pendingStart) { |
| pendingStart = false; |
| await startRecording(); |
| } |
| } else if (data.status === "streaming") { |
| const outputDiv = document.querySelector("#output-generation"); |
| const placeholder = document.querySelector("#output-placeholder"); |
| |
| if (placeholder) placeholder.hidden = true; |
| |
| if (outputDiv.textContent) { |
| outputDiv.textContent += " " + data.word; |
| } else { |
| outputDiv.textContent = data.word; |
| } |
| outputDiv.hidden = false; |
| } else if (data.status === "chunk_processed") { |
| audioChunksProcessed++; |
| } else if (data.status === "loading") { |
| updateStatusDiv(data.message); |
| } else if (data.error) { |
| updateStatusDiv("Error: " + data.error); |
| pendingStart = false; |
| } |
| }); |
| |
| function updateStatus(data) { |
| const { status, message, word } = data; |
| const outputDiv = document.querySelector("#output-generation"); |
| |
| if (status === "loading" || status === "decoding") { |
| updateStatusDiv(message || (status === "loading" ? "Loading..." : "Decoding...")); |
| } else if (status === "streaming") { |
| if (outputDiv.textContent) { |
| outputDiv.textContent += " " + word; |
| } else { |
| outputDiv.textContent = word; |
| } |
| outputDiv.hidden = false; |
| } else if (status === "complete") { |
| updateStatusDiv("Ready"); |
| } |
| } |
| |
| async function startMicrophone() { |
| try { |
| audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| updateStatusDiv("Microphone access granted"); |
| |
| audioContext = new AudioContext({ sampleRate: 24000 }); |
| source = audioContext.createMediaStreamSource(audioStream); |
| |
| processor = audioContext.createScriptProcessor(1024, 1, 1); |
| |
| processor.onaudioprocess = function (event) { |
| if (!isRecording || !modelInitialized) return; |
| |
| const inputBuffer = event.inputBuffer; |
| const inputData = inputBuffer.getChannelData(0); |
| |
| const audioChunk = new Float32Array(inputData); |
| moshiWorker.postMessage( |
| { |
| command: "process_audio", |
| audioData: audioChunk, |
| }, |
| [audioChunk.buffer] |
| ); |
| }; |
| |
| source.connect(processor); |
| processor.connect(audioContext.destination); |
| } catch (error) { |
| updateStatusDiv("Microphone access denied: " + error.message); |
| throw error; |
| } |
| } |
| |
| function stopMicrophone() { |
| if (processor) { |
| processor.disconnect(); |
| processor = null; |
| } |
| if (source) { |
| source.disconnect(); |
| source = null; |
| } |
| if (audioContext) { |
| audioContext.close(); |
| audioContext = null; |
| } |
| |
| if (audioStream) { |
| audioStream.getTracks().forEach((track) => track.stop()); |
| audioStream = null; |
| } |
| |
| updateStatusDiv("Microphone stopped"); |
| } |
| |
| async function startRecording() { |
| const button = document.querySelector("#speech-button"); |
| |
| try { |
| updateStatusDiv("Requesting microphone access..."); |
| await startMicrophone(); |
| |
| audioChunksProcessed = 0; |
| sessionStartTime = Date.now(); |
| |
| moshiWorker.postMessage({ command: "start_stream" }); |
| |
| isRecording = true; |
| button.textContent = "Stop Capture"; |
| button.className = |
| "inline-flex items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-rose-500 to-red-600 hover:opacity-95 px-5 py-3 text-white font-semibold shadow-[0_10px_30px_rgba(244,63,94,0.22)] transition"; |
| updateStatusDiv("Listening..."); |
| |
| document.querySelector("#output-generation").textContent = ""; |
| document.querySelector("#output-generation").hidden = true; |
| document.querySelector("#output-placeholder").hidden = true; |
| } catch (error) { |
| console.error("Error starting microphone:", error); |
| updateStatusDiv("Error: " + error.message); |
| pendingStart = false; |
| } |
| } |
| |
| document.querySelector("#speech-button").addEventListener("click", async () => { |
| const button = document.querySelector("#speech-button"); |
| |
| if (!isRecording) { |
| if (!modelInitialized) { |
| pendingStart = true; |
| initializeModel(); |
| return; |
| } |
| |
| await startRecording(); |
| } else { |
| stopMicrophone(); |
| |
| moshiWorker.postMessage({ command: "stop_stream" }); |
| |
| isRecording = false; |
| button.textContent = "Start Capture"; |
| button.className = |
| "inline-flex items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 via-indigo-500 to-violet-500 hover:opacity-95 px-5 py-3 text-white font-semibold shadow-[0_10px_30px_rgba(56,189,248,0.22)] transition"; |
| updateStatusDiv("Ready to start"); |
| } |
| }); |
| </script> |
| </head> |
|
|
| <body class="px-4 py-6 sm:px-6 lg:px-8"> |
| <main class="mx-auto max-w-6xl"> |
| <section class="glass brand-glow rounded-3xl p-6 sm:p-8 lg:p-10 overflow-hidden relative"> |
| <div class="absolute inset-0 pointer-events-none"> |
| <div class="absolute -top-24 -right-16 h-56 w-56 rounded-full bg-sky-400/10 blur-3xl"></div> |
| <div class="absolute -bottom-24 -left-12 h-64 w-64 rounded-full bg-violet-400/10 blur-3xl"></div> |
| </div> |
|
|
| <div class="relative z-10 grid grid-cols-1 lg:grid-cols-[1.25fr_0.75fr] gap-8 items-start"> |
| <div> |
| <div class="inline-flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200"> |
| <span class="h-2.5 w-2.5 rounded-full brand-gradient"></span> |
| <span class="font-semibold tracking-wide">EBURON AI</span> |
| <span class="text-slate-400">•</span> |
| <span class="text-slate-300">ASR Interface</span> |
| </div> |
|
|
| <h1 class="mt-6 text-4xl sm:text-5xl font-extrabold leading-tight text-brand"> |
| Eburon ASR |
| </h1> |
|
|
| <p class="mt-4 max-w-3xl text-base sm:text-lg text-slate-300 leading-relaxed"> |
| Real-time browser-based speech recognition with an Eburon-branded interface. |
| This experience runs locally in your browser after the model download and keeps |
| the underlying recognition pipeline unchanged. |
| </p> |
|
|
| <div class="mt-6 flex flex-wrap gap-3 text-sm"> |
| <span class="rounded-full border border-sky-400/20 bg-sky-400/10 px-4 py-2 text-sky-200"> |
| Streaming Transcription |
| </span> |
| <span class="rounded-full border border-violet-400/20 bg-violet-400/10 px-4 py-2 text-violet-200"> |
| Browser Runtime |
| </span> |
| <span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-4 py-2 text-emerald-200"> |
| Offline After Download |
| </span> |
| </div> |
| </div> |
|
|
| <div class="glass rounded-3xl p-5 border border-white/10"> |
| <div class="text-xs uppercase tracking-[0.22em] text-slate-400">System Overview</div> |
| <div class="mt-4 space-y-4"> |
| <div class="rounded-2xl bg-white/5 border border-white/10 p-4"> |
| <div class="text-sm text-slate-400">Engine</div> |
| <div class="mt-1 text-lg font-semibold text-slate-100">via WASM Runtime</div> |
| </div> |
| <div class="rounded-2xl bg-white/5 border border-white/10 p-4"> |
| <div class="text-sm text-slate-400">Execution Mode</div> |
| <div class="mt-1 text-lg font-semibold text-slate-100">On-device CPU Processing</div> |
| </div> |
| <div class="rounded-2xl bg-white/5 border border-white/10 p-4"> |
| <div class="text-sm text-slate-400">Brand Layer</div> |
| <div class="mt-1 text-lg font-semibold text-slate-100">Eburon ASR UI</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| <section class="mt-8 grid grid-cols-1 xl:grid-cols-[0.9fr_1.1fr] gap-8"> |
| <div class="space-y-8"> |
| <div class="glass rounded-3xl p-6"> |
| <div class="flex items-center justify-between gap-4 flex-wrap"> |
| <div> |
| <div class="text-xs uppercase tracking-[0.22em] text-slate-400">Capture Control</div> |
| <h2 class="mt-2 text-2xl font-bold text-slate-100">Live Microphone Session</h2> |
| <p class="mt-2 text-slate-400"> |
| Start or stop live speech capture without changing the recognition model. |
| </p> |
| </div> |
|
|
| <button |
| id="speech-button" |
| class="inline-flex items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 via-indigo-500 to-violet-500 hover:opacity-95 px-5 py-3 text-white font-semibold shadow-[0_10px_30px_rgba(56,189,248,0.22)] transition" |
| > |
| Start Capture |
| </button> |
| </div> |
|
|
| <div class="mt-6 rounded-2xl border border-white/10 bg-white/5 p-4"> |
| <div class="flex items-center gap-3 text-sm text-slate-300"> |
| <span id="live-indicator" class="status-dot bg-sky-400"></span> |
| <span class="font-semibold">System Status</span> |
| </div> |
| <div class="mt-2 text-slate-200"> |
| <span id="status-div">Click "Start Capture" to begin</span> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="glass rounded-3xl p-6"> |
| <div class="text-xs uppercase tracking-[0.22em] text-slate-400">Performance Telemetry</div> |
| <h3 class="mt-2 text-2xl font-bold text-slate-100">Runtime Diagnostics</h3> |
| <p class="mt-2 text-slate-400"> |
| Monitoring interface performance and live throughput during recognition. |
| </p> |
| <div id="diagnostics" class="mt-5"></div> |
| </div> |
| </div> |
|
|
| <div class="glass rounded-3xl p-6"> |
| <div class="flex items-center justify-between gap-4 flex-wrap"> |
| <div> |
| <div class="text-xs uppercase tracking-[0.22em] text-slate-400">Recognition Output</div> |
| <h3 class="mt-2 text-2xl font-bold text-slate-100">Live Transcription</h3> |
| </div> |
| <div class="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300"> |
| Eburon ASR Stream |
| </div> |
| </div> |
|
|
| <div class="mt-5 min-h-[320px] rounded-3xl border border-white/10 bg-[#09101c] p-5 sm:p-6 text-slate-200 shadow-inner overflow-auto scroll-soft"> |
| <p id="output-generation" class="whitespace-pre-wrap leading-8 text-lg" hidden></p> |
| <span id="output-placeholder" class="text-slate-500"> |
| Your live transcript will appear here once capture begins. |
| </span> |
| </div> |
|
|
| <div class="mt-5 grid grid-cols-1 md:grid-cols-2 gap-4"> |
| <div class="rounded-2xl bg-white/5 border border-white/10 p-4"> |
| <div class="text-sm font-semibold text-slate-200">Scope</div> |
| <p class="mt-2 text-sm text-slate-400"> |
| Interface only. Recognition model, URLs, worker, and audio pipeline remain unchanged. |
| </p> |
| </div> |
| <div class="rounded-2xl bg-white/5 border border-white/10 p-4"> |
| <div class="text-sm font-semibold text-slate-200">Deployment Feel</div> |
| <p class="mt-2 text-sm text-slate-400"> |
| Polished dark dashboard suitable for Eburon demos, client previews, or product shells. |
| </p> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| <footer class="mt-8"> |
| <div class="glass rounded-3xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"> |
| <div> |
| <div class="text-sm font-semibold text-slate-200">Eburon ASR Interface</div> |
| <p class="mt-1 text-sm text-slate-400"> |
| Premium branding layer for browser-based streaming speech recognition. |
| </p> |
| </div> |
| <div class="text-xs uppercase tracking-[0.18em] text-slate-500"> |
| Eburon AI • Speech Systems UI |
| </div> |
| </div> |
| </footer> |
| </main> |
| </body> |
| </html> |