Files
christianandClaude Opus 5 c9eef0672c Church Translate, as it runs at the campuses
Live sermon translation into 100+ languages, running entirely on one Mac per
campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own
process, and a built-in web server the congregation reads from on their
phones. No audio leaves the building.

This is the source only. What used to sit beside it — 14 GB of speech and
translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron
caches, and the app data holding the operator PIN and every service transcript
— is machine state and downloaded assets, and stays in the NAS backup where it
belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too
(533 MB, re-fetched for packaging); the browser-side runtime beside them is
kept, so a checkout still serves the congregation page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:41:21 -04:00

1224 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* voice-audit.mjs — does each built-in voice actually SAY what it was given?
*
* The captions can be perfect while the audio is wrong. Text goes through
* espeak-ng's phonemizer before it ever reaches the VITS model, and a wrong
* espeak voice, a missing dictionary, or a numeral read in the wrong language
* all produce fluent-sounding audio that says something else. Nobody on staff
* speaks thirty-one languages, so the ear cannot be the check. This is.
*
* The loop, per language:
*
* reference text (in-language)
* -> sherpa-onnx VITS, the exact config tts-service.js uses
* -> WAV (optionally through the same afconvert AAC the listeners get)
* -> Whisper large-v3-turbo, language FORCED to the target
* -> normalized character similarity against the reference
*
* Big divergence means the voice mispronounced it, dropped it, or is the wrong
* voice entirely. Whisper is an imperfect judge, so the scoring is built to
* avoid crying wolf — see "FALSE-POSITIVE CONTROLS" below. A harness that fails
* clean languages gets ignored, which is worse than no harness.
*
* MEMORY. A loaded VITS voice costs ~350 MB of ONNX arena and sherpa-onnx-node
* exposes no free() — the native handle goes when the JS finalizer runs, which
* is not something to bet a 31-language sweep on. So synthesis for each language
* happens in a short-lived CHILD process that exits when the language is done,
* and the OS reclaims the arena. Peak is one voice plus one ASR model, never a
* pile of voices.
*
* EXIT CODE. Whisper lives in a child too, and for a different reason: with
* onnxruntime-node loaded, this process aborts during teardown —
*
* libc++abi: terminating due to uncaught exception of type
* std::__1::system_error: mutex lock failed: Invalid argument
*
* — turning a perfectly clean audit into exit 134. A release gate that reports
* failure when everything passed is worse than no gate, so the parent stays pure
* JavaScript: it loads no native model, orchestrates the children, does the
* scoring, and exits with the code it actually means.
*
* PLAIN NODE ONLY. Same reason as mt-service.js and tts-service.js: Chromium's
* PartitionAlloc traps on the allocations a model load makes. Never run this
* inside Electron.
*
* ---------------------------------------------------------------------------
* FALSE-POSITIVE CONTROLS
*
* Every one of these exists because a naive string compare fails clean audio:
*
* 1. Normalization. NFKC, casefold, punctuation stripped, whitespace collapsed.
* Whisper writes "Jesús dijo, yo soy" where the reference says
* "Jesús dijo: Yo soy" — identical speech, different glyphs.
* 2. Character-level similarity, not exact match. One wrong word in a
* twelve-word sentence should cost a little, not everything.
* 3. …but characters ALONE are too kind to short sentences, so a word-level
* score has to clear its own bar too. "Oremos juntos" came back as "aremos
* puntos" — both words wrong, two characters different, 0.846 on characters
* and a pass it did not deserve. On words it is 0.0. Scripts without word
* breaks (Chinese, Japanese, Thai, Khmer, Lao, Burmese) get characters only,
* with spaces stripped from both sides, because Whisper's word breaks there
* are arbitrary and carry no information.
* 4. Numerals, in BOTH directions. A sentence is scored strictly and again
* "numeral-blind" whenever either side contains digits, and the better score
* wins with the variant named in the report. It has to work both ways: a
* reference reading "3:16" meets a transcript that spells it out, and a
* reference reading "veintitrés" meets a transcript that writes "23". All
* four Spanish voices scored an identical 0.62 on "Salmo veintitrés" purely
* because Whisper wrote digits — four voices failing identically is the
* signature of an orthography artifact, not a defect. The leniency is capped
* at three dropped tokens per numeral so it cannot become a free pass.
* 5. Diacritics. Never folded for scoring — a wrong vowel is a real defect —
* but a folded score is reported alongside so a human can tell instantly
* whether accents were the entire difference.
* 6. ASR failure is not TTS failure. An empty transcript, one that loops a
* phrase, one that is a known Whisper hallucination (it answered "Amén."
* with "¡Gracias por ver el video!"), or audio too short to hold the text at
* all become ASR_EMPTY / ASR_LOOP / ASR_HALLUCINATION / SHORT_AUDIO and are
* reported without counting for or against the language.
* 7. Every failure is re-heard before it is believed. VITS samples noise on
* each call, so the same text synthesized twice is not the same audio — a
* single unlucky take can fail a healthy voice. A FAIL is resynthesized
* (--retakes, default 2) and judged on the MEDIAN of the takes, never the
* best, so retries cannot buy a broken voice a pass. All takes are printed:
* three different garbles of the same words is proof the voice is at fault.
* 8. Two duration checks that do not involve Whisper at all. Seconds-per-
* character far below the language's own median means text was dropped
* before it was ever spoken. And an absolute floor besides, because when a
* voice is broken for EVERY sentence the language's own median is broken
* too and the relative test sees nothing wrong — which is exactly how the
* Ukrainian voice hid.
* 9. Audio over Whisper's 30 s window is chunked rather than silently
* truncated to its first 30 seconds and scored against text it never heard.
* 10. --selftest is the falsification test. It synthesizes each language's text
* with a DIFFERENT language's voice and asserts the score collapses. If the
* wrong voice scores as well as the right one, the harness has no
* discriminating power and its PASSes mean nothing — so it says so and
* exits non-zero. Only languages whose own voice works can be targets:
* asking whether the right voice beats a wrong one is meaningless when the
* right voice is itself broken.
*
* WHAT THIS DOES NOT PROVE. The negative control also measures the method's
* ceiling, and reports it: within one script the audit cannot tell a Spanish
* voice from a Portuguese one, because sherpa takes the espeak voice from the
* model's own metadata and a Portuguese phonemization of Spanish words is still
* legible to Whisper. This verifies that a voice says the RIGHT WORDS. It does
* not verify that it has the right accent.
*
* ---------------------------------------------------------------------------
* USAGE
*
* node tools/voice-audit.mjs # every installed voice
* node tools/voice-audit.mjs spa_Latn deu_Latn # named FLORES codes
* node tools/voice-audit.mjs --all # all 31, downloading as needed
* node tools/voice-audit.mjs --aac # audit the AAC listeners hear
* node tools/voice-audit.mjs --selftest # prove the harness discriminates
* node tools/voice-audit.mjs --keep-audio # leave the WAVs for a human ear
* node tools/voice-audit.mjs --json out.json # machine-readable results
*
* # audition a replacement voice against the identical corpus
* node tools/voice-audit.mjs spa_Latn --voice-bundle vits-piper-es_MX-claude-high
*
* Exits non-zero if any language is SUSPECT, so it can gate a release.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFileSync, fork, spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const APP = path.join(HERE, '..');
const voices = require(path.join(APP, 'voices.js'));
const VOICE_DIR = path.join(os.homedir(), 'Library/Application Support/Church Translate Voices');
const MODELS_DIR = path.join(os.homedir(), 'Library/Application Support/Church Translate Models');
const CORPUS_FILE = path.join(HERE, 'voice-audit-corpus.json');
/* A sentence must clear BOTH bars — see wordSimilarity() for why characters
alone are not enough. A language is SUSPECT below either language bar. */
const SENTENCE_PASS = 0.80; // character similarity
const SENTENCE_PASS_WORDS = 0.55; // word similarity, space-delimited scripts only
const LANG_MIN_PASS_RATE = 0.80;
const LANG_MIN_MEAN = 0.85;
/*
* Han script gets its own, looser bars — not to be kind to it, but because the
* measurement genuinely means something different there.
*
* Chinese ASR picks characters, and Mandarin is full of homophones, so a
* PERFECTLY pronounced sentence still comes back with different glyphs:
* 耶稣基督 (Yēsū Jīdū) transcribed as 夜宿季度 (yèsù jìdù), 把 as 拜, 洁净 as
* 结尽. Every one of those is the right sound and the wrong character, and no
* amount of normalization can tell them apart without a pronunciation table
* this tool does not carry. Scoring them as defects would fail a healthy voice
* three sentences out of twelve — the definition of crying wolf.
*
* The bars below sit between what a working Han voice scores and what a broken
* one does, and the separation is not subtle: the real Chinese voice measured
* 0.878-0.904 across runs, while the negative control (Chinese text spoken by
* the Spanish voice) measured 0.000. A voice as broken as Ukrainian's would be
* caught here just as loudly.
*
* The honest fix is to compare pinyin rather than characters. That needs a
* romanization table nobody has vendored yet, so until then the report says
* plainly that a Han-script PASS means "said something that sounds right", and
* the per-sentence evidence is printed for a reader who can adjudicate.
*/
const HAN_MIN_PASS_RATE = 0.60;
const HAN_MIN_MEAN = 0.78;
const HAN_SCRIPTS = new Set(['Hans', 'Hant', 'Jpan']);
/* FLORES codes are <lang>_<Script>; the script half decides several thresholds. */
const scriptOf = (flores) => String(flores).split('_')[1] || '';
/*
* No script on earth is spoken faster than this. Audio below it cannot contain
* the text it was given, whatever the ASR reports — and unlike the per-language
* median check it still fires when EVERY sentence in a language is truncated,
* which is exactly what a wholly broken voice looks like.
*/
const ABSOLUTE_MIN_SEC_PER_CHAR = 0.030;
/* The negative control must land at least this far below the real voice, or the
scoring cannot tell a right voice from a wrong one. */
const SELFTEST_MIN_GAP = 0.25;
/* How many extra renditions to score when a sentence fails. VITS samples noise
on every call (noise_scale 0.667), so two runs of the same text are not the
same audio and a single unlucky take can fail a healthy voice. */
const DEFAULT_RETAKES = 2;
// ============================================================ reference corpus
/*
* Canonical English set: 12 sentences chosen to exercise the places a
* phonemizer actually breaks — numerals and scripture references, proper nouns
* that are not in any pronunciation dictionary, long clause-heavy sentences
* where a voice can silently truncate, and plain service announcements.
*/
const ENGLISH_REFERENCE = [
{ id: 'greeting', kind: 'prose', text: 'Good morning, and welcome to church. We are glad you are here today.' },
{ id: 'ref-john', kind: 'numeric', text: 'Please turn in your Bible to John chapter 3, verse 16.' },
{ id: 'names-1', kind: 'proper', text: 'Jesus Christ is the same yesterday, today, and forever.' },
{ id: 'names-2', kind: 'proper', text: 'The Holy Spirit gives us peace, patience, kindness, and self control.' },
{ id: 'long-1', kind: 'long', text: 'For God so loved the world that he gave his only Son, so that everyone who believes in him should not perish but have eternal life.' },
{ id: 'short-1', kind: 'prose', text: 'Let us pray together.' },
{ id: 'ref-psalm', kind: 'numeric', text: 'We will read from Psalm 23 and then from Romans 8, verses 28 through 30.' },
{ id: 'names-3', kind: 'proper', text: 'The apostle Paul wrote this letter to the church in Corinth while he was in prison.' },
{ id: 'prose-2', kind: 'prose', text: 'Give thanks to the Lord, for he is good, and his love endures forever.' },
{ id: 'prose-3', kind: 'prose', text: 'The offering will be received now, and the ushers will come to your row.' },
{ id: 'names-4', kind: 'proper', text: 'Grace and peace to you from God our Father and the Lord Jesus Christ.' },
{ id: 'long-2', kind: 'long', text: 'If we confess our sins, he is faithful and just to forgive us our sins and to cleanse us from all unrighteousness.' }
];
function loadCorpus() {
try { return JSON.parse(fs.readFileSync(CORPUS_FILE, 'utf8')); } catch (_) { return {}; }
}
function saveCorpus(c) {
fs.writeFileSync(CORPUS_FILE, JSON.stringify(c, null, 2) + '\n');
}
/*
* In-language reference text. Cached in voice-audit-corpus.json so a normal run
* needs nothing but the voice; when a language is missing, it is produced by the
* app's OWN translator — the same NLLB-200 process the service runs — rather
* than by some other engine whose output nobody here can check. That also makes
* the corpus the exact text distribution the voices meet in production.
*
* `source` records provenance per language: "human" text is hand-checked,
* "nllb" text came from the app's translator.
*/
async function ensureCorpus(langs, corpus) {
const missing = langs.filter((l) => !corpus[l] || !corpus[l].sentences || !corpus[l].sentences.length);
if (!missing.length) return corpus;
console.log('corpus: generating reference text for ' + missing.join(', ') + ' via the app\'s own NLLB (mt-service.js)');
const child = fork(path.join(APP, 'mt-service.js'), [], { stdio: ['ignore', 'inherit', 'inherit', 'ipc'] });
const pending = new Map();
let seq = 0;
const ready = new Promise((resolve, reject) => {
child.on('message', (m) => {
if (m.type === 'ready') resolve();
else if (m.type === 'loadError' || m.type === 'fatal') reject(new Error(m.message));
else if (m.type === 'result' || m.type === 'error') {
const p = pending.get(m.id);
if (!p) return;
pending.delete(m.id);
if (m.type === 'error') p.reject(new Error(m.message));
else p.resolve(m.text);
}
});
child.on('exit', (c) => reject(new Error('mt-service exited with ' + c)));
});
child.send({ type: 'load', model: 'Xenova/nllb-200-distilled-600M', dtype: 'fp32', cacheDir: MODELS_DIR });
await ready;
const translate = (text, tgt) => new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
child.send({ type: 'translate', id, text, src: 'eng_Latn', tgt });
});
for (const lang of missing) {
const out = [];
for (const s of ENGLISH_REFERENCE) {
/* NLLB is sentence-level; a two-sentence reference must go one at a time
or it translates the first and quietly stops. */
const parts = s.text.split(/(?<=[.!?])\s+/).filter(Boolean);
const done = [];
for (const p of parts) done.push(await translate(p, lang));
out.push({ id: s.id, kind: s.kind, text: done.join(' ').trim() });
process.stdout.write('.');
}
process.stdout.write(' ' + lang + '\n');
corpus[lang] = { source: 'nllb', model: 'Xenova/nllb-200-distilled-600M', sentences: out };
}
child.kill();
saveCorpus(corpus);
return corpus;
}
// ================================================================ voice bundle
function voicePath(v) { return path.join(VOICE_DIR, v.bundle); }
function isInstalled(v) {
try { return !!v && fs.existsSync(path.join(voicePath(v), v.model)); } catch (_) { return false; }
}
/* Same fetch-and-unpack as installVoice() in tts-service.js, minus the IPC
progress messages there is no parent here to receive. */
function installVoice(v) {
fs.mkdirSync(VOICE_DIR, { recursive: true });
const url = voices.bundleUrl(v.bundle);
const tmp = path.join(VOICE_DIR, v.bundle + '.tar.bz2');
console.log(' downloading ' + v.bundle + ' …');
let r = spawnSync('/usr/bin/curl', ['-sL', '--fail', '-o', tmp, url], { stdio: 'inherit' });
if (r.status !== 0) throw new Error('download failed for ' + v.bundle);
r = spawnSync('/usr/bin/tar', ['xjf', tmp, '-C', VOICE_DIR], { stdio: 'inherit' });
try { fs.unlinkSync(tmp); } catch (_) {}
if (r.status !== 0) throw new Error('unpack failed for ' + v.bundle);
}
// =================================================================== synthesis
/*
* Child-process mode. Loads ONE voice, writes one WAV per sentence, prints a
* JSON manifest on the last line of stdout, and exits — which is the only
* reliable way to give back the ~350 MB the ONNX arena took.
*/
async function synthWorker(argv) {
const bundleDir = argv[0];
const voiceJson = argv[1];
const outDir = argv[2];
const jobsFile = argv[3];
const wantAac = argv[4] === 'aac';
const takes = Math.max(1, Number(argv[5]) || 1);
const voice = JSON.parse(voiceJson);
const sherpa = require('sherpa-onnx-node');
const jobs = JSON.parse(fs.readFileSync(jobsFile, 'utf8'));
/* voices.ttsConfig is the SAME function tts-service.js calls, deliberately —
this used to be a hand-copied duplicate, which is exactly how the Chinese
voice ended up audited with a config the app does not use. */
const tts = new sherpa.OfflineTts(voices.ttsConfig(bundleDir, voice));
fs.mkdirSync(outDir, { recursive: true });
const results = [];
for (const job of jobs) {
/* Every take is a fresh generate(), so each one draws new noise — that is
the whole point. Synthesis runs ~20x real time, so the extra takes cost
almost nothing next to the ASR they may never need. */
const renditions = [];
for (let k = 0; k < takes; k++) {
const t0 = Date.now();
const audio = tts.generate({ text: String(job.text || ''), sid: Number(voice.sid) || 0, speed: 1.0 });
const wav = path.join(outDir, job.id + '.t' + k + '.wav');
sherpa.writeWave(wav, { samples: audio.samples, sampleRate: audio.sampleRate });
let file = wav;
if (wantAac) {
/* The exact encode from tts-service.js encodeAac(), so the audit hears
what a listener's phone actually decodes. */
const m4a = path.join(outDir, job.id + '.t' + k + '.m4a');
execFileSync('/usr/bin/afconvert', ['-f', 'm4af', '-d', 'aac', '-b', '48000', wav, m4a]);
file = m4a;
}
renditions.push({
file,
seconds: audio.samples.length / audio.sampleRate,
sampleRate: audio.sampleRate,
synthMs: Date.now() - t0
});
}
results.push({
id: job.id,
takes: renditions,
/* First take's numbers, for callers that only want one. */
file: renditions[0].file,
seconds: renditions[0].seconds,
sampleRate: renditions[0].sampleRate,
synthMs: renditions[0].synthMs
});
}
process.stdout.write('\n__AUDIT_JSON__' + JSON.stringify(results) + '\n');
}
function runSynth(bundleDir, voice, outDir, jobs, wantAac, takes = 1) {
const jobsFile = path.join(outDir, '_jobs.json');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(jobsFile, JSON.stringify(jobs));
const r = spawnSync(process.execPath, [
fileURLToPath(import.meta.url), '--synth-worker',
bundleDir, JSON.stringify(voice), outDir, jobsFile, wantAac ? 'aac' : 'wav', String(takes)
], { encoding: 'utf8', maxBuffer: 1 << 26 });
if (r.status !== 0) {
throw new Error('synthesis child failed (' + r.status + '): ' + (r.stderr || '').slice(-2000));
}
const line = (r.stdout || '').split('\n').find((l) => l.startsWith('__AUDIT_JSON__'));
if (!line) throw new Error('synthesis child produced no manifest: ' + (r.stdout || '').slice(-2000));
return JSON.parse(line.slice('__AUDIT_JSON__'.length));
}
// ========================================================================= ASR
/* afconvert ships with macOS, so the resample costs no dependency. Whisper wants
16 kHz mono float; anything else is silently wrong rather than loud. */
function pcm16k(src) {
const out = src.replace(/\.(wav|m4a)$/, '') + '-16k.wav';
execFileSync('/usr/bin/afconvert', ['-f', 'WAVE', '-d', 'LEI16@16000', '-c', '1', src, out]);
const b = fs.readFileSync(out);
let off = 12;
while (off < b.length - 8) {
const id = b.toString('ascii', off, off + 4);
const size = b.readUInt32LE(off + 4);
if (id === 'data') {
const n = Math.floor(size / 2);
const f = new Float32Array(n);
for (let i = 0; i < n; i++) f[i] = b.readInt16LE(off + 8 + i * 2) / 32768;
try { fs.unlinkSync(out); } catch (_) {}
return f;
}
off += 8 + size + (size % 2);
}
throw new Error('no data chunk in ' + out);
}
/* FLORES -> Whisper language token, for every language that has a voice. Whisper
supports all 31; nothing here needs a fallback, and a missing entry is a bug
worth surfacing rather than papering over with English. */
const WHISPER_LANG = {
eng_Latn: 'en', spa_Latn: 'es', por_Latn: 'pt', fra_Latn: 'fr', zho_Hans: 'zh',
vie_Latn: 'vi', arb_Arab: 'ar', rus_Cyrl: 'ru', ukr_Cyrl: 'uk', deu_Latn: 'de',
pol_Latn: 'pl', ron_Latn: 'ro', swh_Latn: 'sw', pes_Arab: 'fa', tur_Latn: 'tr',
hin_Deva: 'hi', npi_Deva: 'ne', ita_Latn: 'it', nld_Latn: 'nl', ces_Latn: 'cs',
hun_Latn: 'hu', ell_Grek: 'el', srp_Cyrl: 'sr', kat_Geor: 'ka', kaz_Cyrl: 'kk',
swe_Latn: 'sv', dan_Latn: 'da', fin_Latn: 'fi', cat_Latn: 'ca', urd_Arab: 'ur',
kor_Hang: 'ko'
};
/*
* Child-process mode for the ASR side. Holds Whisper for the whole run — it is
* the same model for every language, so reloading it per language would be pure
* waste — and is killed outright at the end, which is why its native teardown
* abort can never reach the parent's exit code.
*/
async function asrWorker(argv) {
const model = argv[0];
const dtype = argv[1];
const { pipeline, env } = await import('@huggingface/transformers');
env.allowLocalModels = false;
env.cacheDir = MODELS_DIR;
const t0 = Date.now();
const pipe = await pipeline('automatic-speech-recognition', model, { dtype, device: 'cpu' });
process.send({ type: 'ready', ms: Date.now() - t0 });
process.on('message', async (m) => {
if (m.type !== 'transcribe') return;
try {
const pcm = pcm16k(m.file);
/* Whisper's window is 30 s and it silently keeps only the first one.
A clip that overruns would be scored against text it was never given,
which is a false FAIL, so hand long audio over in chunks instead. */
const long = pcm.length > 30 * 16000;
const out = await pipe(pcm, {
language: m.lang,
task: 'transcribe',
return_timestamps: long,
...(long ? { chunk_length_s: 30, stride_length_s: 5 } : {})
});
const text = (Array.isArray(out) ? out.map((o) => o.text).join(' ') : out.text || '').trim();
process.send({ type: 'transcript', id: m.id, text, seconds: pcm.length / 16000 });
} catch (e) {
process.send({ type: 'transcript', id: m.id, error: (e && e.message) || String(e) });
}
});
}
let asrChild = null;
let asrSeq = 0;
const asrPending = new Map();
async function ensureAsr(model, dtype) {
if (asrChild) return;
console.log('loading ASR ' + model + ' (' + dtype + ', cpu) in a child process …');
asrChild = fork(fileURLToPath(import.meta.url), ['--asr-worker', model, dtype],
{ stdio: ['ignore', 'inherit', 'inherit', 'ipc'] });
await new Promise((resolve, reject) => {
asrChild.on('message', (m) => {
if (m.type === 'ready') { console.log('ASR ready in ' + (m.ms / 1000).toFixed(1) + ' s'); resolve(); return; }
if (m.type === 'transcript') {
const p = asrPending.get(m.id);
if (!p) return;
asrPending.delete(m.id);
if (m.error) p.reject(new Error(m.error)); else p.resolve(m);
}
});
asrChild.on('exit', (c) => {
const err = new Error('ASR child exited with ' + c);
for (const p of asrPending.values()) p.reject(err);
asrPending.clear();
reject(err);
});
});
}
function transcribe(file, lang) {
return new Promise((resolve, reject) => {
const id = ++asrSeq;
asrPending.set(id, { resolve, reject });
asrChild.send({ type: 'transcribe', id, file, lang });
});
}
function stopAsr() {
if (asrChild) { try { asrChild.kill('SIGKILL'); } catch (_) {} asrChild = null; }
}
// ===================================================================== scoring
/* Punctuation and symbols out, case folded, width folded, spacing collapsed.
Diacritics deliberately survive: a voice that says the wrong vowel is broken,
and folding that away would hide exactly the defect being hunted. */
function normalize(s, { cjk = false } = {}) {
let t = String(s || '').normalize('NFKC').toLowerCase();
t = t.replace(/[\p{P}\p{S}]/gu, ' ');
t = t.replace(/\s+/g, ' ').trim();
if (cjk) t = t.replace(/\s+/g, '');
return t;
}
function foldDiacritics(s) {
return s.normalize('NFD').replace(/\p{M}/gu, '').normalize('NFC');
}
/* Works on any indexable sequence, so the same routine scores characters and
whole words. */
function levenshtein(a, b) {
if (a.length === b.length && a.every && a.every((x, i) => x === b[i])) return 0;
if (typeof a === 'string' && a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
let prev = new Array(b.length + 1);
let cur = new Array(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
cur[0] = i;
const ca = a[i - 1];
for (let j = 1; j <= b.length; j++) {
const cost = ca === b[j - 1] ? 0 : 1;
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
}
const t = prev; prev = cur; cur = t;
}
return prev[b.length];
}
function similarity(a, b) {
const m = Math.max(a.length, b.length);
if (!m) return 1;
return 1 - levenshtein(a, b) / m;
}
/*
* Characters alone are too forgiving on short utterances, and short utterances
* are exactly where a listener notices a wrong word. The Spanish voice turned
* "Oremos juntos" into "aremos puntos" — BOTH words wrong, yet only two of
* thirteen characters differ, so character similarity says 0.846 and calls it
* a pass. Word similarity says 0.0, which is the truth a listener hears.
*
* So a sentence must clear both bars. Characters catch a mangled syllable
* inside a long sentence; words catch a short sentence turned into a different
* short sentence. Scripts that do not delimit words get characters only —
* Whisper's word breaks in Chinese are arbitrary and carry no information.
*/
/* Written as escapes on purpose: the same ranges spelled with literal CJK
characters silently swallowed Hangul, and Korean lost its word score without
anything looking wrong. Korean belongs OUT of this set — it delimits words
with spaces and Whisper reproduces them. */
const NO_WORD_BREAKS = new RegExp(
'[' +
'\\u3040-\\u30FF' + // hiragana, katakana
'\\u3400-\\u4DBF' + // CJK extension A
'\\u4E00-\\u9FFF' + // CJK unified ideographs
'\\uF900-\\uFAFF' + // CJK compatibility ideographs
'\\u0E00-\\u0E7F' + // Thai
'\\u0E80-\\u0EFF' + // Lao
'\\u1780-\\u17FF' + // Khmer
'\\u1000-\\u109F' + // Burmese
']'
);
function wordSimilarity(refNorm, hypNorm) {
const a = refNorm.split(' ').filter(Boolean);
const b = hypNorm.split(' ').filter(Boolean);
const m = Math.max(a.length, b.length);
if (!m) return 1;
return 1 - levenshtein(a, b) / m;
}
const HAS_DIGIT = /[0-9٠-٩۰-۹०-९]/;
/*
* Numeral-blind variant, applied only when one side or the other actually
* contains digits.
*
* Digit runs come out of both strings. Then each side may drop tokens the other
* side does not have — that is where a spelled-out "three sixteen" or
* "veintitrés" lives — but only up to a BUDGET of three tokens per numeral
* present. Without that cap this degenerates into comparing the intersection of
* two sentences, which every voice would pass. With it, the leniency is limited
* to roughly the span a spoken number could occupy, and a mispronounced word
* elsewhere in the sentence still costs exactly what it should.
*/
function numeralBlind(refNorm, hypNorm, cjk) {
const digitRun = new RegExp(HAS_DIGIT.source + '+', 'gu');
const strip = (s) => s.replace(digitRun, ' ').replace(/\s+/g, cjk ? '' : ' ').trim();
const numerals = (String(refNorm).match(digitRun) || []).length +
(String(hypNorm).match(digitRun) || []).length;
const budget = Math.max(1, numerals) * 3;
const r = strip(refNorm);
const h = strip(hypNorm);
if (cjk) return { ref: r, hyp: h };
const rt = r.split(' ').filter(Boolean);
const ht = h.split(' ').filter(Boolean);
const prune = (mine, theirs) => {
const pool = new Map();
for (const t of theirs) pool.set(t, (pool.get(t) || 0) + 1);
let left = budget;
return mine.filter((t) => {
if (pool.get(t) > 0) { pool.set(t, pool.get(t) - 1); return true; }
if (left > 0) { left--; return false; } // spend budget dropping it
return true; // budget gone: it counts against us
});
};
return { ref: prune(rt, ht).join(' '), hyp: prune(ht, rt).join(' ') };
}
/*
* Whisper's other classic failure: handed a very short clip it sometimes emits a
* stock phrase from its training data — YouTube subtitle credits, mostly. The
* Spanish davefx voice saying "Amén." came back as "¡Gracias por ver el video!",
* which is not a mispronunciation of anything and must not be charged to the
* voice. Matched as a whole normalized string, never as a substring, so a real
* transcript that happens to contain one of these words is unaffected.
*/
const HALLUCINATIONS = new Set([
'gracias por ver el video', 'gracias por ver este video', 'suscribete al canal',
'subtitulos realizados por la comunidad de amara org', 'subtitulado por la comunidad de amara org',
'thanks for watching', 'thank you for watching', 'thanks for watching the video',
'please subscribe to my channel', 'subtitles by the amara org community',
'untertitel der amara org community', 'untertitelung des zdf fur funk',
'sous titres realises par la communaute damara org',
'sottotitoli e revisione a cura di qtss', 'legendas pela comunidade amara org',
'продолжение следует', 'субтитры сделал диматоржок', 'редактор субтитров а синецкая корректор а егорова',
'дякую за перегляд', 'ご視聴ありがとうございました', '字幕by索兰娅', '字幕提供 ミック',
'請不吝點贊 訂閱 轉發 打賞支持明鏡與點點欄目', '明鏡與點點欄目', '시청해주셔서 감사합니다',
'you', 'bye', 'thank you'
]);
function looksHallucinated(hypNorm) {
return HALLUCINATIONS.has(hypNorm);
}
/* Whisper's classic failure on degraded audio is to loop a phrase. That is an
ASR artifact, not a voice defect, and must not be counted against a language. */
function looksLooped(hypNorm) {
const toks = hypNorm.split(/\s+/).filter(Boolean);
if (toks.length < 12) return false;
/* The runaway usually starts PART WAY IN — Korean take 1 read the verse
reference correctly and then repeated "16" ninety times — so look for a
repeat anywhere, not only one anchored at the first token. */
for (let n = 1; n <= 5; n++) {
for (let start = 0; start + n * 4 <= toks.length; start++) {
const gram = toks.slice(start, start + n).join(' ');
let reps = 0;
for (let i = start; i + n <= toks.length; i += n) {
if (toks.slice(i, i + n).join(' ') === gram) reps++; else break;
}
if (reps * n >= Math.max(8, toks.length * 0.6)) return true;
}
}
return false;
}
function wordDiff(ref, hyp) {
const a = ref.split(' ').filter(Boolean);
const b = hyp.split(' ').filter(Boolean);
const bs = new Set(b);
const as = new Set(a);
return {
missing: a.filter((w) => !bs.has(w)),
extra: b.filter((w) => !as.has(w))
};
}
function scoreSentence(refText, hypText) {
const cjk = NO_WORD_BREAKS.test(refText);
const refN = normalize(refText, { cjk });
const hypN = normalize(hypText, { cjk });
let variant = 'strict';
let score = similarity(refN, hypN);
let wordScore = cjk ? null : wordSimilarity(refN, hypN);
/*
* Numerals cut BOTH ways and the audit has to be blind in both directions.
* A reference with "3:16" meets a transcript that spells it out; a reference
* with "veintitrés" meets a transcript that writes "23". Every Spanish voice
* tested scored 0.62 on "Leemos el Salmo veintitrés" purely because Whisper
* wrote the digits — identical for all four, which is the signature of an
* orthography artifact rather than a voice defect.
*/
if (HAS_DIGIT.test(refText) || HAS_DIGIT.test(hypText)) {
const nb = numeralBlind(refN, hypN, cjk);
const s2 = similarity(nb.ref, nb.hyp);
if (s2 > score) {
score = s2;
variant = 'numeral-blind';
if (!cjk) wordScore = Math.max(wordScore, wordSimilarity(nb.ref, nb.hyp));
}
}
const folded = similarity(foldDiacritics(refN), foldDiacritics(hypN));
return {
score,
wordScore,
variant,
foldedScore: folded,
accentsOnly: folded - score > 0.03,
refNorm: refN,
hypNorm: hypN,
diff: cjk ? { missing: [], extra: [] } : wordDiff(refN, hypN)
};
}
// ====================================================================== runner
function median(xs) {
if (!xs.length) return 0;
const s = xs.slice().sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
/*
* The bundle a language uses is normally whatever voices.js maps it to.
* --voice-bundle points the same audit at a DIFFERENT bundle for the same
* language, which is how you pick a replacement: when a voice turns up SUSPECT,
* run the candidates through the identical corpus and compare the numbers
* instead of guessing from model cards.
*/
function resolveVoice(lang, opts) {
/* --sid audits one speaker of a MULTI-speaker bundle. Carried on the voice
object (a copy — voiceFor() returns the live table entry) so the synth
child sees it without another argv slot. */
const sid = Number(opts.sid) || 0;
/* --voice-json gives the FULL descriptor for a candidate bundle — needed
whenever the candidate is a lexicon-frontend model (frontend/ruleFsts/
sampleRate cannot be guessed from a bundle name, and handing a lexicon
model the espeak config hard-exits the synth child). */
if (opts.voiceJson) return { ...JSON.parse(opts.voiceJson), sid };
if (!opts.voiceBundle) {
const v = voices.voiceFor(lang);
return v ? { ...v, sid } : v;
}
const bundle = opts.voiceBundle;
const model = opts.voiceModel ||
bundle.replace(/^vits-(piper|mimic3|coqui)-/, '') + '.onnx';
return { bundle, model, sid };
}
async function auditLanguage(lang, sentences, opts) {
const v = resolveVoice(lang, opts);
if (!v) return { lang, verdict: 'NO_VOICE', bundle: null, sentences: [] };
if (!isInstalled(v)) {
if (!opts.install) return { lang, verdict: 'NOT_INSTALLED', bundle: v.bundle, sentences: [] };
installVoice(v);
}
const outDir = path.join(opts.audioDir, lang);
const takes = 1 + opts.retakes;
const clips = runSynth(voicePath(v), v, outDir, sentences, opts.aac, takes);
const byId = new Map(clips.map((c) => [c.id, c]));
await ensureAsr(opts.asrModel, opts.asrDtype);
const wl = WHISPER_LANG[lang];
/* Seconds per character, measured against this language's own median rather
than a global constant — scripts differ enormously in characters per second
of speech, and a fixed threshold would fail every non-Latin voice. */
const spc = sentences.map((s) => {
const c = byId.get(s.id);
return c && s.text.length ? median(c.takes.map((t) => t.seconds)) / s.text.length : 0;
});
const medSpc = median(spc.filter((x) => x > 0));
/* Classify one rendition. Kept separate so a retake runs the identical path. */
const judge = async (refText, file) => {
const hyp = (await transcribe(file, wl)).text;
const sc = scoreSentence(refText, hyp);
let status;
if (!sc.hypNorm) status = 'ASR_EMPTY';
else if (looksHallucinated(sc.hypNorm)) status = 'ASR_HALLUCINATION';
else if (looksLooped(sc.hypNorm)) status = 'ASR_LOOP';
else if (sc.score < opts.passAt) status = 'FAIL';
else if (sc.wordScore !== null && sc.wordScore < opts.passAtWords) status = 'FAIL';
else status = 'PASS';
return { ...sc, transcript: hyp, status };
};
const rows = [];
for (const s of sentences) {
const clip = byId.get(s.id);
const secs = median(clip.takes.map((t) => t.seconds));
const row = { id: s.id, kind: s.kind, input: s.text, seconds: secs };
const perChar = s.text.length ? secs / s.text.length : 1;
const ratio = medSpc > 0 && s.text.length ? perChar / medSpc : 1;
row.durationRatio = ratio;
row.secPerChar = perChar;
/*
* ASR-free truncation check, on two independent grounds: far below this
* language's own pace, or below any human pace at all. The second one
* matters — when a voice is broken for EVERY sentence, the language's own
* median is broken too and the relative test alone sees nothing wrong.
*/
if (ratio < 0.55 || perChar < ABSOLUTE_MIN_SEC_PER_CHAR) {
row.status = 'SHORT_AUDIO';
row.score = 0;
row.wordScore = 0;
row.transcript = '';
row.diff = { missing: [], extra: [] };
rows.push(row);
continue;
}
let verdict = await judge(s.text, clip.takes[0].file);
row.takes = [{ transcript: verdict.transcript, score: verdict.score, wordScore: verdict.wordScore }];
/*
* A failure gets re-heard before it is believed. Every take is different
* audio, so a genuine mispronunciation fails all of them while an unlucky
* sample recovers — and the median of the three, not the best of them,
* decides, so extra takes cannot buy a broken voice a pass.
*/
if (verdict.status === 'FAIL' && clip.takes.length > 1) {
const all = [verdict];
for (let k = 1; k < clip.takes.length; k++) {
const again = await judge(s.text, clip.takes[k].file);
all.push(again);
row.takes.push({ transcript: again.transcript, score: again.score, wordScore: again.wordScore });
}
const medScore = median(all.map((a) => a.score));
const medWord = all[0].wordScore === null ? null : median(all.map((a) => a.wordScore));
/* Report the take whose score is the median, so the evidence shown is a
real transcript rather than a synthetic average nobody can listen to. */
verdict = all.find((a) => a.score === medScore) || verdict;
verdict = { ...verdict, score: medScore, wordScore: medWord };
verdict.status =
medScore >= opts.passAt && (medWord === null || medWord >= opts.passAtWords) ? 'PASS' : 'FAIL';
row.retaken = all.length;
}
Object.assign(row, verdict);
rows.push(row);
}
const scored = rows.filter((r) => r.status === 'PASS' || r.status === 'FAIL' || r.status === 'SHORT_AUDIO');
/* ASR failures are the judge's fault, not the voice's — reported, but never
counted for or against the language's pass rate. */
const excluded = rows.filter((r) => r.status.startsWith('ASR_'));
const passes = rows.filter((r) => r.status === 'PASS').length;
const passRate = scored.length ? passes / scored.length : 0;
const mean = scored.length ? scored.reduce((a, r) => a + r.score, 0) / scored.length : 0;
const min = scored.length ? Math.min(...scored.map((r) => r.score)) : 0;
const withWords = scored.filter((r) => r.wordScore !== null && r.wordScore !== undefined);
const meanWord = withWords.length
? withWords.reduce((a, r) => a + r.wordScore, 0) / withWords.length : null;
const han = HAN_SCRIPTS.has(scriptOf(lang));
const minRate = han ? HAN_MIN_PASS_RATE : LANG_MIN_PASS_RATE;
const minMean = han ? HAN_MIN_MEAN : LANG_MIN_MEAN;
let verdict = 'OK';
if (!scored.length) verdict = 'INCONCLUSIVE';
else if (passRate < minRate || mean < minMean) verdict = 'SUSPECT';
if (!opts.keepAudio) { try { fs.rmSync(outDir, { recursive: true, force: true }); } catch (_) {} }
return { lang, bundle: v.bundle, verdict, han, passRate, mean, meanWord, min, passes, scored: scored.length, excluded: excluded.length, medSpc, sentences: rows };
}
// ==================================================================== selftest
/*
* The negative control. If Spanish text spoken by the German voice scores about
* as well as Spanish text spoken by the Spanish voice, then the whole audit is
* measuring nothing, and every PASS above is meaningless. This is the check that
* makes the rest of the output trustworthy, so it is loud and it can fail the run.
*/
async function impostorScore(target, impostorLang, corpus, opts) {
const iv = voices.voiceFor(impostorLang);
/* Four sentences is enough to separate a real voice from a wrong one and
keeps the control cheap. */
const subset = corpus[target.lang].sentences.slice(0, 4);
const outDir = path.join(opts.audioDir, '_neg-' + target.lang + '-' + impostorLang);
const clips = runSynth(voicePath(iv), iv, outDir, subset, false, 1);
const byId = new Map(clips.map((c) => [c.id, c]));
await ensureAsr(opts.asrModel, opts.asrDtype);
let sum = 0;
for (const s of subset) {
const hyp = (await transcribe(byId.get(s.id).file, WHISPER_LANG[target.lang])).text;
sum += scoreSentence(s.text, hyp).score;
}
try { fs.rmSync(outDir, { recursive: true, force: true }); } catch (_) {}
const realSubset = target.sentences.filter((r) => subset.some((s) => s.id === r.id));
return {
bundle: iv.bundle,
wrong: sum / subset.length,
right: realSubset.reduce((a, r) => a + r.score, 0) / realSubset.length
};
}
async function selfTest(results, corpus, opts) {
/*
* Only a language whose own voice WORKS can serve as a target: the control
* asks "is the right voice better than a wrong one", and a broken right voice
* makes the question meaningless. Ukrainian's real voice scored 0.048, so
* comparing it to an impostor measured nothing at all.
*/
const targets = results.filter((r) => r.verdict === 'OK');
/* Impostors come from the working set too. A broken voice makes a trivially
easy impostor and would flatter the control — Russian "beat" the Ukrainian
voice by 0.877, which says nothing except that Ukrainian emits noise. */
const pool = targets;
if (targets.length < 2 || pool.length < 2) {
console.log('\nselftest: needs at least two working languages — skipped');
return true;
}
console.log('\n=== NEGATIVE CONTROL (wrong voice on purpose) ===');
console.log(' FAR = impostor in a different script. This gates the run.');
console.log(' NEAR = impostor in the same script, the hardest case. Reported, never gating.\n');
let ok = true;
const nearNotes = [];
for (const target of targets) {
const far = pool.find((r) => scriptOf(r.lang) !== scriptOf(target.lang));
const near = pool.find((r) => r.lang !== target.lang && scriptOf(r.lang) === scriptOf(target.lang));
if (far) {
const m = await impostorScore(target, far.lang, corpus, opts);
const gap = m.right - m.wrong;
const pass = gap >= SELFTEST_MIN_GAP;
if (!pass) ok = false;
console.log(
' FAR ' + target.lang.padEnd(9) + ' right ' + m.right.toFixed(3) +
' vs ' + m.bundle.padEnd(38) + ' ' + m.wrong.toFixed(3) +
' gap ' + gap.toFixed(3) + ' ' + (pass ? 'DISCRIMINATES' : 'NO POWER')
);
}
if (near) {
const m = await impostorScore(target, near.lang, corpus, opts);
const gap = m.right - m.wrong;
console.log(
' near ' + target.lang.padEnd(9) + ' right ' + m.right.toFixed(3) +
' vs ' + m.bundle.padEnd(38) + ' ' + m.wrong.toFixed(3) +
' gap ' + gap.toFixed(3) + (gap < SELFTEST_MIN_GAP ? ' (indistinguishable)' : '')
);
if (gap < SELFTEST_MIN_GAP) nearNotes.push(target.lang + ' vs ' + m.bundle);
}
}
/*
* A same-script impostor scoring nearly as well as the real voice is not a
* scoring bug — it is the truth about what this method can see. sherpa reads
* the espeak voice out of the model's own metadata, so Spanish text run
* through the Brazilian voice is phonemized as Portuguese, and Portuguese
* phonemes of Spanish words are still perfectly legible to Whisper. The audit
* proves a voice SAYS THE RIGHT WORDS; it does not prove the accent is the
* right one. Say so rather than let a reader assume more.
*/
if (nearNotes.length) {
console.log('\n LIMIT: within one script the audit cannot tell these apart — ' + nearNotes.join('; '));
console.log(' It verifies that a voice says the right words, not that it has the right accent.');
}
if (!ok) console.log('\n !! the audit cannot tell a right voice from a wrong one — treat every PASS above as unproven');
return ok;
}
// ====================================================================== report
function bar(x) {
const n = Math.round(Math.max(0, Math.min(1, x)) * 20);
return '█'.repeat(n) + '·'.repeat(20 - n);
}
function report(results, opts) {
console.log('\n' + '='.repeat(100));
console.log('VOICE AUDIT — ' + new Date().toISOString());
console.log('ASR ' + opts.asrModel + ' (' + opts.asrDtype + ') audio path: ' +
(opts.aac ? 'VITS -> AAC 48k -> ASR (what listeners hear)' : 'VITS -> WAV -> ASR (synthesis only)'));
console.log('a sentence passes at ' + opts.passAt.toFixed(2) + ' char-similarity AND ' +
opts.passAtWords.toFixed(2) + ' word-similarity; failures are re-synthesized ' +
opts.retakes + 'x and judged on the median');
console.log('a language is SUSPECT below ' + LANG_MIN_PASS_RATE.toFixed(2) +
' pass rate or ' + LANG_MIN_MEAN.toFixed(2) + ' mean (Han script: ' +
HAN_MIN_PASS_RATE.toFixed(2) + ' / ' + HAN_MIN_MEAN.toFixed(2) + ')');
console.log('='.repeat(100));
console.log(
'LANGUAGE'.padEnd(10) + 'BUNDLE'.padEnd(40) + 'PASS'.padEnd(8) +
'CHAR'.padEnd(7) + 'WORD'.padEnd(7) + 'MIN'.padEnd(7) + 'SCORE'.padEnd(22) + 'VERDICT'
);
console.log('-'.repeat(100));
for (const r of results) {
if (!r.scored) {
console.log(r.lang.padEnd(10) + String(r.bundle || '—').padEnd(40) + '—'.padEnd(8) +
'—'.padEnd(7) + '—'.padEnd(7) + '—'.padEnd(7) + ''.padEnd(22) + r.verdict);
continue;
}
console.log(
r.lang.padEnd(10) +
r.bundle.padEnd(40) +
(r.passes + '/' + r.scored).padEnd(8) +
r.mean.toFixed(3).padEnd(7) +
(r.meanWord === null ? ' — ' : r.meanWord.toFixed(3)).padEnd(7) +
r.min.toFixed(3).padEnd(7) +
(bar(r.mean) + ' ').padEnd(22) +
r.verdict + (r.han ? ' *' : '') +
(r.excluded ? ' (' + r.excluded + ' excluded: ASR failure)' : '')
);
}
console.log('-'.repeat(100));
if (results.some((r) => r.han && r.scored)) {
console.log('* Han script: the transcript picks CHARACTERS, and Mandarin homophones mean a');
console.log(' perfectly spoken sentence still comes back written differently — 耶稣基督 as');
console.log(' 夜宿季度, both read "yesu jidu". A PASS here means "said something that sounds');
console.log(' right"; read the evidence below before treating a Han FAIL as a defect.');
}
/* Every failure gets its evidence printed. A score with no transcript beside
it is an accusation nobody can act on. */
const bad = [];
for (const r of results) for (const s of r.sentences || []) {
if (s.status !== 'PASS') bad.push({ lang: r.lang, ...s });
}
if (bad.length) {
console.log('\n=== EVIDENCE FOR EVERY NON-PASS ===');
for (const b of bad) {
console.log('\n ' + b.lang + ' / ' + b.id + ' [' + b.kind + '] ' + b.status +
' char ' + (b.score !== undefined ? b.score.toFixed(3) : '—') +
' word ' + (b.wordScore === null || b.wordScore === undefined ? '—' : b.wordScore.toFixed(3)) +
(b.variant ? ' (' + b.variant + ')' : '') +
' ' + (b.seconds || 0).toFixed(2) + ' s' +
(b.retaken ? ' [median of ' + b.retaken + ' takes]' : ''));
console.log(' given : ' + b.input);
console.log(' heard : ' + (b.transcript || '(nothing)'));
/* Show every take when a sentence was re-heard: three different garbles of
the same words is the proof that the voice, not the ASR, is at fault. */
if (b.retaken && b.takes) {
b.takes.forEach((t, i) => console.log(' take ' + (i + 1) + ': ' +
t.score.toFixed(3) + ' ' + JSON.stringify(t.transcript)));
}
if (b.diff && b.diff.missing.length) console.log(' lost : ' + b.diff.missing.join(' '));
if (b.diff && b.diff.extra.length) console.log(' added : ' + b.diff.extra.join(' '));
if (b.accentsOnly) console.log(' note : difference is largely diacritics (folded score ' + b.foldedScore.toFixed(3) + ')');
if (b.status === 'SHORT_AUDIO') {
console.log(' note : ' + b.secPerChar.toFixed(4) + ' s per character (' +
(b.durationRatio * 100).toFixed(0) + '% of this language\'s own pace, floor is ' +
ABSOLUTE_MIN_SEC_PER_CHAR + ') — the audio is far too short to contain this text');
}
}
} else {
console.log('\nevery sentence passed in every audited language.');
}
/* Near-misses are where a real mispronunciation of one word hides, so they are
worth a human ear even though they passed. */
const near = [];
for (const r of results) for (const s of r.sentences || []) {
if (s.status !== 'PASS') continue;
if (s.score < opts.passAt + 0.10 ||
(s.wordScore !== null && s.wordScore !== undefined && s.wordScore < opts.passAtWords + 0.15)) {
near.push({ lang: r.lang, ...s });
}
}
if (near.length) {
console.log('\n=== PASSED BUT WORTH AN EAR (close to either bar) ===');
for (const n of near) {
console.log(' ' + n.lang + ' / ' + n.id + ' char ' + n.score.toFixed(3) +
' word ' + (n.wordScore === null || n.wordScore === undefined ? '—' : n.wordScore.toFixed(3)) +
(n.diff.missing.length ? ' lost: ' + n.diff.missing.join(' ') : '') +
(n.diff.extra.length ? ' added: ' + n.diff.extra.join(' ') : ''));
}
}
}
// ======================================================================== main
async function main() {
const argv = process.argv.slice(2);
if (argv[0] === '--synth-worker') return synthWorker(argv.slice(1));
if (argv[0] === '--asr-worker') return asrWorker(argv.slice(1));
const opts = {
aac: argv.includes('--aac'),
all: argv.includes('--all'),
install: argv.includes('--all') || argv.includes('--install'),
keepAudio: argv.includes('--keep-audio'),
selftest: argv.includes('--selftest'),
passAt: SENTENCE_PASS,
passAtWords: SENTENCE_PASS_WORDS,
retakes: DEFAULT_RETAKES,
asrModel: 'onnx-community/whisper-large-v3-turbo',
asrDtype: 'q8',
audioDir: path.join(os.tmpdir(), 'church-translate-voice-audit')
};
const at = argv.indexOf('--pass-at');
if (at !== -1) opts.passAt = Number(argv[at + 1]);
const aw = argv.indexOf('--pass-at-words');
if (aw !== -1) opts.passAtWords = Number(argv[aw + 1]);
const rt = argv.indexOf('--retakes');
if (rt !== -1) opts.retakes = Math.max(0, Number(argv[rt + 1]) || 0);
const am = argv.indexOf('--asr-model');
if (am !== -1) opts.asrModel = argv[am + 1];
const ad = argv.indexOf('--asr-dtype');
if (ad !== -1) opts.asrDtype = argv[ad + 1];
const jf = argv.indexOf('--json');
const jsonOut = jf !== -1 ? argv[jf + 1] : null;
const kd = argv.indexOf('--audio-dir');
if (kd !== -1) opts.audioDir = argv[kd + 1];
const vb = argv.indexOf('--voice-bundle');
if (vb !== -1) { opts.voiceBundle = argv[vb + 1]; opts.install = true; }
const vm = argv.indexOf('--voice-model');
if (vm !== -1) opts.voiceModel = argv[vm + 1];
const sd = argv.indexOf('--sid');
if (sd !== -1) opts.sid = Number(argv[sd + 1]) || 0;
const vj = argv.indexOf('--voice-json');
if (vj !== -1) { opts.voiceJson = argv[vj + 1]; opts.install = true; }
let langs = argv.filter((a) => /^[a-z]{3}_[A-Za-z]{4}$/.test(a));
if (!langs.length) {
const all = voices.spokenLanguages();
langs = opts.all ? all : all.filter((l) => isInstalled(voices.voiceFor(l)));
if (!langs.length) {
console.error('no voices installed and --all not given — nothing to audit');
process.exit(2);
}
}
const unknown = langs.filter((l) => !voices.hasVoice(l));
if (unknown.length) {
console.error('no built-in voice for: ' + unknown.join(', '));
process.exit(2);
}
const noWhisper = langs.filter((l) => !WHISPER_LANG[l]);
if (noWhisper.length) {
console.error('no Whisper language mapping for: ' + noWhisper.join(', ') + ' — add it to WHISPER_LANG');
process.exit(2);
}
console.log('auditing ' + langs.length + ' language(s): ' + langs.join(', '));
let corpus = loadCorpus();
corpus = await ensureCorpus(langs, corpus);
fs.mkdirSync(opts.audioDir, { recursive: true });
const results = [];
for (const lang of langs) {
const started = Date.now();
process.stdout.write('\n--- ' + lang + ' (' + resolveVoice(lang, opts).bundle + ') ');
const r = await auditLanguage(lang, corpus[lang].sentences, opts);
r.corpusSource = corpus[lang].source;
r.ms = Date.now() - started;
console.log('done in ' + (r.ms / 1000).toFixed(1) + ' s — ' + r.verdict);
results.push(r);
}
report(results, opts);
let discriminates = true;
if (opts.selftest) discriminates = await selfTest(results, corpus, opts);
if (jsonOut) {
fs.writeFileSync(jsonOut, JSON.stringify({
at: new Date().toISOString(), opts: { ...opts }, discriminates, results
}, null, 2));
console.log('\nwrote ' + jsonOut);
}
const suspect = results.filter((r) => r.verdict === 'SUSPECT' || r.verdict === 'INCONCLUSIVE');
console.log('\n' + (suspect.length
? 'SUSPECT: ' + suspect.map((r) => r.lang).join(', ')
: 'all audited languages within tolerance'));
if (!opts.keepAudio) { try { fs.rmSync(opts.audioDir, { recursive: true, force: true }); } catch (_) {} }
stopAsr();
process.exit(suspect.length || !discriminates ? 1 : 0);
}
main().catch((e) => {
console.error('\nvoice-audit failed: ' + (e && e.stack || e));
stopAsr();
process.exit(3);
});