@domdhi/claude-code-tts 1.0.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/INSTALL.md +335 -0
- package/README.md +133 -0
- package/bin/install.js +36 -0
- package/daemon.py +370 -0
- package/install.py +269 -0
- package/package.json +35 -0
- package/repeat.py +108 -0
- package/stop.py +244 -0
- package/task-hook.py +171 -0
- package/voices.json +14 -0
package/daemon.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
claude-code-tts — TTS daemon
|
|
4
|
+
Persistent background process. Keeps model loaded. Serves speech via TCP.
|
|
5
|
+
|
|
6
|
+
Protocol (JSON lines over localhost:6254):
|
|
7
|
+
{"cmd": "speak", "text": "...", "voice": "af_heart", "speed": 1.0, "project": "repo-name"}
|
|
8
|
+
{"cmd": "stop"}
|
|
9
|
+
{"cmd": "ping"}
|
|
10
|
+
{"cmd": "quit"}
|
|
11
|
+
|
|
12
|
+
Queue behavior: at most one pending item per project key.
|
|
13
|
+
New message from same project replaces its queued slot; different projects line up.
|
|
14
|
+
|
|
15
|
+
Engines:
|
|
16
|
+
Primary: edge-tts (free, cloud, Microsoft neural voices, ~0 RAM)
|
|
17
|
+
Fallback: kokoro-onnx (local, offline — optional, activates if edge-tts fails)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
# Limit ONNX inference threads — prevents all-core spike on synthesis.
|
|
28
|
+
# Must be set before kokoro_onnx / onnxruntime is imported.
|
|
29
|
+
os.environ.setdefault('OMP_NUM_THREADS', '6')
|
|
30
|
+
os.environ.setdefault('ONNXRUNTIME_NUM_THREADS', '6')
|
|
31
|
+
|
|
32
|
+
HOST = '127.0.0.1'
|
|
33
|
+
PORT = 6254
|
|
34
|
+
DAEMON_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
35
|
+
PID_FILE = os.path.join(DAEMON_DIR, 'daemon.pid')
|
|
36
|
+
MODEL_PATH = os.path.join(DAEMON_DIR, 'models', 'kokoro-v1.0.onnx')
|
|
37
|
+
VOICES_PATH = os.path.join(DAEMON_DIR, 'models', 'voices-v1.0.bin')
|
|
38
|
+
|
|
39
|
+
# Maps kokoro voice names → closest Edge TTS neural voice (gender/personality match)
|
|
40
|
+
EDGE_VOICE_MAP = {
|
|
41
|
+
'af_heart': 'en-US-AriaNeural', # warm, natural female
|
|
42
|
+
'af_bella': 'en-US-MichelleNeural', # polished female
|
|
43
|
+
'af_sarah': 'en-US-SaraNeural', # professional female
|
|
44
|
+
'af_sky': 'en-US-JennyNeural', # friendly, conversational
|
|
45
|
+
'af_nova': 'en-US-MonicaNeural', # energetic female
|
|
46
|
+
'am_michael': 'en-US-GuyNeural', # natural, authoritative male
|
|
47
|
+
'am_adam': 'en-US-DavisNeural', # deep male
|
|
48
|
+
'am_echo': 'en-US-TonyNeural', # casual male
|
|
49
|
+
'am_eric': 'en-US-EricNeural', # confident male
|
|
50
|
+
'am_liam': 'en-US-RyanNeural', # young, energetic male
|
|
51
|
+
'am_onyx': 'en-US-ChristopherNeural', # deep, authoritative male
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Speech state
|
|
55
|
+
_kokoro = None # None if not installed or failed to load
|
|
56
|
+
|
|
57
|
+
# Queue: list of {'project': str|None, 'text': str, 'voice': str, 'speed': float}
|
|
58
|
+
# Invariant: at most one entry per project key.
|
|
59
|
+
_queue_lock = threading.Lock()
|
|
60
|
+
_play_queue = []
|
|
61
|
+
_play_event = threading.Event() # signals player loop that queue has items
|
|
62
|
+
_current_stop = threading.Event() # signals player to abort current synthesis/playback
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _log(msg, path='debug.log'):
|
|
66
|
+
log_path = os.path.join(DAEMON_DIR, path)
|
|
67
|
+
ts = time.strftime('%H:%M:%S') + f'.{int(time.time()*1000)%1000:03d}'
|
|
68
|
+
try:
|
|
69
|
+
with open(log_path, 'a', encoding='utf-8') as f:
|
|
70
|
+
f.write(f'[{ts}] {msg}\n')
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def _log_error(msg):
|
|
75
|
+
_log(msg, path='daemon.log')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _play_blocking(samples, sr):
|
|
79
|
+
"""Play audio using a dedicated OutputStream — no global sounddevice state.
|
|
80
|
+
Checks _current_stop every ~40ms. Returns True if fully played."""
|
|
81
|
+
import sounddevice as sd
|
|
82
|
+
import traceback
|
|
83
|
+
if samples is None or _current_stop.is_set():
|
|
84
|
+
_log(f'_play_blocking: skipped (stop={_current_stop.is_set()} samples_none={samples is None})')
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
channels = 1 if samples.ndim == 1 else samples.shape[1]
|
|
88
|
+
total_frames = len(samples)
|
|
89
|
+
duration_s = total_frames / sr
|
|
90
|
+
_log(f'_play_blocking: START sr={sr} frames={total_frames} dur={duration_s:.2f}s channels={channels} dtype={samples.dtype}')
|
|
91
|
+
|
|
92
|
+
chunk_frames = 1024 # ~40ms at 24kHz — responsive stop without audible gaps
|
|
93
|
+
stream = sd.OutputStream(samplerate=sr, channels=channels, dtype=samples.dtype)
|
|
94
|
+
stream.start()
|
|
95
|
+
try:
|
|
96
|
+
for i in range(0, total_frames, chunk_frames):
|
|
97
|
+
if _current_stop.is_set():
|
|
98
|
+
_log(f'_play_blocking: ABORTED at frame {i}/{total_frames}')
|
|
99
|
+
stream.abort()
|
|
100
|
+
return False
|
|
101
|
+
stream.write(samples[i:i + chunk_frames])
|
|
102
|
+
_log(f'_play_blocking: all chunks written, calling stream.stop()')
|
|
103
|
+
stream.stop() # drain hardware buffer cleanly before returning
|
|
104
|
+
_log(f'_play_blocking: COMPLETE')
|
|
105
|
+
return True
|
|
106
|
+
except Exception:
|
|
107
|
+
_log(f'_play_blocking: EXCEPTION\n{traceback.format_exc()}')
|
|
108
|
+
try:
|
|
109
|
+
stream.abort()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
raise
|
|
113
|
+
finally:
|
|
114
|
+
try:
|
|
115
|
+
stream.close()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _chunk_text(text, max_chars=1500):
|
|
121
|
+
"""Split text into sentence-boundary chunks, each under max_chars.
|
|
122
|
+
Kokoro-onnx has a 510-token (~2000 char) hard limit — chunking prevents
|
|
123
|
+
IndexError that silently kills synthesis and causes apparent interruptions."""
|
|
124
|
+
import re
|
|
125
|
+
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
|
|
126
|
+
chunks = []
|
|
127
|
+
current = ''
|
|
128
|
+
for sentence in sentences:
|
|
129
|
+
sentence = sentence.strip()
|
|
130
|
+
if not sentence:
|
|
131
|
+
continue
|
|
132
|
+
if len(sentence) > max_chars:
|
|
133
|
+
sentence = sentence[:max_chars]
|
|
134
|
+
if current and len(current) + 1 + len(sentence) > max_chars:
|
|
135
|
+
chunks.append(current)
|
|
136
|
+
current = sentence
|
|
137
|
+
else:
|
|
138
|
+
current = (current + ' ' + sentence).strip() if current else sentence
|
|
139
|
+
if current:
|
|
140
|
+
chunks.append(current)
|
|
141
|
+
return chunks or [text[:max_chars]]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _speed_to_rate(speed):
|
|
145
|
+
"""Convert kokoro speed (1.0 = normal) to edge-tts rate string (e.g. '+10%')."""
|
|
146
|
+
pct = int((speed - 1.0) * 100)
|
|
147
|
+
return f'{pct:+d}%'
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _edge_async(text, edge_voice, rate):
|
|
151
|
+
import edge_tts
|
|
152
|
+
communicate = edge_tts.Communicate(text, edge_voice, rate=rate)
|
|
153
|
+
audio_bytes = b''
|
|
154
|
+
async for chunk in communicate.stream():
|
|
155
|
+
if chunk['type'] == 'audio':
|
|
156
|
+
audio_bytes += chunk['data']
|
|
157
|
+
return audio_bytes
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _synthesize_edge(text, voice, speed):
|
|
161
|
+
"""Synthesize via Edge TTS. Returns (samples_float32, sample_rate) or raises."""
|
|
162
|
+
import asyncio
|
|
163
|
+
import miniaudio
|
|
164
|
+
import numpy as np
|
|
165
|
+
edge_voice = EDGE_VOICE_MAP.get(voice, 'en-US-AriaNeural')
|
|
166
|
+
rate = _speed_to_rate(speed)
|
|
167
|
+
audio_bytes = asyncio.run(_edge_async(text, edge_voice, rate))
|
|
168
|
+
if not audio_bytes:
|
|
169
|
+
raise RuntimeError('edge-tts returned empty audio')
|
|
170
|
+
decoded = miniaudio.decode(audio_bytes, output_format=miniaudio.SampleFormat.FLOAT32, nchannels=1)
|
|
171
|
+
samples = np.frombuffer(decoded.samples, dtype=np.float32).copy()
|
|
172
|
+
return samples, decoded.sample_rate
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _player_loop():
|
|
176
|
+
"""Persistent player thread: drains _play_queue one item at a time.
|
|
177
|
+
Never interrupted by do_speak() — only do_stop() can cut off playback."""
|
|
178
|
+
import traceback
|
|
179
|
+
|
|
180
|
+
while True:
|
|
181
|
+
_play_event.wait()
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
with _queue_lock:
|
|
185
|
+
if not _play_queue:
|
|
186
|
+
_play_event.clear()
|
|
187
|
+
_current_stop.clear() # ready for next message after stop
|
|
188
|
+
_log(f'player: queue empty, waiting')
|
|
189
|
+
break
|
|
190
|
+
item = _play_queue.pop(0)
|
|
191
|
+
_log(f'player: popped project={item["project"]!r} queue_remaining={len(_play_queue)}')
|
|
192
|
+
|
|
193
|
+
if _current_stop.is_set():
|
|
194
|
+
_log(f'player: stop set at item start, breaking')
|
|
195
|
+
break
|
|
196
|
+
_current_stop.clear()
|
|
197
|
+
|
|
198
|
+
# --- Edge TTS (primary): full text, no chunking needed ---
|
|
199
|
+
edge_ok = False
|
|
200
|
+
if not _current_stop.is_set():
|
|
201
|
+
try:
|
|
202
|
+
_log(f'player: edge-tts project={item["project"]!r} text_len={len(item["text"])}')
|
|
203
|
+
samples, sr = _synthesize_edge(item['text'], item['voice'], item['speed'])
|
|
204
|
+
_log(f'player: edge-tts done stop={_current_stop.is_set()}')
|
|
205
|
+
if not _current_stop.is_set():
|
|
206
|
+
_play_blocking(samples, sr)
|
|
207
|
+
edge_ok = True
|
|
208
|
+
except Exception:
|
|
209
|
+
_log('player: edge-tts failed, falling back to kokoro')
|
|
210
|
+
_log_error(traceback.format_exc())
|
|
211
|
+
|
|
212
|
+
# --- Kokoro fallback: chunked to stay under 510-token limit ---
|
|
213
|
+
if not edge_ok and not _current_stop.is_set():
|
|
214
|
+
if _kokoro is None:
|
|
215
|
+
_log('player: edge-tts failed and kokoro not available — skipping item')
|
|
216
|
+
else:
|
|
217
|
+
chunks = _chunk_text(item['text'])
|
|
218
|
+
_log(f'player: kokoro fallback chunks={len(chunks)}')
|
|
219
|
+
for chunk_idx, chunk in enumerate(chunks):
|
|
220
|
+
if _current_stop.is_set():
|
|
221
|
+
_log(f'player: stop set before kokoro chunk {chunk_idx}')
|
|
222
|
+
break
|
|
223
|
+
try:
|
|
224
|
+
_log(f'player: kokoro chunk {chunk_idx+1}/{len(chunks)} len={len(chunk)}')
|
|
225
|
+
samples, sr = _kokoro.create(
|
|
226
|
+
chunk, voice=item['voice'],
|
|
227
|
+
speed=item['speed'], lang='en-us'
|
|
228
|
+
)
|
|
229
|
+
_log(f'player: kokoro done chunk={chunk_idx+1}')
|
|
230
|
+
if not _current_stop.is_set() and samples is not None:
|
|
231
|
+
_play_blocking(samples, sr)
|
|
232
|
+
except Exception:
|
|
233
|
+
_log_error(traceback.format_exc())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def do_speak(text, voice='af_heart', speed=1.0, project=None):
|
|
237
|
+
"""Queue speech. Replaces any existing queued item for the same project key."""
|
|
238
|
+
with _queue_lock:
|
|
239
|
+
_play_queue[:] = [i for i in _play_queue if i['project'] != project]
|
|
240
|
+
_play_queue.append({'project': project, 'text': text, 'voice': voice, 'speed': speed})
|
|
241
|
+
qsize = len(_play_queue)
|
|
242
|
+
_log(f'do_speak: project={project!r} text_len={len(text)} queue_size={qsize}')
|
|
243
|
+
_play_event.set()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def do_stop():
|
|
247
|
+
"""Stop all speech and clear the queue.
|
|
248
|
+
_play_blocking() checks _current_stop between 40ms chunks — stops within ~40ms."""
|
|
249
|
+
with _queue_lock:
|
|
250
|
+
_play_queue.clear()
|
|
251
|
+
_log(f'do_stop: CALLED — clearing queue and setting stop event')
|
|
252
|
+
_current_stop.set()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def handle_client(conn):
|
|
256
|
+
try:
|
|
257
|
+
data = b''
|
|
258
|
+
conn.settimeout(5.0)
|
|
259
|
+
while b'\n' not in data:
|
|
260
|
+
chunk = conn.recv(4096)
|
|
261
|
+
if not chunk:
|
|
262
|
+
break
|
|
263
|
+
data += chunk
|
|
264
|
+
|
|
265
|
+
msg = json.loads(data.decode('utf-8').strip())
|
|
266
|
+
cmd = msg.get('cmd', '')
|
|
267
|
+
|
|
268
|
+
if cmd == 'speak':
|
|
269
|
+
text = msg.get('text', '').strip()
|
|
270
|
+
voice = msg.get('voice', 'af_heart')
|
|
271
|
+
speed = float(msg.get('speed', 1.0))
|
|
272
|
+
project = msg.get('project', None)
|
|
273
|
+
conn.send(json.dumps({'ok': True}).encode() + b'\n')
|
|
274
|
+
conn.close()
|
|
275
|
+
if text:
|
|
276
|
+
do_speak(text, voice, speed, project)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
elif cmd == 'stop':
|
|
280
|
+
do_stop()
|
|
281
|
+
conn.send(json.dumps({'ok': True}).encode() + b'\n')
|
|
282
|
+
|
|
283
|
+
elif cmd == 'ping':
|
|
284
|
+
conn.send(json.dumps({'ok': True, 'pid': os.getpid()}).encode() + b'\n')
|
|
285
|
+
|
|
286
|
+
elif cmd == 'quit':
|
|
287
|
+
conn.send(json.dumps({'ok': True}).encode() + b'\n')
|
|
288
|
+
conn.close()
|
|
289
|
+
try:
|
|
290
|
+
os.remove(PID_FILE)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
os._exit(0)
|
|
294
|
+
|
|
295
|
+
else:
|
|
296
|
+
conn.send(json.dumps({'ok': False, 'error': f'unknown cmd: {cmd}'}).encode() + b'\n')
|
|
297
|
+
|
|
298
|
+
except Exception as e:
|
|
299
|
+
try:
|
|
300
|
+
conn.send(json.dumps({'ok': False, 'error': str(e)}).encode() + b'\n')
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
finally:
|
|
304
|
+
try:
|
|
305
|
+
conn.close()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main():
|
|
311
|
+
global _kokoro
|
|
312
|
+
|
|
313
|
+
with open(PID_FILE, 'w') as f:
|
|
314
|
+
f.write(str(os.getpid()))
|
|
315
|
+
|
|
316
|
+
# Set process to below-normal priority so synthesis yields to foreground work.
|
|
317
|
+
if sys.platform == 'win32':
|
|
318
|
+
import ctypes
|
|
319
|
+
BELOW_NORMAL_PRIORITY_CLASS = 0x00004000
|
|
320
|
+
ctypes.windll.kernel32.SetPriorityClass(-1, BELOW_NORMAL_PRIORITY_CLASS)
|
|
321
|
+
else:
|
|
322
|
+
try:
|
|
323
|
+
os.nice(10)
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
# Load kokoro-onnx if available (optional offline fallback).
|
|
328
|
+
# Daemon starts even if kokoro is not installed — edge-tts handles primary speech.
|
|
329
|
+
try:
|
|
330
|
+
from kokoro_onnx import Kokoro
|
|
331
|
+
_kokoro = Kokoro(MODEL_PATH, VOICES_PATH)
|
|
332
|
+
sys.stderr.write('kokoro-onnx loaded (offline fallback active)\n')
|
|
333
|
+
except ImportError:
|
|
334
|
+
sys.stderr.write('kokoro-onnx not installed — edge-tts only (no offline fallback)\n')
|
|
335
|
+
except Exception as e:
|
|
336
|
+
sys.stderr.write(f'WARNING: kokoro-onnx failed to load: {e} — edge-tts only\n')
|
|
337
|
+
|
|
338
|
+
threading.Thread(target=_player_loop, daemon=True).start()
|
|
339
|
+
|
|
340
|
+
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
341
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
342
|
+
try:
|
|
343
|
+
server.bind((HOST, PORT))
|
|
344
|
+
except OSError as e:
|
|
345
|
+
sys.stderr.write(f'ERROR: Cannot bind {HOST}:{PORT}: {e}\n')
|
|
346
|
+
try:
|
|
347
|
+
os.remove(PID_FILE)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
server.listen(10)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
while True:
|
|
356
|
+
conn, _ = server.accept()
|
|
357
|
+
t = threading.Thread(target=handle_client, args=(conn,), daemon=True)
|
|
358
|
+
t.start()
|
|
359
|
+
except KeyboardInterrupt:
|
|
360
|
+
pass
|
|
361
|
+
finally:
|
|
362
|
+
try:
|
|
363
|
+
os.remove(PID_FILE)
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
server.close()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == '__main__':
|
|
370
|
+
main()
|
package/install.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
claude-code-tts installer
|
|
4
|
+
Copies hook files into ~/.claude/hooks/tts/ and prints the settings.json snippet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
REQUIRED_PACKAGES = ['edge-tts', 'miniaudio', 'sounddevice', 'cffi']
|
|
15
|
+
KOKORO_PACKAGES = ['kokoro-onnx']
|
|
16
|
+
KOKORO_MODEL_URLS = [
|
|
17
|
+
(
|
|
18
|
+
'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx',
|
|
19
|
+
'kokoro-v1.0.onnx',
|
|
20
|
+
),
|
|
21
|
+
(
|
|
22
|
+
'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin',
|
|
23
|
+
'voices-v1.0.bin',
|
|
24
|
+
),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# voices.json is excluded from HOOK_FILES — handled separately to avoid clobbering customizations
|
|
28
|
+
HOOK_FILES = ['daemon.py', 'stop.py', 'task-hook.py', 'repeat.py']
|
|
29
|
+
|
|
30
|
+
INSTALL_DIR = os.path.join(os.path.expanduser('~'), '.claude', 'hooks', 'tts')
|
|
31
|
+
MODELS_DIR = os.path.join(INSTALL_DIR, 'models')
|
|
32
|
+
SOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def step(msg):
|
|
36
|
+
print(f'\n {msg}')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ok(msg):
|
|
40
|
+
print(f' OK {msg}')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def warn(msg):
|
|
44
|
+
print(f' WARN {msg}')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fail(msg):
|
|
48
|
+
print(f' FAIL {msg}')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_python():
|
|
52
|
+
step('Checking Python version...')
|
|
53
|
+
vi = sys.version_info
|
|
54
|
+
if vi < (3, 10):
|
|
55
|
+
warn(f'Python {vi.major}.{vi.minor} detected. Python 3.10+ is recommended.')
|
|
56
|
+
resp = input(' Continue anyway? [y/N] ').strip().lower()
|
|
57
|
+
if resp != 'y':
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
else:
|
|
60
|
+
ok(f'Python {vi.major}.{vi.minor}.{vi.micro}')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pip_install(packages):
|
|
64
|
+
cmd = [sys.executable, '-m', 'pip', 'install'] + packages
|
|
65
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
fail(f'pip install failed:\n{result.stderr}')
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def install_packages():
|
|
72
|
+
step(f'Installing required packages: {", ".join(REQUIRED_PACKAGES)}')
|
|
73
|
+
pip_install(REQUIRED_PACKAGES)
|
|
74
|
+
ok('edge-tts, miniaudio, sounddevice, cffi installed')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_dirs():
|
|
78
|
+
step(f'Creating install directory: {INSTALL_DIR}')
|
|
79
|
+
os.makedirs(INSTALL_DIR, exist_ok=True)
|
|
80
|
+
ok(INSTALL_DIR)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def copy_files():
|
|
84
|
+
step('Copying hook files...')
|
|
85
|
+
for filename in HOOK_FILES:
|
|
86
|
+
src = os.path.join(SOURCE_DIR, filename)
|
|
87
|
+
dst = os.path.join(INSTALL_DIR, filename)
|
|
88
|
+
if not os.path.exists(src):
|
|
89
|
+
fail(f'Source file not found: {src}')
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
shutil.copy2(src, dst)
|
|
92
|
+
ok(filename)
|
|
93
|
+
|
|
94
|
+
# voices.json: only copy on first install — preserve existing customizations
|
|
95
|
+
voices_src = os.path.join(SOURCE_DIR, 'voices.json')
|
|
96
|
+
voices_dst = os.path.join(INSTALL_DIR, 'voices.json')
|
|
97
|
+
if os.path.exists(voices_dst):
|
|
98
|
+
ok('voices.json (kept existing, not overwritten)')
|
|
99
|
+
else:
|
|
100
|
+
shutil.copy2(voices_src, voices_dst)
|
|
101
|
+
ok('voices.json')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def enable_tts():
|
|
105
|
+
step('Enabling TTS (creating on file)...')
|
|
106
|
+
on_file = os.path.join(INSTALL_DIR, 'on')
|
|
107
|
+
open(on_file, 'w').close()
|
|
108
|
+
ok('TTS enabled')
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _kokoro_already_installed():
|
|
112
|
+
"""True if kokoro-onnx is importable and model files exist (in MODELS_DIR or default location)."""
|
|
113
|
+
import importlib.util
|
|
114
|
+
if importlib.util.find_spec('kokoro_onnx') is None:
|
|
115
|
+
return False
|
|
116
|
+
default_models = os.path.join(os.path.expanduser('~'), '.claude', 'hooks', 'tts', 'models')
|
|
117
|
+
for models_dir in (MODELS_DIR, default_models):
|
|
118
|
+
if all(os.path.exists(os.path.join(models_dir, f))
|
|
119
|
+
for f in ('kokoro-v1.0.onnx', 'voices-v1.0.bin')):
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def offer_kokoro():
|
|
125
|
+
step('Offline fallback (kokoro-onnx, ~82MB download)')
|
|
126
|
+
|
|
127
|
+
if _kokoro_already_installed():
|
|
128
|
+
ok('kokoro-onnx already installed, skipping')
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
print(' Edge TTS requires internet. kokoro-onnx is a local fallback that works offline.')
|
|
132
|
+
resp = input(' Install kokoro-onnx offline fallback? [y/N] ').strip().lower()
|
|
133
|
+
if resp != 'y':
|
|
134
|
+
ok('Skipped (edge-tts only mode)')
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
step('Installing kokoro-onnx...')
|
|
138
|
+
pip_install(KOKORO_PACKAGES)
|
|
139
|
+
ok('kokoro-onnx installed')
|
|
140
|
+
|
|
141
|
+
step('Downloading model files...')
|
|
142
|
+
os.makedirs(MODELS_DIR, exist_ok=True)
|
|
143
|
+
try:
|
|
144
|
+
import urllib.request
|
|
145
|
+
for url, filename in KOKORO_MODEL_URLS:
|
|
146
|
+
dst = os.path.join(MODELS_DIR, filename)
|
|
147
|
+
print(f' Downloading {filename}...')
|
|
148
|
+
urllib.request.urlretrieve(url, dst)
|
|
149
|
+
ok(filename)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
fail(f'Model download failed: {e}')
|
|
152
|
+
print(' You can download manually — see INSTALL.md for URLs.')
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def print_settings_snippet():
|
|
156
|
+
tts_dir = INSTALL_DIR.replace('\\', '\\\\')
|
|
157
|
+
|
|
158
|
+
if sys.platform == 'win32':
|
|
159
|
+
py = sys.executable.replace('\\', '\\\\')
|
|
160
|
+
stop_cmd = f'{py} \\"{tts_dir}\\\\stop.py\\"'
|
|
161
|
+
task_cmd = f'{py} \\"{tts_dir}\\\\task-hook.py\\"'
|
|
162
|
+
repeat_cmd = f'{py} \\"{tts_dir}\\\\repeat.py\\"'
|
|
163
|
+
else:
|
|
164
|
+
py = sys.executable
|
|
165
|
+
stop_cmd = f'{py} "{INSTALL_DIR}/stop.py"'
|
|
166
|
+
task_cmd = f'{py} "{INSTALL_DIR}/task-hook.py"'
|
|
167
|
+
repeat_cmd = f'{py} "{INSTALL_DIR}/repeat.py"'
|
|
168
|
+
|
|
169
|
+
snippet = f'''{{
|
|
170
|
+
"hooks": {{
|
|
171
|
+
"Stop": [
|
|
172
|
+
{{
|
|
173
|
+
"hooks": [
|
|
174
|
+
{{
|
|
175
|
+
"type": "command",
|
|
176
|
+
"command": "{stop_cmd}"
|
|
177
|
+
}}
|
|
178
|
+
]
|
|
179
|
+
}}
|
|
180
|
+
],
|
|
181
|
+
"PostToolUse": [
|
|
182
|
+
{{
|
|
183
|
+
"matcher": "Task",
|
|
184
|
+
"hooks": [
|
|
185
|
+
{{
|
|
186
|
+
"type": "command",
|
|
187
|
+
"command": "{task_cmd}"
|
|
188
|
+
}}
|
|
189
|
+
]
|
|
190
|
+
}}
|
|
191
|
+
],
|
|
192
|
+
"UserPromptSubmit": [
|
|
193
|
+
{{
|
|
194
|
+
"hooks": [
|
|
195
|
+
{{
|
|
196
|
+
"type": "command",
|
|
197
|
+
"command": "{repeat_cmd}"
|
|
198
|
+
}}
|
|
199
|
+
]
|
|
200
|
+
}}
|
|
201
|
+
]
|
|
202
|
+
}}
|
|
203
|
+
}}'''
|
|
204
|
+
|
|
205
|
+
settings_path = os.path.join(os.path.expanduser('~'), '.claude', 'settings.json')
|
|
206
|
+
print(f"""
|
|
207
|
+
-------------------------------------------------------------
|
|
208
|
+
Add this to your Claude Code settings (~/.claude/settings.json):
|
|
209
|
+
|
|
210
|
+
If settings.json already has a "hooks" key, merge these entries
|
|
211
|
+
into your existing hooks object -- don't replace the whole file.
|
|
212
|
+
-------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
{snippet}
|
|
215
|
+
|
|
216
|
+
Settings file location: {settings_path}
|
|
217
|
+
-------------------------------------------------------------
|
|
218
|
+
""")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def print_success():
|
|
222
|
+
print(f"""
|
|
223
|
+
DONE: claude-code-tts installed to {INSTALL_DIR}
|
|
224
|
+
|
|
225
|
+
Quick test (after adding settings.json snippet):
|
|
226
|
+
Run Claude Code, ask anything -- response will be read aloud.
|
|
227
|
+
|
|
228
|
+
Commands (type in Claude Code prompt):
|
|
229
|
+
/voice:stop Stop speech immediately
|
|
230
|
+
/repeat Replay last response
|
|
231
|
+
|
|
232
|
+
To disable TTS:
|
|
233
|
+
Delete {os.path.join(INSTALL_DIR, 'on')}
|
|
234
|
+
|
|
235
|
+
To re-enable:
|
|
236
|
+
touch {os.path.join(INSTALL_DIR, 'on')} (Mac/Linux)
|
|
237
|
+
echo. > {os.path.join(INSTALL_DIR, 'on').replace('/', chr(92))} (Windows)
|
|
238
|
+
|
|
239
|
+
Full docs: INSTALL.md
|
|
240
|
+
""")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main():
|
|
244
|
+
global INSTALL_DIR, MODELS_DIR
|
|
245
|
+
|
|
246
|
+
parser = argparse.ArgumentParser(description='claude-code-tts installer')
|
|
247
|
+
parser.add_argument(
|
|
248
|
+
'--dir', metavar='PATH',
|
|
249
|
+
help='Install hooks to PATH instead of ~/.claude/hooks/tts/'
|
|
250
|
+
)
|
|
251
|
+
args = parser.parse_args()
|
|
252
|
+
|
|
253
|
+
if args.dir:
|
|
254
|
+
INSTALL_DIR = os.path.abspath(args.dir)
|
|
255
|
+
MODELS_DIR = os.path.join(INSTALL_DIR, 'models')
|
|
256
|
+
|
|
257
|
+
print('\nclaude-code-tts installer\n')
|
|
258
|
+
check_python()
|
|
259
|
+
install_packages()
|
|
260
|
+
create_dirs()
|
|
261
|
+
copy_files()
|
|
262
|
+
enable_tts()
|
|
263
|
+
offer_kokoro()
|
|
264
|
+
print_settings_snippet()
|
|
265
|
+
print_success()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == '__main__':
|
|
269
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@domdhi/claude-code-tts",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Neural TTS hook system for Claude Code. Reads Claude's responses aloud as they finish.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-code-tts": "bin/install.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"daemon.py",
|
|
11
|
+
"stop.py",
|
|
12
|
+
"task-hook.py",
|
|
13
|
+
"repeat.py",
|
|
14
|
+
"voices.json",
|
|
15
|
+
"install.py",
|
|
16
|
+
"INSTALL.md"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"tts",
|
|
22
|
+
"text-to-speech",
|
|
23
|
+
"hooks",
|
|
24
|
+
"voice"
|
|
25
|
+
],
|
|
26
|
+
"author": "domdhi",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=16"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/domdhi/claude-code-tts"
|
|
34
|
+
}
|
|
35
|
+
}
|