open-standards/aac/aac-rs/tools/voices/intelligibility.mjs
Transaction Science d9e0f89b89
Some checks failed
standards workspaces build + test / jouleclaw-rs (push) Successful in 3m1s
standards workspaces build + test / aac-rs (push) Successful in 1m24s
standards workspaces build + test / joule-code-rs (push) Successful in 54s
standards workspaces build + test / joulecontract-rs (push) Successful in 59s
no-vanity-metrics / Scan committed Markdown for vanity metrics (push) Successful in 4s
standards workspaces build + test / map-rs (push) Successful in 1m8s
standards workspaces build + test / mesh-rs (push) Successful in 40s
standards workspaces build + test / eoc-rs (push) Successful in 1m49s
standards workspaces build + test / jcp-rs (push) Successful in 1m25s
standards workspaces build + test / openpay (push) Successful in 3m24s
standards workspaces build + test / proof-rs (push) Successful in 1m16s
standards workspaces build + test / sandbox-rs (push) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (push) Successful in 2m36s
standards workspaces build + test / arl-rs (push) Has been cancelled
standards workspaces build + test / joule-ui-rs (push) Has been cancelled
standards workspaces build + test / jouledesk-rs (push) Has been cancelled
standards workspaces build + test / joulehook-rs (push) Has been cancelled
standards workspaces build + test / sense-rs (push) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (push) Has been cancelled
feat(aac voices): hear a voice before downloading it; ratings measured
A better voice is a 100 MB download, and the person could not hear
one before deciding. Making the samples showed something worse: some
voices did not say the words at all.

- Samples: each voice says a greeting and a request in its language
  (two or three seconds, MP3, about 10 KB). Settings > Better voices
  plays it with the sentence written beside it, before any download;
  checked by size and SHA-256 and played through the voices' own
  output. render_samples.mjs renders them with LAME; they are
  committed (Piper's synthesis is not repeatable), and build_voices.py
  publishes each beside the voices while it says the current text.
- Intelligibility: intelligibility.py has every voice say seven
  everyday phrases, a speech recognizer (Whisper large-v3-turbo on MLX)
  writes down what it hears, and the scores are committed. Ratings now
  come from that evidence (RATING_RULE): under 0.80 the voice is
  withheld; under 0.90, or any phrase under 0.60, experimental; 0.90
  and over clear; 0.95 and over with an upstream grade of A or B,
  natural.
- Withheld, because they say other words: German (the only German
  voice, 0.14), Dutch MLS (0.27), Ukrainian (0.18) and both Japanese
  voices (0.63-0.69: they misread 外, 好き and 疲れた). Their languages
  say so plainly under "Why". Portuguese and Hindi are experimental;
  Spanish, Chinese, Italian, Flemish and Cori are clear; Heart, Emma
  and Siwis natural. 16 voices in 8 languages, 452 MB of packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 20:20:25 -04:00

57 lines
2.9 KiB
JavaScript

#!/usr/bin/env node
// Say each language's everyday phrases (phrases.json) with every voice in the
// built packs, into WAV files and a manifest, for intelligibility.py to
// transcribe. Nothing is sent anywhere.
//
// node tools/voices/intelligibility.mjs OUTDIR [--out PACKS] [voice-or-language ...]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const dir = path.resolve(argv.shift() || '');
let packs = process.env.AAC_VOICES_DIR || path.resolve(HERE, '../../../../../aac-voice-packs');
const only = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--out') packs = path.resolve(argv[++i]);
else only.push(argv[i]);
}
const phrases = JSON.parse(fs.readFileSync(path.join(HERE, 'phrases.json'), 'utf8')).phrases;
const pointer = JSON.parse(fs.readFileSync(path.join(packs, 'index.json'), 'utf8'));
const cat = JSON.parse(fs.readFileSync(path.join(packs, pointer.current), 'utf8'));
const rt = Object.values(cat.runtimes)[0];
const { default: createModule } = await import(pathToFileURL(path.join(packs, rt.module)).href);
const m = await createModule({ wasmBinary: fs.readFileSync(path.join(packs, rt.wasm)), print: () => {}, printErr: () => {} });
const bytes = (f) => new Uint8Array(fs.readFileSync(path.join(packs, f.url)));
function wav(audio, sr) {
const b = Buffer.alloc(44 + audio.length * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + audio.length * 2, 4); b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(sr, 24);
b.writeUInt32LE(sr * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36);
b.writeUInt32LE(audio.length * 2, 40);
for (let i = 0; i < audio.length; i++) b.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(audio[i] * 32767))), 44 + i * 2);
return b;
}
fs.mkdirSync(dir, { recursive: true });
const manifest = [];
for (const [code, lang] of Object.entries(cat.languages)) {
if (!phrases[code]) continue;
for (const pack of lang.packs) {
if (only.length && !only.includes(pack.id) && !only.includes(code)) continue;
const files = [...pack.requires.flatMap((c) => cat.components[c].files), ...pack.files];
const tts = new m.TextToSpeech(pack.lang_key, files.map((f) => f.name), files.map(bytes), Object.keys(pack.options), Object.values(pack.options));
phrases[code].forEach((text, i) => {
const r = tts.say(text);
const file = path.join(dir, `${pack.id}.${i}.wav`);
fs.writeFileSync(file, wav(r.audio, r.sampleRate));
manifest.push({ pack: pack.id, lang: code, text, file, seconds: Number((r.audio.length / r.sampleRate).toFixed(2)) });
});
tts.delete?.();
console.log(`said ${phrases[code].length} phrases with ${pack.id}`);
}
}
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1));
process.exit(0);