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