openvibers/openvibe.chat Commits
Every equipped voice speaks as itself: Josh's Vegas Announcer and the Dwarf Lord are in the catalog, and Polly voices keep their own Google stand-in when Polly refuses (#129)
Changes
@@@ -64,6 +64,8 @@ const VOICE_CATALOG = {warlord: { name: 'WarLord', emoji: '⚔️', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m7', pitch: 10, speed: 110 } },degenking: { name: 'DegenKing', emoji: '👑', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m5', pitch: 75, speed: 195 } },fisherman: { name: 'OldManSea', emoji: '🎣', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m4', pitch: 30, speed: 95 } },++ // A Live skill-mastery voice (vs_mine_dwarf_lord): deep and slow.++ vs_mine_dwarf_lord: { name: 'DwarfLord', emoji: '⛏️', engine: 'espeak-ng', rarity: 'legendary', tier: 5, params: { voice: 'en+m7', pitch: 5, speed: 120 } },coinlord: { name: 'CoinLord', emoji: '💰', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m6', pitch: 40, speed: 150 } },// ── Google Cloud TTS voices ───────────────────────────────@@@ -93,35 +95,37 @@ const VOICE_CATALOG = {// Journeygc_journey_sage: { name: 'JourneySage', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-D', gender: 'MALE', locale: 'en-US' } },gc_journey_oracle: { name: 'JourneyOracle', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-F', gender: 'FEMALE', locale: 'en-US' } },++ // One of a kind (2026-10-22): Josh's Vegas Announcer, a deep, unhurried Neural2 voice.++ voice_vegas: { name: 'VegasAnnouncer', emoji: '🎙️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Neural2-J', gender: 'MALE', locale: 'en-US', pitch: -4.0, speakingRate: 0.9 } },gc_journey_whisper: { name: 'JourneyWhisper', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-O', gender: 'FEMALE', locale: 'en-US' } },// ── Amazon Polly voices ───────────────────────────────────// Standard-- pl_joanna_std: { name: 'PollyJoanna', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joanna', engine: 'standard', locale: 'en-US' } },-- pl_matthew_std: { name: 'PollyMatt', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Matthew', engine: 'standard', locale: 'en-US' } },-- pl_amy_std: { name: 'PollyAmy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Amy', engine: 'standard', locale: 'en-GB' } },-- pl_brian_std: { name: 'PollyBrian', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Brian', engine: 'standard', locale: 'en-GB' } },-- pl_olivia_std: { name: 'PollyOlivia', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Olivia', engine: 'standard', locale: 'en-AU' } },-- pl_ivy_std: { name: 'PollyIvy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Ivy', engine: 'standard', locale: 'en-US' } },-- pl_joey_std: { name: 'PollyJoey', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joey', engine: 'standard', locale: 'en-US' } },-- pl_kendra_std: { name: 'PollyKendra', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Kendra', engine: 'standard', locale: 'en-US' } },++ pl_joanna_std: { name: 'PollyJoanna', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joanna', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-F', gender: 'FEMALE', locale: 'en-US' } },++ pl_matthew_std: { name: 'PollyMatt', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Matthew', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-B', gender: 'MALE', locale: 'en-US' } },++ pl_amy_std: { name: 'PollyAmy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Amy', engine: 'standard', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Standard-C', gender: 'FEMALE', locale: 'en-GB' } },++ pl_brian_std: { name: 'PollyBrian', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Brian', engine: 'standard', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Standard-D', gender: 'MALE', locale: 'en-GB' } },++ pl_olivia_std: { name: 'PollyOlivia', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Olivia', engine: 'standard', locale: 'en-AU' }, googleStandIn: { name: 'en-AU-Standard-C', gender: 'FEMALE', locale: 'en-AU' } },++ pl_ivy_std: { name: 'PollyIvy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Ivy', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-G', gender: 'FEMALE', locale: 'en-US' } },++ pl_joey_std: { name: 'PollyJoey', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joey', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-I', gender: 'MALE', locale: 'en-US' } },++ pl_kendra_std: { name: 'PollyKendra', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Kendra', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-E', gender: 'FEMALE', locale: 'en-US' } },// Neural-- pl_joanna_neural: { name: 'NeuralJoanna', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Joanna', engine: 'neural', locale: 'en-US' } },-- pl_matthew_neural: { name: 'NeuralMatt', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Matthew', engine: 'neural', locale: 'en-US' } },-- pl_ruth_neural: { name: 'NeuralRuth', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Ruth', engine: 'neural', locale: 'en-US' } },-- pl_stephen_neural: { name: 'NeuralStephen', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Stephen', engine: 'neural', locale: 'en-US' } },-- pl_amy_neural: { name: 'NeuralAmy', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Amy', engine: 'neural', locale: 'en-GB' } },-- pl_arthur_neural: { name: 'NeuralArthur', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Arthur', engine: 'neural', locale: 'en-GB' } },-- pl_danielle_neural: { name: 'NeuralDanielle', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Danielle', engine: 'neural', locale: 'en-US' } },-- pl_gregory_neural: { name: 'NeuralGregory', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Gregory', engine: 'neural', locale: 'en-US' } },-- pl_kevin_neural: { name: 'NeuralKevin', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Kevin', engine: 'neural', locale: 'en-US' } },++ pl_joanna_neural: { name: 'NeuralJoanna', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Joanna', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-C', gender: 'FEMALE', locale: 'en-US' } },++ pl_matthew_neural: { name: 'NeuralMatt', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Matthew', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-A', gender: 'MALE', locale: 'en-US' } },++ pl_ruth_neural: { name: 'NeuralRuth', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Ruth', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-E', gender: 'FEMALE', locale: 'en-US' } },++ pl_stephen_neural: { name: 'NeuralStephen', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Stephen', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-I', gender: 'MALE', locale: 'en-US' } },++ pl_amy_neural: { name: 'NeuralAmy', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Amy', engine: 'neural', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Neural2-C', gender: 'FEMALE', locale: 'en-GB' } },++ pl_arthur_neural: { name: 'NeuralArthur', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Arthur', engine: 'neural', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Neural2-B', gender: 'MALE', locale: 'en-GB' } },++ pl_danielle_neural: { name: 'NeuralDanielle', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Danielle', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-G', gender: 'FEMALE', locale: 'en-US' } },++ pl_gregory_neural: { name: 'NeuralGregory', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Gregory', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-B', gender: 'MALE', locale: 'en-US' } },++ pl_kevin_neural: { name: 'NeuralKevin', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Kevin', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-I', gender: 'MALE', locale: 'en-US' } },// Long-form-- pl_danielle_long: { name: 'LongDanielle', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Danielle', engine: 'long-form', locale: 'en-US' } },-- pl_gregory_long: { name: 'LongGregory', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Gregory', engine: 'long-form', locale: 'en-US' } },-- pl_ruth_long: { name: 'LongRuth', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Ruth', engine: 'long-form', locale: 'en-US' } },++ pl_danielle_long: { name: 'LongDanielle', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Danielle', engine: 'long-form', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-H', gender: 'FEMALE', locale: 'en-US' } },++ pl_gregory_long: { name: 'LongGregory', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Gregory', engine: 'long-form', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-J', gender: 'MALE', locale: 'en-US' } },++ pl_ruth_long: { name: 'LongRuth', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Ruth', engine: 'long-form', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-C', gender: 'FEMALE', locale: 'en-US' } },// Generative-- pl_matthew_gen: { name: 'GenMatt', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Matthew', engine: 'generative', locale: 'en-US' } },-- pl_ruth_gen: { name: 'GenRuth', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Ruth', engine: 'generative', locale: 'en-US' } },++ pl_matthew_gen: { name: 'GenMatt', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Matthew', engine: 'generative', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-A', gender: 'MALE', locale: 'en-US' } },++ pl_ruth_gen: { name: 'GenRuth', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Ruth', engine: 'generative', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-E', gender: 'FEMALE', locale: 'en-US' } },// ── Browser-only voices (Self TTS — no server synthesis) ──voice_default: { name: 'Default', emoji: '🔊', engine: 'browser', rarity: 'common', tier: 0, browserVoice: { pitch: 1.0, rate: 1.0 } },@@@ -323,7 +327,7 @@ function synthesizeGoogleCloud(text, voiceDef) {const requestBody = JSON.stringify({input: { text },voice: { languageCode: cv.locale, name: cv.name, ssmlGender: cv.gender },-- audioConfig: { audioEncoding: 'MP3', speakingRate: 1.0, pitch: 0.0 },++ audioConfig: { audioEncoding: 'MP3', speakingRate: cv.speakingRate != null ? cv.speakingRate : 1.0, pitch: cv.pitch != null ? cv.pitch : 0.0 },});let headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) };@@@ -584,10 +588,12 @@ async function _synthesizeWithDef(text, voiceDef, voiceId) {return { ...(await synthesizeGoogleCloud(text, voiceDef)), voiceId };}case 'amazon-polly': {-- if (!settings.awsAccessKeyId) {-- return _espeakFallback(text, voiceId);++ if (!settings.awsAccessKeyId) return await _pollyStandIn(text, voiceDef, voiceId, 'AWS is not configured');++ try {++ return { ...(await synthesizePolly(text, voiceDef)), voiceId };++ } catch (err) {++ return await _pollyStandIn(text, voiceDef, voiceId, err.message);}-- return { ...(await synthesizePolly(text, voiceDef)), voiceId };}case 'espeak-ng':return { ...(await synthesizeEspeak(text, voiceDef)), voiceId };@@@ -604,6 +610,27 @@ async function _synthesizeWithDef(text, voiceDef, voiceId) {}}++// When Polly cannot answer (no AWS key, or a refused one: 2026-10-22 production answered 403 "The security token included++// in the request is invalid" for every Polly voice), each Polly voice speaks with its own Google stand-in, so the voices++// people equip stay different; only without Google too does it become Gary. The reason is logged at most once in ten++// minutes, so a broken AWS key shows in the logs without flooding them.++let _pollyWarnedAt = 0;++async function _pollyStandIn(text, voiceDef, voiceId, reason) {++ if (Date.now() - _pollyWarnedAt > 10 * 60 * 1000) {++ _pollyWarnedAt = Date.now();++ console.warn(`[TTS] Amazon Polly unavailable (${String(reason || '').slice(0, 160)}): Polly voices use their Google stand-ins`);++ }++ const settings = getTTSSettings();++ if (voiceDef.googleStandIn && (settings.googleApiKey || settings.googleServiceAccount)) {++ try {++ return { ...(await synthesizeGoogleCloud(text, { cloudVoice: voiceDef.googleStandIn })), voiceId, fallback: 'google' };++ } catch (err) {++ console.error(`[TTS] Google stand-in failed (${voiceId}):`, err.message);++ }++ }++ return _espeakFallback(text, voiceId);++}++function _espeakFallback(text, voiceId) {const fallbackDef = VOICE_CATALOG.gary;return synthesizeEspeak(text, fallbackDef).then(result => ({ ...result, voiceId, fallback: true }));nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * Every voice a person equips speaks as itself (server/chat/tts-engine.js; 2026-10-22: in production Amazon Polly++ * answered 403 "The security token included in the request is invalid", so every Polly voice became Gary, and Live's++ * voice_vegas was not in the catalog at all):++ * - voice_vegas and vs_mine_dwarf_lord are in the catalog; voice_vegas is a Google voice with its own pitch and rate;++ * - a Polly voice whose request is refused, or with no AWS key, speaks with its own Google stand-in;++ * - every stand-in is a different Google voice, and none is a voice another entry already uses.++ * The network is a stand-in (https.request): Google answers with the voice it was asked for, Polly refuses.++ */++const assert = require('assert');++const https = require('https');++const { EventEmitter } = require('events');++++const ctx = require('../server/live-context');++const SETTINGS = { tts_enabled: 'true', tts_google_api_key: 'test-google-key', tts_aws_access_key_id: 'AKIATESTTESTTESTTEST', tts_aws_secret_access_key: 'secret', tts_aws_region: 'us-east-1', tts_default_voice: 'gary' };++ctx.getSetting = (k) => (Object.prototype.hasOwnProperty.call(SETTINGS, k) ? SETTINGS[k] : null);++++const asked = [];++https.request = (url, opts, cb) => {++ // Google is called as request(url, options, cb); Polly as request(options, cb).++ if (typeof url === 'object') { cb = opts; url = `https://${url.hostname || url.host}${url.path || '/'}`; }++ const req = new EventEmitter();++ let body = '';++ req.write = (c) => { body += c; };++ req.end = (c) => {++ if (c) body += c;++ const host = new URL(String(url)).host;++ asked.push({ host, body });++ const res = new EventEmitter();++ let status = 200;++ let payload;++ if (host.includes('polly')) { status = 403; payload = { message: 'The security token included in the request is invalid.' }; }++ else { const voice = JSON.parse(body).voice.name; payload = { audioContent: Buffer.from(`mp3 of ${voice}`).toString('base64') }; }++ res.statusCode = status;++ res.headers = { 'content-type': 'application/json' };++ setImmediate(() => { cb(res); res.emit('data', Buffer.from(JSON.stringify(payload))); res.emit('end'); });++ };++ req.on = req.addListener.bind(req);++ req.setTimeout = () => req;++ req.destroy = () => {};++ return req;++};++console.warn = () => {};++console.error = () => {};++++const tts = require('../server/chat/tts-engine');++const voiceOf = (r) => Buffer.from(String(r.audio), 'base64').toString().replace('mp3 of ', '');++++(async () => {++ const C = tts.VOICE_CATALOG;++ assert.ok(C.voice_vegas && C.voice_vegas.engine === 'google-cloud', 'voice_vegas is in the catalog');++ assert.ok(C.vs_mine_dwarf_lord && C.vs_mine_dwarf_lord.engine === 'espeak-ng', 'the Dwarf Lord is in the catalog');++++ asked.length = 0;++ const vegas = await tts.synthesize('Ladies and gentlemen', 'voice_vegas', 'josh');++ assert.deepStrictEqual([vegas.engine, vegas.voiceId, voiceOf(vegas)], ['google-cloud', 'voice_vegas', 'en-US-Neural2-J']);++ const sent = JSON.parse(asked[0].body).audioConfig;++ assert.deepStrictEqual([sent.pitch, sent.speakingRate], [-4, 0.9], 'its own pitch and rate reach Google');++ const plain = await tts.synthesize('hello', 'gc_studio_m', 'x');++ assert.deepStrictEqual(JSON.parse(asked[asked.length - 1].body).audioConfig, { audioEncoding: 'MP3', speakingRate: 1, pitch: 0 }, 'other Google voices keep the defaults');++ assert.strictEqual(voiceOf(plain), 'en-US-Studio-Q');++++ // Polly refuses (a bad AWS key): each voice speaks with its own stand-in, not Gary.++ const joanna = await tts.synthesize('hi', 'pl_joanna_std', 'x');++ const matthew = await tts.synthesize('hi', 'pl_matthew_std', 'x');++ assert.ok(asked.some((a) => a.host.includes('polly')), 'Polly was asked first');++ assert.deepStrictEqual([joanna.engine, joanna.fallback, joanna.voiceId, voiceOf(joanna)], ['google-cloud', 'google', 'pl_joanna_std', 'en-US-Standard-F']);++ assert.strictEqual(voiceOf(matthew), 'en-US-Standard-B');++++ // No AWS key at all: the same stand-ins, without asking Polly.++ SETTINGS.tts_aws_access_key_id = '';++ tts.invalidateSettingsCache();++ asked.length = 0;++ const ivy = await tts.synthesize('hi', 'pl_ivy_std', 'x');++ assert.ok(!asked.some((a) => a.host.includes('polly')));++ assert.deepStrictEqual([ivy.fallback, voiceOf(ivy)], ['google', 'en-US-Standard-G']);++++ // Every Polly voice has a stand-in; all are different and none is a voice another entry uses.++ const used = new Set(Object.values(C).filter((v) => v.cloudVoice).map((v) => v.cloudVoice.name));++ const polly = Object.entries(C).filter(([, v]) => v.engine === 'amazon-polly');++ const standIns = polly.map(([id, v]) => { assert.ok(v.googleStandIn, `${id} has a stand-in`); return v.googleStandIn.name; });++ assert.strictEqual(new Set(standIns).size, standIns.length, 'every stand-in is a different voice');++ for (const n of standIns) assert.ok(!used.has(n), `${n} is not already another voice`);++ console.log('tts voices: all checks passed');++})().catch((e) => { console.log(e); process.exit(1); });
@@ -64,6 +64,8 @@ const VOICE_CATALOG = {
@@ -64,6 +64,8 @@ const VOICE_CATALOG = {
warlord: { name: 'WarLord', emoji: '⚔️', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m7', pitch: 10, speed: 110 } },
warlord: { name: 'WarLord', emoji: '⚔️', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m7', pitch: 10, speed: 110 } },
degenking: { name: 'DegenKing', emoji: '👑', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m5', pitch: 75, speed: 195 } },
degenking: { name: 'DegenKing', emoji: '👑', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m5', pitch: 75, speed: 195 } },
fisherman: { name: 'OldManSea', emoji: '🎣', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m4', pitch: 30, speed: 95 } },
fisherman: { name: 'OldManSea', emoji: '🎣', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m4', pitch: 30, speed: 95 } },
+ // A Live skill-mastery voice (vs_mine_dwarf_lord): deep and slow.
+ vs_mine_dwarf_lord: { name: 'DwarfLord', emoji: '⛏️', engine: 'espeak-ng', rarity: 'legendary', tier: 5, params: { voice: 'en+m7', pitch: 5, speed: 120 } },
coinlord: { name: 'CoinLord', emoji: '💰', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m6', pitch: 40, speed: 150 } },
coinlord: { name: 'CoinLord', emoji: '💰', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m6', pitch: 40, speed: 150 } },
// ── Google Cloud TTS voices ───────────────────────────────
// ── Google Cloud TTS voices ───────────────────────────────
@@ -93,35 +95,37 @@ const VOICE_CATALOG = {
@@ -93,35 +95,37 @@ const VOICE_CATALOG = {
// Journey
// Journey
gc_journey_sage: { name: 'JourneySage', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-D', gender: 'MALE', locale: 'en-US' } },
gc_journey_sage: { name: 'JourneySage', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-D', gender: 'MALE', locale: 'en-US' } },
gc_journey_oracle: { name: 'JourneyOracle', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-F', gender: 'FEMALE', locale: 'en-US' } },
gc_journey_oracle: { name: 'JourneyOracle', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-F', gender: 'FEMALE', locale: 'en-US' } },
+ // One of a kind (2026-10-22): Josh's Vegas Announcer, a deep, unhurried Neural2 voice.
+ voice_vegas: { name: 'VegasAnnouncer', emoji: '🎙️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Neural2-J', gender: 'MALE', locale: 'en-US', pitch: -4.0, speakingRate: 0.9 } },
gc_journey_whisper: { name: 'JourneyWhisper', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-O', gender: 'FEMALE', locale: 'en-US' } },
gc_journey_whisper: { name: 'JourneyWhisper', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-O', gender: 'FEMALE', locale: 'en-US' } },
// ── Amazon Polly voices ───────────────────────────────────
// ── Amazon Polly voices ───────────────────────────────────
// Standard
// Standard
- pl_joanna_std: { name: 'PollyJoanna', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joanna', engine: 'standard', locale: 'en-US' } },
- pl_matthew_std: { name: 'PollyMatt', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Matthew', engine: 'standard', locale: 'en-US' } },
- pl_amy_std: { name: 'PollyAmy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Amy', engine: 'standard', locale: 'en-GB' } },
- pl_brian_std: { name: 'PollyBrian', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Brian', engine: 'standard', locale: 'en-GB' } },
- pl_olivia_std: { name: 'PollyOlivia', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Olivia', engine: 'standard', locale: 'en-AU' } },
- pl_ivy_std: { name: 'PollyIvy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Ivy', engine: 'standard', locale: 'en-US' } },
- pl_joey_std: { name: 'PollyJoey', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joey', engine: 'standard', locale: 'en-US' } },
- pl_kendra_std: { name: 'PollyKendra', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Kendra', engine: 'standard', locale: 'en-US' } },
+ pl_joanna_std: { name: 'PollyJoanna', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joanna', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-F', gender: 'FEMALE', locale: 'en-US' } },
+ pl_matthew_std: { name: 'PollyMatt', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Matthew', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-B', gender: 'MALE', locale: 'en-US' } },
+ pl_amy_std: { name: 'PollyAmy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Amy', engine: 'standard', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Standard-C', gender: 'FEMALE', locale: 'en-GB' } },
+ pl_brian_std: { name: 'PollyBrian', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Brian', engine: 'standard', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Standard-D', gender: 'MALE', locale: 'en-GB' } },
+ pl_olivia_std: { name: 'PollyOlivia', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Olivia', engine: 'standard', locale: 'en-AU' }, googleStandIn: { name: 'en-AU-Standard-C', gender: 'FEMALE', locale: 'en-AU' } },
+ pl_ivy_std: { name: 'PollyIvy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Ivy', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-G', gender: 'FEMALE', locale: 'en-US' } },
+ pl_joey_std: { name: 'PollyJoey', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joey', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-I', gender: 'MALE', locale: 'en-US' } },
+ pl_kendra_std: { name: 'PollyKendra', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Kendra', engine: 'standard', locale: 'en-US' }, googleStandIn: { name: 'en-US-Standard-E', gender: 'FEMALE', locale: 'en-US' } },
// Neural
// Neural
- pl_joanna_neural: { name: 'NeuralJoanna', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Joanna', engine: 'neural', locale: 'en-US' } },
- pl_matthew_neural: { name: 'NeuralMatt', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Matthew', engine: 'neural', locale: 'en-US' } },
- pl_ruth_neural: { name: 'NeuralRuth', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Ruth', engine: 'neural', locale: 'en-US' } },
- pl_stephen_neural: { name: 'NeuralStephen', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Stephen', engine: 'neural', locale: 'en-US' } },
- pl_amy_neural: { name: 'NeuralAmy', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Amy', engine: 'neural', locale: 'en-GB' } },
- pl_arthur_neural: { name: 'NeuralArthur', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Arthur', engine: 'neural', locale: 'en-GB' } },
- pl_danielle_neural: { name: 'NeuralDanielle', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Danielle', engine: 'neural', locale: 'en-US' } },
- pl_gregory_neural: { name: 'NeuralGregory', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Gregory', engine: 'neural', locale: 'en-US' } },
- pl_kevin_neural: { name: 'NeuralKevin', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Kevin', engine: 'neural', locale: 'en-US' } },
+ pl_joanna_neural: { name: 'NeuralJoanna', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Joanna', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-C', gender: 'FEMALE', locale: 'en-US' } },
+ pl_matthew_neural: { name: 'NeuralMatt', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Matthew', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-A', gender: 'MALE', locale: 'en-US' } },
+ pl_ruth_neural: { name: 'NeuralRuth', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Ruth', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-E', gender: 'FEMALE', locale: 'en-US' } },
+ pl_stephen_neural: { name: 'NeuralStephen', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Stephen', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-I', gender: 'MALE', locale: 'en-US' } },
+ pl_amy_neural: { name: 'NeuralAmy', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Amy', engine: 'neural', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Neural2-C', gender: 'FEMALE', locale: 'en-GB' } },
+ pl_arthur_neural: { name: 'NeuralArthur', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Arthur', engine: 'neural', locale: 'en-GB' }, googleStandIn: { name: 'en-GB-Neural2-B', gender: 'MALE', locale: 'en-GB' } },
+ pl_danielle_neural: { name: 'NeuralDanielle', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Danielle', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Neural2-G', gender: 'FEMALE', locale: 'en-US' } },
+ pl_gregory_neural: { name: 'NeuralGregory', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Gregory', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-B', gender: 'MALE', locale: 'en-US' } },
+ pl_kevin_neural: { name: 'NeuralKevin', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Kevin', engine: 'neural', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-I', gender: 'MALE', locale: 'en-US' } },
// Long-form
// Long-form
- pl_danielle_long: { name: 'LongDanielle', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Danielle', engine: 'long-form', locale: 'en-US' } },
- pl_gregory_long: { name: 'LongGregory', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Gregory', engine: 'long-form', locale: 'en-US' } },
- pl_ruth_long: { name: 'LongRuth', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Ruth', engine: 'long-form', locale: 'en-US' } },
+ pl_danielle_long: { name: 'LongDanielle', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Danielle', engine: 'long-form', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-H', gender: 'FEMALE', locale: 'en-US' } },
+ pl_gregory_long: { name: 'LongGregory', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Gregory', engine: 'long-form', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-J', gender: 'MALE', locale: 'en-US' } },
+ pl_ruth_long: { name: 'LongRuth', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Ruth', engine: 'long-form', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-C', gender: 'FEMALE', locale: 'en-US' } },
// Generative
// Generative
- pl_matthew_gen: { name: 'GenMatt', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Matthew', engine: 'generative', locale: 'en-US' } },
- pl_ruth_gen: { name: 'GenRuth', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Ruth', engine: 'generative', locale: 'en-US' } },
+ pl_matthew_gen: { name: 'GenMatt', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Matthew', engine: 'generative', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-A', gender: 'MALE', locale: 'en-US' } },
+ pl_ruth_gen: { name: 'GenRuth', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Ruth', engine: 'generative', locale: 'en-US' }, googleStandIn: { name: 'en-US-Wavenet-E', gender: 'FEMALE', locale: 'en-US' } },
// ── Browser-only voices (Self TTS — no server synthesis) ──
// ── Browser-only voices (Self TTS — no server synthesis) ──
voice_default: { name: 'Default', emoji: '🔊', engine: 'browser', rarity: 'common', tier: 0, browserVoice: { pitch: 1.0, rate: 1.0 } },
voice_default: { name: 'Default', emoji: '🔊', engine: 'browser', rarity: 'common', tier: 0, browserVoice: { pitch: 1.0, rate: 1.0 } },
@@ -323,7 +327,7 @@ function synthesizeGoogleCloud(text, voiceDef) {
@@ -323,7 +327,7 @@ function synthesizeGoogleCloud(text, voiceDef) {
const requestBody = JSON.stringify({
const requestBody = JSON.stringify({
input: { text },
input: { text },
voice: { languageCode: cv.locale, name: cv.name, ssmlGender: cv.gender },
voice: { languageCode: cv.locale, name: cv.name, ssmlGender: cv.gender },
- audioConfig: { audioEncoding: 'MP3', speakingRate: 1.0, pitch: 0.0 },
+ audioConfig: { audioEncoding: 'MP3', speakingRate: cv.speakingRate != null ? cv.speakingRate : 1.0, pitch: cv.pitch != null ? cv.pitch : 0.0 },
});
});
let headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) };
let headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) };
@@ -584,10 +588,12 @@ async function _synthesizeWithDef(text, voiceDef, voiceId) {
@@ -584,10 +588,12 @@ async function _synthesizeWithDef(text, voiceDef, voiceId) {
return { ...(await synthesizeGoogleCloud(text, voiceDef)), voiceId };
return { ...(await synthesizeGoogleCloud(text, voiceDef)), voiceId };
}
}
case 'amazon-polly': {
case 'amazon-polly': {
- if (!settings.awsAccessKeyId) {
- return _espeakFallback(text, voiceId);
+ if (!settings.awsAccessKeyId) return await _pollyStandIn(text, voiceDef, voiceId, 'AWS is not configured');
+ try {
+ return { ...(await synthesizePolly(text, voiceDef)), voiceId };
+ } catch (err) {
+ return await _pollyStandIn(text, voiceDef, voiceId, err.message);
}
}
- return { ...(await synthesizePolly(text, voiceDef)), voiceId };
}
}
case 'espeak-ng':
case 'espeak-ng':
return { ...(await synthesizeEspeak(text, voiceDef)), voiceId };
return { ...(await synthesizeEspeak(text, voiceDef)), voiceId };
@@ -604,6 +610,27 @@ async function _synthesizeWithDef(text, voiceDef, voiceId) {
@@ -604,6 +610,27 @@ async function _synthesizeWithDef(text, voiceDef, voiceId) {
}
}
}
}
+// When Polly cannot answer (no AWS key, or a refused one: 2026-10-22 production answered 403 "The security token included
+// in the request is invalid" for every Polly voice), each Polly voice speaks with its own Google stand-in, so the voices
+// people equip stay different; only without Google too does it become Gary. The reason is logged at most once in ten
+// minutes, so a broken AWS key shows in the logs without flooding them.
+let _pollyWarnedAt = 0;
+async function _pollyStandIn(text, voiceDef, voiceId, reason) {
+ if (Date.now() - _pollyWarnedAt > 10 * 60 * 1000) {
+ _pollyWarnedAt = Date.now();
+ console.warn(`[TTS] Amazon Polly unavailable (${String(reason || '').slice(0, 160)}): Polly voices use their Google stand-ins`);
+ }
+ const settings = getTTSSettings();
+ if (voiceDef.googleStandIn && (settings.googleApiKey || settings.googleServiceAccount)) {
+ try {
+ return { ...(await synthesizeGoogleCloud(text, { cloudVoice: voiceDef.googleStandIn })), voiceId, fallback: 'google' };
+ } catch (err) {
+ console.error(`[TTS] Google stand-in failed (${voiceId}):`, err.message);
+ }
+ }
+ return _espeakFallback(text, voiceId);
+}
+
function _espeakFallback(text, voiceId) {
function _espeakFallback(text, voiceId) {
const fallbackDef = VOICE_CATALOG.gary;
const fallbackDef = VOICE_CATALOG.gary;
return synthesizeEspeak(text, fallbackDef).then(result => ({ ...result, voiceId, fallback: true }));
return synthesizeEspeak(text, fallbackDef).then(result => ({ ...result, voiceId, fallback: true }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * Every voice a person equips speaks as itself (server/chat/tts-engine.js; 2026-10-22: in production Amazon Polly
+ * answered 403 "The security token included in the request is invalid", so every Polly voice became Gary, and Live's
+ * voice_vegas was not in the catalog at all):
+ * - voice_vegas and vs_mine_dwarf_lord are in the catalog; voice_vegas is a Google voice with its own pitch and rate;
+ * - a Polly voice whose request is refused, or with no AWS key, speaks with its own Google stand-in;
+ * - every stand-in is a different Google voice, and none is a voice another entry already uses.
+ * The network is a stand-in (https.request): Google answers with the voice it was asked for, Polly refuses.
+ */
+const assert = require('assert');
+const https = require('https');
+const { EventEmitter } = require('events');
+
+const ctx = require('../server/live-context');
+const SETTINGS = { tts_enabled: 'true', tts_google_api_key: 'test-google-key', tts_aws_access_key_id: 'AKIATESTTESTTESTTEST', tts_aws_secret_access_key: 'secret', tts_aws_region: 'us-east-1', tts_default_voice: 'gary' };
+ctx.getSetting = (k) => (Object.prototype.hasOwnProperty.call(SETTINGS, k) ? SETTINGS[k] : null);
+
+const asked = [];
+https.request = (url, opts, cb) => {
+ // Google is called as request(url, options, cb); Polly as request(options, cb).
+ if (typeof url === 'object') { cb = opts; url = `https://${url.hostname || url.host}${url.path || '/'}`; }
+ const req = new EventEmitter();
+ let body = '';
+ req.write = (c) => { body += c; };
+ req.end = (c) => {
+ if (c) body += c;
+ const host = new URL(String(url)).host;
+ asked.push({ host, body });
+ const res = new EventEmitter();
+ let status = 200;
+ let payload;
+ if (host.includes('polly')) { status = 403; payload = { message: 'The security token included in the request is invalid.' }; }
+ else { const voice = JSON.parse(body).voice.name; payload = { audioContent: Buffer.from(`mp3 of ${voice}`).toString('base64') }; }
+ res.statusCode = status;
+ res.headers = { 'content-type': 'application/json' };
+ setImmediate(() => { cb(res); res.emit('data', Buffer.from(JSON.stringify(payload))); res.emit('end'); });
+ };
+ req.on = req.addListener.bind(req);
+ req.setTimeout = () => req;
+ req.destroy = () => {};
+ return req;
+};
+console.warn = () => {};
+console.error = () => {};
+
+const tts = require('../server/chat/tts-engine');
+const voiceOf = (r) => Buffer.from(String(r.audio), 'base64').toString().replace('mp3 of ', '');
+
+(async () => {
+ const C = tts.VOICE_CATALOG;
+ assert.ok(C.voice_vegas && C.voice_vegas.engine === 'google-cloud', 'voice_vegas is in the catalog');
+ assert.ok(C.vs_mine_dwarf_lord && C.vs_mine_dwarf_lord.engine === 'espeak-ng', 'the Dwarf Lord is in the catalog');
+
+ asked.length = 0;
+ const vegas = await tts.synthesize('Ladies and gentlemen', 'voice_vegas', 'josh');
+ assert.deepStrictEqual([vegas.engine, vegas.voiceId, voiceOf(vegas)], ['google-cloud', 'voice_vegas', 'en-US-Neural2-J']);
+ const sent = JSON.parse(asked[0].body).audioConfig;
+ assert.deepStrictEqual([sent.pitch, sent.speakingRate], [-4, 0.9], 'its own pitch and rate reach Google');
+ const plain = await tts.synthesize('hello', 'gc_studio_m', 'x');
+ assert.deepStrictEqual(JSON.parse(asked[asked.length - 1].body).audioConfig, { audioEncoding: 'MP3', speakingRate: 1, pitch: 0 }, 'other Google voices keep the defaults');
+ assert.strictEqual(voiceOf(plain), 'en-US-Studio-Q');
+
+ // Polly refuses (a bad AWS key): each voice speaks with its own stand-in, not Gary.
+ const joanna = await tts.synthesize('hi', 'pl_joanna_std', 'x');
+ const matthew = await tts.synthesize('hi', 'pl_matthew_std', 'x');
+ assert.ok(asked.some((a) => a.host.includes('polly')), 'Polly was asked first');
+ assert.deepStrictEqual([joanna.engine, joanna.fallback, joanna.voiceId, voiceOf(joanna)], ['google-cloud', 'google', 'pl_joanna_std', 'en-US-Standard-F']);
+ assert.strictEqual(voiceOf(matthew), 'en-US-Standard-B');
+
+ // No AWS key at all: the same stand-ins, without asking Polly.
+ SETTINGS.tts_aws_access_key_id = '';
+ tts.invalidateSettingsCache();
+ asked.length = 0;
+ const ivy = await tts.synthesize('hi', 'pl_ivy_std', 'x');
+ assert.ok(!asked.some((a) => a.host.includes('polly')));
+ assert.deepStrictEqual([ivy.fallback, voiceOf(ivy)], ['google', 'en-US-Standard-G']);
+
+ // Every Polly voice has a stand-in; all are different and none is a voice another entry uses.
+ const used = new Set(Object.values(C).filter((v) => v.cloudVoice).map((v) => v.cloudVoice.name));
+ const polly = Object.entries(C).filter(([, v]) => v.engine === 'amazon-polly');
+ const standIns = polly.map(([id, v]) => { assert.ok(v.googleStandIn, `${id} has a stand-in`); return v.googleStandIn.name; });
+ assert.strictEqual(new Set(standIns).size, standIns.length, 'every stand-in is a different voice');
+ for (const n of standIns) assert.ok(!used.has(n), `${n} is not already another voice`);
+ console.log('tts voices: all checks passed');
+})().catch((e) => { console.log(e); process.exit(1); });