Back to Pursuit PIT
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.

tools/voices/asr.ts
// 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];
}