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
|
@@ -60,6 +60,9 @@ fi
|
|
|
60
60
|
# CLAUDE_PROJECT_DIR takes priority so TUI preview writes to the project dir and
|
|
61
61
|
# audio-processor.sh (running from the package hooks dir) still finds the flag.
|
|
62
62
|
is_background_music_enabled() {
|
|
63
|
+
# Suppress music entirely when a test run is in progress
|
|
64
|
+
[[ -f "${HOME}/.agentvibes-tests-running" ]] && return 1
|
|
65
|
+
|
|
63
66
|
local enabled="" _f
|
|
64
67
|
# 1. Project dir (set by TUI preview via CLAUDE_PROJECT_DIR env var)
|
|
65
68
|
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
|
|
@@ -103,6 +106,47 @@ if ! command -v sox &> /dev/null; then
|
|
|
103
106
|
exit 0
|
|
104
107
|
fi
|
|
105
108
|
|
|
109
|
+
# @function _resolve_one_effect
|
|
110
|
+
# @intent Translate a single preset name to its sox effect string
|
|
111
|
+
# @param $1 Preset name (e.g. 'light', 'echo-short', 'off', 'off-reverb')
|
|
112
|
+
# @returns Sox effect string (empty for off/unknown-off variants)
|
|
113
|
+
_resolve_one_effect() {
|
|
114
|
+
case "${1:-}" in
|
|
115
|
+
light) printf '%s' "reverb 20 50 50" ;;
|
|
116
|
+
medium) printf '%s' "reverb 40 50 70" ;;
|
|
117
|
+
heavy) printf '%s' "reverb 70 50 100" ;;
|
|
118
|
+
cathedral) printf '%s' "reverb 90 30 100" ;;
|
|
119
|
+
chorus-light) printf '%s' "chorus 0.7 0.9 55 0.4 0.25 2 -t" ;;
|
|
120
|
+
chorus-deep) printf '%s' "chorus 0.8 0.9 55 0.4 0.25 2 -t chorus 0.8 0.9 44 0.4 0.2 2.3 -t" ;;
|
|
121
|
+
echo-short) printf '%s' "echo 0.8 0.6 60 0.4" ;;
|
|
122
|
+
echo-long) printf '%s' "echo 0.8 0.7 100 0.5 200 0.3" ;;
|
|
123
|
+
warm) printf '%s' "bass 5 reverb 30 50 60" ;;
|
|
124
|
+
radio) printf '%s' "highpass 300 treble 5 gain -3 overdrive 10 gain -3" ;;
|
|
125
|
+
off*|'') ;; # empty output — no effects
|
|
126
|
+
*) printf '%s' "$1" ;; # raw sox string passthrough
|
|
127
|
+
esac
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# @function _resolve_effect_value
|
|
131
|
+
# @intent Translate a preset value (single or '+'-combined) to sox effects string
|
|
132
|
+
# @param $1 Effect value, e.g. 'light', 'light+echo-short', 'off'
|
|
133
|
+
# @returns Full sox effects argument string (may be empty)
|
|
134
|
+
_resolve_effect_value() {
|
|
135
|
+
local _val="${1:-}"
|
|
136
|
+
if [[ "$_val" == *"+"* ]]; then
|
|
137
|
+
local _combined="" _part _resolved
|
|
138
|
+
IFS='+' read -ra _parts <<< "$_val"
|
|
139
|
+
for _part in "${_parts[@]}"; do
|
|
140
|
+
_part="${_part// /}"
|
|
141
|
+
_resolved=$(_resolve_one_effect "$_part")
|
|
142
|
+
[[ -n "$_resolved" ]] && _combined="${_combined:+$_combined }$_resolved"
|
|
143
|
+
done
|
|
144
|
+
printf '%s' "$_combined"
|
|
145
|
+
else
|
|
146
|
+
_resolve_one_effect "$_val"
|
|
147
|
+
fi
|
|
148
|
+
}
|
|
149
|
+
|
|
106
150
|
# @function get_agent_config
|
|
107
151
|
# @intent Parse audio-effects.cfg for agent-specific settings
|
|
108
152
|
# @param $1 Agent name
|
|
@@ -404,28 +448,15 @@ main() {
|
|
|
404
448
|
local _rest
|
|
405
449
|
IFS='|' read -r _ sox_effects background_file bg_volume _rest <<< "$config"
|
|
406
450
|
|
|
407
|
-
# Translate
|
|
408
|
-
# LLM per-agent rows store human-readable names like
|
|
409
|
-
# Raw sox strings (
|
|
410
|
-
|
|
411
|
-
light) sox_effects="reverb 20 50 50" ;;
|
|
412
|
-
medium) sox_effects="reverb 40 50 70" ;;
|
|
413
|
-
heavy) sox_effects="reverb 70 50 100" ;;
|
|
414
|
-
cathedral) sox_effects="reverb 90 30 100" ;;
|
|
415
|
-
off) sox_effects="" ;;
|
|
416
|
-
esac
|
|
451
|
+
# Translate preset names (single or '+'-combined) to sox effects strings.
|
|
452
|
+
# LLM per-agent rows store human-readable names like 'light' or 'light+echo-short';
|
|
453
|
+
# sox needs the raw effect string. Raw sox strings (BMAD agent rows) pass through.
|
|
454
|
+
sox_effects=$(_resolve_effect_value "${sox_effects:-}")
|
|
417
455
|
|
|
418
456
|
# Per-invocation reverb override (set by play-tts-enhanced.sh for profile-based reverb).
|
|
419
457
|
# Using an env var avoids permanently mutating audio-effects.cfg — process-scoped and race-free.
|
|
420
458
|
if [[ -n "${AGENTVIBES_REVERB_OVERRIDE:-}" ]]; then
|
|
421
|
-
|
|
422
|
-
light) sox_effects="reverb 20 50 50" ;;
|
|
423
|
-
medium) sox_effects="reverb 40 50 70" ;;
|
|
424
|
-
heavy) sox_effects="reverb 70 50 100" ;;
|
|
425
|
-
cathedral) sox_effects="reverb 90 30 100" ;;
|
|
426
|
-
off) sox_effects="" ;;
|
|
427
|
-
*) sox_effects="$AGENTVIBES_REVERB_OVERRIDE" ;; # raw sox string passthrough
|
|
428
|
-
esac
|
|
459
|
+
sox_effects=$(_resolve_effect_value "$AGENTVIBES_REVERB_OVERRIDE")
|
|
429
460
|
fi
|
|
430
461
|
|
|
431
462
|
# Per-agent background music override from bmad-speak.sh profile JSON (takes priority over cfg).
|
|
@@ -447,6 +478,14 @@ main() {
|
|
|
447
478
|
fi
|
|
448
479
|
fi
|
|
449
480
|
|
|
481
|
+
# When audio is explicitly enabled during test runs (AGENTVIBES_TEST_AUDIO=true
|
|
482
|
+
# passed to scripts/run-tests.sh), use a calmer test track so spooky/goa-trance
|
|
483
|
+
# music doesn't play. run-tests.sh sets AGENTVIBES_TEST_TRACK=flamenco by default;
|
|
484
|
+
# the user may override it with any other track filename.
|
|
485
|
+
if [[ -n "${AGENTVIBES_TEST_TRACK:-}" ]]; then
|
|
486
|
+
background_file="$AGENTVIBES_TEST_TRACK"
|
|
487
|
+
fi
|
|
488
|
+
|
|
450
489
|
# SECURITY: Use secure temp directory per CLAUDE.md guidelines
|
|
451
490
|
# Prefer XDG_RUNTIME_DIR (user-owned, restricted permissions)
|
|
452
491
|
# Fall back to user-specific directory in /tmp
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# File: .claude/hooks/kokoro-installer.sh
|
|
4
|
+
#
|
|
5
|
+
# AgentVibes - Kokoro TTS Installer
|
|
6
|
+
# Installs kokoro-onnx and dependencies for local neural TTS.
|
|
7
|
+
# Website: https://agentvibes.org
|
|
8
|
+
#
|
|
9
|
+
# Licensed under the Apache License, Version 2.0
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# ./kokoro-installer.sh # interactive
|
|
13
|
+
# ./kokoro-installer.sh --check # check only, no install
|
|
14
|
+
# ./kokoro-installer.sh --non-interactive # install without prompts
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
export LC_ALL=C
|
|
19
|
+
|
|
20
|
+
NON_INTERACTIVE=false
|
|
21
|
+
CHECK_ONLY=false
|
|
22
|
+
|
|
23
|
+
for arg in "$@"; do
|
|
24
|
+
case "$arg" in
|
|
25
|
+
--non-interactive|--yes|-y) NON_INTERACTIVE=true ;;
|
|
26
|
+
--check) CHECK_ONLY=true ;;
|
|
27
|
+
esac
|
|
28
|
+
done
|
|
29
|
+
|
|
30
|
+
REQUIRED_PACKAGES=("kokoro" "soundfile" "numpy")
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Check current installation state (use find_spec to avoid slow torch import)
|
|
34
|
+
check_kokoro() {
|
|
35
|
+
python3 -c "import importlib.util; exit(0 if importlib.util.find_spec('kokoro') else 1)" 2>/dev/null && \
|
|
36
|
+
python3 -c "import importlib.util; exit(0 if importlib.util.find_spec('soundfile') else 1)" 2>/dev/null && \
|
|
37
|
+
python3 -c "import importlib.util; exit(0 if importlib.util.find_spec('numpy') else 1)" 2>/dev/null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if check_kokoro; then
|
|
41
|
+
echo "✅ Kokoro TTS is already installed"
|
|
42
|
+
if [[ "$CHECK_ONLY" == true ]]; then exit 0; fi
|
|
43
|
+
echo ""
|
|
44
|
+
echo "Voice examples:"
|
|
45
|
+
echo " af_heart af_nova af_sky (American Female)"
|
|
46
|
+
echo " am_adam am_michael (American Male)"
|
|
47
|
+
echo " bf_emma bf_isabella (British Female)"
|
|
48
|
+
echo " bm_george bm_lewis (British Male)"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
if [[ "$CHECK_ONLY" == true ]]; then
|
|
53
|
+
echo "❌ Kokoro TTS is not installed"
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
echo "🎙️ Kokoro TTS Installer"
|
|
59
|
+
echo " High-quality local neural TTS — 60+ voices, 8+ languages"
|
|
60
|
+
echo " No API key needed. Runs 100% offline."
|
|
61
|
+
echo " Model: kokoro-onnx (~82MB download on first use)"
|
|
62
|
+
echo ""
|
|
63
|
+
|
|
64
|
+
if [[ "$NON_INTERACTIVE" != true ]]; then
|
|
65
|
+
read -p "Install Kokoro TTS? [Y/n]: " -r REPLY
|
|
66
|
+
REPLY="${REPLY:-y}"
|
|
67
|
+
if [[ ! "$REPLY" =~ ^[Yy] ]]; then
|
|
68
|
+
echo "Skipping Kokoro installation."
|
|
69
|
+
exit 0
|
|
70
|
+
fi
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Check Python is available
|
|
74
|
+
if ! command -v python3 &>/dev/null; then
|
|
75
|
+
echo "❌ Python 3 is required but not found in PATH" >&2
|
|
76
|
+
echo " Install with: sudo apt install python3 python3-pip" >&2
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
echo ""
|
|
81
|
+
echo "Installing: ${REQUIRED_PACKAGES[*]}"
|
|
82
|
+
echo ""
|
|
83
|
+
|
|
84
|
+
# Try pip install with PEP 668 fallback to pipx/user install
|
|
85
|
+
_install_pkgs() {
|
|
86
|
+
local pkgs=("$@")
|
|
87
|
+
if pip install "${pkgs[@]}" 2>/dev/null; then return 0; fi
|
|
88
|
+
if pip install --user "${pkgs[@]}" 2>/dev/null; then return 0; fi
|
|
89
|
+
if pip3 install "${pkgs[@]}" 2>/dev/null; then return 0; fi
|
|
90
|
+
if pip3 install --user "${pkgs[@]}" 2>/dev/null; then return 0; fi
|
|
91
|
+
if python3 -m pip install "${pkgs[@]}" 2>/dev/null; then return 0; fi
|
|
92
|
+
if python3 -m pip install --user "${pkgs[@]}" 2>/dev/null; then return 0; fi
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if _install_pkgs "${REQUIRED_PACKAGES[@]}"; then
|
|
97
|
+
echo ""
|
|
98
|
+
if check_kokoro; then
|
|
99
|
+
echo "✅ Kokoro TTS installed successfully!"
|
|
100
|
+
echo ""
|
|
101
|
+
echo "Voice examples:"
|
|
102
|
+
echo " af_heart af_nova af_sky (American Female)"
|
|
103
|
+
echo " am_adam am_michael (American Male)"
|
|
104
|
+
echo " bf_emma bf_isabella (British Female)"
|
|
105
|
+
echo " bm_george bm_lewis (British Male)"
|
|
106
|
+
echo ""
|
|
107
|
+
echo "Switch to Kokoro: /agent-vibes:provider switch kokoro"
|
|
108
|
+
else
|
|
109
|
+
echo "⚠️ Packages installed but import check failed." >&2
|
|
110
|
+
echo " Try: python3 -c 'import kokoro'" >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
else
|
|
114
|
+
echo "❌ Installation failed." >&2
|
|
115
|
+
echo " Try manually: pip install kokoro-onnx soundfile numpy" >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
@@ -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()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AgentVibes — Kokoro TTS synthesizer
|
|
4
|
+
Synthesizes text to a WAV file using the kokoro-onnx package.
|
|
5
|
+
Prints the output WAV path to stdout.
|
|
6
|
+
|
|
7
|
+
Usage: kokoro-tts.py <text> <voice> <output_path> [speed]
|
|
8
|
+
|
|
9
|
+
Voices (examples):
|
|
10
|
+
af_heart af_nova af_sky af_bella af_sarah af_nicole
|
|
11
|
+
am_adam am_michael
|
|
12
|
+
bf_emma bf_isabella
|
|
13
|
+
bm_george bm_lewis
|
|
14
|
+
|
|
15
|
+
Install: pip install kokoro-onnx soundfile numpy
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
# Kokoro/torch auto-selects CUDA whenever a GPU is visible, but many setups
|
|
23
|
+
# (notably Windows) fail GPU allocation with "CUDA error: unknown error /
|
|
24
|
+
# Memory allocation failure", which crashes synthesis after a long torch load.
|
|
25
|
+
# The 82M-param Kokoro model runs fine on CPU in a few seconds, so force CPU by
|
|
26
|
+
# default. Opt back into GPU with AGENTVIBES_KOKORO_DEVICE=cuda. This MUST run
|
|
27
|
+
# before torch/kokoro are imported so torch.cuda.is_available() sees no device.
|
|
28
|
+
_KOKORO_DEVICE = os.environ.get('AGENTVIBES_KOKORO_DEVICE', 'cpu').strip().lower()
|
|
29
|
+
if _KOKORO_DEVICE != 'cuda':
|
|
30
|
+
os.environ['CUDA_VISIBLE_DEVICES'] = ''
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
# --download-only: fetch voice .pt file from HuggingFace without synthesis
|
|
34
|
+
if len(sys.argv) >= 2 and sys.argv[1] == '--download-only':
|
|
35
|
+
voice = sys.argv[2] if len(sys.argv) > 2 else 'af_heart'
|
|
36
|
+
# Validate before the id flows into a HuggingFace filename — blocks path
|
|
37
|
+
# traversal / arbitrary repo paths in voices/<voice>.pt.
|
|
38
|
+
if not re.match(r'^[a-z]{2}_[a-z0-9_]+$', voice):
|
|
39
|
+
print(f"❌ Invalid voice id: {voice}", file=sys.stderr)
|
|
40
|
+
sys.exit(4)
|
|
41
|
+
try:
|
|
42
|
+
from huggingface_hub import hf_hub_download
|
|
43
|
+
local = hf_hub_download(repo_id='hexgrad/Kokoro-82M', filename=f'voices/{voice}.pt')
|
|
44
|
+
print(local)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"❌ Download failed: {e}", file=sys.stderr)
|
|
47
|
+
sys.exit(4)
|
|
48
|
+
sys.exit(0)
|
|
49
|
+
|
|
50
|
+
if len(sys.argv) < 4:
|
|
51
|
+
print("Usage: kokoro-tts.py <text> <voice> <output_path> [speed]", file=sys.stderr)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
text = sys.argv[1]
|
|
55
|
+
voice = sys.argv[2]
|
|
56
|
+
output_path = sys.argv[3]
|
|
57
|
+
try:
|
|
58
|
+
speed = float(sys.argv[4]) if len(sys.argv) > 4 else 1.0
|
|
59
|
+
except ValueError:
|
|
60
|
+
speed = 1.0
|
|
61
|
+
|
|
62
|
+
# Determine language code from voice prefix
|
|
63
|
+
# af/am = American English ('a'), bf/bm = British English ('b')
|
|
64
|
+
# jf/jm = Japanese ('j'), kf/km = Korean ('k'), etc.
|
|
65
|
+
prefix = voice[:2].lower() if len(voice) >= 2 else 'af'
|
|
66
|
+
lang_map = {
|
|
67
|
+
'af': 'a', 'am': 'a', # American English
|
|
68
|
+
'bf': 'b', 'bm': 'b', # British English
|
|
69
|
+
'jf': 'j', 'jm': 'j', # Japanese
|
|
70
|
+
'kf': 'k', 'km': 'k', # Korean
|
|
71
|
+
'zf': 'z', 'zm': 'z', # Mandarin
|
|
72
|
+
'ff': 'f', 'fm': 'f', # French
|
|
73
|
+
'hf': 'h', 'hm': 'h', # Hindi
|
|
74
|
+
'if': 'i', 'im': 'i', # Italian
|
|
75
|
+
'pf': 'p', 'pm': 'p', # Brazilian Portuguese
|
|
76
|
+
'ef': 'e', 'em': 'e', # Spanish (Spain)
|
|
77
|
+
'nf': 'n', 'nm': 'n', # Spanish (Latin America)
|
|
78
|
+
}
|
|
79
|
+
lang_code = lang_map.get(prefix, 'a')
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
from kokoro import KPipeline
|
|
83
|
+
except ImportError:
|
|
84
|
+
print("❌ kokoro-onnx not installed. Run: pip install kokoro-onnx soundfile numpy", file=sys.stderr)
|
|
85
|
+
sys.exit(2)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
import numpy as np
|
|
89
|
+
import soundfile as sf
|
|
90
|
+
except ImportError:
|
|
91
|
+
print("❌ soundfile/numpy not installed. Run: pip install soundfile numpy", file=sys.stderr)
|
|
92
|
+
sys.exit(2)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Pass device explicitly when supported; fall back for older kokoro
|
|
96
|
+
# versions whose KPipeline has no `device` kwarg (CUDA_VISIBLE_DEVICES
|
|
97
|
+
# already forces CPU in that case).
|
|
98
|
+
try:
|
|
99
|
+
pipeline = KPipeline(lang_code=lang_code, device=_KOKORO_DEVICE if _KOKORO_DEVICE == 'cuda' else 'cpu')
|
|
100
|
+
except TypeError:
|
|
101
|
+
pipeline = KPipeline(lang_code=lang_code)
|
|
102
|
+
generator = pipeline(text, voice=voice, speed=speed)
|
|
103
|
+
|
|
104
|
+
all_samples = []
|
|
105
|
+
sample_rate = 24000 # kokoro always outputs 24 kHz
|
|
106
|
+
|
|
107
|
+
for result in generator:
|
|
108
|
+
# kokoro >=0.9 yields a KPipeline.Result whose waveform is the
|
|
109
|
+
# `.audio` attribute (a torch.Tensor); older builds yield a
|
|
110
|
+
# (graphemes, phonemes, audio) tuple. Prefer `.audio`, then fall
|
|
111
|
+
# back to the last tuple element.
|
|
112
|
+
audio = getattr(result, "audio", None)
|
|
113
|
+
if audio is None:
|
|
114
|
+
try:
|
|
115
|
+
audio = result[-1]
|
|
116
|
+
except (TypeError, IndexError, KeyError):
|
|
117
|
+
audio = None
|
|
118
|
+
if audio is None:
|
|
119
|
+
continue
|
|
120
|
+
# Tensor -> numpy, force 1-D so np.concatenate never sees a 0-d array
|
|
121
|
+
if hasattr(audio, "detach"):
|
|
122
|
+
audio = audio.detach().cpu().numpy()
|
|
123
|
+
audio = np.asarray(audio).reshape(-1)
|
|
124
|
+
if audio.size > 0:
|
|
125
|
+
all_samples.append(audio)
|
|
126
|
+
|
|
127
|
+
if not all_samples:
|
|
128
|
+
print("❌ Kokoro returned no audio samples", file=sys.stderr)
|
|
129
|
+
sys.exit(3)
|
|
130
|
+
|
|
131
|
+
combined = np.concatenate(all_samples)
|
|
132
|
+
sf.write(output_path, combined, sample_rate)
|
|
133
|
+
print(output_path)
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f"❌ Kokoro synthesis failed: {e}", file=sys.stderr)
|
|
137
|
+
sys.exit(3)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == '__main__':
|
|
141
|
+
main()
|
|
@@ -48,25 +48,48 @@ source "$SCRIPT_DIR/piper-voice-manager.sh"
|
|
|
48
48
|
|
|
49
49
|
# Parse command line arguments
|
|
50
50
|
AUTO_YES=false
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
INCLUDE_LIBRITTS=false
|
|
52
|
+
for arg in "$@"; do
|
|
53
|
+
case "$arg" in
|
|
54
|
+
--yes|-y) AUTO_YES=true ;;
|
|
55
|
+
--libritts) INCLUDE_LIBRITTS=true ;;
|
|
56
|
+
esac
|
|
57
|
+
done
|
|
54
58
|
|
|
55
59
|
# Common voice models to download
|
|
60
|
+
# Includes all models required by voice-assignments.json (voices-tab curated voices)
|
|
61
|
+
# plus BMAD agent voices.
|
|
56
62
|
COMMON_VOICES=(
|
|
57
|
-
|
|
58
|
-
"en_US-
|
|
59
|
-
"en_US-
|
|
60
|
-
"
|
|
61
|
-
"en_US-
|
|
62
|
-
"en_US-
|
|
63
|
-
"en_US-
|
|
64
|
-
"en_US-
|
|
65
|
-
"en_US-
|
|
66
|
-
"en_US-
|
|
67
|
-
"
|
|
63
|
+
# --- Voices-tab curated voices (voice-assignments.json: curated_voices) ---
|
|
64
|
+
"en_US-ryan-high" # Ryan — expressive male (30MB)
|
|
65
|
+
"en_US-joe-medium" # Joe — natural male (13MB)
|
|
66
|
+
"en_GB-alan-medium" # Alan — British male (13MB)
|
|
67
|
+
"en_US-kusal-medium" # Marcus — male (13MB)
|
|
68
|
+
"en_US-john-medium" # John — male (13MB)
|
|
69
|
+
"en_US-lessac-high" # Katherine — clear female high-quality (30MB)
|
|
70
|
+
"en_US-ljspeech-high" # Linda — female high-quality (30MB)
|
|
71
|
+
"en_US-amy-medium" # Amy — warm female (13MB)
|
|
72
|
+
"en_US-kristin-medium" # Kristin — female (13MB)
|
|
73
|
+
"en_GB-southern_english_female-low" # Charlotte — British female (13MB)
|
|
74
|
+
# --- Additional BMAD agent voices ---
|
|
75
|
+
"en_US-hfc_female-medium" # Professional female (13MB) - BMAD: Amelia (dev)
|
|
76
|
+
"en_US-lessac-medium" # Clear female (13MB) - BMAD: Murat (tea)
|
|
77
|
+
"en_US-danny-low" # Calm male (13MB) - BMAD: Winston (architect)
|
|
78
|
+
"en_US-bryce-medium" # Professional male (13MB) - BMAD: Bob (sm)
|
|
79
|
+
"en_US-kathleen-low" # Clear female (13MB) - BMAD: Paige (tech-writer)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# LibriTTS voices — 904 named speakers, opt-in due to size (~114MB total)
|
|
83
|
+
LIBRITTS_VOICES=(
|
|
84
|
+
"en_US-libritts-high" # 904 named speakers — premium quality (57MB)
|
|
85
|
+
"en_US-libritts_r-medium" # Premium male (57MB) - BMAD: BMad Master
|
|
68
86
|
)
|
|
69
87
|
|
|
88
|
+
# Merge libritts into the download list if requested
|
|
89
|
+
if [[ "$INCLUDE_LIBRITTS" == "true" ]]; then
|
|
90
|
+
COMMON_VOICES+=("${LIBRITTS_VOICES[@]}")
|
|
91
|
+
fi
|
|
92
|
+
|
|
70
93
|
echo "🎙️ Piper Voice Model Downloader"
|
|
71
94
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
72
95
|
echo ""
|
|
@@ -155,14 +178,14 @@ for voice in "${NEED_DOWNLOAD[@]}"; do
|
|
|
155
178
|
echo "📥 Downloading: $voice..."
|
|
156
179
|
|
|
157
180
|
if download_voice "$voice"; then
|
|
158
|
-
((DOWNLOADED
|
|
181
|
+
DOWNLOADED=$((DOWNLOADED + 1))
|
|
159
182
|
voice_path="$VOICE_DIR/${voice}.onnx"
|
|
160
183
|
file_size=$(du -h "$voice_path" 2>/dev/null | cut -f1)
|
|
161
184
|
echo " ✓ Downloaded: $voice"
|
|
162
185
|
echo " 📁 Path: $voice_path"
|
|
163
186
|
echo " 📦 Size: $file_size"
|
|
164
187
|
else
|
|
165
|
-
((FAILED
|
|
188
|
+
FAILED=$((FAILED + 1))
|
|
166
189
|
echo " ✗ Failed: $voice"
|
|
167
190
|
fi
|
|
168
191
|
done
|