Spaces:
Running
Running
Deploy hushscribe
Browse files- app.js +50 -12
- 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 = '
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 71 |
-
els.
|
|
|
|
|
|
|
| 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: () =>
|
| 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(
|
| 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>
|
| 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>
|
| 59 |
-
<
|
| 60 |
-
<
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 →</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
|
| 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 →</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>
|