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.
// 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}"`);
