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.
Files changed (160) hide show
  1. package/.agentvibes/config.json +15 -1
  2. package/.agentvibes/install-manifest.json +342 -0
  3. package/.claude/audio/ui/CREDITS.txt +16 -0
  4. package/.claude/audio/ui/bling-success.wav +0 -0
  5. package/.claude/commands/agent-vibes/receiver.md +64 -0
  6. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  7. package/.claude/commands/agent-vibes-rdp.md +24 -24
  8. package/.claude/config/audio-effects.cfg +5 -4
  9. package/.claude/config/audio-effects.cfg.sample +52 -52
  10. package/.claude/config/background-music-enabled.txt +1 -1
  11. package/.claude/config/background-music-position.txt +27 -0
  12. package/.claude/config/background-music-volume.txt +1 -0
  13. package/.claude/config/background-music.cfg +1 -0
  14. package/.claude/config/background-music.txt +1 -0
  15. package/.claude/config/language.txt +1 -0
  16. package/.claude/config/personality.txt +1 -0
  17. package/.claude/config/reverb-level.txt +1 -0
  18. package/.claude/config/tts-speech-rate.txt +1 -0
  19. package/.claude/config/tts-verbosity.txt +1 -0
  20. package/.claude/docs/TERMUX_SETUP.md +408 -408
  21. package/.claude/github-star-reminder.txt +1 -1
  22. package/.claude/hooks/audio-cache-utils.sh +0 -0
  23. package/.claude/hooks/audio-processor.sh +57 -18
  24. package/.claude/hooks/background-music-manager.sh +0 -0
  25. package/.claude/hooks/bmad-party-speak.sh +0 -0
  26. package/.claude/hooks/bmad-speak-enhanced.sh +0 -0
  27. package/.claude/hooks/bmad-speak.sh +0 -0
  28. package/.claude/hooks/bmad-tts-injector.sh +0 -0
  29. package/.claude/hooks/bmad-voice-manager.sh +0 -0
  30. package/.claude/hooks/clawdbot-receiver-SECURE.sh +0 -0
  31. package/.claude/hooks/clawdbot-receiver.sh +0 -0
  32. package/.claude/hooks/clean-audio-cache.sh +0 -0
  33. package/.claude/hooks/cleanup-cache.sh +0 -0
  34. package/.claude/hooks/configure-rdp-mode.sh +0 -0
  35. package/.claude/hooks/download-extra-voices.sh +0 -0
  36. package/.claude/hooks/effects-manager.sh +0 -0
  37. package/.claude/hooks/github-star-reminder.sh +0 -0
  38. package/.claude/hooks/kokoro-installer.sh +117 -0
  39. package/.claude/hooks/kokoro-server.py +219 -0
  40. package/.claude/hooks/kokoro-tts.py +141 -0
  41. package/.claude/hooks/language-manager.sh +0 -0
  42. package/.claude/hooks/learn-manager.sh +0 -0
  43. package/.claude/hooks/macos-voice-manager.sh +0 -0
  44. package/.claude/hooks/migrate-background-music.sh +0 -0
  45. package/.claude/hooks/migrate-to-agentvibes.sh +0 -0
  46. package/.claude/hooks/optimize-background-music.sh +0 -0
  47. package/.claude/hooks/path-resolver.sh +0 -0
  48. package/.claude/hooks/personality-manager.sh +0 -0
  49. package/.claude/hooks/piper-download-voices.sh +39 -16
  50. package/.claude/hooks/piper-installer.sh +11 -6
  51. package/.claude/hooks/piper-multispeaker-registry.sh +0 -0
  52. package/.claude/hooks/piper-voice-manager.sh +6 -6
  53. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  54. package/.claude/hooks/play-tts-agentvibes-receiver.sh +1 -0
  55. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  56. package/.claude/hooks/play-tts-enhanced.sh +0 -0
  57. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  58. package/.claude/hooks/play-tts-macos.sh +0 -0
  59. package/.claude/hooks/play-tts-piper.sh +0 -0
  60. package/.claude/hooks/play-tts-soprano.sh +0 -0
  61. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  62. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  63. package/.claude/hooks/play-tts-windows-receiver.sh +0 -0
  64. package/.claude/hooks/play-tts.sh +78 -2
  65. package/.claude/hooks/prepare-release.sh +0 -0
  66. package/.claude/hooks/provider-commands.sh +71 -22
  67. package/.claude/hooks/provider-manager.sh +15 -7
  68. package/.claude/hooks/replay-target-audio.sh +0 -0
  69. package/.claude/hooks/requirements.txt +6 -6
  70. package/.claude/hooks/sentiment-manager.sh +0 -0
  71. package/.claude/hooks/session-start-tts.sh +0 -0
  72. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  73. package/.claude/hooks/speed-manager.sh +0 -0
  74. package/.claude/hooks/stop-tts.sh +0 -0
  75. package/.claude/hooks/termux-installer.sh +0 -0
  76. package/.claude/hooks/translate-manager.sh +0 -0
  77. package/.claude/hooks/translator.py +237 -237
  78. package/.claude/hooks/tts-queue-worker.sh +0 -0
  79. package/.claude/hooks/tts-queue.sh +0 -0
  80. package/.claude/hooks/verbosity-manager.sh +0 -0
  81. package/.claude/hooks/voice-manager.sh +0 -0
  82. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
  83. package/.claude/hooks-windows/kokoro-server.py +219 -0
  84. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  85. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  86. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  87. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  88. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
  89. package/.claude/piper-voices-dir.txt +1 -0
  90. package/.claude/verbosity.txt +1 -1
  91. package/.clawdbot/README.md +105 -105
  92. package/.mcp.json +6 -19
  93. package/README.md +12 -86
  94. package/RELEASE_NOTES.md +60 -0
  95. package/WINDOWS-SETUP.md +208 -208
  96. package/bin/agent-vibes +39 -39
  97. package/bin/agentvibes-voice-browser.js +0 -0
  98. package/bin/agentvibes.js +0 -0
  99. package/bin/mcp-server.js +121 -121
  100. package/bin/mcp-server.sh +0 -0
  101. package/bin/test-bmad-pr +78 -78
  102. package/mcp-server/QUICK_START.md +203 -203
  103. package/mcp-server/README.md +345 -345
  104. package/mcp-server/WINDOWS_SETUP.md +0 -0
  105. package/mcp-server/examples/claude_desktop_config.json +11 -11
  106. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  107. package/mcp-server/examples/custom_instructions.md +169 -169
  108. package/mcp-server/install-deps.js +0 -0
  109. package/mcp-server/server.py +1807 -1807
  110. package/mcp-server/test_server.py +0 -0
  111. package/package.json +4 -3
  112. package/src/cli/list-personalities.js +110 -110
  113. package/src/cli/list-voices.js +114 -114
  114. package/src/commands/bmad-voices.js +394 -394
  115. package/src/commands/install-mcp.js +3 -3
  116. package/src/console/app.js +25 -9
  117. package/src/console/audio-env.js +85 -1
  118. package/src/console/brand-colors.js +13 -13
  119. package/src/console/constants/personalities.js +44 -44
  120. package/src/console/navigation.js +4 -0
  121. package/src/console/tabs/agents-tab.js +12 -6
  122. package/src/console/tabs/help-tab.js +314 -314
  123. package/src/console/tabs/music-tab.js +7 -25
  124. package/src/console/tabs/readme-tab.js +272 -272
  125. package/src/console/tabs/setup-tab.js +1704 -120
  126. package/src/console/tabs/voices-tab.js +72 -88
  127. package/src/console/widgets/destroy-list.js +25 -25
  128. package/src/console/widgets/format-utils.js +14 -2
  129. package/src/console/widgets/help-bar.js +55 -0
  130. package/src/console/widgets/notice.js +55 -55
  131. package/src/console/widgets/reverb-picker.js +429 -41
  132. package/src/console/widgets/track-picker.js +60 -51
  133. package/src/i18n/de.js +2 -1
  134. package/src/i18n/en.js +1 -0
  135. package/src/i18n/es.js +2 -1
  136. package/src/i18n/fr.js +2 -1
  137. package/src/i18n/hi.js +2 -1
  138. package/src/i18n/ja.js +2 -1
  139. package/src/i18n/ko.js +2 -1
  140. package/src/i18n/pt.js +2 -1
  141. package/src/i18n/strings.js +9 -9
  142. package/src/i18n/zh-CN.js +2 -1
  143. package/src/installer/language-screen.js +31 -31
  144. package/src/installer/music-file-input.js +304 -304
  145. package/src/installer.js +95 -7
  146. package/src/services/config-service.js +264 -264
  147. package/src/services/language-service.js +47 -47
  148. package/src/services/provider-service.js +35 -0
  149. package/src/services/provider-voice-catalog.js +126 -0
  150. package/src/services/tts-engine-service.js +51 -2
  151. package/src/utils/audio-duration-validator.js +53 -10
  152. package/src/utils/audio-format-validator.js +277 -277
  153. package/src/utils/dependency-checker.js +469 -469
  154. package/src/utils/file-ownership-verifier.js +358 -358
  155. package/src/utils/music-file-validator.js +285 -285
  156. package/src/utils/preview-list-prompt.js +144 -136
  157. package/src/utils/provider-validator.js +70 -0
  158. package/src/utils/secure-music-storage.js +412 -412
  159. package/templates/agentvibes-receiver.sh +235 -231
  160. 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
- if ($_LlmEngine) {
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) {