agentvibes 5.1.3 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.agentvibes/config.json +23 -13
  2. package/.claude/commands/agent-vibes/verbosity.md +98 -89
  3. package/.claude/config/audio-effects.cfg +6 -1
  4. package/.claude/hooks/bmad-speak.sh +2 -2
  5. package/.claude/hooks/piper-download-voices.sh +233 -225
  6. package/.claude/hooks/piper-installer.sh +1 -1
  7. package/.claude/hooks/piper-voice-manager.sh +125 -0
  8. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +97 -90
  9. package/.claude/hooks/play-tts-enhanced.sh +1 -1
  10. package/.claude/hooks/play-tts-piper.sh +16 -5
  11. package/.claude/hooks/play-tts-ssh-remote.sh +168 -167
  12. package/.claude/hooks/play-tts.sh +31 -9
  13. package/.claude/hooks/session-start-tts.sh +4 -1
  14. package/.claude/hooks/stop-tts.sh +1 -1
  15. package/.claude/hooks/verbosity-manager.sh +185 -178
  16. package/.claude/hooks-windows/download-extra-voices.ps1 +243 -185
  17. package/.claude/hooks-windows/play-tts-piper.ps1 +7 -2
  18. package/.claude/hooks-windows/play-tts.ps1 +219 -65
  19. package/.claude/hooks-windows/session-start-tts.ps1 +2 -1
  20. package/.claude/hooks-windows/verbosity-manager.ps1 +126 -119
  21. package/README.md +24 -1
  22. package/RELEASE_NOTES.md +113 -0
  23. package/bin/agentvibes-voice-browser.js +1939 -1840
  24. package/mcp-server/server.py +75 -25
  25. package/package.json +1 -1
  26. package/src/console/tabs/receiver-tab.js +1527 -1483
  27. package/src/console/tabs/settings-tab.js +2 -2
  28. package/src/console/tabs/setup-tab.js +122 -20
  29. package/src/console/tabs/voices-tab.js +130 -13
  30. package/src/i18n/en.js +202 -202
  31. package/src/installer.js +29 -25
  32. package/src/services/llm-provider-service.js +114 -11
  33. package/src/services/verbosity-service.js +159 -157
  34. package/templates/agentvibes-receiver.sh +3 -2
@@ -1,1841 +1,1940 @@
1
1
  #!/usr/bin/env node
2
-
3
- /**
4
- * AgentVibes Voice Browser
5
- * Browse and preview 914+ Piper TTS voices
6
- * Press 'I' to install/select a voice for AgentVibes
7
- */
8
-
9
- import blessed from 'blessed';
10
- import chalk from 'chalk';
11
- import { exec, spawn, spawnSync } from 'child_process';
12
- import { promisify } from 'util';
13
- import fs from 'fs/promises';
14
- import fsSync from 'fs';
15
- import path from 'path';
16
- import { fileURLToPath } from 'url';
17
- import os from 'os';
18
-
19
- const execAsync = promisify(exec);
20
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
-
22
- const CONFIG = {
23
- MODEL_PATH: path.join(os.homedir(), '.local/share/piper/en_US-libritts-high.onnx'),
24
- TOTAL_SPEAKERS: 904,
25
- TOTAL_CURATED: 10,
26
- TOTAL_ITEMS: 914,
27
- SAMPLE_TEXT: 'Hello! This is a sample of my voice. I can speak clearly and naturally with expression.',
28
- OUTPUT_DIR: path.join(os.homedir(), '.cache/agentvibes/voice-samples'),
29
- CURATED_DIR: path.join(os.homedir(), '.cache/agentvibes/curated-samples'),
30
- PROGRESS_FILE: path.join(os.homedir(), '.cache/agentvibes/browser-progress.json'),
31
- PIPER_PATH: path.join(os.homedir(), '.local/bin/piper'),
32
- PIPER_VOICES_DIR: path.join(os.homedir(), '.local/share/piper/voices'),
33
- AGENTVIBES_CONFIG: path.join(os.homedir(), '.agentvibes/config.json'),
34
- VOICE_METADATA: path.join(__dirname, '..', '.agentvibes', 'config', 'voice-metadata.json')
35
- };
36
-
37
- // Sample script templates showcasing AgentVibes features
38
- const SAMPLE_TEMPLATES = [
39
- "Hi, I'm {NAME}. AgentVibes supports multiple TTS providers including Piper for local processing, Windows SAPI, macOS system voices, and Soprano. Choose the best fit for your platform.",
40
- "Hey there, I'm {NAME}! AgentVibes supports Soprano, a high-quality neural TTS engine that produces incredibly natural-sounding voices. The audio quality is seriously impressive.",
41
- "Good day, I'm {NAME}. AgentVibes integrates with PulseAudio to stream TTS from headless remote servers to your local machine. Essential when developing on voiceless cloud instances.",
42
- "Hi, I'm {NAME}. AgentVibes provides access to over thirty-seven Piper voices, plus system voices from Windows, macOS, and Linux. Maximum flexibility for your needs.",
43
- "Hey team, I'm {NAME}! AgentVibes lets you add custom background music to your TTS output. Jazz, lo-fi, classical—whatever helps you stay in the zone while coding!",
44
- "Oh wonderful, I'm {NAME}. AgentVibes has a sarcastic personality mode. Because clearly what your development workflow was missing was an AI with attitude. How delightful.",
45
- "Hi, I'm {NAME}. AgentVibes includes a receiver mode that lets you stream TTS from one machine to another. Perfect for using remote servers while hearing audio on your local device.",
46
- "Hi there, I'm {NAME}! AgentVibes includes audio effects like reverb, pitch adjustment, and EQ. Add some atmosphere and personality to your AI assistant's voice!",
47
- "Hello, I'm {NAME}. AgentVibes includes a bundled MCP server that makes configuration incredibly easy. Just use natural language to configure voices, personalities, and settings.",
48
- "Good afternoon, I'm {NAME}. If you're enjoying AgentVibes, we'd be tremendously grateful for a GitHub star. Your support helps the project grow and improve."
49
- ];
50
-
51
- class AgentVibesVoiceBrowser {
52
- constructor() {
53
- this.tableData = [];
54
- this.filteredData = [];
55
- this.currentRow = 0;
56
- this.sortColumn = 'id';
57
- this.sortAsc = true;
58
- this.searchTerm = '';
59
- this.favorites = new Set();
60
- this.favoritesOnly = false; // Filter to show only favorites
61
- this.providerFilter = null; // Filter by provider (null = all)
62
- this.sampleText = CONFIG.SAMPLE_TEXT;
63
- this.playing = false;
64
- this.currentAudioProcess = null;
65
- this.voiceAssignments = null;
66
- this.voiceMetadata = null;
67
- this.currentTab = 'voices'; // 'voices' or 'music'
68
- this.musicTracks = [];
69
- this.currentMusicSelection = null;
70
- this.musicEnabled = false;
71
- this.currentlyPlayingTrack = null; // Track which music track is currently playing
72
- this.musicFavorites = new Set(); // Favorite music tracks
73
- }
74
-
75
- async init() {
76
- await fs.mkdir(CONFIG.OUTPUT_DIR, { recursive: true });
77
- await fs.mkdir(CONFIG.CURATED_DIR, { recursive: true });
78
- await fs.mkdir(path.dirname(CONFIG.PROGRESS_FILE), { recursive: true });
79
-
80
- // Clean up old cached samples (without text hash in filename)
81
- try {
82
- const files = await fs.readdir(CONFIG.OUTPUT_DIR);
83
- for (const file of files) {
84
- if (file.match(/^speaker_\d+\.wav$/)) {
85
- await fs.unlink(path.join(CONFIG.OUTPUT_DIR, file));
86
- }
87
- }
88
- } catch (e) {
89
- // Ignore cleanup errors
90
- }
91
-
92
- await this.loadProgress();
93
- await this.loadVoiceData();
94
- await this.loadMusicData();
95
- this.prepareTable();
96
- this.setupUI();
97
- }
98
-
99
- async loadProgress() {
100
- try {
101
- const data = JSON.parse(await fs.readFile(CONFIG.PROGRESS_FILE, 'utf8'));
102
- this.favorites = new Set(data.favorites || []);
103
- this.musicFavorites = new Set(data.musicFavorites || []);
104
- this.sampleText = data.sampleText || CONFIG.SAMPLE_TEXT;
105
- this.sortColumn = data.sortColumn || 'id';
106
- this.sortAsc = data.sortAsc !== undefined ? data.sortAsc : true;
107
- } catch (error) {
108
- // No previous progress
109
- }
110
- }
111
-
112
- async saveProgress() {
113
- await fs.writeFile(CONFIG.PROGRESS_FILE, JSON.stringify({
114
- favorites: Array.from(this.favorites),
115
- musicFavorites: Array.from(this.musicFavorites),
116
- sampleText: this.sampleText,
117
- sortColumn: this.sortColumn,
118
- sortAsc: this.sortAsc
119
- }, null, 2));
120
- }
121
-
122
- async detectProviders() {
123
- const providers = [];
124
-
125
- // Check for macOS Say
126
- if (process.platform === 'darwin') {
127
- try {
128
- const result = spawnSync('which', ['say'], { encoding: 'utf8', timeout: 1000 });
129
- if (result.status === 0) {
130
- providers.push('macos');
131
- }
132
- } catch {
133
- // Silently skip if check fails
134
- }
135
- }
136
-
137
- // Check for Windows SAPI (not available in WSL)
138
- if (process.platform === 'win32') {
139
- providers.push('windows-sapi');
140
- }
141
-
142
- // Check for Soprano TTS
143
- try {
144
- // Try to start Soprano if available
145
- const ensureScript = path.join(__dirname, 'ensure-soprano-running.sh');
146
- if (fsSync.existsSync(ensureScript)) {
147
- try {
148
- spawnSync('bash', [ensureScript], { encoding: 'utf8', timeout: 5000 });
149
- } catch {
150
- // Failed to start, skip silently
151
- }
152
- }
153
-
154
- // Check if Soprano server is responding
155
- const curlResult = spawnSync('curl', ['-s', '-m', '1', 'http://127.0.0.1:7860/openapi.json'], { encoding: 'utf8', timeout: 2000 });
156
- if (curlResult.status === 0 && curlResult.stdout && curlResult.stdout.includes('Soprano')) {
157
- providers.push('soprano');
158
- }
159
- } catch {
160
- // Silently skip if detection fails
161
- }
162
-
163
- return providers;
164
- }
165
-
166
- async loadMusicData() {
167
- // Load background music tracks
168
- const homeDir = process.env.HOME || process.env.USERPROFILE;
169
- let tracksDir = path.join(homeDir, '.claude', 'audio', 'tracks');
170
-
171
- // If running from project directory, also check project's .claude/audio/tracks
172
- if (!fsSync.existsSync(tracksDir)) {
173
- const projectTracksDir = path.join(__dirname, '..', '.claude', 'audio', 'tracks');
174
- if (fsSync.existsSync(projectTracksDir)) {
175
- tracksDir = projectTracksDir;
176
- }
177
- }
178
-
179
- try {
180
- const files = await fs.readdir(tracksDir);
181
- this.musicTracks = files
182
- .filter(f => f.endsWith('.mp3') && !f.startsWith('.'))
183
- .map(file => ({
184
- file,
185
- name: file.replace(/^agent_vibes_|^agentvibes_|_v\d+|_loop\.mp3$/g, '').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
186
- path: path.join(tracksDir, file)
187
- }))
188
- .sort((a, b) => a.name.localeCompare(b.name));
189
-
190
- // Load current music selection
191
- const musicConfigFile = path.join(homeDir, '.claude', 'config', 'background-music.txt');
192
- try {
193
- this.currentMusicSelection = (await fs.readFile(musicConfigFile, 'utf8')).trim();
194
- } catch {
195
- this.currentMusicSelection = null;
196
- }
197
-
198
- // Load music enabled status
199
- const musicEnabledFile = path.join(homeDir, '.claude', 'config', 'background-music-enabled.txt');
200
- try {
201
- const enabled = (await fs.readFile(musicEnabledFile, 'utf8')).trim();
202
- this.musicEnabled = enabled === 'true';
203
- } catch {
204
- this.musicEnabled = false;
205
- }
206
- } catch (error) {
207
- this.musicTracks = [];
208
- }
209
- }
210
-
211
- async loadMacOSVoices() {
212
- try {
213
- const { stdout } = await execAsync('say -v ? 2>/dev/null');
214
- const voices = [];
215
- const lines = stdout.trim().split('\n');
216
-
217
- for (const line of lines) {
218
- const match = line.match(/^(\S+)\s+(\S+)\s+#\s*(.+)/);
219
- if (match) {
220
- const [, name, lang, description] = match;
221
- voices.push({
222
- name,
223
- language: lang,
224
- description: description || '',
225
- provider: 'macos'
226
- });
227
- }
228
- }
229
- return voices;
230
- } catch {
231
- return [];
232
- }
233
- }
234
-
235
- async loadWindowsSAPIVoices() {
236
- try {
237
- const psScript = 'Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | ForEach-Object { $_.VoiceInfo | Select-Object Name, Gender, Culture | ConvertTo-Json -Compress }';
238
- const { stdout } = await execAsync(`powershell -Command "${psScript}"`, { timeout: 5000 });
239
- const voices = [];
240
- const lines = stdout.trim().split('\n').filter(l => l.trim());
241
-
242
- for (const line of lines) {
243
- try {
244
- const voice = JSON.parse(line);
245
- // SECURITY: Validate expected schema from PowerShell output (#133)
246
- if (voice && typeof voice.Name === 'string' && voice.Name.length > 0) {
247
- voices.push({
248
- name: voice.Name,
249
- gender: typeof voice.Gender === 'string' ? voice.Gender.toLowerCase() : 'unknown',
250
- language: typeof voice.Culture === 'string' ? voice.Culture : 'en-US',
251
- provider: 'windows-sapi'
252
- });
253
- }
254
- } catch {}
255
- }
256
- return voices;
257
- } catch {
258
- return [];
259
- }
260
- }
261
-
262
- async loadSopranoVoices() {
263
- // Soprano TTS currently has only one voice
264
- // It uses OpenAI API format but ignores the voice parameter
265
- return [
266
- {
267
- name: 'Soprano',
268
- language: 'en-US',
269
- provider: 'soprano',
270
- description: 'Neural TTS voice'
271
- }
272
- ];
273
- }
274
-
275
- async loadVoiceData() {
276
- // Detect available providers
277
- this.availableProviders = await this.detectProviders();
278
-
279
- // Load voice assignments (for LibriTTS speakers)
280
- const assignmentsPath = path.join(__dirname, '..', 'voice-assignments.json');
281
- if (fsSync.existsSync(assignmentsPath)) {
282
- this.voiceAssignments = JSON.parse(await fs.readFile(assignmentsPath, 'utf8'));
283
- } else {
284
- // Generate basic assignments if file doesn't exist
285
- console.log(chalk.yellow('⚠ voice-assignments.json not found, generating basic data...'));
286
- this.voiceAssignments = {
287
- libritts_speakers: {},
288
- curated_voices: {}
289
- };
290
-
291
- // Generate basic speaker assignments
292
- for (let id = 0; id < CONFIG.TOTAL_SPEAKERS; id++) {
293
- this.voiceAssignments.libritts_speakers[id] = {
294
- gender: id % 2 === 0 ? 'male' : 'female',
295
- voice_name: `Speaker ${id}`
296
- };
297
- }
298
- }
299
-
300
- // Load voice metadata (for curated voices)
301
- if (fsSync.existsSync(CONFIG.VOICE_METADATA)) {
302
- this.voiceMetadata = JSON.parse(await fs.readFile(CONFIG.VOICE_METADATA, 'utf8'));
303
-
304
- // Merge curated voices into assignments
305
- if (this.voiceMetadata && this.voiceMetadata.voices) {
306
- let curatedId = 1000; // Start curated voices at ID 1000
307
- for (const [friendlyName, voice] of Object.entries(this.voiceMetadata.voices)) {
308
- this.voiceAssignments.curated_voices[curatedId] = {
309
- gender: voice.gender,
310
- voice_name: voice.displayName,
311
- model_file: voice.id,
312
- friendly_name: friendlyName
313
- };
314
- curatedId++;
315
- }
316
- }
317
- }
318
-
319
- // Load voices from other providers
320
- this.otherProviderVoices = {
321
- macos: [],
322
- 'windows-sapi': [],
323
- soprano: []
324
- };
325
-
326
- if (this.availableProviders.includes('macos')) {
327
- this.otherProviderVoices.macos = await this.loadMacOSVoices();
328
- }
329
-
330
- if (this.availableProviders.includes('windows-sapi')) {
331
- this.otherProviderVoices['windows-sapi'] = await this.loadWindowsSAPIVoices();
332
- }
333
-
334
- if (this.availableProviders.includes('soprano')) {
335
- this.otherProviderVoices.soprano = await this.loadSopranoVoices();
336
- }
337
- }
338
-
339
- prepareTable() {
340
- this.tableData = [];
341
- let nextId = 0;
342
-
343
- // Add LibriTTS speakers
344
- for (let id = 0; id < CONFIG.TOTAL_SPEAKERS; id++) {
345
- const assignment = this.voiceAssignments.libritts_speakers[id];
346
- if (assignment) {
347
- // Assign random sample template with voice name
348
- const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
349
- const sampleText = template.replace('{NAME}', assignment.voice_name);
350
-
351
- this.tableData.push({
352
- id: nextId++,
353
- originalId: id,
354
- gender: assignment.gender,
355
- name: assignment.voice_name,
356
- model: 'LibriTTS',
357
- type: 'libritts',
358
- provider: 'Piper',
359
- piperVoiceId: `speaker-${id}`,
360
- sampleText: sampleText,
361
- language: 'en_US'
362
- });
363
- }
364
- }
365
-
366
- // Add curated voices
367
- for (const [id, curated] of Object.entries(this.voiceAssignments.curated_voices)) {
368
- // Assign random sample template with voice name
369
- const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
370
- const sampleText = template.replace('{NAME}', curated.voice_name);
371
-
372
- // Extract language from model file (e.g., en_US-amy-medium -> en_US)
373
- const langMatch = curated.model_file.match(/^([a-z]{2}_[A-Z]{2})/);
374
- const language = langMatch ? langMatch[1] : 'en_US';
375
-
376
- this.tableData.push({
377
- id: nextId++,
378
- originalId: parseInt(id),
379
- gender: curated.gender,
380
- name: curated.voice_name,
381
- model: curated.model_file,
382
- type: 'curated',
383
- provider: 'Piper',
384
- piperVoiceId: curated.model_file,
385
- friendlyName: curated.friendly_name,
386
- sampleText: sampleText,
387
- language: language
388
- });
389
- }
390
-
391
- // Add macOS voices
392
- for (const voice of this.otherProviderVoices.macos || []) {
393
- const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
394
- const sampleText = template.replace('{NAME}', voice.name);
395
-
396
- this.tableData.push({
397
- id: nextId++,
398
- gender: 'unknown',
399
- name: voice.name,
400
- model: 'macOS Say',
401
- type: 'macos',
402
- provider: 'macOS',
403
- sampleText: sampleText,
404
- language: voice.language || 'en_US'
405
- });
406
- }
407
-
408
- // Add Windows SAPI voices
409
- for (const voice of this.otherProviderVoices['windows-sapi'] || []) {
410
- const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
411
- const sampleText = template.replace('{NAME}', voice.name);
412
-
413
- this.tableData.push({
414
- id: nextId++,
415
- gender: voice.gender || 'unknown',
416
- name: voice.name,
417
- model: 'Windows SAPI',
418
- type: 'windows-sapi',
419
- provider: 'Windows',
420
- sampleText: sampleText,
421
- language: voice.language || 'en-US'
422
- });
423
- }
424
-
425
- // Add Soprano voices
426
- for (const voice of this.otherProviderVoices.soprano || []) {
427
- const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
428
- const sampleText = template.replace('{NAME}', voice.name);
429
-
430
- this.tableData.push({
431
- id: nextId++,
432
- gender: 'unknown',
433
- name: voice.name,
434
- model: 'Soprano',
435
- type: 'soprano',
436
- provider: 'Soprano',
437
- sampleText: sampleText,
438
- language: voice.language || 'en-US'
439
- });
440
- }
441
-
442
- this.applyFilter();
443
- }
444
-
445
- applyFilter() {
446
- // Start with all voices or favorites only
447
- let data = this.favoritesOnly
448
- ? this.tableData.filter(row => this.favorites.has(row.id))
449
- : [...this.tableData];
450
-
451
- // Apply provider filter
452
- if (this.providerFilter) {
453
- data = data.filter(row => row.provider === this.providerFilter);
454
- }
455
-
456
- // Apply search filter
457
- if (this.searchTerm) {
458
- const term = this.searchTerm.toLowerCase();
459
- data = data.filter(row =>
460
- row.id.toString().includes(term) ||
461
- row.gender.includes(term) ||
462
- row.name.toLowerCase().includes(term) ||
463
- row.model.toLowerCase().includes(term) ||
464
- row.language.toLowerCase().includes(term) ||
465
- row.provider.toLowerCase().includes(term)
466
- );
467
- }
468
-
469
- this.filteredData = data;
470
-
471
- // Sort
472
- this.filteredData.sort((a, b) => {
473
- let aVal = a[this.sortColumn];
474
- let bVal = b[this.sortColumn];
475
- if (typeof aVal === 'string') aVal = aVal.toLowerCase();
476
- if (typeof bVal === 'string') bVal = bVal.toLowerCase();
477
- if (aVal < bVal) return this.sortAsc ? -1 : 1;
478
- if (aVal > bVal) return this.sortAsc ? 1 : -1;
479
- return 0;
480
- });
481
- }
482
-
483
- formatRow(row) {
484
- const fav = this.favorites.has(row.id) ? '*' : ' ';
485
- const genderIcon = row.gender === 'male' ? '♂' : (row.gender === 'female' ? '♀' : '-');
486
- const genderColor = row.gender === 'male' ? 'blue-fg' : (row.gender === 'female' ? 'magenta-fg' : 'gray-fg');
487
- const gender = `{${genderColor}}${genderIcon}{/${genderColor}}`;
488
- const id = String(row.id).padStart(4);
489
- const name = row.name.substring(0, 13).padEnd(13);
490
- const provider = row.provider.substring(0, 8).padEnd(8);
491
- const lang = row.language.substring(0, 6).padEnd(6);
492
- const model = row.model.substring(0, 15).padEnd(15);
493
- return `${fav} ${id} ${gender} ${name} ${provider} ${lang} ${model}`;
494
- }
495
-
496
- setupUI() {
497
- this.screen = blessed.screen({ smartCSR: true, title: 'AgentVibes Voice Browser' });
498
-
499
- // Calculate unique models and store as instance variable
500
- this.uniqueModels = new Set(this.tableData.map(row => row.model)).size;
501
-
502
- const title = blessed.box({
503
- top: 0,
504
- height: 1,
505
- width: '100%',
506
- content: `{center}{bold}{cyan-fg}Agent{/cyan-fg} {magenta-fg}Vibes{/magenta-fg} {gray-fg}v1.0{/gray-fg} {yellow-fg}Voice Browser{/yellow-fg}{/bold}{/center}`,
507
- tags: true,
508
- style: { fg: 'white' }
509
- });
510
-
511
- const headerBar = blessed.box({
512
- top: 1,
513
- height: 4,
514
- width: '100%',
515
- content: `{center}{gray-fg}github.com/paulpreibisch/agentvibes{/gray-fg} {white-fg}www.agentvibes.org{/white-fg}{/center}\n{center}{red-fg}[T]{/red-fg}Tabs {cyan-fg}[1-6]{/cyan-fg}Sort {cyan-fg}[/]{/cyan-fg}Search {cyan-fg}[P]{/cyan-fg}Prompt {cyan-fg}[L]{/cyan-fg}Filter {cyan-fg}[F/X]{/cyan-fg}Fav {cyan-fg}[Space]{/cyan-fg}Play {cyan-fg}[*]{/cyan-fg}★ {cyan-fg}[I]{/cyan-fg}Install{/center}`,
516
- tags: true,
517
- padding: 0,
518
- border: { type: 'line', fg: 'gray' },
519
- style: {
520
- bg: 'black',
521
- fg: 'white',
522
- border: { bg: 'black' }
523
- }
524
- });
525
-
526
- // Tab bar
527
- this.tabBar = blessed.box({
528
- top: 5,
529
- height: 1,
530
- width: '100%',
531
- tags: true,
532
- mouse: true,
533
- clickable: true,
534
- style: { fg: 'white', bg: 'black' }
535
- });
536
-
537
- // Voices Tab Content
538
- this.voicesContainer = blessed.box({
539
- top: 6,
540
- left: 0,
541
- width: '100%',
542
- height: '100%-11',
543
- hidden: false
544
- });
545
-
546
- this.tableHeader = blessed.box({
547
- top: 0,
548
- left: 0,
549
- height: 1,
550
- width: '70%',
551
- content: ` ID G Name Provider Lang Model `,
552
- style: { fg: 'cyan', bold: true },
553
- mouse: true,
554
- clickable: true
555
- });
556
-
557
- this.list = blessed.list({
558
- top: 1,
559
- left: 0,
560
- width: '70%',
561
- height: '100%-1',
562
- keys: true,
563
- vi: true,
564
- mouse: true,
565
- tags: true,
566
- style: {
567
- selected: { bg: 'blue', fg: 'white', bold: true },
568
- item: { fg: 'white' },
569
- border: { fg: 'cyan' },
570
- label: { fg: 'gray' }
571
- },
572
- border: { type: 'line', fg: 'cyan' },
573
- label: ` Voices (${this.filteredData.length}) - Model (${this.uniqueModels}) - Sort: ${this.sortColumn} ${this.sortAsc ? '↑' : '↓'} `
574
- });
575
-
576
- this.infoPanel = blessed.box({
577
- top: 0,
578
- left: '70%',
579
- width: '30%',
580
- height: '100%',
581
- tags: true,
582
- border: { type: 'line', fg: 'cyan' },
583
- label: ' Voice Info ',
584
- scrollable: true,
585
- alwaysScroll: true,
586
- mouse: true,
587
- keys: true,
588
- vi: true,
589
- style: {
590
- border: { fg: 'cyan' },
591
- label: { fg: 'gray' }
592
- }
593
- });
594
-
595
- this.voicesContainer.append(this.tableHeader);
596
- this.voicesContainer.append(this.list);
597
- this.voicesContainer.append(this.infoPanel);
598
-
599
- // Music Tab Content
600
- this.musicContainer = blessed.box({
601
- top: 6,
602
- left: 0,
603
- width: '100%',
604
- height: '100%-11',
605
- hidden: true
606
- });
607
-
608
- this.musicList = blessed.list({
609
- top: 0,
610
- left: 0,
611
- width: '70%',
612
- height: '100%',
613
- keys: true,
614
- vi: true,
615
- mouse: true,
616
- tags: true,
617
- style: {
618
- selected: { bg: 'blue', fg: 'white', bold: true },
619
- item: { fg: 'white' },
620
- border: { fg: 'cyan' },
621
- label: { fg: 'gray' }
622
- },
623
- border: { type: 'line', fg: 'cyan' },
624
- label: ` Background Music (${this.musicTracks.length} tracks) `
625
- });
626
-
627
- this.musicInfo = blessed.box({
628
- top: 0,
629
- left: '70%',
630
- width: '30%',
631
- height: '100%',
632
- tags: true,
633
- border: { type: 'line', fg: 'cyan' },
634
- label: ' Track Info ',
635
- content: '',
636
- padding: 1,
637
- style: {
638
- border: { fg: 'cyan' },
639
- label: { fg: 'gray' }
640
- }
641
- });
642
-
643
- this.musicContainer.append(this.musicList);
644
- this.musicContainer.append(this.musicInfo);
645
-
646
- this.statusBar = blessed.box({
647
- bottom: 4,
648
- height: 1,
649
- width: '100%',
650
- content: 'Ready',
651
- tags: true,
652
- style: { fg: 'green' }
653
- });
654
-
655
- this.helpBar = blessed.box({
656
- bottom: 1,
657
- height: 3,
658
- width: '100%',
659
- content: '{cyan-fg}[1-6]{/cyan-fg}Sort {cyan-fg}[/]{/cyan-fg}Search {cyan-fg}[P]{/cyan-fg}Prompt {cyan-fg}[L]{/cyan-fg}Filter {cyan-fg}[F/X]{/cyan-fg}Fav {cyan-fg}[Space]{/cyan-fg}Play {cyan-fg}[R]{/cyan-fg}Reverb {cyan-fg}[*]{/cyan-fg}★ {cyan-fg}[I]{/cyan-fg}Install {cyan-fg}[Nav]{/cyan-fg}Keys',
660
- tags: true,
661
- padding: 0,
662
- border: { type: 'line', fg: 'gray' },
663
- style: {
664
- bg: 'black',
665
- fg: 'white',
666
- border: { bg: 'black' }
667
- }
668
- });
669
-
670
- this.githubMessage = blessed.box({
671
- bottom: 0,
672
- height: 1,
673
- width: '100%',
674
- content: '{center}{gray-fg}Please consider giving us a GitHub star *{/gray-fg} {yellow-fg}github.com/paulpreibisch/agentvibes{/yellow-fg}{/center}',
675
- tags: true,
676
- style: { fg: 'white' }
677
- });
678
-
679
- this.screen.append(title);
680
- this.screen.append(headerBar);
681
- this.screen.append(this.tabBar);
682
- this.screen.append(this.voicesContainer);
683
- this.screen.append(this.musicContainer);
684
- this.screen.append(this.statusBar);
685
- this.screen.append(this.helpBar);
686
- this.screen.append(this.githubMessage);
687
-
688
- this.updateTabBar();
689
- this.updateMusicList();
690
-
691
- this.updateList();
692
- this.list.focus();
693
- this.setupKeys();
694
- this.screen.render();
695
- }
696
-
697
- updateList() {
698
- const items = this.filteredData.map(row => this.formatRow(row));
699
- this.list.setItems(items);
700
- this.list.select(Math.min(this.currentRow, items.length - 1));
701
-
702
- const modeLabel = this.favoritesOnly ? ' * Favorites ' : ' Voices ';
703
- this.list.setLabel(`${modeLabel}(${this.filteredData.length}) - Model (${this.uniqueModels}) - Sort: ${this.sortColumn} ${this.sortAsc ? '↑' : '↓'} `);
704
- this.updateInfo();
705
- }
706
-
707
- updateInfo() {
708
- const idx = this.list.selected;
709
- if (idx < 0 || idx >= this.filteredData.length) return;
710
-
711
- const row = this.filteredData[idx];
712
- let info = `{bold}${row.type === 'curated' ? row.name : 'Speaker ' + row.id}{/bold}\n`;
713
- info += `{gray-fg}${''.repeat(20)}{/gray-fg}\n\n`;
714
- if (this.favorites.has(row.id)) info += '{yellow-fg}* Favorite{/yellow-fg}\n\n';
715
- info += `{cyan-fg}ID:{/cyan-fg} ${row.id}\n`;
716
-
717
- // Color gender value: blue for male, pink for female
718
- const genderColor = row.gender === 'male' ? 'blue-fg' : 'magenta-fg';
719
- info += `{cyan-fg}Gender:{/cyan-fg} {${genderColor}}${row.gender}{/${genderColor}}\n`;
720
-
721
- info += `{cyan-fg}Voice:{/cyan-fg} ${row.name}\n`;
722
- info += `{cyan-fg}Provider:{/cyan-fg} {green-fg}${row.provider}{/green-fg}\n`;
723
- info += `{cyan-fg}Language:{/cyan-fg} ${row.language}\n`;
724
-
725
- // Color model in yellow
726
- info += `{cyan-fg}Model:{/cyan-fg} {yellow-fg}${row.model}{/yellow-fg}\n`;
727
-
728
- if (row.type === 'curated' && row.friendlyName) {
729
- info += `{cyan-fg}Friendly:{/cyan-fg} ${row.friendlyName}\n`;
730
- }
731
-
732
- // Color sample text in green - use voice-specific sample
733
- const voiceSample = row.sampleText || this.sampleText;
734
- info += `\n{gray-fg}Sample:{/gray-fg}\n{green-fg}"${voiceSample}"{/green-fg}\n`;
735
-
736
- info += `\n{cyan-fg}Position:{/cyan-fg} ${idx + 1}/${this.filteredData.length}\n`;
737
- info += `{cyan-fg}Favorites:{/cyan-fg} ${this.favorites.size}\n\n`;
738
- info += `{green-fg}[I]{/green-fg} Install voice {cyan-fg}[P]{/cyan-fg} Copy prompt`;
739
-
740
- this.infoPanel.setContent(info);
741
- this.screen.render();
742
- }
743
-
744
- updateTabBar() {
745
- const voicesTab = this.currentTab === 'voices'
746
- ? '{black-bg}{magenta-fg}[V]{/magenta-fg} {cyan-fg}Voices{/cyan-fg}{/black-bg}'
747
- : '{gray-fg}[V] Voices{/gray-fg}';
748
- const musicTab = this.currentTab === 'music'
749
- ? '{black-bg}{red-fg}[B]{/red-fg} {cyan-fg}🎶 Background Music{/cyan-fg}{/black-bg}'
750
- : '{gray-fg}[B] 🎶 Background Music{/gray-fg}';
751
-
752
- this.tabBar.setContent(` ${voicesTab} │ ${musicTab}`);
753
- this.screen.render();
754
- }
755
-
756
- switchTab(tab) {
757
- this.currentTab = tab;
758
-
759
- if (tab === 'voices') {
760
- this.voicesContainer.show();
761
- this.musicContainer.hide();
762
- this.list.focus();
763
- } else {
764
- this.voicesContainer.hide();
765
- this.musicContainer.show();
766
- this.musicList.focus();
767
- }
768
-
769
- this.updateTabBar();
770
- this.screen.render();
771
- }
772
-
773
- updateMusicList() {
774
- const items = this.musicTracks.map(track => {
775
- const isCurrent = track.file === this.currentMusicSelection;
776
- const isFavorite = this.musicFavorites.has(track.file);
777
- const isEnabled = this.musicEnabled ? '🔊' : '🔇';
778
- const marker = isCurrent ? `{cyan-fg}▶{/cyan-fg}` : ' ';
779
- const favMarker = isFavorite ? '*' : ' ';
780
- return `${marker}${favMarker} ${track.name} ${isCurrent ? isEnabled : ''}`;
781
- });
782
-
783
- this.musicList.setItems(items);
784
-
785
- // Update music info
786
- this.updateMusicInfo();
787
- }
788
-
789
- updateMusicInfo() {
790
- const enabledText = this.musicEnabled ? '{green-fg}Enabled{/green-fg}' : '{red-fg}Disabled{/red-fg}';
791
- const currentTrack = this.currentMusicSelection
792
- ? this.musicTracks.find(t => t.file === this.currentMusicSelection)?.name || 'None'
793
- : 'None';
794
-
795
- let content = '{cyan-fg}{bold}Background Music{/bold}{/cyan-fg}\n\n';
796
- content += `Status: ${enabledText}\n\n`;
797
- content += `Current Track:\n{yellow-fg}${currentTrack}{/yellow-fg}\n\n`;
798
- content += '{gray-fg}Controls:{/gray-fg}\n';
799
- content += '{cyan-fg}Space{/cyan-fg} - Preview track\n';
800
- content += '{cyan-fg}Enter{/cyan-fg} - Select track\n';
801
- content += '{cyan-fg}F/*{/cyan-fg} - Favorite\n';
802
- content += '{cyan-fg}M{/cyan-fg} - Toggle on/off\n';
803
- content += '{cyan-fg}R{/cyan-fg} - Toggle reverb\n';
804
- content += '{cyan-fg}T{/cyan-fg} - Switch tabs\n\n';
805
- content += `{gray-fg}Total Tracks: {/gray-fg}{white-fg}${this.musicTracks.length}{/white-fg}`;
806
-
807
- this.musicInfo.setContent(content);
808
- }
809
-
810
- setupKeys() {
811
- this.screen.key(['q', 'Q', 'C-c'], () => this.exit());
812
-
813
- // Tab switching
814
- this.screen.key(['t', 'T'], () => {
815
- const newTab = this.currentTab === 'voices' ? 'music' : 'voices';
816
- this.switchTab(newTab);
817
- });
818
-
819
- // Tab bar click handling
820
- this.tabBar.on('click', (data) => {
821
- const x = data.x;
822
- // "[V] Voices" is at position 2-12 (approx)
823
- // "[B] 🎶 Background Music" starts around position 15+
824
- if (x < 15) {
825
- // Clicked on Voices tab
826
- if (this.currentTab !== 'voices') {
827
- this.switchTab('voices');
828
- }
829
- } else {
830
- // Clicked on Background Music tab
831
- if (this.currentTab !== 'music') {
832
- this.switchTab('music');
833
- }
834
- }
835
- });
836
-
837
- // Listen to selection changes (blessed handles arrow keys automatically)
838
- this.list.on('select', () => {
839
- this.updateInfo();
840
- });
841
-
842
- // Double-click to play voice
843
- let lastClickTime = 0;
844
- this.list.on('click', async () => {
845
- const now = Date.now();
846
- if (now - lastClickTime < 400) {
847
- // Double-click detected
848
- const row = this.filteredData[this.list.selected];
849
- if (row) await this.playSample(row);
850
- lastClickTime = 0; // Reset to prevent triple-click
851
- } else {
852
- lastClickTime = now;
853
- }
854
- });
855
-
856
- // Double-click column header to sort
857
- let lastHeaderClickTime = 0;
858
- let lastHeaderClickX = 0;
859
- this.tableHeader.on('click', (data) => {
860
- const now = Date.now();
861
- const x = data.x;
862
-
863
- if (now - lastHeaderClickTime < 400 && Math.abs(x - lastHeaderClickX) < 3) {
864
- // Double-click detected on same column
865
- let newSortColumn = this.sortColumn;
866
-
867
- // Map x position to column (accounting for border offset)
868
- // " ID G Name Provider Lang Model "
869
- if (x < 8) {
870
- newSortColumn = 'id';
871
- } else if (x < 11) {
872
- newSortColumn = 'gender';
873
- } else if (x < 25) {
874
- newSortColumn = 'name';
875
- } else if (x < 34) {
876
- newSortColumn = 'provider';
877
- } else if (x < 41) {
878
- newSortColumn = 'language';
879
- } else {
880
- newSortColumn = 'model';
881
- }
882
-
883
- // Toggle sort direction if same column, otherwise ascending
884
- if (newSortColumn === this.sortColumn) {
885
- this.sortAsc = !this.sortAsc;
886
- } else {
887
- this.sortColumn = newSortColumn;
888
- this.sortAsc = true;
889
- }
890
-
891
- this.applyFilter();
892
- this.updateList();
893
-
894
- lastHeaderClickTime = 0; // Reset to prevent triple-click
895
- } else {
896
- lastHeaderClickTime = now;
897
- lastHeaderClickX = x;
898
- }
899
- });
900
-
901
- // Sorting
902
- this.screen.key(['1'], () => { this.sortColumn = 'id'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
903
- this.screen.key(['2'], () => { this.sortColumn = 'gender'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
904
- this.screen.key(['3'], () => { this.sortColumn = 'name'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
905
- this.screen.key(['4'], () => { this.sortColumn = 'provider'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
906
- this.screen.key(['5'], () => { this.sortColumn = 'language'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
907
- this.screen.key(['6'], () => { this.sortColumn = 'model'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
908
-
909
- // Search
910
- this.screen.key(['/'], () => this.showSearch());
911
-
912
- // Play
913
- this.list.key(['space'], async () => {
914
- const row = this.filteredData[this.list.selected];
915
- if (row) await this.playSample(row);
916
- });
917
-
918
- // Reverb toggle (on voices tab)
919
- this.list.key(['r', 'R'], async () => {
920
- if (this.currentTab === 'voices') {
921
- await this.toggleReverb();
922
- }
923
- });
924
-
925
- // Favorite
926
- this.list.key(['*', '8'], async () => {
927
- const row = this.filteredData[this.list.selected];
928
- if (row) {
929
- if (this.favorites.has(row.id)) {
930
- this.favorites.delete(row.id);
931
- this.statusBar.setContent('{yellow-fg}Removed from favorites{/yellow-fg}');
932
- } else {
933
- this.favorites.add(row.id);
934
- this.statusBar.setContent('{yellow-fg}Added to favorites *{/yellow-fg}');
935
- }
936
- await this.saveProgress();
937
- this.updateList();
938
- }
939
- });
940
-
941
- // Install/Select voice for AgentVibes
942
- this.screen.key(['i', 'I'], () => this.installVoice());
943
-
944
- // Toggle favorites filter
945
- this.screen.key(['f', 'F'], () => {
946
- this.favoritesOnly = !this.favoritesOnly;
947
- this.applyFilter();
948
- this.updateList();
949
-
950
- if (this.favoritesOnly) {
951
- this.statusBar.setContent(`{yellow-fg}* Showing ${this.filteredData.length} favorites - Press [F] or [X] to show all{/yellow-fg}`);
952
- } else {
953
- this.statusBar.setContent(`{cyan-fg}Showing all voices - Press [F] to filter favorites{/cyan-fg}`);
954
- }
955
- this.screen.render();
956
- });
957
-
958
- // Exit favorites filter with X
959
- this.screen.key(['x', 'X'], () => {
960
- if (this.favoritesOnly) {
961
- this.favoritesOnly = false;
962
- this.applyFilter();
963
- this.updateList();
964
- this.statusBar.setContent(`{cyan-fg}Showing all voices - Press [F] to filter favorites{/cyan-fg}`);
965
- this.screen.render();
966
- }
967
- });
968
-
969
- // Export
970
- this.screen.key(['e', 'E'], () => this.exportFavorites());
971
-
972
- // Navigation: Page Down
973
- this.list.key(['pagedown'], () => {
974
- const pageSize = Math.floor(this.list.height / 2);
975
- const newIndex = Math.min(this.list.selected + pageSize, this.filteredData.length - 1);
976
- this.list.select(newIndex);
977
- this.screen.render();
978
- });
979
-
980
- // Navigation: Page Up
981
- this.list.key(['pageup'], () => {
982
- const pageSize = Math.floor(this.list.height / 2);
983
- const newIndex = Math.max(this.list.selected - pageSize, 0);
984
- this.list.select(newIndex);
985
- this.screen.render();
986
- });
987
-
988
- // Navigation: Home (go to top)
989
- this.list.key(['home'], () => {
990
- this.list.select(0);
991
- this.screen.render();
992
- });
993
-
994
- // Navigation: End (go to bottom)
995
- this.list.key(['end'], () => {
996
- if (this.filteredData.length > 0) {
997
- this.list.select(this.filteredData.length - 1);
998
- this.screen.render();
999
- }
1000
- });
1001
-
1002
- // Provider filter toggle
1003
- this.screen.key(['l', 'L'], () => this.showProviderFilter());
1004
-
1005
- // Voice prompt — copy-pasteable AgentVibes instructions
1006
- this.list.key(['p', 'P'], () => this.showVoicePrompt());
1007
-
1008
- // Music tab controls
1009
- this.musicList.key(['space'], async () => {
1010
- if (this.currentTab !== 'music') return;
1011
- const selected = this.musicList.selected;
1012
- if (selected >= 0 && selected < this.musicTracks.length) {
1013
- const selectedTrack = this.musicTracks[selected];
1014
-
1015
- // If this track is already playing, stop it
1016
- if (this.currentlyPlayingTrack && this.currentlyPlayingTrack.file === selectedTrack.file) {
1017
- this.stopMusic();
1018
- } else {
1019
- // Otherwise, play the new track
1020
- await this.previewMusic(selectedTrack);
1021
- }
1022
- }
1023
- });
1024
-
1025
- this.musicList.key(['enter'], async () => {
1026
- if (this.currentTab !== 'music') return;
1027
- const selected = this.musicList.selected;
1028
- if (selected >= 0 && selected < this.musicTracks.length) {
1029
- await this.selectMusic(this.musicTracks[selected]);
1030
- }
1031
- });
1032
-
1033
- this.musicList.key(['m', 'M'], async () => {
1034
- if (this.currentTab !== 'music') return;
1035
- await this.toggleMusic();
1036
- });
1037
-
1038
- this.musicList.key(['r', 'R'], async () => {
1039
- if (this.currentTab !== 'music') return;
1040
- await this.toggleReverb();
1041
- });
1042
-
1043
- // Favorite music track
1044
- this.musicList.key(['f', 'F', '*', '8'], async () => {
1045
- if (this.currentTab !== 'music') return;
1046
- const selected = this.musicList.selected;
1047
- if (selected >= 0 && selected < this.musicTracks.length) {
1048
- const track = this.musicTracks[selected];
1049
- if (this.musicFavorites.has(track.file)) {
1050
- this.musicFavorites.delete(track.file);
1051
- this.statusBar.setContent('{yellow-fg}Removed from favorites{/yellow-fg}');
1052
- } else {
1053
- this.musicFavorites.add(track.file);
1054
- this.statusBar.setContent('{yellow-fg}Added to favorites *{/yellow-fg}');
1055
- }
1056
- await this.saveProgress();
1057
- this.updateMusicList();
1058
- this.screen.render();
1059
- }
1060
- });
1061
- }
1062
-
1063
- showVoicePrompt() {
1064
- const row = this.filteredData[this.list.selected];
1065
- if (!row) return;
1066
-
1067
- // Build copy-pasteable AgentVibes instructions per voice type
1068
- let lines = [];
1069
- let subtitle = '';
1070
-
1071
- switch (row.type) {
1072
- case 'curated': {
1073
- const switchName = row.friendlyName || row.piperVoiceId || row.model;
1074
- subtitle = `Piper curated voice`;
1075
- lines = [
1076
- `# Switch to: ${row.name}`,
1077
- ``,
1078
- `# If piper is already your active provider:`,
1079
- `/agent-vibes:switch ${switchName}`,
1080
- ``,
1081
- `# If switching from another provider first:`,
1082
- `/agent-vibes:provider switch piper`,
1083
- `/agent-vibes:switch ${switchName}`,
1084
- ];
1085
- break;
1086
- }
1087
- case 'libritts': {
1088
- const speakerId = row.originalId;
1089
- const safeName = row.name.replace(/\s+/g, '_');
1090
- const modelFile = path.basename(CONFIG.MODEL_PATH, '.onnx');
1091
- subtitle = `LibriTTS multi-speaker — speaker ID ${speakerId}`;
1092
- lines = [
1093
- `# Use LibriTTS Speaker ${speakerId}: ${row.name}`,
1094
- ``,
1095
- `# Step 1 — Download the model (skip if already downloaded):`,
1096
- `bash .claude/hooks/piper-voice-manager.sh download ${modelFile}`,
1097
- ``,
1098
- `# Step 2 — Register speaker in piper-multispeaker-registry.sh:`,
1099
- `# Add this line to the MULTISPEAKER_VOICES array:`,
1100
- ` "${safeName}:${modelFile}:${speakerId}:LibriTTS Speaker"`,
1101
- ``,
1102
- `# Step 3 Switch AgentVibes to this voice:`,
1103
- `/agent-vibes:switch ${safeName}`,
1104
- ];
1105
- break;
1106
- }
1107
- case 'macos': {
1108
- subtitle = `macOS built-in voice`;
1109
- lines = [
1110
- `# Switch to macOS voice: ${row.name}`,
1111
- ``,
1112
- `# Step 1 — Switch provider to macOS:`,
1113
- `/agent-vibes:provider switch macos`,
1114
- ``,
1115
- `# Step 2 — Switch to this voice:`,
1116
- `/agent-vibes:switch ${row.name}`,
1117
- ];
1118
- break;
1119
- }
1120
- case 'windows-sapi': {
1121
- subtitle = `Windows SAPI built-in voice`;
1122
- lines = [
1123
- `# Switch to Windows SAPI voice: ${row.name}`,
1124
- ``,
1125
- `# Step 1 — Switch provider to Windows SAPI:`,
1126
- `/agent-vibes:provider switch windows-sapi`,
1127
- ``,
1128
- `# Step 2 Switch to this voice:`,
1129
- `/agent-vibes:switch ${row.name}`,
1130
- ];
1131
- break;
1132
- }
1133
- case 'soprano': {
1134
- subtitle = `Soprano neural TTS — single voice`;
1135
- lines = [
1136
- `# Switch to Soprano TTS`,
1137
- ``,
1138
- `/agent-vibes:provider switch soprano`,
1139
- ``,
1140
- `# Soprano has one built-in voice — no voice selection needed.`,
1141
- ];
1142
- break;
1143
- }
1144
- default: {
1145
- subtitle = row.provider;
1146
- lines = [
1147
- `# Switch to: ${row.name}`,
1148
- `/agent-vibes:switch ${row.name}`,
1149
- ];
1150
- }
1151
- }
1152
-
1153
- const promptText = lines.join('\n');
1154
- const contentHeight = lines.length + 8;
1155
- const boxHeight = Math.min(contentHeight, Math.floor(this.screen.height * 0.8));
1156
-
1157
- const modal = blessed.box({
1158
- parent: this.screen,
1159
- top: 'center',
1160
- left: 'center',
1161
- width: 72,
1162
- height: boxHeight,
1163
- border: { type: 'line', fg: 'green' },
1164
- label: ` [P] Prompt — ${row.name} `,
1165
- tags: true,
1166
- scrollable: true,
1167
- alwaysScroll: true,
1168
- keys: true,
1169
- vi: true,
1170
- mouse: true,
1171
- padding: 1,
1172
- style: {
1173
- border: { fg: 'green' },
1174
- bg: 'black',
1175
- fg: 'white'
1176
- }
1177
- });
1178
-
1179
- let content = `{yellow-fg}{bold}${row.name}{/bold}{/yellow-fg} {gray-fg}${subtitle}{/gray-fg}\n\n`;
1180
- content += `{gray-fg}Copy and paste these commands into your terminal or Claude session:{/gray-fg}\n\n`;
1181
- content += `{green-fg}${lines.join('\n')}{/green-fg}\n\n`;
1182
- content += `{gray-fg}─────────────────────────────────────────────────────────────{/gray-fg}\n`;
1183
- content += `{gray-fg}[Esc/Q] Close [↑↓] Scroll{/gray-fg}`;
1184
-
1185
- modal.setContent(content);
1186
-
1187
- // Try to copy to clipboard (best-effort, silent on failure)
1188
- const clipboardCmds = [
1189
- ['xclip', ['-selection', 'clipboard']],
1190
- ['xsel', ['--clipboard', '--input']],
1191
- ['pbcopy', []]
1192
- ];
1193
- for (const [cmd, args] of clipboardCmds) {
1194
- try {
1195
- const proc = spawnSync('which', [cmd], { encoding: 'utf8', timeout: 500 });
1196
- if (proc.status === 0) {
1197
- const cp = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
1198
- cp.stdin.write(promptText);
1199
- cp.stdin.end();
1200
- // Update status bar to let user know
1201
- this.statusBar.setContent(`{green-fg}✓ Prompt copied to clipboard via ${cmd}{/green-fg}`);
1202
- break;
1203
- }
1204
- } catch {
1205
- // Silently skip
1206
- }
1207
- }
1208
-
1209
- modal.key(['escape', 'q', 'Q'], () => {
1210
- this.screen.remove(modal);
1211
- this.list.focus();
1212
- this.screen.render();
1213
- });
1214
-
1215
- modal.focus();
1216
- this.screen.render();
1217
- }
1218
-
1219
- showProviderFilter() {
1220
- // Get unique providers from tableData
1221
- const providers = [...new Set(this.tableData.map(row => row.provider))].sort();
1222
-
1223
- const menu = blessed.list({
1224
- parent: this.screen,
1225
- top: 'center',
1226
- left: 'center',
1227
- width: 40,
1228
- height: Math.min(providers.length + 4, 15),
1229
- border: { type: 'line', fg: 'cyan' },
1230
- label: ' Filter by Provider ',
1231
- keys: true,
1232
- vi: true,
1233
- mouse: true,
1234
- style: {
1235
- selected: { bg: 'cyan', fg: 'black' },
1236
- border: { fg: 'cyan' }
1237
- }
1238
- });
1239
-
1240
- const items = ['All Providers', ...providers];
1241
- menu.setItems(items);
1242
-
1243
- // Select current filter
1244
- if (this.providerFilter) {
1245
- const index = items.indexOf(this.providerFilter);
1246
- if (index >= 0) menu.select(index);
1247
- }
1248
-
1249
- menu.on('select', (item, index) => {
1250
- if (index === 0) {
1251
- // All Providers
1252
- this.providerFilter = null;
1253
- this.statusBar.setContent(`{cyan-fg}Showing all providers - Press [P] to filter{/cyan-fg}`);
1254
- } else {
1255
- // Specific provider
1256
- this.providerFilter = item.getText();
1257
- this.statusBar.setContent(`{cyan-fg}Showing ${this.providerFilter} only - Press [P] to change{/cyan-fg}`);
1258
- }
1259
-
1260
- this.applyFilter();
1261
- this.updateList();
1262
- this.screen.remove(menu);
1263
- this.list.focus();
1264
- this.screen.render();
1265
- });
1266
-
1267
- menu.key(['escape'], () => {
1268
- this.screen.remove(menu);
1269
- this.list.focus();
1270
- this.screen.render();
1271
- });
1272
-
1273
- menu.focus();
1274
- this.screen.render();
1275
- }
1276
-
1277
- stopMusic() {
1278
- // Kill existing audio process if any
1279
- if (this.currentAudioProcess) {
1280
- try {
1281
- this.currentAudioProcess.kill('SIGKILL');
1282
- this.currentAudioProcess = null;
1283
- this.currentlyPlayingTrack = null;
1284
- } catch (error) {}
1285
- }
1286
-
1287
- this.statusBar.setContent(`{yellow-fg}⏹ Stopped playback{/yellow-fg}`);
1288
- this.screen.render();
1289
- }
1290
-
1291
- async previewMusic(track) {
1292
- // Kill existing audio process if any
1293
- if (this.currentAudioProcess) {
1294
- try {
1295
- this.currentAudioProcess.kill('SIGKILL');
1296
- this.currentAudioProcess = null;
1297
- } catch (error) {}
1298
- }
1299
-
1300
- const trackPath = track.path;
1301
- this.currentlyPlayingTrack = track;
1302
-
1303
- this.statusBar.setContent(`{cyan-fg}▶ Playing: ${track.name}...{/cyan-fg}`);
1304
- this.screen.render();
1305
-
1306
- // Try different audio players
1307
- const players = [
1308
- { cmd: 'ffplay', args: ['-nodisp', '-autoexit', '-t', '15', trackPath] },
1309
- { cmd: 'mpg123', args: ['-q', '--loop', '1', trackPath] },
1310
- { cmd: 'afplay', args: [trackPath] }
1311
- ];
1312
-
1313
- for (const player of players) {
1314
- try {
1315
- // SECURITY: Use spawnSync instead of shell string (#126)
1316
- if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1317
-
1318
- const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1319
- this.currentAudioProcess = audioProcess;
1320
-
1321
- audioProcess.on('close', () => {
1322
- if (this.currentAudioProcess === audioProcess) {
1323
- this.currentAudioProcess = null;
1324
- this.currentlyPlayingTrack = null;
1325
- }
1326
- this.statusBar.setContent(`{green-fg} Playback complete{/green-fg}`);
1327
- this.screen.render();
1328
- });
1329
-
1330
- audioProcess.on('error', (err) => {
1331
- if (this.currentAudioProcess === audioProcess) {
1332
- this.currentAudioProcess = null;
1333
- this.currentlyPlayingTrack = null;
1334
- }
1335
- this.statusBar.setContent(`{red-fg}✗ Error playing track{/red-fg}`);
1336
- this.screen.render();
1337
- });
1338
-
1339
- break;
1340
- } catch (error) {
1341
- continue;
1342
- }
1343
- }
1344
- }
1345
-
1346
- async selectMusic(track) {
1347
- const homeDir = process.env.HOME || process.env.USERPROFILE;
1348
- const configDir = path.join(homeDir, '.claude', 'config');
1349
- const musicConfigFile = path.join(configDir, 'background-music.txt');
1350
-
1351
- try {
1352
- // Ensure config directory exists
1353
- await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1354
-
1355
- await fs.writeFile(musicConfigFile, track.file, { mode: 0o600 });
1356
- this.currentMusicSelection = track.file;
1357
- this.updateMusicList();
1358
- this.statusBar.setContent(`{green-fg}✓ Selected: ${track.name}{/green-fg}`);
1359
- this.screen.render();
1360
- } catch (error) {
1361
- this.statusBar.setContent(`{red-fg}✗ Error selecting track: ${error.message}{/red-fg}`);
1362
- this.screen.render();
1363
- }
1364
- }
1365
-
1366
- async toggleMusic() {
1367
- const homeDir = process.env.HOME || process.env.USERPROFILE;
1368
- const configDir = path.join(homeDir, '.claude', 'config');
1369
- const musicEnabledFile = path.join(configDir, 'background-music-enabled.txt');
1370
-
1371
- try {
1372
- // Ensure config directory exists
1373
- await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1374
-
1375
- this.musicEnabled = !this.musicEnabled;
1376
- await fs.writeFile(musicEnabledFile, this.musicEnabled ? 'true' : 'false', { mode: 0o600 });
1377
- this.updateMusicList();
1378
- this.updateMusicInfo();
1379
-
1380
- const status = this.musicEnabled ? 'Enabled' : 'Disabled';
1381
- this.statusBar.setContent(`{green-fg}✓ Background Music ${status}{/green-fg}`);
1382
- this.screen.render();
1383
- } catch (error) {
1384
- this.statusBar.setContent(`{red-fg}✗ Error toggling music: ${error.message}{/red-fg}`);
1385
- this.screen.render();
1386
- }
1387
- }
1388
-
1389
- async toggleReverb() {
1390
- const homeDir = process.env.HOME || process.env.USERPROFILE;
1391
- const configDir = path.join(homeDir, '.claude', 'config');
1392
- const audioEffectsPath = path.join(configDir, 'audio-effects.cfg');
1393
-
1394
- try {
1395
- // Ensure config directory exists
1396
- await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1397
-
1398
- // Read current reverb setting
1399
- let content = '';
1400
- try {
1401
- content = await fs.readFile(audioEffectsPath, 'utf8');
1402
- } catch {
1403
- content = 'REVERB_ENABLED=false\nREVERB_LEVEL=medium\n';
1404
- }
1405
-
1406
- // Toggle reverb
1407
- const currentEnabled = content.includes('REVERB_ENABLED=true');
1408
- const newEnabled = !currentEnabled;
1409
-
1410
- content = content.replace(
1411
- /REVERB_ENABLED=(true|false)/,
1412
- `REVERB_ENABLED=${newEnabled}`
1413
- );
1414
-
1415
- await fs.writeFile(audioEffectsPath, content, { mode: 0o600 });
1416
-
1417
- const status = newEnabled ? 'Enabled' : 'Disabled';
1418
- this.statusBar.setContent(`{green-fg}✓ Reverb ${status}{/green-fg}`);
1419
- this.screen.render();
1420
- } catch (error) {
1421
- this.statusBar.setContent(`{red-fg}✗ Error toggling reverb: ${error.message}{/red-fg}`);
1422
- this.screen.render();
1423
- }
1424
- }
1425
-
1426
- showSearch() {
1427
- const searchBox = blessed.textbox({
1428
- parent: this.screen,
1429
- top: 'center',
1430
- left: 'center',
1431
- width: 50,
1432
- height: 3,
1433
- border: { type: 'line', fg: 'cyan' },
1434
- label: ' Search ',
1435
- inputOnFocus: true
1436
- });
1437
-
1438
- searchBox.on('submit', (value) => {
1439
- this.searchTerm = value.trim();
1440
- this.applyFilter();
1441
- this.updateList();
1442
- this.screen.remove(searchBox);
1443
- this.list.focus();
1444
- this.statusBar.setContent(`{cyan-fg}Search: "${this.searchTerm}" - ${this.filteredData.length} results{/cyan-fg}`);
1445
- this.screen.render();
1446
- });
1447
-
1448
- searchBox.key(['escape'], () => {
1449
- this.screen.remove(searchBox);
1450
- this.list.focus();
1451
- this.screen.render();
1452
- });
1453
-
1454
- searchBox.focus();
1455
- this.screen.render();
1456
- }
1457
-
1458
- async playSample(row) {
1459
- if (this.currentAudioProcess) {
1460
- try {
1461
- this.currentAudioProcess.kill('SIGKILL');
1462
- this.currentAudioProcess = null;
1463
- } catch (error) {
1464
- // Process might have already finished
1465
- }
1466
- }
1467
-
1468
- this.statusBar.setContent(`{cyan-fg}Playing ${row.name}...{/cyan-fg}`);
1469
- this.screen.render();
1470
-
1471
- // Use voice-specific sample text
1472
- const sampleText = row.sampleText || this.sampleText;
1473
-
1474
- // Handle different providers
1475
- switch (row.type) {
1476
- case 'macos':
1477
- return await this.playMacOSVoice(row, sampleText);
1478
- case 'windows-sapi':
1479
- return await this.playWindowsSAPIVoice(row, sampleText);
1480
- case 'soprano':
1481
- return await this.playSopranoVoice(row, sampleText);
1482
- default:
1483
- return await this.playPiperVoice(row, sampleText);
1484
- }
1485
- }
1486
-
1487
- async playMacOSVoice(row, sampleText) {
1488
- try {
1489
- const process = spawn('say', ['-v', row.name, sampleText], { stdio: 'ignore' });
1490
- this.currentAudioProcess = process;
1491
-
1492
- process.on('close', () => {
1493
- if (this.currentAudioProcess === process) {
1494
- this.currentAudioProcess = null;
1495
- }
1496
- this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1497
- this.screen.render();
1498
- });
1499
-
1500
- process.on('error', (err) => {
1501
- if (this.currentAudioProcess === process) {
1502
- this.currentAudioProcess = null;
1503
- }
1504
- this.statusBar.setContent(`{red-fg}✗ Error playing voice{/red-fg}`);
1505
- this.screen.render();
1506
- });
1507
- } catch (error) {
1508
- this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1509
- this.screen.render();
1510
- }
1511
- }
1512
-
1513
- async playWindowsSAPIVoice(row, sampleText) {
1514
- try {
1515
- // SECURITY: Escape row.name and sampleText for PowerShell single-quote context (#124)
1516
- const safeName = row.name.replace(/'/g, "''");
1517
- const safeText = sampleText.replace(/'/g, "''");
1518
- const psScript = `Add-Type -AssemblyName System.Speech; $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; $synth.SelectVoice('${safeName}'); $synth.Speak('${safeText}')`;
1519
- const process = spawn('powershell', ['-Command', psScript], { stdio: 'ignore' });
1520
- this.currentAudioProcess = process;
1521
-
1522
- process.on('close', () => {
1523
- if (this.currentAudioProcess === process) {
1524
- this.currentAudioProcess = null;
1525
- }
1526
- this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1527
- this.screen.render();
1528
- });
1529
-
1530
- process.on('error', (err) => {
1531
- if (this.currentAudioProcess === process) {
1532
- this.currentAudioProcess = null;
1533
- }
1534
- this.statusBar.setContent(`{red-fg}✗ Error playing voice{/red-fg}`);
1535
- this.screen.render();
1536
- });
1537
- } catch (error) {
1538
- this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1539
- this.screen.render();
1540
- }
1541
- }
1542
-
1543
- async playSopranoVoice(row, sampleText) {
1544
- try {
1545
- // Soprano uses OpenAI API format
1546
- const outputFile = path.join(CONFIG.OUTPUT_DIR, `soprano_${row.name.toLowerCase()}_${Date.now()}.wav`);
1547
-
1548
- // Create JSON payload file to avoid shell escaping issues
1549
- const payloadFile = path.join(CONFIG.OUTPUT_DIR, `soprano_payload_${Date.now()}.json`);
1550
- const payload = {
1551
- input: sampleText,
1552
- model: 'tts-1',
1553
- voice: row.name.toLowerCase() // API expects lowercase voice names
1554
- };
1555
- await fs.writeFile(payloadFile, JSON.stringify(payload));
1556
-
1557
- // SECURITY: Use spawn with argument array instead of shell string (#125)
1558
- await new Promise((resolve, reject) => {
1559
- const curlProc = spawn('curl', [
1560
- '-s', '-m', '10', '-X', 'POST',
1561
- 'http://127.0.0.1:7860/v1/audio/speech',
1562
- '-H', 'Content-Type: application/json',
1563
- '-d', `@${payloadFile}`,
1564
- '-o', outputFile
1565
- ], { stdio: 'ignore' });
1566
- curlProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`curl exited ${code}`)));
1567
- curlProc.on('error', reject);
1568
- });
1569
-
1570
- // Clean up payload file
1571
- try {
1572
- await fs.unlink(payloadFile);
1573
- } catch {}
1574
-
1575
- // Play the generated audio
1576
- const players = [
1577
- { cmd: 'aplay', args: [outputFile] },
1578
- { cmd: 'paplay', args: [outputFile] },
1579
- { cmd: 'ffplay', args: ['-nodisp', '-autoexit', outputFile] }
1580
- ];
1581
-
1582
- for (const player of players) {
1583
- try {
1584
- // SECURITY: Use spawnSync instead of shell string (#126)
1585
- if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1586
-
1587
- const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1588
- this.currentAudioProcess = audioProcess;
1589
-
1590
- audioProcess.on('close', async () => {
1591
- if (this.currentAudioProcess === audioProcess) {
1592
- this.currentAudioProcess = null;
1593
- }
1594
- // Clean up temp file
1595
- try {
1596
- await fs.unlink(outputFile);
1597
- } catch {}
1598
- this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1599
- this.screen.render();
1600
- });
1601
-
1602
- audioProcess.on('error', (err) => {
1603
- if (this.currentAudioProcess === audioProcess) {
1604
- this.currentAudioProcess = null;
1605
- }
1606
- this.statusBar.setContent(`{red-fg}✗ Error playing voice{/red-fg}`);
1607
- this.screen.render();
1608
- });
1609
-
1610
- break;
1611
- } catch (error) {
1612
- continue;
1613
- }
1614
- }
1615
- } catch (error) {
1616
- this.statusBar.setContent(`{red-fg}✗ Error with Soprano: ${error.message}{/red-fg}`);
1617
- this.screen.render();
1618
- }
1619
- }
1620
-
1621
- async playPiperVoice(row, sampleText) {
1622
- // Sanitize sampleText to prevent command injection
1623
- const safeSampleText = sampleText.replace(/[`$\\!"]/g, '\\$&');
1624
-
1625
- // Generate unique filename based on sample text hash to support different samples
1626
- const textHash = sampleText.substring(0, 20).replace(/[^a-zA-Z0-9]/g, '');
1627
-
1628
- let outputFile;
1629
- if (row.type === 'curated') {
1630
- // Validate model name to prevent path traversal
1631
- const safeModel = path.basename(row.model);
1632
- if (safeModel !== row.model || /[^a-zA-Z0-9_-]/.test(safeModel)) {
1633
- this.statusBar.setContent(`{red-fg}✗ Invalid model name{/red-fg}`);
1634
- this.screen.render();
1635
- return;
1636
- }
1637
-
1638
- outputFile = path.join(CONFIG.CURATED_DIR, `${safeModel}_${textHash}.wav`);
1639
-
1640
- // Verify output path stays within intended directory
1641
- const resolvedOutput = path.resolve(outputFile);
1642
- const resolvedDir = path.resolve(CONFIG.CURATED_DIR);
1643
- if (!resolvedOutput.startsWith(resolvedDir + path.sep)) {
1644
- this.statusBar.setContent(`{red-fg}✗ Invalid output path{/red-fg}`);
1645
- this.screen.render();
1646
- return;
1647
- }
1648
-
1649
- const modelPath = path.join(CONFIG.PIPER_VOICES_DIR, `${safeModel}.onnx`);
1650
- // SECURITY: Always regenerate instead of TOCTOU check (#132)
1651
- {
1652
- const piperProcess = spawn(CONFIG.PIPER_PATH, [
1653
- '--model', modelPath,
1654
- '--output_file', outputFile
1655
- ], { stdio: ['pipe', 'ignore', 'ignore'] });
1656
-
1657
- piperProcess.stdin.write(safeSampleText);
1658
- piperProcess.stdin.end();
1659
-
1660
- await new Promise((resolve, reject) => {
1661
- piperProcess.on('close', code => code === 0 ? resolve() : reject(new Error(`Piper exit ${code}`)));
1662
- piperProcess.on('error', reject);
1663
- });
1664
- }
1665
- } else {
1666
- // Validate speaker ID is numeric
1667
- if (!Number.isInteger(row.id) || row.id < 0) {
1668
- this.statusBar.setContent(`{red-fg}✗ Invalid speaker ID{/red-fg}`);
1669
- this.screen.render();
1670
- return;
1671
- }
1672
-
1673
- outputFile = path.join(CONFIG.OUTPUT_DIR, `speaker_${row.id}_${textHash}.wav`);
1674
-
1675
- // Verify output path stays within intended directory
1676
- const resolvedOutput = path.resolve(outputFile);
1677
- const resolvedDir = path.resolve(CONFIG.OUTPUT_DIR);
1678
- if (!resolvedOutput.startsWith(resolvedDir + path.sep)) {
1679
- this.statusBar.setContent(`{red-fg}✗ Invalid output path{/red-fg}`);
1680
- this.screen.render();
1681
- return;
1682
- }
1683
-
1684
- // SECURITY: Always regenerate instead of TOCTOU check (#132)
1685
- {
1686
- const piperProcess = spawn(CONFIG.PIPER_PATH, [
1687
- '--model', CONFIG.MODEL_PATH,
1688
- '--speaker', row.id.toString(),
1689
- '--output_file', outputFile
1690
- ], { stdio: ['pipe', 'ignore', 'ignore'] });
1691
-
1692
- piperProcess.stdin.write(safeSampleText);
1693
- piperProcess.stdin.end();
1694
-
1695
- await new Promise((resolve, reject) => {
1696
- piperProcess.on('close', code => code === 0 ? resolve() : reject(new Error(`Piper exit ${code}`)));
1697
- piperProcess.on('error', reject);
1698
- });
1699
- }
1700
- }
1701
-
1702
- const players = [
1703
- { cmd: 'aplay', args: [outputFile] },
1704
- { cmd: 'paplay', args: [outputFile] },
1705
- { cmd: 'ffplay', args: ['-nodisp', '-autoexit', outputFile] }
1706
- ];
1707
-
1708
- for (const player of players) {
1709
- try {
1710
- // SECURITY: Use spawnSync instead of shell string (#126)
1711
- if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1712
-
1713
- // SECURITY: Store process immediately to prevent leak
1714
- const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1715
- this.currentAudioProcess = audioProcess;
1716
-
1717
- audioProcess.on('close', () => {
1718
- if (this.currentAudioProcess === audioProcess) {
1719
- this.currentAudioProcess = null;
1720
- }
1721
- this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1722
- this.screen.render();
1723
- });
1724
-
1725
- audioProcess.on('error', (err) => {
1726
- if (this.currentAudioProcess === audioProcess) {
1727
- this.currentAudioProcess = null;
1728
- }
1729
- });
1730
-
1731
- break;
1732
- } catch (error) {
1733
- continue;
1734
- }
1735
- }
1736
- }
1737
-
1738
- async installVoice() {
1739
- const row = this.filteredData[this.list.selected];
1740
- if (!row) return;
1741
-
1742
- try {
1743
- // Read current config
1744
- let config = {};
1745
- try {
1746
- const configData = await fs.readFile(CONFIG.AGENTVIBES_CONFIG, 'utf8');
1747
- config = JSON.parse(configData);
1748
- } catch (e) {
1749
- // Config doesn't exist yet, will create it
1750
- }
1751
-
1752
- // Determine the voice ID to save
1753
- let voiceId;
1754
- if (row.type === 'curated' && row.friendlyName) {
1755
- // For curated voices with friendly names, save the friendly name
1756
- // This allows users to reference them easily (e.g., "switch to Ryan")
1757
- voiceId = row.friendlyName;
1758
- } else if (row.type === 'curated') {
1759
- // Fallback to Piper ID if no friendly name
1760
- voiceId = row.piperVoiceId;
1761
- } else {
1762
- // For LibriTTS speakers, save as speaker ID
1763
- voiceId = `libritts-speaker-${row.id}`;
1764
- }
1765
-
1766
- // SECURITY: Validate voiceId to prevent JSON injection
1767
- if (!/^[a-zA-Z0-9_-]+$/.test(voiceId)) {
1768
- this.statusBar.setContent(`{red-fg}✗ Invalid voice ID format{/red-fg}`);
1769
- this.screen.render();
1770
- return;
1771
- }
1772
-
1773
- // Update config
1774
- config.defaultVoice = voiceId;
1775
- config.ttsProvider = 'piper';
1776
-
1777
- // Ensure config directory exists with secure permissions
1778
- const configDir = path.dirname(CONFIG.AGENTVIBES_CONFIG);
1779
- await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1780
-
1781
- // SECURITY: Atomic write to prevent race condition
1782
- const tempFile = CONFIG.AGENTVIBES_CONFIG + '.tmp.' + Date.now();
1783
- await fs.writeFile(tempFile, JSON.stringify(config, null, 2), { mode: 0o600 });
1784
- await fs.rename(tempFile, CONFIG.AGENTVIBES_CONFIG);
1785
-
1786
- this.statusBar.setContent(`{green-fg}✓ Installed: ${row.name} → AgentVibes default voice{/green-fg}`);
1787
- this.screen.render();
1788
-
1789
- // Show confirmation dialog
1790
- setTimeout(() => {
1791
- const confirmBox = blessed.box({
1792
- parent: this.screen,
1793
- top: 'center',
1794
- left: 'center',
1795
- width: 60,
1796
- height: 7,
1797
- border: { type: 'line', fg: 'green' },
1798
- label: ' ✓ Voice Installed ',
1799
- content: `\n{center}${row.name} is now your AgentVibes default voice!{/center}\n\n{center}{gray-fg}Press any key to continue...{/gray-fg}{/center}`,
1800
- tags: true
1801
- });
1802
-
1803
- this.screen.append(confirmBox);
1804
- this.screen.render();
1805
-
1806
- const closeDialog = () => {
1807
- this.screen.remove(confirmBox);
1808
- this.list.focus();
1809
- this.screen.render();
1810
- this.screen.unkey(['space'], closeDialog);
1811
- this.screen.unkey(['enter'], closeDialog);
1812
- this.screen.unkey(['escape'], closeDialog);
1813
- };
1814
-
1815
- this.screen.key(['space', 'enter', 'escape'], closeDialog);
1816
- this.screen.onceKey(['space', 'enter', 'escape'], closeDialog);
1817
- }, 500);
1818
-
1819
- } catch (error) {
1820
- this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1821
- this.screen.render();
1822
- }
1823
- }
1824
-
1825
- async exportFavorites() {
1826
- const favData = this.tableData.filter(row => this.favorites.has(row.id));
1827
- const exportFile = path.join(os.homedir(), 'agentvibes-favorites.json');
1828
- await fs.writeFile(exportFile, JSON.stringify(favData, null, 2));
1829
- this.statusBar.setContent(`{green-fg}✓ Exported ${favData.length} favorites to ${exportFile}{/green-fg}`);
1830
- this.screen.render();
1831
- }
1832
-
1833
- async exit() {
1834
- await this.saveProgress();
1835
- this.screen.destroy();
1836
- console.log('\n✓ Progress saved. Goodbye!\n');
1837
- process.exit(0);
1838
- }
1839
- }
1840
-
1841
- new AgentVibesVoiceBrowser().init().catch(console.error);
2
+
3
+ /**
4
+ * AgentVibes Voice Browser
5
+ * Browse and preview 914+ Piper TTS voices
6
+ * Press 'I' to install/select a voice for AgentVibes
7
+ */
8
+
9
+ import blessed from 'blessed';
10
+ import chalk from 'chalk';
11
+ import { exec, spawn, spawnSync } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import fs from 'fs/promises';
14
+ import fsSync from 'fs';
15
+ import path from 'path';
16
+ import { fileURLToPath } from 'url';
17
+ import os from 'os';
18
+
19
+ const execAsync = promisify(exec);
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ const CONFIG = {
23
+ MODEL_PATH: path.join(os.homedir(), '.local/share/piper/en_US-libritts-high.onnx'),
24
+ TOTAL_SPEAKERS: 904,
25
+ TOTAL_CURATED: 10,
26
+ TOTAL_ITEMS: 914,
27
+ SAMPLE_TEXT: 'Hello! This is a sample of my voice. I can speak clearly and naturally with expression.',
28
+ OUTPUT_DIR: path.join(os.homedir(), '.cache/agentvibes/voice-samples'),
29
+ CURATED_DIR: path.join(os.homedir(), '.cache/agentvibes/curated-samples'),
30
+ PROGRESS_FILE: path.join(os.homedir(), '.cache/agentvibes/browser-progress.json'),
31
+ PIPER_PATH: path.join(os.homedir(), '.local/bin/piper'),
32
+ PIPER_VOICES_DIR: path.join(os.homedir(), '.local/share/piper/voices'),
33
+ AGENTVIBES_CONFIG: path.join(os.homedir(), '.agentvibes/config.json'),
34
+ VOICE_METADATA: path.join(__dirname, '..', '.agentvibes', 'config', 'voice-metadata.json')
35
+ };
36
+
37
+ // Sample script templates showcasing AgentVibes features
38
+ const SAMPLE_TEMPLATES = [
39
+ "Hi, I'm {NAME}. AgentVibes supports multiple TTS providers including Piper for local processing, Windows SAPI, macOS system voices, and Soprano. Choose the best fit for your platform.",
40
+ "Hey there, I'm {NAME}! AgentVibes supports Soprano, a high-quality neural TTS engine that produces incredibly natural-sounding voices. The audio quality is seriously impressive.",
41
+ "Good day, I'm {NAME}. AgentVibes integrates with PulseAudio to stream TTS from headless remote servers to your local machine. Essential when developing on voiceless cloud instances.",
42
+ "Hi, I'm {NAME}. AgentVibes provides access to over thirty-seven Piper voices, plus system voices from Windows, macOS, and Linux. Maximum flexibility for your needs.",
43
+ "Hey team, I'm {NAME}! AgentVibes lets you add custom background music to your TTS output. Jazz, lo-fi, classical—whatever helps you stay in the zone while coding!",
44
+ "Oh wonderful, I'm {NAME}. AgentVibes has a sarcastic personality mode. Because clearly what your development workflow was missing was an AI with attitude. How delightful.",
45
+ "Hi, I'm {NAME}. AgentVibes includes a receiver mode that lets you stream TTS from one machine to another. Perfect for using remote servers while hearing audio on your local device.",
46
+ "Hi there, I'm {NAME}! AgentVibes includes audio effects like reverb, pitch adjustment, and EQ. Add some atmosphere and personality to your AI assistant's voice!",
47
+ "Hello, I'm {NAME}. AgentVibes includes a bundled MCP server that makes configuration incredibly easy. Just use natural language to configure voices, personalities, and settings.",
48
+ "Good afternoon, I'm {NAME}. If you're enjoying AgentVibes, we'd be tremendously grateful for a GitHub star. Your support helps the project grow and improve."
49
+ ];
50
+
51
+ class AgentVibesVoiceBrowser {
52
+ constructor() {
53
+ this.tableData = [];
54
+ this.filteredData = [];
55
+ this.currentRow = 0;
56
+ this.sortColumn = 'id';
57
+ this.sortAsc = true;
58
+ this.searchTerm = '';
59
+ this.favorites = new Set(); // backward compat — migrated to thumbsUp on load
60
+ this.thumbsUp = new Set();
61
+ this.thumbsDown = new Set();
62
+ this.favoritesOnly = false; // Filter to show only thumbs-up voices
63
+ this.providerFilter = null; // Filter by provider (null = all)
64
+ this.sampleText = CONFIG.SAMPLE_TEXT;
65
+ this.playing = false;
66
+ this.currentAudioProcess = null;
67
+ this.voiceAssignments = null;
68
+ this.voiceMetadata = null;
69
+ this.currentTab = 'voices'; // 'voices' or 'music'
70
+ this.musicTracks = [];
71
+ this.currentMusicSelection = null;
72
+ this.musicEnabled = false;
73
+ this.currentlyPlayingTrack = null; // Track which music track is currently playing
74
+ this.musicFavorites = new Set(); // Favorite music tracks
75
+ }
76
+
77
+ async init() {
78
+ await fs.mkdir(CONFIG.OUTPUT_DIR, { recursive: true });
79
+ await fs.mkdir(CONFIG.CURATED_DIR, { recursive: true });
80
+ await fs.mkdir(path.dirname(CONFIG.PROGRESS_FILE), { recursive: true });
81
+
82
+ // Clean up old cached samples (without text hash in filename)
83
+ try {
84
+ const files = await fs.readdir(CONFIG.OUTPUT_DIR);
85
+ for (const file of files) {
86
+ if (file.match(/^speaker_\d+\.wav$/)) {
87
+ await fs.unlink(path.join(CONFIG.OUTPUT_DIR, file));
88
+ }
89
+ }
90
+ } catch (e) {
91
+ // Ignore cleanup errors
92
+ }
93
+
94
+ await this.loadProgress();
95
+ await this.loadVoiceData();
96
+ await this.loadMusicData();
97
+ this.prepareTable();
98
+ this.setupUI();
99
+ }
100
+
101
+ async loadProgress() {
102
+ try {
103
+ const data = JSON.parse(await fs.readFile(CONFIG.PROGRESS_FILE, 'utf8'));
104
+ this.thumbsUp = new Set(data.thumbsUp || []);
105
+ this.thumbsDown = new Set(data.thumbsDown || []);
106
+ // Migrate legacy favorites thumbsUp
107
+ if (data.favorites && data.favorites.length && !data.thumbsUp) {
108
+ for (const f of data.favorites) this.thumbsUp.add(f);
109
+ }
110
+ this.favorites = this.thumbsUp; // backward compat alias
111
+ this.musicFavorites = new Set(data.musicFavorites || []);
112
+ this.sampleText = data.sampleText || CONFIG.SAMPLE_TEXT;
113
+ this.sortColumn = data.sortColumn || 'id';
114
+ this.sortAsc = data.sortAsc !== undefined ? data.sortAsc : true;
115
+ } catch (error) {
116
+ // No previous progress
117
+ }
118
+ }
119
+
120
+ async saveProgress() {
121
+ await fs.writeFile(CONFIG.PROGRESS_FILE, JSON.stringify({
122
+ thumbsUp: Array.from(this.thumbsUp),
123
+ thumbsDown: Array.from(this.thumbsDown),
124
+ favorites: Array.from(this.thumbsUp), // backward compat
125
+ musicFavorites: Array.from(this.musicFavorites),
126
+ sampleText: this.sampleText,
127
+ sortColumn: this.sortColumn,
128
+ sortAsc: this.sortAsc
129
+ }, null, 2));
130
+ }
131
+
132
+ async detectProviders() {
133
+ const providers = [];
134
+
135
+ // Check for macOS Say
136
+ if (process.platform === 'darwin') {
137
+ try {
138
+ const result = spawnSync('which', ['say'], { encoding: 'utf8', timeout: 1000 });
139
+ if (result.status === 0) {
140
+ providers.push('macos');
141
+ }
142
+ } catch {
143
+ // Silently skip if check fails
144
+ }
145
+ }
146
+
147
+ // Check for Windows SAPI (not available in WSL)
148
+ if (process.platform === 'win32') {
149
+ providers.push('windows-sapi');
150
+ }
151
+
152
+ // Check for Soprano TTS
153
+ try {
154
+ // Try to start Soprano if available
155
+ const ensureScript = path.join(__dirname, 'ensure-soprano-running.sh');
156
+ if (fsSync.existsSync(ensureScript)) {
157
+ try {
158
+ spawnSync('bash', [ensureScript], { encoding: 'utf8', timeout: 5000 });
159
+ } catch {
160
+ // Failed to start, skip silently
161
+ }
162
+ }
163
+
164
+ // Check if Soprano server is responding
165
+ const curlResult = spawnSync('curl', ['-s', '-m', '1', 'http://127.0.0.1:7860/openapi.json'], { encoding: 'utf8', timeout: 2000 });
166
+ if (curlResult.status === 0 && curlResult.stdout && curlResult.stdout.includes('Soprano')) {
167
+ providers.push('soprano');
168
+ }
169
+ } catch {
170
+ // Silently skip if detection fails
171
+ }
172
+
173
+ return providers;
174
+ }
175
+
176
+ async loadMusicData() {
177
+ // Load background music tracks
178
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
179
+ let tracksDir = path.join(homeDir, '.claude', 'audio', 'tracks');
180
+
181
+ // If running from project directory, also check project's .claude/audio/tracks
182
+ if (!fsSync.existsSync(tracksDir)) {
183
+ const projectTracksDir = path.join(__dirname, '..', '.claude', 'audio', 'tracks');
184
+ if (fsSync.existsSync(projectTracksDir)) {
185
+ tracksDir = projectTracksDir;
186
+ }
187
+ }
188
+
189
+ try {
190
+ const files = await fs.readdir(tracksDir);
191
+ this.musicTracks = files
192
+ .filter(f => f.endsWith('.mp3') && !f.startsWith('.'))
193
+ .map(file => ({
194
+ file,
195
+ name: file.replace(/^agent_vibes_|^agentvibes_|_v\d+|_loop\.mp3$/g, '').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
196
+ path: path.join(tracksDir, file)
197
+ }))
198
+ .sort((a, b) => a.name.localeCompare(b.name));
199
+
200
+ // Load current music selection
201
+ const musicConfigFile = path.join(homeDir, '.claude', 'config', 'background-music.txt');
202
+ try {
203
+ this.currentMusicSelection = (await fs.readFile(musicConfigFile, 'utf8')).trim();
204
+ } catch {
205
+ this.currentMusicSelection = null;
206
+ }
207
+
208
+ // Load music enabled status
209
+ const musicEnabledFile = path.join(homeDir, '.claude', 'config', 'background-music-enabled.txt');
210
+ try {
211
+ const enabled = (await fs.readFile(musicEnabledFile, 'utf8')).trim();
212
+ this.musicEnabled = enabled === 'true';
213
+ } catch {
214
+ this.musicEnabled = false;
215
+ }
216
+ } catch (error) {
217
+ this.musicTracks = [];
218
+ }
219
+ }
220
+
221
+ async loadMacOSVoices() {
222
+ try {
223
+ const { stdout } = await execAsync('say -v ? 2>/dev/null');
224
+ const voices = [];
225
+ const lines = stdout.trim().split('\n');
226
+
227
+ for (const line of lines) {
228
+ const match = line.match(/^(\S+)\s+(\S+)\s+#\s*(.+)/);
229
+ if (match) {
230
+ const [, name, lang, description] = match;
231
+ voices.push({
232
+ name,
233
+ language: lang,
234
+ description: description || '',
235
+ provider: 'macos'
236
+ });
237
+ }
238
+ }
239
+ return voices;
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+
245
+ async loadWindowsSAPIVoices() {
246
+ try {
247
+ const psScript = 'Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | ForEach-Object { $_.VoiceInfo | Select-Object Name, Gender, Culture | ConvertTo-Json -Compress }';
248
+ const { stdout } = await execAsync(`powershell -Command "${psScript}"`, { timeout: 5000 });
249
+ const voices = [];
250
+ const lines = stdout.trim().split('\n').filter(l => l.trim());
251
+
252
+ for (const line of lines) {
253
+ try {
254
+ const voice = JSON.parse(line);
255
+ // SECURITY: Validate expected schema from PowerShell output (#133)
256
+ if (voice && typeof voice.Name === 'string' && voice.Name.length > 0) {
257
+ voices.push({
258
+ name: voice.Name,
259
+ gender: typeof voice.Gender === 'string' ? voice.Gender.toLowerCase() : 'unknown',
260
+ language: typeof voice.Culture === 'string' ? voice.Culture : 'en-US',
261
+ provider: 'windows-sapi'
262
+ });
263
+ }
264
+ } catch {}
265
+ }
266
+ return voices;
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ async loadSopranoVoices() {
273
+ // Soprano TTS currently has only one voice
274
+ // It uses OpenAI API format but ignores the voice parameter
275
+ return [
276
+ {
277
+ name: 'Soprano',
278
+ language: 'en-US',
279
+ provider: 'soprano',
280
+ description: 'Neural TTS voice'
281
+ }
282
+ ];
283
+ }
284
+
285
+ async loadVoiceData() {
286
+ // Detect available providers
287
+ this.availableProviders = await this.detectProviders();
288
+
289
+ // Load voice assignments (for LibriTTS speakers)
290
+ const assignmentsPath = path.join(__dirname, '..', 'voice-assignments.json');
291
+ if (fsSync.existsSync(assignmentsPath)) {
292
+ this.voiceAssignments = JSON.parse(await fs.readFile(assignmentsPath, 'utf8'));
293
+ } else {
294
+ // Generate basic assignments if file doesn't exist
295
+ console.log(chalk.yellow('⚠ voice-assignments.json not found, generating basic data...'));
296
+ this.voiceAssignments = {
297
+ libritts_speakers: {},
298
+ curated_voices: {}
299
+ };
300
+
301
+ // Generate basic speaker assignments
302
+ for (let id = 0; id < CONFIG.TOTAL_SPEAKERS; id++) {
303
+ this.voiceAssignments.libritts_speakers[id] = {
304
+ gender: id % 2 === 0 ? 'male' : 'female',
305
+ voice_name: `Speaker ${id}`
306
+ };
307
+ }
308
+ }
309
+
310
+ // Load voice metadata (for curated voices)
311
+ if (fsSync.existsSync(CONFIG.VOICE_METADATA)) {
312
+ this.voiceMetadata = JSON.parse(await fs.readFile(CONFIG.VOICE_METADATA, 'utf8'));
313
+
314
+ // Merge curated voices into assignments
315
+ if (this.voiceMetadata && this.voiceMetadata.voices) {
316
+ let curatedId = 1000; // Start curated voices at ID 1000
317
+ for (const [friendlyName, voice] of Object.entries(this.voiceMetadata.voices)) {
318
+ this.voiceAssignments.curated_voices[curatedId] = {
319
+ gender: voice.gender,
320
+ voice_name: voice.displayName,
321
+ model_file: voice.id,
322
+ friendly_name: friendlyName
323
+ };
324
+ curatedId++;
325
+ }
326
+ }
327
+ }
328
+
329
+ // Load voices from other providers
330
+ this.otherProviderVoices = {
331
+ macos: [],
332
+ 'windows-sapi': [],
333
+ soprano: []
334
+ };
335
+
336
+ if (this.availableProviders.includes('macos')) {
337
+ this.otherProviderVoices.macos = await this.loadMacOSVoices();
338
+ }
339
+
340
+ if (this.availableProviders.includes('windows-sapi')) {
341
+ this.otherProviderVoices['windows-sapi'] = await this.loadWindowsSAPIVoices();
342
+ }
343
+
344
+ if (this.availableProviders.includes('soprano')) {
345
+ this.otherProviderVoices.soprano = await this.loadSopranoVoices();
346
+ }
347
+ }
348
+
349
+ prepareTable() {
350
+ this.tableData = [];
351
+ let nextId = 0;
352
+
353
+ // Add LibriTTS speakers
354
+ for (let id = 0; id < CONFIG.TOTAL_SPEAKERS; id++) {
355
+ const assignment = this.voiceAssignments.libritts_speakers[id];
356
+ if (assignment) {
357
+ // Assign random sample template with voice name
358
+ const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
359
+ const sampleText = template.replace('{NAME}', assignment.voice_name);
360
+
361
+ this.tableData.push({
362
+ id: nextId++,
363
+ originalId: id,
364
+ gender: assignment.gender,
365
+ name: assignment.voice_name,
366
+ model: 'LibriTTS',
367
+ type: 'libritts',
368
+ provider: 'Piper',
369
+ piperVoiceId: `speaker-${id}`,
370
+ sampleText: sampleText,
371
+ language: 'en_US'
372
+ });
373
+ }
374
+ }
375
+
376
+ // Add curated voices
377
+ for (const [id, curated] of Object.entries(this.voiceAssignments.curated_voices)) {
378
+ // Assign random sample template with voice name
379
+ const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
380
+ const sampleText = template.replace('{NAME}', curated.voice_name);
381
+
382
+ // Extract language from model file (e.g., en_US-amy-medium -> en_US)
383
+ const langMatch = curated.model_file.match(/^([a-z]{2}_[A-Z]{2})/);
384
+ const language = langMatch ? langMatch[1] : 'en_US';
385
+
386
+ this.tableData.push({
387
+ id: nextId++,
388
+ originalId: parseInt(id),
389
+ gender: curated.gender,
390
+ name: curated.voice_name,
391
+ model: curated.model_file,
392
+ type: 'curated',
393
+ provider: 'Piper',
394
+ piperVoiceId: curated.model_file,
395
+ friendlyName: curated.friendly_name,
396
+ sampleText: sampleText,
397
+ language: language
398
+ });
399
+ }
400
+
401
+ // Add macOS voices
402
+ for (const voice of this.otherProviderVoices.macos || []) {
403
+ const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
404
+ const sampleText = template.replace('{NAME}', voice.name);
405
+
406
+ this.tableData.push({
407
+ id: nextId++,
408
+ gender: 'unknown',
409
+ name: voice.name,
410
+ model: 'macOS Say',
411
+ type: 'macos',
412
+ provider: 'macOS',
413
+ sampleText: sampleText,
414
+ language: voice.language || 'en_US'
415
+ });
416
+ }
417
+
418
+ // Add Windows SAPI voices
419
+ for (const voice of this.otherProviderVoices['windows-sapi'] || []) {
420
+ const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
421
+ const sampleText = template.replace('{NAME}', voice.name);
422
+
423
+ this.tableData.push({
424
+ id: nextId++,
425
+ gender: voice.gender || 'unknown',
426
+ name: voice.name,
427
+ model: 'Windows SAPI',
428
+ type: 'windows-sapi',
429
+ provider: 'Windows',
430
+ sampleText: sampleText,
431
+ language: voice.language || 'en-US'
432
+ });
433
+ }
434
+
435
+ // Add Soprano voices
436
+ for (const voice of this.otherProviderVoices.soprano || []) {
437
+ const template = SAMPLE_TEMPLATES[Math.floor(Math.random() * SAMPLE_TEMPLATES.length)];
438
+ const sampleText = template.replace('{NAME}', voice.name);
439
+
440
+ this.tableData.push({
441
+ id: nextId++,
442
+ gender: 'unknown',
443
+ name: voice.name,
444
+ model: 'Soprano',
445
+ type: 'soprano',
446
+ provider: 'Soprano',
447
+ sampleText: sampleText,
448
+ language: voice.language || 'en-US'
449
+ });
450
+ }
451
+
452
+ this.applyFilter();
453
+ }
454
+
455
+ applyFilter() {
456
+ // Start with all voices or thumbs-up only
457
+ let data = this.favoritesOnly
458
+ ? this.tableData.filter(row => this.thumbsUp.has(row.id))
459
+ : [...this.tableData];
460
+
461
+ // Apply provider filter
462
+ if (this.providerFilter) {
463
+ data = data.filter(row => row.provider === this.providerFilter);
464
+ }
465
+
466
+ // Apply search filter
467
+ if (this.searchTerm) {
468
+ const term = this.searchTerm.toLowerCase();
469
+ data = data.filter(row =>
470
+ row.id.toString().includes(term) ||
471
+ row.gender.includes(term) ||
472
+ row.name.toLowerCase().includes(term) ||
473
+ row.model.toLowerCase().includes(term) ||
474
+ row.language.toLowerCase().includes(term) ||
475
+ row.provider.toLowerCase().includes(term)
476
+ );
477
+ }
478
+
479
+ this.filteredData = data;
480
+
481
+ // Sort
482
+ this.filteredData.sort((a, b) => {
483
+ let aVal = a[this.sortColumn];
484
+ let bVal = b[this.sortColumn];
485
+ if (typeof aVal === 'string') aVal = aVal.toLowerCase();
486
+ if (typeof bVal === 'string') bVal = bVal.toLowerCase();
487
+ if (aVal < bVal) return this.sortAsc ? -1 : 1;
488
+ if (aVal > bVal) return this.sortAsc ? 1 : -1;
489
+ return 0;
490
+ });
491
+ }
492
+
493
+ formatRow(row) {
494
+ const fav = this.thumbsUp.has(row.id) ? '{green-fg}+{/green-fg}' : (this.thumbsDown.has(row.id) ? '{red-fg}-{/red-fg}' : ' ');
495
+ const genderIcon = row.gender === 'male' ? '♂' : (row.gender === 'female' ? '♀' : '-');
496
+ const genderColor = row.gender === 'male' ? 'blue-fg' : (row.gender === 'female' ? 'magenta-fg' : 'gray-fg');
497
+ const gender = `{${genderColor}}${genderIcon}{/${genderColor}}`;
498
+ const id = String(row.id).padStart(4);
499
+ const name = row.name.substring(0, 13).padEnd(13);
500
+ const provider = row.provider.substring(0, 8).padEnd(8);
501
+ const lang = row.language.substring(0, 6).padEnd(6);
502
+ const model = row.model.substring(0, 15).padEnd(15);
503
+ return `${fav} ${id} ${gender} ${name} ${provider} ${lang} ${model}`;
504
+ }
505
+
506
+ setupUI() {
507
+ this.screen = blessed.screen({ smartCSR: true, title: 'AgentVibes Voice Browser' });
508
+
509
+ // Calculate unique models and store as instance variable
510
+ this.uniqueModels = new Set(this.tableData.map(row => row.model)).size;
511
+
512
+ const title = blessed.box({
513
+ top: 0,
514
+ height: 1,
515
+ width: '100%',
516
+ content: `{center}{bold}{cyan-fg}Agent{/cyan-fg} {magenta-fg}Vibes{/magenta-fg} {gray-fg}v1.0{/gray-fg} {yellow-fg}Voice Browser{/yellow-fg}{/bold}{/center}`,
517
+ tags: true,
518
+ style: { fg: 'white' }
519
+ });
520
+
521
+ const headerBar = blessed.box({
522
+ top: 1,
523
+ height: 4,
524
+ width: '100%',
525
+ content: `{center}{gray-fg}github.com/paulpreibisch/agentvibes{/gray-fg} {white-fg}www.agentvibes.org{/white-fg}{/center}\n{center}{red-fg}[T]{/red-fg}Tabs {cyan-fg}[1-6]{/cyan-fg}Sort {cyan-fg}[/]{/cyan-fg}Search {cyan-fg}[P]{/cyan-fg}Prompt {cyan-fg}[L]{/cyan-fg}Filter {cyan-fg}[F/X]{/cyan-fg}Fav {cyan-fg}[Space]{/cyan-fg}Play {green-fg}[+/*]{/green-fg}Up {red-fg}[-]{/red-fg}Down {cyan-fg}[I]{/cyan-fg}Install{/center}`,
526
+ tags: true,
527
+ padding: 0,
528
+ border: { type: 'line', fg: 'gray' },
529
+ style: {
530
+ bg: 'black',
531
+ fg: 'white',
532
+ border: { bg: 'black' }
533
+ }
534
+ });
535
+
536
+ // Tab bar
537
+ this.tabBar = blessed.box({
538
+ top: 5,
539
+ height: 1,
540
+ width: '100%',
541
+ tags: true,
542
+ mouse: true,
543
+ clickable: true,
544
+ style: { fg: 'white', bg: 'black' }
545
+ });
546
+
547
+ // Voices Tab Content
548
+ this.voicesContainer = blessed.box({
549
+ top: 6,
550
+ left: 0,
551
+ width: '100%',
552
+ height: '100%-11',
553
+ hidden: false
554
+ });
555
+
556
+ this.tableHeader = blessed.box({
557
+ top: 0,
558
+ left: 0,
559
+ height: 1,
560
+ width: '70%',
561
+ content: ` ID G Name Provider Lang Model `,
562
+ style: { fg: 'cyan', bold: true },
563
+ mouse: true,
564
+ clickable: true
565
+ });
566
+
567
+ this.list = blessed.list({
568
+ top: 1,
569
+ left: 0,
570
+ width: '70%',
571
+ height: '100%-1',
572
+ keys: true,
573
+ vi: true,
574
+ mouse: true,
575
+ tags: true,
576
+ style: {
577
+ selected: { bg: 'blue', fg: 'white', bold: true },
578
+ item: { fg: 'white' },
579
+ border: { fg: 'cyan' },
580
+ label: { fg: 'gray' }
581
+ },
582
+ border: { type: 'line', fg: 'cyan' },
583
+ label: ` Voices (${this.filteredData.length}) - Model (${this.uniqueModels}) - Sort: ${this.sortColumn} ${this.sortAsc ? '↑' : '↓'} `
584
+ });
585
+
586
+ this.infoPanel = blessed.box({
587
+ top: 0,
588
+ left: '70%',
589
+ width: '30%',
590
+ height: '100%',
591
+ tags: true,
592
+ border: { type: 'line', fg: 'cyan' },
593
+ label: ' Voice Info ',
594
+ scrollable: true,
595
+ alwaysScroll: true,
596
+ mouse: true,
597
+ keys: true,
598
+ vi: true,
599
+ style: {
600
+ border: { fg: 'cyan' },
601
+ label: { fg: 'gray' }
602
+ }
603
+ });
604
+
605
+ this.voicesContainer.append(this.tableHeader);
606
+ this.voicesContainer.append(this.list);
607
+ this.voicesContainer.append(this.infoPanel);
608
+
609
+ // Music Tab Content
610
+ this.musicContainer = blessed.box({
611
+ top: 6,
612
+ left: 0,
613
+ width: '100%',
614
+ height: '100%-11',
615
+ hidden: true
616
+ });
617
+
618
+ this.musicList = blessed.list({
619
+ top: 0,
620
+ left: 0,
621
+ width: '70%',
622
+ height: '100%',
623
+ keys: true,
624
+ vi: true,
625
+ mouse: true,
626
+ tags: true,
627
+ style: {
628
+ selected: { bg: 'blue', fg: 'white', bold: true },
629
+ item: { fg: 'white' },
630
+ border: { fg: 'cyan' },
631
+ label: { fg: 'gray' }
632
+ },
633
+ border: { type: 'line', fg: 'cyan' },
634
+ label: ` Background Music (${this.musicTracks.length} tracks) `
635
+ });
636
+
637
+ this.musicInfo = blessed.box({
638
+ top: 0,
639
+ left: '70%',
640
+ width: '30%',
641
+ height: '100%',
642
+ tags: true,
643
+ border: { type: 'line', fg: 'cyan' },
644
+ label: ' Track Info ',
645
+ content: '',
646
+ padding: 1,
647
+ style: {
648
+ border: { fg: 'cyan' },
649
+ label: { fg: 'gray' }
650
+ }
651
+ });
652
+
653
+ this.musicContainer.append(this.musicList);
654
+ this.musicContainer.append(this.musicInfo);
655
+
656
+ this.statusBar = blessed.box({
657
+ bottom: 4,
658
+ height: 1,
659
+ width: '100%',
660
+ content: 'Ready',
661
+ tags: true,
662
+ style: { fg: 'green' }
663
+ });
664
+
665
+ this.helpBar = blessed.box({
666
+ bottom: 1,
667
+ height: 3,
668
+ width: '100%',
669
+ content: '{cyan-fg}[1-6]{/cyan-fg}Sort {cyan-fg}[/]{/cyan-fg}Search {cyan-fg}[P]{/cyan-fg}Prompt {cyan-fg}[L]{/cyan-fg}Filter {cyan-fg}[F/X]{/cyan-fg}Fav {cyan-fg}[Space]{/cyan-fg}Play {cyan-fg}[R]{/cyan-fg}Reverb {green-fg}[+/*]{/green-fg}Up {red-fg}[-]{/red-fg}Down {cyan-fg}[I]{/cyan-fg}Install',
670
+ tags: true,
671
+ padding: 0,
672
+ border: { type: 'line', fg: 'gray' },
673
+ style: {
674
+ bg: 'black',
675
+ fg: 'white',
676
+ border: { bg: 'black' }
677
+ }
678
+ });
679
+
680
+ this.githubMessage = blessed.box({
681
+ bottom: 0,
682
+ height: 1,
683
+ width: '100%',
684
+ content: '{center}{gray-fg}Please consider giving us a GitHub star *{/gray-fg} {yellow-fg}github.com/paulpreibisch/agentvibes{/yellow-fg}{/center}',
685
+ tags: true,
686
+ style: { fg: 'white' }
687
+ });
688
+
689
+ this.screen.append(title);
690
+ this.screen.append(headerBar);
691
+ this.screen.append(this.tabBar);
692
+ this.screen.append(this.voicesContainer);
693
+ this.screen.append(this.musicContainer);
694
+ this.screen.append(this.statusBar);
695
+ this.screen.append(this.helpBar);
696
+ this.screen.append(this.githubMessage);
697
+
698
+ this.updateTabBar();
699
+ this.updateMusicList();
700
+
701
+ this.updateList();
702
+ this.list.focus();
703
+ this.setupKeys();
704
+ this.screen.render();
705
+ }
706
+
707
+ updateList() {
708
+ const items = this.filteredData.map(row => this.formatRow(row));
709
+ this.list.setItems(items);
710
+ this.list.select(Math.min(this.currentRow, items.length - 1));
711
+
712
+ const modeLabel = this.favoritesOnly ? ' + Thumbs Up ' : ' Voices ';
713
+ this.list.setLabel(`${modeLabel}(${this.filteredData.length}) - Model (${this.uniqueModels}) - Sort: ${this.sortColumn} ${this.sortAsc ? '' : '↓'} `);
714
+ this.updateInfo();
715
+ }
716
+
717
+ updateInfo() {
718
+ const idx = this.list.selected;
719
+ if (idx < 0 || idx >= this.filteredData.length) return;
720
+
721
+ const row = this.filteredData[idx];
722
+ let info = `{bold}${row.type === 'curated' ? row.name : 'Speaker ' + row.id}{/bold}\n`;
723
+ info += `{gray-fg}${'─'.repeat(20)}{/gray-fg}\n\n`;
724
+ if (this.thumbsUp.has(row.id)) info += '{green-fg}+ Thumbs Up{/green-fg}\n\n';
725
+ else if (this.thumbsDown.has(row.id)) info += '{red-fg}- Thumbs Down{/red-fg}\n\n';
726
+ info += `{cyan-fg}ID:{/cyan-fg} ${row.id}\n`;
727
+
728
+ // Color gender value: blue for male, pink for female
729
+ const genderColor = row.gender === 'male' ? 'blue-fg' : 'magenta-fg';
730
+ info += `{cyan-fg}Gender:{/cyan-fg} {${genderColor}}${row.gender}{/${genderColor}}\n`;
731
+
732
+ info += `{cyan-fg}Voice:{/cyan-fg} ${row.name}\n`;
733
+ info += `{cyan-fg}Provider:{/cyan-fg} {green-fg}${row.provider}{/green-fg}\n`;
734
+ info += `{cyan-fg}Language:{/cyan-fg} ${row.language}\n`;
735
+
736
+ // Color model in yellow
737
+ info += `{cyan-fg}Model:{/cyan-fg} {yellow-fg}${row.model}{/yellow-fg}\n`;
738
+
739
+ if (row.type === 'curated' && row.friendlyName) {
740
+ info += `{cyan-fg}Friendly:{/cyan-fg} ${row.friendlyName}\n`;
741
+ }
742
+
743
+ // Color sample text in green - use voice-specific sample
744
+ const voiceSample = row.sampleText || this.sampleText;
745
+ info += `\n{gray-fg}Sample:{/gray-fg}\n{green-fg}"${voiceSample}"{/green-fg}\n`;
746
+
747
+ info += `\n{cyan-fg}Position:{/cyan-fg} ${idx + 1}/${this.filteredData.length}\n`;
748
+ info += `{green-fg}Thumbs Up:{/green-fg} ${this.thumbsUp.size} {red-fg}Thumbs Down:{/red-fg} ${this.thumbsDown.size}\n\n`;
749
+ info += `{green-fg}[I]{/green-fg} Install voice {cyan-fg}[P]{/cyan-fg} Copy prompt`;
750
+
751
+ this.infoPanel.setContent(info);
752
+ this.screen.render();
753
+ }
754
+
755
+ updateTabBar() {
756
+ const voicesTab = this.currentTab === 'voices'
757
+ ? '{black-bg}{magenta-fg}[V]{/magenta-fg} {cyan-fg}Voices{/cyan-fg}{/black-bg}'
758
+ : '{gray-fg}[V] Voices{/gray-fg}';
759
+ const musicTab = this.currentTab === 'music'
760
+ ? '{black-bg}{red-fg}[B]{/red-fg} {cyan-fg}🎶 Background Music{/cyan-fg}{/black-bg}'
761
+ : '{gray-fg}[B] 🎶 Background Music{/gray-fg}';
762
+
763
+ this.tabBar.setContent(` ${voicesTab} │ ${musicTab}`);
764
+ this.screen.render();
765
+ }
766
+
767
+ switchTab(tab) {
768
+ this.currentTab = tab;
769
+
770
+ if (tab === 'voices') {
771
+ this.voicesContainer.show();
772
+ this.musicContainer.hide();
773
+ this.list.focus();
774
+ } else {
775
+ this.voicesContainer.hide();
776
+ this.musicContainer.show();
777
+ this.musicList.focus();
778
+ }
779
+
780
+ this.updateTabBar();
781
+ this.screen.render();
782
+ }
783
+
784
+ updateMusicList() {
785
+ const items = this.musicTracks.map(track => {
786
+ const isCurrent = track.file === this.currentMusicSelection;
787
+ const isFavorite = this.musicFavorites.has(track.file);
788
+ const isEnabled = this.musicEnabled ? '🔊' : '🔇';
789
+ const marker = isCurrent ? `{cyan-fg}▶{/cyan-fg}` : ' ';
790
+ const favMarker = isFavorite ? '+' : ' ';
791
+ return `${marker}${favMarker} ${track.name} ${isCurrent ? isEnabled : ''}`;
792
+ });
793
+
794
+ this.musicList.setItems(items);
795
+
796
+ // Update music info
797
+ this.updateMusicInfo();
798
+ }
799
+
800
+ updateMusicInfo() {
801
+ const enabledText = this.musicEnabled ? '{green-fg}Enabled{/green-fg}' : '{red-fg}Disabled{/red-fg}';
802
+ const currentTrack = this.currentMusicSelection
803
+ ? this.musicTracks.find(t => t.file === this.currentMusicSelection)?.name || 'None'
804
+ : 'None';
805
+
806
+ let content = '{cyan-fg}{bold}Background Music{/bold}{/cyan-fg}\n\n';
807
+ content += `Status: ${enabledText}\n\n`;
808
+ content += `Current Track:\n{yellow-fg}${currentTrack}{/yellow-fg}\n\n`;
809
+ content += '{gray-fg}Controls:{/gray-fg}\n';
810
+ content += '{cyan-fg}Space{/cyan-fg} - Preview track\n';
811
+ content += '{cyan-fg}Enter{/cyan-fg} - Select track\n';
812
+ content += '{cyan-fg}F/*{/cyan-fg} - Favorite\n';
813
+ content += '{cyan-fg}M{/cyan-fg} - Toggle on/off\n';
814
+ content += '{cyan-fg}R{/cyan-fg} - Toggle reverb\n';
815
+ content += '{cyan-fg}T{/cyan-fg} - Switch tabs\n\n';
816
+ content += `{gray-fg}Total Tracks: {/gray-fg}{white-fg}${this.musicTracks.length}{/white-fg}`;
817
+
818
+ this.musicInfo.setContent(content);
819
+ }
820
+
821
+ setupKeys() {
822
+ this.screen.key(['q', 'Q', 'C-c'], () => this.exit());
823
+
824
+ // Tab switching
825
+ this.screen.key(['t', 'T'], () => {
826
+ const newTab = this.currentTab === 'voices' ? 'music' : 'voices';
827
+ this.switchTab(newTab);
828
+ });
829
+
830
+ // Tab bar click handling
831
+ this.tabBar.on('click', (data) => {
832
+ const x = data.x;
833
+ // "[V] Voices" is at position 2-12 (approx)
834
+ // "[B] 🎶 Background Music" starts around position 15+
835
+ if (x < 15) {
836
+ // Clicked on Voices tab
837
+ if (this.currentTab !== 'voices') {
838
+ this.switchTab('voices');
839
+ }
840
+ } else {
841
+ // Clicked on Background Music tab
842
+ if (this.currentTab !== 'music') {
843
+ this.switchTab('music');
844
+ }
845
+ }
846
+ });
847
+
848
+ // Listen to selection changes (blessed handles arrow keys automatically)
849
+ this.list.on('select', () => {
850
+ this.updateInfo();
851
+ });
852
+
853
+ // Double-click to play voice
854
+ let lastClickTime = 0;
855
+ this.list.on('click', async () => {
856
+ const now = Date.now();
857
+ if (now - lastClickTime < 400) {
858
+ // Double-click detected
859
+ const row = this.filteredData[this.list.selected];
860
+ if (row) await this.playSample(row);
861
+ lastClickTime = 0; // Reset to prevent triple-click
862
+ } else {
863
+ lastClickTime = now;
864
+ }
865
+ });
866
+
867
+ // Double-click column header to sort
868
+ let lastHeaderClickTime = 0;
869
+ let lastHeaderClickX = 0;
870
+ this.tableHeader.on('click', (data) => {
871
+ const now = Date.now();
872
+ const x = data.x;
873
+
874
+ if (now - lastHeaderClickTime < 400 && Math.abs(x - lastHeaderClickX) < 3) {
875
+ // Double-click detected on same column
876
+ let newSortColumn = this.sortColumn;
877
+
878
+ // Map x position to column (accounting for border offset)
879
+ // " ID G Name Provider Lang Model "
880
+ if (x < 8) {
881
+ newSortColumn = 'id';
882
+ } else if (x < 11) {
883
+ newSortColumn = 'gender';
884
+ } else if (x < 25) {
885
+ newSortColumn = 'name';
886
+ } else if (x < 34) {
887
+ newSortColumn = 'provider';
888
+ } else if (x < 41) {
889
+ newSortColumn = 'language';
890
+ } else {
891
+ newSortColumn = 'model';
892
+ }
893
+
894
+ // Toggle sort direction if same column, otherwise ascending
895
+ if (newSortColumn === this.sortColumn) {
896
+ this.sortAsc = !this.sortAsc;
897
+ } else {
898
+ this.sortColumn = newSortColumn;
899
+ this.sortAsc = true;
900
+ }
901
+
902
+ this.applyFilter();
903
+ this.updateList();
904
+
905
+ lastHeaderClickTime = 0; // Reset to prevent triple-click
906
+ } else {
907
+ lastHeaderClickTime = now;
908
+ lastHeaderClickX = x;
909
+ }
910
+ });
911
+
912
+ // Sorting
913
+ this.screen.key(['1'], () => { this.sortColumn = 'id'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
914
+ this.screen.key(['2'], () => { this.sortColumn = 'gender'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
915
+ this.screen.key(['3'], () => { this.sortColumn = 'name'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
916
+ this.screen.key(['4'], () => { this.sortColumn = 'provider'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
917
+ this.screen.key(['5'], () => { this.sortColumn = 'language'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
918
+ this.screen.key(['6'], () => { this.sortColumn = 'model'; this.sortAsc = !this.sortAsc; this.applyFilter(); this.updateList(); });
919
+
920
+ // Search
921
+ this.screen.key(['/'], () => this.showSearch());
922
+
923
+ // Play
924
+ this.list.key(['space'], async () => {
925
+ const row = this.filteredData[this.list.selected];
926
+ if (row) await this.playSample(row);
927
+ });
928
+
929
+ // Reverb toggle (on voices tab)
930
+ this.list.key(['r', 'R'], async () => {
931
+ if (this.currentTab === 'voices') {
932
+ await this.toggleReverb();
933
+ }
934
+ });
935
+
936
+ // Thumbs up (* or +) — toggle, clears thumbs down
937
+ this.list.key(['*', '+'], async () => {
938
+ const row = this.filteredData[this.list.selected];
939
+ if (row) {
940
+ if (this.thumbsUp.has(row.id)) {
941
+ this.thumbsUp.delete(row.id);
942
+ this.statusBar.setContent('{yellow-fg}Removed thumbs up{/yellow-fg}');
943
+ } else {
944
+ this.thumbsUp.add(row.id);
945
+ this.thumbsDown.delete(row.id);
946
+ this.statusBar.setContent('{green-fg}Thumbs up +{/green-fg}');
947
+ }
948
+ await this.saveProgress();
949
+ this.updateList();
950
+ }
951
+ });
952
+
953
+ // Thumbs down (-) toggle, clears thumbs up
954
+ this.list.key(['-'], async () => {
955
+ const row = this.filteredData[this.list.selected];
956
+ if (row) {
957
+ if (this.thumbsDown.has(row.id)) {
958
+ this.thumbsDown.delete(row.id);
959
+ this.statusBar.setContent('{yellow-fg}Removed thumbs down{/yellow-fg}');
960
+ } else {
961
+ this.thumbsDown.add(row.id);
962
+ this.thumbsUp.delete(row.id);
963
+ this.statusBar.setContent('{red-fg}Thumbs down -{/red-fg}');
964
+ }
965
+ await this.saveProgress();
966
+ this.updateList();
967
+ }
968
+ });
969
+
970
+ // Install/Select voice for AgentVibes
971
+ this.screen.key(['i', 'I'], () => this.installVoice());
972
+
973
+ // Toggle favorites filter
974
+ this.screen.key(['f', 'F'], () => {
975
+ this.favoritesOnly = !this.favoritesOnly;
976
+ this.applyFilter();
977
+ this.updateList();
978
+
979
+ if (this.favoritesOnly) {
980
+ this.statusBar.setContent(`{green-fg}+ Showing ${this.filteredData.length} thumbs-up voices - Press [F] or [X] to show all{/green-fg}`);
981
+ } else {
982
+ this.statusBar.setContent(`{cyan-fg}Showing all voices - Press [F] to filter thumbs-up{/cyan-fg}`);
983
+ }
984
+ this.screen.render();
985
+ });
986
+
987
+ // Exit favorites filter with X
988
+ this.screen.key(['x', 'X'], () => {
989
+ if (this.favoritesOnly) {
990
+ this.favoritesOnly = false;
991
+ this.applyFilter();
992
+ this.updateList();
993
+ this.statusBar.setContent(`{cyan-fg}Showing all voices - Press [F] to filter thumbs-up{/cyan-fg}`);
994
+ this.screen.render();
995
+ }
996
+ });
997
+
998
+ // Export
999
+ this.screen.key(['e', 'E'], () => this.exportFavorites());
1000
+
1001
+ // Navigation: Page Down
1002
+ this.list.key(['pagedown'], () => {
1003
+ const pageSize = Math.floor(this.list.height / 2);
1004
+ const newIndex = Math.min(this.list.selected + pageSize, this.filteredData.length - 1);
1005
+ this.list.select(newIndex);
1006
+ this.screen.render();
1007
+ });
1008
+
1009
+ // Navigation: Page Up
1010
+ this.list.key(['pageup'], () => {
1011
+ const pageSize = Math.floor(this.list.height / 2);
1012
+ const newIndex = Math.max(this.list.selected - pageSize, 0);
1013
+ this.list.select(newIndex);
1014
+ this.screen.render();
1015
+ });
1016
+
1017
+ // Navigation: Home (go to top)
1018
+ this.list.key(['home'], () => {
1019
+ this.list.select(0);
1020
+ this.screen.render();
1021
+ });
1022
+
1023
+ // Navigation: End (go to bottom)
1024
+ this.list.key(['end'], () => {
1025
+ if (this.filteredData.length > 0) {
1026
+ this.list.select(this.filteredData.length - 1);
1027
+ this.screen.render();
1028
+ }
1029
+ });
1030
+
1031
+ // Provider filter toggle
1032
+ this.screen.key(['l', 'L'], () => this.showProviderFilter());
1033
+
1034
+ // Voice prompt copy-pasteable AgentVibes instructions
1035
+ this.list.key(['p', 'P'], () => this.showVoicePrompt());
1036
+
1037
+ // Music tab controls
1038
+ this.musicList.key(['space'], async () => {
1039
+ if (this.currentTab !== 'music') return;
1040
+ const selected = this.musicList.selected;
1041
+ if (selected >= 0 && selected < this.musicTracks.length) {
1042
+ const selectedTrack = this.musicTracks[selected];
1043
+
1044
+ // If this track is already playing, stop it
1045
+ if (this.currentlyPlayingTrack && this.currentlyPlayingTrack.file === selectedTrack.file) {
1046
+ this.stopMusic();
1047
+ } else {
1048
+ // Otherwise, play the new track
1049
+ await this.previewMusic(selectedTrack);
1050
+ }
1051
+ }
1052
+ });
1053
+
1054
+ this.musicList.key(['enter'], async () => {
1055
+ if (this.currentTab !== 'music') return;
1056
+ const selected = this.musicList.selected;
1057
+ if (selected >= 0 && selected < this.musicTracks.length) {
1058
+ await this.selectMusic(this.musicTracks[selected]);
1059
+ }
1060
+ });
1061
+
1062
+ this.musicList.key(['m', 'M'], async () => {
1063
+ if (this.currentTab !== 'music') return;
1064
+ await this.toggleMusic();
1065
+ });
1066
+
1067
+ this.musicList.key(['r', 'R'], async () => {
1068
+ if (this.currentTab !== 'music') return;
1069
+ await this.toggleReverb();
1070
+ });
1071
+
1072
+ // Favorite music track (thumbs up)
1073
+ this.musicList.key(['f', 'F', '*', '+'], async () => {
1074
+ if (this.currentTab !== 'music') return;
1075
+ const selected = this.musicList.selected;
1076
+ if (selected >= 0 && selected < this.musicTracks.length) {
1077
+ const track = this.musicTracks[selected];
1078
+ if (this.musicFavorites.has(track.file)) {
1079
+ this.musicFavorites.delete(track.file);
1080
+ this.statusBar.setContent('{yellow-fg}Removed from favorites{/yellow-fg}');
1081
+ } else {
1082
+ this.musicFavorites.add(track.file);
1083
+ this.statusBar.setContent('{green-fg}Thumbs up +{/green-fg}');
1084
+ }
1085
+ await this.saveProgress();
1086
+ this.updateMusicList();
1087
+ this.screen.render();
1088
+ }
1089
+ });
1090
+ }
1091
+
1092
+ showVoicePrompt() {
1093
+ const row = this.filteredData[this.list.selected];
1094
+ if (!row) return;
1095
+
1096
+ // Build copy-pasteable AgentVibes instructions per voice type
1097
+ let lines = [];
1098
+ let subtitle = '';
1099
+
1100
+ switch (row.type) {
1101
+ case 'curated': {
1102
+ const switchName = row.friendlyName || row.piperVoiceId || row.model;
1103
+ subtitle = `Piper curated voice`;
1104
+ lines = [
1105
+ `# Switch to: ${row.name}`,
1106
+ ``,
1107
+ `# If piper is already your active provider:`,
1108
+ `/agent-vibes:switch ${switchName}`,
1109
+ ``,
1110
+ `# If switching from another provider first:`,
1111
+ `/agent-vibes:provider switch piper`,
1112
+ `/agent-vibes:switch ${switchName}`,
1113
+ ];
1114
+ break;
1115
+ }
1116
+ case 'libritts': {
1117
+ const speakerId = row.originalId;
1118
+ const safeName = row.name.replace(/\s+/g, '_');
1119
+ const modelFile = path.basename(CONFIG.MODEL_PATH, '.onnx');
1120
+ subtitle = `LibriTTS multi-speaker — speaker ID ${speakerId}`;
1121
+ lines = [
1122
+ `# Use LibriTTS Speaker ${speakerId}: ${row.name}`,
1123
+ ``,
1124
+ `# Step 1 — Download the model (skip if already downloaded):`,
1125
+ `bash .claude/hooks/piper-voice-manager.sh download ${modelFile}`,
1126
+ ``,
1127
+ `# Step 2 — Register speaker in piper-multispeaker-registry.sh:`,
1128
+ `# Add this line to the MULTISPEAKER_VOICES array:`,
1129
+ ` "${safeName}:${modelFile}:${speakerId}:LibriTTS Speaker"`,
1130
+ ``,
1131
+ `# Step 3 — Switch AgentVibes to this voice:`,
1132
+ `/agent-vibes:switch ${safeName}`,
1133
+ ];
1134
+ break;
1135
+ }
1136
+ case 'macos': {
1137
+ subtitle = `macOS built-in voice`;
1138
+ lines = [
1139
+ `# Switch to macOS voice: ${row.name}`,
1140
+ ``,
1141
+ `# Step 1 — Switch provider to macOS:`,
1142
+ `/agent-vibes:provider switch macos`,
1143
+ ``,
1144
+ `# Step 2 — Switch to this voice:`,
1145
+ `/agent-vibes:switch ${row.name}`,
1146
+ ];
1147
+ break;
1148
+ }
1149
+ case 'windows-sapi': {
1150
+ subtitle = `Windows SAPI built-in voice`;
1151
+ lines = [
1152
+ `# Switch to Windows SAPI voice: ${row.name}`,
1153
+ ``,
1154
+ `# Step 1 Switch provider to Windows SAPI:`,
1155
+ `/agent-vibes:provider switch windows-sapi`,
1156
+ ``,
1157
+ `# Step 2 — Switch to this voice:`,
1158
+ `/agent-vibes:switch ${row.name}`,
1159
+ ];
1160
+ break;
1161
+ }
1162
+ case 'soprano': {
1163
+ subtitle = `Soprano neural TTS single voice`;
1164
+ lines = [
1165
+ `# Switch to Soprano TTS`,
1166
+ ``,
1167
+ `/agent-vibes:provider switch soprano`,
1168
+ ``,
1169
+ `# Soprano has one built-in voice — no voice selection needed.`,
1170
+ ];
1171
+ break;
1172
+ }
1173
+ default: {
1174
+ subtitle = row.provider;
1175
+ lines = [
1176
+ `# Switch to: ${row.name}`,
1177
+ `/agent-vibes:switch ${row.name}`,
1178
+ ];
1179
+ }
1180
+ }
1181
+
1182
+ const promptText = lines.join('\n');
1183
+ const contentHeight = lines.length + 8;
1184
+ const boxHeight = Math.min(contentHeight, Math.floor(this.screen.height * 0.8));
1185
+
1186
+ const modal = blessed.box({
1187
+ parent: this.screen,
1188
+ top: 'center',
1189
+ left: 'center',
1190
+ width: 72,
1191
+ height: boxHeight,
1192
+ border: { type: 'line', fg: 'green' },
1193
+ label: ` [P] Prompt ${row.name} `,
1194
+ tags: true,
1195
+ scrollable: true,
1196
+ alwaysScroll: true,
1197
+ keys: true,
1198
+ vi: true,
1199
+ mouse: true,
1200
+ padding: 1,
1201
+ style: {
1202
+ border: { fg: 'green' },
1203
+ bg: 'black',
1204
+ fg: 'white'
1205
+ }
1206
+ });
1207
+
1208
+ let content = `{yellow-fg}{bold}${row.name}{/bold}{/yellow-fg} {gray-fg}${subtitle}{/gray-fg}\n\n`;
1209
+ content += `{gray-fg}Copy and paste these commands into your terminal or Claude session:{/gray-fg}\n\n`;
1210
+ content += `{green-fg}${lines.join('\n')}{/green-fg}\n\n`;
1211
+ content += `{gray-fg}─────────────────────────────────────────────────────────────{/gray-fg}\n`;
1212
+ content += `{gray-fg}[Esc/Q] Close [↑↓] Scroll{/gray-fg}`;
1213
+
1214
+ modal.setContent(content);
1215
+
1216
+ // Try to copy to clipboard (best-effort, silent on failure)
1217
+ const clipboardCmds = [
1218
+ ['xclip', ['-selection', 'clipboard']],
1219
+ ['xsel', ['--clipboard', '--input']],
1220
+ ['pbcopy', []]
1221
+ ];
1222
+ for (const [cmd, args] of clipboardCmds) {
1223
+ try {
1224
+ const proc = spawnSync('which', [cmd], { encoding: 'utf8', timeout: 500 });
1225
+ if (proc.status === 0) {
1226
+ const cp = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
1227
+ cp.stdin.write(promptText);
1228
+ cp.stdin.end();
1229
+ // Update status bar to let user know
1230
+ this.statusBar.setContent(`{green-fg}✓ Prompt copied to clipboard via ${cmd}{/green-fg}`);
1231
+ break;
1232
+ }
1233
+ } catch {
1234
+ // Silently skip
1235
+ }
1236
+ }
1237
+
1238
+ modal.key(['escape', 'q', 'Q'], () => {
1239
+ this.screen.remove(modal);
1240
+ this.list.focus();
1241
+ this.screen.render();
1242
+ });
1243
+
1244
+ modal.focus();
1245
+ this.screen.render();
1246
+ }
1247
+
1248
+ showProviderFilter() {
1249
+ // Get unique providers from tableData
1250
+ const providers = [...new Set(this.tableData.map(row => row.provider))].sort();
1251
+
1252
+ const menu = blessed.list({
1253
+ parent: this.screen,
1254
+ top: 'center',
1255
+ left: 'center',
1256
+ width: 40,
1257
+ height: Math.min(providers.length + 4, 15),
1258
+ border: { type: 'line', fg: 'cyan' },
1259
+ label: ' Filter by Provider ',
1260
+ keys: true,
1261
+ vi: true,
1262
+ mouse: true,
1263
+ style: {
1264
+ selected: { bg: 'cyan', fg: 'black' },
1265
+ border: { fg: 'cyan' }
1266
+ }
1267
+ });
1268
+
1269
+ const items = ['All Providers', ...providers];
1270
+ menu.setItems(items);
1271
+
1272
+ // Select current filter
1273
+ if (this.providerFilter) {
1274
+ const index = items.indexOf(this.providerFilter);
1275
+ if (index >= 0) menu.select(index);
1276
+ }
1277
+
1278
+ menu.on('select', (item, index) => {
1279
+ if (index === 0) {
1280
+ // All Providers
1281
+ this.providerFilter = null;
1282
+ this.statusBar.setContent(`{cyan-fg}Showing all providers - Press [P] to filter{/cyan-fg}`);
1283
+ } else {
1284
+ // Specific provider
1285
+ this.providerFilter = item.getText();
1286
+ this.statusBar.setContent(`{cyan-fg}Showing ${this.providerFilter} only - Press [P] to change{/cyan-fg}`);
1287
+ }
1288
+
1289
+ this.applyFilter();
1290
+ this.updateList();
1291
+ this.screen.remove(menu);
1292
+ this.list.focus();
1293
+ this.screen.render();
1294
+ });
1295
+
1296
+ menu.key(['escape'], () => {
1297
+ this.screen.remove(menu);
1298
+ this.list.focus();
1299
+ this.screen.render();
1300
+ });
1301
+
1302
+ menu.focus();
1303
+ this.screen.render();
1304
+ }
1305
+
1306
+ stopMusic() {
1307
+ // Kill existing audio process if any
1308
+ if (this.currentAudioProcess) {
1309
+ try {
1310
+ this.currentAudioProcess.kill('SIGKILL');
1311
+ this.currentAudioProcess = null;
1312
+ this.currentlyPlayingTrack = null;
1313
+ } catch (error) {}
1314
+ }
1315
+
1316
+ this.statusBar.setContent(`{yellow-fg}⏹ Stopped playback{/yellow-fg}`);
1317
+ this.screen.render();
1318
+ }
1319
+
1320
+ async previewMusic(track) {
1321
+ // Kill existing audio process if any
1322
+ if (this.currentAudioProcess) {
1323
+ try {
1324
+ this.currentAudioProcess.kill('SIGKILL');
1325
+ this.currentAudioProcess = null;
1326
+ } catch (error) {}
1327
+ }
1328
+
1329
+ const trackPath = track.path;
1330
+ this.currentlyPlayingTrack = track;
1331
+
1332
+ this.statusBar.setContent(`{cyan-fg}▶ Playing: ${track.name}...{/cyan-fg}`);
1333
+ this.screen.render();
1334
+
1335
+ // Try different audio players
1336
+ const players = [
1337
+ { cmd: 'ffplay', args: ['-nodisp', '-autoexit', '-t', '15', trackPath] },
1338
+ { cmd: 'mpg123', args: ['-q', '--loop', '1', trackPath] },
1339
+ { cmd: 'afplay', args: [trackPath] }
1340
+ ];
1341
+
1342
+ for (const player of players) {
1343
+ try {
1344
+ // SECURITY: Use spawnSync instead of shell string (#126)
1345
+ if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1346
+
1347
+ const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1348
+ this.currentAudioProcess = audioProcess;
1349
+
1350
+ audioProcess.on('close', () => {
1351
+ if (this.currentAudioProcess === audioProcess) {
1352
+ this.currentAudioProcess = null;
1353
+ this.currentlyPlayingTrack = null;
1354
+ }
1355
+ this.statusBar.setContent(`{green-fg}✓ Playback complete{/green-fg}`);
1356
+ this.screen.render();
1357
+ });
1358
+
1359
+ audioProcess.on('error', (err) => {
1360
+ if (this.currentAudioProcess === audioProcess) {
1361
+ this.currentAudioProcess = null;
1362
+ this.currentlyPlayingTrack = null;
1363
+ }
1364
+ this.statusBar.setContent(`{red-fg}✗ Error playing track{/red-fg}`);
1365
+ this.screen.render();
1366
+ });
1367
+
1368
+ break;
1369
+ } catch (error) {
1370
+ continue;
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ async selectMusic(track) {
1376
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
1377
+ const configDir = path.join(homeDir, '.claude', 'config');
1378
+ const musicConfigFile = path.join(configDir, 'background-music.txt');
1379
+
1380
+ try {
1381
+ // Ensure config directory exists
1382
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1383
+
1384
+ await fs.writeFile(musicConfigFile, track.file, { mode: 0o600 });
1385
+ this.currentMusicSelection = track.file;
1386
+ this.updateMusicList();
1387
+ this.statusBar.setContent(`{green-fg}✓ Selected: ${track.name}{/green-fg}`);
1388
+ this.screen.render();
1389
+ } catch (error) {
1390
+ this.statusBar.setContent(`{red-fg}✗ Error selecting track: ${error.message}{/red-fg}`);
1391
+ this.screen.render();
1392
+ }
1393
+ }
1394
+
1395
+ async toggleMusic() {
1396
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
1397
+ const configDir = path.join(homeDir, '.claude', 'config');
1398
+ const musicEnabledFile = path.join(configDir, 'background-music-enabled.txt');
1399
+
1400
+ try {
1401
+ // Ensure config directory exists
1402
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1403
+
1404
+ this.musicEnabled = !this.musicEnabled;
1405
+ await fs.writeFile(musicEnabledFile, this.musicEnabled ? 'true' : 'false', { mode: 0o600 });
1406
+ this.updateMusicList();
1407
+ this.updateMusicInfo();
1408
+
1409
+ const status = this.musicEnabled ? 'Enabled' : 'Disabled';
1410
+ this.statusBar.setContent(`{green-fg}✓ Background Music ${status}{/green-fg}`);
1411
+ this.screen.render();
1412
+ } catch (error) {
1413
+ this.statusBar.setContent(`{red-fg}✗ Error toggling music: ${error.message}{/red-fg}`);
1414
+ this.screen.render();
1415
+ }
1416
+ }
1417
+
1418
+ async toggleReverb() {
1419
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
1420
+ const configDir = path.join(homeDir, '.claude', 'config');
1421
+ const audioEffectsPath = path.join(configDir, 'audio-effects.cfg');
1422
+
1423
+ try {
1424
+ // Ensure config directory exists
1425
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1426
+
1427
+ // Read current reverb setting
1428
+ let content = '';
1429
+ try {
1430
+ content = await fs.readFile(audioEffectsPath, 'utf8');
1431
+ } catch {
1432
+ content = 'REVERB_ENABLED=false\nREVERB_LEVEL=medium\n';
1433
+ }
1434
+
1435
+ // Toggle reverb
1436
+ const currentEnabled = content.includes('REVERB_ENABLED=true');
1437
+ const newEnabled = !currentEnabled;
1438
+
1439
+ content = content.replace(
1440
+ /REVERB_ENABLED=(true|false)/,
1441
+ `REVERB_ENABLED=${newEnabled}`
1442
+ );
1443
+
1444
+ await fs.writeFile(audioEffectsPath, content, { mode: 0o600 });
1445
+
1446
+ const status = newEnabled ? 'Enabled' : 'Disabled';
1447
+ this.statusBar.setContent(`{green-fg}✓ Reverb ${status}{/green-fg}`);
1448
+ this.screen.render();
1449
+ } catch (error) {
1450
+ this.statusBar.setContent(`{red-fg}✗ Error toggling reverb: ${error.message}{/red-fg}`);
1451
+ this.screen.render();
1452
+ }
1453
+ }
1454
+
1455
+ showSearch() {
1456
+ const searchBox = blessed.textbox({
1457
+ parent: this.screen,
1458
+ top: 'center',
1459
+ left: 'center',
1460
+ width: 50,
1461
+ height: 3,
1462
+ border: { type: 'line', fg: 'cyan' },
1463
+ label: ' Search ',
1464
+ inputOnFocus: true
1465
+ });
1466
+
1467
+ searchBox.on('submit', (value) => {
1468
+ this.searchTerm = value.trim();
1469
+ this.applyFilter();
1470
+ this.updateList();
1471
+ this.screen.remove(searchBox);
1472
+ this.list.focus();
1473
+ this.statusBar.setContent(`{cyan-fg}Search: "${this.searchTerm}" - ${this.filteredData.length} results{/cyan-fg}`);
1474
+ this.screen.render();
1475
+ });
1476
+
1477
+ searchBox.key(['escape'], () => {
1478
+ this.screen.remove(searchBox);
1479
+ this.list.focus();
1480
+ this.screen.render();
1481
+ });
1482
+
1483
+ searchBox.focus();
1484
+ this.screen.render();
1485
+ }
1486
+
1487
+ _getActiveProvider() {
1488
+ const remoteProviders = ['ssh-remote', 'agentvibes-receiver'];
1489
+ try {
1490
+ const providerPaths = [
1491
+ path.join(process.cwd(), '.claude', 'tts-provider.txt'),
1492
+ path.join(os.homedir(), '.claude', 'tts-provider.txt'),
1493
+ ];
1494
+ for (const p of providerPaths) {
1495
+ if (fsSync.existsSync(p)) {
1496
+ const provider = fsSync.readFileSync(p, 'utf8').trim();
1497
+ if (provider) return { provider, isRemote: remoteProviders.includes(provider) };
1498
+ }
1499
+ }
1500
+ } catch {}
1501
+ return { provider: 'piper', isRemote: false };
1502
+ }
1503
+
1504
+ async playSample(row) {
1505
+ if (this.currentAudioProcess) {
1506
+ try {
1507
+ this.currentAudioProcess.kill('SIGKILL');
1508
+ this.currentAudioProcess = null;
1509
+ } catch (error) {
1510
+ // Process might have already finished
1511
+ }
1512
+ }
1513
+
1514
+ this.statusBar.setContent(`{cyan-fg}Playing ${row.name}...{/cyan-fg}`);
1515
+ this.screen.render();
1516
+
1517
+ // Use voice-specific sample text
1518
+ const sampleText = row.sampleText || this.sampleText;
1519
+
1520
+ // Route through remote provider if active
1521
+ const { isRemote } = this._getActiveProvider();
1522
+ if (isRemote) {
1523
+ return await this._playRemote(row, sampleText);
1524
+ }
1525
+
1526
+ // Handle different providers
1527
+ switch (row.type) {
1528
+ case 'macos':
1529
+ return await this.playMacOSVoice(row, sampleText);
1530
+ case 'windows-sapi':
1531
+ return await this.playWindowsSAPIVoice(row, sampleText);
1532
+ case 'soprano':
1533
+ return await this.playSopranoVoice(row, sampleText);
1534
+ default:
1535
+ return await this.playPiperVoice(row, sampleText);
1536
+ }
1537
+ }
1538
+
1539
+ async _playRemote(row, sampleText) {
1540
+ // Build voice ID for play-tts.sh
1541
+ let voiceId;
1542
+ if (row.type === 'curated') {
1543
+ voiceId = row.piperVoiceId || row.model;
1544
+ } else {
1545
+ // LibriTTS multi-speaker: pass as model::SpeakerName-ID
1546
+ voiceId = `en_US-libritts-high::${row.name}-${row.id}`;
1547
+ }
1548
+
1549
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1550
+ try {
1551
+ let proc;
1552
+ if (isWindows) {
1553
+ const playTts = path.join(__dirname, '..', '.claude', 'hooks-windows', 'play-tts.ps1');
1554
+ proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, sampleText, voiceId], {
1555
+ stdio: 'ignore', detached: false, windowsHide: true,
1556
+ });
1557
+ } else {
1558
+ const playTts = path.join(__dirname, '..', '.claude', 'hooks', 'play-tts.sh');
1559
+ proc = spawn('bash', [playTts, sampleText, voiceId], {
1560
+ stdio: 'ignore', detached: true,
1561
+ });
1562
+ }
1563
+ this.currentAudioProcess = proc;
1564
+
1565
+ this.statusBar.setContent(`{cyan-fg}Playing ${row.name} (remote)...{/cyan-fg}`);
1566
+ this.screen.render();
1567
+
1568
+ proc.on('close', () => {
1569
+ if (this.currentAudioProcess === proc) this.currentAudioProcess = null;
1570
+ this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1571
+ this.screen.render();
1572
+ });
1573
+
1574
+ proc.on('error', () => {
1575
+ if (this.currentAudioProcess === proc) this.currentAudioProcess = null;
1576
+ this.statusBar.setContent(`{red-fg}✗ Remote preview failed{/red-fg}`);
1577
+ this.screen.render();
1578
+ });
1579
+ } catch (error) {
1580
+ this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1581
+ this.screen.render();
1582
+ }
1583
+ }
1584
+
1585
+ async playMacOSVoice(row, sampleText) {
1586
+ try {
1587
+ const process = spawn('say', ['-v', row.name, sampleText], { stdio: 'ignore' });
1588
+ this.currentAudioProcess = process;
1589
+
1590
+ process.on('close', () => {
1591
+ if (this.currentAudioProcess === process) {
1592
+ this.currentAudioProcess = null;
1593
+ }
1594
+ this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1595
+ this.screen.render();
1596
+ });
1597
+
1598
+ process.on('error', (err) => {
1599
+ if (this.currentAudioProcess === process) {
1600
+ this.currentAudioProcess = null;
1601
+ }
1602
+ this.statusBar.setContent(`{red-fg}✗ Error playing voice{/red-fg}`);
1603
+ this.screen.render();
1604
+ });
1605
+ } catch (error) {
1606
+ this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1607
+ this.screen.render();
1608
+ }
1609
+ }
1610
+
1611
+ async playWindowsSAPIVoice(row, sampleText) {
1612
+ try {
1613
+ // SECURITY: Escape row.name and sampleText for PowerShell single-quote context (#124)
1614
+ const safeName = row.name.replace(/'/g, "''");
1615
+ const safeText = sampleText.replace(/'/g, "''");
1616
+ const psScript = `Add-Type -AssemblyName System.Speech; $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; $synth.SelectVoice('${safeName}'); $synth.Speak('${safeText}')`;
1617
+ const process = spawn('powershell', ['-Command', psScript], { stdio: 'ignore' });
1618
+ this.currentAudioProcess = process;
1619
+
1620
+ process.on('close', () => {
1621
+ if (this.currentAudioProcess === process) {
1622
+ this.currentAudioProcess = null;
1623
+ }
1624
+ this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1625
+ this.screen.render();
1626
+ });
1627
+
1628
+ process.on('error', (err) => {
1629
+ if (this.currentAudioProcess === process) {
1630
+ this.currentAudioProcess = null;
1631
+ }
1632
+ this.statusBar.setContent(`{red-fg}✗ Error playing voice{/red-fg}`);
1633
+ this.screen.render();
1634
+ });
1635
+ } catch (error) {
1636
+ this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1637
+ this.screen.render();
1638
+ }
1639
+ }
1640
+
1641
+ async playSopranoVoice(row, sampleText) {
1642
+ try {
1643
+ // Soprano uses OpenAI API format
1644
+ const outputFile = path.join(CONFIG.OUTPUT_DIR, `soprano_${row.name.toLowerCase()}_${Date.now()}.wav`);
1645
+
1646
+ // Create JSON payload file to avoid shell escaping issues
1647
+ const payloadFile = path.join(CONFIG.OUTPUT_DIR, `soprano_payload_${Date.now()}.json`);
1648
+ const payload = {
1649
+ input: sampleText,
1650
+ model: 'tts-1',
1651
+ voice: row.name.toLowerCase() // API expects lowercase voice names
1652
+ };
1653
+ await fs.writeFile(payloadFile, JSON.stringify(payload));
1654
+
1655
+ // SECURITY: Use spawn with argument array instead of shell string (#125)
1656
+ await new Promise((resolve, reject) => {
1657
+ const curlProc = spawn('curl', [
1658
+ '-s', '-m', '10', '-X', 'POST',
1659
+ 'http://127.0.0.1:7860/v1/audio/speech',
1660
+ '-H', 'Content-Type: application/json',
1661
+ '-d', `@${payloadFile}`,
1662
+ '-o', outputFile
1663
+ ], { stdio: 'ignore' });
1664
+ curlProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`curl exited ${code}`)));
1665
+ curlProc.on('error', reject);
1666
+ });
1667
+
1668
+ // Clean up payload file
1669
+ try {
1670
+ await fs.unlink(payloadFile);
1671
+ } catch {}
1672
+
1673
+ // Play the generated audio
1674
+ const players = [
1675
+ { cmd: 'aplay', args: [outputFile] },
1676
+ { cmd: 'paplay', args: [outputFile] },
1677
+ { cmd: 'ffplay', args: ['-nodisp', '-autoexit', outputFile] }
1678
+ ];
1679
+
1680
+ for (const player of players) {
1681
+ try {
1682
+ // SECURITY: Use spawnSync instead of shell string (#126)
1683
+ if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1684
+
1685
+ const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1686
+ this.currentAudioProcess = audioProcess;
1687
+
1688
+ audioProcess.on('close', async () => {
1689
+ if (this.currentAudioProcess === audioProcess) {
1690
+ this.currentAudioProcess = null;
1691
+ }
1692
+ // Clean up temp file
1693
+ try {
1694
+ await fs.unlink(outputFile);
1695
+ } catch {}
1696
+ this.statusBar.setContent(`{green-fg}✓ Played ${row.name}{/green-fg}`);
1697
+ this.screen.render();
1698
+ });
1699
+
1700
+ audioProcess.on('error', (err) => {
1701
+ if (this.currentAudioProcess === audioProcess) {
1702
+ this.currentAudioProcess = null;
1703
+ }
1704
+ this.statusBar.setContent(`{red-fg}✗ Error playing voice{/red-fg}`);
1705
+ this.screen.render();
1706
+ });
1707
+
1708
+ break;
1709
+ } catch (error) {
1710
+ continue;
1711
+ }
1712
+ }
1713
+ } catch (error) {
1714
+ this.statusBar.setContent(`{red-fg}✗ Error with Soprano: ${error.message}{/red-fg}`);
1715
+ this.screen.render();
1716
+ }
1717
+ }
1718
+
1719
+ async playPiperVoice(row, sampleText) {
1720
+ // Sanitize sampleText to prevent command injection
1721
+ const safeSampleText = sampleText.replace(/[`$\\!"]/g, '\\$&');
1722
+
1723
+ // Generate unique filename based on sample text hash to support different samples
1724
+ const textHash = sampleText.substring(0, 20).replace(/[^a-zA-Z0-9]/g, '');
1725
+
1726
+ let outputFile;
1727
+ if (row.type === 'curated') {
1728
+ // Validate model name to prevent path traversal
1729
+ const safeModel = path.basename(row.model);
1730
+ if (safeModel !== row.model || /[^a-zA-Z0-9_-]/.test(safeModel)) {
1731
+ this.statusBar.setContent(`{red-fg}✗ Invalid model name{/red-fg}`);
1732
+ this.screen.render();
1733
+ return;
1734
+ }
1735
+
1736
+ outputFile = path.join(CONFIG.CURATED_DIR, `${safeModel}_${textHash}.wav`);
1737
+
1738
+ // Verify output path stays within intended directory
1739
+ const resolvedOutput = path.resolve(outputFile);
1740
+ const resolvedDir = path.resolve(CONFIG.CURATED_DIR);
1741
+ if (!resolvedOutput.startsWith(resolvedDir + path.sep)) {
1742
+ this.statusBar.setContent(`{red-fg}✗ Invalid output path{/red-fg}`);
1743
+ this.screen.render();
1744
+ return;
1745
+ }
1746
+
1747
+ const modelPath = path.join(CONFIG.PIPER_VOICES_DIR, `${safeModel}.onnx`);
1748
+ // SECURITY: Always regenerate instead of TOCTOU check (#132)
1749
+ {
1750
+ const piperProcess = spawn(CONFIG.PIPER_PATH, [
1751
+ '--model', modelPath,
1752
+ '--output_file', outputFile
1753
+ ], { stdio: ['pipe', 'ignore', 'ignore'] });
1754
+
1755
+ piperProcess.stdin.write(safeSampleText);
1756
+ piperProcess.stdin.end();
1757
+
1758
+ await new Promise((resolve, reject) => {
1759
+ piperProcess.on('close', code => code === 0 ? resolve() : reject(new Error(`Piper exit ${code}`)));
1760
+ piperProcess.on('error', reject);
1761
+ });
1762
+ }
1763
+ } else {
1764
+ // Validate speaker ID is numeric
1765
+ if (!Number.isInteger(row.id) || row.id < 0) {
1766
+ this.statusBar.setContent(`{red-fg}✗ Invalid speaker ID{/red-fg}`);
1767
+ this.screen.render();
1768
+ return;
1769
+ }
1770
+
1771
+ outputFile = path.join(CONFIG.OUTPUT_DIR, `speaker_${row.id}_${textHash}.wav`);
1772
+
1773
+ // Verify output path stays within intended directory
1774
+ const resolvedOutput = path.resolve(outputFile);
1775
+ const resolvedDir = path.resolve(CONFIG.OUTPUT_DIR);
1776
+ if (!resolvedOutput.startsWith(resolvedDir + path.sep)) {
1777
+ this.statusBar.setContent(`{red-fg}✗ Invalid output path{/red-fg}`);
1778
+ this.screen.render();
1779
+ return;
1780
+ }
1781
+
1782
+ // SECURITY: Always regenerate instead of TOCTOU check (#132)
1783
+ {
1784
+ const piperProcess = spawn(CONFIG.PIPER_PATH, [
1785
+ '--model', CONFIG.MODEL_PATH,
1786
+ '--speaker', row.id.toString(),
1787
+ '--output_file', outputFile
1788
+ ], { stdio: ['pipe', 'ignore', 'ignore'] });
1789
+
1790
+ piperProcess.stdin.write(safeSampleText);
1791
+ piperProcess.stdin.end();
1792
+
1793
+ await new Promise((resolve, reject) => {
1794
+ piperProcess.on('close', code => code === 0 ? resolve() : reject(new Error(`Piper exit ${code}`)));
1795
+ piperProcess.on('error', reject);
1796
+ });
1797
+ }
1798
+ }
1799
+
1800
+ const players = [
1801
+ { cmd: 'aplay', args: [outputFile] },
1802
+ { cmd: 'paplay', args: [outputFile] },
1803
+ { cmd: 'ffplay', args: ['-nodisp', '-autoexit', outputFile] }
1804
+ ];
1805
+
1806
+ for (const player of players) {
1807
+ try {
1808
+ // SECURITY: Use spawnSync instead of shell string (#126)
1809
+ if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1810
+
1811
+ // SECURITY: Store process immediately to prevent leak
1812
+ const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1813
+ this.currentAudioProcess = audioProcess;
1814
+
1815
+ audioProcess.on('close', () => {
1816
+ if (this.currentAudioProcess === audioProcess) {
1817
+ this.currentAudioProcess = null;
1818
+ }
1819
+ this.statusBar.setContent(`{green-fg} Played ${row.name}{/green-fg}`);
1820
+ this.screen.render();
1821
+ });
1822
+
1823
+ audioProcess.on('error', (err) => {
1824
+ if (this.currentAudioProcess === audioProcess) {
1825
+ this.currentAudioProcess = null;
1826
+ }
1827
+ });
1828
+
1829
+ break;
1830
+ } catch (error) {
1831
+ continue;
1832
+ }
1833
+ }
1834
+ }
1835
+
1836
+ async installVoice() {
1837
+ const row = this.filteredData[this.list.selected];
1838
+ if (!row) return;
1839
+
1840
+ try {
1841
+ // Read current config
1842
+ let config = {};
1843
+ try {
1844
+ const configData = await fs.readFile(CONFIG.AGENTVIBES_CONFIG, 'utf8');
1845
+ config = JSON.parse(configData);
1846
+ } catch (e) {
1847
+ // Config doesn't exist yet, will create it
1848
+ }
1849
+
1850
+ // Determine the voice ID to save
1851
+ let voiceId;
1852
+ if (row.type === 'curated' && row.friendlyName) {
1853
+ // For curated voices with friendly names, save the friendly name
1854
+ // This allows users to reference them easily (e.g., "switch to Ryan")
1855
+ voiceId = row.friendlyName;
1856
+ } else if (row.type === 'curated') {
1857
+ // Fallback to Piper ID if no friendly name
1858
+ voiceId = row.piperVoiceId;
1859
+ } else {
1860
+ // For LibriTTS speakers, save as speaker ID
1861
+ voiceId = `libritts-speaker-${row.id}`;
1862
+ }
1863
+
1864
+ // SECURITY: Validate voiceId to prevent JSON injection
1865
+ if (!/^[a-zA-Z0-9_-]+$/.test(voiceId)) {
1866
+ this.statusBar.setContent(`{red-fg}✗ Invalid voice ID format{/red-fg}`);
1867
+ this.screen.render();
1868
+ return;
1869
+ }
1870
+
1871
+ // Update config
1872
+ config.defaultVoice = voiceId;
1873
+ config.ttsProvider = 'piper';
1874
+
1875
+ // Ensure config directory exists with secure permissions
1876
+ const configDir = path.dirname(CONFIG.AGENTVIBES_CONFIG);
1877
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
1878
+
1879
+ // SECURITY: Atomic write to prevent race condition
1880
+ const tempFile = CONFIG.AGENTVIBES_CONFIG + '.tmp.' + Date.now();
1881
+ await fs.writeFile(tempFile, JSON.stringify(config, null, 2), { mode: 0o600 });
1882
+ await fs.rename(tempFile, CONFIG.AGENTVIBES_CONFIG);
1883
+
1884
+ this.statusBar.setContent(`{green-fg}✓ Installed: ${row.name} → AgentVibes default voice{/green-fg}`);
1885
+ this.screen.render();
1886
+
1887
+ // Show confirmation dialog
1888
+ setTimeout(() => {
1889
+ const confirmBox = blessed.box({
1890
+ parent: this.screen,
1891
+ top: 'center',
1892
+ left: 'center',
1893
+ width: 60,
1894
+ height: 7,
1895
+ border: { type: 'line', fg: 'green' },
1896
+ label: ' ✓ Voice Installed ',
1897
+ content: `\n{center}${row.name} is now your AgentVibes default voice!{/center}\n\n{center}{gray-fg}Press any key to continue...{/gray-fg}{/center}`,
1898
+ tags: true
1899
+ });
1900
+
1901
+ this.screen.append(confirmBox);
1902
+ this.screen.render();
1903
+
1904
+ const closeDialog = () => {
1905
+ this.screen.remove(confirmBox);
1906
+ this.list.focus();
1907
+ this.screen.render();
1908
+ this.screen.unkey(['space'], closeDialog);
1909
+ this.screen.unkey(['enter'], closeDialog);
1910
+ this.screen.unkey(['escape'], closeDialog);
1911
+ };
1912
+
1913
+ this.screen.key(['space', 'enter', 'escape'], closeDialog);
1914
+ this.screen.onceKey(['space', 'enter', 'escape'], closeDialog);
1915
+ }, 500);
1916
+
1917
+ } catch (error) {
1918
+ this.statusBar.setContent(`{red-fg}✗ Error: ${error.message}{/red-fg}`);
1919
+ this.screen.render();
1920
+ }
1921
+ }
1922
+
1923
+ async exportFavorites() {
1924
+ const upData = this.tableData.filter(row => this.thumbsUp.has(row.id));
1925
+ const downData = this.tableData.filter(row => this.thumbsDown.has(row.id));
1926
+ const exportFile = path.join(os.homedir(), 'agentvibes-favorites.json');
1927
+ await fs.writeFile(exportFile, JSON.stringify({ thumbsUp: upData, thumbsDown: downData }, null, 2));
1928
+ this.statusBar.setContent(`{green-fg}✓ Exported ${upData.length} thumbs-up, ${downData.length} thumbs-down to ${exportFile}{/green-fg}`);
1929
+ this.screen.render();
1930
+ }
1931
+
1932
+ async exit() {
1933
+ await this.saveProgress();
1934
+ this.screen.destroy();
1935
+ console.log('\n✓ Progress saved. Goodbye!\n');
1936
+ process.exit(0);
1937
+ }
1938
+ }
1939
+
1940
+ new AgentVibesVoiceBrowser().init().catch(console.error);