CyberMax-tools commited on
Commit
6b93540
·
verified ·
1 Parent(s): 0942eaf

Deploy hushscribe

Browse files
Files changed (2) hide show
  1. app.js +50 -12
  2. index.html +38 -13
app.js CHANGED
@@ -4,15 +4,28 @@ import { toSegments, toTxt, toSrt, toVtt, toJson, clock, expectedChunks, toMono,
4
  const $ = (id) => document.getElementById(id);
5
  const els = { drop: $('drop'), file: $('file'), rec: $('rec'), info: $('fileinfo'), go: $('go'), status: $('status'), bar: $('bar'),
6
  result: $('result'), segments: $('segments'), partial: $('partial'), copy: $('copy'),
 
7
  model: $('model'), language: $('language'), task: $('task'), device: $('device') };
8
 
9
  let audio = null; // Float32Array, 16 kHz mono
10
- let sourceName = 'recording';
11
- let segments = [];
 
 
 
 
 
 
 
12
  let busy = false;
13
  let worker = null;
14
  const files = new Map(); // model download progress per file
15
 
 
 
 
 
 
16
  const setStatus = (t) => { els.status.textContent = t; };
17
  const setBar = (f) => { els.bar.style.width = `${Math.round(Math.max(0, Math.min(1, f)) * 100)}%`; };
18
 
@@ -25,7 +38,7 @@ async function decode(blob, name) {
25
  const chans = Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i));
26
  audio = toMono(chans);
27
  } finally { ctx.close?.(); }
28
- sourceName = name;
29
  const secs = audio.length / 16000;
30
  els.info.textContent = `${name} · ${clock(secs)} long`;
31
  els.go.disabled = busy;
@@ -37,8 +50,9 @@ function pick(file) {
37
  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.`); });
38
  }
39
 
 
40
  els.drop.addEventListener('click', () => els.file.click());
41
- els.drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); els.file.click(); } });
42
  els.file.addEventListener('change', () => pick(els.file.files[0]));
43
  ['dragenter', 'dragover'].forEach((t) => els.drop.addEventListener(t, (e) => { e.preventDefault(); els.drop.classList.add('over'); }));
44
  ['dragleave', 'drop'].forEach((t) => els.drop.addEventListener(t, (e) => { e.preventDefault(); els.drop.classList.remove('over'); }));
@@ -66,22 +80,47 @@ els.rec.addEventListener('click', async () => {
66
  } catch (e) { setStatus(`Microphone not available: ${e.message || e}`); }
67
  });
68
 
 
 
 
 
 
69
  function render(done) {
70
- els.result.classList.remove('hidden');
71
- els.segments.replaceChildren(...segments.map((s) => {
 
 
72
  const li = document.createElement('li');
 
73
  const t = document.createElement('time'); t.textContent = clock(s.start);
74
  const p = document.createElement('span'); p.textContent = s.text;
 
 
 
75
  li.append(t, p);
76
  return li;
77
  }));
 
78
  if (done) els.partial.textContent = '';
79
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  function download(fmt) {
82
  if (!segments.length) return;
83
  const meta = { source: sourceName, model: els.model.value, task: els.task.value, language: els.language.value || 'auto', createdWith: 'Hearlark by CyberMax' };
84
- const body = { txt: () => toTxt(segments), srt: () => toSrt(segments), vtt: () => toVtt(segments), json: () => toJson(segments, meta) }[fmt]();
85
  const type = { txt: 'text/plain', srt: 'application/x-subrip', vtt: 'text/vtt', json: 'application/json' }[fmt];
86
  const a = document.createElement('a');
87
  a.href = URL.createObjectURL(new Blob([body], { type: `${type};charset=utf-8` }));
@@ -91,14 +130,14 @@ function download(fmt) {
91
  }
92
  document.querySelectorAll('[data-fmt]').forEach((b) => b.addEventListener('click', () => download(b.dataset.fmt)));
93
  els.copy.addEventListener('click', async () => {
94
- try { await navigator.clipboard.writeText(toTxt(segments)); els.copy.textContent = 'Copied'; setTimeout(() => { els.copy.textContent = 'Copy text'; }, 1500); }
95
  catch { setStatus('Copy blocked by the browser; use Download TXT instead.'); }
96
  });
97
 
98
  els.go.addEventListener('click', () => {
99
  if (!audio || busy) return;
100
- busy = true; els.go.disabled = true; segments = []; files.clear();
101
- els.segments.replaceChildren(); els.partial.textContent = ''; setBar(0);
102
  const device = els.device.value === 'auto' ? ('gpu' in navigator ? 'webgpu' : 'wasm') : els.device.value;
103
  const duration = audio.length / 16000;
104
  const total = expectedChunks(duration);
@@ -124,14 +163,13 @@ els.go.addEventListener('click', () => {
124
  setBar(0);
125
  setStatus(`Transcribing ${clock(duration)} of audio on ${m.device === 'webgpu' ? 'your GPU (WebGPU)' : 'your CPU (WASM)'}…`);
126
  } else if (m.type === 'partial') {
127
- els.result.classList.remove('hidden');
128
  els.partial.textContent = (els.partial.textContent + m.text).slice(-400);
129
  } else if (m.type === 'chunk') {
130
  setBar(m.done / total);
131
  setStatus(`Transcribing… part ${Math.min(m.done, total)} of about ${total}`);
132
  els.partial.textContent = '';
133
  } else if (m.type === 'done') {
134
- segments = toSegments(m.result, duration);
135
  render(true); setBar(1);
136
  const secs = (performance.now() - started) / 1000;
137
  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.');
 
4
  const $ = (id) => document.getElementById(id);
5
  const els = { drop: $('drop'), file: $('file'), rec: $('rec'), info: $('fileinfo'), go: $('go'), status: $('status'), bar: $('bar'),
6
  result: $('result'), segments: $('segments'), partial: $('partial'), copy: $('copy'),
7
+ choose: $('choose'), find: $('find'), ts: $('ts'), reset: $('reset'), tag: $('tag'), stats: $('stats'), hint: $('edithint'),
8
  model: $('model'), language: $('language'), task: $('task'), device: $('device') };
9
 
10
  let audio = null; // Float32Array, 16 kHz mono
11
+ let sourceName = 'hearlark-example';
12
+ const EXAMPLE = [
13
+ { start: 0, end: 4.2, text: 'Thanks everyone for joining. Today we are looking at the launch plan for next month.' },
14
+ { start: 4.2, end: 9.6, text: 'First, the new pricing page goes live on Tuesday, and Priya will check the checkout links.' },
15
+ { start: 9.6, end: 15.1, text: 'Second, support needs the updated help articles by Friday so the team can answer questions.' },
16
+ { start: 15.1, end: 20, text: 'Last, we agreed to review the numbers together on the first Monday of November.' },
17
+ ];
18
+ let segments = EXAMPLE.map((s) => ({ ...s }));
19
+ let isExample = true;
20
  let busy = false;
21
  let worker = null;
22
  const files = new Map(); // model download progress per file
23
 
24
+ const KEY = 'hearlark-settings';
25
+ try { const s = JSON.parse(localStorage.getItem(KEY) || '{}'); for (const k of ['model', 'language', 'task', 'device']) if (s[k] && [...els[k].options].some((o) => o.value === s[k])) els[k].value = s[k]; } catch {}
26
+ const saveSettings = () => { try { localStorage.setItem(KEY, JSON.stringify({ model: els.model.value, language: els.language.value, task: els.task.value, device: els.device.value })); } catch {} };
27
+ ['model', 'language', 'task', 'device'].forEach((k) => els[k].addEventListener('change', saveSettings));
28
+
29
  const setStatus = (t) => { els.status.textContent = t; };
30
  const setBar = (f) => { els.bar.style.width = `${Math.round(Math.max(0, Math.min(1, f)) * 100)}%`; };
31
 
 
38
  const chans = Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i));
39
  audio = toMono(chans);
40
  } finally { ctx.close?.(); }
41
+ sourceName = name; if (isExample) { segments = []; isExample = false; render(false); }
42
  const secs = audio.length / 16000;
43
  els.info.textContent = `${name} · ${clock(secs)} long`;
44
  els.go.disabled = busy;
 
50
  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.`); });
51
  }
52
 
53
+ els.choose.addEventListener('click', (e) => { e.stopPropagation(); els.file.click(); });
54
  els.drop.addEventListener('click', () => els.file.click());
55
+ els.drop.addEventListener('keydown', (e) => { if (e.target === els.drop && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); els.file.click(); } });
56
  els.file.addEventListener('change', () => pick(els.file.files[0]));
57
  ['dragenter', 'dragover'].forEach((t) => els.drop.addEventListener(t, (e) => { e.preventDefault(); els.drop.classList.add('over'); }));
58
  ['dragleave', 'drop'].forEach((t) => els.drop.addEventListener(t, (e) => { e.preventDefault(); els.drop.classList.remove('over'); }));
 
80
  } catch (e) { setStatus(`Microphone not available: ${e.message || e}`); }
81
  });
82
 
83
+ function stats() {
84
+ const words = segments.reduce((n, s) => n + (s.text.trim() ? s.text.trim().split(/\s+/).length : 0), 0);
85
+ els.stats.textContent = segments.length ? `${segments.length} line${segments.length === 1 ? '' : 's'} · ${words} words · ${clock(segments[segments.length - 1].end)}` : '';
86
+ }
87
+
88
  function render(done) {
89
+ els.tag.textContent = isExample ? 'Example transcript (not your audio)' : 'Your transcript';
90
+ els.hint.hidden = !segments.length;
91
+ const q = els.find.value.trim().toLowerCase();
92
+ els.segments.replaceChildren(...segments.map((s, i) => {
93
  const li = document.createElement('li');
94
+ li.hidden = !!q && !s.text.toLowerCase().includes(q);
95
  const t = document.createElement('time'); t.textContent = clock(s.start);
96
  const p = document.createElement('span'); p.textContent = s.text;
97
+ p.contentEditable = 'plaintext-only'; p.spellcheck = true; p.tabIndex = 0;
98
+ p.setAttribute('role', 'textbox'); p.setAttribute('aria-label', `Transcript line ${i + 1} at ${clock(s.start)}`);
99
+ p.addEventListener('input', () => { segments[i].text = p.textContent; stats(); });
100
  li.append(t, p);
101
  return li;
102
  }));
103
+ stats();
104
  if (done) els.partial.textContent = '';
105
  }
106
+ els.find.addEventListener('input', () => render(false));
107
+ render(false);
108
+
109
+ const plainText = () => toTxt(segments, { timestamps: els.ts.checked });
110
+
111
+ function startOver() {
112
+ audio = null; busy = false; segments = EXAMPLE.map((s) => ({ ...s })); isExample = true; sourceName = 'hearlark-example';
113
+ els.go.disabled = true; els.info.textContent = ''; els.find.value = ''; els.file.value = ''; setBar(0);
114
+ setStatus('Choose a file to start, or try the example below.');
115
+ render(true);
116
+ }
117
+ els.reset.addEventListener('click', startOver);
118
+ document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); els.go.click(); } });
119
 
120
  function download(fmt) {
121
  if (!segments.length) return;
122
  const meta = { source: sourceName, model: els.model.value, task: els.task.value, language: els.language.value || 'auto', createdWith: 'Hearlark by CyberMax' };
123
+ const body = { txt: () => plainText(), srt: () => toSrt(segments), vtt: () => toVtt(segments), json: () => toJson(segments, meta) }[fmt]();
124
  const type = { txt: 'text/plain', srt: 'application/x-subrip', vtt: 'text/vtt', json: 'application/json' }[fmt];
125
  const a = document.createElement('a');
126
  a.href = URL.createObjectURL(new Blob([body], { type: `${type};charset=utf-8` }));
 
130
  }
131
  document.querySelectorAll('[data-fmt]').forEach((b) => b.addEventListener('click', () => download(b.dataset.fmt)));
132
  els.copy.addEventListener('click', async () => {
133
+ try { await navigator.clipboard.writeText(plainText()); els.copy.textContent = 'Copied'; setTimeout(() => { els.copy.textContent = 'Copy text'; }, 1500); }
134
  catch { setStatus('Copy blocked by the browser; use Download TXT instead.'); }
135
  });
136
 
137
  els.go.addEventListener('click', () => {
138
  if (!audio || busy) return;
139
+ busy = true; els.go.disabled = true; segments = []; isExample = false; files.clear();
140
+ els.segments.replaceChildren(); els.stats.textContent = ''; els.hint.hidden = true; els.tag.textContent = 'Your transcript'; els.partial.textContent = ''; setBar(0);
141
  const device = els.device.value === 'auto' ? ('gpu' in navigator ? 'webgpu' : 'wasm') : els.device.value;
142
  const duration = audio.length / 16000;
143
  const total = expectedChunks(duration);
 
163
  setBar(0);
164
  setStatus(`Transcribing ${clock(duration)} of audio on ${m.device === 'webgpu' ? 'your GPU (WebGPU)' : 'your CPU (WASM)'}…`);
165
  } else if (m.type === 'partial') {
 
166
  els.partial.textContent = (els.partial.textContent + m.text).slice(-400);
167
  } else if (m.type === 'chunk') {
168
  setBar(m.done / total);
169
  setStatus(`Transcribing… part ${Math.min(m.done, total)} of about ${total}`);
170
  els.partial.textContent = '';
171
  } else if (m.type === 'done') {
172
+ segments = toSegments(m.result, duration); isExample = false;
173
  render(true); setBar(1);
174
  const secs = (performance.now() - started) / 1000;
175
  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.');
index.html CHANGED
@@ -23,11 +23,24 @@ h1{font-size:30px;margin:0 0 4px;letter-spacing:-.01em}
23
  .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
24
  .drop{border:2px dashed var(--line);border-radius:10px;padding:26px 14px;text-align:center;cursor:pointer}
25
  .drop.over{border-color:var(--accent);background:var(--soft)}
26
- .drop strong{display:block;font-size:17px}
27
  .row{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin-top:14px}
28
  label{font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:4px}
29
  select,button{font:inherit;font-size:15px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 10px}
30
- button{cursor:pointer}
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  button.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);font-weight:600;padding:9px 18px}
32
  button:disabled{opacity:.5;cursor:not-allowed}
33
  .status{font-size:14px;color:var(--muted);min-height:22px;margin-top:12px}
@@ -41,26 +54,28 @@ button:disabled{opacity:.5;cursor:not-allowed}
41
  .note{font-size:14px;color:var(--muted)}
42
  .priv{background:var(--soft);border-radius:8px;padding:10px 12px;font-size:14px}
43
  a{color:var(--accent)}
 
44
  footer{font-size:13px;color:var(--muted);margin-top:28px}
45
  .more a{display:inline-block;margin:0 12px 6px 0}
46
- .hidden{display:none}
47
  @media (max-width:520px){h1{font-size:25px}.row>label{flex:1 1 45%}}
48
  </style>
49
  <!-- cm:studio-meta --><meta property="og:image" content="https://cybermax-tools-cybermax.static.hf.space/brand/hushscribe/cover.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermax-tools-cybermax.static.hf.space/brand/hushscribe/cover.png"><link rel="icon" type="image/png" href="https://cybermax-tools-cybermax.static.hf.space/brand/hushscribe/icon.png"><!-- /cm:studio-meta -->
50
  </head>
51
- <body><!-- cybermax-offer:start --><div style="max-width:960px;margin:8px auto;padding:8px 14px;border:1px solid rgba(127,127,127,.35);border-radius:10px;font:14px/1.5 system-ui,sans-serif"><b>Turn a podcast into captioned shorts:</b> <a href="https://data.cybermaxtools.com/buy/st-clipwhit-pro-monthly?s=sp-hushscribe" target="_blank" rel="noopener" style="color:inherit;font-weight:700">Clipwhit Pro</a> ($12/month)</div><!-- cybermax-offer:end -->
52
  <main>
53
  <h1>Hearlark</h1>
54
  <p class="sub">Private speech-to-text. Whisper runs <b>inside your browser</b>: your audio is never uploaded. Free, no sign-up.</p>
55
 
56
  <section class="card">
57
  <div id="drop" class="drop" tabindex="0" role="button" aria-label="Choose an audio or video file">
58
- <strong>Drop an audio or video file here, or click to choose</strong>
59
- <span class="note">MP3, WAV, M4A, OGG, WEBM, MP4… anything your browser can play.</span>
60
- <input id="file" type="file" accept="audio/*,video/*" class="hidden">
 
61
  </div>
62
  <div class="row">
63
- <button id="rec" type="button">● Record from microphone</button>
64
  <span id="fileinfo" class="note"></span>
65
  </div>
66
  <div class="row">
@@ -97,15 +112,25 @@ footer{font-size:13px;color:var(--muted);margin-top:28px}
97
  <option value="wasm">WASM (CPU)</option>
98
  </select>
99
  </label>
100
- <button id="go" class="primary" type="button" disabled>Transcribe</button>
101
  </div>
102
- <div id="status" class="status" aria-live="polite">Choose a file to start. The model downloads once and is then cached by your browser.</div>
103
  <div class="bar"><i id="bar"></i></div>
104
  </section>
105
 
106
- <section id="result" class="card hidden">
 
 
 
 
 
 
 
 
 
107
  <ol id="segments"></ol>
108
- <div id="partial" class="partial"></div>
 
109
  <div class="exports">
110
  <button data-fmt="txt" type="button">Download TXT</button>
111
  <button data-fmt="srt" type="button">Download SRT subtitles</button>
@@ -138,5 +163,5 @@ footer{font-size:13px;color:var(--muted);margin-top:28px}
138
  </main>
139
  <script type="module" src="./app.js"></script>
140
  <!-- cm:hf-upgrade-foot --><div id="cm-hf-up" hidden style="max-width:980px;margin:24px auto 12px;padding:0 16px;text-align:center;font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;color:#64748b">Free tool by <a href="https://huggingface.co/spaces/CyberMax-tools/cybermax" target="_blank" rel="noopener" style="color:inherit">CyberMax</a> · Need more? <a href="https://data.cybermaxtools.com/buy/st-clipwhit-pro-monthly?s=hf-space-hushscribe" target="_blank" rel="noopener" style="color:#0284c7;font-weight:600">Turn the recording into captioned shorts: Clipwhit Pro, $12/month &#8594;</a></div><script>(function(){if(/\.static\.hf\.space$/.test(location.hostname)){var d=document.getElementById("cm-hf-up");if(d)d.hidden=false}})()</script><!-- /cm:hf-upgrade-foot -->
141
- <!-- cybermax-count:start --><script>(function(){try{var D="https://data.cybermaxtools.com",P=location.host+location.pathname,H=navigator.webdriver||!(navigator.languages&&navigator.languages.length)||!screen.width?"&h=1":"",Z=function(x){navigator.sendBeacon?navigator.sendBeacon(x):(new Image).src=x},V=null,K="cmx-visit";if(navigator.doNotTrack!=="1")try{var o=JSON.parse(sessionStorage.getItem(K)||"null"),t=Date.now();if(!o||t-o.t>18e5)o={e:P,n:0};o.n++;o.t=t;sessionStorage.setItem(K,JSON.stringify(o));V=o}catch(e){}var u=D+"/v?p="+encodeURIComponent(P)+"&r="+encodeURIComponent((function(r){var h=r.split("/")[2]||"";return h===location.host?r.split(/[?#]/)[0]:h})(document.referrer||""))+H+(function(){var q=new URLSearchParams(location.search),s=q.get("utm_source"),t=(q.get("s")||"").toLowerCase().replace(/[^a-z0-9-]/g,"-").slice(0,80);return s?"&us="+encodeURIComponent(s)+"&uc="+encodeURIComponent(q.get("utm_campaign")||""):t?"&us=s&uc="+encodeURIComponent(t):""})()+(V?"&vn="+V.n+(V.n>1?"&ve="+encodeURIComponent(V.e):""):"");Z(u);if(!V)return;var B="a[data-count],a[href*=\"buy.stripe.com\"],a[href*=\"data.cybermaxtools.com/buy/\"],a[href*=\".workers.dev/buy/\"],a[href*=\"buy.polar.sh\"],a[href*=\"polar.sh/checkout\"],[data-cmx-buy]",E=function(k){try{var o=JSON.parse(sessionStorage.getItem(K)||"null");if(!o||o[k])return;o[k]=1;sessionStorage.setItem(K,JSON.stringify(o));Z(D+"/v?p="+encodeURIComponent(P)+"&ev="+k+"&ve="+encodeURIComponent(o.e)+H)}catch(e){}};document.addEventListener("click",function(x){var a=x.target&&x.target.closest&&x.target.closest(B);if(a){E("seen");E("click")}},true);var S=V.seen,C=function(){if(S)return;var l=document.querySelectorAll(B),w=innerWidth,h=innerHeight;for(var i=0;i<l.length;i++){var r=l[i].getBoundingClientRect();if(r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.top<h&&r.left<w){S=1;E("seen");return}}},T0=Date.now(),I=setInterval(function(){C();if(S||Date.now()-T0>6e5)clearInterval(I)},1500),R=0;addEventListener("scroll",function(){if(!R&&!S){R=1;setTimeout(function(){R=0;C()},250)}},{passive:true});document.readyState=="loading"?document.addEventListener("DOMContentLoaded",C):C()}catch(e){}})()</script><!-- cybermax-count:end --></body>
142
  </html>
 
23
  .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
24
  .drop{border:2px dashed var(--line);border-radius:10px;padding:26px 14px;text-align:center;cursor:pointer}
25
  .drop.over{border-color:var(--accent);background:var(--soft)}
26
+ .drop strong{display:block;font-size:17px;margin-bottom:10px}
27
  .row{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin-top:14px}
28
  label{font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:4px}
29
  select,button{font:inherit;font-size:15px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 10px}
30
+ button{cursor:pointer;min-height:44px}
31
+ select{min-height:44px}
32
+ button.quiet{background:transparent}
33
+ kbd{font:12px/1 ui-monospace,Menlo,monospace;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:2px 5px;background:var(--code);color:var(--ink)}
34
+ :focus-visible{outline:3px solid var(--accent);outline-offset:2px}
35
+ .tools{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px}
36
+ .tools input[type=search]{flex:1 1 180px;min-height:44px;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);padding:8px 10px}
37
+ .tag{display:inline-block;background:var(--soft);color:var(--ink);border-radius:999px;padding:3px 10px;font-size:13px;font-weight:600}
38
+ .chk{flex-direction:row;align-items:center;gap:8px;min-height:44px;font-size:14px}
39
+ .chk input{width:20px;height:20px}
40
+ #segments span[contenteditable]{flex:1;border-radius:4px;padding:0 4px}
41
+ #segments span[contenteditable]:focus{background:var(--soft);outline:2px solid var(--accent)}
42
+ @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
43
+ .more a,.priv a,footer a,.note a,.card a{padding:10px 0}
44
  button.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);font-weight:600;padding:9px 18px}
45
  button:disabled{opacity:.5;cursor:not-allowed}
46
  .status{font-size:14px;color:var(--muted);min-height:22px;margin-top:12px}
 
54
  .note{font-size:14px;color:var(--muted)}
55
  .priv{background:var(--soft);border-radius:8px;padding:10px 12px;font-size:14px}
56
  a{color:var(--accent)}
57
+ .more a{padding:12px 0}
58
  footer{font-size:13px;color:var(--muted);margin-top:28px}
59
  .more a{display:inline-block;margin:0 12px 6px 0}
60
+ .hidden,[hidden]{display:none!important}
61
  @media (max-width:520px){h1{font-size:25px}.row>label{flex:1 1 45%}}
62
  </style>
63
  <!-- cm:studio-meta --><meta property="og:image" content="https://cybermax-tools-cybermax.static.hf.space/brand/hushscribe/cover.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermax-tools-cybermax.static.hf.space/brand/hushscribe/cover.png"><link rel="icon" type="image/png" href="https://cybermax-tools-cybermax.static.hf.space/brand/hushscribe/icon.png"><!-- /cm:studio-meta -->
64
  </head>
65
+ <body>
66
  <main>
67
  <h1>Hearlark</h1>
68
  <p class="sub">Private speech-to-text. Whisper runs <b>inside your browser</b>: your audio is never uploaded. Free, no sign-up.</p>
69
 
70
  <section class="card">
71
  <div id="drop" class="drop" tabindex="0" role="button" aria-label="Choose an audio or video file">
72
+ <strong>Transcribe a recording on your own device</strong>
73
+ <button id="choose" type="button" class="primary">Choose an audio or video file</button>
74
+ <span class="note" style="display:block;margin-top:8px">or drop it here · MP3, WAV, M4A, OGG, WEBM, MP4… anything your browser can play.</span>
75
+ <input id="file" type="file" accept="audio/*,video/*" class="hidden" aria-label="Audio or video file to transcribe">
76
  </div>
77
  <div class="row">
78
+ <button id="rec" type="button" class="quiet" aria-label="Record from microphone">● Record from microphone</button>
79
  <span id="fileinfo" class="note"></span>
80
  </div>
81
  <div class="row">
 
112
  <option value="wasm">WASM (CPU)</option>
113
  </select>
114
  </label>
115
+ <button id="go" class="primary" type="button" disabled>Transcribe <kbd>Ctrl</kbd>+<kbd>Enter</kbd></button>
116
  </div>
117
+ <div id="status" class="status" aria-live="polite">Choose a file to start, or try the example below. The model downloads once and is then cached by your browser.</div>
118
  <div class="bar"><i id="bar"></i></div>
119
  </section>
120
 
121
+ <section id="result" class="card" aria-label="Transcript">
122
+ <div class="tools">
123
+ <span id="tag" class="tag">Example transcript</span>
124
+ <span id="stats" class="note"></span>
125
+ </div>
126
+ <div class="tools">
127
+ <input id="find" type="search" placeholder="Find in transcript" aria-label="Find in transcript">
128
+ <label class="chk"><input id="ts" type="checkbox"> Timestamps in TXT and copy</label>
129
+ <button id="reset" type="button" class="quiet">Start over</button>
130
+ </div>
131
  <ol id="segments"></ol>
132
+ <div id="partial" class="partial" aria-live="off"></div>
133
+ <p class="note" id="edithint">Click any line to fix a word; downloads use your edits. <kbd>Tab</kbd> moves to the next line.</p>
134
  <div class="exports">
135
  <button data-fmt="txt" type="button">Download TXT</button>
136
  <button data-fmt="srt" type="button">Download SRT subtitles</button>
 
163
  </main>
164
  <script type="module" src="./app.js"></script>
165
  <!-- cm:hf-upgrade-foot --><div id="cm-hf-up" hidden style="max-width:980px;margin:24px auto 12px;padding:0 16px;text-align:center;font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;color:#64748b">Free tool by <a href="https://huggingface.co/spaces/CyberMax-tools/cybermax" target="_blank" rel="noopener" style="color:inherit">CyberMax</a> · Need more? <a href="https://data.cybermaxtools.com/buy/st-clipwhit-pro-monthly?s=hf-space-hushscribe" target="_blank" rel="noopener" style="color:#0284c7;font-weight:600">Turn the recording into captioned shorts: Clipwhit Pro, $12/month &#8594;</a></div><script>(function(){if(/\.static\.hf\.space$/.test(location.hostname)){var d=document.getElementById("cm-hf-up");if(d)d.hidden=false}})()</script><!-- /cm:hf-upgrade-foot -->
166
+ <!-- cybermax-count:start --><script>(function(){try{var u="https://data.cybermaxtools.com/v?p="+encodeURIComponent(location.host+location.pathname)+"&r="+encodeURIComponent((document.referrer||"").split("/")[2]||"")+(function(){var q=new URLSearchParams(location.search),s=q.get("utm_source");return s?"&us="+encodeURIComponent(s)+"&uc="+encodeURIComponent(q.get("utm_campaign")||""):""})();navigator.sendBeacon?navigator.sendBeacon(u):(new Image).src=u}catch(e){}})()</script><!-- cybermax-count:end --></body>
167
  </html>