agentvibes 4.6.7 → 5.0.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 (35) 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/README.md +51 -52
  5. package/.claude/config/audio-effects-bmad.cfg +50 -0
  6. package/.claude/config/audio-effects.cfg +4 -4
  7. package/.claude/config/background-music-enabled.txt +1 -0
  8. package/.claude/config/personality.txt +1 -0
  9. package/.claude/hooks/play-tts-piper.sh +3 -1
  10. package/.claude/hooks/play-tts.sh +373 -301
  11. package/.claude/hooks/session-start-tts.sh +81 -81
  12. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  13. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  14. package/.claude/hooks-windows/play-tts.ps1 +101 -9
  15. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  16. package/README.md +107 -7
  17. package/RELEASE_NOTES.md +54 -0
  18. package/bin/bmad-speak.js +16 -8
  19. package/mcp-server/server.py +15 -8
  20. package/package.json +1 -1
  21. package/src/console/app.js +899 -897
  22. package/src/console/footer-config.js +50 -50
  23. package/src/console/navigation.js +65 -65
  24. package/src/console/tabs/agents-tab.js +1896 -1886
  25. package/src/console/tabs/music-tab.js +1046 -1039
  26. package/src/console/tabs/placeholder-tab.js +81 -80
  27. package/src/console/tabs/settings-tab.js +939 -3988
  28. package/src/console/tabs/setup-tab.js +1811 -0
  29. package/src/console/tabs/voices-tab.js +1720 -1713
  30. package/src/installer.js +6147 -6092
  31. package/src/services/llm-provider-service.js +407 -0
  32. package/src/services/navigation-service.js +123 -123
  33. package/src/services/tts-engine-service.js +69 -0
  34. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  35. package/src/console/tabs/install-tab.js +0 -1081
@@ -1,1713 +1,1720 @@
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.includes(MS_SEP)) {
342
- const [model, speakerName] = voiceId.split(MS_SEP, 2);
343
- const jsonPath = path.join(PIPER_VOICES_DIR, model + '.onnx.json');
344
- try {
345
- const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
346
- let speakerId = data.speaker_id_map?.[speakerName] ?? null;
347
- // Fallback: if the .onnx.json still has raw p-names (not yet patched),
348
- // look up the numeric speaker ID from voice-assignments.json catalog.
349
- if (speakerId == null && model === 'en_US-libritts-high') {
350
- try {
351
- const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
352
- const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
353
- const speakers = catalog.libritts_speakers ?? {};
354
- const entry = Object.entries(speakers).find(([, e]) => e.voice_name === speakerName);
355
- if (entry) speakerId = parseInt(entry[0], 10);
356
- } catch { /* non-fatal */ }
357
- }
358
- return { model, speakerId, speakerName, isMultiSpeaker: true };
359
- } catch {
360
- return { model, speakerId: null, speakerName, isMultiSpeaker: true };
361
- }
362
- }
363
- return { model: voiceId, speakerId: null, speakerName: null, isMultiSpeaker: false };
364
- }
365
-
366
- /**
367
- * Scan PIPER_VOICES_DIR for installed voice IDs.
368
- * Expands multi-speaker models into individual speaker entries.
369
- *
370
- * @returns {string[]}
371
- */
372
- export function scanInstalledVoices() {
373
- try {
374
- const files = fs.readdirSync(PIPER_VOICES_DIR);
375
- const onnxFiles = files
376
- .filter(f => f.endsWith('.onnx') && !f.endsWith('.onnx.json'));
377
-
378
- const result = [];
379
- for (const f of onnxFiles) {
380
- const voiceId = f.replace(/\.onnx$/, '');
381
- const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
382
- try {
383
- const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
384
- if (data.num_speakers > 1 && data.speaker_id_map) {
385
- // Expand multi-speaker model into individual entries
386
- for (const speakerName of Object.keys(data.speaker_id_map)) {
387
- result.push(`${voiceId}${MS_SEP}${speakerName}`);
388
- }
389
- continue;
390
- }
391
- } catch { /* fall through to add as single voice */ }
392
- result.push(voiceId);
393
- }
394
- return result.sort();
395
- } catch {
396
- return [];
397
- }
398
- }
399
-
400
- /**
401
- * Get favorites array from config.
402
- * @param {object} configService
403
- * @returns {string[]}
404
- */
405
- export function getFavorites(configService) {
406
- const favs = configService.getConfig().favorites;
407
- return Array.isArray(favs) ? favs : [];
408
- }
409
-
410
- /**
411
- * Toggle a voice in the favorites list.
412
- * @param {object} configService
413
- * @param {string} voiceId
414
- */
415
- export function toggleFavorite(configService, voiceId) {
416
- const favs = getFavorites(configService);
417
- const idx = favs.indexOf(voiceId);
418
- if (idx >= 0) {
419
- favs.splice(idx, 1);
420
- } else {
421
- favs.push(voiceId);
422
- }
423
- configService.set('favorites', favs);
424
- }
425
-
426
- // ---------------------------------------------------------------------------
427
- // Voice metadata cache (lives for the process lifetime)
428
-
429
- const _metaCache = new Map();
430
-
431
- /**
432
- * Load metadata from the .onnx.json file for a voice.
433
- * Caches results so the file is only read once per voice.
434
- *
435
- * @param {string} voiceId
436
- * @returns {{ displayName: string, gender: string, provider: string }}
437
- */
438
- export function getVoiceMeta(voiceId) {
439
- if (_metaCache.has(voiceId)) return _metaCache.get(voiceId);
440
-
441
- const ms = parseMultiSpeaker(voiceId);
442
- if (ms.isMultiSpeaker) {
443
- if (!ms.speakerName) {
444
- const result = { displayName: voiceId, gender: '—', provider: `Piper (${ms.model})` };
445
- _metaCache.set(voiceId, result);
446
- return result;
447
- }
448
- const displayName = ms.speakerName.replace(/_/g, ' ');
449
- const result = {
450
- displayName,
451
- gender: inferGender(ms.speakerName, null),
452
- provider: `Piper (${ms.model})`,
453
- };
454
- _metaCache.set(voiceId, result);
455
- return result;
456
- }
457
-
458
- let dataset = null;
459
- try {
460
- const jsonPath = path.join(PIPER_VOICES_DIR, voiceId + '.onnx.json');
461
- const raw = fs.readFileSync(jsonPath, 'utf8');
462
- const data = JSON.parse(raw);
463
- dataset = data.dataset ?? null;
464
- } catch {}
465
- const result = {
466
- displayName: formatVoiceName(voiceId, dataset),
467
- gender: inferGender(voiceId, dataset),
468
- provider: 'Piper',
469
- };
470
- _metaCache.set(voiceId, result);
471
- return result;
472
- }
473
-
474
- // ---------------------------------------------------------------------------
475
-
476
- /**
477
- * Create the Voices tab component.
478
- *
479
- * @param {object} screen - Blessed screen instance (or test stub)
480
- * @param {object} services
481
- * @param {import('../../services/config-service.js').ConfigService} services.configService
482
- * @param {import('../../services/provider-service.js').ProviderService} services.providerService
483
- * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
484
- */
485
- export function createVoicesTab(screen, services) {
486
- if (IS_TEST) return createTestStub();
487
-
488
- const { configService, providerService, focusMainTabBar, updateHeaderStatus, languageService } = services;
489
- const _tl = (key) => languageService ? languageService.t(key) : key;
490
-
491
- // -------------------------------------------------------------------------
492
- // Container
493
-
494
- const box = blessed.box({
495
- parent: screen,
496
- top: 5,
497
- left: 0,
498
- width: '100%',
499
- bottom: 2,
500
- hidden: true,
501
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
502
- border: { type: 'line' },
503
- borderStyle: { fg: COLORS.borderFg },
504
- });
505
-
506
- // -------------------------------------------------------------------------
507
- // Section header
508
-
509
- const voicesSectionHdr = blessed.text({
510
- parent: box,
511
- top: 1,
512
- left: 2,
513
- content: `{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`,
514
- tags: true,
515
- style: { bg: COLORS.contentBg },
516
- });
517
-
518
- // Currently selected voice indicator (updated by refreshDisplay)
519
- const activeVoiceText = blessed.text({
520
- parent: box,
521
- top: 1,
522
- right: 4,
523
- shrink: true,
524
- tags: true,
525
- content: '',
526
- style: { bg: COLORS.contentBg },
527
- });
528
-
529
- // -------------------------------------------------------------------------
530
- // Search input
531
-
532
- const searchLabelText = blessed.text({
533
- parent: box,
534
- top: 3,
535
- left: 4,
536
- content: _tl('searchLabel'),
537
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
538
- });
539
-
540
- const searchBox = blessed.textbox({
541
- parent: box,
542
- top: 3,
543
- left: 13,
544
- width: 40,
545
- height: 1,
546
- inputOnFocus: true,
547
- keys: true,
548
- style: {
549
- fg: COLORS.valueFg,
550
- bg: '#1a3a5c',
551
- focus: { bg: '#283593' },
552
- },
553
- });
554
-
555
- // -------------------------------------------------------------------------
556
- // Column header row (sits between search and voice list border)
557
-
558
- const colHeaderText = blessed.text({
559
- parent: box,
560
- top: 4,
561
- left: 6,
562
- content: `{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`,
563
- tags: true,
564
- style: { bg: COLORS.contentBg },
565
- });
566
-
567
- // -------------------------------------------------------------------------
568
- // Voice list
569
-
570
- const voiceList = blessed.list({
571
- parent: box,
572
- top: 5,
573
- left: 2,
574
- width: '96%',
575
- height: '50%',
576
- keys: true,
577
- vi: true,
578
- mouse: true,
579
- tags: true,
580
- border: { type: 'line' },
581
- scrollbar: { ch: '', style: { fg: COLORS.sectionHdr } },
582
- style: {
583
- fg: COLORS.labelFg,
584
- bg: COLORS.contentBg,
585
- border: { fg: COLORS.borderFg },
586
- selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
587
- item: { fg: COLORS.labelFg },
588
- },
589
- });
590
-
591
- // -------------------------------------------------------------------------
592
- // Info panel
593
-
594
- const voiceInfoHdr = blessed.text({
595
- parent: box,
596
- top: '60%',
597
- left: 2,
598
- content: `{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`,
599
- tags: true,
600
- style: { bg: COLORS.contentBg },
601
- });
602
-
603
- const infoLine = blessed.text({
604
- parent: box,
605
- top: '65%',
606
- left: 2,
607
- tags: true,
608
- content: '',
609
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
610
- });
611
-
612
- const previewLine = blessed.text({
613
- parent: box,
614
- top: '70%',
615
- left: 2,
616
- tags: true,
617
- content: '',
618
- style: { fg: COLORS.activeFg, bg: COLORS.contentBg },
619
- });
620
-
621
- // -------------------------------------------------------------------------
622
- // Hint text shown in previewLine when the list has focus and nothing is playing
623
- const HINT_TEXT = `{${COLORS.dimFg}-fg}[Space] preview [Enter] select as default voice{/${COLORS.dimFg}-fg}`;
624
- let _listFocused = false;
625
-
626
- // Inline selection hint appended to the currently highlighted voice row.
627
- // _hintBase stores the item's clean content (no hint, no █) — no sentinel needed.
628
- // Use getter functions so hints re-translate when language changes.
629
- const _rowHintInstalled = () => ` {bright-black-fg}${_tl('voicesRowHintInstalled')}{/bright-black-fg}`;
630
- const _rowHintUninstalled = () => ` {bright-yellow-fg}${_tl('voicesRowHintUninstalled')}{/bright-yellow-fg}`;
631
- let _hintIdx = -1;
632
- let _hintBase = ''; // content of items[_hintIdx] before hint was appended
633
- let _refreshing = false;
634
-
635
- function _getRowHint(idx) {
636
- const voices = _getFilteredVoices();
637
- const voiceId = voices[idx];
638
- if (!voiceId) return _rowHintInstalled();
639
- return _isInstalled(voiceId) ? _rowHintInstalled() : _rowHintUninstalled();
640
- }
641
-
642
- // Translate gender value at display time
643
- function _tGender(g) {
644
- if (g === 'Female') return _tl('genderFemale');
645
- if (g === 'Male') return _tl('genderMale');
646
- return g;
647
- }
648
-
649
- // Known limitation: blink (' █') and hint text can briefly interleave when
650
- // _vlTick fires between stripping and re-appending the hint. Accepted as cosmetic.
651
- function _updateHint(idx) {
652
- const items = voiceList.items;
653
- if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
654
- const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
655
- items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
656
- }
657
- if (idx >= 0 && items[idx]) {
658
- let c = items[idx].content ?? '';
659
- const hasBlink = c.endsWith(' █');
660
- if (hasBlink) c = c.slice(0, -2);
661
- _hintBase = c;
662
- items[idx].setContent(c + _getRowHint(idx) + (hasBlink ? ' █' : ''));
663
- } else {
664
- _hintBase = '';
665
- }
666
- _hintIdx = idx;
667
- }
668
-
669
- // -------------------------------------------------------------------------
670
- // Playback state
671
-
672
- let _playingProcess = null;
673
- let _playingVoiceId = null;
674
- let _downloadProcess = null;
675
-
676
- // Kill the entire process group so child audio players (piper, aplay, play) all die
677
- function _killPlayingProcess() {
678
- if (_playingProcess) {
679
- try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
680
- _playingProcess = null;
681
- }
682
- }
683
-
684
- const _spawnEnv = buildAudioEnv();
685
-
686
- /**
687
- * Preview a voice by synthesizing a sample phrase with piper, then playing the wav.
688
- * Second call with the same voice stops playback (toggle).
689
- */
690
- function _previewVoice(voiceId) {
691
- // Toggle: second press stops
692
- if (_playingVoiceId === voiceId) {
693
- _killPlayingProcess();
694
- _playingVoiceId = null;
695
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
696
- screen.render();
697
- return;
698
- }
699
-
700
- // Kill any current preview first
701
- _killPlayingProcess();
702
- _playingVoiceId = null;
703
-
704
- // Resolve model path (may be multi-speaker)
705
- const ms = parseMultiSpeaker(voiceId);
706
- const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
707
- const safeBase = path.resolve(PIPER_VOICES_DIR);
708
- if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
709
- return;
710
- }
711
-
712
- const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${Date.now()}.wav`);
713
- const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
714
-
715
- // Synthesize: spawn piper; on Windows use the exe path directly
716
- const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
717
- let piperBin = 'piper';
718
- if (isWindows) {
719
- const localAppData = process.env.LOCALAPPDATA ||
720
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
721
- if (localAppData) {
722
- const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
723
- if (fs.existsSync(exePath)) piperBin = exePath;
724
- }
725
- }
726
- const piperArgs = ['--model', voicePath, '--output_file', tempWav];
727
- if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
728
- // On Windows, avoid detached:true which opens a visible console window
729
- const piper = spawn(piperBin, piperArgs, {
730
- stdio: ['pipe', 'ignore', 'ignore'],
731
- detached: !isWindows,
732
- windowsHide: true,
733
- env: _spawnEnv,
734
- });
735
- piper.stdin.write(phrase + '\n');
736
- piper.stdin.end();
737
-
738
- _playingProcess = piper;
739
- _playingVoiceId = voiceId;
740
-
741
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Synthesizing: ${voiceId}…{/${COLORS.activeFg}-fg}`);
742
- screen.render();
743
-
744
- piper.on('exit', (code) => {
745
- if (_playingVoiceId !== voiceId) {
746
- // User stopped before synthesis finished
747
- try { fs.unlinkSync(tempWav); } catch {}
748
- return;
749
- }
750
-
751
- if (code !== 0) {
752
- _playingVoiceId = null;
753
- _playingProcess = null;
754
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Preview failed (piper error — is piper installed?){/${COLORS.activeFg}-fg}`);
755
- screen.render();
756
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
757
- return;
758
- }
759
-
760
- // Play the synthesized wav in its own process group so we can kill it
761
- const _wavP = detectWavPlayer(_spawnEnv);
762
- if (!_wavP) {
763
- _playingVoiceId = null;
764
- _playingProcess = null;
765
- previewLine.setContent(`{red-fg}No audio player found. Install ffmpeg.{/red-fg}`);
766
- screen.render();
767
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
768
- try { fs.unlinkSync(tempWav); } catch {}
769
- return;
770
- }
771
- const playProc = spawn(_wavP.bin, _wavP.args(tempWav), {
772
- stdio: 'ignore',
773
- detached: !isWindows,
774
- windowsHide: true,
775
- env: _spawnEnv,
776
- });
777
- // Race note: _playingVoiceId could change between piper exit and here
778
- // if the user stops playback. Re-check before assigning to avoid orphan.
779
- if (_playingVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
780
- _playingProcess = playProc;
781
-
782
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Playing: ${voiceId} (Enter/Space to stop){/${COLORS.activeFg}-fg}`);
783
- screen.render();
784
-
785
- playProc.on('exit', () => {
786
- if (_playingVoiceId === voiceId) {
787
- _playingVoiceId = null;
788
- _playingProcess = null;
789
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
790
- refreshDisplay(); // clears (playing) label
791
- }
792
- try { fs.unlinkSync(tempWav); } catch {}
793
- });
794
-
795
- playProc.on('error', () => {
796
- _playingVoiceId = null;
797
- _playingProcess = null;
798
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
799
- try { fs.unlinkSync(tempWav); } catch {}
800
- });
801
- });
802
-
803
- piper.on('error', () => {
804
- _playingVoiceId = null;
805
- _playingProcess = null;
806
- previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Cannot find piper — install with: pipx install piper-tts{/${COLORS.activeFg}-fg}`);
807
- screen.render();
808
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
809
- try { fs.unlinkSync(tempWav); } catch {}
810
- });
811
- }
812
-
813
- // -------------------------------------------------------------------------
814
- // Voice activation (handles multi-speaker model/speaker-id files)
815
-
816
- function _activateVoice(voiceId) {
817
- const ms = parseMultiSpeaker(voiceId);
818
- const claudeDir = path.resolve(process.cwd(), '.claude');
819
- try { fs.mkdirSync(claudeDir, { recursive: true }); } catch {}
820
- // Always write tts-voice.txt so shell scripts pick up the voice on reload
821
- try {
822
- fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), voiceId, 'utf8');
823
- } catch { /* non-fatal */ }
824
- if (ms.isMultiSpeaker) {
825
- // Store full MS ID (e.g., "16Speakers::Kristin_Hughes") so list matching works
826
- providerService.setActiveVoice(voiceId);
827
- try {
828
- fs.writeFileSync(path.join(claudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
829
- fs.writeFileSync(path.join(claudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
830
- } catch { /* non-fatal */ }
831
- } else {
832
- providerService.setActiveVoice(voiceId);
833
- // Clear multi-speaker files if switching to a single-speaker voice
834
- try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
835
- try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
836
- }
837
- }
838
-
839
- // -------------------------------------------------------------------------
840
- // Buttons
841
-
842
- function _createBtn(label, onClick) {
843
- const btn = blessed.button({
844
- parent: box,
845
- content: label,
846
- mouse: true,
847
- keys: true,
848
- shrink: true,
849
- padding: { left: 1, right: 1 },
850
- style: {
851
- bg: COLORS.btnDefault,
852
- fg: 'white',
853
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
854
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
855
- },
856
- });
857
- btn.on('focus', () => {
858
- btn.style.bg = COLORS.btnFocus;
859
- btn.style.fg = COLORS.btnFocusFg;
860
- const raw = btn.content.replace(/[►◄]/g, '').trim();
861
- btn.setContent(`►${raw}◄`);
862
- screen.render();
863
- });
864
- btn.on('blur', () => {
865
- btn.style.bg = COLORS.btnDefault;
866
- btn.style.fg = 'white';
867
- const raw = btn.content.replace(/[►◄]/g, '').trim();
868
- btn.setContent(raw);
869
- screen.render();
870
- });
871
- btn.key(['enter', 'space'], () => {
872
- btn.style.bg = COLORS.btnPress;
873
- screen.render();
874
- setTimeout(() => {
875
- btn.style.bg = COLORS.btnDefault;
876
- screen.render();
877
- onClick();
878
- }, 150);
879
- });
880
- btn.on('click', () => btn.press());
881
- btn.on('mouseover', () => btn.focus());
882
- return btn;
883
- }
884
-
885
- const switchBtn = _createBtn(_tl('voicesSwitchBtn'), () => {
886
- const voices = _getFilteredVoices();
887
- const selected = voices[voiceList.selected];
888
- if (selected) {
889
- _activateVoice(selected);
890
- refreshDisplay();
891
- }
892
- });
893
- switchBtn.bottom = 4;
894
- switchBtn.left = 4;
895
-
896
- const favoriteBtn = _createBtn(_tl('voicesFavoriteBtn'), () => {
897
- const voices = _getFilteredVoices();
898
- const selected = voices[voiceList.selected];
899
- if (selected) {
900
- toggleFavorite(configService, selected);
901
- refreshDisplay();
902
- }
903
- });
904
- favoriteBtn.bottom = 4;
905
- favoriteBtn.left = 22;
906
-
907
- const installBtn = _createBtn(_tl('voicesDownloadBtn'), () => {
908
- const voices = _getFilteredVoices();
909
- const selected = voices[voiceList.selected];
910
- if (!selected) return;
911
- if (_isInstalled(selected)) {
912
- const notice = blessed.text({
913
- parent: box,
914
- top: 'center',
915
- left: 'center',
916
- content: 'Voice already installed. Scroll down to find uninstalled voices (greyed out).',
917
- tags: true,
918
- style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
919
- });
920
- screen.render();
921
- setTimeout(() => { notice.destroy(); screen.render(); }, 3000);
922
- } else {
923
- _openDownloadModal(selected);
924
- }
925
- });
926
- installBtn.bottom = 4;
927
- installBtn.left = 38;
928
-
929
- // -------------------------------------------------------------------------
930
- // "Voice Changed" notice — auto-dismisses after 2 s
931
-
932
- function _showVoiceChangedNotice(displayName) {
933
- const notice = blessed.box({
934
- parent: screen,
935
- top: 'center',
936
- left: 'center',
937
- width: 44,
938
- height: 5,
939
- border: { type: 'line' },
940
- tags: true,
941
- label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
942
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
943
- content: `\n {${COLORS.activeFg}-fg}✓ Voice changed: ${displayName}{/${COLORS.activeFg}-fg}`,
944
- });
945
- notice.setFront();
946
- screen.render();
947
- setTimeout(() => { notice.destroy(); screen.render(); }, 2000);
948
- }
949
-
950
- // -------------------------------------------------------------------------
951
- // Select-voice confirmation modal
952
-
953
- function _activateVoiceGlobal(voiceId) {
954
- // Save voice globally (all projects)
955
- const ms = parseMultiSpeaker(voiceId);
956
- const globalClaudeDir = path.resolve(os.homedir(), '.claude');
957
- // Verify ownership before writing to global config dir
958
- try {
959
- const stat = fs.statSync(globalClaudeDir);
960
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
961
- } catch {}
962
- if (ms.isMultiSpeaker) {
963
- configService.setGlobal('voice', voiceId);
964
- try {
965
- fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
966
- fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
967
- } catch { /* non-fatal */ }
968
- } else {
969
- configService.setGlobal('voice', voiceId);
970
- try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
971
- try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
972
- }
973
- // Also write global tts-voice.txt for shell scripts
974
- try { fs.writeFileSync(path.join(globalClaudeDir, 'tts-voice.txt'), ms.isMultiSpeaker ? voiceId : voiceId, 'utf8'); } catch { /* ok */ }
975
- }
976
-
977
- function _openSelectVoiceModal(voiceId) {
978
- const { displayName } = getVoiceMeta(voiceId);
979
-
980
- const modal = blessed.box({
981
- parent: screen,
982
- top: 'center',
983
- left: 'center',
984
- width: 72,
985
- height: 8,
986
- border: { type: 'line' },
987
- tags: true,
988
- label: ` {${COLORS.activeFg}-fg}Set Default Voice{/${COLORS.activeFg}-fg} `,
989
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
990
- });
991
-
992
- blessed.text({
993
- parent: modal,
994
- top: 1,
995
- left: 2,
996
- right: 2,
997
- content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your default voice?`,
998
- tags: true,
999
- style: { bg: COLORS.contentBg },
1000
- });
1001
-
1002
- // Status line shows playback state while modal is open
1003
- const modalStatus = blessed.text({
1004
- parent: modal,
1005
- top: 3,
1006
- left: 2,
1007
- right: 2,
1008
- tags: true,
1009
- content: `{${COLORS.dimFg}-fg}Press Preview to audition this voice{/${COLORS.dimFg}-fg}`,
1010
- style: { bg: COLORS.contentBg },
1011
- });
1012
-
1013
- function _close() {
1014
- _killPlayingProcess();
1015
- _playingVoiceId = null;
1016
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
1017
- modal.destroy();
1018
- voiceList.focus();
1019
- screen.render();
1020
- }
1021
-
1022
- // Note: blessed's destroy() does not remove key listeners from child buttons,
1023
- // so modal button handlers may leak. This is a known blessed limitation.
1024
- function _makeBtn(label, bg, left, top, onClick) {
1025
- const btn = blessed.button({
1026
- parent: modal,
1027
- content: label,
1028
- top,
1029
- left,
1030
- mouse: true,
1031
- keys: true,
1032
- shrink: true,
1033
- padding: { left: 1, right: 1 },
1034
- style: {
1035
- bg,
1036
- fg: 'white',
1037
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1038
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1039
- },
1040
- });
1041
- btn.key(['enter', 'space'], () => { _close(); onClick(); });
1042
- btn.on('click', () => btn.press());
1043
- return btn;
1044
- }
1045
-
1046
- const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
1047
- _activateVoice(voiceId);
1048
- refreshDisplay();
1049
- _showVoiceChangedNotice(displayName);
1050
- });
1051
- const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
1052
- _activateVoice(voiceId);
1053
- _activateVoiceGlobal(voiceId);
1054
- refreshDisplay();
1055
- _showVoiceChangedNotice(displayName);
1056
- });
1057
- const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
1058
-
1059
- // Preview button does NOT close the modal; plays/stops the voice inline
1060
- const previewBtn = blessed.button({
1061
- parent: modal,
1062
- content: 'Preview',
1063
- top: 5,
1064
- left: 58,
1065
- mouse: true,
1066
- keys: true,
1067
- shrink: true,
1068
- padding: { left: 1, right: 1 },
1069
- style: {
1070
- bg: '#e65100',
1071
- fg: 'white',
1072
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1073
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1074
- },
1075
- });
1076
- previewBtn.key(['enter', 'space'], () => {
1077
- const isPlaying = _playingVoiceId === voiceId;
1078
- _previewVoice(voiceId);
1079
- modalStatus.setContent(isPlaying
1080
- ? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
1081
- : `{${COLORS.activeFg}-fg}♪ Playing: ${displayName}…{/${COLORS.activeFg}-fg}`
1082
- );
1083
- screen.render();
1084
- });
1085
- previewBtn.on('click', () => previewBtn.press());
1086
-
1087
- // Tab/arrow navigation: SaveLocal → SaveGlobal → Cancel → Preview → SaveLocal
1088
- okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
1089
- okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1090
- cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
1091
- previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
1092
- previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1093
- cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
1094
- okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
1095
- okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
1096
-
1097
- modal.key(['escape', 'q'], _close);
1098
-
1099
- modal.setFront();
1100
- okLocalBtn.focus();
1101
- screen.render();
1102
- }
1103
-
1104
- // -------------------------------------------------------------------------
1105
- // Download modal for uninstalled catalog voices
1106
-
1107
- function _openDownloadModal(voiceId) {
1108
- const cat = _catalogMap.get(voiceId);
1109
- const displayName = cat?.displayName ?? voiceId;
1110
- const modelToDownload = cat?.type === 'libritts' ? 'en_US-libritts-high' : (cat?.model ?? voiceId);
1111
- const isLibriTTS = cat?.type === 'libritts';
1112
-
1113
- const modal = blessed.box({
1114
- parent: screen,
1115
- top: 'center',
1116
- left: 'center',
1117
- width: 64,
1118
- height: 10,
1119
- border: { type: 'line' },
1120
- tags: true,
1121
- label: ` {${COLORS.activeFg}-fg}Download Voice{/${COLORS.activeFg}-fg} `,
1122
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
1123
- });
1124
-
1125
- const msgLine = blessed.text({
1126
- parent: modal,
1127
- top: 1,
1128
- left: 2,
1129
- right: 2,
1130
- tags: true,
1131
- content: `Download {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg}?\n\n` +
1132
- `Model: {${COLORS.activeFg}-fg}${modelToDownload}{/${COLORS.activeFg}-fg}` +
1133
- (isLibriTTS ? ` (~57 MB — unlocks all 904 LibriTTS speakers)` : ` (~25 MB)`),
1134
- style: { bg: COLORS.contentBg },
1135
- });
1136
-
1137
- const statusLine = blessed.text({
1138
- parent: modal,
1139
- top: 5,
1140
- left: 2,
1141
- right: 2,
1142
- tags: true,
1143
- content: '',
1144
- style: { bg: COLORS.contentBg },
1145
- });
1146
-
1147
- let _downloading = false;
1148
-
1149
- function _close() {
1150
- modal.destroy();
1151
- voiceList.focus();
1152
- screen.render();
1153
- }
1154
-
1155
- function _startDownload() {
1156
- if (_downloading) return;
1157
- _downloading = true;
1158
-
1159
- // Animated spinner
1160
- const spinFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1161
- let spinIdx = 0;
1162
- let dlPhase = 'Downloading model';
1163
- const progressBar = (pct) => {
1164
- const filled = Math.round(pct / 5);
1165
- const empty = 20 - filled;
1166
- return '█'.repeat(filled) + '░'.repeat(empty);
1167
- };
1168
-
1169
- const spinTimer = setInterval(() => {
1170
- spinIdx = (spinIdx + 1) % spinFrames.length;
1171
- const frame = spinFrames[spinIdx];
1172
- statusLine.setContent(
1173
- `{${COLORS.activeFg}-fg}${frame} ${dlPhase}… ${modelToDownload}{/${COLORS.activeFg}-fg}`
1174
- );
1175
- screen.render();
1176
- }, 100);
1177
-
1178
- // Download voice model — use PowerShell on Windows, bash on Unix
1179
- const packageRoot = path.resolve(__dirname, '..', '..', '..');
1180
- const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1181
- let dlProc;
1182
-
1183
- if (isWindows) {
1184
- const piperVoicesDir = resolvePiperVoicesDir();
1185
- const hfBase = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
1186
- const match = modelToDownload.match(/^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$/);
1187
- let modelUrl, configUrl;
1188
- if (match) {
1189
- const [, lang, region, speaker, quality] = match;
1190
- const hfPath = `${lang}/${lang}_${region}/${speaker}/${quality}`;
1191
- modelUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx`;
1192
- configUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx.json`;
1193
- } else {
1194
- const customBase = 'https://huggingface.co/agentvibes/piper-custom-voices/resolve/main';
1195
- modelUrl = `${customBase}/${modelToDownload}.onnx`;
1196
- configUrl = `${customBase}/${modelToDownload}.onnx.json`;
1197
- }
1198
- const modelFile = path.join(piperVoicesDir, `${modelToDownload}.onnx`);
1199
- const configFile = path.join(piperVoicesDir, `${modelToDownload}.onnx.json`);
1200
- // PowerShell script with progress reporting
1201
- const psScript = `
1202
- $ErrorActionPreference = 'Stop'
1203
- $ProgressPreference = 'SilentlyContinue'
1204
- $voicesDir = '${piperVoicesDir.replace(/'/g, "''")}'
1205
- if (-not (Test-Path $voicesDir)) { New-Item -ItemType Directory -Path $voicesDir -Force | Out-Null }
1206
- Write-Output 'PHASE:model'
1207
- Invoke-WebRequest -Uri '${modelUrl}' -OutFile '${modelFile.replace(/'/g, "''")}' -ErrorAction Stop
1208
- Write-Output 'PHASE:config'
1209
- Invoke-WebRequest -Uri '${configUrl}' -OutFile '${configFile.replace(/'/g, "''")}' -ErrorAction Stop
1210
- Write-Output 'PHASE:done'
1211
- `;
1212
- dlProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], {
1213
- stdio: ['ignore', 'pipe', 'pipe'],
1214
- env: _spawnEnv,
1215
- });
1216
- } else {
1217
- const managerScript = path.resolve(packageRoot, '.claude', 'hooks', 'piper-voice-manager.sh');
1218
- dlProc = spawn('bash', ['-c', 'source "$1" && download_voice "$2"', '_', managerScript, modelToDownload], {
1219
- stdio: ['ignore', 'pipe', 'pipe'],
1220
- env: _spawnEnv,
1221
- });
1222
- }
1223
- _downloadProcess = dlProc;
1224
-
1225
- let output = '';
1226
- dlProc.stdout.on('data', (d) => {
1227
- const chunk = d.toString();
1228
- output += chunk;
1229
- // Update phase based on progress markers
1230
- if (chunk.includes('PHASE:config') || chunk.includes('config file')) {
1231
- dlPhase = 'Downloading config';
1232
- } else if (chunk.includes('PHASE:done') || chunk.includes('successfully')) {
1233
- dlPhase = 'Finishing up';
1234
- }
1235
- });
1236
- dlProc.stderr.on('data', (d) => { output += d.toString(); });
1237
-
1238
- dlProc.on('exit', (code) => {
1239
- clearInterval(spinTimer);
1240
- _downloading = false;
1241
- _downloadProcess = null;
1242
- if (code === 0) {
1243
- if (isLibriTTS) {
1244
- patchLibriTTSSpeakerNames();
1245
- _metaCache.clear();
1246
- }
1247
- statusLine.setContent(`{green-fg}✓ Downloaded successfully!{/green-fg}`);
1248
- screen.render();
1249
- setTimeout(() => {
1250
- _close();
1251
- refreshDisplay();
1252
- }, 1500);
1253
- } else {
1254
- statusLine.setContent(`{red-fg} Download failed. ${output.slice(-80).trim()}{/red-fg}`);
1255
- screen.render();
1256
- }
1257
- });
1258
-
1259
- dlProc.on('error', () => {
1260
- clearInterval(spinTimer);
1261
- _downloading = false;
1262
- _downloadProcess = null;
1263
- statusLine.setContent(`{red-fg}✗ Could not run download script{/red-fg}`);
1264
- screen.render();
1265
- });
1266
- }
1267
-
1268
- function _makeBtn(label, bg, left, onClick) {
1269
- const btn = blessed.button({
1270
- parent: modal,
1271
- content: label,
1272
- top: 7,
1273
- left,
1274
- mouse: true,
1275
- keys: true,
1276
- shrink: true,
1277
- padding: { left: 1, right: 1 },
1278
- style: {
1279
- bg,
1280
- fg: 'white',
1281
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1282
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1283
- },
1284
- });
1285
- btn.key(['enter', 'space'], onClick);
1286
- btn.on('click', () => btn.press());
1287
- return btn;
1288
- }
1289
-
1290
- const dlBtn = _makeBtn('Download', COLORS.btnDefault, 2, _startDownload);
1291
- const cancelBtn = _makeBtn('Cancel', '#546e7a', 16, _close);
1292
-
1293
- dlBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1294
- cancelBtn.key(['tab', 'right'], () => { dlBtn.focus(); screen.render(); });
1295
- dlBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1296
- cancelBtn.key(['left'], () => { dlBtn.focus(); screen.render(); });
1297
-
1298
- modal.key(['escape', 'q'], () => { if (!_downloading) _close(); });
1299
-
1300
- modal.setFront();
1301
- dlBtn.focus();
1302
- screen.render();
1303
- }
1304
-
1305
- // -------------------------------------------------------------------------
1306
- // State
1307
-
1308
- let _allVoices = []; // voice IDs (installed first, then catalog-only)
1309
- let _installedSet = new Set(); // which IDs are locally installed
1310
- let _filterText = '';
1311
-
1312
- function _getFilteredVoices() {
1313
- if (!_filterText) return _allVoices;
1314
- const f = _filterText.toLowerCase();
1315
- return _allVoices.filter(v => {
1316
- if (v.toLowerCase().includes(f)) return true;
1317
- // Also search by catalog display name
1318
- const cat = _catalogMap.get(v);
1319
- if (cat && cat.displayName.toLowerCase().includes(f)) return true;
1320
- return false;
1321
- });
1322
- }
1323
-
1324
- function _isInstalled(voiceId) {
1325
- return _installedSet.has(voiceId);
1326
- }
1327
-
1328
- function _buildListItems(voices, active, favorites) {
1329
- return voices.map(v => {
1330
- const installed = _isInstalled(v);
1331
- const isFav = favorites.includes(v);
1332
- const isActive = v === active;
1333
- const isPrev = v === _playingVoiceId;
1334
- const star = isFav ? '★' : ' ';
1335
- const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
1336
-
1337
- let displayName, gender, provider;
1338
- if (installed) {
1339
- const meta = getVoiceMeta(v);
1340
- displayName = meta.displayName;
1341
- gender = meta.gender;
1342
- provider = meta.provider;
1343
- } else {
1344
- // Catalog-only voice — use catalog metadata
1345
- const cat = _catalogMap.get(v);
1346
- displayName = cat?.displayName ?? v;
1347
- gender = cat?.gender ?? '—';
1348
- provider = cat?.type === 'libritts' ? 'Piper (LibriTTS)' : 'Piper';
1349
- }
1350
-
1351
- const name = displayName.length > COL_NAME_W
1352
- ? displayName.slice(0, COL_NAME_W - 1) + '…'
1353
- : displayName.padEnd(COL_NAME_W);
1354
-
1355
- if (!installed) {
1356
- // Greyed-out row for uninstalled catalog voices
1357
- return `{bright-black-fg} ${star} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}{/bright-black-fg}`;
1358
- }
1359
- return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1360
- });
1361
- }
1362
-
1363
- // Build a tagged info string with yellow labels for the info panel
1364
- function _formatInfoTagged(voiceId) {
1365
- const Y = COLORS.valueFg; // #ffd700 yellow
1366
-
1367
- // Uninstalled catalog voice show download prompt
1368
- if (!_isInstalled(voiceId)) {
1369
- const cat = _catalogMap.get(voiceId);
1370
- const name = cat?.displayName ?? voiceId;
1371
- const gender = cat?.gender ?? '—';
1372
- const model = cat?.type === 'libritts' ? 'LibriTTS High (multi-speaker)' : (cat?.model ?? voiceId);
1373
- return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1374
- `{${Y}-fg}${_tl('voiceInfoGender')}{/${Y}-fg} ${_tGender(gender)} ` +
1375
- `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${model} ` +
1376
- `{bright-yellow-fg}${_tl('voiceInfoDownload')}{/bright-yellow-fg}`;
1377
- }
1378
-
1379
- const ms = parseMultiSpeaker(voiceId);
1380
- if (ms.isMultiSpeaker) {
1381
- const name = ms.speakerName.replace(/_/g, ' ');
1382
- return `{${Y}-fg}${_tl('voiceInfoSpeaker')}{/${Y}-fg} ${name} ` +
1383
- `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${ms.model} ` +
1384
- `{${Y}-fg}${_tl('voiceInfoSpeakerId')}{/${Y}-fg} ${ms.speakerId ?? '?'} ` +
1385
- `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1386
- }
1387
- const { lang, name, quality } = parseVoiceId(voiceId);
1388
- if (lang === 'unknown') {
1389
- return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${voiceId} {${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1390
- }
1391
- return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1392
- `{${Y}-fg}${_tl('voiceInfoLanguage')}{/${Y}-fg} ${lang} ` +
1393
- `{${Y}-fg}${_tl('voiceInfoQuality')}{/${Y}-fg} ${quality} ` +
1394
- `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper ` +
1395
- `{${Y}-fg}${_tl('voiceInfoId')}{/${Y}-fg} ${voiceId}`;
1396
- }
1397
-
1398
- function refreshDisplay() {
1399
- _refreshing = true;
1400
- const savedIdx = voiceList.selected ?? 0;
1401
-
1402
- // Load catalog on first refresh and patch speaker names (once)
1403
- if (!_catalogLoaded) {
1404
- loadCatalog();
1405
- patchLibriTTSSpeakerNames();
1406
- _metaCache.clear();
1407
- }
1408
-
1409
- // Installed voices (from local disk)
1410
- const installed = scanInstalledVoices();
1411
- _installedSet = new Set(installed);
1412
-
1413
- // Merge: installed voices first, then uninstalled catalog voices
1414
- const catalogOnly = _catalogEntries
1415
- .filter(c => !_installedSet.has(c.voiceId))
1416
- .map(c => c.voiceId);
1417
- _allVoices = [...installed, ...catalogOnly];
1418
-
1419
- const active = providerService.getActiveVoiceId();
1420
- const favorites = getFavorites(configService);
1421
- const filtered = _getFilteredVoices();
1422
- const items = _buildListItems(filtered, active, favorites);
1423
-
1424
- voiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
1425
- const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
1426
- voiceList.select(Math.min(savedIdx, maxIdx));
1427
-
1428
- // Re-apply inline hint if list is focused
1429
- if (_listFocused) {
1430
- _hintIdx = -1;
1431
- _hintBase = '';
1432
- _updateHint(voiceList.selected ?? 0);
1433
- }
1434
-
1435
- // Update info panel for currently selected item
1436
- const sel = filtered[voiceList.selected] ?? active ?? '';
1437
- infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1438
-
1439
- // Update "Currently Selected" header
1440
- if (active) {
1441
- const _msA = parseMultiSpeaker(active);
1442
- const activeName = _msA.isMultiSpeaker ? _msA.speakerName : active;
1443
- const meta = _isInstalled(active) ? getVoiceMeta(active) : null;
1444
- const displayName = meta?.displayName ?? activeName;
1445
- activeVoiceText.setContent(`{green-fg}✓ ${displayName}{/green-fg}`);
1446
- } else {
1447
- activeVoiceText.setContent(`{bright-black-fg}No voice selected{/bright-black-fg}`);
1448
- }
1449
-
1450
- _refreshing = false;
1451
- if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
1452
- screen.render();
1453
- }
1454
-
1455
- // -------------------------------------------------------------------------
1456
- // Search box interaction
1457
-
1458
- searchBox.on('keypress', () => {
1459
- // Update filter after keystroke
1460
- setTimeout(() => {
1461
- _filterText = searchBox.getValue().trim();
1462
- refreshDisplay();
1463
- }, 0);
1464
- });
1465
-
1466
- // Pressing Escape in search returns focus to voiceList
1467
- searchBox.key(['escape'], () => {
1468
- voiceList.focus();
1469
- screen.render();
1470
- });
1471
-
1472
- // Page Up / Page Down — jump ~20 items at a time
1473
- const PAGE_SIZE = 20;
1474
- voiceList.key(['pagedown'], () => {
1475
- const voices = _getFilteredVoices();
1476
- const maxIdx = Math.max(0, voices.length - 1);
1477
- voiceList.select(Math.min((voiceList.selected ?? 0) + PAGE_SIZE, maxIdx));
1478
- screen.render();
1479
- });
1480
- voiceList.key(['pageup'], () => {
1481
- voiceList.select(Math.max((voiceList.selected ?? 0) - PAGE_SIZE, 0));
1482
- screen.render();
1483
- });
1484
-
1485
- // Pressing '/' in voiceList focuses search box
1486
- voiceList.key(['/'], () => {
1487
- searchBox.clearValue();
1488
- searchBox.focus();
1489
- screen.render();
1490
- });
1491
-
1492
- // ↑ at the top of the list → jump to main header tab bar
1493
- voiceList.key(['up'], () => {
1494
- if (voiceList.selected === 0 && typeof focusMainTabBar === 'function') {
1495
- focusMainTabBar();
1496
- // Reset selection to 0 after built-in handler potentially wraps to end
1497
- setTimeout(() => { voiceList.select(0); screen.render(); }, 0);
1498
- }
1499
- });
1500
-
1501
- // Escape at the list level return to header tab bar
1502
- voiceList.key(['escape'], () => {
1503
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1504
- });
1505
-
1506
- // 'f' or '*' in voiceList toggles favorite
1507
- voiceList.key(['f', '*'], () => {
1508
- const voices = _getFilteredVoices();
1509
- const selected = voices[voiceList.selected];
1510
- if (selected) {
1511
- toggleFavorite(configService, selected);
1512
- refreshDisplay();
1513
- }
1514
- });
1515
-
1516
- // Space preview voice (toggle: second press stops playback)
1517
- // Only works for installed voices — uninstalled need download first
1518
- voiceList.key(['space'], () => {
1519
- const voices = _getFilteredVoices();
1520
- const selected = voices[voiceList.selected];
1521
- if (!selected) return;
1522
- if (!_isInstalled(selected)) {
1523
- previewLine.setContent(`{bright-yellow-fg}⬇ Voice not installed press [Enter] to download first{/bright-yellow-fg}`);
1524
- screen.render();
1525
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 3000);
1526
- return;
1527
- }
1528
- _previewVoice(selected);
1529
- refreshDisplay();
1530
- });
1531
-
1532
- // Enter open "Set as default voice" or download uninstalled voice
1533
- voiceList.key(['enter'], () => {
1534
- const voices = _getFilteredVoices();
1535
- const selected = voices[voiceList.selected];
1536
- if (!selected) return;
1537
- _killPlayingProcess();
1538
- _playingVoiceId = null;
1539
- previewLine.setContent('');
1540
- screen.render();
1541
-
1542
- if (_isInstalled(selected)) {
1543
- _openSelectVoiceModal(selected);
1544
- } else {
1545
- _openDownloadModal(selected);
1546
- }
1547
- });
1548
-
1549
- // Blinking █ on selected row while list is focused
1550
- let _vlBlink = { interval: null, on: false, sel: -1 };
1551
- process.on('exit', () => { if (_vlBlink.interval) clearInterval(_vlBlink.interval); });
1552
- function _vlTick() {
1553
- _vlBlink.on = !_vlBlink.on;
1554
- const items = voiceList.items;
1555
- const cur = voiceList.selected ?? 0;
1556
- if (_vlBlink.sel !== cur && _vlBlink.sel >= 0 && items[_vlBlink.sel]) {
1557
- items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '').replace(/ █$/, ''));
1558
- }
1559
- _vlBlink.sel = cur;
1560
- if (items[cur]) {
1561
- const base = (items[cur].content ?? '').replace(/ █$/, '');
1562
- items[cur].setContent(_vlBlink.on ? `${base} █` : base);
1563
- }
1564
- screen.render();
1565
- }
1566
- voiceList.on('focus', () => {
1567
- _listFocused = true;
1568
- _vlBlink.on = true;
1569
- _vlBlink.sel = voiceList.selected ?? 0;
1570
- _hintIdx = -1;
1571
- _hintBase = '';
1572
- _updateHint(_vlBlink.sel);
1573
- const items = voiceList.items;
1574
- if (items[_vlBlink.sel]) items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '') + ' █');
1575
- if (!_playingVoiceId) previewLine.setContent(HINT_TEXT);
1576
- screen.render();
1577
- _vlBlink.interval = setInterval(_vlTick, 500);
1578
- });
1579
- voiceList.on('blur', () => {
1580
- _listFocused = false;
1581
- if (!_playingVoiceId) previewLine.setContent('');
1582
- if (_vlBlink.interval) { clearInterval(_vlBlink.interval); _vlBlink.interval = null; }
1583
- const items = voiceList.items;
1584
- const sel = voiceList.selected ?? 0;
1585
- if (items[sel]) {
1586
- items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1587
- }
1588
- if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1589
- items[_hintIdx].setContent(_hintBase);
1590
- }
1591
- _hintIdx = -1;
1592
- _hintBase = '';
1593
- screen.render();
1594
- });
1595
-
1596
- // Update info panel when selection changes
1597
- voiceList.on('select item', () => {
1598
- if (_refreshing) return;
1599
- _updateHint(voiceList.selected ?? 0);
1600
- if (_vlBlink.interval) _vlTick(); // move █ to newly selected row
1601
- const voices = _getFilteredVoices();
1602
- const sel = voices[voiceList.selected] ?? '';
1603
- infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1604
- screen.render();
1605
- });
1606
-
1607
- // Type-to-jump: press a letter to jump to first voice whose display name starts with it
1608
- const _voiceJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'f']);
1609
- voiceList.on('keypress', (ch, key) => {
1610
- if (!ch || key.ctrl || key.meta) return;
1611
- const lower = ch.toLowerCase();
1612
- if (!/^[a-z]$/.test(lower)) return;
1613
- if (_voiceJumpBlocked.has(lower)) return;
1614
- const voices = _getFilteredVoices();
1615
- const count = voices.length;
1616
- if (count === 0) return;
1617
- const start = voiceList.selected ?? 0;
1618
- for (let i = 1; i <= count; i++) {
1619
- const idx = (start + i) % count;
1620
- const name = getVoiceMeta(voices[idx]).displayName.toLowerCase();
1621
- if (name.startsWith(lower)) {
1622
- voiceList.select(idx);
1623
- screen.render();
1624
- break;
1625
- }
1626
- }
1627
- });
1628
-
1629
- // -------------------------------------------------------------------------
1630
- // Button-row keyboard navigation
1631
- // ↓ at the last list item → descend into the button row (Switch Voice gets focus first)
1632
- // Note: Tab is NOT used — navigation.js registers screen.key(['tab']) to cycle tabs,
1633
- // so element.key(['tab']) + screen.key(['tab']) both fire simultaneously.
1634
- voiceList.key(['down'], () => {
1635
- const voices = _getFilteredVoices();
1636
- if (voiceList.selected >= voices.length - 1) {
1637
- switchBtn.focus();
1638
- screen.render();
1639
- }
1640
- });
1641
-
1642
- // ←/→ navigate between the three buttons
1643
- switchBtn.key(['right'], () => { favoriteBtn.focus(); screen.render(); });
1644
- favoriteBtn.key(['right'], () => { installBtn.focus(); screen.render(); });
1645
- installBtn.key(['right'], () => { switchBtn.focus(); screen.render(); });
1646
- switchBtn.key(['left'], () => { installBtn.focus(); screen.render(); });
1647
- favoriteBtn.key(['left'], () => { switchBtn.focus(); screen.render(); });
1648
- installBtn.key(['left'], () => { favoriteBtn.focus(); screen.render(); });
1649
-
1650
- // or Escape from any button → back to voice list
1651
- switchBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1652
- favoriteBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1653
- installBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1654
-
1655
- // -------------------------------------------------------------------------
1656
- // Language refresh
1657
-
1658
- function refreshVoicesLabels() {
1659
- voicesSectionHdr.setContent(`{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`);
1660
- searchLabelText.setContent(_tl('searchLabel'));
1661
- colHeaderText.setContent(`{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`);
1662
- voiceInfoHdr.setContent(`{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`);
1663
- switchBtn.setContent(_tl('voicesSwitchBtn'));
1664
- favoriteBtn.setContent(_tl('voicesFavoriteBtn'));
1665
- installBtn.setContent(_tl('voicesDownloadBtn'));
1666
- screen.render();
1667
- }
1668
-
1669
- if (languageService) {
1670
- languageService.onChange(() => refreshVoicesLabels());
1671
- }
1672
-
1673
- // -------------------------------------------------------------------------
1674
- // Tab Component Contract
1675
-
1676
- return {
1677
- box,
1678
-
1679
- show() {
1680
- box.show();
1681
- refreshDisplay();
1682
- screen.render();
1683
- },
1684
-
1685
- hide() {
1686
- _killPlayingProcess();
1687
- _playingVoiceId = null;
1688
- if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1689
- previewLine.setContent('');
1690
- box.hide();
1691
- screen.render();
1692
- },
1693
-
1694
- onFocus() {
1695
- voiceList.focus();
1696
- screen.render();
1697
- },
1698
-
1699
- onBlur() {
1700
- _killPlayingProcess();
1701
- _playingVoiceId = null;
1702
- if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1703
- },
1704
-
1705
- getFooterText() {
1706
- return _tl('voicesFooter');
1707
- },
1708
-
1709
- getFooterColor() {
1710
- return COLORS.footerBg;
1711
- },
1712
- };
1713
- }
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
+ const _pad = ' '.repeat(60);
655
+ if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
656
+ const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
657
+ items[_hintIdx].setContent((hadBlink ? _hintBase + ' █' : _hintBase) + _pad);
658
+ }
659
+ if (idx >= 0 && items[idx]) {
660
+ let c = items[idx].content ?? '';
661
+ const hasBlink = c.endsWith(' █');
662
+ if (hasBlink) c = c.slice(0, -2);
663
+ _hintBase = c;
664
+ items[idx].setContent(c + _getRowHint(idx) + (hasBlink ? '' : ''));
665
+ } else {
666
+ _hintBase = '';
667
+ }
668
+ _hintIdx = idx;
669
+ }
670
+
671
+ // -------------------------------------------------------------------------
672
+ // Playback state
673
+
674
+ let _playingProcess = null;
675
+ let _playingVoiceId = null;
676
+ let _downloadProcess = null;
677
+
678
+ // Kill the entire process group so child audio players (piper, aplay, play) all die
679
+ function _killPlayingProcess() {
680
+ if (_playingProcess) {
681
+ try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
682
+ _playingProcess = null;
683
+ }
684
+ }
685
+
686
+ const _spawnEnv = buildAudioEnv();
687
+
688
+ /**
689
+ * Preview a voice by synthesizing a sample phrase with piper, then playing the wav.
690
+ * Second call with the same voice stops playback (toggle).
691
+ */
692
+ function _previewVoice(voiceId) {
693
+ // Toggle: second press stops
694
+ if (_playingVoiceId === voiceId) {
695
+ _killPlayingProcess();
696
+ _playingVoiceId = null;
697
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
698
+ screen.render();
699
+ return;
700
+ }
701
+
702
+ // Kill any current preview first
703
+ _killPlayingProcess();
704
+ _playingVoiceId = null;
705
+
706
+ // Resolve model path (may be multi-speaker)
707
+ const ms = parseMultiSpeaker(voiceId);
708
+ const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
709
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
710
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
711
+ return;
712
+ }
713
+
714
+ const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${Date.now()}.wav`);
715
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
716
+
717
+ // Synthesize: spawn piper; on Windows use the exe path directly
718
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
719
+ let piperBin = 'piper';
720
+ if (isWindows) {
721
+ const localAppData = process.env.LOCALAPPDATA ||
722
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
723
+ if (localAppData) {
724
+ const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
725
+ if (fs.existsSync(exePath)) piperBin = exePath;
726
+ }
727
+ }
728
+ const piperArgs = ['--model', voicePath, '--output_file', tempWav];
729
+ if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
730
+ // On Windows, avoid detached:true which opens a visible console window
731
+ const piper = spawn(piperBin, piperArgs, {
732
+ stdio: ['pipe', 'ignore', 'ignore'],
733
+ detached: !isWindows,
734
+ windowsHide: true,
735
+ env: _spawnEnv,
736
+ });
737
+ piper.stdin.write(phrase + '\n');
738
+ piper.stdin.end();
739
+
740
+ _playingProcess = piper;
741
+ _playingVoiceId = voiceId;
742
+
743
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Synthesizing: ${voiceId}…{/${COLORS.activeFg}-fg}`);
744
+ screen.render();
745
+
746
+ piper.on('exit', (code) => {
747
+ if (_playingVoiceId !== voiceId) {
748
+ // User stopped before synthesis finished
749
+ try { fs.unlinkSync(tempWav); } catch {}
750
+ return;
751
+ }
752
+
753
+ if (code !== 0) {
754
+ _playingVoiceId = null;
755
+ _playingProcess = null;
756
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Preview failed (piper error is piper installed?){/${COLORS.activeFg}-fg}`);
757
+ screen.render();
758
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
759
+ return;
760
+ }
761
+
762
+ // Play the synthesized wav in its own process group so we can kill it
763
+ const _wavP = detectWavPlayer(_spawnEnv);
764
+ if (!_wavP) {
765
+ _playingVoiceId = null;
766
+ _playingProcess = null;
767
+ previewLine.setContent(`{red-fg}No audio player found. Install ffmpeg.{/red-fg}`);
768
+ screen.render();
769
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
770
+ try { fs.unlinkSync(tempWav); } catch {}
771
+ return;
772
+ }
773
+ const playProc = spawn(_wavP.bin, _wavP.args(tempWav), {
774
+ stdio: 'ignore',
775
+ detached: !isWindows,
776
+ windowsHide: true,
777
+ env: _spawnEnv,
778
+ });
779
+ // Race note: _playingVoiceId could change between piper exit and here
780
+ // if the user stops playback. Re-check before assigning to avoid orphan.
781
+ if (_playingVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
782
+ _playingProcess = playProc;
783
+
784
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Playing: ${voiceId} (Enter/Space to stop){/${COLORS.activeFg}-fg}`);
785
+ screen.render();
786
+
787
+ playProc.on('exit', () => {
788
+ if (_playingVoiceId === voiceId) {
789
+ _playingVoiceId = null;
790
+ _playingProcess = null;
791
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
792
+ refreshDisplay(); // clears (playing) label
793
+ }
794
+ try { fs.unlinkSync(tempWav); } catch {}
795
+ });
796
+
797
+ playProc.on('error', () => {
798
+ _playingVoiceId = null;
799
+ _playingProcess = null;
800
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
801
+ try { fs.unlinkSync(tempWav); } catch {}
802
+ });
803
+ });
804
+
805
+ piper.on('error', () => {
806
+ _playingVoiceId = null;
807
+ _playingProcess = null;
808
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Cannot find piper — install with: pipx install piper-tts{/${COLORS.activeFg}-fg}`);
809
+ screen.render();
810
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
811
+ try { fs.unlinkSync(tempWav); } catch {}
812
+ });
813
+ }
814
+
815
+ // -------------------------------------------------------------------------
816
+ // Voice activation (handles multi-speaker model/speaker-id files)
817
+
818
+ function _activateVoice(voiceId) {
819
+ const ms = parseMultiSpeaker(voiceId);
820
+ const claudeDir = path.resolve(process.cwd(), '.claude');
821
+ try { fs.mkdirSync(claudeDir, { recursive: true }); } catch {}
822
+ // Always write tts-voice.txt so shell scripts pick up the voice on reload
823
+ try {
824
+ fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), voiceId, 'utf8');
825
+ } catch { /* non-fatal */ }
826
+ if (ms.isMultiSpeaker) {
827
+ // Store full MS ID (e.g., "16Speakers::Kristin_Hughes") so list matching works
828
+ providerService.setActiveVoice(voiceId);
829
+ try {
830
+ fs.writeFileSync(path.join(claudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
831
+ fs.writeFileSync(path.join(claudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
832
+ } catch { /* non-fatal */ }
833
+ } else {
834
+ providerService.setActiveVoice(voiceId);
835
+ // Clear multi-speaker files if switching to a single-speaker voice
836
+ try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
837
+ try { fs.unlinkSync(path.join(claudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
838
+ }
839
+ }
840
+
841
+ // -------------------------------------------------------------------------
842
+ // Buttons
843
+
844
+ function _createBtn(label, onClick) {
845
+ const btn = blessed.button({
846
+ parent: box,
847
+ content: label,
848
+ mouse: true,
849
+ keys: true,
850
+ shrink: true,
851
+ padding: { left: 1, right: 1 },
852
+ style: {
853
+ bg: COLORS.btnDefault,
854
+ fg: 'white',
855
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
856
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
857
+ },
858
+ });
859
+ btn.on('focus', () => {
860
+ btn.style.bg = COLORS.btnFocus;
861
+ btn.style.fg = COLORS.btnFocusFg;
862
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
863
+ btn.setContent(`►${raw}◄`);
864
+ screen.render();
865
+ });
866
+ btn.on('blur', () => {
867
+ btn.style.bg = COLORS.btnDefault;
868
+ btn.style.fg = 'white';
869
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
870
+ btn.setContent(raw);
871
+ screen.render();
872
+ });
873
+ btn.key(['enter', 'space'], () => {
874
+ btn.style.bg = COLORS.btnPress;
875
+ screen.render();
876
+ setTimeout(() => {
877
+ btn.style.bg = COLORS.btnDefault;
878
+ screen.render();
879
+ onClick();
880
+ }, 150);
881
+ });
882
+ btn.on('click', () => btn.press());
883
+ btn.on('mouseover', () => btn.focus());
884
+ return btn;
885
+ }
886
+
887
+ const switchBtn = _createBtn(_tl('voicesSwitchBtn'), () => {
888
+ const voices = _getFilteredVoices();
889
+ const selected = voices[voiceList.selected];
890
+ if (selected) {
891
+ _activateVoice(selected);
892
+ refreshDisplay();
893
+ }
894
+ });
895
+ switchBtn.bottom = 4;
896
+ switchBtn.left = 4;
897
+
898
+ const favoriteBtn = _createBtn(_tl('voicesFavoriteBtn'), () => {
899
+ const voices = _getFilteredVoices();
900
+ const selected = voices[voiceList.selected];
901
+ if (selected) {
902
+ toggleFavorite(configService, selected);
903
+ refreshDisplay();
904
+ }
905
+ });
906
+ favoriteBtn.bottom = 4;
907
+ favoriteBtn.left = 22;
908
+
909
+ const installBtn = _createBtn(_tl('voicesDownloadBtn'), () => {
910
+ const voices = _getFilteredVoices();
911
+ const selected = voices[voiceList.selected];
912
+ if (!selected) return;
913
+ if (_isInstalled(selected)) {
914
+ const notice = blessed.text({
915
+ parent: box,
916
+ top: 'center',
917
+ left: 'center',
918
+ content: 'Voice already installed. Scroll down to find uninstalled voices (greyed out).',
919
+ tags: true,
920
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
921
+ });
922
+ screen.render();
923
+ setTimeout(() => { notice.destroy(); screen.render(); }, 3000);
924
+ } else {
925
+ _openDownloadModal(selected);
926
+ }
927
+ });
928
+ installBtn.bottom = 4;
929
+ installBtn.left = 38;
930
+
931
+ // -------------------------------------------------------------------------
932
+ // "Voice Changed" notice — auto-dismisses after 2 s
933
+
934
+ function _showVoiceChangedNotice(displayName) {
935
+ const notice = blessed.box({
936
+ parent: screen,
937
+ top: 'center',
938
+ left: 'center',
939
+ width: 44,
940
+ height: 5,
941
+ border: { type: 'line' },
942
+ tags: true,
943
+ label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
944
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
945
+ content: `\n {${COLORS.activeFg}-fg}✓ Voice changed: ${displayName}{/${COLORS.activeFg}-fg}`,
946
+ });
947
+ notice.setFront();
948
+ screen.render();
949
+ setTimeout(() => { notice.destroy(); screen.render(); }, 2000);
950
+ }
951
+
952
+ // -------------------------------------------------------------------------
953
+ // Select-voice confirmation modal
954
+
955
+ function _activateVoiceGlobal(voiceId) {
956
+ // Save voice globally (all projects)
957
+ const ms = parseMultiSpeaker(voiceId);
958
+ const globalClaudeDir = path.resolve(os.homedir(), '.claude');
959
+ // Verify ownership before writing to global config dir
960
+ try {
961
+ const stat = fs.statSync(globalClaudeDir);
962
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
963
+ } catch {}
964
+ if (ms.isMultiSpeaker) {
965
+ configService.setGlobal('voice', voiceId);
966
+ try {
967
+ fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-model.txt'), ms.model, 'utf8');
968
+ fs.writeFileSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt'), String(ms.speakerId), 'utf8');
969
+ } catch { /* non-fatal */ }
970
+ } else {
971
+ configService.setGlobal('voice', voiceId);
972
+ try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-model.txt')); } catch { /* ok */ }
973
+ try { fs.unlinkSync(path.join(globalClaudeDir, 'tts-piper-speaker-id.txt')); } catch { /* ok */ }
974
+ }
975
+ // Also write global tts-voice.txt for shell scripts
976
+ try { fs.writeFileSync(path.join(globalClaudeDir, 'tts-voice.txt'), ms.isMultiSpeaker ? voiceId : voiceId, 'utf8'); } catch { /* ok */ }
977
+ }
978
+
979
+ function _openSelectVoiceModal(voiceId) {
980
+ const { displayName } = getVoiceMeta(voiceId);
981
+
982
+ const modal = blessed.box({
983
+ parent: screen,
984
+ top: 'center',
985
+ left: 'center',
986
+ width: 72,
987
+ height: 8,
988
+ border: { type: 'line' },
989
+ tags: true,
990
+ label: ` {${COLORS.activeFg}-fg}Set Default Voice{/${COLORS.activeFg}-fg} `,
991
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
992
+ });
993
+
994
+ blessed.text({
995
+ parent: modal,
996
+ top: 1,
997
+ left: 2,
998
+ right: 2,
999
+ content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your default voice?`,
1000
+ tags: true,
1001
+ style: { bg: COLORS.contentBg },
1002
+ });
1003
+
1004
+ // Status line shows playback state while modal is open
1005
+ const modalStatus = blessed.text({
1006
+ parent: modal,
1007
+ top: 3,
1008
+ left: 2,
1009
+ right: 2,
1010
+ tags: true,
1011
+ content: `{${COLORS.dimFg}-fg}Press Preview to audition this voice{/${COLORS.dimFg}-fg}`,
1012
+ style: { bg: COLORS.contentBg },
1013
+ });
1014
+
1015
+ function _close() {
1016
+ _killPlayingProcess();
1017
+ _playingVoiceId = null;
1018
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
1019
+ modal.destroy();
1020
+ voiceList.focus();
1021
+ screen.render();
1022
+ }
1023
+
1024
+ // Note: blessed's destroy() does not remove key listeners from child buttons,
1025
+ // so modal button handlers may leak. This is a known blessed limitation.
1026
+ function _makeBtn(label, bg, left, top, onClick) {
1027
+ const btn = blessed.button({
1028
+ parent: modal,
1029
+ content: label,
1030
+ top,
1031
+ left,
1032
+ mouse: true,
1033
+ keys: true,
1034
+ shrink: true,
1035
+ padding: { left: 1, right: 1 },
1036
+ style: {
1037
+ bg,
1038
+ fg: 'white',
1039
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1040
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1041
+ },
1042
+ });
1043
+ btn.key(['enter', 'space'], () => { _close(); onClick(); });
1044
+ btn.on('click', () => btn.press());
1045
+ return btn;
1046
+ }
1047
+
1048
+ const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
1049
+ _activateVoice(voiceId);
1050
+ refreshDisplay();
1051
+ _showVoiceChangedNotice(displayName);
1052
+ });
1053
+ const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
1054
+ _activateVoice(voiceId);
1055
+ _activateVoiceGlobal(voiceId);
1056
+ refreshDisplay();
1057
+ _showVoiceChangedNotice(displayName);
1058
+ });
1059
+ const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
1060
+
1061
+ // Preview button — does NOT close the modal; plays/stops the voice inline
1062
+ const previewBtn = blessed.button({
1063
+ parent: modal,
1064
+ content: 'Preview',
1065
+ top: 5,
1066
+ left: 58,
1067
+ mouse: true,
1068
+ keys: true,
1069
+ shrink: true,
1070
+ padding: { left: 1, right: 1 },
1071
+ style: {
1072
+ bg: '#e65100',
1073
+ fg: 'white',
1074
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1075
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1076
+ },
1077
+ });
1078
+ previewBtn.key(['enter', 'space'], () => {
1079
+ const isPlaying = _playingVoiceId === voiceId;
1080
+ _previewVoice(voiceId);
1081
+ modalStatus.setContent(isPlaying
1082
+ ? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
1083
+ : `{${COLORS.activeFg}-fg}♪ Playing: ${displayName}…{/${COLORS.activeFg}-fg}`
1084
+ );
1085
+ screen.render();
1086
+ });
1087
+ previewBtn.on('click', () => previewBtn.press());
1088
+
1089
+ // Tab/arrow navigation: SaveLocal SaveGlobal → Cancel → Preview → SaveLocal
1090
+ okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
1091
+ okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1092
+ cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
1093
+ previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
1094
+ previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1095
+ cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
1096
+ okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
1097
+ okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
1098
+
1099
+ modal.key(['escape', 'q'], _close);
1100
+
1101
+ modal.setFront();
1102
+ okLocalBtn.focus();
1103
+ screen.render();
1104
+ }
1105
+
1106
+ // -------------------------------------------------------------------------
1107
+ // Download modal for uninstalled catalog voices
1108
+
1109
+ function _openDownloadModal(voiceId) {
1110
+ const cat = _catalogMap.get(voiceId);
1111
+ const displayName = cat?.displayName ?? voiceId;
1112
+ const modelToDownload = cat?.type === 'libritts' ? 'en_US-libritts-high' : (cat?.model ?? voiceId);
1113
+ const isLibriTTS = cat?.type === 'libritts';
1114
+
1115
+ const modal = blessed.box({
1116
+ parent: screen,
1117
+ top: 'center',
1118
+ left: 'center',
1119
+ width: 64,
1120
+ height: 10,
1121
+ border: { type: 'line' },
1122
+ tags: true,
1123
+ label: ` {${COLORS.activeFg}-fg}Download Voice{/${COLORS.activeFg}-fg} `,
1124
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
1125
+ });
1126
+
1127
+ const msgLine = blessed.text({
1128
+ parent: modal,
1129
+ top: 1,
1130
+ left: 2,
1131
+ right: 2,
1132
+ tags: true,
1133
+ content: `Download {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg}?\n\n` +
1134
+ `Model: {${COLORS.activeFg}-fg}${modelToDownload}{/${COLORS.activeFg}-fg}` +
1135
+ (isLibriTTS ? ` (~57 MB — unlocks all 904 LibriTTS speakers)` : ` (~25 MB)`),
1136
+ style: { bg: COLORS.contentBg },
1137
+ });
1138
+
1139
+ const statusLine = blessed.text({
1140
+ parent: modal,
1141
+ top: 5,
1142
+ left: 2,
1143
+ right: 2,
1144
+ tags: true,
1145
+ content: '',
1146
+ style: { bg: COLORS.contentBg },
1147
+ });
1148
+
1149
+ let _downloading = false;
1150
+
1151
+ function _close() {
1152
+ modal.destroy();
1153
+ voiceList.focus();
1154
+ screen.render();
1155
+ }
1156
+
1157
+ function _startDownload() {
1158
+ if (_downloading) return;
1159
+ _downloading = true;
1160
+
1161
+ // Animated spinner
1162
+ const spinFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1163
+ let spinIdx = 0;
1164
+ let dlPhase = 'Downloading model';
1165
+ const progressBar = (pct) => {
1166
+ const filled = Math.round(pct / 5);
1167
+ const empty = 20 - filled;
1168
+ return '█'.repeat(filled) + '░'.repeat(empty);
1169
+ };
1170
+
1171
+ const spinTimer = setInterval(() => {
1172
+ spinIdx = (spinIdx + 1) % spinFrames.length;
1173
+ const frame = spinFrames[spinIdx];
1174
+ statusLine.setContent(
1175
+ `{${COLORS.activeFg}-fg}${frame} ${dlPhase}… ${modelToDownload}{/${COLORS.activeFg}-fg}`
1176
+ );
1177
+ screen.render();
1178
+ }, 100);
1179
+
1180
+ // Download voice model use PowerShell on Windows, bash on Unix
1181
+ const packageRoot = path.resolve(__dirname, '..', '..', '..');
1182
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1183
+ let dlProc;
1184
+
1185
+ if (isWindows) {
1186
+ const piperVoicesDir = resolvePiperVoicesDir();
1187
+ const hfBase = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
1188
+ const match = modelToDownload.match(/^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$/);
1189
+ let modelUrl, configUrl;
1190
+ if (match) {
1191
+ const [, lang, region, speaker, quality] = match;
1192
+ const hfPath = `${lang}/${lang}_${region}/${speaker}/${quality}`;
1193
+ modelUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx`;
1194
+ configUrl = `${hfBase}/${hfPath}/${modelToDownload}.onnx.json`;
1195
+ } else {
1196
+ const customBase = 'https://huggingface.co/agentvibes/piper-custom-voices/resolve/main';
1197
+ modelUrl = `${customBase}/${modelToDownload}.onnx`;
1198
+ configUrl = `${customBase}/${modelToDownload}.onnx.json`;
1199
+ }
1200
+ const modelFile = path.join(piperVoicesDir, `${modelToDownload}.onnx`);
1201
+ const configFile = path.join(piperVoicesDir, `${modelToDownload}.onnx.json`);
1202
+ // PowerShell script with progress reporting
1203
+ const psScript = `
1204
+ $ErrorActionPreference = 'Stop'
1205
+ $ProgressPreference = 'SilentlyContinue'
1206
+ $voicesDir = '${piperVoicesDir.replace(/'/g, "''")}'
1207
+ if (-not (Test-Path $voicesDir)) { New-Item -ItemType Directory -Path $voicesDir -Force | Out-Null }
1208
+ Write-Output 'PHASE:model'
1209
+ Invoke-WebRequest -Uri '${modelUrl}' -OutFile '${modelFile.replace(/'/g, "''")}' -ErrorAction Stop
1210
+ Write-Output 'PHASE:config'
1211
+ Invoke-WebRequest -Uri '${configUrl}' -OutFile '${configFile.replace(/'/g, "''")}' -ErrorAction Stop
1212
+ Write-Output 'PHASE:done'
1213
+ `;
1214
+ dlProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], {
1215
+ stdio: ['ignore', 'pipe', 'pipe'],
1216
+ env: _spawnEnv,
1217
+ });
1218
+ } else {
1219
+ const managerScript = path.resolve(packageRoot, '.claude', 'hooks', 'piper-voice-manager.sh');
1220
+ dlProc = spawn('bash', ['-c', 'source "$1" && download_voice "$2"', '_', managerScript, modelToDownload], {
1221
+ stdio: ['ignore', 'pipe', 'pipe'],
1222
+ env: _spawnEnv,
1223
+ });
1224
+ }
1225
+ _downloadProcess = dlProc;
1226
+
1227
+ let output = '';
1228
+ dlProc.stdout.on('data', (d) => {
1229
+ const chunk = d.toString();
1230
+ output += chunk;
1231
+ // Update phase based on progress markers
1232
+ if (chunk.includes('PHASE:config') || chunk.includes('config file')) {
1233
+ dlPhase = 'Downloading config';
1234
+ } else if (chunk.includes('PHASE:done') || chunk.includes('successfully')) {
1235
+ dlPhase = 'Finishing up';
1236
+ }
1237
+ });
1238
+ dlProc.stderr.on('data', (d) => { output += d.toString(); });
1239
+
1240
+ dlProc.on('exit', (code) => {
1241
+ clearInterval(spinTimer);
1242
+ _downloading = false;
1243
+ _downloadProcess = null;
1244
+ if (code === 0) {
1245
+ if (isLibriTTS) {
1246
+ patchLibriTTSSpeakerNames();
1247
+ _metaCache.clear();
1248
+ }
1249
+ statusLine.setContent(`{green-fg}✓ Downloaded successfully!{/green-fg}`);
1250
+ screen.render();
1251
+ setTimeout(() => {
1252
+ _close();
1253
+ refreshDisplay();
1254
+ }, 1500);
1255
+ } else {
1256
+ statusLine.setContent(`{red-fg}✗ Download failed. ${output.slice(-80).trim()}{/red-fg}`);
1257
+ screen.render();
1258
+ }
1259
+ });
1260
+
1261
+ dlProc.on('error', () => {
1262
+ clearInterval(spinTimer);
1263
+ _downloading = false;
1264
+ _downloadProcess = null;
1265
+ statusLine.setContent(`{red-fg}✗ Could not run download script{/red-fg}`);
1266
+ screen.render();
1267
+ });
1268
+ }
1269
+
1270
+ function _makeBtn(label, bg, left, onClick) {
1271
+ const btn = blessed.button({
1272
+ parent: modal,
1273
+ content: label,
1274
+ top: 7,
1275
+ left,
1276
+ mouse: true,
1277
+ keys: true,
1278
+ shrink: true,
1279
+ padding: { left: 1, right: 1 },
1280
+ style: {
1281
+ bg,
1282
+ fg: 'white',
1283
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1284
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
1285
+ },
1286
+ });
1287
+ btn.key(['enter', 'space'], onClick);
1288
+ btn.on('click', () => btn.press());
1289
+ return btn;
1290
+ }
1291
+
1292
+ const dlBtn = _makeBtn('Download', COLORS.btnDefault, 2, _startDownload);
1293
+ const cancelBtn = _makeBtn('Cancel', '#546e7a', 16, _close);
1294
+
1295
+ dlBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
1296
+ cancelBtn.key(['tab', 'right'], () => { dlBtn.focus(); screen.render(); });
1297
+ dlBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
1298
+ cancelBtn.key(['left'], () => { dlBtn.focus(); screen.render(); });
1299
+
1300
+ modal.key(['escape', 'q'], () => { if (!_downloading) _close(); });
1301
+
1302
+ modal.setFront();
1303
+ dlBtn.focus();
1304
+ screen.render();
1305
+ }
1306
+
1307
+ // -------------------------------------------------------------------------
1308
+ // State
1309
+
1310
+ let _allVoices = []; // voice IDs (installed first, then catalog-only)
1311
+ let _installedSet = new Set(); // which IDs are locally installed
1312
+ let _filterText = '';
1313
+
1314
+ function _getFilteredVoices() {
1315
+ if (!_filterText) return _allVoices;
1316
+ const f = _filterText.toLowerCase();
1317
+ return _allVoices.filter(v => {
1318
+ if (v.toLowerCase().includes(f)) return true;
1319
+ // Also search by catalog display name
1320
+ const cat = _catalogMap.get(v);
1321
+ if (cat && cat.displayName.toLowerCase().includes(f)) return true;
1322
+ return false;
1323
+ });
1324
+ }
1325
+
1326
+ function _isInstalled(voiceId) {
1327
+ return _installedSet.has(voiceId);
1328
+ }
1329
+
1330
+ function _buildListItems(voices, active, favorites) {
1331
+ return voices.map(v => {
1332
+ const installed = _isInstalled(v);
1333
+ const isFav = favorites.includes(v);
1334
+ const isActive = v === active;
1335
+ const isPrev = v === _playingVoiceId;
1336
+ const star = isFav ? '★' : ' ';
1337
+ const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
1338
+
1339
+ let displayName, gender, provider;
1340
+ if (installed) {
1341
+ const meta = getVoiceMeta(v);
1342
+ displayName = meta.displayName;
1343
+ gender = meta.gender;
1344
+ provider = meta.provider;
1345
+ } else {
1346
+ // Catalog-only voice use catalog metadata
1347
+ const cat = _catalogMap.get(v);
1348
+ displayName = cat?.displayName ?? v;
1349
+ gender = cat?.gender ?? '—';
1350
+ provider = cat?.type === 'libritts' ? 'Piper (LibriTTS)' : 'Piper';
1351
+ }
1352
+
1353
+ const name = displayName.length > COL_NAME_W
1354
+ ? displayName.slice(0, COL_NAME_W - 1) + '…'
1355
+ : displayName.padEnd(COL_NAME_W);
1356
+
1357
+ if (!installed) {
1358
+ // Greyed-out row for uninstalled catalog voices
1359
+ return `{bright-black-fg} ${star} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}{/bright-black-fg}`;
1360
+ }
1361
+ return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1362
+ });
1363
+ }
1364
+
1365
+ // Build a tagged info string with yellow labels for the info panel
1366
+ function _formatInfoTagged(voiceId) {
1367
+ const Y = COLORS.valueFg; // #ffd700 yellow
1368
+
1369
+ // Uninstalled catalog voice — show download prompt
1370
+ if (!_isInstalled(voiceId)) {
1371
+ const cat = _catalogMap.get(voiceId);
1372
+ const name = cat?.displayName ?? voiceId;
1373
+ const gender = cat?.gender ?? '';
1374
+ const model = cat?.type === 'libritts' ? 'LibriTTS High (multi-speaker)' : (cat?.model ?? voiceId);
1375
+ return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1376
+ `{${Y}-fg}${_tl('voiceInfoGender')}{/${Y}-fg} ${_tGender(gender)} ` +
1377
+ `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${model} ` +
1378
+ `{bright-yellow-fg}${_tl('voiceInfoDownload')}{/bright-yellow-fg}`;
1379
+ }
1380
+
1381
+ const ms = parseMultiSpeaker(voiceId);
1382
+ if (ms.isMultiSpeaker) {
1383
+ const name = ms.speakerName.replace(/_/g, ' ');
1384
+ return `{${Y}-fg}${_tl('voiceInfoSpeaker')}{/${Y}-fg} ${name} ` +
1385
+ `{${Y}-fg}${_tl('voiceInfoModel')}{/${Y}-fg} ${ms.model} ` +
1386
+ `{${Y}-fg}${_tl('voiceInfoSpeakerId')}{/${Y}-fg} ${ms.speakerId ?? '?'} ` +
1387
+ `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1388
+ }
1389
+ const { lang, name, quality } = parseVoiceId(voiceId);
1390
+ if (lang === 'unknown') {
1391
+ return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${voiceId} {${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper`;
1392
+ }
1393
+ return `{${Y}-fg}${_tl('voiceInfoVoice')}{/${Y}-fg} ${name} ` +
1394
+ `{${Y}-fg}${_tl('voiceInfoLanguage')}{/${Y}-fg} ${lang} ` +
1395
+ `{${Y}-fg}${_tl('voiceInfoQuality')}{/${Y}-fg} ${quality} ` +
1396
+ `{${Y}-fg}${_tl('voiceInfoProvider')}{/${Y}-fg} Piper ` +
1397
+ `{${Y}-fg}${_tl('voiceInfoId')}{/${Y}-fg} ${voiceId}`;
1398
+ }
1399
+
1400
+ function refreshDisplay() {
1401
+ _refreshing = true;
1402
+ const savedIdx = voiceList.selected ?? 0;
1403
+ const savedScroll = voiceList.childBase ?? 0;
1404
+
1405
+ // Load catalog on first refresh and patch speaker names (once)
1406
+ if (!_catalogLoaded) {
1407
+ loadCatalog();
1408
+ patchLibriTTSSpeakerNames();
1409
+ _metaCache.clear();
1410
+ }
1411
+
1412
+ // Installed voices (from local disk)
1413
+ const installed = scanInstalledVoices();
1414
+ _installedSet = new Set(installed);
1415
+
1416
+ // Merge: installed voices first, then uninstalled catalog voices
1417
+ const catalogOnly = _catalogEntries
1418
+ .filter(c => !_installedSet.has(c.voiceId))
1419
+ .map(c => c.voiceId);
1420
+ _allVoices = [...installed, ...catalogOnly];
1421
+
1422
+ const active = providerService.getActiveVoiceId();
1423
+ const favorites = getFavorites(configService);
1424
+ const filtered = _getFilteredVoices();
1425
+ const items = _buildListItems(filtered, active, favorites);
1426
+
1427
+ voiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
1428
+ const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
1429
+ voiceList.select(Math.min(savedIdx, maxIdx));
1430
+ voiceList.childBase = Math.min(savedScroll, Math.max(0, (items.length > 0 ? items.length : 1) - (voiceList.height - 2)));
1431
+
1432
+ // Re-apply inline hint if list is focused
1433
+ if (_listFocused) {
1434
+ _hintIdx = -1;
1435
+ _hintBase = '';
1436
+ _updateHint(voiceList.selected ?? 0);
1437
+ }
1438
+
1439
+ // Update info panel for currently selected item
1440
+ const sel = filtered[voiceList.selected] ?? active ?? '';
1441
+ infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1442
+
1443
+ // Update "Currently Selected" header
1444
+ if (active) {
1445
+ const _msA = parseMultiSpeaker(active);
1446
+ const activeName = _msA.isMultiSpeaker ? _msA.speakerName : active;
1447
+ const meta = _isInstalled(active) ? getVoiceMeta(active) : null;
1448
+ const displayName = meta?.displayName ?? activeName;
1449
+ activeVoiceText.setContent(`{green-fg}✓ ${displayName}{/green-fg}`);
1450
+ } else {
1451
+ activeVoiceText.setContent(`{bright-black-fg}No voice selected{/bright-black-fg}`);
1452
+ }
1453
+
1454
+ _refreshing = false;
1455
+ if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
1456
+ screen.render();
1457
+ }
1458
+
1459
+ // -------------------------------------------------------------------------
1460
+ // Search box interaction
1461
+
1462
+ searchBox.on('keypress', () => {
1463
+ // Update filter after keystroke
1464
+ setTimeout(() => {
1465
+ _filterText = searchBox.getValue().trim();
1466
+ refreshDisplay();
1467
+ }, 0);
1468
+ });
1469
+
1470
+ // Pressing Escape in search returns focus to voiceList
1471
+ searchBox.key(['escape'], () => {
1472
+ voiceList.focus();
1473
+ screen.render();
1474
+ });
1475
+
1476
+ // Page Up / Page Down — jump ~20 items at a time
1477
+ const PAGE_SIZE = 20;
1478
+ voiceList.key(['pagedown'], () => {
1479
+ const voices = _getFilteredVoices();
1480
+ const maxIdx = Math.max(0, voices.length - 1);
1481
+ voiceList.select(Math.min((voiceList.selected ?? 0) + PAGE_SIZE, maxIdx));
1482
+ screen.render();
1483
+ });
1484
+ voiceList.key(['pageup'], () => {
1485
+ voiceList.select(Math.max((voiceList.selected ?? 0) - PAGE_SIZE, 0));
1486
+ screen.render();
1487
+ });
1488
+
1489
+ // Pressing '/' in voiceList focuses search box
1490
+ voiceList.key(['/'], () => {
1491
+ searchBox.clearValue();
1492
+ searchBox.focus();
1493
+ screen.render();
1494
+ });
1495
+
1496
+ // at the top of the list jump to main header tab bar
1497
+ // [↑] at top of list → jump to main header tab bar (second press)
1498
+ let _prevVoiceSel = -1;
1499
+ voiceList.key(['up'], () => {
1500
+ const cur = voiceList.selected ?? 0;
1501
+ if (cur === 0 && _prevVoiceSel === 0 && typeof focusMainTabBar === 'function') {
1502
+ focusMainTabBar();
1503
+ setTimeout(() => { voiceList.select(0); screen.render(); }, 0);
1504
+ }
1505
+ _prevVoiceSel = cur;
1506
+ });
1507
+
1508
+ // Escape at the list level → return to header tab bar
1509
+ voiceList.key(['escape'], () => {
1510
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1511
+ });
1512
+
1513
+ // 'f' or '*' in voiceList toggles favorite
1514
+ voiceList.key(['f', '*'], () => {
1515
+ const voices = _getFilteredVoices();
1516
+ const selected = voices[voiceList.selected];
1517
+ if (selected) {
1518
+ toggleFavorite(configService, selected);
1519
+ refreshDisplay();
1520
+ }
1521
+ });
1522
+
1523
+ // Space preview voice (toggle: second press stops playback)
1524
+ // Only works for installed voices — uninstalled need download first
1525
+ voiceList.key(['space'], () => {
1526
+ const voices = _getFilteredVoices();
1527
+ const selected = voices[voiceList.selected];
1528
+ if (!selected) return;
1529
+ if (!_isInstalled(selected)) {
1530
+ previewLine.setContent(`{bright-yellow-fg}⬇ Voice not installed — press [Enter] to download first{/bright-yellow-fg}`);
1531
+ screen.render();
1532
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 3000);
1533
+ return;
1534
+ }
1535
+ _previewVoice(selected);
1536
+ refreshDisplay();
1537
+ });
1538
+
1539
+ // Enter → open "Set as default voice" or download uninstalled voice
1540
+ voiceList.key(['enter'], () => {
1541
+ const voices = _getFilteredVoices();
1542
+ const selected = voices[voiceList.selected];
1543
+ if (!selected) return;
1544
+ _killPlayingProcess();
1545
+ _playingVoiceId = null;
1546
+ previewLine.setContent('');
1547
+ screen.render();
1548
+
1549
+ if (_isInstalled(selected)) {
1550
+ _openSelectVoiceModal(selected);
1551
+ } else {
1552
+ _openDownloadModal(selected);
1553
+ }
1554
+ });
1555
+
1556
+ // Blinking on selected row while list is focused
1557
+ let _vlBlink = { interval: null, on: false, sel: -1 };
1558
+ process.on('exit', () => { if (_vlBlink.interval) clearInterval(_vlBlink.interval); });
1559
+ function _vlTick() {
1560
+ _vlBlink.on = !_vlBlink.on;
1561
+ const items = voiceList.items;
1562
+ const cur = voiceList.selected ?? 0;
1563
+ if (_vlBlink.sel !== cur && _vlBlink.sel >= 0 && items[_vlBlink.sel]) {
1564
+ items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '').replace(/ █$/, ''));
1565
+ }
1566
+ _vlBlink.sel = cur;
1567
+ if (items[cur]) {
1568
+ const base = (items[cur].content ?? '').replace(/ █$/, '');
1569
+ items[cur].setContent(_vlBlink.on ? `${base} █` : base);
1570
+ }
1571
+ screen.render();
1572
+ }
1573
+ voiceList.on('focus', () => {
1574
+ _listFocused = true;
1575
+ _vlBlink.on = true;
1576
+ _vlBlink.sel = voiceList.selected ?? 0;
1577
+ _hintIdx = -1;
1578
+ _hintBase = '';
1579
+ _updateHint(_vlBlink.sel);
1580
+ const items = voiceList.items;
1581
+ if (items[_vlBlink.sel]) items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '') + ' █');
1582
+ if (!_playingVoiceId) previewLine.setContent(HINT_TEXT);
1583
+ screen.render();
1584
+ _vlBlink.interval = setInterval(_vlTick, 500);
1585
+ });
1586
+ voiceList.on('blur', () => {
1587
+ _listFocused = false;
1588
+ if (!_playingVoiceId) previewLine.setContent('');
1589
+ if (_vlBlink.interval) { clearInterval(_vlBlink.interval); _vlBlink.interval = null; }
1590
+ const items = voiceList.items;
1591
+ const sel = voiceList.selected ?? 0;
1592
+ if (items[sel]) {
1593
+ items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1594
+ }
1595
+ if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1596
+ items[_hintIdx].setContent(_hintBase);
1597
+ }
1598
+ _hintIdx = -1;
1599
+ _hintBase = '';
1600
+ screen.render();
1601
+ });
1602
+
1603
+ // Update info panel when selection changes
1604
+ voiceList.on('select item', () => {
1605
+ if (_refreshing) return;
1606
+ _updateHint(voiceList.selected ?? 0);
1607
+ if (_vlBlink.interval) _vlTick(); // move to newly selected row
1608
+ const voices = _getFilteredVoices();
1609
+ const sel = voices[voiceList.selected] ?? '';
1610
+ infoLine.setContent(` ${_formatInfoTagged(sel)}`);
1611
+ screen.render();
1612
+ });
1613
+
1614
+ // Type-to-jump: press a letter to jump to first voice whose display name starts with it
1615
+ const _voiceJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'f']);
1616
+ voiceList.on('keypress', (ch, key) => {
1617
+ if (!ch || key.ctrl || key.meta) return;
1618
+ const lower = ch.toLowerCase();
1619
+ if (!/^[a-z]$/.test(lower)) return;
1620
+ if (_voiceJumpBlocked.has(lower)) return;
1621
+ const voices = _getFilteredVoices();
1622
+ const count = voices.length;
1623
+ if (count === 0) return;
1624
+ const start = voiceList.selected ?? 0;
1625
+ for (let i = 1; i <= count; i++) {
1626
+ const idx = (start + i) % count;
1627
+ const name = getVoiceMeta(voices[idx]).displayName.toLowerCase();
1628
+ if (name.startsWith(lower)) {
1629
+ voiceList.select(idx);
1630
+ screen.render();
1631
+ break;
1632
+ }
1633
+ }
1634
+ });
1635
+
1636
+ // -------------------------------------------------------------------------
1637
+ // Button-row keyboard navigation
1638
+ // ↓ at the last list item → descend into the button row (Switch Voice gets focus first)
1639
+ // Note: Tab is NOT used — navigation.js registers screen.key(['tab']) to cycle tabs,
1640
+ // so element.key(['tab']) + screen.key(['tab']) both fire simultaneously.
1641
+ voiceList.key(['down'], () => {
1642
+ const voices = _getFilteredVoices();
1643
+ if (voiceList.selected >= voices.length - 1) {
1644
+ switchBtn.focus();
1645
+ screen.render();
1646
+ }
1647
+ });
1648
+
1649
+ // ←/→ navigate between the three buttons
1650
+ switchBtn.key(['right'], () => { favoriteBtn.focus(); screen.render(); });
1651
+ favoriteBtn.key(['right'], () => { installBtn.focus(); screen.render(); });
1652
+ installBtn.key(['right'], () => { switchBtn.focus(); screen.render(); });
1653
+ switchBtn.key(['left'], () => { installBtn.focus(); screen.render(); });
1654
+ favoriteBtn.key(['left'], () => { switchBtn.focus(); screen.render(); });
1655
+ installBtn.key(['left'], () => { favoriteBtn.focus(); screen.render(); });
1656
+
1657
+ // ↑ or Escape from any button → back to voice list
1658
+ switchBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1659
+ favoriteBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1660
+ installBtn.key(['up', 'escape'], () => { voiceList.focus(); screen.render(); });
1661
+
1662
+ // -------------------------------------------------------------------------
1663
+ // Language refresh
1664
+
1665
+ function refreshVoicesLabels() {
1666
+ voicesSectionHdr.setContent(`{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`);
1667
+ searchLabelText.setContent(_tl('searchLabel'));
1668
+ colHeaderText.setContent(`{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`);
1669
+ voiceInfoHdr.setContent(`{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`);
1670
+ switchBtn.setContent(_tl('voicesSwitchBtn'));
1671
+ favoriteBtn.setContent(_tl('voicesFavoriteBtn'));
1672
+ installBtn.setContent(_tl('voicesDownloadBtn'));
1673
+ screen.render();
1674
+ }
1675
+
1676
+ if (languageService) {
1677
+ languageService.onChange(() => refreshVoicesLabels());
1678
+ }
1679
+
1680
+ // -------------------------------------------------------------------------
1681
+ // Tab Component Contract
1682
+
1683
+ return {
1684
+ box,
1685
+
1686
+ show() {
1687
+ box.show();
1688
+ refreshDisplay();
1689
+ screen.render();
1690
+ },
1691
+
1692
+ hide() {
1693
+ _killPlayingProcess();
1694
+ _playingVoiceId = null;
1695
+ if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1696
+ previewLine.setContent('');
1697
+ box.hide();
1698
+ screen.render();
1699
+ },
1700
+
1701
+ onFocus() {
1702
+ voiceList.focus();
1703
+ screen.render();
1704
+ },
1705
+
1706
+ onBlur() {
1707
+ _killPlayingProcess();
1708
+ _playingVoiceId = null;
1709
+ if (_downloadProcess) { try { _downloadProcess.kill(); } catch {} _downloadProcess = null; }
1710
+ },
1711
+
1712
+ getFooterText() {
1713
+ return _tl('voicesFooter');
1714
+ },
1715
+
1716
+ getFooterColor() {
1717
+ return COLORS.footerBg;
1718
+ },
1719
+ };
1720
+ }