agentvibes 5.10.1 → 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 +15 -1
- package/.agentvibes/install-manifest.json +342 -0
- 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/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg +5 -4
- package/.claude/config/audio-effects.cfg.sample +52 -52
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/config/background-music-position.txt +27 -0
- package/.claude/config/background-music-volume.txt +1 -0
- package/.claude/config/background-music.cfg +1 -0
- package/.claude/config/background-music.txt +1 -0
- package/.claude/config/language.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/config/reverb-level.txt +1 -0
- package/.claude/config/tts-speech-rate.txt +1 -0
- package/.claude/config/tts-verbosity.txt +1 -0
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-cache-utils.sh +0 -0
- package/.claude/hooks/audio-processor.sh +57 -18
- package/.claude/hooks/background-music-manager.sh +0 -0
- package/.claude/hooks/bmad-party-speak.sh +0 -0
- package/.claude/hooks/bmad-speak-enhanced.sh +0 -0
- package/.claude/hooks/bmad-speak.sh +0 -0
- package/.claude/hooks/bmad-tts-injector.sh +0 -0
- package/.claude/hooks/bmad-voice-manager.sh +0 -0
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +0 -0
- package/.claude/hooks/clawdbot-receiver.sh +0 -0
- package/.claude/hooks/clean-audio-cache.sh +0 -0
- package/.claude/hooks/cleanup-cache.sh +0 -0
- package/.claude/hooks/configure-rdp-mode.sh +0 -0
- package/.claude/hooks/download-extra-voices.sh +0 -0
- package/.claude/hooks/effects-manager.sh +0 -0
- package/.claude/hooks/github-star-reminder.sh +0 -0
- 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/language-manager.sh +0 -0
- package/.claude/hooks/learn-manager.sh +0 -0
- package/.claude/hooks/macos-voice-manager.sh +0 -0
- package/.claude/hooks/migrate-background-music.sh +0 -0
- package/.claude/hooks/migrate-to-agentvibes.sh +0 -0
- package/.claude/hooks/optimize-background-music.sh +0 -0
- package/.claude/hooks/path-resolver.sh +0 -0
- package/.claude/hooks/personality-manager.sh +0 -0
- package/.claude/hooks/piper-download-voices.sh +39 -16
- package/.claude/hooks/piper-installer.sh +11 -6
- package/.claude/hooks/piper-multispeaker-registry.sh +0 -0
- 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-agentvibes-receiver.sh +1 -0
- package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
- package/.claude/hooks/play-tts-enhanced.sh +0 -0
- package/.claude/hooks/play-tts-kokoro.sh +127 -0
- package/.claude/hooks/play-tts-macos.sh +0 -0
- package/.claude/hooks/play-tts-piper.sh +0 -0
- package/.claude/hooks/play-tts-soprano.sh +0 -0
- 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-windows-receiver.sh +0 -0
- package/.claude/hooks/play-tts.sh +78 -2
- package/.claude/hooks/prepare-release.sh +0 -0
- package/.claude/hooks/provider-commands.sh +71 -22
- package/.claude/hooks/provider-manager.sh +15 -7
- package/.claude/hooks/replay-target-audio.sh +0 -0
- package/.claude/hooks/requirements.txt +6 -6
- package/.claude/hooks/sentiment-manager.sh +0 -0
- package/.claude/hooks/session-start-tts.sh +0 -0
- package/.claude/hooks/soprano-gradio-synth.py +139 -139
- package/.claude/hooks/speed-manager.sh +0 -0
- package/.claude/hooks/stop-tts.sh +0 -0
- package/.claude/hooks/termux-installer.sh +0 -0
- package/.claude/hooks/translate-manager.sh +0 -0
- package/.claude/hooks/translator.py +237 -237
- package/.claude/hooks/tts-queue-worker.sh +0 -0
- package/.claude/hooks/tts-queue.sh +0 -0
- package/.claude/hooks/verbosity-manager.sh +0 -0
- package/.claude/hooks/voice-manager.sh +0 -0
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -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.ps1 +29 -1
- package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
- package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
- package/.claude/piper-voices-dir.txt +1 -0
- package/.claude/verbosity.txt +1 -1
- package/.clawdbot/README.md +105 -105
- package/.mcp.json +6 -19
- package/README.md +12 -86
- package/RELEASE_NOTES.md +60 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/agentvibes-voice-browser.js +0 -0
- package/bin/agentvibes.js +0 -0
- package/bin/mcp-server.js +121 -121
- package/bin/mcp-server.sh +0 -0
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/WINDOWS_SETUP.md +0 -0
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/install-deps.js +0 -0
- package/mcp-server/server.py +1807 -1807
- package/mcp-server/test_server.py +0 -0
- package/package.json +4 -3
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/commands/install-mcp.js +3 -3
- package/src/console/app.js +25 -9
- package/src/console/audio-env.js +85 -1
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/navigation.js +4 -0
- package/src/console/tabs/agents-tab.js +12 -6
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/music-tab.js +7 -25
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/setup-tab.js +1704 -120
- package/src/console/tabs/voices-tab.js +72 -88
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/format-utils.js +14 -2
- package/src/console/widgets/help-bar.js +55 -0
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/reverb-picker.js +429 -41
- package/src/console/widgets/track-picker.js +60 -51
- package/src/i18n/de.js +2 -1
- package/src/i18n/en.js +1 -0
- package/src/i18n/es.js +2 -1
- package/src/i18n/fr.js +2 -1
- package/src/i18n/hi.js +2 -1
- package/src/i18n/ja.js +2 -1
- package/src/i18n/ko.js +2 -1
- package/src/i18n/pt.js +2 -1
- package/src/i18n/strings.js +9 -9
- package/src/i18n/zh-CN.js +2 -1
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +95 -7
- package/src/services/config-service.js +264 -264
- package/src/services/language-service.js +47 -47
- package/src/services/provider-service.js +35 -0
- package/src/services/provider-voice-catalog.js +126 -0
- package/src/services/tts-engine-service.js +51 -2
- package/src/utils/audio-duration-validator.js +53 -10
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -136
- package/src/utils/provider-validator.js +70 -0
- package/src/utils/secure-music-storage.js +412 -412
- package/templates/agentvibes-receiver.sh +235 -231
- package/templates/audio/welcome-music.mp3 +0 -0
|
@@ -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,107 @@
|
|
|
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
|
+
|
|
21
|
+
def main():
|
|
22
|
+
if len(sys.argv) < 4:
|
|
23
|
+
print("Usage: kokoro-tts.py <text> <voice> <output_path> [speed]", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
text = sys.argv[1]
|
|
27
|
+
voice = sys.argv[2]
|
|
28
|
+
output_path = sys.argv[3]
|
|
29
|
+
try:
|
|
30
|
+
speed = float(sys.argv[4]) if len(sys.argv) > 4 else 1.0
|
|
31
|
+
except ValueError:
|
|
32
|
+
speed = 1.0
|
|
33
|
+
|
|
34
|
+
# Determine language code from voice prefix
|
|
35
|
+
# af/am = American English ('a'), bf/bm = British English ('b')
|
|
36
|
+
# jf/jm = Japanese ('j'), kf/km = Korean ('k'), etc.
|
|
37
|
+
prefix = voice[:2].lower() if len(voice) >= 2 else 'af'
|
|
38
|
+
lang_map = {
|
|
39
|
+
'af': 'a', 'am': 'a', # American English
|
|
40
|
+
'bf': 'b', 'bm': 'b', # British English
|
|
41
|
+
'jf': 'j', 'jm': 'j', # Japanese
|
|
42
|
+
'kf': 'k', 'km': 'k', # Korean
|
|
43
|
+
'zf': 'z', 'zm': 'z', # Mandarin
|
|
44
|
+
'ff': 'f', 'fm': 'f', # French
|
|
45
|
+
'hf': 'h', 'hm': 'h', # Hindi
|
|
46
|
+
'if': 'i', 'im': 'i', # Italian
|
|
47
|
+
'pf': 'p', 'pm': 'p', # Brazilian Portuguese
|
|
48
|
+
'ef': 'e', 'em': 'e', # Spanish (Spain)
|
|
49
|
+
'nf': 'n', 'nm': 'n', # Spanish (Latin America)
|
|
50
|
+
}
|
|
51
|
+
lang_code = lang_map.get(prefix, 'a')
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from kokoro import KPipeline
|
|
55
|
+
except ImportError:
|
|
56
|
+
print("❌ kokoro-onnx not installed. Run: pip install kokoro-onnx soundfile numpy", file=sys.stderr)
|
|
57
|
+
sys.exit(2)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
import numpy as np
|
|
61
|
+
import soundfile as sf
|
|
62
|
+
except ImportError:
|
|
63
|
+
print("❌ soundfile/numpy not installed. Run: pip install soundfile numpy", file=sys.stderr)
|
|
64
|
+
sys.exit(2)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
pipeline = KPipeline(lang_code=lang_code)
|
|
68
|
+
generator = pipeline(text, voice=voice, speed=speed)
|
|
69
|
+
|
|
70
|
+
all_samples = []
|
|
71
|
+
sample_rate = 24000 # kokoro always outputs 24 kHz
|
|
72
|
+
|
|
73
|
+
for result in generator:
|
|
74
|
+
# kokoro >=0.9 yields a KPipeline.Result whose waveform is the
|
|
75
|
+
# `.audio` attribute (a torch.Tensor); older builds yield a
|
|
76
|
+
# (graphemes, phonemes, audio) tuple. Prefer `.audio`, then fall
|
|
77
|
+
# back to the last tuple element.
|
|
78
|
+
audio = getattr(result, "audio", None)
|
|
79
|
+
if audio is None:
|
|
80
|
+
try:
|
|
81
|
+
audio = result[-1]
|
|
82
|
+
except (TypeError, IndexError, KeyError):
|
|
83
|
+
audio = None
|
|
84
|
+
if audio is None:
|
|
85
|
+
continue
|
|
86
|
+
# Tensor -> numpy, force 1-D so np.concatenate never sees a 0-d array
|
|
87
|
+
if hasattr(audio, "detach"):
|
|
88
|
+
audio = audio.detach().cpu().numpy()
|
|
89
|
+
audio = np.asarray(audio).reshape(-1)
|
|
90
|
+
if audio.size > 0:
|
|
91
|
+
all_samples.append(audio)
|
|
92
|
+
|
|
93
|
+
if not all_samples:
|
|
94
|
+
print("❌ Kokoro returned no audio samples", file=sys.stderr)
|
|
95
|
+
sys.exit(3)
|
|
96
|
+
|
|
97
|
+
combined = np.concatenate(all_samples)
|
|
98
|
+
sf.write(output_path, combined, sample_rate)
|
|
99
|
+
print(output_path)
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
print(f"❌ Kokoro synthesis failed: {e}", file=sys.stderr)
|
|
103
|
+
sys.exit(3)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == '__main__':
|
|
107
|
+
main()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#
|
|
2
|
+
# File: .claude/hooks-windows/play-tts-kokoro.ps1
|
|
3
|
+
#
|
|
4
|
+
# AgentVibes - Windows Kokoro TTS Provider
|
|
5
|
+
# High-quality local neural TTS via the `kokoro` Python package (KPipeline).
|
|
6
|
+
# Synthesizes text to a WAV file with kokoro-tts.py and emits the path so the
|
|
7
|
+
# parent play-tts.ps1 router coordinates playback / effects / background music.
|
|
8
|
+
#
|
|
9
|
+
# Contract (mirrors play-tts-piper.ps1):
|
|
10
|
+
# - Receives $Text and optional $VoiceOverride (a Kokoro voice id, e.g. af_heart)
|
|
11
|
+
# - Synthesizes a WAV, optionally applies per-agent effects via audio-processor.ps1
|
|
12
|
+
# - Does NOT play audio; emits the final WAV path via Write-Output for the parent
|
|
13
|
+
#
|
|
14
|
+
# Install deps: pip install kokoro soundfile numpy
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
param(
|
|
18
|
+
[Parameter(Mandatory = $true)]
|
|
19
|
+
[string]$Text,
|
|
20
|
+
|
|
21
|
+
[Parameter(Mandatory = $false)]
|
|
22
|
+
[string]$VoiceOverride
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# --- Configuration paths (mirror play-tts-piper.ps1) -------------------------
|
|
26
|
+
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
27
|
+
$ProjectClaudeDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptPath)) ".claude"
|
|
28
|
+
if (Test-Path $ProjectClaudeDir) { $ClaudeDir = $ProjectClaudeDir } else { $ClaudeDir = "$env:USERPROFILE\.claude" }
|
|
29
|
+
|
|
30
|
+
$AudioDir = "$ClaudeDir\audio"
|
|
31
|
+
$ConfigDir = "$ClaudeDir\config"
|
|
32
|
+
if (-not (Test-Path $AudioDir)) { New-Item -ItemType Directory -Path $AudioDir -Force | Out-Null }
|
|
33
|
+
|
|
34
|
+
# --- Locate the kokoro synth helper (lives next to this script) --------------
|
|
35
|
+
$KokoroPy = Join-Path $ScriptPath "kokoro-tts.py"
|
|
36
|
+
if (-not (Test-Path $KokoroPy)) {
|
|
37
|
+
Write-Host "[ERROR] kokoro-tts.py not found at: $KokoroPy" -ForegroundColor Red
|
|
38
|
+
exit 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# --- Resolve the Python executable -------------------------------------------
|
|
42
|
+
# SSH-receiver watcher sessions can inherit a minimal PATH; refresh from the
|
|
43
|
+
# registry if the first lookup fails (same pattern as play-tts-piper.ps1).
|
|
44
|
+
function Find-Python {
|
|
45
|
+
foreach ($cand in @("python", "python3", "py")) {
|
|
46
|
+
$c = Get-Command $cand -ErrorAction SilentlyContinue
|
|
47
|
+
if ($c) { return $c.Source }
|
|
48
|
+
}
|
|
49
|
+
return $null
|
|
50
|
+
}
|
|
51
|
+
$PythonExe = Find-Python
|
|
52
|
+
if (-not $PythonExe) {
|
|
53
|
+
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" +
|
|
54
|
+
[System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
55
|
+
$PythonExe = Find-Python
|
|
56
|
+
}
|
|
57
|
+
if (-not $PythonExe) {
|
|
58
|
+
Write-Host "[ERROR] Python not found. Install Python 3, then: pip install kokoro soundfile numpy" -ForegroundColor Red
|
|
59
|
+
exit 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# --- Determine the Kokoro voice ----------------------------------------------
|
|
63
|
+
$VoiceName = ""
|
|
64
|
+
if ($VoiceOverride) {
|
|
65
|
+
$VoiceName = $VoiceOverride.Trim()
|
|
66
|
+
} else {
|
|
67
|
+
$VoiceFile = "$ClaudeDir\tts-voice-kokoro.txt"
|
|
68
|
+
if (-not (Test-Path $VoiceFile)) { $VoiceFile = "$ClaudeDir\tts-voice.txt" }
|
|
69
|
+
if (Test-Path $VoiceFile) { $VoiceName = (Get-Content $VoiceFile -Raw).Trim() }
|
|
70
|
+
}
|
|
71
|
+
# Kokoro voice ids look like af_heart, am_michael, bf_emma. Strip any ::display suffix.
|
|
72
|
+
if ($VoiceName -match '::') { $VoiceName = ($VoiceName -split '::')[0] }
|
|
73
|
+
if (-not $VoiceName) { $VoiceName = "af_heart" }
|
|
74
|
+
|
|
75
|
+
# Security: validate voice id (lowercase prefix + underscore + name).
|
|
76
|
+
# Rejects path-traversal / injection before it reaches the command line.
|
|
77
|
+
if ($VoiceName -notmatch '^[a-z]{2}_[a-z0-9_]+$') {
|
|
78
|
+
Write-Host "[WARNING] Invalid Kokoro voice '$VoiceName' - falling back to af_heart" -ForegroundColor Yellow
|
|
79
|
+
$VoiceName = "af_heart"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# --- Optional speed ----------------------------------------------------------
|
|
83
|
+
$Speed = "1.0"
|
|
84
|
+
$SpeedFile = "$ConfigDir\tts-speed.txt"
|
|
85
|
+
if (Test-Path $SpeedFile) {
|
|
86
|
+
$s = (Get-Content $SpeedFile -Raw).Trim()
|
|
87
|
+
if ($s -match '^\d+(\.\d+)?$') { $Speed = $s }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# --- Sanitize text -----------------------------------------------------------
|
|
91
|
+
$Text = $Text -replace '\\', ' '
|
|
92
|
+
$Text = $Text -replace '[{}<>|`~^;]', ''
|
|
93
|
+
$Text = $Text -replace '\s+', ' '
|
|
94
|
+
$Text = $Text.Trim()
|
|
95
|
+
if (-not $Text) { Write-Host "[ERROR] No text to synthesize" -ForegroundColor Red; exit 1 }
|
|
96
|
+
|
|
97
|
+
# --- Output path (random name, no predictable timestamp; issue #130) ----------
|
|
98
|
+
$AudioFile = "$AudioDir\tts-$([System.IO.Path]::GetRandomFileName() -replace '\..*').wav"
|
|
99
|
+
|
|
100
|
+
# --- Persistent daemon configuration -----------------------------------------
|
|
101
|
+
# A resident kokoro-server keeps the model loaded on the GPU so each request is
|
|
102
|
+
# ~2-3s (just synthesis) instead of ~31s (fresh model load + CUDA init per call).
|
|
103
|
+
# Fast path: POST to the daemon. Fallback: auto-start the daemon for next time,
|
|
104
|
+
# and synthesize this one message directly so nothing is dropped while it warms.
|
|
105
|
+
$KokoroPort = 7855
|
|
106
|
+
$KokoroServerPy = Join-Path $ScriptPath "kokoro-server.py"
|
|
107
|
+
|
|
108
|
+
function Test-KokoroServer {
|
|
109
|
+
try {
|
|
110
|
+
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$KokoroPort/health" -TimeoutSec 2 -UseBasicParsing -ErrorAction Stop
|
|
111
|
+
return $r.StatusCode -eq 200
|
|
112
|
+
} catch { return $false }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Start-KokoroServer {
|
|
116
|
+
if (-not (Test-Path $KokoroServerPy)) { return }
|
|
117
|
+
# Prefer pythonw.exe (no console window); fall back to python.exe hidden.
|
|
118
|
+
$pythonw = $PythonExe -replace 'python\.exe$', 'pythonw.exe'
|
|
119
|
+
$exe = if (Test-Path $pythonw) { $pythonw } else { $PythonExe }
|
|
120
|
+
try {
|
|
121
|
+
Start-Process -FilePath $exe -ArgumentList @($KokoroServerPy, "$KokoroPort") `
|
|
122
|
+
-WindowStyle Hidden -ErrorAction Stop | Out-Null
|
|
123
|
+
} catch {
|
|
124
|
+
Write-Host "[WARNING] Could not auto-start kokoro-server: $_" -ForegroundColor Yellow
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# --- Synthesize --------------------------------------------------------------
|
|
129
|
+
try {
|
|
130
|
+
$usedServer = $false
|
|
131
|
+
if (Test-KokoroServer) {
|
|
132
|
+
Write-Host "[SYNTH] Synthesizing with Kokoro daemon (voice=$VoiceName speed=$Speed)..." -ForegroundColor Cyan
|
|
133
|
+
try {
|
|
134
|
+
$payload = @{ text = $Text; voice = $VoiceName; speed = [double]$Speed; output = $AudioFile } | ConvertTo-Json -Compress
|
|
135
|
+
$resp = Invoke-WebRequest -Uri "http://127.0.0.1:$KokoroPort/synth" -Method POST `
|
|
136
|
+
-Body $payload -ContentType 'application/json' -TimeoutSec 120 -UseBasicParsing -ErrorAction Stop
|
|
137
|
+
if ($resp.StatusCode -eq 200 -and (Test-Path $AudioFile) -and (Get-Item $AudioFile).Length -gt 0) {
|
|
138
|
+
$usedServer = $true
|
|
139
|
+
} else {
|
|
140
|
+
Write-Host "[WARNING] Kokoro daemon returned no audio; falling back to direct synth" -ForegroundColor Yellow
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
Write-Host "[WARNING] Kokoro daemon request failed ($_); falling back to direct synth" -ForegroundColor Yellow
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (-not $usedServer) {
|
|
148
|
+
# Daemon not up (or failed): start it for subsequent messages, then
|
|
149
|
+
# synthesize this one directly so the current message is never dropped.
|
|
150
|
+
Start-KokoroServer
|
|
151
|
+
Write-Host "[SYNTH] Synthesizing with Kokoro direct (voice=$VoiceName speed=$Speed)..." -ForegroundColor Cyan
|
|
152
|
+
$synthOut = & $PythonExe $KokoroPy $Text $VoiceName $AudioFile $Speed 2>&1
|
|
153
|
+
if (-not (Test-Path $AudioFile) -or (Get-Item $AudioFile).Length -eq 0) {
|
|
154
|
+
Write-Host "[ERROR] Kokoro synthesis failed" -ForegroundColor Red
|
|
155
|
+
if ($synthOut) { $synthOut | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } }
|
|
156
|
+
exit 1
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
Write-Host "[OK] Saved to: $AudioFile" -ForegroundColor Green
|
|
161
|
+
Write-Host "[VOICE] Voice used: $VoiceName (Kokoro TTS)" -ForegroundColor Green
|
|
162
|
+
|
|
163
|
+
# Apply per-agent audio effects via audio-processor.ps1, but SKIP when
|
|
164
|
+
# AGENTVIBES_NO_PLAY is set - that means the parent play-tts.ps1 will do its
|
|
165
|
+
# own reverb / background-music post-processing and running effects here too
|
|
166
|
+
# would double-process the audio.
|
|
167
|
+
$ProcessorScript = Join-Path $ScriptPath "audio-processor.ps1"
|
|
168
|
+
$ProcessedFile = $AudioFile
|
|
169
|
+
if (-not $env:AGENTVIBES_NO_PLAY -and (Test-Path $ProcessorScript)) {
|
|
170
|
+
$AgentName = if ($env:AGENTVIBES_AGENT_NAME) { $env:AGENTVIBES_AGENT_NAME } elseif ($env:AGENTVIBES_LLM_KEY) { $env:AGENTVIBES_LLM_KEY } else { "default" }
|
|
171
|
+
$EffectedFile = "$AudioFile.effected.wav"
|
|
172
|
+
try {
|
|
173
|
+
& $ProcessorScript $AudioFile $AgentName $EffectedFile
|
|
174
|
+
if ((Test-Path $EffectedFile) -and (Get-Item $EffectedFile).Length -gt 0) {
|
|
175
|
+
$ProcessedFile = $EffectedFile
|
|
176
|
+
Write-Host "[EFFECTS] Audio effects applied" -ForegroundColor Cyan
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
Write-Host "[WARNING] Audio effects processing skipped: $_" -ForegroundColor Yellow
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Emit the final path for the parent router to coordinate playback.
|
|
184
|
+
# DO NOT play here - play-tts.ps1 owns all audio playback.
|
|
185
|
+
Write-Host "[OUTPUT] Processed audio: $ProcessedFile" -ForegroundColor Gray
|
|
186
|
+
Write-Output $ProcessedFile
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
Write-Host "[ERROR] Error running Kokoro: $_" -ForegroundColor Red
|
|
190
|
+
exit 1
|
|
191
|
+
}
|
|
@@ -102,6 +102,9 @@ switch ($ActiveProvider) {
|
|
|
102
102
|
"soprano" {
|
|
103
103
|
$ProviderScript = "$HooksDir\play-tts-soprano.ps1"
|
|
104
104
|
}
|
|
105
|
+
"kokoro" {
|
|
106
|
+
$ProviderScript = "$HooksDir\play-tts-kokoro.ps1"
|
|
107
|
+
}
|
|
105
108
|
default {
|
|
106
109
|
Write-Host "[ERROR] Unknown provider: $ActiveProvider" -ForegroundColor Red
|
|
107
110
|
Write-Host "Use: .\provider-manager.ps1 list" -ForegroundColor Yellow
|
|
@@ -126,6 +129,7 @@ if ($ProviderOverride) {
|
|
|
126
129
|
if (-not (Test-Path $ProviderScript)) { $ProviderScript = "$HooksDir\play-tts-windows-sapi.ps1" }
|
|
127
130
|
}
|
|
128
131
|
"soprano" { $ProviderScript = "$HooksDir\play-tts-soprano.ps1" }
|
|
132
|
+
"kokoro" { $ProviderScript = "$HooksDir\play-tts-kokoro.ps1" }
|
|
129
133
|
default {
|
|
130
134
|
Write-Host "[WARNING] play-tts.ps1: Unknown ProviderOverride '$ProviderOverride' ignored" -ForegroundColor Yellow
|
|
131
135
|
}
|
|
@@ -382,7 +386,25 @@ if ($OverrideEffects -ne "" -and $OverrideEffects -in @("off", "light", "medium"
|
|
|
382
386
|
# Transport providers (ssh-remote etc.) are not listed because they forward
|
|
383
387
|
# TTS to a remote host — overriding with a local engine would synthesize on
|
|
384
388
|
# the wrong machine.
|
|
385
|
-
|
|
389
|
+
#
|
|
390
|
+
# Voice/engine coupling guard: Kokoro voice ids follow a strict
|
|
391
|
+
# "<2-letter prefix>_<name>" pattern where the 2nd char is the gender (f/m)
|
|
392
|
+
# — e.g. af_river, am_eric, bf_emma, jf_alpha. They are all-lowercase with no
|
|
393
|
+
# locale, hyphen, digit, or "::" multi-speaker separator that Piper/LibriTTS
|
|
394
|
+
# voices always carry, so the pattern can never match a Piper voice. Such a
|
|
395
|
+
# voice can ONLY be synthesised by the Kokoro engine. When the active voice is
|
|
396
|
+
# a Kokoro voice (e.g. the Kokoro voice picker preview, which sends
|
|
397
|
+
# provider=kokoro + a Kokoro voice), the per-LLM ENGINE column — which may name
|
|
398
|
+
# piper/sapi for that LLM's normal text responses that use Piper voices — must
|
|
399
|
+
# NOT redirect it to an incompatible engine, or synthesis fails silently
|
|
400
|
+
# (Piper can't find the Kokoro voice model → no audio, exit 0).
|
|
401
|
+
$_VoiceIsKokoro = $VoiceOverride -match '^[a-z]{2}_[a-z0-9_]+$'
|
|
402
|
+
if ($_VoiceIsKokoro) {
|
|
403
|
+
# A Kokoro-format voice forces the Kokoro engine regardless of the per-LLM
|
|
404
|
+
# ENGINE column or the global tts-provider.txt default.
|
|
405
|
+
$ProviderScript = "$HooksDir\play-tts-kokoro.ps1"
|
|
406
|
+
}
|
|
407
|
+
elseif ($_LlmEngine) {
|
|
386
408
|
# Accept both canonical Windows names and the cross-platform aliases the TUI
|
|
387
409
|
# writes (e.g. "piper" saved on a Linux/WSL install that is later read on
|
|
388
410
|
# Windows, or "sapi" as a short form). Unknown values keep the global default.
|
|
@@ -398,6 +420,7 @@ if ($_LlmEngine) {
|
|
|
398
420
|
if (-not (Test-Path $ProviderScript)) { $ProviderScript = "$HooksDir\play-tts-windows-piper.ps1" }
|
|
399
421
|
}
|
|
400
422
|
"soprano" { $ProviderScript = "$HooksDir\play-tts-soprano.ps1" }
|
|
423
|
+
"kokoro" { $ProviderScript = "$HooksDir\play-tts-kokoro.ps1" }
|
|
401
424
|
default {
|
|
402
425
|
Write-Host "[INFO] play-tts.ps1: Unrecognised engine '$_LlmEngine' — keeping default provider" -ForegroundColor DarkGray
|
|
403
426
|
}
|
|
@@ -428,6 +451,11 @@ if ($env:AGENTVIBES_VERBOSE -eq "1") {
|
|
|
428
451
|
# ffplay uses libswresample with sinc resampling — no artefacts.
|
|
429
452
|
function Invoke-AudioPlay {
|
|
430
453
|
param([string]$FilePath)
|
|
454
|
+
# Stay silent while the automated test suite is running. The windows-tts/effects
|
|
455
|
+
# tests invoke this script and assert on its [VOICE]/routing output (printed
|
|
456
|
+
# earlier) — they never check playback — so skipping only the audio output keeps
|
|
457
|
+
# them green while preventing real speech on the developer's machine.
|
|
458
|
+
if (Test-Path (Join-Path $env:USERPROFILE ".agentvibes-tests-running")) { return }
|
|
431
459
|
$ffplayCmd = Get-Command ffplay -ErrorAction SilentlyContinue
|
|
432
460
|
$fp = if ($ffplayCmd) { $ffplayCmd.Source } else { $null }
|
|
433
461
|
if (-not $fp) {
|