SOURCE / PINNED RELEASE
Made of little things.
Pursuit PIT
- Release
- 565f4981bdc3…
- Author-recorded commit
- 197afdbed44b…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7
Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Speech-recognition helpers shared by build.ts (picking takes that are understood) and
// verify.ts (checking the finished clips): Whisper via whisper.cpp, and word comparison.
import { writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const WHISPER_CLI = join(homedir(), '.cache/whisper-dl/bin/Release/whisper-cli.exe');
export const WHISPER_MODEL = join(homedir(), '.cache/whisper-dl/ggml-small.en.bin');
/** Mono float samples as a 16 kHz 16-bit WAV, with half a second of silence either side. */
export function writeWav16k(path: string, samples: Float32Array, rate: number): void {
const pad = 8000;
const n = Math.floor((samples.length * 16000) / rate);
const total = n + pad * 2;
const buf = Buffer.alloc(44 + total * 2);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + total * 2, 4); buf.write('WAVE', 8);
buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(16000, 24); buf.writeUInt32LE(32000, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34);
buf.write('data', 36); buf.writeUInt32LE(total * 2, 40);
for (let i = 0; i < n; i++) {
const x = (i * rate) / 16000;
const k = Math.floor(x), f = x - k;
const v = (samples[k] ?? 0) * (1 - f) + (samples[k + 1] ?? 0) * f;
buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(v * 32767))), 44 + (pad + i) * 2);
}
writeFileSync(path, buf);
}
/** What Whisper hears in a WAV file. */
export function transcribe(path: string, cli = WHISPER_CLI, model = WHISPER_MODEL): string {
const p = Bun.spawnSync([cli, '-m', model, '-f', path, '-nt', '-np', '-l', 'en'], { cwd: join(cli, '..') });
return p.stdout.toString().replace(/\s+/g, ' ').trim();
}
const ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
function spell(n: number): string {
if (n < 20) return ONES[n];
if (n < 100) return TENS[Math.floor(n / 10)] + (n % 10 ? ' ' + ONES[n % 10] : '');
if (n === 100) return 'a hundred';
return 'one ' + spell(n % 100); // radio style: 140 is "one forty"
}
// Fillers, and the "is"/"has" that "driver's" can be written out as ("driver is"), aren't
// counted, so a correct transcription written differently doesn't count as a miss.
const FILLER = new Set(['uh', 'um', 'oh', 'is', 'has']);
/** Lowercase words, numbers spelled the way the script says them, fillers dropped. */
export function words(text: string): string[] {
return text
.toLowerCase()
.replace(/\b10-4\b/g, 'ten four')
.replace(/\bi-87\b/g, 'i eighty seven')
.replace(/\bems\b/g, 'e m s')
.replace(/(\d+)(st|nd|rd|th)?/g, (_, m) => ' ' + spell(Number(m)) + ' ')
.replace(/[^a-z' ]+/g, ' ')
.replace(/'s\b/g, '')
.replace(/\btraffics\b/g, 'traffic') // the respelling used for its s sound
.replace(/'/g, '')
.split(/\s+/)
.filter((w) => w && !FILLER.has(w));
}
/** Word-level edit distance. */
export function wordErrors(want: string[], got: string[]): number {
const d = Array.from({ length: want.length + 1 }, (_, i) => [i, ...Array(got.length).fill(0)]);
for (let j = 1; j <= got.length; j++) d[0][j] = j;
for (let i = 1; i <= want.length; i++) {
for (let j = 1; j <= got.length; j++) {
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (want[i - 1] === got[j - 1] ? 0 : 1));
}
}
return d[want.length][got.length];
}
