agentvibes 4.6.8 → 5.1.0

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 (40) hide show
  1. package/.agentvibes/bmad-voice-map.json +104 -0
  2. package/.agentvibes/config.json +13 -12
  3. package/.agentvibes/copilot-sessions.log +4 -0
  4. package/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
  5. package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
  6. package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
  7. package/.claude/audio/tracks/README.md +51 -52
  8. package/.claude/config/audio-effects-bmad.cfg +50 -0
  9. package/.claude/config/audio-effects.cfg +4 -4
  10. package/.claude/config/background-music-enabled.txt +1 -0
  11. package/.claude/config/personality.txt +1 -0
  12. package/.claude/hooks/play-tts-piper.sh +3 -1
  13. package/.claude/hooks/play-tts.sh +380 -301
  14. package/.claude/hooks/session-start-tts.sh +81 -81
  15. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  16. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  17. package/.claude/hooks-windows/play-tts.ps1 +28 -6
  18. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  19. package/README.md +112 -6
  20. package/RELEASE_NOTES.md +83 -0
  21. package/bin/bmad-speak.js +16 -8
  22. package/mcp-server/server.py +15 -8
  23. package/package.json +1 -1
  24. package/src/console/app.js +899 -897
  25. package/src/console/footer-config.js +50 -50
  26. package/src/console/navigation.js +65 -65
  27. package/src/console/tabs/agents-tab.js +1899 -1886
  28. package/src/console/tabs/music-tab.js +1076 -1039
  29. package/src/console/tabs/placeholder-tab.js +81 -80
  30. package/src/console/tabs/settings-tab.js +941 -3988
  31. package/src/console/tabs/setup-tab.js +2071 -0
  32. package/src/console/tabs/voices-tab.js +1843 -1714
  33. package/src/console/widgets/format-utils.js +92 -89
  34. package/src/console/widgets/track-picker.js +325 -322
  35. package/src/installer.js +6147 -6092
  36. package/src/services/llm-provider-service.js +486 -0
  37. package/src/services/navigation-service.js +123 -123
  38. package/src/services/tts-engine-service.js +69 -0
  39. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  40. package/src/console/tabs/install-tab.js +0 -1081
@@ -1,1714 +1,1843 @@
1
- /**
2
- * AgentVibes TUI Console — Voices Tab
3
- * Epic 8: Stories 8.1-8.4
4
- *
5
- * Implements the Tab Component Contract:
6
- * createVoicesTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
- *
8
- * Features: installed voice list, search/filter, favorites (★), voice info panel, install stub.
9
- */
10
-
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import os from 'node:os';
14
- import { spawn } from 'node:child_process';
15
- import { fileURLToPath } from 'node:url';
16
- import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
17
-
18
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
-
20
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
21
-
22
- let blessed;
23
- if (!IS_TEST) {
24
- const { default: b } = await import('blessed');
25
- blessed = b;
26
- }
27
-
28
- // ---------------------------------------------------------------------------
29
-
30
- const COLORS = {
31
- contentBg: '#0a0e1a',
32
- sectionHdr: '#00897b', // Teal — section headers for Voices tab
33
- labelFg: '#e3f2fd',
34
- valueFg: '#f06292', // Light magenta — brand color
35
- activeFg: 'bright-cyan', // Cyan — active voice
36
- favoriteFg: '#ffff00', // Yellow — favorite star
37
- btnDefault: '#00695c', // Teal — Voices tab buttons
38
- btnFocus: '#2e7d32', // Green — focused/selected
39
- btnFocusFg: '#ffffff',
40
- btnPress: '#ff00ff',
41
- borderFg: '#00897b',
42
- footerBg: '#00695c', // Teal — Voices tab footer
43
- noticeFg: '#90a4ae',
44
- dimFg: '#455a64',
45
- };
46
-
47
- const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select/Install [F] Favorite [/] Search';
48
- /**
49
- * Resolve the Piper voice storage directory using the same precedence as the
50
- * shell-side get_voice_storage_dir() in piper-voice-manager.sh:
51
- * 1. PIPER_VOICES_DIR env var
52
- * 2. Project-local .claude/piper-voices-dir.txt (walk up from cwd)
53
- * 3. Global ~/.claude/piper-voices-dir.txt
54
- * 4. Default ~/.claude/piper-voices
55
- */
56
- function resolvePiperVoicesDir() {
57
- const defaultDir = path.join(os.homedir(), '.claude', 'piper-voices');
58
- if (process.env.PIPER_VOICES_DIR) {
59
- const resolved = path.resolve(process.env.PIPER_VOICES_DIR);
60
- if (resolved.includes('..')) return defaultDir;
61
- return resolved;
62
- }
63
-
64
- // Search up directory tree for .claude/piper-voices-dir.txt
65
- let dir = process.cwd();
66
- while (dir !== path.dirname(dir)) {
67
- const cfg = path.join(dir, '.claude', 'piper-voices-dir.txt');
68
- try {
69
- if (fs.existsSync(cfg)) return fs.readFileSync(cfg, 'utf8').trim();
70
- } catch { /* skip */ }
71
- dir = path.dirname(dir);
72
- }
73
-
74
- // Global config
75
- const globalCfg = path.join(os.homedir(), '.claude', 'piper-voices-dir.txt');
76
- try {
77
- if (fs.existsSync(globalCfg)) return fs.readFileSync(globalCfg, 'utf8').trim();
78
- } catch { /* skip */ }
79
-
80
- // Default fallback
81
- return defaultDir;
82
- }
83
-
84
- export const PIPER_VOICES_DIR = resolvePiperVoicesDir();
85
-
86
- // ---------------------------------------------------------------------------
87
- // Voice catalog — loaded from voice-assignments.json (914 voices)
88
-
89
- let _catalogEntries = []; // { voiceId, displayName, gender, model, type, speakerId }
90
- let _catalogMap = new Map(); // voiceId → catalog entry (rebuilt when catalog loads)
91
- let _catalogLoaded = false;
92
-
93
- /**
94
- * Load the full voice catalog from voice-assignments.json.
95
- * Called once on first tab show. Safe to call multiple times.
96
- */
97
- function loadCatalog() {
98
- if (_catalogLoaded) return;
99
- _catalogLoaded = true;
100
- try {
101
- const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
102
- if (!fs.existsSync(catalogPath)) return;
103
- const data = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
104
-
105
- // LibriTTS multi-speaker entries (904 speakers from en_US-libritts-high model)
106
- const libritts = data.libritts_speakers ?? {};
107
- for (const [id, entry] of Object.entries(libritts)) {
108
- _catalogEntries.push({
109
- voiceId: `en_US-libritts-high${MS_SEP}${entry.voice_name}`,
110
- displayName: entry.voice_name,
111
- gender: (entry.gender ?? '').charAt(0).toUpperCase() + (entry.gender ?? '').slice(1),
112
- model: 'en_US-libritts-high',
113
- type: 'libritts',
114
- speakerId: parseInt(id, 10),
115
- });
116
- }
117
-
118
- // Curated standalone voices (10 individual models)
119
- const curated = data.curated_voices ?? {};
120
- for (const [, entry] of Object.entries(curated)) {
121
- _catalogEntries.push({
122
- voiceId: entry.model_file,
123
- displayName: entry.voice_name,
124
- gender: (entry.gender ?? '').charAt(0).toUpperCase() + (entry.gender ?? '').slice(1),
125
- model: entry.model_file,
126
- type: 'curated',
127
- speakerId: null,
128
- });
129
- }
130
- } catch { /* non-fatal — catalog just won't show */ }
131
- // Build lookup map for O(1) access by voiceId
132
- _catalogMap = new Map();
133
- for (const c of _catalogEntries) _catalogMap.set(c.voiceId, c);
134
- }
135
-
136
- /**
137
- * Patch the speaker_id_map in a LibriTTS .onnx.json to use friendly names
138
- * instead of raw corpus IDs (p3922 → Anna, p8699 → Bella, etc.).
139
- * Reads the catalog's index→friendly-name mapping and rebuilds the map.
140
- * Safe to call multiple times skips if already patched.
141
- */
142
- function patchLibriTTSSpeakerNames() {
143
- try {
144
- const jsonPath = path.join(PIPER_VOICES_DIR, 'en_US-libritts-high.onnx.json');
145
- if (!fs.existsSync(jsonPath)) return;
146
- const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
147
- if (!data.speaker_id_map || data.num_speakers <= 1) return;
148
-
149
- const names = Object.keys(data.speaker_id_map);
150
- // Already patched if first name doesn't start with 'p' followed by digits
151
- if (names.length > 0 && !/^p\d+$/.test(names[0])) return;
152
-
153
- // Build index p-name reverse map
154
- const indexToP = {};
155
- for (const [pname, idx] of Object.entries(data.speaker_id_map)) {
156
- indexToP[idx] = pname;
157
- }
158
-
159
- // Load friendly names from catalog
160
- const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
161
- if (!fs.existsSync(catalogPath)) return;
162
- const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
163
- const speakers = catalog.libritts_speakers ?? {};
164
-
165
- // Rebuild speaker_id_map with friendly names
166
- const newMap = {};
167
- for (const [idx, pname] of Object.entries(indexToP)) {
168
- const friendly = speakers[idx]?.voice_name;
169
- if (friendly) {
170
- newMap[friendly] = parseInt(idx, 10);
171
- } else {
172
- newMap[pname] = parseInt(idx, 10);
173
- }
174
- }
175
-
176
- data.speaker_id_map = newMap;
177
- // Verify file ownership before writing (security: CLAUDE.md)
178
- const stat = fs.statSync(jsonPath);
179
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
180
- fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2), 'utf8');
181
- } catch { /* non-fatal */ }
182
- }
183
-
184
- // Column widths for the multi-column voice list
185
- export const COL_NAME_W = 26;
186
- export const COL_GENDER_W = 10;
187
-
188
- // ---------------------------------------------------------------------------
189
- // Pure helpers — exported for testability
190
-
191
- // Well-known piper dataset gender
192
- const GENDER_MAP = {
193
- // Single-speaker datasets
194
- amy: 'Female', kristin: 'Female', jenny: 'Female', cori: 'Female',
195
- aria: 'Female', glados: 'Female', litvyak: 'Female', hfc_female: 'Female',
196
- ljspeech: 'Female',
197
- alan: 'Male', joe: 'Male', john: 'Male', ryan: 'Male', lessac: 'Male',
198
- kusal: 'Male', hfc_male: 'Male', danny: 'Male', arctic: 'Male',
199
- l2arctic: 'Male',
200
- // 16Speakers multi-speaker model (names from speaker_id_map)
201
- cori_samuel: 'Female', kara_shallenberg: 'Female', kristin_hughes: 'Female',
202
- maria_kasper: 'Female', rose_ibex: 'Female', owlivia: 'Female',
203
- jennifer_dorr: 'Female', emily_cripps: 'Female',
204
- mike_pelton: 'Male', mark_nelson: 'Male', michael_scherer: 'Male',
205
- james_k_white: 'Male', progressingamerica: 'Male', steve_c: 'Male',
206
- paul_hampton: 'Male', martin_clifton: 'Male',
207
- // LibriTTS / common first names used as multi-speaker speaker IDs
208
- anna: 'Female', bella: 'Female', chloe: 'Female', donna: 'Female',
209
- ella: 'Female', faith: 'Female', gina: 'Female', holly: 'Female',
210
- ivy: 'Female', jane: 'Female', kelly: 'Female', laura: 'Female',
211
- mary: 'Female', nina: 'Female', olivia: 'Female', penny: 'Female',
212
- rachel: 'Female', sarah: 'Female', tara: 'Female', uma: 'Female',
213
- vera: 'Female', wendy: 'Female', yara: 'Female', zoe: 'Female',
214
- betty: 'Female', cindy: 'Female', debra: 'Female', erica: 'Female',
215
- faye: 'Female', gloria: 'Female', quinn: 'Female',
216
- alex: 'Male', ben: 'Male', carl: 'Male', dan: 'Male', evan: 'Male',
217
- frank: 'Male', greg: 'Male', hank: 'Male', ivan: 'Male', jake: 'Male',
218
- kevin: 'Male', leo: 'Male', mike: 'Male', nathan: 'Male', oscar: 'Male',
219
- paul: 'Male', rick: 'Male', sam: 'Male', tom: 'Male', victor: 'Male',
220
- will: 'Male', xavier: 'Male', zach: 'Male', adam: 'Male', brad: 'Male',
221
- colin: 'Male', derek: 'Male', ethan: 'Male', felix: 'Male',
222
- };
223
-
224
- // Well-known piper dataset → nice display name
225
- const DISPLAY_NAMES = {
226
- ljspeech: 'LJ Speech',
227
- libritts: 'LibriTTS',
228
- libritts_r: 'LibriTTS',
229
- l2arctic: 'L2-Arctic',
230
- hfc_male: 'HFC Male',
231
- hfc_female: 'HFC Female',
232
- };
233
-
234
- /**
235
- * Infer voice gender from voice ID and/or dataset name.
236
- * Returns 'Female', 'Male', or '—'.
237
- *
238
- * @param {string} voiceId e.g. 'en_GB-southern_english_female-low'
239
- * @param {string} [dataset] e.g. 'southern_english_female'
240
- * @returns {string}
241
- */
242
- export function inferGender(voiceId, dataset) {
243
- const id = voiceId.toLowerCase();
244
- const ds = (dataset ?? '').toLowerCase();
245
- // Explicit in name
246
- if (id.includes('_female') || ds.includes('female')) return 'Female';
247
- if (id.includes('_male') || ds.includes('male')) return 'Male';
248
- // Dataset lookup first
249
- if (ds && GENDER_MAP[ds]) return GENDER_MAP[ds];
250
- // For multi-speaker speaker names like "Anna-9", strip trailing "-N" suffix
251
- // then look up the base name (e.g. "anna")
252
- const baseName = id.replace(/-\d+$/, '');
253
- if (GENDER_MAP[baseName]) return GENDER_MAP[baseName];
254
- // Fall back to middle segment of voice ID (e.g. "ryan" from "en_US-ryan-high")
255
- const segment = id.split('-')[1] ?? '';
256
- return GENDER_MAP[segment] ?? GENDER_MAP[id] ?? '';
257
- }
258
-
259
- /**
260
- * Format a piper dataset name into a human-readable voice display name.
261
- *
262
- * @param {string} voiceId
263
- * @param {string} [dataset] from the .onnx.json file
264
- * @returns {string}
265
- */
266
- export function formatVoiceName(voiceId, dataset) {
267
- // Skip generic dataset values that aren't useful as display names
268
- const GENERIC_DATASETS = new Set(['training', 'dataset', 'default', 'unknown', '']);
269
- const usableDataset = dataset && !GENERIC_DATASETS.has(dataset.toLowerCase()) ? dataset : null;
270
- const raw = usableDataset ?? voiceId.split('-')[1] ?? voiceId;
271
- if (DISPLAY_NAMES[raw]) return DISPLAY_NAMES[raw];
272
- return raw
273
- .replace(/_/g, ' ')
274
- .replace(/\b\w/g, c => c.toUpperCase());
275
- }
276
-
277
- export const SAMPLE_PHRASES = [
278
- "Hello! I'm ready to assist you with your tasks today.",
279
- "Code review complete. I found several areas that could be improved.",
280
- "Welcome to AgentVibes. I'll help you get things done efficiently.",
281
- "Task finished successfully. What shall we work on next?",
282
- "I've analyzed the code and have some suggestions for you.",
283
- ];
284
-
285
- // ---------------------------------------------------------------------------
286
- // Exported pure helpers (testable without blessed)
287
-
288
- /**
289
- * Parse a piper voice ID into its components.
290
- * e.g. 'en_US-amy-medium' → { lang: 'en_US', name: 'amy', quality: 'medium' }
291
- *
292
- * @param {string} voiceId
293
- * @returns {{ lang: string, name: string, quality: string }}
294
- */
295
- export function parseVoiceId(voiceId) {
296
- if (!voiceId) return { lang: 'unknown', name: 'unknown', quality: 'unknown' };
297
- // Expected format: <lang>-<name>-<quality> e.g. en_US-amy-medium
298
- const match = voiceId.match(/^([a-z]{2}_[A-Z]{2})-(.+)-(low|medium|high)$/);
299
- if (!match) return { lang: 'unknown', name: voiceId, quality: 'unknown' };
300
- return { lang: match[1], name: match[2], quality: match[3] };
301
- }
302
-
303
- /**
304
- * Format a one-line voice info summary.
305
- *
306
- * @param {string} voiceId
307
- * @returns {string}
308
- */
309
- export function formatVoiceInfo(voiceId) {
310
- const { lang, name, quality } = parseVoiceId(voiceId);
311
- if (lang === 'unknown') return `Voice: ${voiceId} | Provider: Piper`;
312
- return `Voice: ${name} | Language: ${lang} | Quality: ${quality} | Provider: Piper`;
313
- }
314
-
315
- // ---------------------------------------------------------------------------
316
- // Test stub
317
-
318
- function createTestStub() {
319
- return {
320
- box: {},
321
- show: () => {},
322
- hide: () => {},
323
- onFocus: () => {},
324
- onBlur: () => {},
325
- getFooterText: () => FOOTER_TEXT,
326
- getFooterColor: () => COLORS.footerBg,
327
- };
328
- }
329
-
330
- // ---------------------------------------------------------------------------
331
-
332
- // Multi-speaker voice separator: "modelName::speakerName"
333
- export const MS_SEP = '::';
334
-
335
- /**
336
- * Parse a voice entry that may be a multi-speaker ID.
337
- * @param {string} voiceId - e.g. "16Speakers::Cori_Samuel" or "en_US-lessac-medium"
338
- * @returns {{ model: string, speakerId: number|null, speakerName: string|null, isMultiSpeaker: boolean }}
339
- */
340
- export function parseMultiSpeaker(voiceId) {
341
- if (!voiceId) return { model: voiceId ?? '', speakerId: null, speakerName: null, isMultiSpeaker: false };
342
- if (voiceId.includes(MS_SEP)) {
343
- const [model, speakerName] = voiceId.split(MS_SEP, 2);
344
- const jsonPath = path.join(PIPER_VOICES_DIR, model + '.onnx.json');
345
- try {
346
- const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
347
- let speakerId = data.speaker_id_map?.[speakerName] ?? null;
348
- // Fallback: if the .onnx.json still has raw p-names (not yet patched),
349
- // look up the numeric speaker ID from voice-assignments.json catalog.
350
- if (speakerId == null && model === 'en_US-libritts-high') {
351
- try {
352
- const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
353
- const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
354
- const speakers = catalog.libritts_speakers ?? {};
355
- const entry = Object.entries(speakers).find(([, e]) => e.voice_name === speakerName);
356
- if (entry) speakerId = parseInt(entry[0], 10);
357
- } catch { /* non-fatal */ }
358
- }
359
- return { model, speakerId, speakerName, isMultiSpeaker: true };
360
- } catch {
361
- return { model, speakerId: null, speakerName, isMultiSpeaker: true };
362
- }
363
- }
364
- return { model: voiceId, speakerId: null, speakerName: null, isMultiSpeaker: false };
365
- }
366
-
367
- /**
368
- * Scan PIPER_VOICES_DIR for installed voice IDs.
369
- * Expands multi-speaker models into individual speaker entries.
370
- *
371
- * @returns {string[]}
372
- */
373
- export function scanInstalledVoices() {
374
- try {
375
- const files = fs.readdirSync(PIPER_VOICES_DIR);
376
- const onnxFiles = files
377
- .filter(f => f.endsWith('.onnx') && !f.endsWith('.onnx.json'));
378
-
379
- const result = [];
380
- for (const f of onnxFiles) {
381
- const voiceId = f.replace(/\.onnx$/, '');
382
- const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
383
- try {
384
- const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
385
- if (data.num_speakers > 1 && data.speaker_id_map) {
386
- // Expand multi-speaker model into individual entries
387
- for (const speakerName of Object.keys(data.speaker_id_map)) {
388
- result.push(`${voiceId}${MS_SEP}${speakerName}`);
389
- }
390
- continue;
391
- }
392
- } catch { /* fall through to add as single voice */ }
393
- result.push(voiceId);
394
- }
395
- return result.sort();
396
- } catch {
397
- return [];
398
- }
399
- }
400
-
401
- /**
402
- * Get favorites array from config.
403
- * @param {object} configService
404
- * @returns {string[]}
405
- */
406
- export function getFavorites(configService) {
407
- const favs = configService.getConfig().favorites;
408
- return Array.isArray(favs) ? favs : [];
409
- }
410
-
411
- /**
412
- * Toggle a voice in the favorites list.
413
- * @param {object} configService
414
- * @param {string} voiceId
415
- */
416
- export function toggleFavorite(configService, voiceId) {
417
- const favs = getFavorites(configService);
418
- const idx = favs.indexOf(voiceId);
419
- if (idx >= 0) {
420
- favs.splice(idx, 1);
421
- } else {
422
- favs.push(voiceId);
423
- }
424
- configService.set('favorites', favs);
425
- }
426
-
427
- // ---------------------------------------------------------------------------
428
- // Voice metadata cache (lives for the process lifetime)
429
-
430
- const _metaCache = new Map();
431
-
432
- /**
433
- * Load metadata from the .onnx.json file for a voice.
434
- * Caches results so the file is only read once per voice.
435
- *
436
- * @param {string} voiceId
437
- * @returns {{ displayName: string, gender: string, provider: string }}
438
- */
439
- export function getVoiceMeta(voiceId) {
440
- if (_metaCache.has(voiceId)) return _metaCache.get(voiceId);
441
-
442
- const ms = parseMultiSpeaker(voiceId);
443
- if (ms.isMultiSpeaker) {
444
- if (!ms.speakerName) {
445
- const result = { displayName: voiceId, gender: '—', provider: `Piper (${ms.model})` };
446
- _metaCache.set(voiceId, result);
447
- return result;
448
- }
449
- const displayName = ms.speakerName.replace(/_/g, ' ');
450
- const result = {
451
- displayName,
452
- gender: inferGender(ms.speakerName, null),
453
- provider: `Piper (${ms.model})`,
454
- };
455
- _metaCache.set(voiceId, result);
456
- return result;
457
- }
458
-
459
- let dataset = null;
460
- try {
461
- const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
462
- const raw = fs.readFileSync(jsonPath, 'utf8');
463
- const data = JSON.parse(raw);
464
- dataset = data.dataset ?? null;
465
- } catch {}
466
- const result = {
467
- displayName: formatVoiceName(voiceId, dataset),
468
- gender: inferGender(voiceId, dataset),
469
- provider: 'Piper',
470
- };
471
- _metaCache.set(voiceId, result);
472
- return result;
473
- }
474
-
475
- // ---------------------------------------------------------------------------
476
-
477
- /**
478
- * Create the Voices tab component.
479
- *
480
- * @param {object} screen - Blessed screen instance (or test stub)
481
- * @param {object} services
482
- * @param {import('../../services/config-service.js').ConfigService} services.configService
483
- * @param {import('../../services/provider-service.js').ProviderService} services.providerService
484
- * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
485
- */
486
- export function createVoicesTab(screen, services) {
487
- if (IS_TEST) return createTestStub();
488
-
489
- const { configService, providerService, focusMainTabBar, updateHeaderStatus, languageService } = services;
490
- const _tl = (key) => languageService ? languageService.t(key) : key;
491
-
492
- // -------------------------------------------------------------------------
493
- // Container
494
-
495
- const box = blessed.box({
496
- parent: screen,
497
- top: 5,
498
- left: 0,
499
- width: '100%',
500
- bottom: 2,
501
- hidden: true,
502
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
503
- border: { type: 'line' },
504
- borderStyle: { fg: COLORS.borderFg },
505
- });
506
-
507
- // -------------------------------------------------------------------------
508
- // Section header
509
-
510
- const voicesSectionHdr = blessed.text({
511
- parent: box,
512
- top: 1,
513
- left: 2,
514
- content: `{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`,
515
- tags: true,
516
- style: { bg: COLORS.contentBg },
517
- });
518
-
519
- // Currently selected voice indicator (updated by refreshDisplay)
520
- const activeVoiceText = blessed.text({
521
- parent: box,
522
- top: 1,
523
- right: 4,
524
- shrink: true,
525
- tags: true,
526
- content: '',
527
- style: { bg: COLORS.contentBg },
528
- });
529
-
530
- // -------------------------------------------------------------------------
531
- // Search input
532
-
533
- const searchLabelText = blessed.text({
534
- parent: box,
535
- top: 3,
536
- left: 4,
537
- content: _tl('searchLabel'),
538
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
539
- });
540
-
541
- const searchBox = blessed.textbox({
542
- parent: box,
543
- top: 3,
544
- left: 13,
545
- width: 40,
546
- height: 1,
547
- inputOnFocus: true,
548
- keys: true,
549
- style: {
550
- fg: COLORS.valueFg,
551
- bg: '#1a3a5c',
552
- focus: { bg: '#283593' },
553
- },
554
- });
555
-
556
- // -------------------------------------------------------------------------
557
- // Column header row (sits between search and voice list border)
558
-
559
- const colHeaderText = blessed.text({
560
- parent: box,
561
- top: 4,
562
- left: 6,
563
- content: `{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`,
564
- tags: true,
565
- style: { bg: COLORS.contentBg },
566
- });
567
-
568
- // -------------------------------------------------------------------------
569
- // Voice list
570
-
571
- const voiceList = blessed.list({
572
- parent: box,
573
- top: 5,
574
- left: 2,
575
- width: '96%',
576
- height: '50%',
577
- keys: true,
578
- vi: true,
579
- mouse: true,
580
- tags: true,
581
- border: { type: 'line' },
582
- scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
583
- style: {
584
- fg: COLORS.labelFg,
585
- bg: COLORS.contentBg,
586
- border: { fg: COLORS.borderFg },
587
- selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
588
- item: { fg: COLORS.labelFg },
589
- },
590
- });
591
-
592
- // -------------------------------------------------------------------------
593
- // Info panel
594
-
595
- const voiceInfoHdr = blessed.text({
596
- parent: box,
597
- top: '60%',
598
- left: 2,
599
- content: `{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`,
600
- tags: true,
601
- style: { bg: COLORS.contentBg },
602
- });
603
-
604
- const infoLine = blessed.text({
605
- parent: box,
606
- top: '65%',
607
- left: 2,
608
- tags: true,
609
- content: '',
610
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
611
- });
612
-
613
- const previewLine = blessed.text({
614
- parent: box,
615
- top: '70%',
616
- left: 2,
617
- tags: true,
618
- content: '',
619
- style: { fg: COLORS.activeFg, bg: COLORS.contentBg },
620
- });
621
-
622
- // -------------------------------------------------------------------------
623
- // Hint text shown in previewLine when the list has focus and nothing is playing
624
- const HINT_TEXT = `{${COLORS.dimFg}-fg}[Space] preview [Enter] select as default voice{/${COLORS.dimFg}-fg}`;
625
- let _listFocused = false;
626
-
627
- // Inline selection hint appended to the currently highlighted voice row.
628
- // _hintBase stores the item's clean content (no hint, no █) — no sentinel needed.
629
- // Use getter functions so hints re-translate when language changes.
630
- const _rowHintInstalled = () => ` {bright-black-fg}${_tl('voicesRowHintInstalled')}{/bright-black-fg}`;
631
- const _rowHintUninstalled = () => ` {bright-yellow-fg}${_tl('voicesRowHintUninstalled')}{/bright-yellow-fg}`;
632
- let _hintIdx = -1;
633
- let _hintBase = ''; // content of items[_hintIdx] before hint was appended
634
- let _refreshing = false;
635
-
636
- function _getRowHint(idx) {
637
- const voices = _getFilteredVoices();
638
- const voiceId = voices[idx];
639
- if (!voiceId) return _rowHintInstalled();
640
- return _isInstalled(voiceId) ? _rowHintInstalled() : _rowHintUninstalled();
641
- }
642
-
643
- // Translate gender value at display time
644
- function _tGender(g) {
645
- if (g === 'Female') return _tl('genderFemale');
646
- if (g === 'Male') return _tl('genderMale');
647
- return g;
648
- }
649
-
650
- // Known limitation: blink (' ') and hint text can briefly interleave when
651
- // _vlTick fires between stripping and re-appending the hint. Accepted as cosmetic.
652
- function _updateHint(idx) {
653
- const items = voiceList.items;
654
- if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
655
- const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
656
- items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
657
- }
658
- if (idx >= 0 && items[idx]) {
659
- let c = items[idx].content ?? '';
660
- const hasBlink = c.endsWith(' █');
661
- if (hasBlink) c = c.slice(0, -2);
662
- _hintBase = c;
663
- items[idx].setContent(c + _getRowHint(idx) + (hasBlink ? ' █' : ''));
664
- } else {
665
- _hintBase = '';
666
- }
667
- _hintIdx = idx;
668
- }
669
-
670
- // -------------------------------------------------------------------------
671
- // Playback state
672
-
673
- let _playingProcess = null;
674
- let _playingVoiceId = null;
675
- let _downloadProcess = null;
676
-
677
- // Kill the entire process group so child audio players (piper, aplay, play) all die
678
- function _killPlayingProcess() {
679
- if (_playingProcess) {
680
- try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
681
- _playingProcess = null;
682
- }
683
- }
684
-
685
- const _spawnEnv = buildAudioEnv();
686
-
687
- /**
688
- * Preview a voice by synthesizing a sample phrase with piper, then playing the wav.
689
- * Second call with the same voice stops playback (toggle).
690
- */
691
- function _previewVoice(voiceId) {
692
- // Toggle: second press stops
693
- if (_playingVoiceId === voiceId) {
694
- _killPlayingProcess();
695
- _playingVoiceId = null;
696
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
697
- screen.render();
698
- return;
699
- }
700
-
701
- // Kill any current preview first
702
- _killPlayingProcess();
703
- _playingVoiceId = null;
704
-
705
- // Resolve model path (may be multi-speaker)
706
- const ms = parseMultiSpeaker(voiceId);
707
- const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
708
- const safeBase = path.resolve(PIPER_VOICES_DIR);
709
- if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
710
- return;
711
- }
712
-
713
- const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${Date.now()}.wav`);
714
- const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
715
-
716
- // Synthesize: spawn piper; on Windows use the exe path directly
717
- const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
718
- let piperBin = 'piper';
719
- if (isWindows) {
720
- const localAppData = process.env.LOCALAPPDATA ||
721
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
722
- if (localAppData) {
723
- const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
724
- if (fs.existsSync(exePath)) piperBin = exePath;
725
- }
726
- }
727
- const piperArgs = ['--model', voicePath, '--output_file', tempWav];
728
- if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
729
- // On Windows, avoid detached:true which opens a visible console window
730
- const piper = spawn(piperBin, piperArgs, {
731
- stdio: ['pipe', 'ignore', 'ignore'],
732
- detached: !isWindows,
733
- windowsHide: true,
734
- env: _spawnEnv,
735
- });
736
- piper.stdin.write(phrase + '\n');
737
- piper.stdin.end();
738
-
739
- _playingProcess = piper;
740
- _playingVoiceId = voiceId;
741
-
742
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Synthesizing: ${voiceId}…{/${COLORS.activeFg}-fg}`);
743
- screen.render();
744
-
745
- piper.on('exit', (code) => {
746
- if (_playingVoiceId !== voiceId) {
747
- // User stopped before synthesis finished
748
- try { fs.unlinkSync(tempWav); } catch {}
749
- return;
750
- }
751
-
752
- if (code !== 0) {
753
- _playingVoiceId = null;
754
- _playingProcess = null;
755
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Preview failed (piper error — is piper installed?){/${COLORS.activeFg}-fg}`);
756
- screen.render();
757
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
758
- return;
759
- }
760
-
761
- // Play the synthesized wav in its own process group so we can kill it
762
- const _wavP = detectWavPlayer(_spawnEnv);
763
- if (!_wavP) {
764
- _playingVoiceId = null;
765
- _playingProcess = null;
766
- previewLine.setContent(`{red-fg}No audio player found. Install ffmpeg.{/red-fg}`);
767
- screen.render();
768
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
769
- try { fs.unlinkSync(tempWav); } catch {}
770
- return;
771
- }
772
- const playProc = spawn(_wavP.bin, _wavP.args(tempWav), {
773
- stdio: 'ignore',
774
- detached: !isWindows,
775
- windowsHide: true,
776
- env: _spawnEnv,
777
- });
778
- // Race note: _playingVoiceId could change between piper exit and here
779
- // if the user stops playback. Re-check before assigning to avoid orphan.
780
- if (_playingVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
781
- _playingProcess = playProc;
782
-
783
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Playing: ${voiceId} (Enter/Space to stop){/${COLORS.activeFg}-fg}`);
784
- screen.render();
785
-
786
- playProc.on('exit', () => {
787
- if (_playingVoiceId === voiceId) {
788
- _playingVoiceId = null;
789
- _playingProcess = null;
790
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
791
- refreshDisplay(); // clears (playing) label
792
- }
793
- try { fs.unlinkSync(tempWav); } catch {}
794
- });
795
-
796
- playProc.on('error', () => {
797
- _playingVoiceId = null;
798
- _playingProcess = null;
799
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
800
- try { fs.unlinkSync(tempWav); } catch {}
801
- });
802
- });
803
-
804
- piper.on('error', () => {
805
- _playingVoiceId = null;
806
- _playingProcess = null;
807
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Cannot find piper — install with: pipx install piper-tts{/${COLORS.activeFg}-fg}`);
808
- screen.render();
809
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
810
- try { fs.unlinkSync(tempWav); } catch {}
811
- });
812
- }
813
-
814
- // -------------------------------------------------------------------------
815
- // Voice activation (handles multi-speaker model/speaker-id files)
816
-
817
- function _activateVoice(voiceId) {
818
- const ms = parseMultiSpeaker(voiceId);
819
- const claudeDir = path.resolve(process.cwd(), '.claude');
820
- try { fs.mkdirSync(claudeDir, { recursive: true }); } catch {}
821
- // Always write tts-voice.txt so shell scripts pick up the voice on reload
822
- try {
823
- fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), voiceId, 'utf8');
824
- } catch { /* non-fatal */ }
825
- if (ms.isMultiSpeaker) {
826
- // Store full MS ID (e.g., "16Speakers::Kristin_Hughes") so list matching works
827
- providerService.setActiveVoice(voiceId);
828
- try {
829
- fs.writeFileSync(path.join(claudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
830
- fs.writeFileSync(path.join(claudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
831
- } catch { /* non-fatal */ }
832
- } else {
833
- providerService.setActiveVoice(voiceId);
834
- // Clear multi-speaker files if switching to a single-speaker voice
835
- try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
836
- try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
837
- }
838
- }
839
-
840
- // -------------------------------------------------------------------------
841
- // Buttons
842
-
843
- function _createBtn(label, onClick) {
844
- const btn = blessed.button({
845
- parent: box,
846
- content: label,
847
- mouse: true,
848
- keys: true,
849
- shrink: true,
850
- padding: { left: 1, right: 1 },
851
- style: {
852
- bg: COLORS.btnDefault,
853
- fg: 'white',
854
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
855
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
856
- },
857
- });
858
- btn.on('focus', () => {
859
- btn.style.bg = COLORS.btnFocus;
860
- btn.style.fg = COLORS.btnFocusFg;
861
- const raw = btn.content.replace(/[►◄]/g, '').trim();
862
- btn.setContent(`►${raw}◄`);
863
- screen.render();
864
- });
865
- btn.on('blur', () => {
866
- btn.style.bg = COLORS.btnDefault;
867
- btn.style.fg = 'white';
868
- const raw = btn.content.replace(/[►◄]/g, '').trim();
869
- btn.setContent(raw);
870
- screen.render();
871
- });
872
- btn.key(['enter', 'space'], () => {
873
- btn.style.bg = COLORS.btnPress;
874
- screen.render();
875
- setTimeout(() => {
876
- btn.style.bg = COLORS.btnDefault;
877
- screen.render();
878
- onClick();
879
- }, 150);
880
- });
881
- btn.on('click', () => btn.press());
882
- btn.on('mouseover', () => btn.focus());
883
- return btn;
884
- }
885
-
886
- const switchBtn = _createBtn(_tl('voicesSwitchBtn'), () => {
887
- const voices = _getFilteredVoices();
888
- const selected = voices[voiceList.selected];
889
- if (selected) {
890
- _activateVoice(selected);
891
- refreshDisplay();
892
- }
893
- });
894
- switchBtn.bottom = 4;
895
- switchBtn.left = 4;
896
-
897
- const favoriteBtn = _createBtn(_tl('voicesFavoriteBtn'), () => {
898
- const voices = _getFilteredVoices();
899
- const selected = voices[voiceList.selected];
900
- if (selected) {
901
- toggleFavorite(configService, selected);
902
- refreshDisplay();
903
- }
904
- });
905
- favoriteBtn.bottom = 4;
906
- favoriteBtn.left = 22;
907
-
908
- const installBtn = _createBtn(_tl('voicesDownloadBtn'), () => {
909
- const voices = _getFilteredVoices();
910
- const selected = voices[voiceList.selected];
911
- if (!selected) return;
912
- if (_isInstalled(selected)) {
913
- const notice = blessed.text({
914
- parent: box,
915
- top: 'center',
916
- left: 'center',
917
- content: 'Voice already installed. Scroll down to find uninstalled voices (greyed out).',
918
- tags: true,
919
- style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
920
- });
921
- screen.render();
922
- setTimeout(() => { notice.destroy(); screen.render(); }, 3000);
923
- } else {
924
- _openDownloadModal(selected);
925
- }
926
- });
927
- installBtn.bottom = 4;
928
- installBtn.left = 38;
929
-
930
- // -------------------------------------------------------------------------
931
- // "Voice Changed" notice — auto-dismisses after 2 s
932
-
933
- function _showVoiceChangedNotice(displayName) {
934
- const notice = blessed.box({
935
- parent: screen,
936
- top: 'center',
937
- left: 'center',
938
- width: 44,
939
- height: 5,
940
- border: { type: 'line' },
941
- tags: true,
942
- label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
943
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
944
- content: `\n {${COLORS.activeFg}-fg} Voice changed: ${displayName}{/${COLORS.activeFg}-fg}`,
945
- });
946
- notice.setFront();
947
- screen.render();
948
- setTimeout(() => { notice.destroy(); screen.render(); }, 2000);
949
- }
950
-
951
- // -------------------------------------------------------------------------
952
- // Select-voice confirmation modal
953
-
954
- function _activateVoiceGlobal(voiceId) {
955
- // Save voice globally (all projects)
956
- const ms = parseMultiSpeaker(voiceId);
957
- const globalClaudeDir = path.resolve(os.homedir(), '.claude');
958
- // Verify ownership before writing to global config dir
959
- try {
960
- const stat = fs.statSync(globalClaudeDir);
961
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
962
- } catch {}
963
- if (ms.isMultiSpeaker) {
964
- configService.setGlobal('voice', voiceId);
965
- try {
966
- fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
967
- fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
968
- } catch { /* non-fatal */ }
969
- } else {
970
- configService.setGlobal('voice', voiceId);
971
- try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
972
- try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
973
- }
974
- // Also write global tts-voice.txt for shell scripts
975
- try { fs.writeFileSync(path.join(globalClaudeDir, 'tts-voice.txt'), ms.isMultiSpeaker ? voiceId : voiceId, 'utf8'); } catch { /* ok */ }
976
- }
977
-
978
- function _openSelectVoiceModal(voiceId) {
979
- const { displayName } = getVoiceMeta(voiceId);
980
-
981
- const modal = blessed.box({
982
- parent: screen,
983
- top: 'center',
984
- left: 'center',
985
- width: 72,
986
- height: 8,
987
- border: { type: 'line' },
988
- tags: true,
989
- label: ` {${COLORS.activeFg}-fg}Set Default Voice{/${COLORS.activeFg}-fg} `,
990
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
991
- });
992
-
993
- blessed.text({
994
- parent: modal,
995
- top: 1,
996
- left: 2,
997
- right: 2,
998
- content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your default voice?`,
999
- tags: true,
1000
- style: { bg: COLORS.contentBg },
1001
- });
1002
-
1003
- // Status line shows playback state while modal is open
1004
- const modalStatus = blessed.text({
1005
- parent: modal,
1006
- top: 3,
1007
- left: 2,
1008
- right: 2,
1009
- tags: true,
1010
- content: `{${COLORS.dimFg}-fg}Press Preview to audition this voice{/${COLORS.dimFg}-fg}`,
1011
- style: { bg: COLORS.contentBg },
1012
- });
1013
-
1014
- function _close() {
1015
- _killPlayingProcess();
1016
- _playingVoiceId = null;
1017
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
1018
- modal.destroy();
1019
- voiceList.focus();
1020
- screen.render();
1021
- }
1022
-
1023
- // Note: blessed's destroy() does not remove key listeners from child buttons,
1024
- // so modal button handlers may leak. This is a known blessed limitation.
1025
- function _makeBtn(label, bg, left, top, onClick) {
1026
- const btn = blessed.button({
1027
- parent: modal,
1028
- content: label,
1029
- top,
1030
- left,
1031
- mouse: true,
1032
- keys: true,
1033
- shrink: true,
1034
- padding: { left: 1, right: 1 },
1035
- style: {
1036
- bg,
1037
- fg: 'white',
1038
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1039
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1040
- },
1041
- });
1042
- btn.key(['enter', 'space'], () => { _close(); onClick(); });
1043
- btn.on('click', () => btn.press());
1044
- return btn;
1045
- }
1046
-
1047
- const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
1048
- _activateVoice(voiceId);
1049
- refreshDisplay();
1050
- _showVoiceChangedNotice(displayName);
1051
- });
1052
- const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
1053
- _activateVoice(voiceId);
1054
- _activateVoiceGlobal(voiceId);
1055
- refreshDisplay();
1056
- _showVoiceChangedNotice(displayName);
1057
- });
1058
- const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
1059
-
1060
- // Preview button — does NOT close the modal; plays/stops the voice inline
1061
- const previewBtn = blessed.button({
1062
- parent: modal,
1063
- content: 'Preview',
1064
- top: 5,
1065
- left: 58,
1066
- mouse: true,
1067
- keys: true,
1068
- shrink: true,
1069
- padding: { left: 1, right: 1 },
1070
- style: {
1071
- bg: '#e65100',
1072
- fg: 'white',
1073
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1074
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1075
- },
1076
- });
1077
- previewBtn.key(['enter', 'space'], () => {
1078
- const isPlaying = _playingVoiceId === voiceId;
1079
- _previewVoice(voiceId);
1080
- modalStatus.setContent(isPlaying
1081
- ? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
1082
- : `{${COLORS.activeFg}-fg}♪ Playing: ${displayName}…{/${COLORS.activeFg}-fg}`
1083
- );
1084
- screen.render();
1085
- });
1086
- previewBtn.on('click', () => previewBtn.press());
1087
-
1088
- // Tab/arrow navigation: SaveLocal SaveGlobal → Cancel → Preview → SaveLocal
1089
- okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
1090
- okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1091
- cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
1092
- previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
1093
- previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1094
- cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
1095
- okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
1096
- okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
1097
-
1098
- modal.key(['escape', 'q'], _close);
1099
-
1100
- modal.setFront();
1101
- okLocalBtn.focus();
1102
- screen.render();
1103
- }
1104
-
1105
- // -------------------------------------------------------------------------
1106
- // Download modal for uninstalled catalog voices
1107
-
1108
- function _openDownloadModal(voiceId) {
1109
- const cat = _catalogMap.get(voiceId);
1110
- const displayName = cat?.displayName ?? voiceId;
1111
- const modelToDownload = cat?.type === 'libritts' ? 'en_US-libritts-high' : (cat?.model ?? voiceId);
1112
- const isLibriTTS = cat?.type === 'libritts';
1113
-
1114
- const modal = blessed.box({
1115
- parent: screen,
1116
- top: 'center',
1117
- left: 'center',
1118
- width: 64,
1119
- height: 10,
1120
- border: { type: 'line' },
1121
- tags: true,
1122
- label: ` {${COLORS.activeFg}-fg}Download Voice{/${COLORS.activeFg}-fg} `,
1123
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
1124
- });
1125
-
1126
- const msgLine = blessed.text({
1127
- parent: modal,
1128
- top: 1,
1129
- left: 2,
1130
- right: 2,
1131
- tags: true,
1132
- content: `Download {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg}?\n\n` +
1133
- `Model: {${COLORS.activeFg}-fg}${modelToDownload}{/${COLORS.activeFg}-fg}` +
1134
- (isLibriTTS ? ` (~57 MB unlocks all 904 LibriTTS speakers)` : ` (~25 MB)`),
1135
- style: { bg: COLORS.contentBg },
1136
- });
1137
-
1138
- const statusLine = blessed.text({
1139
- parent: modal,
1140
- top: 5,
1141
- left: 2,
1142
- right: 2,
1143
- tags: true,
1144
- content: '',
1145
- style: { bg: COLORS.contentBg },
1146
- });
1147
-
1148
- let _downloading = false;
1149
-
1150
- function _close() {
1151
- modal.destroy();
1152
- voiceList.focus();
1153
- screen.render();
1154
- }
1155
-
1156
- function _startDownload() {
1157
- if (_downloading) return;
1158
- _downloading = true;
1159
-
1160
- // Animated spinner
1161
- const spinFrames = ['⠋', '⠙', '⠹', '⠸', '', '⠴', '⠦', '⠧', '⠇', '⠏'];
1162
- let spinIdx = 0;
1163
- let dlPhase = 'Downloading model';
1164
- const progressBar = (pct) => {
1165
- const filled = Math.round(pct / 5);
1166
- const empty = 20 - filled;
1167
- return ''.repeat(filled) + ''.repeat(empty);
1168
- };
1169
-
1170
- const spinTimer = setInterval(() => {
1171
- spinIdx = (spinIdx + 1) % spinFrames.length;
1172
- const frame = spinFrames[spinIdx];
1173
- statusLine.setContent(
1174
- `{${COLORS.activeFg}-fg}${frame} ${dlPhase}… ${modelToDownload}{/${COLORS.activeFg}-fg}`
1175
- );
1176
- screen.render();
1177
- }, 100);
1178
-
1179
- // Download voice model — use PowerShell on Windows, bash on Unix
1180
- const packageRoot = path.resolve(__dirname, '..', '..', '..');
1181
- const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1182
- let dlProc;
1183
-
1184
- if (isWindows) {
1185
- const piperVoicesDir = resolvePiperVoicesDir();
1186
- const hfBase = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
1187
- const match = modelToDownload.match(/^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$/);
1188
- let modelUrl, configUrl;
1189
- if (match) {
1190
- const [, lang, region, speaker, quality] = match;
1191
- const hfPath = `${lang}/${lang}_${region}/${speaker}/${quality}`;
1192
- modelUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx`;
1193
- configUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx.json`;
1194
- } else {
1195
- const customBase = 'https://huggingface.co/agentvibes/piper-custom-voices/resolve/main';
1196
- modelUrl = `${customBase}/${modelToDownload}.onnx`;
1197
- configUrl = `${customBase}/${modelToDownload}.onnx.json`;
1198
- }
1199
- const modelFile = path.join(piperVoicesDir, `${modelToDownload}.onnx`);
1200
- const configFile = path.join(piperVoicesDir, `${modelToDownload}.onnx.json`);
1201
- // PowerShell script with progress reporting
1202
- const psScript = `
1203
- $ErrorActionPreference = 'Stop'
1204
- $ProgressPreference = 'SilentlyContinue'
1205
- $voicesDir = '${piperVoicesDir.replace(/'/g, "''")}'
1206
- if (-not (Test-Path $voicesDir)) { New-Item -ItemType Directory -Path $voicesDir -Force | Out-Null }
1207
- Write-Output 'PHASE:model'
1208
- Invoke-WebRequest -Uri '${modelUrl}' -OutFile '${modelFile.replace(/'/g, "''")}' -ErrorAction Stop
1209
- Write-Output 'PHASE:config'
1210
- Invoke-WebRequest -Uri '${configUrl}' -OutFile '${configFile.replace(/'/g, "''")}' -ErrorAction Stop
1211
- Write-Output 'PHASE:done'
1212
- `;
1213
- dlProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], {
1214
- stdio: ['ignore', 'pipe', 'pipe'],
1215
- env: _spawnEnv,
1216
- });
1217
- } else {
1218
- const managerScript = path.resolve(packageRoot, '.claude', 'hooks', 'piper-voice-manager.sh');
1219
- dlProc = spawn('bash', ['-c', 'source "$1" && download_voice "$2"', '_', managerScript, modelToDownload], {
1220
- stdio: ['ignore', 'pipe', 'pipe'],
1221
- env: _spawnEnv,
1222
- });
1223
- }
1224
- _downloadProcess = dlProc;
1225
-
1226
- let output = '';
1227
- dlProc.stdout.on('data', (d) => {
1228
- const chunk = d.toString();
1229
- output += chunk;
1230
- // Update phase based on progress markers
1231
- if (chunk.includes('PHASE:config') || chunk.includes('config file')) {
1232
- dlPhase = 'Downloading config';
1233
- } else if (chunk.includes('PHASE:done') || chunk.includes('successfully')) {
1234
- dlPhase = 'Finishing up';
1235
- }
1236
- });
1237
- dlProc.stderr.on('data', (d) => { output += d.toString(); });
1238
-
1239
- dlProc.on('exit', (code) => {
1240
- clearInterval(spinTimer);
1241
- _downloading = false;
1242
- _downloadProcess = null;
1243
- if (code === 0) {
1244
- if (isLibriTTS) {
1245
- patchLibriTTSSpeakerNames();
1246
- _metaCache.clear();
1247
- }
1248
- statusLine.setContent(`{green-fg}✓ Downloaded successfully!{/green-fg}`);
1249
- screen.render();
1250
- setTimeout(() => {
1251
- _close();
1252
- refreshDisplay();
1253
- }, 1500);
1254
- } else {
1255
- statusLine.setContent(`{red-fg}✗ Download failed. ${output.slice(-80).trim()}{/red-fg}`);
1256
- screen.render();
1257
- }
1258
- });
1259
-
1260
- dlProc.on('error', () => {
1261
- clearInterval(spinTimer);
1262
- _downloading = false;
1263
- _downloadProcess = null;
1264
- statusLine.setContent(`{red-fg}✗ Could not run download script{/red-fg}`);
1265
- screen.render();
1266
- });
1267
- }
1268
-
1269
- function _makeBtn(label, bg, left, onClick) {
1270
- const btn = blessed.button({
1271
- parent: modal,
1272
- content: label,
1273
- top: 7,
1274
- left,
1275
- mouse: true,
1276
- keys: true,
1277
- shrink: true,
1278
- padding: { left: 1, right: 1 },
1279
- style: {
1280
- bg,
1281
- fg: 'white',
1282
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1283
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1284
- },
1285
- });
1286
- btn.key(['enter', 'space'], onClick);
1287
- btn.on('click', () => btn.press());
1288
- return btn;
1289
- }
1290
-
1291
- const dlBtn = _makeBtn('Download', COLORS.btnDefault, 2, _startDownload);
1292
- const cancelBtn = _makeBtn('Cancel', '#546e7a', 16, _close);
1293
-
1294
- dlBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1295
- cancelBtn.key(['tab', 'right'], () => { dlBtn.focus(); screen.render(); });
1296
- dlBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1297
- cancelBtn.key(['left'], () => { dlBtn.focus(); screen.render(); });
1298
-
1299
- modal.key(['escape', 'q'], () => { if (!_downloading) _close(); });
1300
-
1301
- modal.setFront();
1302
- dlBtn.focus();
1303
- screen.render();
1304
- }
1305
-
1306
- // -------------------------------------------------------------------------
1307
- // State
1308
-
1309
- let _allVoices = []; // voice IDs (installed first, then catalog-only)
1310
- let _installedSet = new Set(); // which IDs are locally installed
1311
- let _filterText = '';
1312
-
1313
- function _getFilteredVoices() {
1314
- if (!_filterText) return _allVoices;
1315
- const f = _filterText.toLowerCase();
1316
- return _allVoices.filter(v => {
1317
- if (v.toLowerCase().includes(f)) return true;
1318
- // Also search by catalog display name
1319
- const cat = _catalogMap.get(v);
1320
- if (cat && cat.displayName.toLowerCase().includes(f)) return true;
1321
- return false;
1322
- });
1323
- }
1324
-
1325
- function _isInstalled(voiceId) {
1326
- return _installedSet.has(voiceId);
1327
- }
1328
-
1329
- function _buildListItems(voices, active, favorites) {
1330
- return voices.map(v => {
1331
- const installed = _isInstalled(v);
1332
- const isFav = favorites.includes(v);
1333
- const isActive = v === active;
1334
- const isPrev = v === _playingVoiceId;
1335
- const star = isFav ? '' : ' ';
1336
- const dot = isPrev ? '' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
1337
-
1338
- let displayName, gender, provider;
1339
- if (installed) {
1340
- const meta = getVoiceMeta(v);
1341
- displayName = meta.displayName;
1342
- gender = meta.gender;
1343
- provider = meta.provider;
1344
- } else {
1345
- // Catalog-only voice — use catalog metadata
1346
- const cat = _catalogMap.get(v);
1347
- displayName = cat?.displayName ?? v;
1348
- gender = cat?.gender ?? '—';
1349
- provider = cat?.type === 'libritts' ? 'Piper (LibriTTS)' : 'Piper';
1350
- }
1351
-
1352
- const name = displayName.length > COL_NAME_W
1353
- ? displayName.slice(0, COL_NAME_W - 1) + '…'
1354
- : displayName.padEnd(COL_NAME_W);
1355
-
1356
- if (!installed) {
1357
- // Greyed-out row for uninstalled catalog voices
1358
- return `{bright-black-fg} ${star} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}{/bright-black-fg}`;
1359
- }
1360
- return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1361
- });
1362
- }
1363
-
1364
- // Build a tagged info string with yellow labels for the info panel
1365
- function _formatInfoTagged(voiceId) {
1366
- const Y = COLORS.valueFg; // #ffd700 yellow
1367
-
1368
- // Uninstalled catalog voice — show download prompt
1369
- if (!_isInstalled(voiceId)) {
1370
- const cat = _catalogMap.get(voiceId);
1371
- const name = cat?.displayName ?? voiceId;
1372
- const gender = cat?.gender ?? '—';
1373
- const model = cat?.type === 'libritts' ? 'LibriTTS High (multi-speaker)' : (cat?.model ?? voiceId);
1374
- return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1375
- `{${Y}-fg}${_tl('voiceInfoGender')}{/${Y}-fg} ${_tGender(gender)} ` +
1376
- `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${model} ` +
1377
- `{bright-yellow-fg}${_tl('voiceInfoDownload')}{/bright-yellow-fg}`;
1378
- }
1379
-
1380
- const ms = parseMultiSpeaker(voiceId);
1381
- if (ms.isMultiSpeaker) {
1382
- const name = ms.speakerName.replace(/_/g, ' ');
1383
- return `{${Y}-fg}${_tl('voiceInfoSpeaker')}{/${Y}-fg} ${name} ` +
1384
- `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${ms.model} ` +
1385
- `{${Y}-fg}${_tl('voiceInfoSpeakerId')}{/${Y}-fg} ${ms.speakerId ?? '?'} ` +
1386
- `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1387
- }
1388
- const { lang, name, quality } = parseVoiceId(voiceId);
1389
- if (lang === 'unknown') {
1390
- return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${voiceId} {${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1391
- }
1392
- return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1393
- `{${Y}-fg}${_tl('voiceInfoLanguage')}{/${Y}-fg} ${lang} ` +
1394
- `{${Y}-fg}${_tl('voiceInfoQuality')}{/${Y}-fg} ${quality} ` +
1395
- `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper ` +
1396
- `{${Y}-fg}${_tl('voiceInfoId')}{/${Y}-fg} ${voiceId}`;
1397
- }
1398
-
1399
- function refreshDisplay() {
1400
- _refreshing = true;
1401
- const savedIdx = voiceList.selected ?? 0;
1402
-
1403
- // Load catalog on first refresh and patch speaker names (once)
1404
- if (!_catalogLoaded) {
1405
- loadCatalog();
1406
- patchLibriTTSSpeakerNames();
1407
- _metaCache.clear();
1408
- }
1409
-
1410
- // Installed voices (from local disk)
1411
- const installed = scanInstalledVoices();
1412
- _installedSet = new Set(installed);
1413
-
1414
- // Merge: installed voices first, then uninstalled catalog voices
1415
- const catalogOnly = _catalogEntries
1416
- .filter(c => !_installedSet.has(c.voiceId))
1417
- .map(c => c.voiceId);
1418
- _allVoices = [...installed, ...catalogOnly];
1419
-
1420
- const active = providerService.getActiveVoiceId();
1421
- const favorites = getFavorites(configService);
1422
- const filtered = _getFilteredVoices();
1423
- const items = _buildListItems(filtered, active, favorites);
1424
-
1425
- voiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
1426
- const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
1427
- voiceList.select(Math.min(savedIdx, maxIdx));
1428
-
1429
- // Re-apply inline hint if list is focused
1430
- if (_listFocused) {
1431
- _hintIdx = -1;
1432
- _hintBase = '';
1433
- _updateHint(voiceList.selected ?? 0);
1434
- }
1435
-
1436
- // Update info panel for currently selected item
1437
- const sel = filtered[voiceList.selected] ?? active ?? '';
1438
- infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1439
-
1440
- // Update "Currently Selected" header
1441
- if (active) {
1442
- const _msA = parseMultiSpeaker(active);
1443
- const activeName = _msA.isMultiSpeaker ? _msA.speakerName : active;
1444
- const meta = _isInstalled(active) ? getVoiceMeta(active) : null;
1445
- const displayName = meta?.displayName ?? activeName;
1446
- activeVoiceText.setContent(`{green-fg}✓ ${displayName}{/green-fg}`);
1447
- } else {
1448
- activeVoiceText.setContent(`{bright-black-fg}No voice selected{/bright-black-fg}`);
1449
- }
1450
-
1451
- _refreshing = false;
1452
- if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
1453
- screen.render();
1454
- }
1455
-
1456
- // -------------------------------------------------------------------------
1457
- // Search box interaction
1458
-
1459
- searchBox.on('keypress', () => {
1460
- // Update filter after keystroke
1461
- setTimeout(() => {
1462
- _filterText = searchBox.getValue().trim();
1463
- refreshDisplay();
1464
- }, 0);
1465
- });
1466
-
1467
- // Pressing Escape in search returns focus to voiceList
1468
- searchBox.key(['escape'], () => {
1469
- voiceList.focus();
1470
- screen.render();
1471
- });
1472
-
1473
- // Page Up / Page Down — jump ~20 items at a time
1474
- const PAGE_SIZE = 20;
1475
- voiceList.key(['pagedown'], () => {
1476
- const voices = _getFilteredVoices();
1477
- const maxIdx = Math.max(0, voices.length - 1);
1478
- voiceList.select(Math.min((voiceList.selected ?? 0) + PAGE_SIZE, maxIdx));
1479
- screen.render();
1480
- });
1481
- voiceList.key(['pageup'], () => {
1482
- voiceList.select(Math.max((voiceList.selected ?? 0) - PAGE_SIZE, 0));
1483
- screen.render();
1484
- });
1485
-
1486
- // Pressing '/' in voiceList focuses search box
1487
- voiceList.key(['/'], () => {
1488
- searchBox.clearValue();
1489
- searchBox.focus();
1490
- screen.render();
1491
- });
1492
-
1493
- // at the top of the list → jump to main header tab bar
1494
- voiceList.key(['up'], () => {
1495
- if (voiceList.selected === 0 && typeof focusMainTabBar === 'function') {
1496
- focusMainTabBar();
1497
- // Reset selection to 0 after built-in handler potentially wraps to end
1498
- setTimeout(() => { voiceList.select(0); screen.render(); }, 0);
1499
- }
1500
- });
1501
-
1502
- // Escape at the list level → return to header tab bar
1503
- voiceList.key(['escape'], () => {
1504
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1505
- });
1506
-
1507
- // 'f' or '*' in voiceList toggles favorite
1508
- voiceList.key(['f', '*'], () => {
1509
- const voices = _getFilteredVoices();
1510
- const selected = voices[voiceList.selected];
1511
- if (selected) {
1512
- toggleFavorite(configService, selected);
1513
- refreshDisplay();
1514
- }
1515
- });
1516
-
1517
- // Space → preview voice (toggle: second press stops playback)
1518
- // Only works for installed voices — uninstalled need download first
1519
- voiceList.key(['space'], () => {
1520
- const voices = _getFilteredVoices();
1521
- const selected = voices[voiceList.selected];
1522
- if (!selected) return;
1523
- if (!_isInstalled(selected)) {
1524
- previewLine.setContent(`{bright-yellow-fg}⬇ Voice not installed — press [Enter] to download first{/bright-yellow-fg}`);
1525
- screen.render();
1526
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 3000);
1527
- return;
1528
- }
1529
- _previewVoice(selected);
1530
- refreshDisplay();
1531
- });
1532
-
1533
- // Enter open "Set as default voice" or download uninstalled voice
1534
- voiceList.key(['enter'], () => {
1535
- const voices = _getFilteredVoices();
1536
- const selected = voices[voiceList.selected];
1537
- if (!selected) return;
1538
- _killPlayingProcess();
1539
- _playingVoiceId = null;
1540
- previewLine.setContent('');
1541
- screen.render();
1542
-
1543
- if (_isInstalled(selected)) {
1544
- _openSelectVoiceModal(selected);
1545
- } else {
1546
- _openDownloadModal(selected);
1547
- }
1548
- });
1549
-
1550
- // Blinking on selected row while list is focused
1551
- let _vlBlink = { interval: null, on: false, sel: -1 };
1552
- process.on('exit', () => { if (_vlBlink.interval) clearInterval(_vlBlink.interval); });
1553
- function _vlTick() {
1554
- _vlBlink.on = !_vlBlink.on;
1555
- const items = voiceList.items;
1556
- const cur = voiceList.selected ?? 0;
1557
- if (_vlBlink.sel !== cur && _vlBlink.sel >= 0 && items[_vlBlink.sel]) {
1558
- items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '').replace(/ █$/, ''));
1559
- }
1560
- _vlBlink.sel = cur;
1561
- if (items[cur]) {
1562
- const base = (items[cur].content ?? '').replace(/ █$/, '');
1563
- items[cur].setContent(_vlBlink.on ? `${base} █` : base);
1564
- }
1565
- screen.render();
1566
- }
1567
- voiceList.on('focus', () => {
1568
- _listFocused = true;
1569
- _vlBlink.on = true;
1570
- _vlBlink.sel = voiceList.selected ?? 0;
1571
- _hintIdx = -1;
1572
- _hintBase = '';
1573
- _updateHint(_vlBlink.sel);
1574
- const items = voiceList.items;
1575
- if (items[_vlBlink.sel]) items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '') + ' █');
1576
- if (!_playingVoiceId) previewLine.setContent(HINT_TEXT);
1577
- screen.render();
1578
- _vlBlink.interval = setInterval(_vlTick, 500);
1579
- });
1580
- voiceList.on('blur', () => {
1581
- _listFocused = false;
1582
- if (!_playingVoiceId) previewLine.setContent('');
1583
- if (_vlBlink.interval) { clearInterval(_vlBlink.interval); _vlBlink.interval = null; }
1584
- const items = voiceList.items;
1585
- const sel = voiceList.selected ?? 0;
1586
- if (items[sel]) {
1587
- items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1588
- }
1589
- if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1590
- items[_hintIdx].setContent(_hintBase);
1591
- }
1592
- _hintIdx = -1;
1593
- _hintBase = '';
1594
- screen.render();
1595
- });
1596
-
1597
- // Update info panel when selection changes
1598
- voiceList.on('select item', () => {
1599
- if (_refreshing) return;
1600
- _updateHint(voiceList.selected ?? 0);
1601
- if (_vlBlink.interval) _vlTick(); // move █ to newly selected row
1602
- const voices = _getFilteredVoices();
1603
- const sel = voices[voiceList.selected] ?? '';
1604
- infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1605
- screen.render();
1606
- });
1607
-
1608
- // Type-to-jump: press a letter to jump to first voice whose display name starts with it
1609
- const _voiceJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'f']);
1610
- voiceList.on('keypress', (ch, key) => {
1611
- if (!ch || key.ctrl || key.meta) return;
1612
- const lower = ch.toLowerCase();
1613
- if (!/^[a-z]$/.test(lower)) return;
1614
- if (_voiceJumpBlocked.has(lower)) return;
1615
- const voices = _getFilteredVoices();
1616
- const count = voices.length;
1617
- if (count === 0) return;
1618
- const start = voiceList.selected ?? 0;
1619
- for (let i = 1; i <= count; i++) {
1620
- const idx = (start + i) % count;
1621
- const name = getVoiceMeta(voices[idx]).displayName.toLowerCase();
1622
- if (name.startsWith(lower)) {
1623
- voiceList.select(idx);
1624
- screen.render();
1625
- break;
1626
- }
1627
- }
1628
- });
1629
-
1630
- // -------------------------------------------------------------------------
1631
- // Button-row keyboard navigation
1632
- // ↓ at the last list item → descend into the button row (Switch Voice gets focus first)
1633
- // Note: Tab is NOT used navigation.js registers screen.key(['tab']) to cycle tabs,
1634
- // so element.key(['tab']) + screen.key(['tab']) both fire simultaneously.
1635
- voiceList.key(['down'], () => {
1636
- const voices = _getFilteredVoices();
1637
- if (voiceList.selected >= voices.length - 1) {
1638
- switchBtn.focus();
1639
- screen.render();
1640
- }
1641
- });
1642
-
1643
- // ←/→ navigate between the three buttons
1644
- switchBtn.key(['right'], () => { favoriteBtn.focus(); screen.render(); });
1645
- favoriteBtn.key(['right'], () => { installBtn.focus(); screen.render(); });
1646
- installBtn.key(['right'], () => { switchBtn.focus(); screen.render(); });
1647
- switchBtn.key(['left'], () => { installBtn.focus(); screen.render(); });
1648
- favoriteBtn.key(['left'], () => { switchBtn.focus(); screen.render(); });
1649
- installBtn.key(['left'], () => { favoriteBtn.focus(); screen.render(); });
1650
-
1651
- // or Escape from any button → back to voice list
1652
- switchBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1653
- favoriteBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1654
- installBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1655
-
1656
- // -------------------------------------------------------------------------
1657
- // Language refresh
1658
-
1659
- function refreshVoicesLabels() {
1660
- voicesSectionHdr.setContent(`{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`);
1661
- searchLabelText.setContent(_tl('searchLabel'));
1662
- colHeaderText.setContent(`{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`);
1663
- voiceInfoHdr.setContent(`{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`);
1664
- switchBtn.setContent(_tl('voicesSwitchBtn'));
1665
- favoriteBtn.setContent(_tl('voicesFavoriteBtn'));
1666
- installBtn.setContent(_tl('voicesDownloadBtn'));
1667
- screen.render();
1668
- }
1669
-
1670
- if (languageService) {
1671
- languageService.onChange(() => refreshVoicesLabels());
1672
- }
1673
-
1674
- // -------------------------------------------------------------------------
1675
- // Tab Component Contract
1676
-
1677
- return {
1678
- box,
1679
-
1680
- show() {
1681
- box.show();
1682
- refreshDisplay();
1683
- screen.render();
1684
- },
1685
-
1686
- hide() {
1687
- _killPlayingProcess();
1688
- _playingVoiceId = null;
1689
- if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1690
- previewLine.setContent('');
1691
- box.hide();
1692
- screen.render();
1693
- },
1694
-
1695
- onFocus() {
1696
- voiceList.focus();
1697
- screen.render();
1698
- },
1699
-
1700
- onBlur() {
1701
- _killPlayingProcess();
1702
- _playingVoiceId = null;
1703
- if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1704
- },
1705
-
1706
- getFooterText() {
1707
- return _tl('voicesFooter');
1708
- },
1709
-
1710
- getFooterColor() {
1711
- return COLORS.footerBg;
1712
- },
1713
- };
1714
- }
1
+ /**
2
+ * AgentVibes TUI Console — Voices Tab
3
+ * Epic 8: Stories 8.1-8.4
4
+ *
5
+ * Implements the Tab Component Contract:
6
+ * createVoicesTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
+ *
8
+ * Features: installed voice list, search/filter, favorites (★), voice info panel, install stub.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import { spawn } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
21
+
22
+ let blessed;
23
+ if (!IS_TEST) {
24
+ const { default: b } = await import('blessed');
25
+ blessed = b;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const COLORS = {
31
+ contentBg: '#0a0e1a',
32
+ sectionHdr: '#00897b', // Teal — section headers for Voices tab
33
+ labelFg: '#e3f2fd',
34
+ valueFg: '#f06292', // Light magenta — brand color
35
+ activeFg: 'bright-cyan', // Cyan — active voice
36
+ favoriteFg: '#ffff00', // Yellow — favorite star
37
+ btnDefault: '#00695c', // Teal — Voices tab buttons
38
+ btnFocus: '#2e7d32', // Green — focused/selected
39
+ btnFocusFg: '#ffffff',
40
+ btnPress: '#ff00ff',
41
+ borderFg: '#00897b',
42
+ footerBg: '#00695c', // Teal — Voices tab footer
43
+ noticeFg: '#90a4ae',
44
+ dimFg: '#455a64',
45
+ };
46
+
47
+ const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select/Install [*] Favorite [/] Search';
48
+ /**
49
+ * Resolve the Piper voice storage directory using the same precedence as the
50
+ * shell-side get_voice_storage_dir() in piper-voice-manager.sh:
51
+ * 1. PIPER_VOICES_DIR env var
52
+ * 2. Project-local .claude/piper-voices-dir.txt (walk up from cwd)
53
+ * 3. Global ~/.claude/piper-voices-dir.txt
54
+ * 4. Default ~/.claude/piper-voices
55
+ */
56
+ function resolvePiperVoicesDir() {
57
+ const defaultDir = path.join(os.homedir(), '.claude', 'piper-voices');
58
+ if (process.env.PIPER_VOICES_DIR) {
59
+ const resolved = path.resolve(process.env.PIPER_VOICES_DIR);
60
+ if (resolved.includes('..')) return defaultDir;
61
+ return resolved;
62
+ }
63
+
64
+ // Search up directory tree for .claude/piper-voices-dir.txt
65
+ let dir = process.cwd();
66
+ while (dir !== path.dirname(dir)) {
67
+ const cfg = path.join(dir, '.claude', 'piper-voices-dir.txt');
68
+ try {
69
+ if (fs.existsSync(cfg)) return fs.readFileSync(cfg, 'utf8').trim();
70
+ } catch { /* skip */ }
71
+ dir = path.dirname(dir);
72
+ }
73
+
74
+ // Global config
75
+ const globalCfg = path.join(os.homedir(), '.claude', 'piper-voices-dir.txt');
76
+ try {
77
+ if (fs.existsSync(globalCfg)) return fs.readFileSync(globalCfg, 'utf8').trim();
78
+ } catch { /* skip */ }
79
+
80
+ // Default fallback
81
+ return defaultDir;
82
+ }
83
+
84
+ export const PIPER_VOICES_DIR = resolvePiperVoicesDir();
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Voice catalog — loaded from voice-assignments.json (914 voices)
88
+
89
+ let _catalogEntries = []; // { voiceId, displayName, gender, model, type, speakerId }
90
+ let _catalogMap = new Map(); // voiceId → catalog entry (rebuilt when catalog loads)
91
+ let _catalogLoaded = false;
92
+
93
+ /**
94
+ * Load the full voice catalog from voice-assignments.json.
95
+ * Called once on first tab show. Safe to call multiple times.
96
+ */
97
+ function loadCatalog() {
98
+ if (_catalogLoaded) return;
99
+ _catalogLoaded = true;
100
+ try {
101
+ const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
102
+ if (!fs.existsSync(catalogPath)) return;
103
+ const data = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
104
+
105
+ // LibriTTS multi-speaker entries (904 speakers from en_US-libritts-high model)
106
+ const libritts = data.libritts_speakers ?? {};
107
+ for (const [id, entry] of Object.entries(libritts)) {
108
+ _catalogEntries.push({
109
+ // voiceId stays raw so existing user configs continue to resolve
110
+ voiceId: `en_US-libritts-high${MS_SEP}${entry.voice_name}`,
111
+ // displayName gets a deterministic surname so every speaker is unique
112
+ displayName: uniquifyVoiceName(entry.voice_name),
113
+ gender: (entry.gender ?? '').charAt(0).toUpperCase() + (entry.gender ?? '').slice(1),
114
+ model: 'en_US-libritts-high',
115
+ type: 'libritts',
116
+ speakerId: parseInt(id, 10),
117
+ });
118
+ }
119
+
120
+ // Curated standalone voices (10 individual models)
121
+ const curated = data.curated_voices ?? {};
122
+ for (const [, entry] of Object.entries(curated)) {
123
+ _catalogEntries.push({
124
+ voiceId: entry.model_file,
125
+ displayName: entry.voice_name,
126
+ gender: (entry.gender ?? '').charAt(0).toUpperCase() + (entry.gender ?? '').slice(1),
127
+ model: entry.model_file,
128
+ type: 'curated',
129
+ speakerId: null,
130
+ });
131
+ }
132
+ } catch { /* non-fatal — catalog just won't show */ }
133
+ // Build lookup map for O(1) access by voiceId
134
+ _catalogMap = new Map();
135
+ for (const c of _catalogEntries) _catalogMap.set(c.voiceId, c);
136
+ }
137
+
138
+ /**
139
+ * Patch the speaker_id_map in a LibriTTS .onnx.json to use friendly names
140
+ * instead of raw corpus IDs (p3922 Anna, p8699 → Bella, etc.).
141
+ * Reads the catalog's index→friendly-name mapping and rebuilds the map.
142
+ * Safe to call multiple times — skips if already patched.
143
+ */
144
+ function patchLibriTTSSpeakerNames() {
145
+ try {
146
+ const jsonPath = path.join(PIPER_VOICES_DIR, 'en_US-libritts-high.onnx.json');
147
+ if (!fs.existsSync(jsonPath)) return;
148
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
149
+ if (!data.speaker_id_map || data.num_speakers <= 1) return;
150
+
151
+ const names = Object.keys(data.speaker_id_map);
152
+ // Already patched if first name doesn't start with 'p' followed by digits
153
+ if (names.length > 0 && !/^p\d+$/.test(names[0])) return;
154
+
155
+ // Build index p-name reverse map
156
+ const indexToP = {};
157
+ for (const [pname, idx] of Object.entries(data.speaker_id_map)) {
158
+ indexToP[idx] = pname;
159
+ }
160
+
161
+ // Load friendly names from catalog
162
+ const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
163
+ if (!fs.existsSync(catalogPath)) return;
164
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
165
+ const speakers = catalog.libritts_speakers ?? {};
166
+
167
+ // Rebuild speaker_id_map with friendly names
168
+ const newMap = {};
169
+ for (const [idx, pname] of Object.entries(indexToP)) {
170
+ const friendly = speakers[idx]?.voice_name;
171
+ if (friendly) {
172
+ newMap[friendly] = parseInt(idx, 10);
173
+ } else {
174
+ newMap[pname] = parseInt(idx, 10);
175
+ }
176
+ }
177
+
178
+ data.speaker_id_map = newMap;
179
+ // Verify file ownership before writing (security: CLAUDE.md)
180
+ const stat = fs.statSync(jsonPath);
181
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
182
+ fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2), 'utf8');
183
+ } catch { /* non-fatal */ }
184
+ }
185
+
186
+ // Column widths for the multi-column voice list
187
+ export const COL_NAME_W = 26;
188
+ export const COL_GENDER_W = 4;
189
+
190
+ // Surname pool used to convert "Anna-2" → "Anna Carter" so every speaker
191
+ // in the LibriTTS catalog gets a unique, friendly display name. Indexed by
192
+ // the trailing -N suffix (1-based: no suffix → idx 0, "-2" → idx 1, ...).
193
+ const SURNAME_POOL = [
194
+ 'Bell', 'Carter', 'Davis', 'Ellis', 'Foster', 'Gray', 'Hayes', 'Irving',
195
+ 'Jones', 'Knox', 'Lane', 'Mason', 'Nash', 'Owens', 'Pierce', 'Quinn',
196
+ ];
197
+
198
+ /**
199
+ * Convert a raw catalog speaker name like "Anna" or "Anna-2" into a unique
200
+ * friendly display name by appending a deterministic surname.
201
+ * "Anna" → "Anna Bell"
202
+ * "Anna-2" → "Anna Carter"
203
+ * "Anna-16" → "Anna Quinn"
204
+ * "Cori Samuel" "Cori Samuel" (already full name — left alone)
205
+ *
206
+ * @param {string} rawName
207
+ * @returns {string}
208
+ */
209
+ export function uniquifyVoiceName(rawName) {
210
+ if (!rawName) return rawName;
211
+ const m = rawName.match(/^(.+)-(\d+)$/);
212
+ if (m) {
213
+ const base = m[1];
214
+ const n = parseInt(m[2], 10);
215
+ // Only treat -N as a duplicate counter when N >= 2 (catalog convention).
216
+ // -0 / -1 would otherwise produce collisions or undefined surnames.
217
+ if (n >= 2) {
218
+ const idx = (n - 1) % SURNAME_POOL.length;
219
+ return `${base} ${SURNAME_POOL[idx]}`;
220
+ }
221
+ }
222
+ // Only append a surname if the name is a single word — otherwise it's
223
+ // already a full name (e.g. "Cori Samuel") and we leave it alone.
224
+ if (/\s/.test(rawName)) return rawName;
225
+ return `${rawName} ${SURNAME_POOL[0]}`;
226
+ }
227
+
228
+ /**
229
+ * Map a gender string to a single-character icon for compact display.
230
+ * "Female" → "♀"
231
+ * "Male" → "♂"
232
+ * anything else → "—"
233
+ *
234
+ * @param {string} gender
235
+ * @returns {string}
236
+ */
237
+ export function genderIcon(gender) {
238
+ if (gender === 'Female') return '';
239
+ if (gender === 'Male') return '';
240
+ return '—';
241
+ }
242
+
243
+ /**
244
+ * Same as genderIcon but wrapped in blessed color tags:
245
+ * Female pink (magenta)
246
+ * Male → light blue (bright-cyan)
247
+ * other → no color
248
+ * The returned string is 1 visible char wide but contains color tags;
249
+ * callers should add padding spaces separately rather than using padEnd().
250
+ *
251
+ * @param {string} gender
252
+ * @returns {string}
253
+ */
254
+ export function genderIconTag(gender) {
255
+ if (gender === 'Female') return '{magenta-fg}♀{/magenta-fg}';
256
+ if (gender === 'Male') return '{bright-cyan-fg}♂{/bright-cyan-fg}';
257
+ return '—';
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Pure helpers — exported for testability
262
+
263
+ // Well-known piper dataset gender
264
+ const GENDER_MAP = {
265
+ // Single-speaker datasets
266
+ amy: 'Female', kristin: 'Female', jenny: 'Female', cori: 'Female',
267
+ aria: 'Female', glados: 'Female', litvyak: 'Female', hfc_female: 'Female',
268
+ ljspeech: 'Female',
269
+ alan: 'Male', joe: 'Male', john: 'Male', ryan: 'Male', lessac: 'Male',
270
+ kusal: 'Male', hfc_male: 'Male', danny: 'Male', arctic: 'Male',
271
+ l2arctic: 'Male',
272
+ // 16Speakers multi-speaker model (names from speaker_id_map)
273
+ cori_samuel: 'Female', kara_shallenberg: 'Female', kristin_hughes: 'Female',
274
+ maria_kasper: 'Female', rose_ibex: 'Female', owlivia: 'Female',
275
+ jennifer_dorr: 'Female', emily_cripps: 'Female',
276
+ mike_pelton: 'Male', mark_nelson: 'Male', michael_scherer: 'Male',
277
+ james_k_white: 'Male', progressingamerica: 'Male', steve_c: 'Male',
278
+ paul_hampton: 'Male', martin_clifton: 'Male',
279
+ // LibriTTS / common first names used as multi-speaker speaker IDs
280
+ anna: 'Female', bella: 'Female', chloe: 'Female', donna: 'Female',
281
+ ella: 'Female', faith: 'Female', gina: 'Female', holly: 'Female',
282
+ ivy: 'Female', jane: 'Female', kelly: 'Female', laura: 'Female',
283
+ mary: 'Female', nina: 'Female', olivia: 'Female', penny: 'Female',
284
+ rachel: 'Female', sarah: 'Female', tara: 'Female', uma: 'Female',
285
+ vera: 'Female', wendy: 'Female', yara: 'Female', zoe: 'Female',
286
+ betty: 'Female', cindy: 'Female', debra: 'Female', erica: 'Female',
287
+ faye: 'Female', gloria: 'Female', quinn: 'Female',
288
+ alex: 'Male', ben: 'Male', carl: 'Male', dan: 'Male', evan: 'Male',
289
+ frank: 'Male', greg: 'Male', hank: 'Male', ivan: 'Male', jake: 'Male',
290
+ kevin: 'Male', leo: 'Male', mike: 'Male', nathan: 'Male', oscar: 'Male',
291
+ paul: 'Male', rick: 'Male', sam: 'Male', tom: 'Male', victor: 'Male',
292
+ will: 'Male', xavier: 'Male', zach: 'Male', adam: 'Male', brad: 'Male',
293
+ colin: 'Male', derek: 'Male', ethan: 'Male', felix: 'Male',
294
+ };
295
+
296
+ // Well-known piper dataset nice display name
297
+ const DISPLAY_NAMES = {
298
+ ljspeech: 'LJ Speech',
299
+ libritts: 'LibriTTS',
300
+ libritts_r: 'LibriTTS',
301
+ l2arctic: 'L2-Arctic',
302
+ hfc_male: 'HFC Male',
303
+ hfc_female: 'HFC Female',
304
+ };
305
+
306
+ /**
307
+ * Infer voice gender from voice ID and/or dataset name.
308
+ * Returns 'Female', 'Male', or '—'.
309
+ *
310
+ * @param {string} voiceId e.g. 'en_GB-southern_english_female-low'
311
+ * @param {string} [dataset] e.g. 'southern_english_female'
312
+ * @returns {string}
313
+ */
314
+ export function inferGender(voiceId, dataset) {
315
+ const id = voiceId.toLowerCase();
316
+ const ds = (dataset ?? '').toLowerCase();
317
+ // Explicit in name
318
+ if (id.includes('_female') || ds.includes('female')) return 'Female';
319
+ if (id.includes('_male') || ds.includes('male')) return 'Male';
320
+ // Dataset lookup first
321
+ if (ds && GENDER_MAP[ds]) return GENDER_MAP[ds];
322
+ // For multi-speaker speaker names like "Anna-9", strip trailing "-N" suffix
323
+ // then look up the base name (e.g. "anna")
324
+ const baseName = id.replace(/-\d+$/, '');
325
+ if (GENDER_MAP[baseName]) return GENDER_MAP[baseName];
326
+ // Fall back to middle segment of voice ID (e.g. "ryan" from "en_US-ryan-high")
327
+ const segment = id.split('-')[1] ?? '';
328
+ return GENDER_MAP[segment] ?? GENDER_MAP[id] ?? '—';
329
+ }
330
+
331
+ /**
332
+ * Format a piper dataset name into a human-readable voice display name.
333
+ *
334
+ * @param {string} voiceId
335
+ * @param {string} [dataset] from the .onnx.json file
336
+ * @returns {string}
337
+ */
338
+ export function formatVoiceName(voiceId, dataset) {
339
+ // Skip generic dataset values that aren't useful as display names
340
+ const GENERIC_DATASETS = new Set(['training', 'dataset', 'default', 'unknown', '']);
341
+ const usableDataset = dataset && !GENERIC_DATASETS.has(dataset.toLowerCase()) ? dataset : null;
342
+ const raw = usableDataset ?? voiceId.split('-')[1] ?? voiceId;
343
+ if (DISPLAY_NAMES[raw]) return DISPLAY_NAMES[raw];
344
+ return raw
345
+ .replace(/_/g, ' ')
346
+ .replace(/\b\w/g, c => c.toUpperCase());
347
+ }
348
+
349
+ export const SAMPLE_PHRASES = [
350
+ "Hello! I'm ready to assist you with your tasks today.",
351
+ "Code review complete. I found several areas that could be improved.",
352
+ "Welcome to AgentVibes. I'll help you get things done efficiently.",
353
+ "Task finished successfully. What shall we work on next?",
354
+ "I've analyzed the code and have some suggestions for you.",
355
+ ];
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Exported pure helpers (testable without blessed)
359
+
360
+ /**
361
+ * Parse a piper voice ID into its components.
362
+ * e.g. 'en_US-amy-medium' → { lang: 'en_US', name: 'amy', quality: 'medium' }
363
+ *
364
+ * @param {string} voiceId
365
+ * @returns {{ lang: string, name: string, quality: string }}
366
+ */
367
+ export function parseVoiceId(voiceId) {
368
+ if (!voiceId) return { lang: 'unknown', name: 'unknown', quality: 'unknown' };
369
+ // Expected format: <lang>-<name>-<quality> e.g. en_US-amy-medium
370
+ const match = voiceId.match(/^([a-z]{2}_[A-Z]{2})-(.+)-(low|medium|high)$/);
371
+ if (!match) return { lang: 'unknown', name: voiceId, quality: 'unknown' };
372
+ return { lang: match[1], name: match[2], quality: match[3] };
373
+ }
374
+
375
+ /**
376
+ * Format a one-line voice info summary.
377
+ *
378
+ * @param {string} voiceId
379
+ * @returns {string}
380
+ */
381
+ export function formatVoiceInfo(voiceId) {
382
+ const { lang, name, quality } = parseVoiceId(voiceId);
383
+ if (lang === 'unknown') return `Voice: ${voiceId} | Provider: Piper`;
384
+ return `Voice: ${name} | Language: ${lang} | Quality: ${quality} | Provider: Piper`;
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Test stub
389
+
390
+ function createTestStub() {
391
+ return {
392
+ box: {},
393
+ show: () => {},
394
+ hide: () => {},
395
+ onFocus: () => {},
396
+ onBlur: () => {},
397
+ getFooterText: () => FOOTER_TEXT,
398
+ getFooterColor: () => COLORS.footerBg,
399
+ };
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+
404
+ // Multi-speaker voice separator: "modelName::speakerName"
405
+ export const MS_SEP = '::';
406
+
407
+ /**
408
+ * Parse a voice entry that may be a multi-speaker ID.
409
+ * @param {string} voiceId - e.g. "16Speakers::Cori_Samuel" or "en_US-lessac-medium"
410
+ * @returns {{ model: string, speakerId: number|null, speakerName: string|null, isMultiSpeaker: boolean }}
411
+ */
412
+ export function parseMultiSpeaker(voiceId) {
413
+ if (!voiceId) return { model: voiceId ?? '', speakerId: null, speakerName: null, isMultiSpeaker: false };
414
+ if (voiceId.includes(MS_SEP)) {
415
+ const [model, speakerName] = voiceId.split(MS_SEP, 2);
416
+ const jsonPath = path.join(PIPER_VOICES_DIR, model + '.onnx.json');
417
+ try {
418
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
419
+ let speakerId = data.speaker_id_map?.[speakerName] ?? null;
420
+ // Fallback: if the .onnx.json still has raw p-names (not yet patched),
421
+ // look up the numeric speaker ID from voice-assignments.json catalog.
422
+ if (speakerId == null && model === 'en_US-libritts-high') {
423
+ try {
424
+ const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
425
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
426
+ const speakers = catalog.libritts_speakers ?? {};
427
+ const entry = Object.entries(speakers).find(([, e]) => e.voice_name === speakerName);
428
+ if (entry) speakerId = parseInt(entry[0], 10);
429
+ } catch { /* non-fatal */ }
430
+ }
431
+ return { model, speakerId, speakerName, isMultiSpeaker: true };
432
+ } catch {
433
+ return { model, speakerId: null, speakerName, isMultiSpeaker: true };
434
+ }
435
+ }
436
+ return { model: voiceId, speakerId: null, speakerName: null, isMultiSpeaker: false };
437
+ }
438
+
439
+ /**
440
+ * Scan PIPER_VOICES_DIR for installed voice IDs.
441
+ * Expands multi-speaker models into individual speaker entries.
442
+ *
443
+ * @returns {string[]}
444
+ */
445
+ export function scanInstalledVoices() {
446
+ try {
447
+ const files = fs.readdirSync(PIPER_VOICES_DIR);
448
+ const onnxFiles = files
449
+ .filter(f => f.endsWith('.onnx') && !f.endsWith('.onnx.json'));
450
+
451
+ const result = [];
452
+ for (const f of onnxFiles) {
453
+ const voiceId = f.replace(/\.onnx$/, '');
454
+ const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
455
+ try {
456
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
457
+ if (data.num_speakers > 1 && data.speaker_id_map) {
458
+ // Expand multi-speaker model into individual entries
459
+ for (const speakerName of Object.keys(data.speaker_id_map)) {
460
+ result.push(`${voiceId}${MS_SEP}${speakerName}`);
461
+ }
462
+ continue;
463
+ }
464
+ } catch { /* fall through to add as single voice */ }
465
+ result.push(voiceId);
466
+ }
467
+ return result.sort();
468
+ } catch {
469
+ return [];
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Get favorites array from config.
475
+ * @param {object} configService
476
+ * @returns {string[]}
477
+ */
478
+ export function getFavorites(configService) {
479
+ const favs = configService.getConfig().favorites;
480
+ return Array.isArray(favs) ? favs : [];
481
+ }
482
+
483
+ /**
484
+ * Toggle a voice in the favorites list.
485
+ * @param {object} configService
486
+ * @param {string} voiceId
487
+ */
488
+ export function toggleFavorite(configService, voiceId) {
489
+ const favs = getFavorites(configService);
490
+ const idx = favs.indexOf(voiceId);
491
+ if (idx >= 0) {
492
+ favs.splice(idx, 1);
493
+ } else {
494
+ favs.push(voiceId);
495
+ }
496
+ configService.set('favorites', favs);
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Voice metadata cache (lives for the process lifetime)
501
+
502
+ const _metaCache = new Map();
503
+
504
+ /**
505
+ * Load metadata from the .onnx.json file for a voice.
506
+ * Caches results so the file is only read once per voice.
507
+ *
508
+ * @param {string} voiceId
509
+ * @returns {{ displayName: string, gender: string, provider: string }}
510
+ */
511
+ export function getVoiceMeta(voiceId) {
512
+ if (_metaCache.has(voiceId)) return _metaCache.get(voiceId);
513
+
514
+ // Lazy-load the catalog so callers from any tab get uniquified names
515
+ loadCatalog();
516
+
517
+ const ms = parseMultiSpeaker(voiceId);
518
+ if (ms.isMultiSpeaker) {
519
+ if (!ms.speakerName) {
520
+ const result = { displayName: voiceId, gender: '—', provider: `Piper (${ms.model})` };
521
+ _metaCache.set(voiceId, result);
522
+ return result;
523
+ }
524
+ // Prefer the catalog entry (which has the uniquified name + gender)
525
+ const cat = _catalogMap.get(voiceId);
526
+ if (cat) {
527
+ const result = {
528
+ displayName: cat.displayName,
529
+ gender: cat.gender || inferGender(ms.speakerName, null),
530
+ provider: `Piper (${ms.model})`,
531
+ };
532
+ _metaCache.set(voiceId, result);
533
+ return result;
534
+ }
535
+ // Fallback for speakers not in the catalog (e.g. 16Speakers model)
536
+ const displayName = uniquifyVoiceName(ms.speakerName.replace(/_/g, ' '));
537
+ const result = {
538
+ displayName,
539
+ gender: inferGender(ms.speakerName, null),
540
+ provider: `Piper (${ms.model})`,
541
+ };
542
+ _metaCache.set(voiceId, result);
543
+ return result;
544
+ }
545
+
546
+ let dataset = null;
547
+ try {
548
+ const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
549
+ const raw = fs.readFileSync(jsonPath, 'utf8');
550
+ const data = JSON.parse(raw);
551
+ dataset = data.dataset ?? null;
552
+ } catch {}
553
+ const result = {
554
+ displayName: formatVoiceName(voiceId, dataset),
555
+ gender: inferGender(voiceId, dataset),
556
+ provider: 'Piper',
557
+ };
558
+ _metaCache.set(voiceId, result);
559
+ return result;
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * Create the Voices tab component.
566
+ *
567
+ * @param {object} screen - Blessed screen instance (or test stub)
568
+ * @param {object} services
569
+ * @param {import('../../services/config-service.js').ConfigService} services.configService
570
+ * @param {import('../../services/provider-service.js').ProviderService} services.providerService
571
+ * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
572
+ */
573
+ export function createVoicesTab(screen, services) {
574
+ if (IS_TEST) return createTestStub();
575
+
576
+ const { configService, providerService, focusMainTabBar, updateHeaderStatus, languageService } = services;
577
+ const _tl = (key) => languageService ? languageService.t(key) : key;
578
+
579
+ // -------------------------------------------------------------------------
580
+ // Container
581
+
582
+ const box = blessed.box({
583
+ parent: screen,
584
+ top: 5,
585
+ left: 0,
586
+ width: '100%',
587
+ bottom: 2,
588
+ hidden: true,
589
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
590
+ border: { type: 'line' },
591
+ borderStyle: { fg: COLORS.borderFg },
592
+ });
593
+
594
+ // -------------------------------------------------------------------------
595
+ // Section header
596
+
597
+ const voicesSectionHdr = blessed.text({
598
+ parent: box,
599
+ top: 1,
600
+ left: 2,
601
+ content: `{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`,
602
+ tags: true,
603
+ style: { bg: COLORS.contentBg },
604
+ });
605
+
606
+ // Currently selected voice indicator (updated by refreshDisplay)
607
+ const activeVoiceText = blessed.text({
608
+ parent: box,
609
+ top: 1,
610
+ right: 4,
611
+ shrink: true,
612
+ tags: true,
613
+ content: '',
614
+ style: { bg: COLORS.contentBg },
615
+ });
616
+
617
+ // -------------------------------------------------------------------------
618
+ // Search input
619
+
620
+ const searchLabelText = blessed.text({
621
+ parent: box,
622
+ top: 3,
623
+ left: 4,
624
+ content: _tl('searchLabel'),
625
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
626
+ });
627
+
628
+ const searchBox = blessed.textbox({
629
+ parent: box,
630
+ top: 3,
631
+ left: 13,
632
+ width: 40,
633
+ height: 1,
634
+ inputOnFocus: true,
635
+ keys: true,
636
+ style: {
637
+ fg: COLORS.valueFg,
638
+ bg: '#1a3a5c',
639
+ focus: { bg: '#283593' },
640
+ },
641
+ });
642
+
643
+ // -------------------------------------------------------------------------
644
+ // Column header row (sits between search and voice list border)
645
+
646
+ const colHeaderText = blessed.text({
647
+ parent: box,
648
+ top: 4,
649
+ left: 6,
650
+ content: `{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}{/#00897b-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {#00897b-fg}${_tl('voicesColProvider')}{/#00897b-fg}`,
651
+ tags: true,
652
+ style: { bg: COLORS.contentBg },
653
+ });
654
+
655
+ // -------------------------------------------------------------------------
656
+ // Voice list
657
+
658
+ const voiceList = blessed.list({
659
+ parent: box,
660
+ top: 5,
661
+ left: 2,
662
+ width: '96%',
663
+ height: '50%',
664
+ keys: true,
665
+ vi: true,
666
+ mouse: true,
667
+ tags: true,
668
+ border: { type: 'line' },
669
+ scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
670
+ style: {
671
+ fg: COLORS.labelFg,
672
+ bg: COLORS.contentBg,
673
+ border: { fg: COLORS.borderFg },
674
+ selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
675
+ item: { fg: COLORS.labelFg },
676
+ },
677
+ });
678
+
679
+ // -------------------------------------------------------------------------
680
+ // Info panel
681
+
682
+ const voiceInfoHdr = blessed.text({
683
+ parent: box,
684
+ top: '60%',
685
+ left: 2,
686
+ content: `{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`,
687
+ tags: true,
688
+ style: { bg: COLORS.contentBg },
689
+ });
690
+
691
+ const infoLine = blessed.text({
692
+ parent: box,
693
+ top: '65%',
694
+ left: 2,
695
+ tags: true,
696
+ content: '',
697
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
698
+ });
699
+
700
+ const previewLine = blessed.text({
701
+ parent: box,
702
+ top: '70%',
703
+ left: 2,
704
+ tags: true,
705
+ content: '',
706
+ style: { fg: COLORS.activeFg, bg: COLORS.contentBg },
707
+ });
708
+
709
+ // -------------------------------------------------------------------------
710
+ // Hint text shown in previewLine when the list has focus and nothing is playing
711
+ const HINT_TEXT = '{white-fg}[Space] preview [Enter] select as default voice{/white-fg}';
712
+ let _listFocused = false;
713
+
714
+ // Inline selection hint appended to the currently highlighted voice row.
715
+ // _hintBase stores the item's clean content (no hint, no █) — no sentinel needed.
716
+ // Use getter functions so hints re-translate when language changes.
717
+ const _rowHintInstalled = () => ` {bright-black-fg}${_tl('voicesRowHintInstalled')}{/bright-black-fg}`;
718
+ const _rowHintUninstalled = () => ` {bright-yellow-fg}${_tl('voicesRowHintUninstalled')}{/bright-yellow-fg}`;
719
+ let _hintIdx = -1;
720
+ let _hintBase = ''; // content of items[_hintIdx] before hint was appended
721
+ let _refreshing = false;
722
+
723
+ function _getRowHint(idx) {
724
+ const voices = _getFilteredVoices();
725
+ const voiceId = voices[idx];
726
+ if (!voiceId) return _rowHintInstalled();
727
+ return _isInstalled(voiceId) ? _rowHintInstalled() : _rowHintUninstalled();
728
+ }
729
+
730
+ // Translate gender value at display time
731
+ function _tGender(g) {
732
+ if (g === 'Female') return _tl('genderFemale');
733
+ if (g === 'Male') return _tl('genderMale');
734
+ return g;
735
+ }
736
+
737
+ // Decoration helpers — strip blink cursor and (optionally) hint text from
738
+ // a list item's content. We use a precise approach instead of a generic
739
+ // regex over `bright-black-fg` blocks because uninstalled rows wrap their
740
+ // PROVIDER column in `bright-black-fg` too — a generic strip would erase it.
741
+ //
742
+ // _stripBlink(): only removes ` █` markers (safe to call on any row).
743
+ // _stripHint(): removes hint by anchoring to the exact hint text from
744
+ // _getRowHint(idx). Only safe on rows we know are hinted.
745
+ const _BLINK_RE = / █/g;
746
+ function _stripBlink(raw) {
747
+ return (raw ?? '').replace(_BLINK_RE, '');
748
+ }
749
+ function _stripHint(raw, idx) {
750
+ const hint = _getRowHint(idx);
751
+ if (!hint) return raw;
752
+ // The hint is appended to the end (possibly followed by blink); remove it.
753
+ const noBlink = (raw ?? '').replace(_BLINK_RE, '');
754
+ if (noBlink.endsWith(hint)) return noBlink.slice(0, -hint.length);
755
+ return noBlink;
756
+ }
757
+ function _stripDecorations(raw, idx) {
758
+ return _stripHint(_stripBlink(raw), idx);
759
+ }
760
+
761
+ function _updateHint(idx) {
762
+ const items = voiceList.items;
763
+ const _pad = ' '.repeat(60);
764
+ // Restore previously hinted row to its clean base
765
+ if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
766
+ items[_hintIdx].setContent(_hintBase + _pad);
767
+ }
768
+ if (idx >= 0 && items[idx]) {
769
+ // The row may already have a hint (from a prior _updateHint cycle)
770
+ // and/or a blink — strip both before capturing the clean base.
771
+ _hintBase = _stripDecorations(items[idx].content, idx);
772
+ items[idx].setContent(_hintBase + _getRowHint(idx));
773
+ } else {
774
+ _hintBase = '';
775
+ }
776
+ _hintIdx = idx;
777
+ }
778
+
779
+ // -------------------------------------------------------------------------
780
+ // Playback state
781
+
782
+ let _playingProcess = null;
783
+ let _playingVoiceId = null;
784
+ let _downloadProcess = null;
785
+
786
+ // Kill the entire process group so child audio players (piper, aplay, play) all die
787
+ function _killPlayingProcess() {
788
+ if (_playingProcess) {
789
+ try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
790
+ _playingProcess = null;
791
+ }
792
+ }
793
+
794
+ const _spawnEnv = buildAudioEnv();
795
+
796
+ /**
797
+ * Preview a voice by synthesizing a sample phrase with piper, then playing the wav.
798
+ * Second call with the same voice stops playback (toggle).
799
+ */
800
+ function _previewVoice(voiceId) {
801
+ // Toggle: second press stops
802
+ if (_playingVoiceId === voiceId) {
803
+ _killPlayingProcess();
804
+ _playingVoiceId = null;
805
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
806
+ screen.render();
807
+ return;
808
+ }
809
+
810
+ // Kill any current preview first
811
+ _killPlayingProcess();
812
+ _playingVoiceId = null;
813
+
814
+ // Resolve model path (may be multi-speaker)
815
+ const ms = parseMultiSpeaker(voiceId);
816
+ const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
817
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
818
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
819
+ return;
820
+ }
821
+
822
+ const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${Date.now()}.wav`);
823
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
824
+
825
+ // Synthesize: spawn piper; on Windows use the exe path directly
826
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
827
+ let piperBin = 'piper';
828
+ if (isWindows) {
829
+ const localAppData = process.env.LOCALAPPDATA ||
830
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
831
+ if (localAppData) {
832
+ const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
833
+ if (fs.existsSync(exePath)) piperBin = exePath;
834
+ }
835
+ }
836
+ const piperArgs = ['--model', voicePath, '--output_file', tempWav];
837
+ if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
838
+ // On Windows, avoid detached:true which opens a visible console window
839
+ const piper = spawn(piperBin, piperArgs, {
840
+ stdio: ['pipe', 'ignore', 'ignore'],
841
+ detached: !isWindows,
842
+ windowsHide: true,
843
+ env: _spawnEnv,
844
+ });
845
+ piper.stdin.write(phrase + '\n');
846
+ piper.stdin.end();
847
+
848
+ _playingProcess = piper;
849
+ _playingVoiceId = voiceId;
850
+
851
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Synthesizing: ${voiceId}…{/${COLORS.activeFg}-fg}`);
852
+ screen.render();
853
+
854
+ piper.on('exit', (code) => {
855
+ if (_playingVoiceId !== voiceId) {
856
+ // User stopped before synthesis finished
857
+ try { fs.unlinkSync(tempWav); } catch {}
858
+ return;
859
+ }
860
+
861
+ if (code !== 0) {
862
+ _playingVoiceId = null;
863
+ _playingProcess = null;
864
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Preview failed (piper error — is piper installed?){/${COLORS.activeFg}-fg}`);
865
+ screen.render();
866
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
867
+ return;
868
+ }
869
+
870
+ // Play the synthesized wav in its own process group so we can kill it
871
+ const _wavP = detectWavPlayer(_spawnEnv);
872
+ if (!_wavP) {
873
+ _playingVoiceId = null;
874
+ _playingProcess = null;
875
+ previewLine.setContent(`{red-fg}No audio player found. Install ffmpeg.{/red-fg}`);
876
+ screen.render();
877
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
878
+ try { fs.unlinkSync(tempWav); } catch {}
879
+ return;
880
+ }
881
+ const playProc = spawn(_wavP.bin, _wavP.args(tempWav), {
882
+ stdio: 'ignore',
883
+ detached: !isWindows,
884
+ windowsHide: true,
885
+ env: _spawnEnv,
886
+ });
887
+ // Race note: _playingVoiceId could change between piper exit and here
888
+ // if the user stops playback. Re-check before assigning to avoid orphan.
889
+ if (_playingVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
890
+ _playingProcess = playProc;
891
+
892
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Playing: ${voiceId} (Enter/Space to stop){/${COLORS.activeFg}-fg}`);
893
+ screen.render();
894
+
895
+ playProc.on('exit', () => {
896
+ if (_playingVoiceId === voiceId) {
897
+ _playingVoiceId = null;
898
+ _playingProcess = null;
899
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
900
+ refreshDisplay(); // clears (playing) label
901
+ }
902
+ try { fs.unlinkSync(tempWav); } catch {}
903
+ });
904
+
905
+ playProc.on('error', () => {
906
+ _playingVoiceId = null;
907
+ _playingProcess = null;
908
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
909
+ try { fs.unlinkSync(tempWav); } catch {}
910
+ });
911
+ });
912
+
913
+ piper.on('error', () => {
914
+ _playingVoiceId = null;
915
+ _playingProcess = null;
916
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Cannot find piper — install with: pipx install piper-tts{/${COLORS.activeFg}-fg}`);
917
+ screen.render();
918
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
919
+ try { fs.unlinkSync(tempWav); } catch {}
920
+ });
921
+ }
922
+
923
+ // -------------------------------------------------------------------------
924
+ // Voice activation (handles multi-speaker model/speaker-id files)
925
+
926
+ function _activateVoice(voiceId) {
927
+ const ms = parseMultiSpeaker(voiceId);
928
+ const claudeDir = path.resolve(process.cwd(), '.claude');
929
+ try { fs.mkdirSync(claudeDir, { recursive: true }); } catch {}
930
+ // Always write tts-voice.txt so shell scripts pick up the voice on reload
931
+ try {
932
+ fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), voiceId, 'utf8');
933
+ } catch { /* non-fatal */ }
934
+ if (ms.isMultiSpeaker) {
935
+ // Store full MS ID (e.g., "16Speakers::Kristin_Hughes") so list matching works
936
+ providerService.setActiveVoice(voiceId);
937
+ try {
938
+ fs.writeFileSync(path.join(claudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
939
+ fs.writeFileSync(path.join(claudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
940
+ } catch { /* non-fatal */ }
941
+ } else {
942
+ providerService.setActiveVoice(voiceId);
943
+ // Clear multi-speaker files if switching to a single-speaker voice
944
+ try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
945
+ try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
946
+ }
947
+ }
948
+
949
+ // -------------------------------------------------------------------------
950
+ // Buttons
951
+
952
+ function _createBtn(label, onClick) {
953
+ const btn = blessed.button({
954
+ parent: box,
955
+ content: label,
956
+ mouse: true,
957
+ keys: true,
958
+ shrink: true,
959
+ padding: { left: 1, right: 1 },
960
+ style: {
961
+ bg: COLORS.btnDefault,
962
+ fg: 'white',
963
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
964
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
965
+ },
966
+ });
967
+ btn.on('focus', () => {
968
+ btn.style.bg = COLORS.btnFocus;
969
+ btn.style.fg = COLORS.btnFocusFg;
970
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
971
+ btn.setContent(`►${raw}◄`);
972
+ screen.render();
973
+ });
974
+ btn.on('blur', () => {
975
+ btn.style.bg = COLORS.btnDefault;
976
+ btn.style.fg = 'white';
977
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
978
+ btn.setContent(raw);
979
+ screen.render();
980
+ });
981
+ btn.key(['enter', 'space'], () => {
982
+ btn.style.bg = COLORS.btnPress;
983
+ screen.render();
984
+ setTimeout(() => {
985
+ btn.style.bg = COLORS.btnDefault;
986
+ screen.render();
987
+ onClick();
988
+ }, 150);
989
+ });
990
+ btn.on('click', () => btn.press());
991
+ btn.on('mouseover', () => btn.focus());
992
+ return btn;
993
+ }
994
+
995
+ const switchBtn = _createBtn(_tl('voicesSwitchBtn'), () => {
996
+ const voices = _getFilteredVoices();
997
+ const selected = voices[voiceList.selected];
998
+ if (selected) {
999
+ _activateVoice(selected);
1000
+ refreshDisplay();
1001
+ }
1002
+ });
1003
+ switchBtn.bottom = 4;
1004
+ switchBtn.left = 4;
1005
+
1006
+ const favoriteBtn = _createBtn(_tl('voicesFavoriteBtn'), () => {
1007
+ const voices = _getFilteredVoices();
1008
+ const selected = voices[voiceList.selected];
1009
+ if (selected) {
1010
+ toggleFavorite(configService, selected);
1011
+ refreshDisplay();
1012
+ }
1013
+ });
1014
+ favoriteBtn.bottom = 4;
1015
+ favoriteBtn.left = 22;
1016
+
1017
+ const installBtn = _createBtn(_tl('voicesDownloadBtn'), () => {
1018
+ const voices = _getFilteredVoices();
1019
+ const selected = voices[voiceList.selected];
1020
+ if (!selected) return;
1021
+ if (_isInstalled(selected)) {
1022
+ const notice = blessed.text({
1023
+ parent: box,
1024
+ top: 'center',
1025
+ left: 'center',
1026
+ content: 'Voice already installed. Scroll down to find uninstalled voices (greyed out).',
1027
+ tags: true,
1028
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
1029
+ });
1030
+ screen.render();
1031
+ setTimeout(() => { notice.destroy(); screen.render(); }, 3000);
1032
+ } else {
1033
+ _openDownloadModal(selected);
1034
+ }
1035
+ });
1036
+ installBtn.bottom = 4;
1037
+ installBtn.left = 38;
1038
+
1039
+ // -------------------------------------------------------------------------
1040
+ // "Voice Changed" notice — auto-dismisses after 2 s
1041
+
1042
+ function _showVoiceChangedNotice(displayName) {
1043
+ const notice = blessed.box({
1044
+ parent: screen,
1045
+ top: 'center',
1046
+ left: 'center',
1047
+ width: 44,
1048
+ height: 5,
1049
+ border: { type: 'line' },
1050
+ tags: true,
1051
+ label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
1052
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
1053
+ content: `\n {${COLORS.activeFg}-fg}✓ Voice changed: ${displayName}{/${COLORS.activeFg}-fg}`,
1054
+ });
1055
+ notice.setFront();
1056
+ screen.render();
1057
+ setTimeout(() => { notice.destroy(); screen.render(); }, 2000);
1058
+ }
1059
+
1060
+ // -------------------------------------------------------------------------
1061
+ // Select-voice confirmation modal
1062
+
1063
+ function _activateVoiceGlobal(voiceId) {
1064
+ // Save voice globally (all projects)
1065
+ const ms = parseMultiSpeaker(voiceId);
1066
+ const globalClaudeDir = path.resolve(os.homedir(), '.claude');
1067
+ // Verify ownership before writing to global config dir
1068
+ try {
1069
+ const stat = fs.statSync(globalClaudeDir);
1070
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
1071
+ } catch {}
1072
+ if (ms.isMultiSpeaker) {
1073
+ configService.setGlobal('voice', voiceId);
1074
+ try {
1075
+ fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
1076
+ fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
1077
+ } catch { /* non-fatal */ }
1078
+ } else {
1079
+ configService.setGlobal('voice', voiceId);
1080
+ try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
1081
+ try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
1082
+ }
1083
+ // Also write global tts-voice.txt for shell scripts
1084
+ try { fs.writeFileSync(path.join(globalClaudeDir, 'tts-voice.txt'), ms.isMultiSpeaker ? voiceId : voiceId, 'utf8'); } catch { /* ok */ }
1085
+ }
1086
+
1087
+ function _openSelectVoiceModal(voiceId) {
1088
+ const { displayName } = getVoiceMeta(voiceId);
1089
+
1090
+ const modal = blessed.box({
1091
+ parent: screen,
1092
+ top: 'center',
1093
+ left: 'center',
1094
+ width: 72,
1095
+ height: 8,
1096
+ border: { type: 'line' },
1097
+ tags: true,
1098
+ label: ` {${COLORS.activeFg}-fg}Set Default Voice{/${COLORS.activeFg}-fg} `,
1099
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
1100
+ });
1101
+
1102
+ blessed.text({
1103
+ parent: modal,
1104
+ top: 1,
1105
+ left: 2,
1106
+ right: 2,
1107
+ content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your default voice?`,
1108
+ tags: true,
1109
+ style: { bg: COLORS.contentBg },
1110
+ });
1111
+
1112
+ // Status line shows playback state while modal is open
1113
+ const modalStatus = blessed.text({
1114
+ parent: modal,
1115
+ top: 3,
1116
+ left: 2,
1117
+ right: 2,
1118
+ tags: true,
1119
+ content: '{white-fg}Press Preview to audition this voice{/white-fg}',
1120
+ style: { bg: COLORS.contentBg },
1121
+ });
1122
+
1123
+ function _close() {
1124
+ _killPlayingProcess();
1125
+ _playingVoiceId = null;
1126
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
1127
+ modal.destroy();
1128
+ voiceList.focus();
1129
+ screen.render();
1130
+ }
1131
+
1132
+ // Note: blessed's destroy() does not remove key listeners from child buttons,
1133
+ // so modal button handlers may leak. This is a known blessed limitation.
1134
+ function _makeBtn(label, bg, left, top, onClick) {
1135
+ const btn = blessed.button({
1136
+ parent: modal,
1137
+ content: label,
1138
+ top,
1139
+ left,
1140
+ mouse: true,
1141
+ keys: true,
1142
+ shrink: true,
1143
+ padding: { left: 1, right: 1 },
1144
+ style: {
1145
+ bg,
1146
+ fg: 'white',
1147
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1148
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1149
+ },
1150
+ });
1151
+ btn.key(['enter', 'space'], () => { _close(); onClick(); });
1152
+ btn.on('click', () => btn.press());
1153
+ return btn;
1154
+ }
1155
+
1156
+ const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
1157
+ _activateVoice(voiceId);
1158
+ refreshDisplay();
1159
+ _showVoiceChangedNotice(displayName);
1160
+ });
1161
+ const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
1162
+ _activateVoice(voiceId);
1163
+ _activateVoiceGlobal(voiceId);
1164
+ refreshDisplay();
1165
+ _showVoiceChangedNotice(displayName);
1166
+ });
1167
+ const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
1168
+
1169
+ // Preview button — does NOT close the modal; plays/stops the voice inline
1170
+ const previewBtn = blessed.button({
1171
+ parent: modal,
1172
+ content: 'Preview',
1173
+ top: 5,
1174
+ left: 58,
1175
+ mouse: true,
1176
+ keys: true,
1177
+ shrink: true,
1178
+ padding: { left: 1, right: 1 },
1179
+ style: {
1180
+ bg: '#e65100',
1181
+ fg: 'white',
1182
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1183
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1184
+ },
1185
+ });
1186
+ previewBtn.key(['enter', 'space'], () => {
1187
+ const isPlaying = _playingVoiceId === voiceId;
1188
+ _previewVoice(voiceId);
1189
+ modalStatus.setContent(isPlaying
1190
+ ? '{white-fg}Stopped.{/white-fg}'
1191
+ : `{${COLORS.activeFg}-fg}♪ Playing: ${displayName}{/${COLORS.activeFg}-fg}`
1192
+ );
1193
+ screen.render();
1194
+ });
1195
+ previewBtn.on('click', () => previewBtn.press());
1196
+
1197
+ // Tab/arrow navigation: SaveLocal → SaveGlobal → Cancel → Preview → SaveLocal
1198
+ okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
1199
+ okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1200
+ cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
1201
+ previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
1202
+ previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1203
+ cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
1204
+ okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
1205
+ okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
1206
+
1207
+ modal.key(['escape', 'q'], _close);
1208
+
1209
+ modal.setFront();
1210
+ okLocalBtn.focus();
1211
+ screen.render();
1212
+ }
1213
+
1214
+ // -------------------------------------------------------------------------
1215
+ // Download modal for uninstalled catalog voices
1216
+
1217
+ function _openDownloadModal(voiceId) {
1218
+ const cat = _catalogMap.get(voiceId);
1219
+ const displayName = cat?.displayName ?? voiceId;
1220
+ const modelToDownload = cat?.type === 'libritts' ? 'en_US-libritts-high' : (cat?.model ?? voiceId);
1221
+ const isLibriTTS = cat?.type === 'libritts';
1222
+
1223
+ const modal = blessed.box({
1224
+ parent: screen,
1225
+ top: 'center',
1226
+ left: 'center',
1227
+ width: 64,
1228
+ height: 10,
1229
+ border: { type: 'line' },
1230
+ tags: true,
1231
+ label: ` {${COLORS.activeFg}-fg}Download Voice{/${COLORS.activeFg}-fg} `,
1232
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
1233
+ });
1234
+
1235
+ const msgLine = blessed.text({
1236
+ parent: modal,
1237
+ top: 1,
1238
+ left: 2,
1239
+ right: 2,
1240
+ tags: true,
1241
+ content: `Download {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg}?\n\n` +
1242
+ `Model: {${COLORS.activeFg}-fg}${modelToDownload}{/${COLORS.activeFg}-fg}` +
1243
+ (isLibriTTS ? ` (~57 MB — unlocks all 904 LibriTTS speakers)` : ` (~25 MB)`),
1244
+ style: { bg: COLORS.contentBg },
1245
+ });
1246
+
1247
+ const statusLine = blessed.text({
1248
+ parent: modal,
1249
+ top: 5,
1250
+ left: 2,
1251
+ right: 2,
1252
+ tags: true,
1253
+ content: '',
1254
+ style: { bg: COLORS.contentBg },
1255
+ });
1256
+
1257
+ let _downloading = false;
1258
+
1259
+ function _close() {
1260
+ modal.destroy();
1261
+ voiceList.focus();
1262
+ screen.render();
1263
+ }
1264
+
1265
+ function _startDownload() {
1266
+ if (_downloading) return;
1267
+ _downloading = true;
1268
+
1269
+ // Animated spinner
1270
+ const spinFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1271
+ let spinIdx = 0;
1272
+ let dlPhase = 'Downloading model';
1273
+ const progressBar = (pct) => {
1274
+ const filled = Math.round(pct / 5);
1275
+ const empty = 20 - filled;
1276
+ return '█'.repeat(filled) + '░'.repeat(empty);
1277
+ };
1278
+
1279
+ const spinTimer = setInterval(() => {
1280
+ spinIdx = (spinIdx + 1) % spinFrames.length;
1281
+ const frame = spinFrames[spinIdx];
1282
+ statusLine.setContent(
1283
+ `{${COLORS.activeFg}-fg}${frame} ${dlPhase}… ${modelToDownload}{/${COLORS.activeFg}-fg}`
1284
+ );
1285
+ screen.render();
1286
+ }, 100);
1287
+
1288
+ // Download voice model — use PowerShell on Windows, bash on Unix
1289
+ const packageRoot = path.resolve(__dirname, '..', '..', '..');
1290
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1291
+ let dlProc;
1292
+
1293
+ if (isWindows) {
1294
+ const piperVoicesDir = resolvePiperVoicesDir();
1295
+ const hfBase = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
1296
+ const match = modelToDownload.match(/^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$/);
1297
+ let modelUrl, configUrl;
1298
+ if (match) {
1299
+ const [, lang, region, speaker, quality] = match;
1300
+ const hfPath = `${lang}/${lang}_${region}/${speaker}/${quality}`;
1301
+ modelUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx`;
1302
+ configUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx.json`;
1303
+ } else {
1304
+ const customBase = 'https://huggingface.co/agentvibes/piper-custom-voices/resolve/main';
1305
+ modelUrl = `${customBase}/${modelToDownload}.onnx`;
1306
+ configUrl = `${customBase}/${modelToDownload}.onnx.json`;
1307
+ }
1308
+ const modelFile = path.join(piperVoicesDir, `${modelToDownload}.onnx`);
1309
+ const configFile = path.join(piperVoicesDir, `${modelToDownload}.onnx.json`);
1310
+ // PowerShell script with progress reporting
1311
+ const psScript = `
1312
+ $ErrorActionPreference = 'Stop'
1313
+ $ProgressPreference = 'SilentlyContinue'
1314
+ $voicesDir = '${piperVoicesDir.replace(/'/g, "''")}'
1315
+ if (-not (Test-Path $voicesDir)) { New-Item -ItemType Directory -Path $voicesDir -Force | Out-Null }
1316
+ Write-Output 'PHASE:model'
1317
+ Invoke-WebRequest -Uri '${modelUrl}' -OutFile '${modelFile.replace(/'/g, "''")}' -ErrorAction Stop
1318
+ Write-Output 'PHASE:config'
1319
+ Invoke-WebRequest -Uri '${configUrl}' -OutFile '${configFile.replace(/'/g, "''")}' -ErrorAction Stop
1320
+ Write-Output 'PHASE:done'
1321
+ `;
1322
+ dlProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], {
1323
+ stdio: ['ignore', 'pipe', 'pipe'],
1324
+ env: _spawnEnv,
1325
+ });
1326
+ } else {
1327
+ const managerScript = path.resolve(packageRoot, '.claude', 'hooks', 'piper-voice-manager.sh');
1328
+ dlProc = spawn('bash', ['-c', 'source "$1" && download_voice "$2"', '_', managerScript, modelToDownload], {
1329
+ stdio: ['ignore', 'pipe', 'pipe'],
1330
+ env: _spawnEnv,
1331
+ });
1332
+ }
1333
+ _downloadProcess = dlProc;
1334
+
1335
+ let output = '';
1336
+ dlProc.stdout.on('data', (d) => {
1337
+ const chunk = d.toString();
1338
+ output += chunk;
1339
+ // Update phase based on progress markers
1340
+ if (chunk.includes('PHASE:config') || chunk.includes('config file')) {
1341
+ dlPhase = 'Downloading config';
1342
+ } else if (chunk.includes('PHASE:done') || chunk.includes('successfully')) {
1343
+ dlPhase = 'Finishing up';
1344
+ }
1345
+ });
1346
+ dlProc.stderr.on('data', (d) => { output += d.toString(); });
1347
+
1348
+ dlProc.on('exit', (code) => {
1349
+ clearInterval(spinTimer);
1350
+ _downloading = false;
1351
+ _downloadProcess = null;
1352
+ if (code === 0) {
1353
+ if (isLibriTTS) {
1354
+ patchLibriTTSSpeakerNames();
1355
+ _metaCache.clear();
1356
+ }
1357
+ statusLine.setContent(`{green-fg}✓ Downloaded successfully!{/green-fg}`);
1358
+ screen.render();
1359
+ setTimeout(() => {
1360
+ _close();
1361
+ refreshDisplay();
1362
+ }, 1500);
1363
+ } else {
1364
+ statusLine.setContent(`{red-fg}✗ Download failed. ${output.slice(-80).trim()}{/red-fg}`);
1365
+ screen.render();
1366
+ }
1367
+ });
1368
+
1369
+ dlProc.on('error', () => {
1370
+ clearInterval(spinTimer);
1371
+ _downloading = false;
1372
+ _downloadProcess = null;
1373
+ statusLine.setContent(`{red-fg}✗ Could not run download script{/red-fg}`);
1374
+ screen.render();
1375
+ });
1376
+ }
1377
+
1378
+ function _makeBtn(label, bg, left, onClick) {
1379
+ const btn = blessed.button({
1380
+ parent: modal,
1381
+ content: label,
1382
+ top: 7,
1383
+ left,
1384
+ mouse: true,
1385
+ keys: true,
1386
+ shrink: true,
1387
+ padding: { left: 1, right: 1 },
1388
+ style: {
1389
+ bg,
1390
+ fg: 'white',
1391
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1392
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1393
+ },
1394
+ });
1395
+ btn.key(['enter', 'space'], onClick);
1396
+ btn.on('click', () => btn.press());
1397
+ return btn;
1398
+ }
1399
+
1400
+ const dlBtn = _makeBtn('Download', COLORS.btnDefault, 2, _startDownload);
1401
+ const cancelBtn = _makeBtn('Cancel', '#546e7a', 16, _close);
1402
+
1403
+ dlBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1404
+ cancelBtn.key(['tab', 'right'], () => { dlBtn.focus(); screen.render(); });
1405
+ dlBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1406
+ cancelBtn.key(['left'], () => { dlBtn.focus(); screen.render(); });
1407
+
1408
+ modal.key(['escape', 'q'], () => { if (!_downloading) _close(); });
1409
+
1410
+ modal.setFront();
1411
+ dlBtn.focus();
1412
+ screen.render();
1413
+ }
1414
+
1415
+ // -------------------------------------------------------------------------
1416
+ // State
1417
+
1418
+ let _allVoices = []; // voice IDs (installed first, then catalog-only)
1419
+ let _installedSet = new Set(); // which IDs are locally installed
1420
+ let _filterText = '';
1421
+
1422
+ function _getFilteredVoices() {
1423
+ if (!_filterText) return _allVoices;
1424
+ const f = _filterText.toLowerCase();
1425
+ return _allVoices.filter(v => {
1426
+ if (v.toLowerCase().includes(f)) return true;
1427
+ // Also search by catalog display name
1428
+ const cat = _catalogMap.get(v);
1429
+ if (cat && cat.displayName.toLowerCase().includes(f)) return true;
1430
+ return false;
1431
+ });
1432
+ }
1433
+
1434
+ function _isInstalled(voiceId) {
1435
+ return _installedSet.has(voiceId);
1436
+ }
1437
+
1438
+ function _buildListItems(voices, active, favorites) {
1439
+ return voices.map(v => {
1440
+ const installed = _isInstalled(v);
1441
+ const isFav = favorites.includes(v);
1442
+ const isActive = v === active;
1443
+ const isPrev = v === _playingVoiceId;
1444
+ const star = isFav ? '★' : ' ';
1445
+ const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
1446
+
1447
+ let displayName, gender, provider;
1448
+ if (installed) {
1449
+ const meta = getVoiceMeta(v);
1450
+ displayName = meta.displayName;
1451
+ gender = meta.gender;
1452
+ provider = meta.provider;
1453
+ } else {
1454
+ // Catalog-only voice — use catalog metadata
1455
+ const cat = _catalogMap.get(v);
1456
+ displayName = cat?.displayName ?? v;
1457
+ gender = cat?.gender ?? '—';
1458
+ provider = cat?.type === 'libritts' ? 'Piper (LibriTTS)' : 'Piper';
1459
+ }
1460
+
1461
+ const name = displayName.length > COL_NAME_W
1462
+ ? displayName.slice(0, COL_NAME_W - 1) + '…'
1463
+ : displayName.padEnd(COL_NAME_W);
1464
+
1465
+ // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1466
+ const gIcon = genderIconTag(gender);
1467
+ if (!installed) {
1468
+ // Greyed-out row for uninstalled catalog voices — close grey wrap around the icon so its colors show
1469
+ return `{bright-black-fg} ${star} ${name}{/bright-black-fg}${gIcon} {bright-black-fg}${provider}{/bright-black-fg}`;
1470
+ }
1471
+ return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}{/${COLORS.labelFg}-fg}${gIcon} {${COLORS.labelFg}-fg}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1472
+ });
1473
+ }
1474
+
1475
+ // Build a tagged info string with yellow labels for the info panel
1476
+ function _formatInfoTagged(voiceId) {
1477
+ const Y = COLORS.valueFg; // #ffd700 yellow
1478
+
1479
+ // Uninstalled catalog voice — show download prompt
1480
+ if (!_isInstalled(voiceId)) {
1481
+ const cat = _catalogMap.get(voiceId);
1482
+ const name = cat?.displayName ?? voiceId;
1483
+ const gender = cat?.gender ?? '—';
1484
+ const model = cat?.type === 'libritts' ? 'LibriTTS High (multi-speaker)' : (cat?.model ?? voiceId);
1485
+ return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1486
+ `{${Y}-fg}${_tl('voiceInfoGender')}{/${Y}-fg} ${_tGender(gender)} ` +
1487
+ `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${model} ` +
1488
+ `{bright-yellow-fg}${_tl('voiceInfoDownload')}{/bright-yellow-fg}`;
1489
+ }
1490
+
1491
+ const ms = parseMultiSpeaker(voiceId);
1492
+ if (ms.isMultiSpeaker) {
1493
+ const name = ms.speakerName.replace(/_/g, ' ');
1494
+ return `{${Y}-fg}${_tl('voiceInfoSpeaker')}{/${Y}-fg} ${name} ` +
1495
+ `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${ms.model} ` +
1496
+ `{${Y}-fg}${_tl('voiceInfoSpeakerId')}{/${Y}-fg} ${ms.speakerId ?? '?'} ` +
1497
+ `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1498
+ }
1499
+ const { lang, name, quality } = parseVoiceId(voiceId);
1500
+ if (lang === 'unknown') {
1501
+ return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${voiceId} {${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1502
+ }
1503
+ return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1504
+ `{${Y}-fg}${_tl('voiceInfoLanguage')}{/${Y}-fg} ${lang} ` +
1505
+ `{${Y}-fg}${_tl('voiceInfoQuality')}{/${Y}-fg} ${quality} ` +
1506
+ `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper ` +
1507
+ `{${Y}-fg}${_tl('voiceInfoId')}{/${Y}-fg} ${voiceId}`;
1508
+ }
1509
+
1510
+ function refreshDisplay() {
1511
+ _refreshing = true;
1512
+ const savedIdx = voiceList.selected ?? 0;
1513
+ const savedScroll = voiceList.childBase ?? 0;
1514
+
1515
+ // Load catalog on first refresh and patch speaker names (once)
1516
+ if (!_catalogLoaded) {
1517
+ loadCatalog();
1518
+ patchLibriTTSSpeakerNames();
1519
+ _metaCache.clear();
1520
+ }
1521
+
1522
+ // Installed voices (from local disk)
1523
+ const installed = scanInstalledVoices();
1524
+ _installedSet = new Set(installed);
1525
+
1526
+ // Merge: installed voices first, then uninstalled catalog voices
1527
+ const catalogOnly = _catalogEntries
1528
+ .filter(c => !_installedSet.has(c.voiceId))
1529
+ .map(c => c.voiceId);
1530
+ _allVoices = [...installed, ...catalogOnly];
1531
+
1532
+ const active = providerService.getActiveVoiceId();
1533
+ const favorites = getFavorites(configService);
1534
+ const filtered = _getFilteredVoices();
1535
+ const items = _buildListItems(filtered, active, favorites);
1536
+
1537
+ voiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
1538
+ const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
1539
+ voiceList.select(Math.min(savedIdx, maxIdx));
1540
+ voiceList.childBase = Math.min(savedScroll, Math.max(0, (items.length > 0 ? items.length : 1) - (voiceList.height - 2)));
1541
+
1542
+ // Re-apply inline hint if list is focused
1543
+ if (_listFocused) {
1544
+ _hintIdx = -1;
1545
+ _hintBase = '';
1546
+ _updateHint(voiceList.selected ?? 0);
1547
+ }
1548
+
1549
+ // Update info panel for currently selected item
1550
+ const sel = filtered[voiceList.selected] ?? active ?? '';
1551
+ infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1552
+
1553
+ // Update "Currently Selected" header
1554
+ if (active) {
1555
+ const _msA = parseMultiSpeaker(active);
1556
+ const activeName = _msA.isMultiSpeaker ? _msA.speakerName : active;
1557
+ const meta = _isInstalled(active) ? getVoiceMeta(active) : null;
1558
+ const displayName = meta?.displayName ?? activeName;
1559
+ activeVoiceText.setContent(`{green-fg}✓ ${displayName}{/green-fg}`);
1560
+ } else {
1561
+ activeVoiceText.setContent(`{bright-black-fg}No voice selected{/bright-black-fg}`);
1562
+ }
1563
+
1564
+ _refreshing = false;
1565
+ if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
1566
+ screen.render();
1567
+ }
1568
+
1569
+ // -------------------------------------------------------------------------
1570
+ // Search box interaction
1571
+
1572
+ searchBox.on('keypress', () => {
1573
+ // Update filter after keystroke
1574
+ setTimeout(() => {
1575
+ _filterText = searchBox.getValue().trim();
1576
+ refreshDisplay();
1577
+ }, 0);
1578
+ });
1579
+
1580
+ // Pressing Escape in search returns focus to voiceList
1581
+ searchBox.key(['escape'], () => {
1582
+ voiceList.focus();
1583
+ screen.render();
1584
+ });
1585
+
1586
+ // Page Up / Page Down — jump ~20 items at a time
1587
+ const PAGE_SIZE = 20;
1588
+ voiceList.key(['pagedown'], () => {
1589
+ const voices = _getFilteredVoices();
1590
+ const maxIdx = Math.max(0, voices.length - 1);
1591
+ voiceList.select(Math.min((voiceList.selected ?? 0) + PAGE_SIZE, maxIdx));
1592
+ screen.render();
1593
+ });
1594
+ voiceList.key(['pageup'], () => {
1595
+ voiceList.select(Math.max((voiceList.selected ?? 0) - PAGE_SIZE, 0));
1596
+ screen.render();
1597
+ });
1598
+
1599
+ // Pressing '/' in voiceList focuses search box
1600
+ voiceList.key(['/'], () => {
1601
+ searchBox.clearValue();
1602
+ searchBox.focus();
1603
+ screen.render();
1604
+ });
1605
+
1606
+ // ↑ at the top of the list → jump to main header tab bar
1607
+ // [↑] at top of list → jump to main header tab bar (second press)
1608
+ let _prevVoiceSel = -1;
1609
+ voiceList.key(['up'], () => {
1610
+ const cur = voiceList.selected ?? 0;
1611
+ if (cur === 0 && _prevVoiceSel === 0 && typeof focusMainTabBar === 'function') {
1612
+ focusMainTabBar();
1613
+ setTimeout(() => { voiceList.select(0); screen.render(); }, 0);
1614
+ }
1615
+ _prevVoiceSel = cur;
1616
+ });
1617
+
1618
+ // Escape at the list level → return to header tab bar
1619
+ voiceList.key(['escape'], () => {
1620
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1621
+ });
1622
+
1623
+ // 'f' or '*' in voiceList toggles favorite
1624
+ voiceList.key(['*'], () => {
1625
+ const voices = _getFilteredVoices();
1626
+ const selected = voices[voiceList.selected];
1627
+ if (selected) {
1628
+ toggleFavorite(configService, selected);
1629
+ refreshDisplay();
1630
+ }
1631
+ });
1632
+
1633
+ // Space preview voice (toggle: second press stops playback)
1634
+ // Only works for installed voices uninstalled need download first
1635
+ voiceList.key(['space'], () => {
1636
+ const voices = _getFilteredVoices();
1637
+ const selected = voices[voiceList.selected];
1638
+ if (!selected) return;
1639
+ if (!_isInstalled(selected)) {
1640
+ previewLine.setContent(`{bright-yellow-fg}⬇ Voice not installed — press [Enter] to download first{/bright-yellow-fg}`);
1641
+ screen.render();
1642
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 3000);
1643
+ return;
1644
+ }
1645
+ _previewVoice(selected);
1646
+ refreshDisplay();
1647
+ });
1648
+
1649
+ // Enter open "Set as default voice" or download uninstalled voice
1650
+ voiceList.key(['enter'], () => {
1651
+ const voices = _getFilteredVoices();
1652
+ const selected = voices[voiceList.selected];
1653
+ if (!selected) return;
1654
+ _killPlayingProcess();
1655
+ _playingVoiceId = null;
1656
+ previewLine.setContent('');
1657
+ screen.render();
1658
+
1659
+ if (_isInstalled(selected)) {
1660
+ _openSelectVoiceModal(selected);
1661
+ } else {
1662
+ _openDownloadModal(selected);
1663
+ }
1664
+ });
1665
+
1666
+ // Blinking █ on selected row while list is focused
1667
+ let _vlBlink = { interval: null, on: false, sel: -1 };
1668
+ process.on('exit', () => { if (_vlBlink.interval) clearInterval(_vlBlink.interval); });
1669
+ function _vlTick() {
1670
+ _vlBlink.on = !_vlBlink.on;
1671
+ const items = voiceList.items;
1672
+ const cur = voiceList.selected ?? 0;
1673
+ // Clean old row — only strip blink (it's not the hinted row anymore,
1674
+ // and we don't want to accidentally erase its provider column).
1675
+ if (_vlBlink.sel !== cur && _vlBlink.sel >= 0 && items[_vlBlink.sel]) {
1676
+ items[_vlBlink.sel].setContent(_stripBlink(items[_vlBlink.sel].content));
1677
+ }
1678
+ _vlBlink.sel = cur;
1679
+ if (items[cur]) {
1680
+ // Get content without blink but preserve hint
1681
+ const raw = _stripBlink(items[cur].content);
1682
+ items[cur].setContent(_vlBlink.on ? `${raw} █` : raw);
1683
+ }
1684
+ screen.render();
1685
+ }
1686
+ voiceList.on('focus', () => {
1687
+ _listFocused = true;
1688
+ _vlBlink.on = true;
1689
+ _vlBlink.sel = voiceList.selected ?? 0;
1690
+ _hintIdx = -1;
1691
+ _hintBase = '';
1692
+ _updateHint(_vlBlink.sel);
1693
+ const items = voiceList.items;
1694
+ if (items[_vlBlink.sel]) {
1695
+ const raw = _stripBlink(items[_vlBlink.sel].content);
1696
+ items[_vlBlink.sel].setContent(raw + ' █');
1697
+ }
1698
+ if (!_playingVoiceId) previewLine.setContent(HINT_TEXT);
1699
+ screen.render();
1700
+ _vlBlink.interval = setInterval(_vlTick, 500);
1701
+ });
1702
+ voiceList.on('blur', () => {
1703
+ _listFocused = false;
1704
+ if (!_playingVoiceId) previewLine.setContent('');
1705
+ if (_vlBlink.interval) { clearInterval(_vlBlink.interval); _vlBlink.interval = null; }
1706
+ const items = voiceList.items;
1707
+ // Clean up: strip blink from selected row, strip hint from hinted row.
1708
+ // Only the hinted row knows what its hint text was — use _stripDecorations
1709
+ // there. For other rows, only strip blink.
1710
+ const sel = voiceList.selected ?? 0;
1711
+ if (items[sel]) {
1712
+ if (sel === _hintIdx) {
1713
+ items[sel].setContent(_stripDecorations(items[sel].content, sel));
1714
+ } else {
1715
+ items[sel].setContent(_stripBlink(items[sel].content));
1716
+ }
1717
+ }
1718
+ if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1719
+ items[_hintIdx].setContent(_stripDecorations(items[_hintIdx].content, _hintIdx));
1720
+ }
1721
+ _hintIdx = -1;
1722
+ _hintBase = '';
1723
+ screen.render();
1724
+ });
1725
+
1726
+ // Update info panel when selection changes
1727
+ voiceList.on('select item', () => {
1728
+ if (_refreshing) return;
1729
+ _updateHint(voiceList.selected ?? 0);
1730
+ if (_vlBlink.interval) _vlTick(); // move █ to newly selected row
1731
+ const voices = _getFilteredVoices();
1732
+ const sel = voices[voiceList.selected] ?? '';
1733
+ infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1734
+ screen.render();
1735
+ });
1736
+
1737
+ // Type-to-jump: press a letter to jump to first voice whose display name starts with it
1738
+ const _voiceJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u']);
1739
+ voiceList.on('keypress', (ch, key) => {
1740
+ if (!ch || key.ctrl || key.meta) return;
1741
+ const lower = ch.toLowerCase();
1742
+ if (!/^[a-z]$/.test(lower)) return;
1743
+ if (_voiceJumpBlocked.has(lower)) return;
1744
+ const voices = _getFilteredVoices();
1745
+ const count = voices.length;
1746
+ if (count === 0) return;
1747
+ const start = voiceList.selected ?? 0;
1748
+ for (let i = 1; i <= count; i++) {
1749
+ const idx = (start + i) % count;
1750
+ const name = getVoiceMeta(voices[idx]).displayName.toLowerCase();
1751
+ if (name.startsWith(lower)) {
1752
+ voiceList.select(idx);
1753
+ screen.render();
1754
+ break;
1755
+ }
1756
+ }
1757
+ });
1758
+
1759
+ // -------------------------------------------------------------------------
1760
+ // Button-row keyboard navigation
1761
+ // ↓ at the last list item → descend into the button row (Switch Voice gets focus first)
1762
+ // Note: Tab is NOT used — navigation.js registers screen.key(['tab']) to cycle tabs,
1763
+ // so element.key(['tab']) + screen.key(['tab']) both fire simultaneously.
1764
+ voiceList.key(['down'], () => {
1765
+ const voices = _getFilteredVoices();
1766
+ if (voiceList.selected >= voices.length - 1) {
1767
+ switchBtn.focus();
1768
+ screen.render();
1769
+ }
1770
+ });
1771
+
1772
+ // ←/→ navigate between the three buttons
1773
+ switchBtn.key(['right'], () => { favoriteBtn.focus(); screen.render(); });
1774
+ favoriteBtn.key(['right'], () => { installBtn.focus(); screen.render(); });
1775
+ installBtn.key(['right'], () => { switchBtn.focus(); screen.render(); });
1776
+ switchBtn.key(['left'], () => { installBtn.focus(); screen.render(); });
1777
+ favoriteBtn.key(['left'], () => { switchBtn.focus(); screen.render(); });
1778
+ installBtn.key(['left'], () => { favoriteBtn.focus(); screen.render(); });
1779
+
1780
+ // ↑ or Escape from any button → back to voice list
1781
+ switchBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1782
+ favoriteBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1783
+ installBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1784
+
1785
+ // -------------------------------------------------------------------------
1786
+ // Language refresh
1787
+
1788
+ function refreshVoicesLabels() {
1789
+ voicesSectionHdr.setContent(`{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`);
1790
+ searchLabelText.setContent(_tl('searchLabel'));
1791
+ colHeaderText.setContent(`{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}{/#00897b-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {#00897b-fg}${_tl('voicesColProvider')}{/#00897b-fg}`);
1792
+ voiceInfoHdr.setContent(`{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`);
1793
+ switchBtn.setContent(_tl('voicesSwitchBtn'));
1794
+ favoriteBtn.setContent(_tl('voicesFavoriteBtn'));
1795
+ installBtn.setContent(_tl('voicesDownloadBtn'));
1796
+ screen.render();
1797
+ }
1798
+
1799
+ if (languageService) {
1800
+ languageService.onChange(() => refreshVoicesLabels());
1801
+ }
1802
+
1803
+ // -------------------------------------------------------------------------
1804
+ // Tab Component Contract
1805
+
1806
+ return {
1807
+ box,
1808
+
1809
+ show() {
1810
+ box.show();
1811
+ refreshDisplay();
1812
+ screen.render();
1813
+ },
1814
+
1815
+ hide() {
1816
+ _killPlayingProcess();
1817
+ _playingVoiceId = null;
1818
+ if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1819
+ previewLine.setContent('');
1820
+ box.hide();
1821
+ screen.render();
1822
+ },
1823
+
1824
+ onFocus() {
1825
+ voiceList.focus();
1826
+ screen.render();
1827
+ },
1828
+
1829
+ onBlur() {
1830
+ _killPlayingProcess();
1831
+ _playingVoiceId = null;
1832
+ if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1833
+ },
1834
+
1835
+ getFooterText() {
1836
+ return _tl('voicesFooter');
1837
+ },
1838
+
1839
+ getFooterColor() {
1840
+ return COLORS.footerBg;
1841
+ },
1842
+ };
1843
+ }