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.
// Voice audition by measurement: renders one radio line with each candidate voice and
// reports the acoustic features that separate human speech from robotic TTS.
//
// bun tools/voices/audition.ts [count] (count = libritts-high speakers to try, default 60)
//
// - f0: median pitch (Hz), for male/female
// - intonation: pitch movement over the line, semitones s.d. (conversation ~2.5-4)
// - jitter: frame-to-frame pitch wobble inside steady voicing, % (people ~1-3% at this
// 10 ms resolution; synthetic voices are often suspiciously steady)
// - shimmer: frame-to-frame loudness wobble in voiced speech, dB (people ~0.4-1 dB here)
// - hnr: harmonics-to-noise ratio, dB (connected human speech ~8-15; buzzy vocoders run high)
// - glitch: sudden pitch jumps, % of frames
// Results are written to tools/voices/audition.json for build.ts to pick speakers from.
import { readFileSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
const LINE = "Fourteen, uh, still on him, northbound. We're doing about one-forty, he's not slowing down.";
const dir = join(homedir(), '.cache/piper-dl');
const piper = join(dir, 'piper/piper/piper.exe');
const work = resolve('tools/voices/.audition');
rmSync(work, { recursive: true, force: true });
mkdirSync(work, { recursive: true });
const count = Number(process.argv[2] ?? 60);
function readWav(path: string): { rate: number; s: Float32Array } {
const buf = readFileSync(path);
const rate = buf.readUInt32LE(24);
const s = new Float32Array((buf.length - 44) / 2);
for (let i = 0; i < s.length; i++) s[i] = buf.readInt16LE(44 + i * 2) / 32768;
return { rate, s };
}
export interface Features { f0: number; intonation: number; jitter: number; shimmer: number; hnr: number; glitch: number }
export function features(s: Float32Array, rate: number): Features {
const win = Math.round(0.03 * rate), hop = Math.round(0.01 * rate);
const minLag = Math.floor(rate / 400), maxLag = Math.ceil(rate / 65);
const frames: { f0: number; r: number; db: number }[] = [];
let emax = 0;
const energies: number[] = [];
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];
energies.push(e);
emax = Math.max(emax, e);
}
let fi = 0;
for (let i = 0; i + win + maxLag < s.length; i += hop, fi++) {
const e0 = energies[fi];
if (e0 < emax * 0.03) { frames.push({ f0: 0, r: 0, db: 0 }); continue; }
let best = 0, bestLag = 0;
const rs: number[] = [];
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] ** 2; }
const r = c / Math.sqrt(e0 * e1 + 1e-12);
rs.push(r);
if (r > best) { best = r; bestLag = lag; }
}
// Parabolic refinement of the peak for sub-sample pitch.
const j = bestLag - minLag;
let lag = bestLag;
if (j > 0 && j < rs.length - 1) {
const a = rs[j - 1], b = rs[j], c = rs[j + 1];
const d = a - 2 * b + c;
if (d < 0) lag += (0.5 * (a - c)) / d;
}
frames.push(best > 0.6 ? { f0: rate / lag, r: best, db: 10 * Math.log10(e0 / win + 1e-12) } : { f0: 0, r: 0, db: 0 });
}
const voiced = frames.filter((f) => f.f0 > 0);
const st = voiced.map((f) => 12 * Math.log2(f.f0 / 100));
const mean = st.reduce((a, b) => a + b, 0) / st.length;
const intonation = Math.sqrt(st.reduce((a, b) => a + (b - mean) ** 2, 0) / st.length);
let jSum = 0, jN = 0, sSum = 0, sN = 0, jumps = 0, pairs = 0;
for (let i = 1; i < frames.length; i++) {
const a = frames[i - 1], b = frames[i];
if (!a.f0 || !b.f0) continue;
pairs++;
const dst = Math.abs(12 * Math.log2(b.f0 / a.f0));
if (dst > 3) { jumps++; continue; }
if (dst < 1) { jSum += Math.abs(b.f0 - a.f0) / ((a.f0 + b.f0) / 2); jN++; }
sSum += Math.abs(b.db - a.db); sN++;
}
const rMean = voiced.reduce((a, f) => a + Math.min(0.999, f.r), 0) / voiced.length;
const f0s = voiced.map((f) => f.f0).sort((a, b) => a - b);
return {
f0: f0s[f0s.length >> 1],
intonation,
jitter: (jSum / Math.max(1, jN)) * 100,
shimmer: sSum / Math.max(1, sN),
hnr: 10 * Math.log10(rMean / (1 - rMean)),
glitch: (jumps / Math.max(1, pairs)) * 100,
};
}
function render(model: string, speaker: number | null, out: string): void {
const args = [piper, '--model', join(dir, 'models', `${model}.onnx`), '--output_file', out, '--quiet'];
if (speaker !== null) args.push('--speaker', String(speaker));
const p = Bun.spawnSync(args, { stdin: Buffer.from(LINE + '\n'), cwd: join(dir, 'piper/piper') });
if (p.exitCode !== 0) throw new Error(p.stderr.toString());
}
if (import.meta.main) {
const results: (Features & { voice: string; model: string; speaker: number | null })[] = [];
for (const model of ['en_US-kristin-medium', 'en_US-joe-medium', 'en_US-john-medium', 'en_US-norman-medium']) {
const out = join(work, `${model}.wav`);
render(model, null, out);
const w = readWav(out);
results.push({ voice: model.replace('en_US-', '').replace('-medium', ''), model, speaker: null, ...features(w.s, w.rate) });
}
// A spread of libritts-high speakers (deterministic picks across the 904).
for (let i = 0; i < count; i++) {
const speaker = Math.floor(((i * 0.6180339887) % 1) * 904);
const out = join(work, `libritts-${speaker}.wav`);
render('en_US-libritts-high', speaker, out);
const w = readWav(out);
results.push({ voice: `libritts #${speaker}`, model: 'en_US-libritts-high', speaker, ...features(w.s, w.rate) });
}
rmSync(work, { recursive: true, force: true });
writeFileSync('tools/voices/audition.json', JSON.stringify(results, null, 1) + '\n');
const fmt = (r: (typeof results)[number]) =>
`${r.voice.padEnd(15)} f0 ${r.f0.toFixed(0).padStart(3)} Hz inton ${r.intonation.toFixed(2)} jitter ${r.jitter.toFixed(2)}% shimmer ${r.shimmer.toFixed(2)} dB hnr ${r.hnr.toFixed(1)} dB glitch ${r.glitch.toFixed(1)}%`;
console.log('current voices:');
results.slice(0, 4).forEach((r) => console.log(' ' + fmt(r)));
const lib = results.slice(4);
const avg = (k: keyof Features) => lib.reduce((a, r) => a + r[k], 0) / lib.length;
console.log(`libritts-high average over ${lib.length} speakers: inton ${avg('intonation').toFixed(2)} jitter ${avg('jitter').toFixed(2)}% shimmer ${avg('shimmer').toFixed(2)} dB hnr ${avg('hnr').toFixed(1)} dB glitch ${avg('glitch').toFixed(1)}%`);
}
