Spaces:
Running
Running
Download app.js from CyberMax-tools/hushscribe: direct link, hf CLI and curl.
- Browser
- Download file 7.94 kB
-
https://huggingface.co/spaces/CyberMax-tools/hushscribe/resolve/main/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/hushscribe/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/hushscribe/resolve/main/app.js
7.94 kB
| // 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 }; | |