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.
- package/.agentvibes/config.json +17 -12
- package/.agentvibes/install-manifest.json +98 -86
- package/.claude/audio/ui/CREDITS.txt +16 -0
- package/.claude/audio/ui/bling-success.wav +0 -0
- package/.claude/commands/agent-vibes/receiver.md +64 -0
- package/.claude/config/audio-effects.cfg +3 -3
- package/.claude/config/personality.txt +1 -0
- package/.claude/config/reverb-level.txt +1 -1
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +57 -18
- package/.claude/hooks/kokoro-installer.sh +117 -0
- package/.claude/hooks/kokoro-server.py +219 -0
- package/.claude/hooks/kokoro-tts.py +141 -0
- package/.claude/hooks/piper-download-voices.sh +39 -16
- package/.claude/hooks/piper-installer.sh +11 -6
- package/.claude/hooks/piper-voice-manager.sh +6 -6
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
- package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
- package/.claude/hooks/play-tts-kokoro.sh +127 -0
- package/.claude/hooks/play-tts-piper.sh +20 -13
- package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
- package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
- package/.claude/hooks/play-tts.sh +78 -2
- package/.claude/hooks/provider-commands.sh +71 -22
- package/.claude/hooks/provider-manager.sh +15 -7
- package/.claude/hooks/voice-manager.sh +6 -0
- package/.claude/hooks-windows/kokoro-server.py +219 -0
- package/.claude/hooks-windows/kokoro-tts.py +107 -0
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
- package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
- package/.claude/hooks-windows/play-tts.ps1 +29 -1
- package/README.md +12 -86
- package/RELEASE_NOTES.md +60 -0
- package/mcp-server/server.py +17 -7
- package/package.json +4 -3
- package/src/commands/install-mcp.js +730 -476
- package/src/console/app.js +28 -12
- package/src/console/audio-env.js +85 -1
- package/src/console/navigation.js +4 -0
- package/src/console/tabs/agents-tab.js +18 -12
- package/src/console/tabs/music-tab.js +7 -25
- package/src/console/tabs/receiver-tab.js +13 -13
- package/src/console/tabs/settings-tab.js +2 -2
- package/src/console/tabs/setup-tab.js +1708 -124
- package/src/console/tabs/voices-tab.js +76 -92
- package/src/console/widgets/format-utils.js +14 -2
- package/src/console/widgets/help-bar.js +55 -0
- package/src/console/widgets/personality-picker.js +2 -2
- package/src/console/widgets/reverb-picker.js +429 -41
- package/src/console/widgets/track-picker.js +60 -51
- package/src/i18n/de.js +204 -203
- package/src/i18n/en.js +1 -0
- package/src/i18n/es.js +204 -203
- package/src/i18n/fr.js +204 -203
- package/src/i18n/hi.js +204 -203
- package/src/i18n/ja.js +204 -203
- package/src/i18n/ko.js +204 -203
- package/src/i18n/pt.js +204 -203
- package/src/i18n/strings.js +54 -54
- package/src/i18n/zh-CN.js +204 -203
- package/src/installer.js +127 -34
- package/src/services/provider-service.js +178 -143
- package/src/services/provider-voice-catalog.js +126 -0
- package/src/services/tts-engine-service.js +53 -4
- package/src/utils/audio-duration-validator.js +341 -298
- package/src/utils/list-formatter.js +200 -194
- package/src/utils/platform-resolver.js +369 -0
- package/src/utils/preview-list-prompt.js +8 -0
- package/src/utils/provider-validator.js +79 -9
- package/templates/agentvibes-receiver.sh +6 -2
|
@@ -223,6 +223,8 @@ done
|
|
|
223
223
|
|
|
224
224
|
# LLM settings win over agent-name settings
|
|
225
225
|
[[ -n "$LLM_REVERB" ]] && SOX_EFFECTS="$LLM_REVERB"
|
|
226
|
+
# Allow caller to override reverb for one-shot preview (e.g. effects picker Space preview)
|
|
227
|
+
[[ -n "${AGENTVIBES_REVERB_OVERRIDE:-}" ]] && SOX_EFFECTS="$AGENTVIBES_REVERB_OVERRIDE"
|
|
226
228
|
[[ -n "$LLM_BG_FILE" ]] && BG_FILE="$LLM_BG_FILE"
|
|
227
229
|
[[ -n "$LLM_BG_VOLUME" ]] && BG_VOLUME="$LLM_BG_VOLUME"
|
|
228
230
|
|
|
@@ -274,7 +276,7 @@ if [[ -z "$PROVIDER" ]]; then
|
|
|
274
276
|
fi
|
|
275
277
|
# Validate — only known TTS providers (not transport providers like ssh-remote)
|
|
276
278
|
case "${PROVIDER:-}" in
|
|
277
|
-
piper|soprano|macos|windows-sapi) ;;
|
|
279
|
+
piper|soprano|macos|windows-sapi|kokoro|elevenlabs) ;;
|
|
278
280
|
*) PROVIDER="piper" ;;
|
|
279
281
|
esac
|
|
280
282
|
|
|
@@ -334,7 +336,12 @@ echo "Sending to $SSH_HOST..." >&2
|
|
|
334
336
|
# Build SSH args — use explicit key/port from config if available, else rely on ~/.ssh/config
|
|
335
337
|
SSH_ARGS=()
|
|
336
338
|
[[ -n "$SSH_KEY" && -f "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
|
|
337
|
-
|
|
339
|
+
# Only pass -p for an explicit, non-default port. Port 22 is ssh's universal
|
|
340
|
+
# default, so forcing "-p 22" is at best a no-op and at worst overrides the
|
|
341
|
+
# real port of an ~/.ssh/config Host alias (e.g. laptop-win -> Port 45217),
|
|
342
|
+
# silently delivering the payload to the wrong port. Empty or "22" means
|
|
343
|
+
# "use ssh / ~/.ssh/config defaults" -- never force it.
|
|
344
|
+
if [[ -n "$SSH_PORT" && "$SSH_PORT" != "22" ]]; then SSH_ARGS+=(-p "$SSH_PORT"); fi
|
|
338
345
|
|
|
339
346
|
# ForceCommand receiver: SSH_ORIGINAL_COMMAND passes the payload directly.
|
|
340
347
|
# Run ssh inside the backgrounded subshell so its exit code is reachable via $?
|
|
@@ -165,7 +165,9 @@ SAFE_TEXT="${TEXT//\'/\'\\\'\'}"
|
|
|
165
165
|
# Build SSH args — use explicit key/port from config if available, else rely on ~/.ssh/config
|
|
166
166
|
SSH_ARGS=(-o ConnectTimeout=5)
|
|
167
167
|
[[ -n "$SSH_KEY" && -f "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
|
|
168
|
-
|
|
168
|
+
# Never force "-p 22" (ssh default) -- it would override an ~/.ssh/config
|
|
169
|
+
# Host alias port (e.g. laptop-win -> 45217). Empty/22 => defer to ssh config.
|
|
170
|
+
if [[ -n "$SSH_PORT" && "$SSH_PORT" != "22" ]]; then SSH_ARGS+=(-p "$SSH_PORT"); fi
|
|
169
171
|
|
|
170
172
|
# Send TTS command to Android device via SSH
|
|
171
173
|
# Use termux-tts-speak for native Android TTS
|
|
@@ -77,6 +77,15 @@ fi
|
|
|
77
77
|
|
|
78
78
|
export PROJECT_ROOT # Export for child scripts
|
|
79
79
|
|
|
80
|
+
# Suppress audio when tests are running in a separate shell.
|
|
81
|
+
# AGENTVIBES_TEST_MODE only propagates to child processes of the test runner;
|
|
82
|
+
# external callers (e.g. Claude Code hooks) don't inherit it.
|
|
83
|
+
# scripts/run-tests.sh writes this marker file so all play-tts.sh invocations —
|
|
84
|
+
# regardless of which process spawns them — stay silent during test runs.
|
|
85
|
+
if [[ -f "${HOME}/.agentvibes-tests-running" ]]; then
|
|
86
|
+
exit 0
|
|
87
|
+
fi
|
|
88
|
+
|
|
80
89
|
# Check if muted (persists across sessions)
|
|
81
90
|
# Project settings always override global settings:
|
|
82
91
|
# - .claude/agentvibes-unmuted = project explicitly unmuted (overrides global mute)
|
|
@@ -197,8 +206,8 @@ if [[ -n "$LLM_PROVIDER" ]]; then
|
|
|
197
206
|
done < "$_cfg"
|
|
198
207
|
fi
|
|
199
208
|
done
|
|
200
|
-
# Apply LLM voice (only if no explicit voice override)
|
|
201
|
-
if [[ -n "$_LLM_VOICE" && -z "$VOICE_OVERRIDE" ]]; then
|
|
209
|
+
# Apply LLM voice (only if no explicit voice override, and not in effects-preview mode)
|
|
210
|
+
if [[ -n "$_LLM_VOICE" && -z "$VOICE_OVERRIDE" && -z "${AGENTVIBES_EFFECTS_PREVIEW:-}" ]]; then
|
|
202
211
|
VOICE_OVERRIDE="$_LLM_VOICE"
|
|
203
212
|
fi
|
|
204
213
|
# Export LLM key for child scripts (process-local, not system-wide)
|
|
@@ -256,6 +265,17 @@ case "$ACTIVE_PROVIDER" in
|
|
|
256
265
|
;;
|
|
257
266
|
esac
|
|
258
267
|
|
|
268
|
+
# AGENTVIBES_FORCE_PROVIDER: set by agentvibes-receiver.sh to honour the
|
|
269
|
+
# provider field in the incoming JSON payload (e.g. kokoro, elevenlabs).
|
|
270
|
+
# Only applies when the locally-configured provider is still the default "piper"
|
|
271
|
+
# so a receiver that has tts-provider.txt set keeps its own preference.
|
|
272
|
+
if [[ -n "${AGENTVIBES_FORCE_PROVIDER:-}" && "$ACTIVE_PROVIDER" == "piper" ]]; then
|
|
273
|
+
case "$AGENTVIBES_FORCE_PROVIDER" in
|
|
274
|
+
piper|soprano|macos|windows-sapi|kokoro|elevenlabs)
|
|
275
|
+
ACTIVE_PROVIDER="$AGENTVIBES_FORCE_PROVIDER" ;;
|
|
276
|
+
esac
|
|
277
|
+
fi
|
|
278
|
+
|
|
259
279
|
# Per-LLM SSH override: if the current LLM has mode=remote in transport-config.json,
|
|
260
280
|
# write a one-shot env override so play-tts-ssh-remote.sh uses that LLM's SSH config.
|
|
261
281
|
_TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
|
|
@@ -341,6 +361,43 @@ if [[ "${AGENTVIBES_VERBOSE:-0}" == "1" ]]; then
|
|
|
341
361
|
echo "provider=${ACTIVE_PROVIDER}" >&2
|
|
342
362
|
fi
|
|
343
363
|
|
|
364
|
+
# @function _play_windows_sapi
|
|
365
|
+
# @intent Speak via the built-in Windows SAPI engine from a POSIX shell
|
|
366
|
+
# @why On Windows git-bash the `sapi`/`windows-sapi` provider has no native bash
|
|
367
|
+
# player; delegate to the PowerShell SAPI script so the engine selector's
|
|
368
|
+
# `sapi` id no longer dead-ends in "Unknown provider".
|
|
369
|
+
# @param $1 text to speak
|
|
370
|
+
# @param $2 voice override (optional)
|
|
371
|
+
_play_windows_sapi() {
|
|
372
|
+
local text="$1"
|
|
373
|
+
local voice="${2:-}"
|
|
374
|
+
|
|
375
|
+
# Sibling hooks-windows/ holds the PowerShell players. Prefer the short name,
|
|
376
|
+
# fall back to the legacy windows-prefixed one.
|
|
377
|
+
local ps1="$SCRIPT_DIR/../hooks-windows/play-tts-sapi.ps1"
|
|
378
|
+
[[ -f "$ps1" ]] || ps1="$SCRIPT_DIR/../hooks-windows/play-tts-windows-sapi.ps1"
|
|
379
|
+
if [[ ! -f "$ps1" ]]; then
|
|
380
|
+
echo "❌ Windows SAPI player not found beside this hook" >&2
|
|
381
|
+
return 1
|
|
382
|
+
fi
|
|
383
|
+
|
|
384
|
+
local ps_exe=""
|
|
385
|
+
local c
|
|
386
|
+
for c in powershell.exe pwsh.exe powershell pwsh; do
|
|
387
|
+
if command -v "$c" >/dev/null 2>&1; then ps_exe="$c"; break; fi
|
|
388
|
+
done
|
|
389
|
+
if [[ -z "$ps_exe" ]]; then
|
|
390
|
+
echo "❌ Windows SAPI requires PowerShell (Windows only)" >&2
|
|
391
|
+
return 1
|
|
392
|
+
fi
|
|
393
|
+
|
|
394
|
+
# PowerShell needs a native Windows path, not a /c/... MSYS path.
|
|
395
|
+
local win_ps1="$ps1"
|
|
396
|
+
command -v cygpath >/dev/null 2>&1 && win_ps1="$(cygpath -w "$ps1")"
|
|
397
|
+
|
|
398
|
+
"$ps_exe" -NoProfile -ExecutionPolicy Bypass -File "$win_ps1" "$text" "$voice"
|
|
399
|
+
}
|
|
400
|
+
|
|
344
401
|
# @function speak_text
|
|
345
402
|
# @intent Route text to appropriate TTS provider
|
|
346
403
|
# @why Reusable function for speaking, used by both single and learning modes
|
|
@@ -363,6 +420,15 @@ speak_text() {
|
|
|
363
420
|
macos)
|
|
364
421
|
bash "$SCRIPT_DIR/play-tts-macos.sh" "$text" "$voice"
|
|
365
422
|
;;
|
|
423
|
+
elevenlabs)
|
|
424
|
+
bash "$SCRIPT_DIR/play-tts-elevenlabs.sh" "$text" "$voice"
|
|
425
|
+
;;
|
|
426
|
+
kokoro)
|
|
427
|
+
bash "$SCRIPT_DIR/play-tts-kokoro.sh" "$text" "$voice"
|
|
428
|
+
;;
|
|
429
|
+
sapi|windows-sapi)
|
|
430
|
+
_play_windows_sapi "$text" "$voice"
|
|
431
|
+
;;
|
|
366
432
|
termux-ssh)
|
|
367
433
|
bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$text" "$voice"
|
|
368
434
|
;;
|
|
@@ -493,6 +559,16 @@ case "$ACTIVE_PROVIDER" in
|
|
|
493
559
|
macos)
|
|
494
560
|
exec bash "$SCRIPT_DIR/play-tts-macos.sh" "$TEXT" "$VOICE_OVERRIDE"
|
|
495
561
|
;;
|
|
562
|
+
elevenlabs)
|
|
563
|
+
exec bash "$SCRIPT_DIR/play-tts-elevenlabs.sh" "$TEXT" "$VOICE_OVERRIDE"
|
|
564
|
+
;;
|
|
565
|
+
kokoro)
|
|
566
|
+
exec bash "$SCRIPT_DIR/play-tts-kokoro.sh" "$TEXT" "$VOICE_OVERRIDE"
|
|
567
|
+
;;
|
|
568
|
+
sapi|windows-sapi)
|
|
569
|
+
_play_windows_sapi "$TEXT" "$VOICE_OVERRIDE"
|
|
570
|
+
exit $?
|
|
571
|
+
;;
|
|
496
572
|
termux-ssh)
|
|
497
573
|
exec bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$TEXT" "$VOICE_OVERRIDE"
|
|
498
574
|
;;
|
|
@@ -63,13 +63,24 @@ is_language_supported() {
|
|
|
63
63
|
|
|
64
64
|
case "$provider" in
|
|
65
65
|
piper)
|
|
66
|
-
# Piper only supports English natively
|
|
66
|
+
# Piper only supports English natively (multilingual models exist but aren't bundled)
|
|
67
67
|
return 1
|
|
68
68
|
;;
|
|
69
69
|
macos)
|
|
70
70
|
# macOS has voices for 40+ languages built-in
|
|
71
71
|
return 0
|
|
72
72
|
;;
|
|
73
|
+
elevenlabs)
|
|
74
|
+
# ElevenLabs eleven_multilingual_v2 supports 32+ languages
|
|
75
|
+
return 0
|
|
76
|
+
;;
|
|
77
|
+
kokoro)
|
|
78
|
+
# Kokoro supports English, Japanese, Korean, French, German, Spanish, Portuguese, Hindi
|
|
79
|
+
case "$language" in
|
|
80
|
+
japanese|korean|french|german|spanish|portuguese|hindi) return 0 ;;
|
|
81
|
+
*) return 1 ;;
|
|
82
|
+
esac
|
|
83
|
+
;;
|
|
73
84
|
*)
|
|
74
85
|
return 1
|
|
75
86
|
;;
|
|
@@ -88,48 +99,86 @@ provider_list() {
|
|
|
88
99
|
is_macos=true
|
|
89
100
|
fi
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
# Check installation status of optional providers
|
|
103
|
+
local kokoro_installed=false
|
|
104
|
+
python3 -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('kokoro') else 1)" 2>/dev/null && kokoro_installed=true
|
|
105
|
+
|
|
106
|
+
local elevenlabs_key_set=false
|
|
107
|
+
if [[ -n "${ELEVENLABS_API_KEY:-}" ]]; then
|
|
108
|
+
elevenlabs_key_set=true
|
|
109
|
+
elif [[ -f "${HOME}/.agentvibes/elevenlabs-key.txt" ]]; then
|
|
110
|
+
elevenlabs_key_set=true
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
_active() { [[ "$current_provider" == "$1" ]]; }
|
|
114
|
+
_mark() { _active "$1" && echo "[ACTIVE]" || echo ""; }
|
|
115
|
+
|
|
116
|
+
echo "┌──────────────────────────────────────────────────────────────┐"
|
|
117
|
+
echo "│ Available TTS Providers │"
|
|
118
|
+
echo "├──────────────────────────────────────────────────────────────┤"
|
|
94
119
|
|
|
95
120
|
# macOS Say (show first on macOS systems)
|
|
96
121
|
if [[ "$is_macos" == true ]]; then
|
|
97
|
-
if
|
|
98
|
-
echo "│ ✓ macOS Say Built-in, free ⭐⭐⭐⭐ [ACTIVE]
|
|
122
|
+
if _active "macos"; then
|
|
123
|
+
echo "│ ✓ macOS Say Built-in, free ⭐⭐⭐⭐ [ACTIVE] │"
|
|
99
124
|
else
|
|
100
|
-
echo "│ macOS Say Built-in, free ⭐⭐⭐⭐
|
|
125
|
+
echo "│ macOS Say Built-in, free ⭐⭐⭐⭐ [RECOMMENDED] │"
|
|
101
126
|
fi
|
|
102
|
-
echo "│ Cost: Free (built-in)
|
|
103
|
-
echo "│
|
|
104
|
-
echo "│ Offline: Yes │"
|
|
105
|
-
echo "│ │"
|
|
127
|
+
echo "│ Cost: Free (built-in) Platform: macOS Offline: Yes │"
|
|
128
|
+
echo "│ │"
|
|
106
129
|
fi
|
|
107
130
|
|
|
108
131
|
# Piper
|
|
109
|
-
if
|
|
132
|
+
if _active "piper"; then
|
|
110
133
|
echo "│ ✓ Piper TTS Free, offline ⭐⭐⭐⭐ [ACTIVE] │"
|
|
111
134
|
else
|
|
112
|
-
echo "│ Piper TTS Free, offline ⭐⭐⭐⭐
|
|
135
|
+
echo "│ Piper TTS Free, offline ⭐⭐⭐⭐ │"
|
|
136
|
+
fi
|
|
137
|
+
echo "│ Cost: Free forever Platform: Linux/WSL Offline: Yes │"
|
|
138
|
+
echo "│ │"
|
|
139
|
+
|
|
140
|
+
# Kokoro
|
|
141
|
+
if _active "kokoro"; then
|
|
142
|
+
echo "│ ✓ Kokoro TTS Local neural ⭐⭐⭐⭐⭐ [ACTIVE] │"
|
|
143
|
+
else
|
|
144
|
+
if [[ "$kokoro_installed" == true ]]; then
|
|
145
|
+
echo "│ Kokoro TTS Local neural ⭐⭐⭐⭐⭐ [installed] │"
|
|
146
|
+
else
|
|
147
|
+
echo "│ Kokoro TTS Local neural ⭐⭐⭐⭐⭐ [not installed]│"
|
|
148
|
+
fi
|
|
149
|
+
fi
|
|
150
|
+
echo "│ Cost: Free 60+ voices 8 languages Offline: Yes │"
|
|
151
|
+
echo "│ Install: ${SCRIPT_DIR}/kokoro-installer.sh │"
|
|
152
|
+
echo "│ │"
|
|
153
|
+
|
|
154
|
+
# ElevenLabs
|
|
155
|
+
if _active "elevenlabs"; then
|
|
156
|
+
echo "│ ✓ ElevenLabs Premium cloud ⭐⭐⭐⭐⭐ [ACTIVE] │"
|
|
157
|
+
else
|
|
158
|
+
if [[ "$elevenlabs_key_set" == true ]]; then
|
|
159
|
+
echo "│ ElevenLabs Premium cloud ⭐⭐⭐⭐⭐ [key set] │"
|
|
160
|
+
else
|
|
161
|
+
echo "│ ElevenLabs Premium cloud ⭐⭐⭐⭐⭐ [needs key] │"
|
|
162
|
+
fi
|
|
113
163
|
fi
|
|
114
|
-
echo "│ Cost: Free
|
|
115
|
-
echo "│
|
|
116
|
-
echo "│
|
|
164
|
+
echo "│ Cost: Free tier available 32+ languages Offline: No │"
|
|
165
|
+
echo "│ Key: export ELEVENLABS_API_KEY=your_key │"
|
|
166
|
+
echo "│ │"
|
|
117
167
|
|
|
118
168
|
# macOS Say (show at end for non-macOS systems)
|
|
119
169
|
if [[ "$is_macos" != true ]]; then
|
|
120
|
-
|
|
121
|
-
if [[ "$current_provider" == "macos" ]]; then
|
|
170
|
+
if _active "macos"; then
|
|
122
171
|
echo "│ ✓ macOS Say Built-in, free ⭐⭐⭐⭐ [ACTIVE] │"
|
|
123
172
|
else
|
|
124
173
|
echo "│ macOS Say Built-in, free ⭐⭐⭐⭐ (macOS only) │"
|
|
125
174
|
fi
|
|
126
|
-
echo "│ Cost: Free (built-in)
|
|
127
|
-
echo "│
|
|
128
|
-
echo "│ Offline: Yes │"
|
|
175
|
+
echo "│ Cost: Free (built-in) Platform: macOS only │"
|
|
176
|
+
echo "│ │"
|
|
129
177
|
fi
|
|
130
178
|
|
|
131
|
-
echo "
|
|
179
|
+
echo "└──────────────────────────────────────────────────────────────┘"
|
|
132
180
|
echo ""
|
|
181
|
+
echo "Switch: /agent-vibes:provider switch <name>"
|
|
133
182
|
echo "Learn more: agentvibes.org/providers"
|
|
134
183
|
}
|
|
135
184
|
|
|
@@ -191,12 +191,18 @@ migrate_voice_to_provider() {
|
|
|
191
191
|
local piper_default="en_US-lessac-medium"
|
|
192
192
|
local macos_default="Samantha"
|
|
193
193
|
local soprano_default="soprano-default" # Single voice — no selection needed
|
|
194
|
+
local elevenlabs_default="Rachel"
|
|
195
|
+
local kokoro_default="af_heart"
|
|
194
196
|
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
# Single-voice providers: migration is straightforward
|
|
198
|
+
case "$target_provider" in
|
|
199
|
+
soprano)
|
|
200
|
+
echo "$soprano_default"; return 0 ;;
|
|
201
|
+
elevenlabs)
|
|
202
|
+
echo "$elevenlabs_default"; return 0 ;;
|
|
203
|
+
kokoro)
|
|
204
|
+
echo "$kokoro_default"; return 0 ;;
|
|
205
|
+
esac
|
|
200
206
|
|
|
201
207
|
# If no current voice, return default for target provider
|
|
202
208
|
if [[ -z "$current_voice" ]]; then
|
|
@@ -208,8 +214,8 @@ migrate_voice_to_provider() {
|
|
|
208
214
|
return 0
|
|
209
215
|
fi
|
|
210
216
|
|
|
211
|
-
# If migrating FROM
|
|
212
|
-
if [[ "$current_voice" == "soprano-default" ]]; then
|
|
217
|
+
# If migrating FROM a single-voice provider, return default for target provider
|
|
218
|
+
if [[ "$current_voice" == "soprano-default" || "$current_voice" == "Rachel" || "$current_voice" =~ ^[a-z]{2}_[a-z0-9_]+$ ]]; then
|
|
213
219
|
case "$target_provider" in
|
|
214
220
|
piper) echo "$piper_default" ;;
|
|
215
221
|
macos) echo "$macos_default" ;;
|
|
@@ -265,6 +271,8 @@ migrate_voice_to_provider() {
|
|
|
265
271
|
case "$target_provider" in
|
|
266
272
|
piper) echo "$piper_default" ;;
|
|
267
273
|
macos) echo "$macos_default" ;;
|
|
274
|
+
elevenlabs) echo "$elevenlabs_default" ;;
|
|
275
|
+
kokoro) echo "$kokoro_default" ;;
|
|
268
276
|
*) echo "$piper_default" ;;
|
|
269
277
|
esac
|
|
270
278
|
}
|
|
@@ -513,6 +513,12 @@ case "$1" in
|
|
|
513
513
|
fi
|
|
514
514
|
fi
|
|
515
515
|
|
|
516
|
+
# Normalize to canonical path so ls output is consistent across platforms
|
|
517
|
+
# (Git Bash resolves /tmp→/c/Users/..., macOS has /var→/private/var)
|
|
518
|
+
if [[ -d "$AUDIO_DIR" ]]; then
|
|
519
|
+
AUDIO_DIR=$(cd "$AUDIO_DIR" && pwd -P)
|
|
520
|
+
fi
|
|
521
|
+
|
|
516
522
|
# Default to replay last audio (N=1)
|
|
517
523
|
N="${2:-1}"
|
|
518
524
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# File: .claude/hooks/kokoro-server.py
|
|
4
|
+
#
|
|
5
|
+
# AgentVibes - Kokoro TTS persistent daemon
|
|
6
|
+
# Loads the Kokoro model ONCE (on GPU when available) and serves synthesis over
|
|
7
|
+
# a localhost HTTP socket, so each request skips the ~10s model-load + CUDA-init
|
|
8
|
+
# cost that a fresh `python kokoro-tts.py` invocation pays every time.
|
|
9
|
+
#
|
|
10
|
+
# This is the difference between ~31s/message (cold process per call) and
|
|
11
|
+
# ~2-3s/message (warm resident model).
|
|
12
|
+
#
|
|
13
|
+
# Endpoints (bound to 127.0.0.1 only — never exposed off-host):
|
|
14
|
+
# GET /health -> 200 {"ok":true} once the model is loaded and warmed
|
|
15
|
+
# POST /synth -> body {text, voice, speed, output}; writes a WAV to `output`
|
|
16
|
+
# and returns {"ok":true,"path":output} or {"ok":false,"error":...}
|
|
17
|
+
#
|
|
18
|
+
# Usage: python kokoro-server.py [port] (default port 7855)
|
|
19
|
+
#
|
|
20
|
+
# Licensed under the Apache License, Version 2.0
|
|
21
|
+
#
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import threading
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
28
|
+
|
|
29
|
+
# A bad argv[1] (non-numeric) must not crash the daemon before it can bind;
|
|
30
|
+
# fall back to the standard kokoro-server port.
|
|
31
|
+
try:
|
|
32
|
+
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 7855
|
|
33
|
+
except (ValueError, IndexError):
|
|
34
|
+
PORT = 7855
|
|
35
|
+
|
|
36
|
+
# Language code from the voice prefix (af/am=American, bf/bm=British, etc.)
|
|
37
|
+
LANG_MAP = {
|
|
38
|
+
'af': 'a', 'am': 'a', 'bf': 'b', 'bm': 'b', 'jf': 'j', 'jm': 'j',
|
|
39
|
+
'kf': 'k', 'km': 'k', 'zf': 'z', 'zm': 'z', 'ff': 'f', 'fm': 'f',
|
|
40
|
+
'hf': 'h', 'hm': 'h', 'if': 'i', 'im': 'i', 'pf': 'p', 'pm': 'p',
|
|
41
|
+
'ef': 'e', 'em': 'e', 'nf': 'n', 'nm': 'n',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Heavy imports up front so the model is resident before we accept connections.
|
|
45
|
+
import numpy as np
|
|
46
|
+
import soundfile as sf
|
|
47
|
+
from kokoro import KPipeline
|
|
48
|
+
|
|
49
|
+
_pipelines = {}
|
|
50
|
+
_lock = threading.Lock() # guards pipeline creation only
|
|
51
|
+
_infer_lock = threading.Lock() # serializes inference (torch is not thread-safe)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_pipeline(lang_code):
|
|
55
|
+
"""Return a cached KPipeline for the language, creating it once on demand."""
|
|
56
|
+
with _lock:
|
|
57
|
+
pipe = _pipelines.get(lang_code)
|
|
58
|
+
if pipe is None:
|
|
59
|
+
pipe = KPipeline(lang_code=lang_code)
|
|
60
|
+
_pipelines[lang_code] = pipe
|
|
61
|
+
return pipe
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def synth(text, voice, speed, output):
|
|
65
|
+
"""Synthesize `text` with `voice` to the `output` WAV path. Returns the path."""
|
|
66
|
+
prefix = voice[:2].lower() if len(voice) >= 2 else 'af'
|
|
67
|
+
lang_code = LANG_MAP.get(prefix, 'a')
|
|
68
|
+
pipeline = get_pipeline(lang_code)
|
|
69
|
+
|
|
70
|
+
chunks = []
|
|
71
|
+
# The daemon shares one KPipeline across ThreadingHTTPServer worker threads
|
|
72
|
+
# and torch inference is not thread-safe — run one inference at a time. This
|
|
73
|
+
# lock is acquired after get_pipeline() has released _lock, so no deadlock.
|
|
74
|
+
with _infer_lock:
|
|
75
|
+
for result in pipeline(text, voice=voice, speed=speed):
|
|
76
|
+
audio = getattr(result, "audio", None)
|
|
77
|
+
if audio is None:
|
|
78
|
+
try:
|
|
79
|
+
audio = result[-1]
|
|
80
|
+
except (TypeError, IndexError, KeyError):
|
|
81
|
+
audio = None
|
|
82
|
+
if audio is None:
|
|
83
|
+
continue
|
|
84
|
+
if hasattr(audio, "detach"):
|
|
85
|
+
audio = audio.detach().cpu().numpy()
|
|
86
|
+
audio = np.asarray(audio).reshape(-1)
|
|
87
|
+
if audio.size > 0:
|
|
88
|
+
chunks.append(audio)
|
|
89
|
+
|
|
90
|
+
if not chunks:
|
|
91
|
+
raise RuntimeError("Kokoro returned no audio samples")
|
|
92
|
+
|
|
93
|
+
sf.write(output, np.concatenate(chunks), 24000) # kokoro is always 24 kHz
|
|
94
|
+
return output
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _allowed_output_dirs():
|
|
98
|
+
"""Directories a /synth request is permitted to write a WAV into.
|
|
99
|
+
|
|
100
|
+
Mirrors the shell hooks' audio-cache resolution (project-local first, then
|
|
101
|
+
the global ~/.claude/audio) plus TEMP, which is where the daemon writes its
|
|
102
|
+
own transient/warmup files.
|
|
103
|
+
"""
|
|
104
|
+
dirs = []
|
|
105
|
+
proj = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
106
|
+
if proj:
|
|
107
|
+
dirs.append(os.path.join(proj, ".claude", "audio"))
|
|
108
|
+
home = os.environ.get("HOME") or os.path.expanduser("~")
|
|
109
|
+
dirs.append(os.path.join(home, ".claude", "audio"))
|
|
110
|
+
dirs.append(os.environ.get("TEMP", "/tmp"))
|
|
111
|
+
return dirs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_output_within_cache(output):
|
|
115
|
+
"""True if `output` resolves inside an allowed audio dir (blocks traversal).
|
|
116
|
+
|
|
117
|
+
The daemon is shared across projects on a fixed port, so it cannot rely on
|
|
118
|
+
its own CLAUDE_PROJECT_DIR to know every project's audio dir. Accept the
|
|
119
|
+
home/TEMP locations plus any file that sits directly inside a ".claude/audio"
|
|
120
|
+
directory (the only place the providers ever write). realpath has already
|
|
121
|
+
collapsed any "..", so true traversal (e.g. /etc/x.wav) is still rejected.
|
|
122
|
+
"""
|
|
123
|
+
tgt = os.path.realpath(output)
|
|
124
|
+
for base in _allowed_output_dirs():
|
|
125
|
+
base = os.path.realpath(base)
|
|
126
|
+
if tgt == base or tgt.startswith(base + os.sep):
|
|
127
|
+
return True
|
|
128
|
+
# Project-local audio dir for ANY project — the file must live directly in
|
|
129
|
+
# a ".claude/audio" directory.
|
|
130
|
+
parent = os.path.dirname(tgt).replace("\\", "/").lower()
|
|
131
|
+
if parent.endswith("/.claude/audio"):
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Handler(BaseHTTPRequestHandler):
|
|
137
|
+
# Silence default per-request stderr logging
|
|
138
|
+
def log_message(self, *args):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def _send(self, code, obj):
|
|
142
|
+
data = json.dumps(obj).encode("utf-8")
|
|
143
|
+
self.send_response(code)
|
|
144
|
+
self.send_header("Content-Type", "application/json")
|
|
145
|
+
self.send_header("Content-Length", str(len(data)))
|
|
146
|
+
self.end_headers()
|
|
147
|
+
try:
|
|
148
|
+
self.wfile.write(data)
|
|
149
|
+
except OSError:
|
|
150
|
+
# Client closed the socket early (health probes do this) — WinError
|
|
151
|
+
# 10053/10054 etc. Nothing to send to; ignore.
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def do_GET(self):
|
|
155
|
+
if self.path == "/health":
|
|
156
|
+
self._send(200, {"ok": True})
|
|
157
|
+
else:
|
|
158
|
+
self._send(404, {"ok": False, "error": "not found"})
|
|
159
|
+
|
|
160
|
+
def do_POST(self):
|
|
161
|
+
if self.path != "/synth":
|
|
162
|
+
self._send(404, {"ok": False, "error": "not found"})
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
166
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
167
|
+
text = body["text"]
|
|
168
|
+
voice = body.get("voice", "af_heart")
|
|
169
|
+
speed = float(body.get("speed", 1.0))
|
|
170
|
+
output = body["output"]
|
|
171
|
+
# Output must be an absolute .wav path (the provider always supplies one)
|
|
172
|
+
if not isinstance(output, str) or not output.lower().endswith(".wav"):
|
|
173
|
+
raise ValueError("output must be a .wav path")
|
|
174
|
+
# Path-traversal guard: the resolved target must stay inside an
|
|
175
|
+
# allowed audio directory — reject before any file is written.
|
|
176
|
+
if not is_output_within_cache(output):
|
|
177
|
+
self._send(400, {"ok": False, "error": "output path outside allowed audio directory"})
|
|
178
|
+
return
|
|
179
|
+
synth(text, voice, speed, output)
|
|
180
|
+
self._send(200, {"ok": True, "path": output})
|
|
181
|
+
except Exception as e: # noqa: BLE001 - report any failure to the client
|
|
182
|
+
self._send(500, {"ok": False, "error": str(e)})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main():
|
|
186
|
+
try:
|
|
187
|
+
import torch
|
|
188
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
189
|
+
except Exception:
|
|
190
|
+
device = "cpu"
|
|
191
|
+
|
|
192
|
+
# Bind first so a second accidental launch fails fast (address in use) and
|
|
193
|
+
# exits instead of loading a second copy of the model.
|
|
194
|
+
try:
|
|
195
|
+
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
|
196
|
+
except OSError as e:
|
|
197
|
+
print(f"kokoro-server: port {PORT} unavailable ({e}); another instance is likely running", file=sys.stderr)
|
|
198
|
+
sys.exit(0)
|
|
199
|
+
|
|
200
|
+
# Warm the default English pipeline + trigger CUDA kernel compilation so the
|
|
201
|
+
# very first real /synth request is already fast.
|
|
202
|
+
warm_fd, warm_out = tempfile.mkstemp(suffix=".wav")
|
|
203
|
+
os.close(warm_fd) # synth/soundfile reopens by path; we only need the name
|
|
204
|
+
try:
|
|
205
|
+
synth("ready", "af_heart", 1.0, warm_out)
|
|
206
|
+
except Exception as e: # noqa: BLE001
|
|
207
|
+
print(f"kokoro-server: warmup failed (continuing): {e}", file=sys.stderr)
|
|
208
|
+
finally:
|
|
209
|
+
try:
|
|
210
|
+
os.remove(warm_out)
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
print(f"kokoro-server ready on 127.0.0.1:{PORT} device={device}", flush=True)
|
|
215
|
+
server.serve_forever()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|