1224 lines
54 KiB
JavaScript
1224 lines
54 KiB
JavaScript
/*
|
||||
|
|
* 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);
|
|||
|
|
});
|