agentvibes 5.12.0 → 5.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.agentvibes/install-manifest.json +111 -91
  2. package/.claude/commands/agent-vibes/commands.json +0 -20
  3. package/.claude/commands/agent-vibes/unmute.md +6 -2
  4. package/.claude/config/audio-effects.cfg +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  7. package/.claude/hooks/bmad-party-speak.sh +20 -4
  8. package/.claude/hooks/bmad-speak.sh +60 -2
  9. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  10. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  11. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  12. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  13. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  14. package/.claude/hooks/kokoro-installer.sh +20 -10
  15. package/.claude/hooks/language-manager.sh +10 -3
  16. package/.claude/hooks/party-set-room.sh +71 -0
  17. package/.claude/hooks/party-stage-roster.py +328 -0
  18. package/.claude/hooks/personality-manager.sh +19 -2
  19. package/.claude/hooks/piper-voice-manager.sh +3 -2
  20. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  21. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  22. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  23. package/.claude/hooks/play-tts-soprano.sh +3 -2
  24. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  25. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  26. package/.claude/hooks/play-tts.sh +66 -61
  27. package/.claude/hooks/provider-catalog.json +352 -0
  28. package/.claude/hooks/provider-catalog.sh +161 -0
  29. package/.claude/hooks/provider-commands.sh +2 -1
  30. package/.claude/hooks/provider-manager.sh +47 -9
  31. package/.claude/hooks/python-resolver.sh +117 -0
  32. package/.claude/hooks/session-id.sh +56 -0
  33. package/.claude/hooks/session-start-tts.sh +39 -0
  34. package/.claude/hooks/speed-manager.sh +1 -1
  35. package/.claude/hooks/translate-manager.sh +3 -2
  36. package/.claude/hooks/translator.py +1 -1
  37. package/.claude/hooks/voice-manager.sh +242 -10
  38. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  39. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  40. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  41. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  42. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  43. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  44. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  45. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  46. package/.mcp.json +0 -7
  47. package/README.md +12 -3
  48. package/RELEASE_NOTES.md +61 -0
  49. package/mcp-server/server.py +146 -49
  50. package/mcp-server/test_mcp_correctness.py +20 -2
  51. package/mcp-server/test_windows_script_parity.py +0 -2
  52. package/package.json +2 -2
  53. package/src/cli/list-voices.js +218 -114
  54. package/src/console/bling.js +71 -0
  55. package/src/console/music-preview.js +79 -0
  56. package/src/console/tabs/music-tab.js +16 -39
  57. package/src/console/tabs/settings-tab.js +195 -13
  58. package/src/console/tabs/setup-tab.js +9 -34
  59. package/src/console/tabs/voices-tab.js +83 -14
  60. package/src/console/widgets/track-picker.js +82 -0
  61. package/src/installer.js +136 -18
  62. package/src/services/provider-catalog.js +412 -0
  63. package/src/services/provider-voice-catalog.js +52 -73
  64. package/src/services/tts-engine-service.js +29 -0
  65. package/src/utils/provider-validator.js +62 -12
  66. package/.claude/commands/agent-vibes/language.md +0 -23
  67. package/.claude/commands/agent-vibes/learn.md +0 -67
  68. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  69. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  70. package/.claude/commands/agent-vibes/target.md +0 -30
  71. package/.claude/hooks/learn-manager.sh +0 -492
  72. package/.claude/hooks/replay-target-audio.sh +0 -95
  73. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Provider Catalog — Layer 2 of the SSOT program (companion to the Utterance
3
+ * Resolver, src/services/utterance-resolver.js). See
4
+ * docs/architecture/provider-catalog-design.md.
5
+ *
6
+ * ONE record per provider answers the inventory question — "WHAT exists to speak
7
+ * with?": voice lists, defaults, membership validation, per-platform runtime,
8
+ * and display names. The resolver answers the orthogonal question ("HOW is this
9
+ * utterance spoken?"). The two layers NEVER import each other — the seam is
10
+ * welded by conformance assertions only (see
11
+ * test/unit/provider-catalog-conformance.test.js). That keeps the resolver's
12
+ * 433-test contract untouched.
13
+ *
14
+ * DISCIPLINE: this module is PURE data + PURE functions. No `fs`, no `env`, no
15
+ * clock — exactly like the resolver. Discovered providers (piper, macos, SAPI)
16
+ * receive their installed-voice list via injection (`{ installed }`); the
17
+ * catalog owns the RULE, never the I/O.
18
+ *
19
+ * SECURITY NOTE: the receiver `ALLOWED_VOICES` allowlists
20
+ * (clawdbot-receiver-SECURE.sh) are a SECURITY control, NOT inventory — they are
21
+ * deliberately narrower than this catalog and must never be unified with it.
22
+ *
23
+ * @module services/provider-catalog
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ /* ------------------------------------------------------------------ *
29
+ * Static voice data (the canonical copies; provider-voice-catalog.js
30
+ * re-exports these for back-compat).
31
+ * ------------------------------------------------------------------ */
32
+
33
+ /** ElevenLabs DEFAULT-LIBRARY voices (voice_id + display metadata), available to
34
+ * every API key. Community/library voices that must be added to an account first
35
+ * are intentionally excluded so a switch can't fail at synth time. Mirrored on
36
+ * the shell side by .claude/hooks/elevenlabs-voices.sh (parity-guarded). */
37
+ const ELEVENLABS_VOICES = [
38
+ { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', gender: 'Female', lang: 'en-US', desc: 'Mature, reassuring' },
39
+ { id: 'CwhRBWXzGAHq8TQ4Fs17', name: 'Roger', gender: 'Male', lang: 'en-US', desc: 'Laid-back, casual' },
40
+ { id: 'FGY2WhTYpPnrIDTdsKH5', name: 'Laura', gender: 'Female', lang: 'en-US', desc: 'Enthusiast, quirky' },
41
+ { id: 'IKne3meq5aSn9XLyUdCD', name: 'Charlie', gender: 'Male', lang: 'en-AU', desc: 'Deep, confident' },
42
+ { id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George', gender: 'Male', lang: 'en-GB', desc: 'Warm storyteller' },
43
+ { id: 'N2lVS1w4EtoT3dr4eOWO', name: 'Callum', gender: 'Male', lang: 'en-US', desc: 'Husky trickster' },
44
+ { id: 'SAz9YHcvj6GT2YYXdXww', name: 'River', gender: '', lang: 'en-US', desc: 'Relaxed, neutral' },
45
+ { id: 'SOYHLrjzK2X1ezoPC6cr', name: 'Harry', gender: 'Male', lang: 'en-US', desc: 'Fierce warrior' },
46
+ { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', gender: 'Male', lang: 'en-US', desc: 'Energetic creator' },
47
+ { id: 'Xb7hH8MSUJpSbSDYk0k2', name: 'Alice', gender: 'Female', lang: 'en-GB', desc: 'Clear educator' },
48
+ { id: 'XrExE9yKIg1WjnnlVkGX', name: 'Matilda', gender: 'Female', lang: 'en-US', desc: 'Knowledgable, pro' },
49
+ { id: 'bIHbv24MWmeRgasZH58o', name: 'Will', gender: 'Male', lang: 'en-US', desc: 'Relaxed optimist' },
50
+ { id: 'cgSgspJ2msm6clMCkdW9', name: 'Jessica', gender: 'Female', lang: 'en-US', desc: 'Playful, bright' },
51
+ { id: 'cjVigY5qzO86Huf0OWal', name: 'Eric', gender: 'Male', lang: 'en-US', desc: 'Smooth, trustworthy' },
52
+ { id: 'hpp4J3VqNfWAUOO0d1Us', name: 'Bella', gender: 'Female', lang: 'en-US', desc: 'Professional, warm' },
53
+ { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', gender: 'Male', lang: 'en-US', desc: 'Charming, down-to-earth' },
54
+ { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', gender: 'Male', lang: 'en-US', desc: 'Deep, comforting' },
55
+ { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', gender: 'Male', lang: 'en-GB', desc: 'Steady broadcaster' },
56
+ { id: 'pFZP5JQG7iQjIQuC4Bku', name: 'Lily', gender: 'Female', lang: 'en-GB', desc: 'Velvety actress' },
57
+ { id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam', gender: 'Male', lang: 'en-US', desc: 'Dominant, firm' },
58
+ { id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill', gender: 'Male', lang: 'en-US', desc: 'Wise, mature' },
59
+ ];
60
+
61
+ /** Kokoro voice ids. Gender is encoded in the id: the 2nd char is f|m. */
62
+ const KOKORO_VOICE_IDS = [
63
+ // American English
64
+ 'af_heart','af_alloy','af_aoede','af_bella','af_jessica','af_kore','af_nicole','af_nova','af_river','af_sarah','af_sky',
65
+ 'am_adam','am_echo','am_eric','am_fenrir','am_liam','am_michael','am_onyx','am_puck',
66
+ // British English
67
+ 'bf_alice','bf_emma','bf_isabella','bf_lily',
68
+ 'bm_daniel','bm_fable','bm_george','bm_lewis',
69
+ // Japanese
70
+ 'jf_alpha','jf_gongitsune','jf_nezumi','jf_tebukuro','jm_kumo',
71
+ // Mandarin Chinese
72
+ 'zf_xiaobei','zf_xiaoni','zf_xiaoxiao','zf_xiaoyi','zm_yunxi','zm_yunxia','zm_yunyang',
73
+ // Spanish
74
+ 'ef_dora','em_alex','em_santa',
75
+ // French
76
+ 'ff_siwis',
77
+ // Hindi
78
+ 'hf_alpha','hm_omega',
79
+ // Italian
80
+ 'if_sara','im_nicola',
81
+ // Brazilian Portuguese
82
+ 'pf_dora','pm_alex','pm_santa',
83
+ // Korean
84
+ 'kf_alpha','km_hyunsu',
85
+ ];
86
+
87
+ /**
88
+ * Infer a Kokoro voice's gender from its id. Kokoro ids follow `<lang><sex>_name`
89
+ * where the 2nd character is `f` (female) or `m` (male).
90
+ * @param {string} id
91
+ * @returns {'Female'|'Male'|''}
92
+ */
93
+ function kokoroGender(id) {
94
+ const c = typeof id === 'string' && id.length > 1 ? id[1].toLowerCase() : '';
95
+ if (c === 'f') return 'Female';
96
+ if (c === 'm') return 'Male';
97
+ return '';
98
+ }
99
+
100
+ /* ------------------------------------------------------------------ *
101
+ * Voice-model validation rules (pure). Each returns { ok, canonical, reason }.
102
+ * ------------------------------------------------------------------ */
103
+
104
+ /** Piper-family voice shape: `en_US-lessac-medium` or a `model::Speaker` form. */
105
+ const PIPER_VOICE_SHAPE = /^[a-z]{2,3}_[A-Z]{2}-/;
106
+ /** Raw ElevenLabs voice_id shape (preserves elevenlabs-voices.sh:66 semantics). */
107
+ const ELEVENLABS_ID_SHAPE = /^[A-Za-z0-9]{20}$/;
108
+
109
+ /* ------------------------------------------------------------------ *
110
+ * The seven canonical ProviderRecords.
111
+ *
112
+ * `aliases` are spellings that resolve to THIS record via getProvider()
113
+ * (e.g. windows-sapi ← 'sapi'). `engineId` is the resolver ENGINES value
114
+ * ('sapi' for windows-sapi, 'piper' for windows-piper); the derived
115
+ * engineAliasTable() mirrors the resolver's ENGINE_ALIASES.
116
+ * ------------------------------------------------------------------ */
117
+
118
+ const RECORDS = [
119
+ {
120
+ id: 'soprano',
121
+ engineId: 'soprano',
122
+ aliases: [],
123
+ displayName: 'Soprano TTS',
124
+ voiceModel: 'single',
125
+ voices: ['soprano-default'],
126
+ defaultVoice: 'soprano-default',
127
+ runtime: { unix: 'play-tts-soprano.sh', windows: 'play-tts-soprano.ps1', darwinOnly: false },
128
+ requires: 'pip',
129
+ },
130
+ {
131
+ id: 'piper',
132
+ engineId: 'piper',
133
+ aliases: [],
134
+ displayName: 'Piper TTS',
135
+ voiceModel: 'discovered',
136
+ voices: null, // discovered on disk (*.onnx glob)
137
+ defaultVoice: 'en_US-lessac-medium', // preferred fallback, NOT a guaranteed install
138
+ runtime: { unix: 'play-tts-piper.sh', windows: null, darwinOnly: false }, // Windows uses windows-piper
139
+ requires: 'pip',
140
+ },
141
+ {
142
+ id: 'kokoro',
143
+ engineId: 'kokoro',
144
+ aliases: [],
145
+ displayName: 'Kokoro TTS',
146
+ voiceModel: 'static',
147
+ voices: KOKORO_VOICE_IDS,
148
+ defaultVoice: 'af_heart',
149
+ runtime: { unix: 'play-tts-kokoro.sh', windows: 'play-tts-kokoro.ps1', darwinOnly: false },
150
+ requires: 'pip',
151
+ },
152
+ {
153
+ id: 'elevenlabs',
154
+ engineId: 'elevenlabs',
155
+ aliases: [],
156
+ displayName: 'ElevenLabs',
157
+ voiceModel: 'name-to-id',
158
+ voices: ELEVENLABS_VOICES,
159
+ defaultVoice: 'Sarah',
160
+ runtime: { unix: 'play-tts-elevenlabs.sh', windows: null, darwinOnly: false }, // ← no ps1 runtime; the contradiction, now data
161
+ requires: 'api-key',
162
+ },
163
+ {
164
+ id: 'macos',
165
+ engineId: 'macos',
166
+ aliases: ['macos-say', 'say'],
167
+ displayName: 'macOS Say',
168
+ voiceModel: 'discovered',
169
+ voices: null, // `say -v ?`
170
+ defaultVoice: 'Samantha',
171
+ runtime: { unix: 'play-tts-macos.sh', windows: null, darwinOnly: true },
172
+ requires: 'builtin',
173
+ },
174
+ {
175
+ id: 'windows-sapi',
176
+ engineId: 'sapi',
177
+ aliases: ['sapi'],
178
+ displayName: 'Windows SAPI',
179
+ voiceModel: 'discovered',
180
+ voices: null, // System.Speech
181
+ defaultVoice: '', // system default
182
+ runtime: { unix: null, windows: 'play-tts-sapi.ps1', darwinOnly: false },
183
+ requires: 'builtin',
184
+ },
185
+ {
186
+ id: 'windows-piper',
187
+ engineId: 'piper',
188
+ aliases: ['piper'], // on Windows, bare 'piper' forwards to windows-piper
189
+ displayName: 'Piper TTS',
190
+ voiceModel: 'discovered',
191
+ voices: null, // same *.onnx models as piper
192
+ defaultVoice: 'en_US-lessac-medium',
193
+ runtime: { unix: null, windows: 'play-tts-windows-piper.ps1', darwinOnly: false },
194
+ requires: 'exe',
195
+ },
196
+ ];
197
+
198
+ // Lookup indexes (built once).
199
+ const _BY_ID = new Map();
200
+ const _BY_ALIAS = new Map();
201
+ for (const r of RECORDS) {
202
+ _BY_ID.set(r.id, r);
203
+ }
204
+ for (const r of RECORDS) {
205
+ for (const a of r.aliases) {
206
+ // Exact-id spellings always win, so never let an alias shadow a real id.
207
+ if (!_BY_ID.has(a) && !_BY_ALIAS.has(a)) _BY_ALIAS.set(a, r);
208
+ }
209
+ }
210
+
211
+ /** Normalize an id-or-alias to a lowercase key. */
212
+ function _norm(idOrAlias) {
213
+ return typeof idOrAlias === 'string' ? idOrAlias.trim().toLowerCase() : '';
214
+ }
215
+
216
+ /* ------------------------------------------------------------------ *
217
+ * Public API (design §3.2). All entries are alias-normalizing.
218
+ * ------------------------------------------------------------------ */
219
+
220
+ /**
221
+ * @param {string} idOrAlias
222
+ * @returns {object|null} the ProviderRecord, or null if unknown.
223
+ */
224
+ function getProvider(idOrAlias) {
225
+ const key = _norm(idOrAlias);
226
+ if (!key) return null;
227
+ return _BY_ID.get(key) || _BY_ALIAS.get(key) || null;
228
+ }
229
+
230
+ /** @returns {object[]} all seven records (fresh array; records are shared). */
231
+ function listProviders() {
232
+ return RECORDS.slice();
233
+ }
234
+
235
+ /**
236
+ * Is a provider playable on `platform`?
237
+ * - 'windows' → has a Windows runtime script.
238
+ * - 'darwin' → has a Unix runtime script (darwin runs the .sh players).
239
+ * - 'unix' → has a Unix runtime AND is not darwin-only (i.e. Linux).
240
+ * @param {string} idOrAlias
241
+ * @param {'unix'|'windows'|'darwin'} platform
242
+ * @returns {boolean}
243
+ */
244
+ function isAvailable(idOrAlias, platform) {
245
+ const r = getProvider(idOrAlias);
246
+ if (!r) return false;
247
+ if (platform === 'windows') return r.runtime.windows !== null;
248
+ if (platform === 'darwin') return r.runtime.unix !== null;
249
+ return r.runtime.unix !== null && !r.runtime.darwinOnly; // 'unix' == generic Linux
250
+ }
251
+
252
+ /**
253
+ * @param {'unix'|'windows'|'darwin'} platform
254
+ * @returns {object[]} records available on that platform.
255
+ */
256
+ function providersFor(platform) {
257
+ return RECORDS.filter((r) => isAvailable(r.id, platform));
258
+ }
259
+
260
+ /**
261
+ * List a provider's voices as `{ id, name?, gender, lang? }` entries. Discovered
262
+ * providers require an injected `installed` list (else an empty array).
263
+ * @param {string} idOrAlias
264
+ * @param {{installed?: string[]}} [opts]
265
+ * @returns {Array<{id: string, name?: string, gender: string, lang?: string}>}
266
+ */
267
+ function listVoices(idOrAlias, { installed } = {}) {
268
+ const r = getProvider(idOrAlias);
269
+ if (!r) return [];
270
+ switch (r.voiceModel) {
271
+ case 'static': // kokoro
272
+ return r.voices.map((id) => ({ id, gender: kokoroGender(id) }));
273
+ case 'name-to-id': // elevenlabs
274
+ return r.voices.map((v) => ({ id: v.id, name: v.name, gender: v.gender || '', lang: v.lang }));
275
+ case 'single': // soprano
276
+ return r.voices.map((id) => ({ id, gender: '' }));
277
+ case 'discovered': // piper / macos / windows-sapi / windows-piper
278
+ default:
279
+ if (Array.isArray(installed)) return installed.map((id) => ({ id, gender: '' }));
280
+ return [];
281
+ }
282
+ }
283
+
284
+ /**
285
+ * @param {string} idOrAlias
286
+ * @returns {string} the provider's default voice ('' for windows-sapi = system default).
287
+ */
288
+ function defaultVoice(idOrAlias) {
289
+ const r = getProvider(idOrAlias);
290
+ return r ? r.defaultVoice : '';
291
+ }
292
+
293
+ /**
294
+ * Validate a voice for a provider. STRICT membership at config-write time
295
+ * (typos never reach voice.txt). Returns `{ ok, canonical, reason }` — never a
296
+ * bare boolean — so every rejection carries a message and the accepted spelling
297
+ * is normalized ONCE.
298
+ *
299
+ * @param {string} idOrAlias
300
+ * @param {string} voice
301
+ * @param {{installed?: string[], allowUnlisted?: boolean}} [opts]
302
+ * `allowUnlisted` expresses the AGENTVIBES_ALLOW_UNLISTED_VOICE escape hatch —
303
+ * the catalog never reads env; consumers pass the flag (design §3.3). It relaxes
304
+ * MEMBERSHIP, not shape.
305
+ * @returns {{ok: boolean, canonical: string|null, reason: string}}
306
+ */
307
+ function validateVoice(idOrAlias, voice, { installed, allowUnlisted = false } = {}) {
308
+ const r = getProvider(idOrAlias);
309
+ if (!r) return { ok: false, canonical: null, reason: `Unknown provider: ${idOrAlias}` };
310
+ const raw = typeof voice === 'string' ? voice.trim() : '';
311
+
312
+ switch (r.voiceModel) {
313
+ case 'static': { // kokoro — case-fold, exact membership
314
+ const folded = raw.toLowerCase();
315
+ if (r.voices.includes(folded)) return { ok: true, canonical: folded, reason: '' };
316
+ if (allowUnlisted && folded) return { ok: true, canonical: folded, reason: '' };
317
+ return { ok: false, canonical: null, reason: `"${voice}" is not a known ${r.displayName} voice` };
318
+ }
319
+ case 'name-to-id': { // elevenlabs — case-insensitive name → id, OR raw 20-char id
320
+ const lc = raw.toLowerCase();
321
+ const byName = r.voices.find((v) => v.name.toLowerCase() === lc);
322
+ if (byName) return { ok: true, canonical: byName.id, reason: '' };
323
+ if (ELEVENLABS_ID_SHAPE.test(raw)) return { ok: true, canonical: raw, reason: '' };
324
+ return { ok: false, canonical: null, reason: `"${voice}" is not a known ElevenLabs voice name or id` };
325
+ }
326
+ case 'single': { // soprano — '', 'soprano', 'soprano-default' → canonical 'soprano-default'
327
+ const lc = raw.toLowerCase();
328
+ if (lc === '' || lc === r.id || lc === r.defaultVoice) {
329
+ return { ok: true, canonical: r.defaultVoice, reason: '' };
330
+ }
331
+ return { ok: false, canonical: null, reason: `${r.displayName} has a single voice (${r.defaultVoice})` };
332
+ }
333
+ case 'discovered': // piper / macos / windows-sapi / windows-piper
334
+ default: {
335
+ const isPiperFamily = r.engineId === 'piper';
336
+ if (Array.isArray(installed)) {
337
+ if (installed.includes(raw)) return { ok: true, canonical: raw, reason: '' };
338
+ if (!allowUnlisted) {
339
+ return { ok: false, canonical: null, reason: `"${voice}" is not an installed ${r.displayName} voice` };
340
+ }
341
+ // allowUnlisted: fall through to shape/permissive check
342
+ }
343
+ if (isPiperFamily) {
344
+ if (PIPER_VOICE_SHAPE.test(raw) || raw.includes('::')) return { ok: true, canonical: raw, reason: '' };
345
+ return { ok: false, canonical: null, reason: `"${voice}" is not a valid Piper voice id` };
346
+ }
347
+ // macos / windows-sapi: permissive when no list is available; '' = system default.
348
+ if (raw === '' && r.defaultVoice === '') return { ok: true, canonical: '', reason: '' };
349
+ if (raw) return { ok: true, canonical: raw, reason: '' };
350
+ return { ok: false, canonical: null, reason: `Empty voice for ${r.displayName}` };
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * @param {string} idOrAlias
357
+ * @returns {string} a human display name, or the input unchanged if unknown
358
+ * (mirrors provider-validator.getProviderDisplayName's passthrough).
359
+ */
360
+ function displayName(idOrAlias) {
361
+ const r = getProvider(idOrAlias);
362
+ if (r) return r.displayName;
363
+ return typeof idOrAlias === 'string' ? idOrAlias : '';
364
+ }
365
+
366
+ /**
367
+ * Derived alias→engine table, built to MIRROR the resolver's ENGINE_ALIASES
368
+ * (utterance-resolver.js:52-57). A record whose id differs from its engineId
369
+ * contributes id→engineId; each non-identity alias contributes alias→engineId.
370
+ * Conformance group 6 asserts this deep-equals the resolver's table.
371
+ * @returns {Record<string,string>}
372
+ */
373
+ function engineAliasTable() {
374
+ const table = {};
375
+ for (const r of RECORDS) {
376
+ if (r.id !== r.engineId) table[r.id] = r.engineId;
377
+ for (const a of r.aliases) {
378
+ if (a !== r.engineId) table[a] = r.engineId;
379
+ }
380
+ }
381
+ return table;
382
+ }
383
+
384
+ export {
385
+ getProvider,
386
+ listProviders,
387
+ providersFor,
388
+ isAvailable,
389
+ listVoices,
390
+ defaultVoice,
391
+ validateVoice,
392
+ displayName,
393
+ engineAliasTable,
394
+ kokoroGender,
395
+ ELEVENLABS_VOICES,
396
+ KOKORO_VOICE_IDS,
397
+ };
398
+
399
+ export default {
400
+ getProvider,
401
+ listProviders,
402
+ providersFor,
403
+ isAvailable,
404
+ listVoices,
405
+ defaultVoice,
406
+ validateVoice,
407
+ displayName,
408
+ engineAliasTable,
409
+ kokoroGender,
410
+ ELEVENLABS_VOICES,
411
+ KOKORO_VOICE_IDS,
412
+ };
@@ -1,87 +1,55 @@
1
1
  /**
2
- * Provider voice catalogs — single source of truth for the static voice lists
3
- * of the cloud/neural providers, plus a helper that returns the voice pool for
4
- * whichever provider is currently active.
2
+ * Provider voice catalogs — BACK-COMPAT SHIM.
5
3
  *
6
- * Piper voices are NOT listed here (they are discovered on disk via
7
- * scanInstalledVoices); this module covers the providers whose voices are a
8
- * fixed catalog: ElevenLabs and Kokoro. Single-voice providers (e.g. soprano)
9
- * resolve to a single synthetic entry.
4
+ * The canonical source of provider voice data is now
5
+ * `src/services/provider-catalog.js` (the Provider Catalog, SSOT Layer 2). This
6
+ * module re-exports the static voice lists (`ELEVENLABS_VOICES`,
7
+ * `KOKORO_VOICE_IDS`) and the gender helper from the catalog, and keeps the
8
+ * `voicesForProvider()` convenience used by BMAD gender-aware auto-assignment.
9
+ *
10
+ * Kept as a shim so existing importers (and test/unit/provider-voice-catalog.test.js)
11
+ * continue to work unmodified.
10
12
  *
11
13
  * @module services/provider-voice-catalog
12
14
  */
13
15
 
14
16
  import { isSingleVoiceProvider } from './agent-voice-store.js';
17
+ import {
18
+ ELEVENLABS_VOICES,
19
+ KOKORO_VOICE_IDS,
20
+ kokoroGender,
21
+ } from './provider-catalog.js';
22
+
23
+ export { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, kokoroGender };
15
24
 
16
- /** ElevenLabs library voices (voice_id + display metadata). */
17
- export const ELEVENLABS_VOICES = [
18
- { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', gender: 'Female', lang: 'en-US', desc: 'Mature, reassuring' },
19
- { id: 'CwhRBWXzGAHq8TQ4Fs17', name: 'Roger', gender: 'Male', lang: 'en-US', desc: 'Laid-back, casual' },
20
- { id: 'FGY2WhTYpPnrIDTdsKH5', name: 'Laura', gender: 'Female', lang: 'en-US', desc: 'Enthusiast, quirky' },
21
- { id: 'IKne3meq5aSn9XLyUdCD', name: 'Charlie', gender: 'Male', lang: 'en-AU', desc: 'Deep, confident' },
22
- { id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George', gender: 'Male', lang: 'en-GB', desc: 'Warm storyteller' },
23
- { id: 'N2lVS1w4EtoT3dr4eOWO', name: 'Callum', gender: 'Male', lang: 'en-US', desc: 'Husky trickster' },
24
- { id: 'SAz9YHcvj6GT2YYXdXww', name: 'River', gender: '', lang: 'en-US', desc: 'Relaxed, neutral' },
25
- { id: 'SOYHLrjzK2X1ezoPC6cr', name: 'Harry', gender: 'Male', lang: 'en-US', desc: 'Fierce warrior' },
26
- { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', gender: 'Male', lang: 'en-US', desc: 'Energetic creator' },
27
- { id: 'Xb7hH8MSUJpSbSDYk0k2', name: 'Alice', gender: 'Female', lang: 'en-GB', desc: 'Clear educator' },
28
- { id: 'XrExE9yKIg1WjnnlVkGX', name: 'Matilda', gender: 'Female', lang: 'en-US', desc: 'Knowledgable, pro' },
29
- { id: 'bIHbv24MWmeRgasZH58o', name: 'Will', gender: 'Male', lang: 'en-US', desc: 'Relaxed optimist' },
30
- { id: 'cgSgspJ2msm6clMCkdW9', name: 'Jessica', gender: 'Female', lang: 'en-US', desc: 'Playful, bright' },
31
- { id: 'cjVigY5qzO86Huf0OWal', name: 'Eric', gender: 'Male', lang: 'en-US', desc: 'Smooth, trustworthy' },
32
- { id: 'hpp4J3VqNfWAUOO0d1Us', name: 'Bella', gender: 'Female', lang: 'en-US', desc: 'Professional, warm' },
33
- { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', gender: 'Male', lang: 'en-US', desc: 'Charming, down-to-earth' },
34
- { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', gender: 'Male', lang: 'en-US', desc: 'Deep, comforting' },
35
- { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', gender: 'Male', lang: 'en-GB', desc: 'Steady broadcaster' },
36
- { id: 'pFZP5JQG7iQjIQuC4Bku', name: 'Lily', gender: 'Female', lang: 'en-GB', desc: 'Velvety actress' },
37
- { id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam', gender: 'Male', lang: 'en-US', desc: 'Dominant, firm' },
38
- { id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill', gender: 'Male', lang: 'en-US', desc: 'Wise, mature' },
39
- // ── Added from the ElevenLabs voice library (raw library voice_ids) ───────
40
- { id: 'HBDoL4wkcalemIO0nUAu', name: 'Emily', gender: 'Female', lang: 'en-GB', desc: 'RPG, immersive' },
41
- { id: 'Mtmp3KhFIjYpWYRycDe3', name: 'John', gender: 'Male', lang: 'en-US', desc: 'Raspy surfer' },
42
- { id: '8kgj5469z1URcH4MB2G4', name: 'Sakuya', gender: 'Female', lang: 'en-US', desc: 'Cheerful anime' },
43
- { id: '2ajXGJNYBR0iNHpS4VZb', name: 'Rob', gender: 'Male', lang: 'en-GB', desc: 'Tough, gritty' },
44
- { id: 'mMdTuD2nnFiCH88UdXSb', name: 'Jane', gender: 'Female', lang: 'en-US', desc: 'Cinematic villain' },
25
+ /**
26
+ * Curated built-in voices for the OS-native "discovered" providers (Windows SAPI,
27
+ * macOS `say`). Their real voices live on the target device — for a remote
28
+ * receiver we cannot enumerate them from here — so these standard voices (shipped
29
+ * by default on Windows 10/11 and macOS) give the picker an honest, selectable
30
+ * list. The `id` is the exact name the player passes to SelectVoice / `say -v`;
31
+ * if the receiver lacks one it falls back to the system default (benign).
32
+ *
33
+ * Verified against a real Windows 11 install (System.Speech GetInstalledVoices).
34
+ * The receiver remains authoritative — this is UI convenience, not inventory SSOT.
35
+ */
36
+ const WINDOWS_SAPI_VOICES = [
37
+ { id: 'Microsoft David Desktop', name: 'David (Desktop)', gender: 'Male', lang: 'en-US' },
38
+ { id: 'Microsoft Zira Desktop', name: 'Zira (Desktop)', gender: 'Female', lang: 'en-US' },
39
+ { id: 'Microsoft David', name: 'David', gender: 'Male', lang: 'en-US' },
40
+ { id: 'Microsoft Mark', name: 'Mark', gender: 'Male', lang: 'en-US' },
41
+ { id: 'Microsoft Zira', name: 'Zira', gender: 'Female', lang: 'en-US' },
45
42
  ];
46
43
 
47
- /** Kokoro voice ids. Gender is encoded in the id: the 2nd char is f|m. */
48
- export const KOKORO_VOICE_IDS = [
49
- // American English
50
- 'af_heart','af_alloy','af_aoede','af_bella','af_jessica','af_kore','af_nicole','af_nova','af_river','af_sarah','af_sky',
51
- 'am_adam','am_echo','am_eric','am_fenrir','am_liam','am_michael','am_onyx','am_puck',
52
- // British English
53
- 'bf_alice','bf_emma','bf_isabella','bf_lily',
54
- 'bm_daniel','bm_fable','bm_george','bm_lewis',
55
- // Japanese
56
- 'jf_alpha','jf_gongitsune','jf_nezumi','jf_tebukuro','jm_kumo',
57
- // Mandarin Chinese
58
- 'zf_xiaobei','zf_xiaoni','zf_xiaoxiao','zf_xiaoyi','zm_yunxi','zm_yunxia','zm_yunyang',
59
- // Spanish
60
- 'ef_dora','em_alex','em_santa',
61
- // French
62
- 'ff_siwis',
63
- // Hindi
64
- 'hf_alpha','hm_omega',
65
- // Italian
66
- 'if_sara','im_nicola',
67
- // Brazilian Portuguese
68
- 'pf_dora','pm_alex','pm_santa',
69
- // Korean
70
- 'kf_alpha','km_hyunsu',
44
+ const MACOS_VOICES = [
45
+ { id: 'Samantha', name: 'Samantha', gender: 'Female', lang: 'en-US' },
46
+ { id: 'Alex', name: 'Alex', gender: 'Male', lang: 'en-US' },
47
+ { id: 'Daniel', name: 'Daniel', gender: 'Male', lang: 'en-GB' },
48
+ { id: 'Karen', name: 'Karen', gender: 'Female', lang: 'en-AU' },
49
+ { id: 'Moira', name: 'Moira', gender: 'Female', lang: 'en-IE' },
71
50
  ];
72
51
 
73
- /**
74
- * Infer a Kokoro voice's gender from its id. Kokoro ids follow `<lang><sex>_name`
75
- * where the 2nd character is `f` (female) or `m` (male).
76
- * @param {string} id
77
- * @returns {'Female'|'Male'|''}
78
- */
79
- export function kokoroGender(id) {
80
- const c = typeof id === 'string' && id.length > 1 ? id[1].toLowerCase() : '';
81
- if (c === 'f') return 'Female';
82
- if (c === 'm') return 'Male';
83
- return '';
84
- }
52
+ export { WINDOWS_SAPI_VOICES, MACOS_VOICES };
85
53
 
86
54
  /**
87
55
  * Return the voice pool for the currently-active provider as `{ id, gender }`
@@ -108,6 +76,17 @@ export function voicesForProvider(provider, { scanInstalledVoices, getVoiceMeta
108
76
  return ELEVENLABS_VOICES.map(v => ({ id: v.id, gender: v.gender || '' }));
109
77
  }
110
78
 
79
+ // OS-native discovered providers: their voices live on the target device (the
80
+ // receiver in remote mode), so they can't be scanned here. Return the curated
81
+ // built-in list instead of falling through to the Piper disk scan (which would
82
+ // mislabel them as Piper voices).
83
+ if (p === 'sapi' || p === 'windows-sapi') {
84
+ return WINDOWS_SAPI_VOICES.map(v => ({ id: v.id, gender: v.gender || '' }));
85
+ }
86
+ if (p === 'macos' || p === 'macos-say' || p === 'say') {
87
+ return MACOS_VOICES.map(v => ({ id: v.id, gender: v.gender || '' }));
88
+ }
89
+
111
90
  // Single-voice providers (e.g. soprano): one synthetic entry named after the
112
91
  // provider — every agent ends up sharing it, which is correct for these.
113
92
  if (isSingleVoiceProvider(p)) {
@@ -123,4 +102,4 @@ export function voicesForProvider(provider, { scanInstalledVoices, getVoiceMeta
123
102
  return [];
124
103
  }
125
104
 
126
- export default { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, kokoroGender, voicesForProvider };
105
+ export default { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, WINDOWS_SAPI_VOICES, MACOS_VOICES, kokoroGender, voicesForProvider };
@@ -53,6 +53,22 @@ export function getAvailableEngines() {
53
53
  return TTS_ENGINES.filter(e => !e.platform || e.platform === process.platform);
54
54
  }
55
55
 
56
+ /** Is this engine runnable on the current OS? (Native engines are platform-bound.) */
57
+ export function isEngineSupported(engineId) {
58
+ const e = TTS_ENGINES.find(x => x.id === engineId);
59
+ return !!e && (!e.platform || e.platform === process.platform);
60
+ }
61
+
62
+ /**
63
+ * Every engine, each tagged with `supported` for the current platform. Unlike
64
+ * getAvailableEngines() (which hides off-platform engines), this lists them all
65
+ * so the picker can show e.g. Windows SAPI greyed "(Not supported)" on Linux
66
+ * instead of appearing to be missing.
67
+ */
68
+ export function getAllEngines() {
69
+ return TTS_ENGINES.map(e => ({ ...e, supported: !e.platform || e.platform === process.platform }));
70
+ }
71
+
56
72
  export function checkEngineInstalled(engineId) {
57
73
  const engine = TTS_ENGINES.find(e => e.id === engineId);
58
74
  if (!engine) return false;
@@ -110,6 +126,19 @@ export function checkEngineInstalled(engineId) {
110
126
  return false;
111
127
  }
112
128
 
129
+ /**
130
+ * Map a picker engine id to the provider name the SSH-remote sender + receiver
131
+ * understand. play-tts-ssh-remote.sh validates
132
+ * piper|soprano|macos|windows-sapi|kokoro|elevenlabs, but the picker/config use
133
+ * 'sapi' and 'macos-say'. This is the single translation point.
134
+ * @param {string} engineId
135
+ * @returns {string} receiver/sender provider name
136
+ */
137
+ const RECEIVER_PROVIDER_MAP = { sapi: 'windows-sapi', 'macos-say': 'macos' };
138
+ export function receiverProviderId(engineId) {
139
+ return RECEIVER_PROVIDER_MAP[engineId] || engineId;
140
+ }
141
+
113
142
  export function getEngineStatuses() {
114
143
  return getAvailableEngines().map(e => ({
115
144
  ...e,