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 };