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