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
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]>
126 lines
6.1 KiB
JavaScript
126 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Render each voice's sample: the voice, from the built packs, says its
|
|
// language's sample sentence (the catalogue's `sample_text`, set in
|
|
// build_voices.py SAMPLE_TEXT), which LAME encodes into
|
|
// tools/voices/samples/<pack>.mp3, recorded in samples/index.json.
|
|
//
|
|
// The samples are committed: synthesis is not repeatable (Piper samples its
|
|
// durations), so they are source files, rendered once and listened to before
|
|
// they ship. build_voices.py then pins and publishes them beside the voices,
|
|
// and the app plays one before the person decides to download its voice.
|
|
//
|
|
// node tools/voices/render_samples.mjs # voices whose sample is missing or stale
|
|
// node tools/voices/render_samples.mjs --all # every voice
|
|
// node tools/voices/render_samples.mjs en-piper-cori de
|
|
// node tools/voices/render_samples.mjs --out DIR # packs elsewhere
|
|
//
|
|
// Needs Node >= 22 and LAME (brew install lame). A sample that is silent,
|
|
// under 1 s or over 8 s is not written: listen to that voice, and to every
|
|
// sample before committing it.
|
|
import crypto from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const SAMPLES = path.join(HERE, 'samples');
|
|
// Mono MPEG-2 layer III at 32 kbit/s, 24 kHz: about 4 KB a second, clear
|
|
// speech, and decoded by every browser the app supports (the open-source
|
|
// Chromium the tests use has no AAC). LAME's output is repeatable.
|
|
const LAME_ARGS = ['--quiet', '-m', 'm', '-b', '32', '--resample', '24', '--noreplaygain', '-q', '0'];
|
|
// Keep peaks at -1 dBFS: the voices reach full scale, which clips once encoded.
|
|
const PEAK = 0.89;
|
|
|
|
let out = process.env.AAC_VOICES_DIR || path.resolve(HERE, '../../../../../aac-voice-packs');
|
|
let all = false;
|
|
const only = [];
|
|
const argv = process.argv.slice(2);
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--out') out = path.resolve(argv[++i]);
|
|
else if (argv[i] === '--all') all = true;
|
|
else only.push(argv[i]);
|
|
}
|
|
|
|
const lameVersion = execFileSync('lame', ['--version']).toString().split('\n')[0].trim();
|
|
const pointer = JSON.parse(fs.readFileSync(path.join(out, 'index.json'), 'utf8'));
|
|
const cat = JSON.parse(fs.readFileSync(path.join(out, pointer.current), 'utf8'));
|
|
if (cat.schema !== 'aac-voice-index/1') throw new Error(`unknown schema ${cat.schema}`);
|
|
fs.mkdirSync(SAMPLES, { recursive: true });
|
|
const indexPath = path.join(SAMPLES, 'index.json');
|
|
const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, 'utf8')) : {};
|
|
|
|
const bytes = (f) => new Uint8Array(fs.readFileSync(path.join(out, f.url)));
|
|
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
const runtimes = new Map();
|
|
async function runtime(id) {
|
|
if (!runtimes.has(id)) {
|
|
const rt = cat.runtimes[id];
|
|
const { default: createModule } = await import(pathToFileURL(path.join(out, rt.module)).href);
|
|
runtimes.set(id, await createModule({ wasmBinary: fs.readFileSync(path.join(out, rt.wasm)), print: () => {}, printErr: () => {} }));
|
|
}
|
|
return runtimes.get(id);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aac-samples-'));
|
|
const failures = [];
|
|
let rendered = 0;
|
|
for (const [code, lang] of Object.entries(cat.languages)) {
|
|
for (const pack of lang.packs) {
|
|
const text = pack.sample_text;
|
|
if (!text) continue;
|
|
if (only.length && !only.includes(pack.id) && !only.includes(code)) continue;
|
|
const file = path.join(SAMPLES, `${pack.id}.mp3`);
|
|
const current = index[pack.id]?.text === text && fs.existsSync(file) && sha256(fs.readFileSync(file)) === index[pack.id]?.sha256;
|
|
if (current && !all && !only.length) continue;
|
|
|
|
const m = await runtime(pack.runtime);
|
|
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));
|
|
const r = tts.say(text);
|
|
tts.delete?.();
|
|
const audio = Float32Array.from(r.audio);
|
|
const seconds = audio.length / r.sampleRate;
|
|
let peak = 0, sum = 0;
|
|
for (const x of audio) { peak = Math.max(peak, Math.abs(x)); sum += x * x; }
|
|
const rms = Math.sqrt(sum / Math.max(1, audio.length));
|
|
if (seconds < 1 || seconds > 8 || rms < 0.01) {
|
|
failures.push(`${pack.id}: ${seconds.toFixed(2)} s, rms ${rms.toFixed(3)}: listen to this voice`);
|
|
continue;
|
|
}
|
|
if (peak > PEAK) for (let i = 0; i < audio.length; i++) audio[i] *= PEAK / peak;
|
|
const wavPath = path.join(tmp, `${pack.id}.wav`);
|
|
fs.writeFileSync(wavPath, wav(audio, r.sampleRate));
|
|
execFileSync('lame', [...LAME_ARGS, wavPath, file]);
|
|
const mp3 = fs.readFileSync(file);
|
|
index[pack.id] = {
|
|
text,
|
|
sha256: sha256(mp3),
|
|
bytes: mp3.length,
|
|
seconds: Number(seconds.toFixed(2)),
|
|
tool: `${lameVersion}: ${LAME_ARGS.join(' ')}; peak limited to ${PEAK}`,
|
|
};
|
|
rendered++;
|
|
console.log(`ok ${pack.id.padEnd(26)} ${seconds.toFixed(2)} s, ${(mp3.length / 1024).toFixed(1)} KB ${file}`);
|
|
}
|
|
}
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
const sorted = Object.fromEntries(Object.keys(index).sort().map((k) => [k, index[k]]));
|
|
fs.writeFileSync(indexPath, JSON.stringify(sorted, null, 2) + '\n');
|
|
console.log(`\n${rendered} sample(s) rendered into ${SAMPLES}; listen to each before committing.`);
|
|
if (failures.length) {
|
|
console.error(failures.map((f) => `FAIL ${f}`).join('\n'));
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|