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
@@ -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
- [[ -n "$SSH_PORT" ]] && SSH_ARGS+=(-p "$SSH_PORT")
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
- [[ -n "$SSH_PORT" ]] && SSH_ARGS+=(-p "$SSH_PORT")
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
- echo "┌────────────────────────────────────────────────────────────┐"
92
- echo "│ Available TTS Providers │"
93
- echo "├────────────────────────────────────────────────────────────┤"
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 [[ "$current_provider" == "macos" ]]; then
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 ⭐⭐⭐⭐ [RECOMMENDED] │"
125
+ echo "│ macOS Say Built-in, free ⭐⭐⭐⭐ [RECOMMENDED] │"
101
126
  fi
102
- echo "│ Cost: Free (built-in) │"
103
- echo "│ Platform: macOS only │"
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 [[ "$current_provider" == "piper" ]]; then
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 forever │"
115
- echo "│ Platform: WSL, Linux only │"
116
- echo "│ Offline: Yes │"
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
- echo "│ │"
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 "│ Platform: macOS only │"
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
- # Soprano has a single voice, so migration is straightforward
196
- if [[ "$target_provider" == "soprano" ]]; then
197
- echo "$soprano_default"
198
- return 0
199
- fi
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 Soprano, return default for target provider
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()