hushscribe / app.js
CyberMax-tools's picture
Rename products (unique names)
d0fb3e3 verified
Raw History Blame Contribute Delete
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 };