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/audition.ts
// 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)}%`);
}