Spaces:
Running
Running
File size: 7,940 Bytes
d0fb3e3 d636c52 d0fb3e3 d636c52 133f473 d636c52 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | // 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 };
|