agentvibes 5.9.0 → 5.11.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 (70) hide show
  1. package/.agentvibes/config.json +17 -12
  2. package/.agentvibes/install-manifest.json +98 -86
  3. package/.claude/audio/ui/CREDITS.txt +16 -0
  4. package/.claude/audio/ui/bling-success.wav +0 -0
  5. package/.claude/commands/agent-vibes/receiver.md +64 -0
  6. package/.claude/config/audio-effects.cfg +3 -3
  7. package/.claude/config/personality.txt +1 -0
  8. package/.claude/config/reverb-level.txt +1 -1
  9. package/.claude/github-star-reminder.txt +1 -1
  10. package/.claude/hooks/audio-processor.sh +57 -18
  11. package/.claude/hooks/kokoro-installer.sh +117 -0
  12. package/.claude/hooks/kokoro-server.py +219 -0
  13. package/.claude/hooks/kokoro-tts.py +141 -0
  14. package/.claude/hooks/piper-download-voices.sh +39 -16
  15. package/.claude/hooks/piper-installer.sh +11 -6
  16. package/.claude/hooks/piper-voice-manager.sh +6 -6
  17. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  18. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  19. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  20. package/.claude/hooks/play-tts-piper.sh +20 -13
  21. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  22. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  23. package/.claude/hooks/play-tts.sh +78 -2
  24. package/.claude/hooks/provider-commands.sh +71 -22
  25. package/.claude/hooks/provider-manager.sh +15 -7
  26. package/.claude/hooks/voice-manager.sh +6 -0
  27. package/.claude/hooks-windows/kokoro-server.py +219 -0
  28. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  29. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  30. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
  31. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  32. package/README.md +12 -86
  33. package/RELEASE_NOTES.md +60 -0
  34. package/mcp-server/server.py +17 -7
  35. package/package.json +4 -3
  36. package/src/commands/install-mcp.js +730 -476
  37. package/src/console/app.js +28 -12
  38. package/src/console/audio-env.js +85 -1
  39. package/src/console/navigation.js +4 -0
  40. package/src/console/tabs/agents-tab.js +18 -12
  41. package/src/console/tabs/music-tab.js +7 -25
  42. package/src/console/tabs/receiver-tab.js +13 -13
  43. package/src/console/tabs/settings-tab.js +2 -2
  44. package/src/console/tabs/setup-tab.js +1708 -124
  45. package/src/console/tabs/voices-tab.js +76 -92
  46. package/src/console/widgets/format-utils.js +14 -2
  47. package/src/console/widgets/help-bar.js +55 -0
  48. package/src/console/widgets/personality-picker.js +2 -2
  49. package/src/console/widgets/reverb-picker.js +429 -41
  50. package/src/console/widgets/track-picker.js +60 -51
  51. package/src/i18n/de.js +204 -203
  52. package/src/i18n/en.js +1 -0
  53. package/src/i18n/es.js +204 -203
  54. package/src/i18n/fr.js +204 -203
  55. package/src/i18n/hi.js +204 -203
  56. package/src/i18n/ja.js +204 -203
  57. package/src/i18n/ko.js +204 -203
  58. package/src/i18n/pt.js +204 -203
  59. package/src/i18n/strings.js +54 -54
  60. package/src/i18n/zh-CN.js +204 -203
  61. package/src/installer.js +127 -34
  62. package/src/services/provider-service.js +178 -143
  63. package/src/services/provider-voice-catalog.js +126 -0
  64. package/src/services/tts-engine-service.js +53 -4
  65. package/src/utils/audio-duration-validator.js +341 -298
  66. package/src/utils/list-formatter.js +200 -194
  67. package/src/utils/platform-resolver.js +369 -0
  68. package/src/utils/preview-list-prompt.js +8 -0
  69. package/src/utils/provider-validator.js +79 -9
  70. package/templates/agentvibes-receiver.sh +6 -2
@@ -1,143 +1,178 @@
1
- /**
2
- * AgentVibes Provider Service
3
- * Story 7.1: Provider & Voice Settings Group
4
- *
5
- * Detects installed TTS providers, reads/writes active provider and voice
6
- * through ConfigService. Gracefully degrades when detection fails.
7
- */
8
-
9
- import { execFileSync } from 'node:child_process';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import os from 'node:os';
13
-
14
- export class ProviderService {
15
- /**
16
- * @param {import('./config-service.js').ConfigService} configService
17
- */
18
- constructor(configService) {
19
- this._config = configService;
20
- this._installedProviders = null; // cached after first detection
21
- }
22
-
23
- // ---------------------------------------------------------------------------
24
- // Provider
25
-
26
- /**
27
- * Returns the currently active TTS provider from config.
28
- * Defaults to 'piper' if not configured.
29
- * @returns {string}
30
- */
31
- getActiveProvider() {
32
- return this._config.getConfig().provider ?? 'piper';
33
- }
34
-
35
- /**
36
- * Sets the active TTS provider in config AND syncs to .claude/tts-provider.txt
37
- * so the shell hooks (play-tts.sh → provider-manager.sh) pick up the change.
38
- * @param {string} provider
39
- */
40
- setActiveProvider(provider) {
41
- this._config.set('provider', provider);
42
- this._config.setGlobal('provider', provider);
43
- this._syncProviderFile(provider);
44
- }
45
-
46
- /**
47
- * Write provider to .claude/tts-provider.txt so shell hooks stay in sync.
48
- * Writes to projectRoot/.claude/tts-provider.txt if .claude/ exists there,
49
- * otherwise falls back to ~/.claude/tts-provider.txt.
50
- * @param {string} provider
51
- */
52
- _syncProviderFile(provider) {
53
- try {
54
- const projectClaudeDir = path.resolve(this._config.getProjectRoot(), '.claude');
55
- const targetDir = fs.existsSync(projectClaudeDir)
56
- ? projectClaudeDir
57
- : path.resolve(os.homedir(), '.claude');
58
- const targetFile = path.resolve(targetDir, 'tts-provider.txt');
59
- // Verify resolved path stays within targetDir (path traversal guard)
60
- if (!targetFile.startsWith(targetDir + path.sep) && targetFile !== targetDir) return;
61
- fs.mkdirSync(targetDir, { recursive: true });
62
- fs.writeFileSync(targetFile, provider, 'utf8');
63
- } catch {
64
- // Non-fatal — config.json is the authoritative source
65
- }
66
- }
67
-
68
- /**
69
- * Returns an array of installed/available TTS providers.
70
- * Detection uses `which` binary check. Always returns at least ['piper']
71
- * as graceful degradation (piper is the primary supported provider).
72
- * @returns {string[]}
73
- */
74
- getInstalledProviders() {
75
- if (this._installedProviders) return this._installedProviders;
76
-
77
- const providers = [];
78
-
79
- if (this._isAvailable('piper')) providers.push('piper');
80
- if (this._isAvailable('soprano')) providers.push('soprano');
81
-
82
- // macOS Say (darwin only)
83
- if (process.platform === 'darwin' && this._isAvailable('say')) {
84
- providers.push('macos');
85
- }
86
-
87
- // Graceful degradation: always return at least piper
88
- if (providers.length === 0) providers.push('piper');
89
-
90
- this._installedProviders = providers;
91
- return providers;
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Voice
96
-
97
- /**
98
- * Returns the currently active voice ID from config.
99
- * Falls back to first installed voice if not configured.
100
- * @returns {string|null}
101
- */
102
- getActiveVoiceId() {
103
- const voice = this._config.getConfig().voice;
104
- if (voice) return voice;
105
- // Detect first installed voice instead of hardcoding a default that may not exist
106
- const voicesDir = path.join(os.homedir(), '.claude', 'piper-voices');
107
- try {
108
- const models = fs.readdirSync(voicesDir).filter(f => f.endsWith('.onnx'));
109
- if (models.length > 0) return models[0].replace(/\.onnx$/, '');
110
- } catch { /* dir may not exist */ }
111
- return null;
112
- }
113
-
114
- /**
115
- * Sets the active voice ID in config.
116
- * Writes to both project (if exists) and global config for portability.
117
- * @param {string} voiceId
118
- */
119
- setActiveVoice(voiceId) {
120
- this._config.set('voice', voiceId);
121
- this._config.setGlobal('voice', voiceId);
122
- }
123
-
124
- // ---------------------------------------------------------------------------
125
- // Private
126
-
127
- /**
128
- * Check if a binary is available in PATH using `which`.
129
- * Binary names are all hardcoded (not user input) — safe from injection.
130
- * @param {string} binary - hardcoded binary name ('piper', 'soprano', 'say')
131
- * @returns {boolean}
132
- */
133
- _isAvailable(binary) {
134
- try {
135
- execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
136
- return true;
137
- } catch {
138
- return false;
139
- }
140
- }
141
- }
142
-
143
- export default ProviderService;
1
+ /**
2
+ * AgentVibes Provider Service
3
+ * Story 7.1: Provider & Voice Settings Group
4
+ *
5
+ * Detects installed TTS providers, reads/writes active provider and voice
6
+ * through ConfigService. Gracefully degrades when detection fails.
7
+ */
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+
14
+ export class ProviderService {
15
+ /**
16
+ * @param {import('./config-service.js').ConfigService} configService
17
+ */
18
+ constructor(configService) {
19
+ this._config = configService;
20
+ this._installedProviders = null; // cached after first detection
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Provider
25
+
26
+ /**
27
+ * Returns the currently active TTS provider from config.
28
+ * Defaults to 'piper' if not configured.
29
+ * @returns {string}
30
+ */
31
+ getActiveProvider() {
32
+ return this._config.getConfig().provider ?? 'piper';
33
+ }
34
+
35
+ /**
36
+ * Sets the active TTS provider in config AND syncs to .claude/tts-provider.txt
37
+ * so the shell hooks (play-tts.sh → provider-manager.sh) pick up the change.
38
+ * @param {string} provider
39
+ */
40
+ setActiveProvider(provider) {
41
+ this._config.set('provider', provider);
42
+ this._config.setGlobal('provider', provider);
43
+ this._syncProviderFile(provider);
44
+ }
45
+
46
+ /**
47
+ * Write provider to .claude/tts-provider.txt so shell hooks stay in sync.
48
+ * Writes to projectRoot/.claude/tts-provider.txt if .claude/ exists there,
49
+ * otherwise falls back to ~/.claude/tts-provider.txt.
50
+ * @param {string} provider
51
+ */
52
+ _syncProviderFile(provider) {
53
+ try {
54
+ const projectClaudeDir = path.resolve(this._config.getProjectRoot(), '.claude');
55
+ const targetDir = fs.existsSync(projectClaudeDir)
56
+ ? projectClaudeDir
57
+ : path.resolve(os.homedir(), '.claude');
58
+ const targetFile = path.resolve(targetDir, 'tts-provider.txt');
59
+ // Verify resolved path stays within targetDir (path traversal guard)
60
+ if (!targetFile.startsWith(targetDir + path.sep) && targetFile !== targetDir) return;
61
+ fs.mkdirSync(targetDir, { recursive: true });
62
+ fs.writeFileSync(targetFile, provider, 'utf8');
63
+ } catch {
64
+ // Non-fatal — config.json is the authoritative source
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Returns an array of installed/available TTS providers.
70
+ * Detection uses `which` binary check. Always returns at least ['piper']
71
+ * as graceful degradation (piper is the primary supported provider).
72
+ * @returns {string[]}
73
+ */
74
+ getInstalledProviders() {
75
+ if (this._installedProviders) return this._installedProviders;
76
+
77
+ const providers = [];
78
+
79
+ if (this._isAvailable('piper')) providers.push('piper');
80
+ if (this._isKokoroInstalled()) providers.push('kokoro');
81
+ if (this._isElevenLabsConfigured()) providers.push('elevenlabs');
82
+ if (this._isAvailable('soprano')) providers.push('soprano');
83
+
84
+ // macOS Say (darwin only)
85
+ if (process.platform === 'darwin' && this._isAvailable('say')) {
86
+ providers.push('macos');
87
+ }
88
+
89
+ // Graceful degradation: always return at least piper
90
+ if (providers.length === 0) providers.push('piper');
91
+
92
+ this._installedProviders = providers;
93
+ return providers;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Voice
98
+
99
+ /**
100
+ * Returns the currently active voice ID from config.
101
+ * Falls back to first installed voice if not configured.
102
+ * @returns {string|null}
103
+ */
104
+ getActiveVoiceId() {
105
+ const voice = this._config.getConfig().voice;
106
+ if (voice) return voice;
107
+ // Detect first installed voice instead of hardcoding a default that may not exist
108
+ const voicesDir = path.join(os.homedir(), '.claude', 'piper-voices');
109
+ try {
110
+ const models = fs.readdirSync(voicesDir).filter(f => f.endsWith('.onnx'));
111
+ if (models.length > 0) return models[0].replace(/\.onnx$/, '');
112
+ } catch { /* dir may not exist */ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Sets the active voice ID in config.
118
+ * Writes to both project (if exists) and global config for portability.
119
+ * @param {string} voiceId
120
+ */
121
+ setActiveVoice(voiceId) {
122
+ this._config.set('voice', voiceId);
123
+ this._config.setGlobal('voice', voiceId);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Private
128
+
129
+ /**
130
+ * Check if a binary is available in PATH using `which`.
131
+ * Binary names are all hardcoded (not user input) — safe from injection.
132
+ * @param {string} binary - hardcoded binary name ('piper', 'soprano', 'say')
133
+ * @returns {boolean}
134
+ */
135
+ _isAvailable(binary) {
136
+ try {
137
+ execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Check if the kokoro Python package is importable.
146
+ * Uses importlib.util.find_spec (no torch import) to avoid the slow load
147
+ * that can exceed the timeout and falsely report not-installed. Tries each
148
+ * platform-appropriate python command until one succeeds.
149
+ */
150
+ _isKokoroInstalled() {
151
+ // On Windows `python3` usually doesn't exist; `py` is the standard launcher.
152
+ const pythonCommands = process.platform === 'win32'
153
+ ? ['py', 'python', 'python3']
154
+ : ['python3', 'python'];
155
+ for (const pythonCmd of pythonCommands) {
156
+ try {
157
+ execFileSync(pythonCmd, ['-c', "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('kokoro') else 1)"], { stdio: 'ignore', timeout: 5000 }); // NOSONAR
158
+ return true;
159
+ } catch {
160
+ // try next python command
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+
166
+ /** Check if ElevenLabs API key is configured (env var or key file). */
167
+ _isElevenLabsConfigured() {
168
+ if (process.env.ELEVENLABS_API_KEY?.trim()) return true;
169
+ try {
170
+ const keyFile = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
171
+ return fs.existsSync(keyFile) && fs.readFileSync(keyFile, 'utf8').trim().length > 0;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+ }
177
+
178
+ export default ProviderService;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Provider voice catalogs — single source of truth for the static voice lists
3
+ * of the cloud/neural providers, plus a helper that returns the voice pool for
4
+ * whichever provider is currently active.
5
+ *
6
+ * Piper voices are NOT listed here (they are discovered on disk via
7
+ * scanInstalledVoices); this module covers the providers whose voices are a
8
+ * fixed catalog: ElevenLabs and Kokoro. Single-voice providers (e.g. soprano)
9
+ * resolve to a single synthetic entry.
10
+ *
11
+ * @module services/provider-voice-catalog
12
+ */
13
+
14
+ import { isSingleVoiceProvider } from './agent-voice-store.js';
15
+
16
+ /** ElevenLabs library voices (voice_id + display metadata). */
17
+ export const ELEVENLABS_VOICES = [
18
+ { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', gender: 'Female', lang: 'en-US', desc: 'Mature, reassuring' },
19
+ { id: 'CwhRBWXzGAHq8TQ4Fs17', name: 'Roger', gender: 'Male', lang: 'en-US', desc: 'Laid-back, casual' },
20
+ { id: 'FGY2WhTYpPnrIDTdsKH5', name: 'Laura', gender: 'Female', lang: 'en-US', desc: 'Enthusiast, quirky' },
21
+ { id: 'IKne3meq5aSn9XLyUdCD', name: 'Charlie', gender: 'Male', lang: 'en-AU', desc: 'Deep, confident' },
22
+ { id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George', gender: 'Male', lang: 'en-GB', desc: 'Warm storyteller' },
23
+ { id: 'N2lVS1w4EtoT3dr4eOWO', name: 'Callum', gender: 'Male', lang: 'en-US', desc: 'Husky trickster' },
24
+ { id: 'SAz9YHcvj6GT2YYXdXww', name: 'River', gender: '', lang: 'en-US', desc: 'Relaxed, neutral' },
25
+ { id: 'SOYHLrjzK2X1ezoPC6cr', name: 'Harry', gender: 'Male', lang: 'en-US', desc: 'Fierce warrior' },
26
+ { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', gender: 'Male', lang: 'en-US', desc: 'Energetic creator' },
27
+ { id: 'Xb7hH8MSUJpSbSDYk0k2', name: 'Alice', gender: 'Female', lang: 'en-GB', desc: 'Clear educator' },
28
+ { id: 'XrExE9yKIg1WjnnlVkGX', name: 'Matilda', gender: 'Female', lang: 'en-US', desc: 'Knowledgable, pro' },
29
+ { id: 'bIHbv24MWmeRgasZH58o', name: 'Will', gender: 'Male', lang: 'en-US', desc: 'Relaxed optimist' },
30
+ { id: 'cgSgspJ2msm6clMCkdW9', name: 'Jessica', gender: 'Female', lang: 'en-US', desc: 'Playful, bright' },
31
+ { id: 'cjVigY5qzO86Huf0OWal', name: 'Eric', gender: 'Male', lang: 'en-US', desc: 'Smooth, trustworthy' },
32
+ { id: 'hpp4J3VqNfWAUOO0d1Us', name: 'Bella', gender: 'Female', lang: 'en-US', desc: 'Professional, warm' },
33
+ { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', gender: 'Male', lang: 'en-US', desc: 'Charming, down-to-earth' },
34
+ { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', gender: 'Male', lang: 'en-US', desc: 'Deep, comforting' },
35
+ { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', gender: 'Male', lang: 'en-GB', desc: 'Steady broadcaster' },
36
+ { id: 'pFZP5JQG7iQjIQuC4Bku', name: 'Lily', gender: 'Female', lang: 'en-GB', desc: 'Velvety actress' },
37
+ { id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam', gender: 'Male', lang: 'en-US', desc: 'Dominant, firm' },
38
+ { id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill', gender: 'Male', lang: 'en-US', desc: 'Wise, mature' },
39
+ // ── Added from the ElevenLabs voice library (raw library voice_ids) ───────
40
+ { id: 'HBDoL4wkcalemIO0nUAu', name: 'Emily', gender: 'Female', lang: 'en-GB', desc: 'RPG, immersive' },
41
+ { id: 'Mtmp3KhFIjYpWYRycDe3', name: 'John', gender: 'Male', lang: 'en-US', desc: 'Raspy surfer' },
42
+ { id: '8kgj5469z1URcH4MB2G4', name: 'Sakuya', gender: 'Female', lang: 'en-US', desc: 'Cheerful anime' },
43
+ { id: '2ajXGJNYBR0iNHpS4VZb', name: 'Rob', gender: 'Male', lang: 'en-GB', desc: 'Tough, gritty' },
44
+ { id: 'mMdTuD2nnFiCH88UdXSb', name: 'Jane', gender: 'Female', lang: 'en-US', desc: 'Cinematic villain' },
45
+ ];
46
+
47
+ /** Kokoro voice ids. Gender is encoded in the id: the 2nd char is f|m. */
48
+ export const KOKORO_VOICE_IDS = [
49
+ // American English
50
+ 'af_heart','af_alloy','af_aoede','af_bella','af_jessica','af_kore','af_nicole','af_nova','af_river','af_sarah','af_sky',
51
+ 'am_adam','am_echo','am_eric','am_fenrir','am_liam','am_michael','am_onyx','am_puck',
52
+ // British English
53
+ 'bf_alice','bf_emma','bf_isabella','bf_lily',
54
+ 'bm_daniel','bm_fable','bm_george','bm_lewis',
55
+ // Japanese
56
+ 'jf_alpha','jf_gongitsune','jf_nezumi','jf_tebukuro','jm_kumo',
57
+ // Mandarin Chinese
58
+ 'zf_xiaobei','zf_xiaoni','zf_xiaoxiao','zf_xiaoyi','zm_yunxi','zm_yunxia','zm_yunyang',
59
+ // Spanish
60
+ 'ef_dora','em_alex','em_santa',
61
+ // French
62
+ 'ff_siwis',
63
+ // Hindi
64
+ 'hf_alpha','hm_omega',
65
+ // Italian
66
+ 'if_sara','im_nicola',
67
+ // Brazilian Portuguese
68
+ 'pf_dora','pm_alex','pm_santa',
69
+ // Korean
70
+ 'kf_alpha','km_hyunsu',
71
+ ];
72
+
73
+ /**
74
+ * Infer a Kokoro voice's gender from its id. Kokoro ids follow `<lang><sex>_name`
75
+ * where the 2nd character is `f` (female) or `m` (male).
76
+ * @param {string} id
77
+ * @returns {'Female'|'Male'|''}
78
+ */
79
+ export function kokoroGender(id) {
80
+ const c = typeof id === 'string' && id.length > 1 ? id[1].toLowerCase() : '';
81
+ if (c === 'f') return 'Female';
82
+ if (c === 'm') return 'Male';
83
+ return '';
84
+ }
85
+
86
+ /**
87
+ * Return the voice pool for the currently-active provider as `{ id, gender }`
88
+ * entries, suitable for gender-aware auto-assignment.
89
+ *
90
+ * Piper voices are discovered on disk, so the caller injects `scanInstalledVoices`
91
+ * and `getVoiceMeta` (both live in the voices-tab module) to avoid a circular
92
+ * import here.
93
+ *
94
+ * @param {string} provider - active provider id (piper|kokoro|elevenlabs|soprano|…)
95
+ * @param {object} helpers
96
+ * @param {() => string[]} helpers.scanInstalledVoices - lists installed Piper voice ids
97
+ * @param {(id: string) => {gender?: string}} helpers.getVoiceMeta - Piper voice metadata
98
+ * @returns {Array<{id: string, gender: string}>}
99
+ */
100
+ export function voicesForProvider(provider, { scanInstalledVoices, getVoiceMeta } = {}) {
101
+ const p = (provider || 'piper').toLowerCase();
102
+
103
+ if (p === 'kokoro') {
104
+ return KOKORO_VOICE_IDS.map(id => ({ id, gender: kokoroGender(id) }));
105
+ }
106
+
107
+ if (p === 'elevenlabs') {
108
+ return ELEVENLABS_VOICES.map(v => ({ id: v.id, gender: v.gender || '' }));
109
+ }
110
+
111
+ // Single-voice providers (e.g. soprano): one synthetic entry named after the
112
+ // provider — every agent ends up sharing it, which is correct for these.
113
+ if (isSingleVoiceProvider(p)) {
114
+ return [{ id: p, gender: '' }];
115
+ }
116
+
117
+ // Piper (and any disk-discovered provider): read installed models.
118
+ if (typeof scanInstalledVoices === 'function') {
119
+ const metaOf = typeof getVoiceMeta === 'function' ? getVoiceMeta : () => ({});
120
+ return scanInstalledVoices().map(id => ({ id, gender: metaOf(id).gender || '' }));
121
+ }
122
+
123
+ return [];
124
+ }
125
+
126
+ export default { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, kokoroGender, voicesForProvider };
@@ -8,6 +8,10 @@
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const _hooksDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.claude', 'hooks');
11
15
 
12
16
  const TTS_ENGINES = [
13
17
  {
@@ -16,10 +20,29 @@ const TTS_ENGINES = [
16
20
  ? 'winget install --id Rhasspy.Piper --accept-package-agreements --accept-source-agreements'
17
21
  : process.platform === 'darwin'
18
22
  ? 'brew install piper'
19
- : 'pip install piper-tts',
23
+ : `${path.join(_hooksDir, 'piper-installer.sh')} --non-interactive`,
24
+ // Structured form — robust against spaces in paths (do NOT split installCmd on ' ').
25
+ installSpec: process.platform === 'win32'
26
+ ? { cmd: 'winget', args: ['install', '--id', 'Rhasspy.Piper', '--accept-package-agreements', '--accept-source-agreements'] }
27
+ : process.platform === 'darwin'
28
+ ? { cmd: 'brew', args: ['install', 'piper'] }
29
+ : { cmd: path.join(_hooksDir, 'piper-installer.sh'), args: ['--non-interactive'] },
30
+ },
31
+ {
32
+ id: 'kokoro', name: 'Kokoro TTS', desc: 'Local neural TTS — 60+ voices, 8 languages, no API key', native: false,
33
+ installCmd: `${path.join(_hooksDir, 'kokoro-installer.sh')} --non-interactive`,
34
+ // Structured form — robust against spaces in paths (do NOT split installCmd on ' ').
35
+ installSpec: { cmd: path.join(_hooksDir, 'kokoro-installer.sh'), args: ['--non-interactive'] },
36
+ requiresApiKey: false,
20
37
  },
21
38
  {
22
- id: 'soprano', name: 'Soprano TTS', desc: 'Web-based TTS service with premium voices', native: false,
39
+ id: 'elevenlabs', name: 'ElevenLabs', desc: 'Premium cloud voices free tier available, 32+ languages', native: false,
40
+ installCmd: `echo "Set ELEVENLABS_API_KEY in your shell profile. Free key at elevenlabs.io"`,
41
+ requiresApiKey: true,
42
+ apiKeyEnvVar: 'ELEVENLABS_API_KEY',
43
+ },
44
+ {
45
+ id: 'soprano', name: 'Soprano TTS', desc: 'Local neural TTS via Gradio WebUI', native: false,
23
46
  installCmd: 'pip install soprano-tts',
24
47
  },
25
48
  { id: 'sapi', name: 'Windows SAPI', desc: 'Built-in Windows speech — no install needed', native: true, platform: 'win32' },
@@ -35,6 +58,32 @@ export function checkEngineInstalled(engineId) {
35
58
  if (!engine) return false;
36
59
  if (engine.native) return true; // Native engines are always available on their platform
37
60
 
61
+ // ElevenLabs: check API key in env or key file
62
+ if (engineId === 'elevenlabs') {
63
+ if (process.env.ELEVENLABS_API_KEY?.trim()) return true;
64
+ try {
65
+ const keyFile = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
66
+ return fs.existsSync(keyFile) && fs.readFileSync(keyFile, 'utf8').trim().length > 0;
67
+ } catch { return false; }
68
+ }
69
+
70
+ // Kokoro: use find_spec (no import) to avoid the slow torch load that causes ETIMEDOUT.
71
+ // Try each platform-appropriate python command (Windows lacks `python3`).
72
+ if (engineId === 'kokoro') {
73
+ const pythonCommands = process.platform === 'win32'
74
+ ? ['py', 'python', 'python3']
75
+ : ['python3', 'python'];
76
+ for (const pythonCmd of pythonCommands) {
77
+ try {
78
+ execFileSync(pythonCmd, ['-c', "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('kokoro') else 1)"], { stdio: 'ignore', timeout: 3000 }); // NOSONAR
79
+ return true;
80
+ } catch {
81
+ // try next python command
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
38
87
  // Check binary availability — soprano has two possible binaries
39
88
  const binaryMap = { piper: ['piper'], soprano: ['soprano-tts', 'soprano-webui'] };
40
89
  const binaries = binaryMap[engineId];
@@ -49,10 +98,10 @@ export function checkEngineInstalled(engineId) {
49
98
  (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
50
99
  if (localAppData && fs.existsSync(path.join(localAppData, 'Programs', 'Piper', 'piper.exe'))) return true;
51
100
  }
52
- execFileSync('where', [binary], { stdio: 'ignore', timeout: 2000 });
101
+ execFileSync('where', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
53
102
  return true;
54
103
  }
55
- execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
104
+ execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 }); // NOSONAR
56
105
  return true;
57
106
  } catch {
58
107
  // try next binary