@tablefolk/hear 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/bark-worker.js +125 -0
- package/index.js +124 -0
- package/mel.js +75 -0
- package/package.json +30 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jason
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tablefolk/hear
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Speech to text in the browser, from either of two engines:
|
|
4
|
+
|
|
5
|
+
- **Underdog Bark 3.0** (ai-ecoverse, CC-BY-4.0, a Parakeet-TDT 0.6B
|
|
6
|
+
fine-tune). It runs fp16 on WebGPU (1.3 GB once, ~0.4 s a sentence) or int8
|
|
7
|
+
on the processor (0.9 GB, ~1.2 s). 1.3% WER on LibriSpeech; the voice never
|
|
8
|
+
leaves the machine.
|
|
9
|
+
- **the browser's SpeechRecognition** (Chrome: on this device when it has the
|
|
10
|
+
language pack).
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { createListener } from '@tablefolk/hear';
|
|
14
|
+
const ear = await createListener({ engine: 'auto', ask: async ({ gb }) => confirm(`Download ${gb} GB?`) });
|
|
15
|
+
await ear.start(); // hold to talk...
|
|
16
|
+
const text = await ear.stop(); // ...release: the words
|
|
17
|
+
await ear.transcribe(pcm16k) // Bark: your own 16 kHz Float32Array
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`'auto'` picks the browser's recognition on a phone or tablet (lighter), Bark
|
|
21
|
+
elsewhere. Helpers: `recorder()`, `toPcm16k(blob)`, `level(pcm)` (whether a
|
|
22
|
+
clip is silent). Bark downloads from Hugging Face (Tablefolk's mirror on
|
|
23
|
+
tablefolk.world).
|
|
24
|
+
|
|
25
|
+
Part of [Tablefolk](https://github.com/runvnc/tablefolk) (`libs/`). MIT.
|
package/bark-worker.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/* asr/worker.js -- speech to text in the browser: Underdog Bark 3.0
|
|
2
|
+
* (ai-ecoverse/Underdog-Bark-3.0-ONNX, a Parakeet-TDT 0.6B fine-tune,
|
|
3
|
+
* CC-BY-4.0) on onnxruntime-web. The encoder (fp16, 1.2 GB) runs on WebGPU,
|
|
4
|
+
* the small decoder/joint network on WASM; mel.js is the feature frontend and
|
|
5
|
+
* the greedy TDT loop below is export/tdt.py of the model card.
|
|
6
|
+
*
|
|
7
|
+
* Messages in: { type: 'load', urls: [base urls to try] }
|
|
8
|
+
* { type: 'transcribe', id, audio: Float32Array (mono, 16 kHz) }
|
|
9
|
+
* Messages out: { type: 'progress', note, fraction } ... { type: 'ready' }
|
|
10
|
+
* { type: 'text', id, text, ms } | { type: 'error', id?, message }
|
|
11
|
+
* Files are kept in the Cache API (tablefolk-asr-1), so a download happens once.
|
|
12
|
+
*/
|
|
13
|
+
import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.1/dist/ort.webgpu.min.mjs';
|
|
14
|
+
import { logMel } from './mel.js';
|
|
15
|
+
|
|
16
|
+
// fp16 (1.2 GB) on WebGPU where the card has shader-f16 (token-exact with the
|
|
17
|
+
// reference on 19 of 20 clips); else int8 (0.84 GB) on WASM, which reads the
|
|
18
|
+
// same words (the card measured it token-exact on fewer clips, but the text
|
|
19
|
+
// matches on the ones checked). The int4 build is broken: never used.
|
|
20
|
+
const VARIANTS = {
|
|
21
|
+
fp16: { enc: 'encoder_model_fp16.onnx', dec: 'decoder_joint_model_fp16.onnx', ep: 'webgpu', sizes: [0.001, 0.93, 0.06, 0.009] },
|
|
22
|
+
q4: { enc: 'encoder_model_q4f16.onnx', dec: 'decoder_joint_model_q4f16.onnx', ep: 'webgpu', sizes: [0.003, 0.862, 0.133, 0.002] }, // 0.52 GB (testing: ?asrvariant=q4)
|
|
23
|
+
q8: { enc: 'encoder_model_quantized.onnx', dec: 'decoder_joint_model_quantized.onnx', ep: 'wasm', sizes: [0.004, 0.92, 0.07, 0.006] },
|
|
24
|
+
};
|
|
25
|
+
async function pickVariant(force) {
|
|
26
|
+
if (force && VARIANTS[force]) return force;
|
|
27
|
+
try { const a = await navigator.gpu?.requestAdapter(); if (a?.features.has('shader-f16')) return 'fp16'; } catch {}
|
|
28
|
+
return 'q8';
|
|
29
|
+
}
|
|
30
|
+
const GEN = { blank: 8192, durations: [0, 1, 2, 3, 4], hidden: 640, layers: 2, maxSymbols: 10, eos: 3 };
|
|
31
|
+
const CACHE = 'tablefolk-asr-1';
|
|
32
|
+
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.1/dist/';
|
|
33
|
+
ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 2) : 1;
|
|
34
|
+
|
|
35
|
+
let enc = null, dec = null, pieces = null, loading = null;
|
|
36
|
+
const say = m => postMessage(m);
|
|
37
|
+
|
|
38
|
+
// R2 takes uploads up to 300 MiB through wrangler, so the big file is stored as
|
|
39
|
+
// <name>.p000, .p001, ... and a <name>.parts.json ({ size, partSize, parts }),
|
|
40
|
+
// fetched four at a time from the bucket's four hostnames (one connection each).
|
|
41
|
+
const hostFor = (u, i) => u.replace('//geebr-models.mindroot.io', i % 4 ? `//geebr-models-${(i % 4) + 1}.mindroot.io` : '//geebr-models.mindroot.io');
|
|
42
|
+
const pathFor = u => (/huggingface\.co/.test(u) ? 'onnx/' : '');
|
|
43
|
+
async function readInto(resp, buf, at, onBytes) {
|
|
44
|
+
const reader = resp.body.getReader();
|
|
45
|
+
for (;;) { const { value, done } = await reader.read(); if (done) break; buf.set(value, at); at += value.length; onBytes(value.length); }
|
|
46
|
+
}
|
|
47
|
+
async function download(cache, name, urls, weight, base) {
|
|
48
|
+
const hit = await cache.match('/asr/' + name);
|
|
49
|
+
if (hit) return new Uint8Array(await hit.arrayBuffer());
|
|
50
|
+
let lastErr;
|
|
51
|
+
for (const u0 of urls) {
|
|
52
|
+
const u = u0.replace(/\/?$/, '/');
|
|
53
|
+
try {
|
|
54
|
+
let buf, got = 0, total = 0;
|
|
55
|
+
const tick = n => { got += n; say({ type: 'progress', note: `downloading ${name}`, fraction: base + weight * (total ? got / total : 0) }); };
|
|
56
|
+
const man = /huggingface\.co/.test(u) ? null : await fetch(u + name + '.parts.json').then(r => r.ok ? r.json() : null).catch(() => null);
|
|
57
|
+
if (man) {
|
|
58
|
+
total = man.size; buf = new Uint8Array(total);
|
|
59
|
+
let next = 0;
|
|
60
|
+
const lane = async () => { for (let i = next++; i < man.parts; i = next++) {
|
|
61
|
+
const r = await fetch(hostFor(u, i) + name + '.p' + String(i).padStart(3, '0')); if (!r.ok) throw new Error(`${name} part ${i}: HTTP ${r.status}`);
|
|
62
|
+
await readInto(r, buf, i * man.partSize, tick);
|
|
63
|
+
} };
|
|
64
|
+
await Promise.all([lane(), lane(), lane(), lane()]);
|
|
65
|
+
} else {
|
|
66
|
+
const r = await fetch(u + (name === 'tokenizer.json' ? '' : pathFor(u)) + name); if (!r.ok) throw new Error(`${name}: HTTP ${r.status}`);
|
|
67
|
+
total = +r.headers.get('content-length') || 0; buf = total ? new Uint8Array(total) : null;
|
|
68
|
+
if (buf) await readInto(r, buf, 0, tick);
|
|
69
|
+
else { buf = new Uint8Array(await r.arrayBuffer()); }
|
|
70
|
+
}
|
|
71
|
+
await cache.put('/asr/' + name, new Response(buf));
|
|
72
|
+
return buf;
|
|
73
|
+
} catch (e) { lastErr = e; }
|
|
74
|
+
}
|
|
75
|
+
throw lastErr || new Error('no model source');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function load(urls, force) {
|
|
79
|
+
if (enc) return;
|
|
80
|
+
const V = VARIANTS[await pickVariant(force)], files = [V.enc, V.enc + '_data', V.dec, 'tokenizer.json'];
|
|
81
|
+
const cache = await caches.open(CACHE);
|
|
82
|
+
let base = 0; const got = {};
|
|
83
|
+
for (let i = 0; i < files.length; i++) { got[files[i]] = await download(cache, files[i], urls, V.sizes[i], base); base += V.sizes[i]; }
|
|
84
|
+
say({ type: 'progress', note: 'starting the speech model', fraction: 1 });
|
|
85
|
+
enc = await ort.InferenceSession.create(got[V.enc], { executionProviders: [V.ep], externalData: [{ path: V.enc + '_data', data: got[V.enc + '_data'] }] });
|
|
86
|
+
dec = await ort.InferenceSession.create(got[V.dec], { executionProviders: ['wasm'] });
|
|
87
|
+
const vocab = JSON.parse(new TextDecoder().decode(got['tokenizer.json'])).model.vocab;
|
|
88
|
+
pieces = []; for (const [p, id] of Object.entries(vocab)) pieces[id] = p;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const f32 = t => t.type === 'float16' ? Float32Array.from(t.data, halfToFloat) : t.data;
|
|
92
|
+
function halfToFloat(h) { const s = (h & 0x8000) ? -1 : 1, e = (h >> 10) & 31, f = h & 1023; return e === 0 ? s * 5.960464477539063e-8 * f : e === 31 ? (f ? NaN : s * Infinity) : s * 2 ** (e - 15) * (1 + f / 1024); }
|
|
93
|
+
|
|
94
|
+
async function transcribe(audio) {
|
|
95
|
+
const { features, T, valid } = logMel(audio);
|
|
96
|
+
const mask = new BigInt64Array(T); for (let t = 0; t < valid; t++) mask[t] = 1n;
|
|
97
|
+
const out = await enc.run({ input_features: new ort.Tensor('float32', features, [1, T, 128]), attention_mask: new ort.Tensor('int64', mask, [1, T]) });
|
|
98
|
+
const eo = out.encoder_outputs, len = Number(out.encoder_lengths.data[0]), E = f32(eo), Tp = eo.dims[1];
|
|
99
|
+
let h = new Float32Array(GEN.layers * GEN.hidden), c = new Float32Array(GEN.layers * GEN.hidden);
|
|
100
|
+
const toks = []; let last = GEN.blank, t = 0, steps = 0;
|
|
101
|
+
const maxSteps = GEN.maxSymbols * Tp - 1;
|
|
102
|
+
while (t < len && steps++ < maxSteps) {
|
|
103
|
+
const r = await dec.run({
|
|
104
|
+
encoder_outputs: new ort.Tensor('float32', E.slice(t * GEN.hidden, (t + 1) * GEN.hidden), [1, 1, GEN.hidden]),
|
|
105
|
+
input_ids: new ort.Tensor('int64', BigInt64Array.of(BigInt(last)), [1, 1]),
|
|
106
|
+
state_h: new ort.Tensor('float32', h, [GEN.layers, 1, GEN.hidden]), state_c: new ort.Tensor('float32', c, [GEN.layers, 1, GEN.hidden]),
|
|
107
|
+
});
|
|
108
|
+
const tl = f32(r.token_logits), dl = f32(r.duration_logits);
|
|
109
|
+
let tok = 0; for (let i = 1; i < tl.length; i++) if (tl[i] > tl[tok]) tok = i;
|
|
110
|
+
let di = 0; for (let i = 1; i < dl.length; i++) if (dl[i] > dl[di]) di = i;
|
|
111
|
+
let d = GEN.durations[di];
|
|
112
|
+
if (tok === GEN.blank) d = Math.max(d, 1); else { last = tok; h = Float32Array.from(f32(r.state_h_out)); c = Float32Array.from(f32(r.state_c_out)); toks.push(tok); }
|
|
113
|
+
t += d;
|
|
114
|
+
if (tok === GEN.eos) break;
|
|
115
|
+
}
|
|
116
|
+
return toks.filter(i => i !== GEN.blank && !(pieces[i].startsWith('<') && pieces[i].endsWith('>'))).map(i => pieces[i]).join('').replace(/▁/g, ' ').trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onmessage = async e => {
|
|
120
|
+
const m = e.data;
|
|
121
|
+
try {
|
|
122
|
+
if (m.type === 'load') { await (loading ||= load(m.urls, m.variant)); say({ type: 'ready' }); }
|
|
123
|
+
else if (m.type === 'transcribe') { await loading; const t0 = performance.now(); const text = await transcribe(m.audio); say({ type: 'text', id: m.id, text, ms: Math.round(performance.now() - t0) }); }
|
|
124
|
+
} catch (err) { loading = null; say({ type: 'error', id: m.id, message: String(err?.message || err) }); }
|
|
125
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @tablefolk/hear -- speech to text in the browser:
|
|
2
|
+
// browser the browser's SpeechRecognition (Chrome: on this device when it
|
|
3
|
+
// has the language pack, which it may install; else its cloud)
|
|
4
|
+
// bark Underdog Bark 3.0 (ai-ecoverse, CC-BY-4.0, a Parakeet-TDT 0.6B
|
|
5
|
+
// fine-tune) in a worker: fp16 on WebGPU (1.3 GB download once,
|
|
6
|
+
// ~0.4 s a sentence) or int8 on the processor (0.9 GB, ~1.2 s).
|
|
7
|
+
// Your voice never leaves the machine. 1.3% WER on LibriSpeech.
|
|
8
|
+
//
|
|
9
|
+
// import { createListener } from '@tablefolk/hear';
|
|
10
|
+
// const ear = await createListener({ engine: 'auto', onStatus: s => console.log(s) });
|
|
11
|
+
// ear.engine, ear.why
|
|
12
|
+
// await ear.start(); // recording (hold-to-talk: start on press...)
|
|
13
|
+
// const text = await ear.stop() // ...stop on release: the words
|
|
14
|
+
// await ear.transcribe(pcm16k) // bark: a Float32Array at 16 kHz you recorded yourself
|
|
15
|
+
//
|
|
16
|
+
// 'auto': a phone or tablet with SpeechRecognition uses it (lighter);
|
|
17
|
+
// elsewhere Bark (ask: async ({ gb }) => true to allow its download, default
|
|
18
|
+
// allowed), else the browser's. Bark's files come from Hugging Face, or
|
|
19
|
+
// Tablefolk's mirror on tablefolk.world.
|
|
20
|
+
import { createWorker, mirrorAllowed, sources, note } from '@tablefolk/models';
|
|
21
|
+
import { detectHardware } from '@tablefolk/device';
|
|
22
|
+
|
|
23
|
+
const BARK = { mirror: 'https://geebr-models.mindroot.io/underdog-bark-3.0/', hf: 'https://huggingface.co/ai-ecoverse/Underdog-Bark-3.0-ONNX/resolve/main/' };
|
|
24
|
+
const SR = () => globalThis.SpeechRecognition || globalThis.webkitSpeechRecognition || null;
|
|
25
|
+
|
|
26
|
+
export async function chooseEngine({ preferBrowserOnPhones = true } = {}) {
|
|
27
|
+
const hw = await detectHardware().catch(() => null);
|
|
28
|
+
if (preferBrowserOnPhones && SR() && hw && (hw.cls === 'phone' || hw.cls === 'tablet')) return { engine: 'browser', why: 'a phone or tablet: the browser\'s recognition is lighter' };
|
|
29
|
+
if (typeof Worker === 'function') return { engine: 'bark', why: 'Underdog Bark in this browser (private, accurate)' };
|
|
30
|
+
return SR() ? { engine: 'browser', why: 'the browser\'s recognition' } : { engine: null, why: 'no speech recognition here' };
|
|
31
|
+
}
|
|
32
|
+
// fp16 on the graphics card when it has shader-f16, else int8 on the processor
|
|
33
|
+
export async function barkPlan({ cpu = false } = {}) {
|
|
34
|
+
let f16 = false; try { f16 = !!(await navigator.gpu?.requestAdapter())?.features.has('shader-f16'); } catch {}
|
|
35
|
+
return f16 && !cpu ? { variant: 'fp16', gb: 1.3, where: 'the graphics card' } : { variant: 'q8', gb: 0.9, where: 'the processor' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createListener({ engine = 'auto', onStatus = () => {}, ask = null, cpu = false, lang = null } = {}) {
|
|
39
|
+
let pick = engine === 'auto' ? await chooseEngine() : { engine, why: 'asked for' };
|
|
40
|
+
if (pick.engine === 'bark') {
|
|
41
|
+
const plan = await barkPlan({ cpu });
|
|
42
|
+
if (ask && !(await ask(plan))) pick = SR() ? { engine: 'browser', why: 'Bark\'s download declined: the browser\'s recognition' } : { engine: null, why: 'Bark\'s download declined' };
|
|
43
|
+
}
|
|
44
|
+
if (!pick.engine) throw new Error(pick.why);
|
|
45
|
+
note('hear', `${pick.engine}: ${pick.why}`);
|
|
46
|
+
return pick.engine === 'browser' ? browserListener(pick, lang) : barkListener(pick, { onStatus, cpu });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- Bark
|
|
50
|
+
async function barkListener(pick, { onStatus, cpu }) {
|
|
51
|
+
const plan = await barkPlan({ cpu });
|
|
52
|
+
const w = createWorker(new URL('./bark-worker.js', import.meta.url));
|
|
53
|
+
const wait = new Map(); let id = 0;
|
|
54
|
+
await new Promise((resolve, reject) => {
|
|
55
|
+
w.onmessage = e => {
|
|
56
|
+
const m = e.data;
|
|
57
|
+
if (m.type === 'progress') onStatus(`${m.note || 'loading'} ${Math.round(m.fraction * 100)}%`);
|
|
58
|
+
else if (m.type === 'ready') resolve();
|
|
59
|
+
else if (m.type === 'text') { wait.get(m.id)?.resolve(m.text); wait.delete(m.id); }
|
|
60
|
+
else if (m.type === 'error') { if (m.id != null) { wait.get(m.id)?.reject(new Error(m.message)); wait.delete(m.id); } else reject(new Error(m.message)); }
|
|
61
|
+
};
|
|
62
|
+
w.onerror = e => reject(new Error(e.message || 'speech worker failed'));
|
|
63
|
+
w.postMessage({ type: 'load', urls: sources(BARK, mirrorAllowed()), variant: plan.variant });
|
|
64
|
+
});
|
|
65
|
+
onStatus(`Underdog Bark ready on ${plan.where}`);
|
|
66
|
+
const transcribe = pcm => new Promise((resolve, reject) => { const n = ++id; wait.set(n, { resolve, reject }); const a = Float32Array.from(pcm); w.postMessage({ type: 'transcribe', id: n, audio: a }, [a.buffer]); });
|
|
67
|
+
const rec = recorder();
|
|
68
|
+
return { engine: 'bark', why: pick.why + ` (${plan.where})`, transcribe,
|
|
69
|
+
start: () => rec.start(),
|
|
70
|
+
async stop() { const blob = await rec.stop(); if (!blob) return ''; const pcm = await toPcm16k(blob); return level(pcm).voiced < .02 ? '' : transcribe(pcm); },
|
|
71
|
+
cancel: () => rec.stop(), unload() { rec.stop(); w.terminate(); } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- the browser's recognition
|
|
75
|
+
function browserListener(pick, lang) {
|
|
76
|
+
const R = SR(); if (!R) throw new Error('no SpeechRecognition in this browser');
|
|
77
|
+
lang ||= navigator.language || 'en-US';
|
|
78
|
+
let recog = null, latest = '', ended = null;
|
|
79
|
+
return { engine: 'browser', why: pick.why,
|
|
80
|
+
async start() {
|
|
81
|
+
let local = false;
|
|
82
|
+
try { const o = { langs: [lang], processLocally: true }, a = await R.available?.(o); if (a === 'available') local = true; else if (a === 'downloadable' || a === 'downloading') local = !!(await R.install(o)); } catch {}
|
|
83
|
+
recog = new R(); recog.lang = lang; recog.interimResults = true; recog.continuous = true; if (local) recog.processLocally = true;
|
|
84
|
+
latest = ''; ended = new Promise(r => { recog.onend = () => r(latest); });
|
|
85
|
+
recog.onresult = e => { latest = [...e.results].map(x => x[0].transcript).join(' '); };
|
|
86
|
+
recog.start();
|
|
87
|
+
return { local };
|
|
88
|
+
},
|
|
89
|
+
async stop() { if (!recog) return ''; recog.stop(); const t = await ended; recog = null; return t.trim(); },
|
|
90
|
+
cancel() { recog?.abort(); recog = null; },
|
|
91
|
+
transcribe() { throw new Error('the browser\'s recognition listens itself: use start() and stop()'); },
|
|
92
|
+
unload() { recog?.abort(); } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- helpers: the microphone, 16 kHz mono, loudness
|
|
96
|
+
export function recorder() {
|
|
97
|
+
let rec = null, chunks = [], stream = null;
|
|
98
|
+
return {
|
|
99
|
+
async start() {
|
|
100
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
101
|
+
chunks = []; rec = new MediaRecorder(stream); rec.ondataavailable = e => e.data.size && chunks.push(e.data); rec.start();
|
|
102
|
+
},
|
|
103
|
+
stop() {
|
|
104
|
+
if (!rec) return Promise.resolve(null);
|
|
105
|
+
return new Promise(res => { const r = rec; rec = null; r.onstop = () => { stream.getTracks().forEach(t => t.stop()); res(new Blob(chunks, { type: r.mimeType })); }; r.stop(); });
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export async function toPcm16k(blob) {
|
|
110
|
+
const ctx = new AudioContext(), buf = await ctx.decodeAudioData(await blob.arrayBuffer()); ctx.close();
|
|
111
|
+
const rate = 16000, off = new OfflineAudioContext(1, Math.ceil(buf.duration * rate), rate);
|
|
112
|
+
const src = off.createBufferSource(); src.buffer = buf; src.connect(off.destination); src.start();
|
|
113
|
+
return (await off.startRendering()).getChannelData(0);
|
|
114
|
+
}
|
|
115
|
+
// peak, seconds, and the share of 20 ms frames louder than -40 dBFS (a silent
|
|
116
|
+
// clip is better not transcribed: recognisers hear "Thank you" in silence)
|
|
117
|
+
export function level(pcm) {
|
|
118
|
+
let peak = 0, voiced = 0, frames = 0;
|
|
119
|
+
for (let i = 0; i + 320 <= pcm.length; i += 320) {
|
|
120
|
+
let e = 0; for (let j = i; j < i + 320; j++) { const v = pcm[j]; e += v * v; if (v > peak) peak = v; else if (-v > peak) peak = -v; }
|
|
121
|
+
frames++; if (Math.sqrt(e / 320) > .01) voiced++;
|
|
122
|
+
}
|
|
123
|
+
return { peak, seconds: pcm.length / 16000, voiced: frames ? voiced / frames : 0 };
|
|
124
|
+
}
|
package/mel.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* asr/mel.js -- the log-mel frontend of Parakeet-TDT speech models (Underdog
|
|
2
|
+
* Bark 3.0), in plain JS: a port of export/tdt.py from
|
|
3
|
+
* ai-ecoverse/Underdog-Bark-3.0-ONNX (transformers' ParakeetFeatureExtractor).
|
|
4
|
+
* One utterance: mono 16 kHz Float32Array -> { features [T*128], T, valid }.
|
|
5
|
+
* ES module; runs in the worker and in Node (the test compares it with the
|
|
6
|
+
* model card's fixture).
|
|
7
|
+
*/
|
|
8
|
+
export const PP = { sr: 16000, nFft: 512, hop: 160, win: 400, mels: 128, preemph: 0.97, lead: 0.25, fmin: 0, fmax: 8000, guard: 5.960464477539063e-08, eps: 1e-5 };
|
|
9
|
+
|
|
10
|
+
// librosa.filters.mel(norm='slaney', htk=False): [mels][nFft/2+1]
|
|
11
|
+
export function melFilters(p = PP) {
|
|
12
|
+
const hz2mel = f => f < 1000 ? f / (200 / 3) : 15 + Math.log(f / 1000) / (Math.log(6.4) / 27);
|
|
13
|
+
const mel2hz = m => m < 15 ? m * (200 / 3) : 1000 * Math.exp((m - 15) * (Math.log(6.4) / 27));
|
|
14
|
+
const bins = p.nFft / 2 + 1, lo = hz2mel(p.fmin), hi = hz2mel(p.fmax);
|
|
15
|
+
const pts = Array.from({ length: p.mels + 2 }, (_, i) => mel2hz(lo + (hi - lo) * i / (p.mels + 1)));
|
|
16
|
+
const out = [];
|
|
17
|
+
for (let m = 0; m < p.mels; m++) {
|
|
18
|
+
const row = new Float32Array(bins), l = pts[m], c = pts[m + 1], r = pts[m + 2], en = 2 / (r - l);
|
|
19
|
+
for (let k = 0; k < bins; k++) {
|
|
20
|
+
const f = k * p.sr / p.nFft, w = Math.max(0, Math.min((f - l) / (c - l), (r - f) / (r - c)));
|
|
21
|
+
row[k] = w * en;
|
|
22
|
+
}
|
|
23
|
+
out.push(row);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// in-place radix-2 FFT of re/im (length a power of two)
|
|
29
|
+
function fft(re, im) {
|
|
30
|
+
const n = re.length;
|
|
31
|
+
for (let i = 1, j = 0; i < n; i++) {
|
|
32
|
+
let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit;
|
|
33
|
+
if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
|
|
34
|
+
}
|
|
35
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
36
|
+
const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang);
|
|
37
|
+
for (let i = 0; i < n; i += len) {
|
|
38
|
+
let cr = 1, ci = 0;
|
|
39
|
+
for (let k = 0; k < len / 2; k++) {
|
|
40
|
+
const a = i + k, b = a + len / 2, tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
|
|
41
|
+
re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
|
|
42
|
+
const nr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = nr;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let cached = null;
|
|
49
|
+
export function logMel(wave, p = PP) {
|
|
50
|
+
const filters = cached ||= melFilters(p);
|
|
51
|
+
const lead = Math.round(p.lead * p.sr), L = wave.length + lead, x = new Float32Array(L);
|
|
52
|
+
x.set(wave, lead);
|
|
53
|
+
// pre-emphasis (first sample kept), then zero padding by nFft/2 each side (centre)
|
|
54
|
+
const pe = new Float32Array(L); pe[0] = x[0];
|
|
55
|
+
for (let i = 1; i < L; i++) pe[i] = x[i] - p.preemph * x[i - 1];
|
|
56
|
+
const half = p.nFft / 2, xp = new Float32Array(L + p.nFft); xp.set(pe, half);
|
|
57
|
+
const T = 1 + Math.floor(L / p.hop), valid = Math.floor(L / p.hop), bins = half + 1;
|
|
58
|
+
const w = new Float32Array(p.nFft), off = (p.nFft - p.win) / 2;
|
|
59
|
+
for (let i = 0; i < p.win; i++) w[off + i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (p.win - 1));
|
|
60
|
+
const out = new Float32Array(T * p.mels), re = new Float64Array(p.nFft), im = new Float64Array(p.nFft), power = new Float32Array(bins);
|
|
61
|
+
for (let t = 0; t < T; t++) {
|
|
62
|
+
for (let i = 0; i < p.nFft; i++) { re[i] = xp[t * p.hop + i] * w[i]; im[i] = 0; }
|
|
63
|
+
fft(re, im);
|
|
64
|
+
for (let k = 0; k < bins; k++) power[k] = re[k] * re[k] + im[k] * im[k];
|
|
65
|
+
for (let m = 0; m < p.mels; m++) { const f = filters[m]; let s = 0; for (let k = 0; k < bins; k++) s += power[k] * f[k]; out[t * p.mels + m] = Math.log(s + p.guard); }
|
|
66
|
+
}
|
|
67
|
+
// per-feature normalisation over the valid frames (unbiased), padding zeroed
|
|
68
|
+
for (let m = 0; m < p.mels; m++) {
|
|
69
|
+
let mean = 0; for (let t = 0; t < valid; t++) mean += out[t * p.mels + m]; mean /= valid;
|
|
70
|
+
let v = 0; for (let t = 0; t < valid; t++) { const d = out[t * p.mels + m] - mean; v += d * d; }
|
|
71
|
+
const std = Math.sqrt(v / (valid - 1));
|
|
72
|
+
for (let t = 0; t < T; t++) out[t * p.mels + m] = t < valid ? (out[t * p.mels + m] - mean) / (std + p.eps) : 0;
|
|
73
|
+
}
|
|
74
|
+
return { features: out, T, valid };
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tablefolk/hear",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Speech to text in the browser: Underdog Bark 3.0 (Parakeet TDT, WebGPU or CPU) or the browser's SpeechRecognition, chosen for the device.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./*": "./*"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/runvnc/tablefolk.git",
|
|
15
|
+
"directory": "libs/hear"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/runvnc/tablefolk/tree/main/libs/hear",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"tablefolk",
|
|
20
|
+
"browser",
|
|
21
|
+
"asr",
|
|
22
|
+
"speech-recognition",
|
|
23
|
+
"stt",
|
|
24
|
+
"parakeet",
|
|
25
|
+
"webgpu"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@tablefolk/models": "^0.1.0",
|
|
30
|
+
"@tablefolk/device": "^0.1.0"
|
|
31
|
+
}
|
|
32
|
+
}
|