agentvibes 5.3.0 → 5.4.0

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