@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/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
+ }