agentvibes 5.13.1 → 5.15.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/.claude/audio/ui/CREDITS.txt +16 -16
- 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.sample +52 -52
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/kokoro-server.py +219 -219
- package/.claude/hooks/kokoro-tts.py +141 -141
- package/.claude/hooks/party-stage-roster.py +328 -328
- package/.claude/hooks/play-tts-kokoro.sh +7 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
- package/.claude/hooks/requirements.txt +6 -6
- package/.claude/hooks/soprano-gradio-synth.py +139 -139
- package/.claude/hooks/translator.py +237 -237
- package/.claude/hooks-windows/agentvibes-session-id.ps1 +83 -0
- package/.claude/hooks-windows/kokoro-server.py +219 -219
- package/.claude/hooks-windows/kokoro-tts.py +107 -107
- package/.claude/hooks-windows/play-tts.ps1 +112 -1
- package/.claude/hooks-windows/session-start-tts.ps1 +36 -0
- package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
- package/.claude/verbosity.txt +1 -1
- package/.clawdbot/README.md +105 -105
- package/CLAUDE.md +84 -176
- package/README.md +35 -2
- package/RELEASE_NOTES.md +139 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- 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/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 +12 -2
- package/mcp-server/server.py +2111 -2085
- package/mcp-server/test_mcp_correctness.py +504 -504
- package/mcp-server/test_windows_script_parity.py +339 -339
- package/package.json +6 -3
- package/setup-ssh-receiver.ps1 +259 -0
- package/setup-windows.ps1 +39 -29
- package/src/cli/list-personalities.js +110 -110
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/agents-tab.js +14 -4
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +31 -6
- package/src/console/tabs/setup-tab.js +154 -32
- package/src/console/tabs/voices-tab.js +48 -6
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +98 -37
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +4 -1
- package/src/services/provider-voice-catalog.js +9 -5
- 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 -144
- package/src/utils/secure-music-storage.js +412 -412
- package/templates/agentvibes-receiver.ps1 +311 -0
- package/templates/agentvibes-receiver.sh +297 -297
- package/voice-assignments.json +8244 -8244
- package/.agentvibes/config.json +0 -17
- package/.agentvibes/install-manifest.json +0 -362
- package/.claude/config/audio-effects.cfg +0 -7
- package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
- package/.claude/config/background-music-enabled.txt +0 -1
- package/.claude/config/background-music-position.txt +0 -27
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/language.txt +0 -1
- package/.claude/config/personality.txt +0 -1
- package/.claude/config/reverb-level.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
- package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
20260720
|
|
@@ -1,219 +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()
|
|
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()
|