agentvibes 4.2.0 → 4.4.1

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 (219) hide show
  1. package/.agentvibes/bmad/bmad-voices.md +69 -69
  2. package/.agentvibes/config.json +12 -0
  3. package/.claude/activation-instructions +54 -54
  4. package/.claude/audio/tracks/README.md +52 -52
  5. package/.claude/commands/agent-vibes/add.md +21 -21
  6. package/.claude/commands/agent-vibes/agent-vibes.md +101 -101
  7. package/.claude/commands/agent-vibes/agent.md +79 -79
  8. package/.claude/commands/agent-vibes/background-music.md +111 -111
  9. package/.claude/commands/agent-vibes/bmad.md +198 -198
  10. package/.claude/commands/agent-vibes/clean.md +18 -18
  11. package/.claude/commands/agent-vibes/cleanup.md +18 -18
  12. package/.claude/commands/agent-vibes/commands.json +145 -145
  13. package/.claude/commands/agent-vibes/effects.md +97 -97
  14. package/.claude/commands/agent-vibes/get.md +9 -9
  15. package/.claude/commands/agent-vibes/hide.md +91 -91
  16. package/.claude/commands/agent-vibes/language.md +23 -23
  17. package/.claude/commands/agent-vibes/learn.md +67 -67
  18. package/.claude/commands/agent-vibes/list.md +13 -13
  19. package/.claude/commands/agent-vibes/mute.md +37 -37
  20. package/.claude/commands/agent-vibes/preview.md +17 -17
  21. package/.claude/commands/agent-vibes/provider.md +68 -68
  22. package/.claude/commands/agent-vibes/replay-target.md +14 -14
  23. package/.claude/commands/agent-vibes/sample.md +12 -12
  24. package/.claude/commands/agent-vibes/set-favorite-voice.md +84 -84
  25. package/.claude/commands/agent-vibes/set-pretext.md +65 -65
  26. package/.claude/commands/agent-vibes/set-speed.md +41 -41
  27. package/.claude/commands/agent-vibes/show.md +84 -84
  28. package/.claude/commands/agent-vibes/switch.md +87 -87
  29. package/.claude/commands/agent-vibes/target-voice.md +26 -26
  30. package/.claude/commands/agent-vibes/target.md +30 -30
  31. package/.claude/commands/agent-vibes/translate.md +68 -68
  32. package/.claude/commands/agent-vibes/unmute.md +45 -45
  33. package/.claude/commands/agent-vibes/verbosity.md +89 -89
  34. package/.claude/commands/agent-vibes/whoami.md +7 -7
  35. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  36. package/.claude/commands/agent-vibes-rdp.md +24 -24
  37. package/.claude/config/agentvibes.json +1 -0
  38. package/.claude/config/audio-effects.cfg +2 -2
  39. package/.claude/config/audio-effects.cfg.sample +52 -52
  40. package/.claude/config/background-music-volume.txt +1 -0
  41. package/.claude/config/intro-text.txt +1 -0
  42. package/.claude/config/piper-speech-rate.txt +4 -0
  43. package/.claude/config/piper-target-speech-rate.txt +1 -0
  44. package/.claude/config/reverb-level.txt +1 -0
  45. package/.claude/config/tts-speech-rate.txt +4 -0
  46. package/.claude/config/tts-target-speech-rate.txt +1 -0
  47. package/.claude/docs/TERMUX_SETUP.md +408 -408
  48. package/.claude/github-star-reminder.txt +1 -1
  49. package/.claude/hooks/README-TTS-QUEUE.md +135 -135
  50. package/.claude/hooks/audio-cache-utils.sh +246 -246
  51. package/.claude/hooks/audio-processor.sh +433 -433
  52. package/.claude/hooks/background-music-manager.sh +404 -404
  53. package/.claude/hooks/bmad-speak-enhanced.sh +165 -165
  54. package/.claude/hooks/bmad-speak.sh +269 -269
  55. package/.claude/hooks/bmad-tts-injector.sh +568 -568
  56. package/.claude/hooks/bmad-voice-manager.sh +928 -928
  57. package/.claude/hooks/clawdbot-receiver-SECURE.sh +129 -129
  58. package/.claude/hooks/clawdbot-receiver.sh +107 -107
  59. package/.claude/hooks/clean-audio-cache.sh +22 -22
  60. package/.claude/hooks/cleanup-cache.sh +106 -106
  61. package/.claude/hooks/configure-rdp-mode.sh +137 -137
  62. package/.claude/hooks/download-extra-voices.sh +244 -244
  63. package/.claude/hooks/effects-manager.sh +268 -268
  64. package/.claude/hooks/github-star-reminder.sh +154 -154
  65. package/.claude/hooks/language-manager.sh +362 -362
  66. package/.claude/hooks/learn-manager.sh +492 -492
  67. package/.claude/hooks/macos-voice-manager.sh +205 -205
  68. package/.claude/hooks/migrate-background-music.sh +125 -125
  69. package/.claude/hooks/migrate-to-agentvibes.sh +161 -161
  70. package/.claude/hooks/optimize-background-music.sh +87 -87
  71. package/.claude/hooks/path-resolver.sh +60 -60
  72. package/.claude/hooks/personality-manager.sh +448 -448
  73. package/.claude/hooks/piper-download-voices.sh +225 -225
  74. package/.claude/hooks/piper-installer.sh +292 -292
  75. package/.claude/hooks/piper-multispeaker-registry.sh +171 -171
  76. package/.claude/hooks/piper-voice-manager.sh +24 -3
  77. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -90
  78. package/.claude/hooks/play-tts-enhanced.sh +105 -105
  79. package/.claude/hooks/play-tts-macos.sh +368 -368
  80. package/.claude/hooks/play-tts-piper.sh +679 -679
  81. package/.claude/hooks/play-tts-soprano.sh +356 -356
  82. package/.claude/hooks/play-tts-ssh-remote.sh +167 -167
  83. package/.claude/hooks/play-tts-termux-ssh.sh +169 -169
  84. package/.claude/hooks/play-tts.sh +301 -301
  85. package/.claude/hooks/prepare-release.sh +54 -54
  86. package/.claude/hooks/provider-commands.sh +617 -617
  87. package/.claude/hooks/provider-manager.sh +399 -399
  88. package/.claude/hooks/replay-target-audio.sh +95 -95
  89. package/.claude/hooks/requirements.txt +6 -6
  90. package/.claude/hooks/sentiment-manager.sh +201 -201
  91. package/.claude/hooks/session-start-tts.sh +81 -81
  92. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  93. package/.claude/hooks/speed-manager.sh +291 -291
  94. package/.claude/hooks/stop-tts.sh +84 -84
  95. package/.claude/hooks/termux-installer.sh +261 -261
  96. package/.claude/hooks/translate-manager.sh +341 -341
  97. package/.claude/hooks/translator.py +237 -237
  98. package/.claude/hooks/tts-queue-worker.sh +145 -145
  99. package/.claude/hooks/tts-queue.sh +165 -165
  100. package/.claude/hooks/verbosity-manager.sh +178 -178
  101. package/.claude/hooks/voice-manager.sh +548 -548
  102. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  103. package/.claude/hooks-windows/background-music-manager.ps1 +348 -0
  104. package/.claude/hooks-windows/clean-audio-cache.ps1 +53 -0
  105. package/.claude/hooks-windows/download-extra-voices.ps1 +185 -0
  106. package/.claude/hooks-windows/effects-manager.ps1 +294 -0
  107. package/.claude/hooks-windows/language-manager.ps1 +193 -0
  108. package/.claude/hooks-windows/learn-manager.ps1 +241 -0
  109. package/.claude/hooks-windows/personality-manager.ps1 +266 -0
  110. package/.claude/hooks-windows/play-tts-piper.ps1 +209 -0
  111. package/.claude/hooks-windows/play-tts-sapi.ps1 +108 -0
  112. package/.claude/hooks-windows/play-tts-soprano.ps1 +159 -158
  113. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +50 -5
  114. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
  115. package/.claude/hooks-windows/play-tts.ps1 +344 -266
  116. package/.claude/hooks-windows/provider-manager.ps1 +29 -10
  117. package/.claude/hooks-windows/session-start-tts.ps1 +124 -124
  118. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  119. package/.claude/hooks-windows/speed-manager.ps1 +166 -0
  120. package/.claude/hooks-windows/verbosity-manager.ps1 +119 -0
  121. package/.claude/hooks-windows/voice-manager-windows.ps1 +92 -8
  122. package/.claude/output-styles/agent-vibes.md +202 -202
  123. package/.claude/personalities/angry.md +14 -14
  124. package/.claude/personalities/annoying.md +14 -14
  125. package/.claude/personalities/crass.md +14 -14
  126. package/.claude/personalities/dramatic.md +14 -14
  127. package/.claude/personalities/dry-humor.md +50 -50
  128. package/.claude/personalities/flirty.md +20 -20
  129. package/.claude/personalities/funny.md +14 -14
  130. package/.claude/personalities/grandpa.md +32 -32
  131. package/.claude/personalities/millennial.md +14 -14
  132. package/.claude/personalities/moody.md +14 -14
  133. package/.claude/personalities/normal.md +16 -16
  134. package/.claude/personalities/pirate.md +14 -14
  135. package/.claude/personalities/poetic.md +14 -14
  136. package/.claude/personalities/professional.md +14 -14
  137. package/.claude/personalities/rapper.md +55 -55
  138. package/.claude/personalities/robot.md +14 -14
  139. package/.claude/personalities/sarcastic.md +38 -38
  140. package/.claude/personalities/sassy.md +14 -14
  141. package/.claude/personalities/surfer-dude.md +14 -14
  142. package/.claude/personalities/zen.md +14 -14
  143. package/.claude/settings.json +15 -15
  144. package/.claude/verbosity.txt +1 -1
  145. package/.clawdbot/README.md +105 -105
  146. package/.clawdbot/skill/SKILL.md +241 -241
  147. package/.mcp.json +12 -0
  148. package/CLAUDE.md +170 -170
  149. package/README.md +2029 -2007
  150. package/RELEASE_NOTES.md +1310 -1203
  151. package/WINDOWS-SETUP.md +208 -208
  152. package/bin/agent-vibes +39 -39
  153. package/bin/agentvibes-voice-browser.js +1840 -1840
  154. package/bin/agentvibes.js +48 -2
  155. package/bin/mcp-server.js +121 -121
  156. package/bin/mcp-server.sh +206 -206
  157. package/bin/test-bmad-pr +78 -78
  158. package/mcp-server/QUICK_START.md +203 -203
  159. package/mcp-server/README.md +345 -345
  160. package/mcp-server/WINDOWS_SETUP.md +260 -260
  161. package/mcp-server/docs/troubleshooting-audio.md +313 -313
  162. package/mcp-server/examples/claude_desktop_config.json +11 -11
  163. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  164. package/mcp-server/examples/custom_instructions.md +169 -169
  165. package/mcp-server/install-deps.js +130 -130
  166. package/mcp-server/pyproject.toml +52 -52
  167. package/mcp-server/requirements.txt +2 -2
  168. package/mcp-server/server.py +1465 -1453
  169. package/mcp-server/test_server.py +395 -395
  170. package/mcp-server/test_windows_script_parity.py +336 -0
  171. package/package.json +110 -110
  172. package/setup-windows.ps1 +815 -815
  173. package/src/bmad-detector.js +71 -71
  174. package/src/cli/list-personalities.js +110 -110
  175. package/src/cli/list-voices.js +114 -114
  176. package/src/commands/bmad-voices.js +394 -394
  177. package/src/commands/install-mcp.js +476 -476
  178. package/src/console/app.js +824 -824
  179. package/src/console/audio-env.js +20 -1
  180. package/src/console/brand-colors.js +13 -13
  181. package/src/console/constants/personalities.js +44 -44
  182. package/src/console/footer-config.js +50 -50
  183. package/src/console/modals/modal-overlay.js +247 -247
  184. package/src/console/navigation.js +62 -62
  185. package/src/console/tabs/agents-tab.js +1684 -1516
  186. package/src/console/tabs/help-tab.js +261 -261
  187. package/src/console/tabs/install-tab.js +1007 -991
  188. package/src/console/tabs/music-tab.js +22 -8
  189. package/src/console/tabs/placeholder-tab.js +53 -53
  190. package/src/console/tabs/readme-tab.js +267 -267
  191. package/src/console/tabs/receiver-tab.js +1472 -1212
  192. package/src/console/tabs/settings-tab.js +208 -84
  193. package/src/console/tabs/voices-tab.js +100 -21
  194. package/src/console/widgets/destroy-list.js +25 -25
  195. package/src/console/widgets/format-utils.js +89 -89
  196. package/src/console/widgets/notice.js +55 -55
  197. package/src/console/widgets/personality-picker.js +185 -185
  198. package/src/console/widgets/reverb-picker.js +94 -94
  199. package/src/console/widgets/track-picker.js +285 -285
  200. package/src/installer/music-file-input.js +304 -304
  201. package/src/installer.js +5895 -5829
  202. package/src/services/agent-voice-store.js +423 -423
  203. package/src/services/config-service.js +264 -264
  204. package/src/services/navigation-service.js +123 -123
  205. package/src/services/provider-service.js +143 -132
  206. package/src/services/verbosity-service.js +157 -157
  207. package/src/utils/audio-duration-validator.js +298 -298
  208. package/src/utils/audio-format-validator.js +277 -277
  209. package/src/utils/dependency-checker.js +469 -466
  210. package/src/utils/file-ownership-verifier.js +358 -358
  211. package/src/utils/list-formatter.js +194 -194
  212. package/src/utils/music-file-validator.js +285 -285
  213. package/src/utils/preview-list-prompt.js +136 -136
  214. package/src/utils/provider-validator.js +96 -12
  215. package/src/utils/secure-music-storage.js +412 -412
  216. package/templates/agentvibes-receiver.sh +482 -482
  217. package/templates/audio/welcome-music.mp3 +0 -0
  218. package/voice-assignments.json +8244 -8244
  219. package/.claude/config/background-music-position.txt +0 -1
@@ -1,1841 +1,1841 @@
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();
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);