// Hearlark UI: decode audio locally, hand it to the worker, render and export the transcript. © 2026 CyberMax. import { toSegments, toTxt, toSrt, toVtt, toJson, clock, expectedChunks, toMono, baseName } from './format.js'; const $ = (id) => document.getElementById(id); const els = { drop: $('drop'), file: $('file'), rec: $('rec'), info: $('fileinfo'), go: $('go'), status: $('status'), bar: $('bar'), result: $('result'), segments: $('segments'), partial: $('partial'), copy: $('copy'), model: $('model'), language: $('language'), task: $('task'), device: $('device') }; let audio = null; // Float32Array, 16 kHz mono let sourceName = 'recording'; let segments = []; let busy = false; let worker = null; const files = new Map(); // model download progress per file const setStatus = (t) => { els.status.textContent = t; }; const setBar = (f) => { els.bar.style.width = `${Math.round(Math.max(0, Math.min(1, f)) * 100)}%`; }; async function decode(blob, name) { setStatus(`Decoding ${name} in your browser…`); const buf = await blob.arrayBuffer(); const ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); try { const decoded = await ctx.decodeAudioData(buf); const chans = Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)); audio = toMono(chans); } finally { ctx.close?.(); } sourceName = name; const secs = audio.length / 16000; els.info.textContent = `${name} · ${clock(secs)} long`; els.go.disabled = busy; setStatus(secs < 0.5 ? 'That file has almost no audio.' : 'Ready. Pick a model and press Transcribe.'); } function pick(file) { if (!file) return; decode(file, file.name).catch((e) => { audio = null; els.go.disabled = true; setStatus(`Your browser couldn't decode that file (${e.message || e}). Try MP3, WAV or M4A.`); }); } els.drop.addEventListener('click', () => els.file.click()); els.drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); els.file.click(); } }); els.file.addEventListener('change', () => pick(els.file.files[0])); ['dragenter', 'dragover'].forEach((t) => els.drop.addEventListener(t, (e) => { e.preventDefault(); els.drop.classList.add('over'); })); ['dragleave', 'drop'].forEach((t) => els.drop.addEventListener(t, (e) => { e.preventDefault(); els.drop.classList.remove('over'); })); els.drop.addEventListener('drop', (e) => pick(e.dataTransfer.files[0])); // Microphone recording (MediaRecorder); stays on the device like everything else. let recorder = null; els.rec.addEventListener('click', async () => { if (recorder) { recorder.stop(); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const parts = []; recorder = new MediaRecorder(stream); recorder.ondataavailable = (e) => e.data.size && parts.push(e.data); recorder.onstop = () => { stream.getTracks().forEach((t) => t.stop()); recorder = null; els.rec.textContent = '● Record from microphone'; decode(new Blob(parts, { type: parts[0]?.type || 'audio/webm' }), `recording-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}`) .catch((e) => setStatus(`Recording could not be decoded: ${e.message || e}`)); }; recorder.start(); els.rec.textContent = '■ Stop recording'; setStatus('Recording… press Stop when done.'); } catch (e) { setStatus(`Microphone not available: ${e.message || e}`); } }); function render(done) { els.result.classList.remove('hidden'); els.segments.replaceChildren(...segments.map((s) => { const li = document.createElement('li'); const t = document.createElement('time'); t.textContent = clock(s.start); const p = document.createElement('span'); p.textContent = s.text; li.append(t, p); return li; })); if (done) els.partial.textContent = ''; } function download(fmt) { if (!segments.length) return; const meta = { source: sourceName, model: els.model.value, task: els.task.value, language: els.language.value || 'auto', createdWith: 'Hearlark by CyberMax' }; const body = { txt: () => toTxt(segments), srt: () => toSrt(segments), vtt: () => toVtt(segments), json: () => toJson(segments, meta) }[fmt](); const type = { txt: 'text/plain', srt: 'application/x-subrip', vtt: 'text/vtt', json: 'application/json' }[fmt]; const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([body], { type: `${type};charset=utf-8` })); a.download = `${baseName(sourceName)}.${fmt}`; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); } document.querySelectorAll('[data-fmt]').forEach((b) => b.addEventListener('click', () => download(b.dataset.fmt))); els.copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(toTxt(segments)); els.copy.textContent = 'Copied'; setTimeout(() => { els.copy.textContent = 'Copy text'; }, 1500); } catch { setStatus('Copy blocked by the browser; use Download TXT instead.'); } }); els.go.addEventListener('click', () => { if (!audio || busy) return; busy = true; els.go.disabled = true; segments = []; files.clear(); els.segments.replaceChildren(); els.partial.textContent = ''; setBar(0); const device = els.device.value === 'auto' ? ('gpu' in navigator ? 'webgpu' : 'wasm') : els.device.value; const duration = audio.length / 16000; const total = expectedChunks(duration); const started = performance.now(); if (!worker) { worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker.onerror = (e) => { e.preventDefault?.(); setStatus(`The transcription engine could not start (${e.message || 'script failed to load'}). Check your connection, or try another browser.`); worker = null; busy = false; els.go.disabled = !audio; }; } setStatus('Loading the model… (first time only; it is cached afterwards)'); worker.onmessage = ({ data: m }) => { if (m.type === 'load' && m.file && m.total) { files.set(m.file, [m.loaded || 0, m.total]); let l = 0, t = 0; for (const [a, b] of files.values()) { l += a; t += b; } setBar(t ? l / t : 0); setStatus(`Downloading the model: ${(l / 1e6).toFixed(0)} of ${(t / 1e6).toFixed(0)} MB`); } else if (m.type === 'info') { setStatus(m.message); } else if (m.type === 'ready') { setBar(0); setStatus(`Transcribing ${clock(duration)} of audio on ${m.device === 'webgpu' ? 'your GPU (WebGPU)' : 'your CPU (WASM)'}…`); } else if (m.type === 'partial') { els.result.classList.remove('hidden'); els.partial.textContent = (els.partial.textContent + m.text).slice(-400); } else if (m.type === 'chunk') { setBar(m.done / total); setStatus(`Transcribing… part ${Math.min(m.done, total)} of about ${total}`); els.partial.textContent = ''; } else if (m.type === 'done') { segments = toSegments(m.result, duration); render(true); setBar(1); const secs = (performance.now() - started) / 1000; setStatus(segments.length ? `Done: ${segments.length} segment${segments.length === 1 ? '' : 's'} in ${secs.toFixed(0)} s on ${m.device === 'webgpu' ? 'GPU' : 'CPU'}. Download below.` : 'No speech was found in this audio.'); busy = false; els.go.disabled = false; } else if (m.type === 'error') { setStatus(`Something went wrong: ${m.message}. Try the Tiny model or the WASM engine.`); busy = false; els.go.disabled = false; } }; const copy = audio.slice(); worker.postMessage({ type: 'run', audio: copy, model: els.model.value, language: els.language.value, task: els.task.value, device }, [copy.buffer]); }); // Test/automation hook: lets a headless test inject decoded audio without a file picker. window.hushscribe = { decode: (blob, name) => decode(blob, name), segments: () => segments };