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