@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/repeat.py ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claude-code-tts — UserPromptSubmit hook
4
+ Intercepts /repeat and /voice:stop commands.
5
+ /repeat → replays the last spoken response
6
+ /voice:stop → stops speech immediately
7
+ """
8
+
9
+ import json
10
+ import sys
11
+ import os
12
+ import socket
13
+ import subprocess
14
+ import time
15
+
16
+ HOOK_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ ON_FILE = os.path.join(HOOK_DIR, 'on')
18
+ LAST_FILE = os.path.join(HOOK_DIR, 'last.txt')
19
+ VOICES_FILE = os.path.join(HOOK_DIR, 'voices.json')
20
+ DAEMON_SCRIPT = os.path.join(HOOK_DIR, 'daemon.py')
21
+
22
+ DAEMON_HOST = '127.0.0.1'
23
+ DAEMON_PORT = 6254
24
+
25
+
26
+ def _start_daemon():
27
+ kwargs = {
28
+ 'stdout': subprocess.DEVNULL,
29
+ 'stderr': subprocess.DEVNULL,
30
+ }
31
+ if sys.platform == 'win32':
32
+ kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
33
+ subprocess.Popen([sys.executable, DAEMON_SCRIPT], **kwargs)
34
+ for _ in range(40):
35
+ time.sleep(0.2)
36
+ try:
37
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
38
+ s.settimeout(0.5)
39
+ s.connect((DAEMON_HOST, DAEMON_PORT))
40
+ s.close()
41
+ return True
42
+ except Exception:
43
+ pass
44
+ return False
45
+
46
+
47
+ def send_to_daemon(cmd_dict):
48
+ for attempt in range(2):
49
+ try:
50
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
51
+ s.settimeout(3.0)
52
+ s.connect((DAEMON_HOST, DAEMON_PORT))
53
+ s.send(json.dumps(cmd_dict).encode() + b'\n')
54
+ resp = s.recv(1024)
55
+ s.close()
56
+ return json.loads(resp.decode().strip()).get('ok', False)
57
+ except ConnectionRefusedError:
58
+ if attempt == 0:
59
+ _start_daemon()
60
+ except Exception:
61
+ return False
62
+ return False
63
+
64
+
65
+ def load_default_voice():
66
+ if os.path.exists(VOICES_FILE):
67
+ try:
68
+ with open(VOICES_FILE, 'r', encoding='utf-8') as f:
69
+ cfg = json.load(f).get('default', {})
70
+ return cfg.get('voice', 'af_heart'), float(cfg.get('speed', 1.0))
71
+ except Exception:
72
+ pass
73
+ return 'af_heart', 1.0
74
+
75
+
76
+ def main():
77
+ try:
78
+ data = json.loads(sys.stdin.read())
79
+ except Exception:
80
+ sys.exit(0)
81
+
82
+ prompt = data.get('prompt', '').strip()
83
+
84
+ if prompt in ('/voice stop', '/voice:stop', '/stop'):
85
+ send_to_daemon({'cmd': 'stop'})
86
+ print(json.dumps({'decision': 'block', 'reason': ''}))
87
+ sys.exit(0)
88
+
89
+ if prompt not in ('/repeat', '/voice repeat', '/voice:repeat'):
90
+ sys.exit(0)
91
+
92
+ # Replay last spoken text
93
+ if os.path.exists(LAST_FILE):
94
+ try:
95
+ with open(LAST_FILE, 'r', encoding='utf-8') as f:
96
+ text = f.read().strip()
97
+ if text:
98
+ voice, speed = load_default_voice()
99
+ send_to_daemon({'cmd': 'speak', 'text': text, 'voice': voice, 'speed': speed})
100
+ except Exception:
101
+ pass
102
+
103
+ print(json.dumps({'decision': 'block', 'reason': ''}))
104
+ sys.exit(0)
105
+
106
+
107
+ if __name__ == '__main__':
108
+ main()
package/stop.py ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claude-code-tts — Stop hook
4
+ Fires when Claude finishes a response. Reads the last assistant message from
5
+ the transcript and sends it to the TTS daemon for playback.
6
+ Starts the daemon automatically if it's not running.
7
+ """
8
+
9
+ import json
10
+ import sys
11
+ import re
12
+ import os
13
+ import socket
14
+ import subprocess
15
+ import time
16
+
17
+ HOOK_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ ON_FILE = os.path.join(HOOK_DIR, 'on')
19
+ LAST_FILE = os.path.join(HOOK_DIR, 'last.txt')
20
+ VOICES_FILE = os.path.join(HOOK_DIR, 'voices.json')
21
+ DAEMON_SCRIPT = os.path.join(HOOK_DIR, 'daemon.py')
22
+
23
+ DAEMON_HOST = '127.0.0.1'
24
+ DAEMON_PORT = 6254
25
+
26
+ READ_ALL_CHAIN = False # True = speak all assistant messages in chain, False = last only
27
+
28
+
29
+ def strip_markdown(text):
30
+ text = re.sub(r'```[\w]*\n.*?```', '[code block]', text, flags=re.DOTALL)
31
+ text = re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], text)
32
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
33
+ text = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', text)
34
+ text = re.sub(r'_{1,3}([^_]+)_{1,3}', r'\1', text)
35
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
36
+ text = re.sub(r'^\s*[-*+]\s+', '', text, flags=re.MULTILINE)
37
+ text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
38
+ text = re.sub(r'^[-*_]{3,}$', '', text, flags=re.MULTILINE)
39
+ text = re.sub(r'<[^>]+>', '', text)
40
+ text = re.sub(r'\n{3,}', '\n\n', text)
41
+ text = _sanitize_unicode(text)
42
+ return text.strip()
43
+
44
+
45
+ def _sanitize_unicode(text):
46
+ """Replace unicode symbols that TTS engines can't phonemize with ASCII equivalents."""
47
+ replacements = {
48
+ '→': ', ', '←': ', ', '↑': ', ', '↓': ', ',
49
+ '⇒': ', ', '⇐': ', ', '⇔': ', ',
50
+ '—': ', ', '–': ', ', '…': '...',
51
+ '\u2019': "'", '\u2018': "'", # smart quotes
52
+ '\u201c': '"', '\u201d': '"',
53
+ '•': '', '·': '',
54
+ '✓': 'yes', '✗': 'no', '✅': 'yes', '❌': 'no',
55
+ '🔴': '', '🟡': '', '🟢': '', '⭐': '',
56
+ }
57
+ for src, dst in replacements.items():
58
+ text = text.replace(src, dst)
59
+ # Strip remaining non-ASCII
60
+ text = re.sub(r'[^\x00-\x7F]+', ' ', text)
61
+ text = re.sub(r' {2,}', ' ', text)
62
+ return text
63
+
64
+
65
+ def extract_last_assistant_message(transcript_path):
66
+ if not os.path.exists(transcript_path):
67
+ return None
68
+ entries = []
69
+ with open(transcript_path, 'r', encoding='utf-8', errors='replace') as f:
70
+ for line in f:
71
+ line = line.strip()
72
+ if not line:
73
+ continue
74
+ try:
75
+ entries.append(json.loads(line))
76
+ except json.JSONDecodeError:
77
+ continue
78
+
79
+ # Find index of last real user message (skip tool result entries)
80
+ last_user_idx = -1
81
+ for i, obj in enumerate(entries):
82
+ if obj.get('type') == 'user':
83
+ content = obj.get('message', {}).get('content', [])
84
+ if isinstance(content, list) and content and all(
85
+ isinstance(b, dict) and b.get('type') == 'tool_result'
86
+ for b in content
87
+ ):
88
+ continue
89
+ last_user_idx = i
90
+
91
+ texts = []
92
+ for obj in entries[last_user_idx + 1:]:
93
+ if obj.get('type') != 'assistant':
94
+ continue
95
+ content = obj.get('message', {}).get('content', '')
96
+ if isinstance(content, str) and content.strip():
97
+ texts.append(content.strip())
98
+ elif isinstance(content, list):
99
+ parts = [b.get('text', '') for b in content
100
+ if isinstance(b, dict) and b.get('type') == 'text']
101
+ combined = '\n'.join(parts).strip()
102
+ if combined:
103
+ texts.append(combined)
104
+
105
+ if not texts:
106
+ return None
107
+ return '\n\n'.join(texts) if READ_ALL_CHAIN else texts[-1]
108
+
109
+
110
+ def load_voices():
111
+ if os.path.exists(VOICES_FILE):
112
+ try:
113
+ with open(VOICES_FILE, 'r', encoding='utf-8') as f:
114
+ return json.load(f)
115
+ except Exception:
116
+ pass
117
+ return {'default': {'voice': 'af_heart', 'speed': 1.0}}
118
+
119
+
120
+ def get_project_voice(transcript_path, voices):
121
+ """Look up voice by project dir encoded in transcript path.
122
+ Transcript paths look like: .../.claude/projects/c--Users-...-ProjectName/...
123
+ voices.json 'projects' keys are matched as substrings of that encoded segment."""
124
+ projects = voices.get('projects', {})
125
+ if not projects or not transcript_path:
126
+ return None
127
+ norm = transcript_path.replace('\\', '/').lower()
128
+ for key, cfg in projects.items():
129
+ if key.lower() in norm:
130
+ return cfg
131
+ return None
132
+
133
+
134
+ def parse_agent_voice(text, voices, fallback_cfg=None):
135
+ """Parse [AgentName]: prefix. Returns (stripped_text, voice, speed).
136
+ Priority: agent prefix → fallback_cfg (project) → default."""
137
+ match = re.match(r'^\[([^\]]+)\]:\s*', text)
138
+ if match:
139
+ agent = match.group(1)
140
+ text = text[match.end():]
141
+ cfg = voices.get(agent) or fallback_cfg or voices.get('default') or {}
142
+ else:
143
+ cfg = fallback_cfg or voices.get('default') or {}
144
+ voice = cfg.get('voice', 'af_heart')
145
+ speed = float(cfg.get('speed', 1.0))
146
+ return text, voice, speed
147
+
148
+
149
+ def _start_daemon():
150
+ """Start the TTS daemon as a detached background process."""
151
+ kwargs = {
152
+ 'stdout': subprocess.DEVNULL,
153
+ 'stderr': subprocess.DEVNULL,
154
+ }
155
+ if sys.platform == 'win32':
156
+ kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
157
+ subprocess.Popen([sys.executable, DAEMON_SCRIPT], **kwargs)
158
+ # Poll until daemon is ready (up to 8s for model load on first run)
159
+ for _ in range(40):
160
+ time.sleep(0.2)
161
+ try:
162
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
163
+ s.settimeout(0.5)
164
+ s.connect((DAEMON_HOST, DAEMON_PORT))
165
+ s.close()
166
+ return True
167
+ except Exception:
168
+ pass
169
+ return False
170
+
171
+
172
+ def send_to_daemon(cmd_dict):
173
+ """Send a command to the daemon. Starts daemon if not running."""
174
+ for attempt in range(2):
175
+ try:
176
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
177
+ s.settimeout(3.0)
178
+ s.connect((DAEMON_HOST, DAEMON_PORT))
179
+ s.sendall(json.dumps(cmd_dict).encode() + b'\n')
180
+ resp = s.recv(1024)
181
+ s.close()
182
+ return json.loads(resp.decode().strip()).get('ok', False)
183
+ except ConnectionRefusedError:
184
+ if attempt == 0:
185
+ _start_daemon()
186
+ except Exception:
187
+ return False
188
+ return False
189
+
190
+
191
+ def get_project_key(transcript_path):
192
+ """Extract encoded project dir from transcript path.
193
+ e.g. .../.claude/projects/c--Users-dbaca-Repos-MyProject/... → 'c--Users-dbaca-Repos-MyProject'"""
194
+ if not transcript_path:
195
+ return None
196
+ parts = transcript_path.replace('\\', '/').split('/')
197
+ try:
198
+ idx = parts.index('projects')
199
+ return parts[idx + 1]
200
+ except (ValueError, IndexError):
201
+ return None
202
+
203
+
204
+ def speak(text, voice='af_heart', speed=1.0, project=None):
205
+ send_to_daemon({'cmd': 'speak', 'text': text, 'voice': voice, 'speed': speed, 'project': project})
206
+
207
+
208
+ def main():
209
+ try:
210
+ data = json.loads(sys.stdin.read())
211
+ except Exception:
212
+ sys.exit(0)
213
+
214
+ if not os.path.exists(ON_FILE):
215
+ sys.exit(0)
216
+
217
+ transcript_path = data.get('transcript_path', '')
218
+ if not transcript_path:
219
+ sys.exit(0)
220
+
221
+ time.sleep(0.5)
222
+ text = extract_last_assistant_message(transcript_path)
223
+ if not text:
224
+ sys.exit(0)
225
+
226
+ cleaned = strip_markdown(text)
227
+ if not cleaned:
228
+ sys.exit(0)
229
+
230
+ try:
231
+ with open(LAST_FILE, 'w', encoding='utf-8') as f:
232
+ f.write(cleaned)
233
+ except Exception:
234
+ pass
235
+
236
+ voices = load_voices()
237
+ proj_cfg = get_project_voice(transcript_path, voices)
238
+ cleaned, voice, speed = parse_agent_voice(cleaned, voices, fallback_cfg=proj_cfg)
239
+ speak(cleaned, voice, speed, project=get_project_key(transcript_path))
240
+ sys.exit(0)
241
+
242
+
243
+ if __name__ == '__main__':
244
+ main()
package/task-hook.py ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claude-code-tts — PostToolUse:Task hook
4
+ Fires after a Task tool call completes. Reads subagent_type from tool_input
5
+ to look up the agent's voice — no [agent-name]: prefix required.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ import re
11
+ import os
12
+ import socket
13
+ import subprocess
14
+ import time
15
+
16
+ HOOK_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ ON_FILE = os.path.join(HOOK_DIR, 'on')
18
+ VOICES_FILE = os.path.join(HOOK_DIR, 'voices.json')
19
+ DAEMON_SCRIPT = os.path.join(HOOK_DIR, 'daemon.py')
20
+
21
+ DAEMON_HOST = '127.0.0.1'
22
+ DAEMON_PORT = 6254
23
+
24
+
25
+ def strip_markdown(text):
26
+ text = re.sub(r'```[\w]*\n.*?```', '[code block]', text, flags=re.DOTALL)
27
+ text = re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], text)
28
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
29
+ text = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', text)
30
+ text = re.sub(r'_{1,3}([^_]+)_{1,3}', r'\1', text)
31
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
32
+ text = re.sub(r'^\s*[-*+]\s+', '', text, flags=re.MULTILINE)
33
+ text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
34
+ text = re.sub(r'^[-*_]{3,}$', '', text, flags=re.MULTILINE)
35
+ text = re.sub(r'<[^>]+>', '', text)
36
+ text = re.sub(r'\n{3,}', '\n\n', text)
37
+ text = _sanitize_unicode(text)
38
+ return text.strip()
39
+
40
+
41
+ def _sanitize_unicode(text):
42
+ replacements = {
43
+ '→': ', ', '←': ', ', '↑': ', ', '↓': ', ',
44
+ '⇒': ', ', '⇐': ', ', '⇔': ', ',
45
+ '—': ', ', '–': ', ', '…': '...',
46
+ '\u2019': "'", '\u2018': "'",
47
+ '\u201c': '"', '\u201d': '"',
48
+ '•': '', '·': '',
49
+ '✓': 'yes', '✗': 'no', '✅': 'yes', '❌': 'no',
50
+ '🔴': '', '🟡': '', '🟢': '', '⭐': '',
51
+ }
52
+ for src, dst in replacements.items():
53
+ text = text.replace(src, dst)
54
+ text = re.sub(r'[^\x00-\x7F]+', ' ', text)
55
+ text = re.sub(r' {2,}', ' ', text)
56
+ return text
57
+
58
+
59
+ def extract_text_from_response(tool_response):
60
+ """Pull plain text out of whatever shape the tool_response takes."""
61
+ if isinstance(tool_response, str):
62
+ return tool_response.strip()
63
+
64
+ if isinstance(tool_response, dict):
65
+ content = tool_response.get('content', tool_response.get('output', ''))
66
+ if isinstance(content, str):
67
+ return content.strip()
68
+ if isinstance(content, list):
69
+ parts = []
70
+ for block in content:
71
+ if isinstance(block, dict):
72
+ parts.append(block.get('text', ''))
73
+ elif isinstance(block, str):
74
+ parts.append(block)
75
+ return '\n'.join(p for p in parts if p).strip()
76
+
77
+ return ''
78
+
79
+
80
+ def load_voices():
81
+ if os.path.exists(VOICES_FILE):
82
+ try:
83
+ with open(VOICES_FILE, 'r', encoding='utf-8') as f:
84
+ return json.load(f)
85
+ except Exception:
86
+ pass
87
+ return {'default': {'voice': 'af_heart', 'speed': 1.0}}
88
+
89
+
90
+ def _start_daemon():
91
+ kwargs = {
92
+ 'stdout': subprocess.DEVNULL,
93
+ 'stderr': subprocess.DEVNULL,
94
+ }
95
+ if sys.platform == 'win32':
96
+ kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
97
+ subprocess.Popen([sys.executable, DAEMON_SCRIPT], **kwargs)
98
+ for _ in range(40):
99
+ time.sleep(0.2)
100
+ try:
101
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
102
+ s.settimeout(0.5)
103
+ s.connect((DAEMON_HOST, DAEMON_PORT))
104
+ s.close()
105
+ return True
106
+ except Exception:
107
+ pass
108
+ return False
109
+
110
+
111
+ def send_to_daemon(cmd_dict):
112
+ for attempt in range(2):
113
+ try:
114
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
+ s.settimeout(3.0)
116
+ s.connect((DAEMON_HOST, DAEMON_PORT))
117
+ s.sendall(json.dumps(cmd_dict).encode() + b'\n')
118
+ resp = s.recv(1024)
119
+ s.close()
120
+ return json.loads(resp.decode().strip()).get('ok', False)
121
+ except ConnectionRefusedError:
122
+ if attempt == 0:
123
+ _start_daemon()
124
+ except Exception:
125
+ return False
126
+ return False
127
+
128
+
129
+ def main():
130
+ try:
131
+ data = json.loads(sys.stdin.read())
132
+ except Exception:
133
+ sys.exit(0)
134
+
135
+ if not os.path.exists(ON_FILE):
136
+ sys.exit(0)
137
+
138
+ if data.get('tool_name') != 'Task':
139
+ sys.exit(0)
140
+
141
+ # Determine agent from tool_input.subagent_type — no prefix required
142
+ tool_input = data.get('tool_input', {})
143
+ agent_name = tool_input.get('subagent_type', '')
144
+
145
+ tool_response = data.get('tool_response', '')
146
+ raw_text = extract_text_from_response(tool_response)
147
+
148
+ if not raw_text:
149
+ sys.exit(0)
150
+
151
+ # Strip [agent-name]: prefix if the agent included it anyway
152
+ raw_text = re.sub(r'^\[[^\]]+\]:\s*', '', raw_text)
153
+
154
+ cleaned = strip_markdown(raw_text)
155
+ if not cleaned:
156
+ sys.exit(0)
157
+
158
+ voices = load_voices()
159
+ cfg = voices.get(agent_name) or voices.get('default') or {}
160
+ voice = cfg.get('voice', 'af_heart')
161
+ speed = float(cfg.get('speed', 1.0))
162
+
163
+ # Use tool_use_id as a stable per-task identity so rapid task completions
164
+ # replace each other in the queue rather than stacking.
165
+ task_id = data.get('tool_use_id') or data.get('session_id') or 'task'
166
+ send_to_daemon({'cmd': 'speak', 'text': cleaned, 'voice': voice, 'speed': speed, 'project': task_id})
167
+ sys.exit(0)
168
+
169
+
170
+ if __name__ == '__main__':
171
+ main()
package/voices.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "default": {
3
+ "voice": "af_heart",
4
+ "speed": 1.0
5
+ },
6
+ "_comment_agents": "Add agent name keys to route agent voices. Key = subagent_type value (e.g. 'general-purpose') or [AgentName]: prefix in response (lowercase).",
7
+ "_comment_projects": "Add a 'projects' section to route by project. Key = substring of ~/.claude/projects/ dir name (case-insensitive).",
8
+ "_examples": {
9
+ "general-purpose": {"voice": "am_michael", "speed": 1.0},
10
+ "projects": {
11
+ "my-project": {"voice": "am_onyx", "speed": 0.95}
12
+ }
13
+ }
14
+ }