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
@@ -178,9 +178,9 @@ else
178
178
 
179
179
  # Try to install pipx
180
180
  if command -v apt-get &> /dev/null; then
181
- # Debian/Ubuntu
182
- sudo apt-get update
183
- sudo apt-get install -y pipx
181
+ # Debian/Ubuntu — DEBIAN_FRONTEND prevents tzdata interactive prompt
182
+ DEBIAN_FRONTEND=noninteractive sudo -E apt-get update -qq
183
+ DEBIAN_FRONTEND=noninteractive sudo -E apt-get install -y pipx
184
184
  elif command -v brew &> /dev/null; then
185
185
  # Linux with Homebrew
186
186
  brew install pipx
@@ -198,16 +198,21 @@ else
198
198
  exit 1
199
199
  fi
200
200
 
201
- # Ensure pipx is in PATH
202
- pipx ensurepath
201
+ # Ensure pipx is in PATH (updates shell rc file) and update current session
202
+ pipx ensurepath 2>/dev/null || true
203
+ export PATH="$HOME/.local/bin:$PATH"
203
204
  echo ""
204
205
  fi
205
206
 
207
+ # Make sure ~/.local/bin is in PATH for this session (pipx installs there)
208
+ export PATH="$HOME/.local/bin:$PATH"
209
+ INSTALL_DIR="$HOME/.local/bin"
210
+
206
211
  # Install Piper TTS
207
212
  echo "📥 Installing Piper TTS via pipx..."
208
213
  pipx install piper-tts
209
214
 
210
- if ! command -v piper &> /dev/null; then
215
+ if ! command -v piper &> /dev/null && [[ ! -x "$HOME/.local/bin/piper" ]]; then
211
216
  echo ""
212
217
  echo "❌ Installation completed but piper command not found in PATH"
213
218
  echo ""
@@ -62,16 +62,16 @@ PIPER_VOICES_BASE_URL="https://huggingface.co/rhasspy/piper-voices/resolve/main"
62
62
  # @calledby All voice management functions (verify_voice, get_voice_path, download_voice, list_downloaded_voices)
63
63
  # @calls mkdir, cat, dirname
64
64
  get_voice_storage_dir() {
65
- local voice_dir
65
+ local voice_dir=""
66
66
 
67
67
  # Check for custom path in environment or config file
68
- if [[ -n "$PIPER_VOICES_DIR" ]]; then
69
- voice_dir="$PIPER_VOICES_DIR"
68
+ if [[ -n "${PIPER_VOICES_DIR:-}" ]]; then
69
+ voice_dir="${PIPER_VOICES_DIR}"
70
70
  else
71
71
  # Check for config file (project-local first, then global)
72
- local config_file
73
- if [[ -n "$CLAUDE_PROJECT_DIR" ]] && [[ -f "$CLAUDE_PROJECT_DIR/.claude/piper-voices-dir.txt" ]]; then
74
- config_file="$CLAUDE_PROJECT_DIR/.claude/piper-voices-dir.txt"
72
+ local config_file=""
73
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] && [[ -f "${CLAUDE_PROJECT_DIR}/.claude/piper-voices-dir.txt" ]]; then
74
+ config_file="${CLAUDE_PROJECT_DIR}/.claude/piper-voices-dir.txt"
75
75
  else
76
76
  # Search up directory tree for .claude/
77
77
  local current_dir="$PWD"
@@ -98,7 +98,9 @@ echo "📱 Sending to $SSH_HOST for local playback..." >&2
98
98
  # Build SSH args — use explicit key/port from config if available, else rely on ~/.ssh/config
99
99
  SSH_ARGS=()
100
100
  [[ -n "$SSH_KEY" && -f "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
101
- [[ -n "$SSH_PORT" ]] && SSH_ARGS+=(-p "$SSH_PORT")
101
+ # Never force "-p 22" (ssh default) -- it would override an ~/.ssh/config
102
+ # Host alias port (e.g. laptop-win -> 45217). Empty/22 => defer to ssh config.
103
+ if [[ -n "$SSH_PORT" && "$SSH_PORT" != "22" ]]; then SSH_ARGS+=(-p "$SSH_PORT"); fi
102
104
 
103
105
  # Try receiver scripts in order — single SSH call, no separate probe
104
106
  # SECURITY: Base64-encoded values are safe to pass as arguments (no shell metacharacters)
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # File: .claude/hooks/play-tts-elevenlabs.sh
4
+ #
5
+ # AgentVibes - Text-to-Speech WITH personality for AI Assistants
6
+ # Website: https://agentvibes.org
7
+ # Repository: https://github.com/paulpreibisch/AgentVibes
8
+ #
9
+ # Licensed under the Apache License, Version 2.0
10
+ #
11
+ # @fileoverview ElevenLabs TTS Provider — cloud neural voices with 32+ language support
12
+ # @context Provider-specific implementation for ElevenLabs API integration
13
+ # @architecture Implements provider contract: text/voice → audio playback
14
+ # @dependencies ELEVENLABS_API_KEY env var, curl, ffmpeg (optional), audio player
15
+ # @entrypoints Called by play-tts.sh router when provider=elevenlabs
16
+ # @related play-tts.sh, provider-manager.sh, language-manager.sh
17
+ #
18
+ # Voice can be a name (e.g. "Rachel") or a raw ElevenLabs voice ID.
19
+ # Set ELEVENLABS_API_KEY in your shell profile or via Infisical.
20
+ # Default voice: Rachel (English, warm female)
21
+ #
22
+
23
+ set -euo pipefail
24
+ export LC_ALL=C
25
+
26
+ TEXT="${1:-}"
27
+ VOICE_OVERRIDE="${2:-}"
28
+
29
+ # Resolve script dir (handles symlinks)
30
+ SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
31
+ SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
32
+
33
+ source "$SCRIPT_DIR/audio-cache-utils.sh"
34
+ source "$SCRIPT_DIR/language-manager.sh"
35
+
36
+ if [[ -z "$TEXT" ]]; then
37
+ echo "Usage: $0 \"text to speak\" [voice_name_or_id]" >&2
38
+ exit 1
39
+ fi
40
+
41
+ # Clamp text to 2000 chars (ElevenLabs API limit)
42
+ if [[ ${#TEXT} -gt 2000 ]]; then
43
+ TEXT="${TEXT:0:1997}..."
44
+ fi
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Optional: fetch the key from Infisical on demand, so no key is stored on disk.
48
+ # Opt-in via ~/.agentvibes/infisical.env, which sets (env vars take precedence):
49
+ # INFISICAL_SECRET_NAME name of the secret in Infisical (e.g. ELEVEN_LABS)
50
+ # INFISICAL_PROJECT_ID project/workspace id
51
+ # INFISICAL_ENV environment slug (default: prod)
52
+ # INFISICAL_DOMAIN API base URL (default: http://127.0.0.1:8200/api)
53
+ # INFISICAL_BOOTSTRAP universal-auth creds file (default: ~/.palace-bootstrap)
54
+ # Requires the infisical CLI + python3. Fails soft: prints nothing and returns
55
+ # non-zero when unconfigured or unreachable, so the normal error path still runs.
56
+ # The fetched value is never printed.
57
+ _fetch_key_from_infisical() {
58
+ local cfg="${HOME}/.agentvibes/infisical.env"
59
+ if [[ -f "$cfg" ]]; then
60
+ set -o allexport; source "$cfg"; set +o allexport
61
+ fi
62
+
63
+ local secret_name="${INFISICAL_SECRET_NAME:-}"
64
+ local project_id="${INFISICAL_PROJECT_ID:-}"
65
+ local env_name="${INFISICAL_ENV:-prod}"
66
+ local domain="${INFISICAL_DOMAIN:-http://127.0.0.1:8200/api}"
67
+ local bootstrap="${INFISICAL_BOOTSTRAP:-${HOME}/.palace-bootstrap}"
68
+
69
+ [[ -n "$secret_name" && -n "$project_id" ]] || return 1
70
+ command -v infisical >/dev/null 2>&1 || return 1
71
+ command -v python3 >/dev/null 2>&1 || return 1
72
+
73
+ # Obtain an access token: reuse an exported INFISICAL_TOKEN, else exchange the
74
+ # universal-auth client credentials from the bootstrap file (creds via env only).
75
+ local token="${INFISICAL_TOKEN:-}"
76
+ if [[ -z "$token" ]]; then
77
+ [[ -f "$bootstrap" ]] || return 1
78
+ set -o allexport; source "$bootstrap"; set +o allexport
79
+ [[ -n "${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" && -n "${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" ]] || return 1
80
+ token="$(_AV_DOMAIN="$domain" python3 -c '
81
+ import os, json, urllib.request
82
+ d = os.environ["_AV_DOMAIN"].rstrip("/")
83
+ req = urllib.request.Request(d + "/v1/auth/universal-auth/login",
84
+ data=json.dumps({"clientId": os.environ["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"],
85
+ "clientSecret": os.environ["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]}).encode(),
86
+ headers={"Content-Type": "application/json"})
87
+ print(json.loads(urllib.request.urlopen(req, timeout=10).read())["accessToken"])
88
+ ' 2>/dev/null || true)"
89
+ fi
90
+ [[ -n "$token" ]] || return 1
91
+
92
+ INFISICAL_TOKEN="$token" infisical secrets get "$secret_name" \
93
+ --projectId "$project_id" --env "$env_name" --domain "$domain" \
94
+ --plain 2>/dev/null | head -1
95
+ }
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # API key — env var (only if it looks valid), then key file, then Infisical.
99
+ # A malformed/truncated ELEVENLABS_API_KEY (e.g. a half-pasted key) is ignored so
100
+ # it can't silently shadow a good key file — a common foot-gun that yields
101
+ # confusing "API key fail" errors despite a valid key on disk.
102
+ _looks_like_el_key() {
103
+ [[ "$1" =~ ^sk_[A-Za-z0-9]{40,}$ ]] && return 0 # current format: sk_ + >=40 chars
104
+ [[ "$1" =~ ^[A-Fa-f0-9]{32}$ ]] && return 0 # legacy 32-char hex key
105
+ return 1
106
+ }
107
+
108
+ API_KEY=""
109
+ if [[ -n "${ELEVENLABS_API_KEY:-}" ]]; then
110
+ # A non-empty env var is authoritative — ElevenLabs has shipped several key
111
+ # formats over time, so we must not silently discard a key just because it
112
+ # looks unfamiliar. Warn (using ONLY the length, never the value) if the format
113
+ # is unexpected, but use it regardless.
114
+ API_KEY="$ELEVENLABS_API_KEY"
115
+ if ! _looks_like_el_key "$ELEVENLABS_API_KEY"; then
116
+ echo "⚠️ ELEVENLABS_API_KEY env var has an unexpected format (length ${#ELEVENLABS_API_KEY}); using it anyway." >&2
117
+ fi
118
+ fi
119
+ if [[ -z "$API_KEY" ]]; then
120
+ _key_file="${HOME}/.agentvibes/elevenlabs-key.txt"
121
+ if [[ -f "$_key_file" ]]; then
122
+ API_KEY="$(tr -d '[:space:]' < "$_key_file")"
123
+ fi
124
+ fi
125
+ if [[ -z "$API_KEY" ]]; then
126
+ API_KEY="$(_fetch_key_from_infisical || true)"
127
+ API_KEY="$(printf '%s' "$API_KEY" | tr -d '[:space:]')"
128
+ fi
129
+
130
+ if [[ -z "$API_KEY" ]]; then
131
+ echo "❌ ElevenLabs API key not set." >&2
132
+ echo " Set it one of three ways:" >&2
133
+ echo " 1. export ELEVENLABS_API_KEY=your_key (add to ~/.bashrc or ~/.zshrc)" >&2
134
+ echo " 2. echo 'your_key' > ~/.agentvibes/elevenlabs-key.txt && chmod 600 ~/.agentvibes/elevenlabs-key.txt" >&2
135
+ echo " 3. Configure Infisical in ~/.agentvibes/infisical.env (INFISICAL_SECRET_NAME + INFISICAL_PROJECT_ID)" >&2
136
+ echo " Get a free key at: https://elevenlabs.io" >&2
137
+ exit 2
138
+ fi
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Inline voice name → ID map (covers common built-in ElevenLabs voices)
142
+ declare -A VOICE_IDS
143
+ VOICE_IDS["Rachel"]="21m00Tcm4TlvDq8ikWAM"
144
+ VOICE_IDS["Adam"]="pNInz6obpgDQGcFmaJgB"
145
+ VOICE_IDS["Antoni"]="ErXwobaYiN019PkySvjV"
146
+ VOICE_IDS["Arnold"]="VR6AewLTigWG4xSOukaG"
147
+ VOICE_IDS["Bella"]="EXAVITQu4vr4xnSDxMaL"
148
+ VOICE_IDS["Callum"]="N2lVS1w4EtoT3dr4eOWO"
149
+ VOICE_IDS["Charlie"]="IKne3meq5aSn9XLyUdCD"
150
+ VOICE_IDS["Charlotte"]="XB0fDUnXU5powFXDhCwa"
151
+ VOICE_IDS["Clyde"]="2EiwWnXFnvU5JabPnv8n"
152
+ VOICE_IDS["Daniel"]="onwK4e9ZLuTAKqWW03F9"
153
+ VOICE_IDS["Dave"]="CYw3kZ02Hs0563khs1Fj"
154
+ VOICE_IDS["Dorothy"]="ThT5KcBeYPX3keUQqHPh"
155
+ VOICE_IDS["Domi"]="AZnzlk1XvdvUeBnXmlld"
156
+ VOICE_IDS["Drew"]="29vD33N1CtxCmqQRPOHJ"
157
+ VOICE_IDS["Emily"]="LcfcDJNUP1GQjkzn1xUU"
158
+ VOICE_IDS["Ethan"]="g5CIjZEefAph4nQFvHAz"
159
+ VOICE_IDS["Fin"]="D38z5RcWu1voky8WS1ja"
160
+ VOICE_IDS["Freya"]="jsCqWAovK2LkecY7zXl4"
161
+ VOICE_IDS["Gigi"]="jBpfuIE2acCO8z3wKNLl"
162
+ VOICE_IDS["Giovanni"]="zcAOhNBS3c14rBihAFp1"
163
+ VOICE_IDS["Glinda"]="z9fAnlkpzviPz146aGWa"
164
+ VOICE_IDS["Grace"]="oWAxZDx7w5VEj9dCyTzz"
165
+ VOICE_IDS["Harry"]="SOYHLrjzK2X1ezoPC6cr"
166
+ VOICE_IDS["James"]="ZQe5CZNOzWyzPSCn5a3c"
167
+ VOICE_IDS["Jessie"]="t0jbNlBVZ17f02VDIeMI"
168
+ VOICE_IDS["Josh"]="TxGEqnHWrfWFTfGW9XjX"
169
+ VOICE_IDS["Liam"]="TX3LPaxmHKxFdv7VOQHJ"
170
+ VOICE_IDS["Lily"]="pFZP5JQG7iQjIQuC4Bku"
171
+ VOICE_IDS["Matilda"]="XrExE9yKIg1WjnnlVkGX"
172
+ VOICE_IDS["Michael"]="flq6f7yk4E4fJM5XTYuZ"
173
+ VOICE_IDS["Mimi"]="zrHiDhphv9ZnVXBqCLjz"
174
+ VOICE_IDS["Nicole"]="piTKgcLEGmPE4e6mEKli"
175
+ VOICE_IDS["Patrick"]="ODq5zmih8GrVes37Dizd"
176
+ VOICE_IDS["Paul"]="5Q0t7uMcjvnagumLfvZi"
177
+ VOICE_IDS["Sam"]="yoZ06aMxZJJ28mfd3POQ"
178
+ VOICE_IDS["Sarah"]="EXAVITQu4vr4xnSDxMaL"
179
+ VOICE_IDS["Serena"]="pMsXgVXv3BLzUgSXRplE"
180
+ VOICE_IDS["Thomas"]="GBv7mTt0atIp3Br8iCZE"
181
+
182
+ DEFAULT_VOICE_ID="${VOICE_IDS[Rachel]}"
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Resolve voice ID from override or config
186
+ VOICE_ID=""
187
+
188
+ if [[ -n "$VOICE_OVERRIDE" ]]; then
189
+ if [[ -n "${VOICE_IDS[$VOICE_OVERRIDE]:-}" ]]; then
190
+ VOICE_ID="${VOICE_IDS[$VOICE_OVERRIDE]}"
191
+ elif [[ "$VOICE_OVERRIDE" =~ ^[a-zA-Z0-9]{10,40}$ ]]; then
192
+ # Looks like a raw voice ID
193
+ VOICE_ID="$VOICE_OVERRIDE"
194
+ else
195
+ echo "⚠️ Unknown ElevenLabs voice '$VOICE_OVERRIDE', using Rachel" >&2
196
+ VOICE_ID="$DEFAULT_VOICE_ID"
197
+ fi
198
+ else
199
+ # Check voice manager for configured voice
200
+ VOICE_NAME=""
201
+ if [[ -f "$SCRIPT_DIR/voice-manager.sh" ]]; then
202
+ VOICE_NAME="$("$SCRIPT_DIR/voice-manager.sh" get 2>/dev/null || true)"
203
+ fi
204
+
205
+ if [[ -n "$VOICE_NAME" && -n "${VOICE_IDS[$VOICE_NAME]:-}" ]]; then
206
+ VOICE_ID="${VOICE_IDS[$VOICE_NAME]}"
207
+ else
208
+ VOICE_ID="$DEFAULT_VOICE_ID"
209
+ fi
210
+ fi
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Language/model selection
214
+ CURRENT_LANGUAGE="$(get_language_code 2>/dev/null || echo 'english')"
215
+
216
+ case "$CURRENT_LANGUAGE" in
217
+ english) LANGUAGE_CODE="en"; MODEL_ID="eleven_turbo_v2_5" ;;
218
+ spanish) LANGUAGE_CODE="es"; MODEL_ID="eleven_multilingual_v2" ;;
219
+ french) LANGUAGE_CODE="fr"; MODEL_ID="eleven_multilingual_v2" ;;
220
+ german) LANGUAGE_CODE="de"; MODEL_ID="eleven_multilingual_v2" ;;
221
+ italian) LANGUAGE_CODE="it"; MODEL_ID="eleven_multilingual_v2" ;;
222
+ portuguese) LANGUAGE_CODE="pt"; MODEL_ID="eleven_multilingual_v2" ;;
223
+ chinese) LANGUAGE_CODE="zh"; MODEL_ID="eleven_multilingual_v2" ;;
224
+ japanese) LANGUAGE_CODE="ja"; MODEL_ID="eleven_multilingual_v2" ;;
225
+ korean) LANGUAGE_CODE="ko"; MODEL_ID="eleven_multilingual_v2" ;;
226
+ russian) LANGUAGE_CODE="ru"; MODEL_ID="eleven_multilingual_v2" ;;
227
+ polish) LANGUAGE_CODE="pl"; MODEL_ID="eleven_multilingual_v2" ;;
228
+ dutch) LANGUAGE_CODE="nl"; MODEL_ID="eleven_multilingual_v2" ;;
229
+ arabic) LANGUAGE_CODE="ar"; MODEL_ID="eleven_multilingual_v2" ;;
230
+ hindi) LANGUAGE_CODE="hi"; MODEL_ID="eleven_multilingual_v2" ;;
231
+ turkish) LANGUAGE_CODE="tr"; MODEL_ID="eleven_multilingual_v2" ;;
232
+ swedish) LANGUAGE_CODE="sv"; MODEL_ID="eleven_multilingual_v2" ;;
233
+ *) LANGUAGE_CODE="en"; MODEL_ID="eleven_turbo_v2_5" ;;
234
+ esac
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Audio output directory
238
+ AUDIO_DIR="$(get_audio_cache_dir 2>/dev/null || echo "${HOME}/.claude/audio")"
239
+ mkdir -p "$AUDIO_DIR"
240
+ chmod 700 "$AUDIO_DIR"
241
+
242
+ TEMP_FILE="${AUDIO_DIR}/tts-elevenlabs-$(date +%s%N | head -c 18).mp3"
243
+ BODY_FILE="${AUDIO_DIR}/tts-el-body-$(date +%s%N | head -c 18).json"
244
+ trap 'rm -f "${TEMP_FILE:-}" "${BODY_FILE:-}" 2>/dev/null || true' EXIT
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Build the JSON request body with python so all escaping is handled correctly
248
+ # (text is passed via env, never interpolated into a shell-quoted payload).
249
+ if ! TTS_TEXT="$TEXT" TTS_MODEL="$MODEL_ID" TTS_LANG="$LANGUAGE_CODE" python3 -c '
250
+ import os, json
251
+ print(json.dumps({
252
+ "text": os.environ["TTS_TEXT"],
253
+ "model_id": os.environ["TTS_MODEL"],
254
+ "language_code": os.environ["TTS_LANG"],
255
+ "voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
256
+ }))' > "$BODY_FILE" 2>/dev/null; then
257
+ echo "❌ Failed to build ElevenLabs request body (python3 required)" >&2
258
+ exit 3
259
+ fi
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Test mode: never make a billable request to the ElevenLabs API (and never play
263
+ # audio). Short-circuit BEFORE the network call. Mirrors the sibling providers'
264
+ # AGENTVIBES_TEST_MODE handling; the EXIT trap cleans up the temp files.
265
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" == "true" ]]; then
266
+ exit 0
267
+ fi
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Call ElevenLabs API
271
+ # Security: API key passed via a curl config read from stdin (-K -), so it never
272
+ # appears in argv (where `ps` could expose it) nor in any temp file.
273
+ HTTP_STATUS=$(printf 'header = "xi-api-key: %s"\n' "$API_KEY" | curl -s -o "$TEMP_FILE" -w "%{http_code}" \
274
+ -X POST "https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}" \
275
+ -K - \
276
+ -H "Content-Type: application/json" \
277
+ --max-time 30 \
278
+ --data-binary "@${BODY_FILE}" 2>&1)
279
+ rm -f "$BODY_FILE" 2>/dev/null || true
280
+
281
+ if [[ "$HTTP_STATUS" != "200" ]]; then
282
+ echo "❌ ElevenLabs API error (HTTP $HTTP_STATUS)" >&2
283
+ if [[ -f "$TEMP_FILE" ]]; then
284
+ cat "$TEMP_FILE" >&2
285
+ fi
286
+ exit 3
287
+ fi
288
+
289
+ if [[ ! -f "$TEMP_FILE" || ! -s "$TEMP_FILE" ]]; then
290
+ echo "❌ ElevenLabs returned empty audio" >&2
291
+ exit 3
292
+ fi
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Convert MP3 → WAV so the post-processor (sox effects) can read it, and add a
296
+ # short silence lead-in (prevents a clipped first phoneme / WSL audio static).
297
+ # ffmpeg is preferred; if it is absent we keep the MP3 and skip effects/music.
298
+ if command -v ffmpeg &>/dev/null; then
299
+ _WAV="${AUDIO_DIR}/tts-el-$(date +%s%N | head -c 18).wav"
300
+ trap 'rm -f "${TEMP_FILE:-}" "${_WAV:-}" 2>/dev/null || true' EXIT
301
+ if ffmpeg -f lavfi -i "anullsrc=r=44100:cl=mono:d=0.15" -i "$TEMP_FILE" \
302
+ -filter_complex "[0:a][1:a]concat=n=2:v=0:a=1[out]" \
303
+ -map "[out]" -ar 44100 -ac 1 -y "$_WAV" 2>/dev/null \
304
+ && [[ -s "$_WAV" ]]; then
305
+ rm -f "$TEMP_FILE"
306
+ TEMP_FILE="$_WAV"
307
+ fi
308
+ fi
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Post-process: apply audio effects (reverb/echo/chorus via sox) and mix
312
+ # background music (via ffmpeg) — exactly like the Piper provider does. Honors
313
+ # the per-LLM row (AGENTVIBES_LLM_KEY) and any per-call AGENTVIBES_REVERB_OVERRIDE.
314
+ # Fails soft: if audio-processor.sh or its tools (sox/ffmpeg) are missing, the
315
+ # unprocessed audio is played instead. NOTE: reverb/echo/chorus REQUIRE sox to be
316
+ # installed; without it, effects are silently skipped (background music still works
317
+ # via ffmpeg).
318
+ #
319
+ # Only post-process when invoked via the orchestrator (which exports
320
+ # AGENTVIBES_LLM_KEY) or with an explicit per-call effect override. A bare direct
321
+ # call — e.g. the voice-picker audition — gets the clean, dry voice so the user
322
+ # hears the voice itself, not the default LLM row's effects/music.
323
+ if [[ -f "$SCRIPT_DIR/audio-processor.sh" \
324
+ && ( -n "${AGENTVIBES_LLM_KEY:-}" || -n "${AGENTVIBES_REVERB_OVERRIDE:-}" ) ]]; then
325
+ _PROCESSED="${AUDIO_DIR}/tts-el-proc-$(date +%s%N | head -c 18).wav"
326
+ trap 'rm -f "${TEMP_FILE:-}" "${_PROCESSED:-}" 2>/dev/null || true' EXIT
327
+ _AGENT_KEY="${AGENTVIBES_LLM_KEY:-default}"
328
+ if _PROC_OUT="$(bash "$SCRIPT_DIR/audio-processor.sh" "$TEMP_FILE" "$_AGENT_KEY" "$_PROCESSED" 2>/dev/null)"; then
329
+ _PF="${_PROC_OUT%%|*}"
330
+ if [[ -n "$_PF" && -f "$_PF" && -s "$_PF" && "$_PF" != "$TEMP_FILE" ]]; then
331
+ rm -f "$TEMP_FILE"
332
+ TEMP_FILE="$_PF"
333
+ fi
334
+ fi
335
+ fi
336
+
337
+ # Play audio (skip in test mode or no-playback mode — matches the Piper provider)
338
+ # AGENTVIBES_NO_PLAYBACK: Set to "true" to generate audio without playing (for post-processing)
339
+ # Native Windows (git-bash, OS=Windows_NT — not WSL) has none of the Linux
340
+ # players, and ffplay spawned from the TUI's Node process is unreliable. Use the
341
+ # built-in PowerShell WPF MediaPlayer (same as audio-env.js) — no ffmpeg/PATH
342
+ # dependency. Other platforms try the usual players.
343
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" != "true" ]] && [[ "${AGENTVIBES_NO_PLAYBACK:-false}" != "true" ]]; then
344
+ _PS_BIN="$(command -v powershell 2>/dev/null || command -v powershell.exe 2>/dev/null || true)"
345
+ if [[ "${OS:-}" == "Windows_NT" && -n "$_PS_BIN" ]]; then
346
+ _WINPATH="$(cygpath -w "$TEMP_FILE" 2>/dev/null || echo "$TEMP_FILE")"
347
+ _WINPATH="${_WINPATH//\'/\'\'}" # escape single quotes for the PowerShell literal
348
+ "$_PS_BIN" -NoProfile -Command "Add-Type -AssemblyName PresentationCore; \$p = New-Object System.Windows.Media.MediaPlayer; \$p.Open([uri]'${_WINPATH}'); \$p.Play(); Start-Sleep -Milliseconds 300; \$last=[TimeSpan]::Zero; \$stall=0; \$n=0; while (\$n -lt 100) { Start-Sleep -Milliseconds 100; \$pos=\$p.Position; if (\$p.NaturalDuration.HasTimeSpan -and \$pos -ge \$p.NaturalDuration.TimeSpan) { break }; if (\$pos -eq \$last) { \$stall++ } else { \$stall=0 }; if (\$stall -ge 8 -and \$pos -gt [TimeSpan]::Zero) { break }; \$last=\$pos; \$n++ }; \$p.Stop(); \$p.Close()" 2>/dev/null || true
349
+ else
350
+ (paplay "$TEMP_FILE" 2>/dev/null \
351
+ || aplay "$TEMP_FILE" 2>/dev/null \
352
+ || mpg123 -q "$TEMP_FILE" 2>/dev/null \
353
+ || ffplay -nodisp -autoexit -loglevel error "$TEMP_FILE" 2>/dev/null \
354
+ || true) &
355
+ wait $!
356
+ fi
357
+ fi
358
+
359
+ # Cancel trap so file persists in cache (for replay)
360
+ trap '' EXIT
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # File: .claude/hooks/play-tts-kokoro.sh
4
+ #
5
+ # AgentVibes - Text-to-Speech WITH personality for AI Assistants
6
+ # Website: https://agentvibes.org
7
+ # Repository: https://github.com/paulpreibisch/AgentVibes
8
+ #
9
+ # Licensed under the Apache License, Version 2.0
10
+ #
11
+ # @fileoverview Kokoro TTS Provider — 82M-param local neural TTS, 60+ voices, 8+ languages
12
+ # @context Provides high-quality local TTS via kokoro-onnx (MIT license, no API key needed)
13
+ # @architecture Implements provider contract: text/voice → audio playback
14
+ # @dependencies kokoro-onnx, soundfile, numpy (pip), ffmpeg (optional), audio player
15
+ # @entrypoints Called by play-tts.sh router when provider=kokoro
16
+ # @related play-tts.sh, kokoro-installer.sh, kokoro-tts.py
17
+ #
18
+ # Default voice: af_heart (American Female, warm)
19
+ # Install: /home/user/.claude/hooks/kokoro-installer.sh
20
+ # Or manually: pip install kokoro-onnx soundfile numpy
21
+ #
22
+
23
+ set -euo pipefail
24
+ export LC_ALL=C
25
+
26
+ TEXT="${1:-}"
27
+ VOICE_OVERRIDE="${2:-}"
28
+
29
+ # Resolve script dir (handles symlinks)
30
+ SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
31
+ SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
32
+
33
+ source "$SCRIPT_DIR/audio-cache-utils.sh"
34
+
35
+ if [[ -z "$TEXT" ]]; then
36
+ echo "Usage: $0 \"text to speak\" [voice]" >&2
37
+ exit 1
38
+ fi
39
+
40
+ # Check kokoro is installed (use find_spec to avoid slow torch import)
41
+ if ! python3 -c "import importlib.util; exit(0 if importlib.util.find_spec('kokoro') else 1)" 2>/dev/null; then
42
+ echo "❌ Kokoro TTS not installed." >&2
43
+ echo " Install with: ${SCRIPT_DIR}/kokoro-installer.sh" >&2
44
+ echo " Or manually: pip install kokoro-onnx soundfile numpy" >&2
45
+ exit 2
46
+ fi
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Resolve voice
50
+ DEFAULT_VOICE="af_heart"
51
+
52
+ if [[ -n "$VOICE_OVERRIDE" ]]; then
53
+ # Accept kokoro-style voice IDs (2-letter prefix + underscore + name)
54
+ # e.g. af_heart, am_adam, bf_emma, bm_george
55
+ if [[ "$VOICE_OVERRIDE" =~ ^[a-z]{2}_[a-z0-9_]+$ ]]; then
56
+ VOICE="$VOICE_OVERRIDE"
57
+ else
58
+ echo "⚠️ Unrecognized kokoro voice '$VOICE_OVERRIDE', using $DEFAULT_VOICE" >&2
59
+ VOICE="$DEFAULT_VOICE"
60
+ fi
61
+ else
62
+ # Check voice manager
63
+ if [[ -f "$SCRIPT_DIR/voice-manager.sh" ]]; then
64
+ _CONFIGURED="$("$SCRIPT_DIR/voice-manager.sh" get 2>/dev/null || true)"
65
+ if [[ "$_CONFIGURED" =~ ^[a-z]{2}_[a-z0-9_]+$ ]]; then
66
+ VOICE="$_CONFIGURED"
67
+ else
68
+ VOICE="$DEFAULT_VOICE"
69
+ fi
70
+ else
71
+ VOICE="$DEFAULT_VOICE"
72
+ fi
73
+ fi
74
+
75
+ # Speed from config (optional, defaults to 1.0)
76
+ # SECURITY: Validate speed is numeric before passing to python (avoids traceback)
77
+ SPEED="${KOKORO_SPEED:-1.0}"
78
+ [[ "$SPEED" =~ ^[0-9]+(\.[0-9]+)?$ ]] || SPEED="1.0"
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Synthesis
82
+ AUDIO_DIR="$(get_audio_cache_dir 2>/dev/null || echo "${HOME}/.claude/audio")"
83
+ mkdir -p "$AUDIO_DIR"
84
+ chmod 700 "$AUDIO_DIR"
85
+
86
+ TEMP_WAV="${AUDIO_DIR}/tts-kokoro-$(date +%s%N | head -c 18).wav"
87
+ trap 'rm -f "${TEMP_WAV:-}" 2>/dev/null || true' EXIT
88
+
89
+ SYNTH_SCRIPT="${SCRIPT_DIR}/kokoro-tts.py"
90
+ if [[ ! -f "$SYNTH_SCRIPT" ]]; then
91
+ echo "❌ kokoro-tts.py not found at $SYNTH_SCRIPT" >&2
92
+ exit 2
93
+ fi
94
+
95
+ # Skip the (slow) synthesis + playback entirely in test mode — nothing to play
96
+ # and no need to spin up the kokoro model. Mirrors the sibling Piper provider.
97
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" == "true" ]]; then
98
+ trap '' EXIT
99
+ exit 0
100
+ fi
101
+
102
+ # Run synthesis — output path printed to stdout
103
+ RESULT=$(python3 "$SYNTH_SCRIPT" "$TEXT" "$VOICE" "$TEMP_WAV" "$SPEED" 2>&1) || {
104
+ echo "❌ Kokoro synthesis failed: $RESULT" >&2
105
+ exit 3
106
+ }
107
+
108
+ if [[ ! -f "$TEMP_WAV" || ! -s "$TEMP_WAV" ]]; then
109
+ echo "❌ Kokoro synthesis produced no audio" >&2
110
+ exit 3
111
+ fi
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Play audio — try players in order (WAV-capable)
115
+ # Skip playback in test mode or no-playback mode (matches the Piper provider).
116
+ # AGENTVIBES_NO_PLAYBACK: Set to "true" to generate audio without playing (for post-processing)
117
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" != "true" ]] && [[ "${AGENTVIBES_NO_PLAYBACK:-false}" != "true" ]]; then
118
+ (aplay -q "$TEMP_WAV" 2>/dev/null \
119
+ || paplay "$TEMP_WAV" 2>/dev/null \
120
+ || ffplay -nodisp -autoexit -loglevel quiet "$TEMP_WAV" 2>/dev/null \
121
+ || mpg123 -q "$TEMP_WAV" 2>/dev/null \
122
+ || true) &
123
+ wait $!
124
+ fi
125
+
126
+ # Cancel trap so file persists in cache
127
+ trap '' EXIT
@@ -73,10 +73,8 @@ TEXT=$(printf '%s' "$TEXT" | perl -CSD -pe '
73
73
  s/^\s*[-]\s*//g; # list dashes
74
74
  ')
75
75
 
76
- # Source voice manager and language manager
77
- # Use readlink -f to handle symlinks correctly
78
- SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
79
- SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
76
+ # cd-based resolution works on macOS (BSD readlink lacks -f) and Linux alike
77
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
80
78
  source "$SCRIPT_DIR/piper-voice-manager.sh"
81
79
  source "$SCRIPT_DIR/language-manager.sh"
82
80
  source "$SCRIPT_DIR/audio-cache-utils.sh"
@@ -226,11 +224,19 @@ if [[ -z "$TEXT" ]]; then
226
224
  fi
227
225
 
228
226
  # Augment PATH for non-interactive shells (pipx installs to ~/.local/bin which
229
- # interactive shells get via .bashrc/.zshrc, but Bash tool calls skip profile)
227
+ # interactive shells get via .bashrc/.zshrc, but Bash tool calls skip profile).
228
+ # Mac: add both Apple Silicon (/opt/homebrew) and Intel (/usr/local) Homebrew locations.
230
229
  export PATH="$HOME/.local/bin:$HOME/.local/share/pipx/venvs/piper-tts/bin:$PATH"
230
+ if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
231
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
232
+ fi
233
+
234
+ # Resolve explicit piper binary path — avoids bare `piper` invocation failing when
235
+ # PATH augmentation above hasn't propagated into nested subshells.
236
+ PIPER_BIN=$(command -v piper 2>/dev/null || echo "")
231
237
 
232
238
  # Check if Piper is installed
233
- if ! command -v piper &> /dev/null; then
239
+ if [[ -z "$PIPER_BIN" ]]; then
234
240
  echo "❌ Error: Piper TTS not installed"
235
241
  echo "Install with: pipx install piper-tts"
236
242
  echo "Or run: .claude/hooks/piper-installer.sh"
@@ -309,6 +315,8 @@ else
309
315
  fi
310
316
 
311
317
  mkdir -p "$AUDIO_DIR"
318
+ # Normalize to canonical path (handles Git Bash /tmp→/c/Users/..., macOS /var→/private/var)
319
+ AUDIO_DIR=$(cd "$AUDIO_DIR" && pwd -P)
312
320
  _tmp=$(mktemp "$AUDIO_DIR/tts-XXXXXX"); TEMP_FILE="${_tmp}.wav"; mv "$_tmp" "$TEMP_FILE"
313
321
 
314
322
  # @function get_speech_rate
@@ -380,6 +388,10 @@ get_speech_rate() {
380
388
 
381
389
  SPEECH_RATE=$(get_speech_rate)
382
390
 
391
+ # Ensure piper log directory exists so stderr redirect never silently fails
392
+ _PIPER_LOG_DIR="${AGENTVIBES_LOG_DIR:-$HOME/.local/state/agentvibes/logs}"
393
+ mkdir -p "$_PIPER_LOG_DIR" 2>/dev/null || true
394
+
383
395
  # @function synthesize_with_piper
384
396
  # @intent Generate speech using Piper TTS
385
397
  # @why Provides free, offline TTS alternative
@@ -391,10 +403,10 @@ SPEECH_RATE=$(get_speech_rate)
391
403
  if [[ -n "${SPEAKER_ID:-}" ]]; then
392
404
  # Multi-speaker voice: Pass speaker ID
393
405
  # SECURITY: Use printf instead of echo for pipe safety (#134)
394
- printf '%s\n' "$TEXT" | piper --model "$VOICE_PATH" --speaker "$SPEAKER_ID" --length-scale "$SPEECH_RATE" --sentence-silence 2.0 --output_file "$TEMP_FILE" 2>/dev/null
406
+ printf '%s\n' "$TEXT" | "$PIPER_BIN" --model "$VOICE_PATH" --speaker "$SPEAKER_ID" --length-scale "$SPEECH_RATE" --sentence-silence 2.0 --output_file "$TEMP_FILE" 2>>"$_PIPER_LOG_DIR/piper.log"
395
407
  else
396
408
  # Single-speaker voice
397
- printf '%s\n' "$TEXT" | piper --model "$VOICE_PATH" --length-scale "$SPEECH_RATE" --sentence-silence 2.0 --output_file "$TEMP_FILE" 2>/dev/null
409
+ printf '%s\n' "$TEXT" | "$PIPER_BIN" --model "$VOICE_PATH" --length-scale "$SPEECH_RATE" --sentence-silence 2.0 --output_file "$TEMP_FILE" 2>>"$_PIPER_LOG_DIR/piper.log"
398
410
  fi
399
411
 
400
412
  if [[ ! -f "$TEMP_FILE" ]] || [[ ! -s "$TEMP_FILE" ]]; then
@@ -538,11 +550,6 @@ AUDIO_DIR="${TEMP_FILE%/*}"
538
550
  WRITE_LOCK_FILE="$AUDIO_DIR/$(basename "$TEMP_FILE" .wav).lock"
539
551
  touch "$WRITE_LOCK_FILE"
540
552
 
541
- # Get audio duration for proper lock timing
542
- DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$TEMP_FILE" 2>/dev/null)
543
- DURATION=${DURATION%.*} # Round to integer
544
- DURATION=${DURATION:-1} # Default to 1 second if detection fails
545
-
546
553
  # Get audio duration for proper lock timing
547
554
  DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$TEMP_FILE" 2>/dev/null || true)
548
555
  DURATION=${DURATION%.*} # Round to integer