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.
- package/.agentvibes/config.json +4 -4
- package/.claude/config/reverb-level.txt +1 -1
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks-windows/bmad-speak.ps1 +112 -0
- package/.claude/hooks-windows/play-tts-piper.ps1 +3 -4
- package/.claude/hooks-windows/play-tts-sapi.ps1 +3 -4
- package/.claude/hooks-windows/play-tts-soprano.ps1 +2 -3
- package/.claude/hooks-windows/play-tts-termux-ssh.ps1 +138 -0
- package/.claude/hooks-windows/play-tts.ps1 +14 -6
- package/.claude/hooks-windows/provider-manager.ps1 +16 -1
- package/CLAUDE.md +4 -0
- package/README.md +39 -9
- package/RELEASE_NOTES.md +39 -0
- package/bin/agent-vibes +1 -1
- package/bin/agentvibes-voice-browser.js +1 -1
- package/bin/bmad-speak.js +52 -0
- package/bin/mcp-server.js +1 -1
- package/bin/test-bmad-pr +1 -1
- package/package.json +1 -1
- package/setup-windows.ps1 +4 -4
- package/src/console/app.js +58 -11
- package/src/console/tabs/agents-tab.js +61 -65
- package/src/console/tabs/help-tab.js +107 -54
- package/src/console/tabs/install-tab.js +107 -47
- package/src/console/tabs/music-tab.js +1030 -1011
- package/src/console/tabs/placeholder-tab.js +27 -0
- package/src/console/tabs/readme-tab.js +9 -7
- package/src/console/tabs/receiver-tab.js +23 -12
- package/src/console/tabs/settings-tab.js +4001 -3783
- package/src/console/tabs/voices-tab.js +1680 -1653
- package/src/console/widgets/personality-picker.js +35 -7
- package/src/console/widgets/reverb-picker.js +9 -6
- package/src/console/widgets/track-picker.js +6 -1
- package/src/i18n/de.js +201 -0
- package/src/i18n/en.js +201 -0
- package/src/i18n/es.js +201 -0
- package/src/i18n/fr.js +201 -0
- package/src/i18n/hi.js +201 -0
- package/src/i18n/ja.js +201 -0
- package/src/i18n/ko.js +201 -0
- package/src/i18n/pt.js +201 -0
- package/src/i18n/strings.js +54 -0
- package/src/i18n/zh-CN.js +201 -0
- package/src/installer/language-screen.js +31 -0
- package/src/installer.js +79 -25
- package/src/services/language-service.js +47 -0
- package/src/utils/file-ownership-verifier.js +2 -2
- package/src/utils/provider-validator.js +9 -13
- package/.claude/hooks-windows/play-tts-windows-piper.ps1 +0 -209
- 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
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
style: {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
let
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
if (
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
_hintBase
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
piper.
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
previewLine.setContent(
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
btn.
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
});
|
|
838
|
-
btn.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
switchBtn
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
favoriteBtn
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
:
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
});
|
|
1043
|
-
previewBtn.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
okLocalBtn.focus();
|
|
1059
|
-
screen.render();
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
screen.render();
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
const
|
|
1340
|
-
return `{${Y}-fg}
|
|
1341
|
-
`{${Y}-fg}
|
|
1342
|
-
`{${Y}-fg}
|
|
1343
|
-
`{
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
.
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
searchBox.
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
voiceList.
|
|
1436
|
-
screen.render();
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
});
|
|
1458
|
-
|
|
1459
|
-
//
|
|
1460
|
-
voiceList.key(['
|
|
1461
|
-
if (typeof focusMainTabBar === 'function') {
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
_vlBlink.
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
_vlBlink.
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
//
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
+
}
|