#!/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/.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);