wasm-streaming-speech / index.html
eburondeveloper's picture
Update index.html
9421c6d verified
Raw
History Blame Contribute Delete
21 kB
<!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>