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/build.ts
// Renders the radio script (src/game/radio-script.ts) with Piper, an offline neural
// text-to-speech engine, into src/assets/radio-voices.bin + src/game/radio-index.json.
//
//   bun tools/voices/build.ts [piperDir] [modelsDir]
//
// Defaults: ~/.cache/piper-dl/piper/piper (piper.exe from github.com/rhasspy/piper releases)
// and ~/.cache/piper-dl/models (en_US-libritts-high.onnx + .onnx.json from
// huggingface.co/rhasspy/piper-voices; LibriTTS, CC BY 4.0). Speakers are picked in
// radio-script.ts; tools/voices/audition.ts measures candidates.
//
// Each line is rendered TAKES times with Piper's expressiveness turned up (noise_scale:
// pitch/energy variation, noise_w: rhythm variation). Takes are ranked by how natural their
// pitch contour is (people's pitch moves about 2.5–4 semitones s.d. in conversation; no
// sudden jumps, no odd length); excited lines aim higher and are rendered faster and higher.
// Then pronunciation is checked: going down that ranking, each take is processed exactly as
// the game stores it and transcribed by Whisper (asr.ts), and the first take Whisper hears
// word for word is kept. The build ends by listing any clip no take got perfect; respell
// those in radio-script.ts (bun tools/voices/phonemes.ts shows what espeak makes of a word).
//
// Each chosen clip is trimmed, peak-normalised, resampled to 8 kHz (radio bandwidth) and
// stored as 4-bit IMA ADPCM, about 4 KB per second of speech.
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { decodeAdpcm, encodeAdpcm } from '../../src/game/adpcm.ts';
import { transcribe, wordErrors, words, writeWav16k } from './asr.ts';
import { CLIPS, VOICES } from '../../src/game/radio-script.ts';

const RATE = 8000;
const TAKES = 6;
const EXPRESSIVE = { noiseScale: 0.8, noiseW: 0.95 };
const DEFAULTS = { noiseScale: 0.667, noiseW: 0.8 }; // Piper's own, rendered once for comparison
const TARGET_ST = { calm: 2.8, urgent: 3.6 }; // pitch movement to aim for, semitones s.d.
const URGENT_PITCH = 1.06; // excited speech: higher and quicker

const piperDir = resolve(process.argv[2] ?? join(homedir(), '.cache/piper-dl/piper/piper'));
const modelsDir = resolve(process.argv[3] ?? join(homedir(), '.cache/piper-dl/models'));
const work = resolve('tools/voices/.work');
rmSync(work, { recursive: true, force: true });
mkdirSync(work, { recursive: true });

function readWav(path: string): { rate: number; samples: Float32Array } {
  const buf = readFileSync(path);
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
  let pos = 12, rate = 22050, data: Float32Array | null = null;
  while (pos < buf.length - 8) {
    const id = buf.toString('ascii', pos, pos + 4);
    const size = view.getUint32(pos + 4, true);
    if (id === 'fmt ') rate = view.getUint32(pos + 12, true);
    if (id === 'data') {
      data = new Float32Array(size / 2);
      for (let i = 0; i < data.length; i++) data[i] = view.getInt16(pos + 8 + i * 2, true) / 32768;
    }
    pos += 8 + size + (size & 1);
  }
  if (!data) throw new Error(`no audio in ${path}`);
  return { rate, samples: data };
}

interface Prosody {
  stdSt: number; // pitch movement, semitones standard deviation
  glitch: number; // share of voiced frame pairs with an implausible jump
  seconds: number;
}

/** Track pitch with normalised autocorrelation (40 ms frames, 10 ms hop, 70–400 Hz). */
function prosody(s: Float32Array, rate: number): Prosody {
  const win = Math.round(0.04 * rate), hop = Math.round(0.01 * rate);
  const minLag = Math.floor(rate / 400), maxLag = Math.ceil(rate / 70);
  const f0: number[] = [];
  let energyMax = 0;
  for (let i = 0; i + win + maxLag < s.length; i += hop) {
    let e = 0;
    for (let k = 0; k < win; k++) e += s[i + k] * s[i + k];
    energyMax = Math.max(energyMax, e);
  }
  for (let i = 0; i + win + maxLag < s.length; i += hop) {
    let e0 = 0;
    for (let k = 0; k < win; k++) e0 += s[i + k] * s[i + k];
    if (e0 < energyMax * 0.02) {
      f0.push(0);
      continue;
    }
    let best = 0, bestLag = 0;
    for (let lag = minLag; lag <= maxLag; lag++) {
      let c = 0, e1 = 0;
      for (let k = 0; k < win; k++) {
        c += s[i + k] * s[i + k + lag];
        e1 += s[i + k + lag] * s[i + k + lag];
      }
      const r = c / Math.sqrt(e0 * e1 + 1e-12);
      if (r > best) {
        best = r;
        bestLag = lag;
      }
    }
    f0.push(best > 0.6 ? rate / bestLag : 0);
  }
  // Median-filter in semitones to drop single-frame octave slips before measuring spread.
  const st = f0.map((f) => (f ? 12 * Math.log2(f / 100) : NaN));
  const smooth: number[] = [];
  let jumps = 0, pairs = 0;
  for (let i = 0; i < st.length; i++) {
    if (Number.isNaN(st[i])) continue;
    if (i > 0 && !Number.isNaN(st[i - 1])) {
      pairs++;
      if (Math.abs(st[i] - st[i - 1]) > 3) jumps++;
    }
    const near = st.slice(Math.max(0, i - 2), i + 3).filter((v) => !Number.isNaN(v)).sort((a, b) => a - b);
    smooth.push(near[near.length >> 1]);
  }
  const mean = smooth.reduce((a, b) => a + b, 0) / Math.max(1, smooth.length);
  const variance = smooth.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, smooth.length);
  return { stdSt: Math.sqrt(variance), glitch: pairs ? jumps / pairs : 1, seconds: s.length / rate };
}

/** Windowed-sinc resample; `pitch` > 1 reads the source faster, raising the voice. */
function resample(input: Float32Array, rateIn: number, pitch: number): Float32Array {
  const effective = rateIn * pitch;
  const n = Math.floor((input.length / effective) * RATE);
  const out = new Float32Array(n);
  const cutoff = Math.min(0.45 * RATE, 0.45 * effective) / effective; // cycles per input sample
  const half = 24;
  for (let i = 0; i < n; i++) {
    const x = (i / RATE) * effective;
    const k0 = Math.floor(x);
    let acc = 0;
    for (let k = k0 - half; k <= k0 + half; k++) {
      if (k < 0 || k >= input.length) continue;
      const t = x - k;
      const sinc = t === 0 ? 2 * cutoff : Math.sin(2 * Math.PI * cutoff * t) / (Math.PI * t);
      const w = 0.42 + 0.5 * Math.cos((Math.PI * t) / half) + 0.08 * Math.cos((2 * Math.PI * t) / half);
      acc += input[k] * sinc * w;
    }
    out[i] = acc;
  }
  return out;
}

function trimAndNormalise(s: Float32Array): Float32Array {
  let peak = 0;
  for (const v of s) peak = Math.max(peak, Math.abs(v));
  const gate = peak * 0.012; // gentle: quiet word endings ("forty") must survive
  let a = 0, b = s.length - 1;
  while (a < b && Math.abs(s[a]) < gate) a++;
  while (b > a && Math.abs(s[b]) < gate) b--;
  const pad = Math.round(0.08 * RATE);
  const out = s.slice(Math.max(0, a - pad), Math.min(s.length, b + pad));
  for (let i = 0; i < out.length; i++) out[i] = (out[i] / peak) * 0.95;
  return out;
}

// Render with one Piper process per voice setting. Every clip gets TAKES expressive takes,
// plus one take at Piper's defaults for the comparison report; clips no take of which Whisper
// understands get up to EXTRA_ROUNDS more batches of takes.
const EXTRA_ROUNDS = 3;
const voiceOf = (key: string) => {
  const clip = CLIPS[key];
  const v = VOICES[clip.who];
  const scale = clip.urgent ? v.lengthScale * 0.92 : v.lengthScale;
  return { model: v.model, speaker: String(v.speaker), lengthScale: scale.toFixed(3) };
};
const fileOf = (key: string, take: number | 'base') => join(work, `${key.replace(/[^a-z0-9.]+/gi, '_')}.${take}.wav`);

function render(keys: string[], takes: number[], params: { noiseScale: number; noiseW: number }, base = false): void {
  const groups = new Map<string, string[]>();
  for (const key of keys) {
    const v = voiceOf(key);
    const group = `${v.model}|${v.speaker}|${v.lengthScale}`;
    groups.set(group, [...(groups.get(group) ?? []), key]);
  }
  for (const [group, groupKeys] of groups) {
    const [model, speaker, lengthScale] = group.split('|');
    const lines = groupKeys.flatMap((key) => (base ? ['base' as const] : takes).map((t) =>
      JSON.stringify({ text: CLIPS[key].say, output_file: fileOf(key, t) })));
    const proc = Bun.spawnSync([join(piperDir, 'piper.exe'), '--model', join(modelsDir, `${model}.onnx`), '--speaker', speaker,
      '--length_scale', lengthScale, '--noise_scale', String(params.noiseScale), '--noise_w', String(params.noiseW),
      '--sentence_silence', '0.15', '--json-input', '--quiet'], { stdin: Buffer.from(lines.join('\n') + '\n'), cwd: piperDir });
    if (proc.exitCode !== 0) throw new Error(`piper failed for ${model}: ${proc.stderr.toString()}`);
    if (!base) console.log(`${model} #${speaker} x${lengthScale}: ${groupKeys.length} clips x ${takes.length} takes`);
  }
}

interface Take { wav: { rate: number; samples: Float32Array }; p: Prosody }
interface Pick { take: Take; pcm: Float32Array; errors: number; heard: string }
const picks = new Map<string, Pick>();
const ranked = new Map<string, number>(); // how many takes each clip has had

/** Go through new takes, most natural first, and keep the best-understood (stopping at perfect). */
function choose(key: string, takeIds: number[]): void {
  const clip = CLIPS[key];
  const target = clip.urgent ? TARGET_ST.urgent : TARGET_ST.calm;
  const takes: Take[] = takeIds.map((t) => {
    const wav = readWav(fileOf(key, t));
    return { wav, p: prosody(wav.samples, wav.rate) };
  });
  const medianLen = takes.map((t) => t.p.seconds).sort((a, b) => a - b)[takes.length >> 1];
  const score = ({ p }: Take) =>
    -Math.abs(p.stdSt - target) - 25 * p.glitch - 4 * Math.max(0, Math.abs(p.seconds / medianLen - 1) - 0.15);
  const pitch = VOICES[clip.who].pitch * (clip.urgent ? URGENT_PITCH : 1);
  const want = words(clip.show ?? clip.say);
  for (const take of [...takes].sort((a, b) => score(b) - score(a))) {
    const pcm = trimAndNormalise(resample(take.wav.samples, take.wav.rate, pitch));
    const check = join(work, 'check.wav');
    writeWav16k(check, decodeAdpcm(encodeAdpcm(pcm), 0, pcm.length), RATE); // exactly as stored
    const heard = transcribe(check);
    const errors = wordErrors(want, words(heard));
    const best = picks.get(key);
    if (!best || errors < best.errors) picks.set(key, { take, pcm, errors, heard });
    if (errors === 0) break;
  }
  ranked.set(key, (ranked.get(key) ?? 0) + takeIds.length);
}

const allKeys = Object.keys(CLIPS);
const firstTakes = Array.from({ length: TAKES }, (_, t) => t);
render(allKeys, firstTakes, EXPRESSIVE);
render(allKeys, [], DEFAULTS, true);
for (const key of allKeys) choose(key, firstTakes);
for (let round = 1; round <= EXTRA_ROUNDS; round++) {
  const unclear = allKeys.filter((key) => picks.get(key)!.errors > 0);
  if (!unclear.length) break;
  console.log(`round ${round + 1}: ${unclear.length} clip(s) not yet understood word for word, rendering more takes`);
  const more = Array.from({ length: TAKES }, (_, t) => round * TAKES + t);
  render(unclear, more, EXPRESSIVE);
  for (const key of unclear) choose(key, more);
}

const parts: Uint8Array[] = [];
const index: Record<string, [number, number]> = {};
const report = new Map<string, { chosen: number[]; base: number[]; glitch: number[] }>();
const understood: [string, number, string][] = [];
let offset = 0, seconds = 0;
for (const [key, clip] of Object.entries(CLIPS)) {
  const best = picks.get(key)!;
  understood.push([key, best.errors, best.heard]);
  const base = readWav(fileOf(key, 'base'));
  const r = report.get(clip.who) ?? { chosen: [], base: [], glitch: [] };
  r.chosen.push(best.take.p.stdSt);
  r.glitch.push(best.take.p.glitch);
  r.base.push(prosody(base.samples, base.rate).stdSt);
  report.set(clip.who, r);

  const pcm = best.pcm;
  const bytes = encodeAdpcm(pcm);
  index[key] = [offset, pcm.length];
  parts.push(bytes);
  offset += bytes.length;
  seconds += pcm.length / RATE;
}

const blob = new Uint8Array(offset);
let at = 0;
for (const p of parts) {
  blob.set(p, at);
  at += p.length;
}
mkdirSync('src/assets', { recursive: true });
writeFileSync('src/assets/radio-voices.bin', blob);
writeFileSync('src/game/radio-index.json', JSON.stringify({ rate: RATE, clips: index }) + '\n');
rmSync(work, { recursive: true, force: true });

const avg = (a: number[]) => a.reduce((x, y) => x + y, 0) / a.length;
console.log('\npitch movement (semitones s.d.; conversational speech is ~2.5-4):');
for (const [who, r] of report) {
  console.log(`  ${who.padEnd(9)} Piper defaults ${avg(r.base).toFixed(2)}  ->  chosen takes ${avg(r.chosen).toFixed(2)}   glitchy frames ${(avg(r.glitch) * 100).toFixed(1)}%`);
}
console.log(`${Object.keys(index).length} clips, ${seconds.toFixed(0)} s of speech, ${(offset / 1024).toFixed(0)} KB`);
const unclear = understood.filter(([, errors]) => errors > 0);
console.log(`\nWhisper understood ${understood.length - unclear.length}/${understood.length} clips word for word.`);
for (const [key, errors, heard] of unclear) console.log(`  ${key}: ${errors} word(s) off, heard "${heard}"`);