symphony-code 0.0.1__py3-none-any.whl

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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,651 @@
1
+ """One background speech-to-speech socket for one voice listen.
2
+
3
+ The socket is ``wss://api.x.ai/v1/realtime`` with ``grok-voice-think-fast-2.0``.
4
+ Mic audio goes in as PCM. On commit the model speaks one short acknowledgement
5
+ and the socket closes, so it is not billed through the coding run. The spoken
6
+ summary is a second, mic-less connection that closes once that line has played.
7
+ ``close`` runs when the app quits.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import base64
14
+ import contextlib
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ from typing import Any, Callable
22
+ from urllib.parse import urlencode
23
+
24
+ from coding_agent.addons.voice.credentials import GROK_TTS_VOICE, grok_voice_credentials
25
+
26
+ # The speech-to-speech model. Pinned: the "latest" alias is not what the
27
+ # account is billed or served as, and a pin does not drift under a user.
28
+ VOICE_REALTIME_MODEL = "grok-voice-think-fast-2.0"
29
+ REALTIME_URL = "wss://api.x.ai/v1/realtime"
30
+ SAMPLE_RATE = 24_000
31
+ # 40 ms of mono int16 per callback and per socket message: 25 sends a second.
32
+ MIC_BLOCK_FRAMES = SAMPLE_RATE * 40 // 1000
33
+ # Mic blocks waiting for the socket. Past this the oldest is dropped, so a
34
+ # slow link costs at most ~1.3 s of latency instead of growing without bound.
35
+ MIC_QUEUE_BLOCKS = 32
36
+ # Quiet this long after speech, and the utterance is committed. Enter commits
37
+ # sooner. The pause is counted from audio blocks, not from a UI timer.
38
+ PAUSE_COMMIT_S = 3.0
39
+ PAUSE_COMMIT_BLOCKS = int(PAUSE_COMMIT_S * 1000 / 40)
40
+ # int16 RMS. Normal speech sits well above this; a quiet room does not.
41
+ SPEECH_RMS = 500
42
+ # The audio is base64 and needs no escaping, so the JSON is two fixed halves.
43
+ _APPEND_HEAD = '{"type": "input_audio_buffer.append", "audio": "'
44
+ _APPEND_TAIL = '"}'
45
+ _COMMIT = '{"type": "input_audio_buffer.commit"}'
46
+ # Commit alone does not make the model speak. This asks for the one line
47
+ # the session instructions allow: a short acknowledgement, not the answer.
48
+ _ACK = (
49
+ '{"type": "response.create", "response": {"instructions": '
50
+ '"Reply with one short acknowledgement of what the user just asked. '
51
+ 'One sentence. Do not answer, explain, or give steps."}}'
52
+ )
53
+
54
+ SpeakListener = Callable[[bool], None]
55
+ TranscriptListener = Callable[[str], None]
56
+
57
+
58
+ class VoiceStream:
59
+ """Background thread that owns the realtime socket and the microphone.
60
+
61
+ ``start`` and ``close`` never block the caller (the Textual loop), and
62
+ either may be called any number of times in any order. ``start`` after
63
+ ``close`` begins a new session at once; its thread first waits for the
64
+ previous one to release the socket and the microphone.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ on_speaking: SpeakListener | None = None,
71
+ on_transcript: TranscriptListener | None = None,
72
+ on_commit: Callable[[], None] | None = None,
73
+ ) -> None:
74
+ self._on_speaking = on_speaking
75
+ self._on_transcript = on_transcript
76
+ self._on_commit = on_commit
77
+ self._thread: threading.Thread | None = None
78
+ self._loop: asyncio.AbstractEventLoop | None = None
79
+ # Set when the current session's thread has exited.
80
+ self._closed = threading.Event()
81
+ self._error = ""
82
+ self._speaking = False
83
+ # The mic stays quiet while a coding run is in flight so the socket
84
+ # does not talk over the agent. It opens again for the final answer.
85
+ self._listen = threading.Event()
86
+ self._listen.set()
87
+ # Set from the UI thread (Enter). The mic callback consumes it.
88
+ self._commit_now = False
89
+ self._committed = False
90
+ self._transcript = ""
91
+ self._commit_sent = False
92
+ # A summary spoken before this session has connected.
93
+ self._pending_say = ""
94
+ # A summary-only session never opens the microphone.
95
+ self._speak_only = False
96
+ # One event per session, so closing an old session cannot stop a new one.
97
+ self._closing = threading.Event()
98
+ self._lifecycle_lock = threading.Lock()
99
+ self._session_task: asyncio.Task[None] | None = None
100
+ self._socket: Any = None
101
+ self._stop: asyncio.Event | None = None
102
+
103
+ @property
104
+ def speaking(self) -> bool:
105
+ return self._speaking
106
+
107
+ @property
108
+ def error(self) -> str:
109
+ return self._error
110
+
111
+ @property
112
+ def alive(self) -> bool:
113
+ """True while this session's socket thread is still running."""
114
+ thread = self._thread
115
+ return thread is not None and thread.is_alive() and not self._closing.is_set()
116
+
117
+ def start(self, *, speak_only: bool = False) -> None:
118
+ with self._lifecycle_lock:
119
+ previous = self._thread
120
+ if previous is not None and previous.is_alive() and not self._closing.is_set():
121
+ return
122
+ closing = threading.Event()
123
+ self._closing = closing
124
+ self._closed.clear()
125
+ self._error = ""
126
+ self._commit_now = False
127
+ self._committed = False
128
+ self._transcript = ""
129
+ self._commit_sent = False
130
+ # A summary may already be queued by say() before this session exists.
131
+ self._speak_only = speak_only
132
+ if speak_only:
133
+ self._listen.clear()
134
+ else:
135
+ self._pending_say = ""
136
+ self._listen.set()
137
+ thread = threading.Thread(
138
+ target=self._run, args=(previous, closing), name="grok-voice", daemon=True,
139
+ )
140
+ self._thread = thread
141
+ thread.start()
142
+ # Do not wait here. The caller is the Textual loop, and a connect that
143
+ # takes seconds freezes the cursor even though the terminal still
144
+ # accepts keystrokes.
145
+
146
+ def hold(self) -> None:
147
+ """Stop sending mic audio. The socket stays open until speech ends."""
148
+ self._listen.clear()
149
+
150
+ def resume(self) -> None:
151
+ """Send mic audio again, including after an utterance already committed."""
152
+ self._committed = False
153
+ self._commit_sent = False
154
+ self._commit_now = False
155
+ self._transcript = ""
156
+ self._listen.set()
157
+
158
+ def commit(self) -> None:
159
+ """End the utterance now. Enter lands here; a 3 s pause does too.
160
+
161
+ The flag is read on the audio callback, so this never touches the
162
+ socket or waits, and the UI thread stays free.
163
+ """
164
+ self._commit_now = True
165
+
166
+ def say(self, text: str) -> None:
167
+ """Speak ``text`` on this socket once it is connected.
168
+
169
+ Called before the socket exists, the line waits and is sent from the
170
+ session itself. It is never dropped on the floor.
171
+ """
172
+ spoken = " ".join(text.split())[:600]
173
+ if not spoken or self._closing.is_set():
174
+ return
175
+ self._pending_say = spoken
176
+ loop = self._loop
177
+ if loop is None:
178
+ return
179
+
180
+ def schedule() -> None:
181
+ if self._socket is not None and not self._closing.is_set() and self._pending_say:
182
+ pending = self._pending_say
183
+ self._pending_say = ""
184
+ asyncio.create_task(self._say(pending))
185
+
186
+ with contextlib.suppress(RuntimeError):
187
+ loop.call_soon_threadsafe(schedule)
188
+
189
+ def close(self, *, timeout: float = 0.0) -> None:
190
+ """Stop even a session that is still connecting; safe before start.
191
+
192
+ Only queues cancellation, so the Textual loop never waits on the
193
+ provider. ``timeout`` waits that long for the thread (app shutdown and
194
+ tests). A worker callback never joins itself.
195
+ """
196
+ with self._lifecycle_lock:
197
+ already_closing = self._closing.is_set()
198
+ self._closing.set()
199
+ thread = self._thread
200
+ loop = self._loop
201
+ if loop is not None and not already_closing:
202
+ def cancel() -> None:
203
+ if self._session_task is not None:
204
+ self._session_task.cancel()
205
+ with contextlib.suppress(RuntimeError):
206
+ loop.call_soon_threadsafe(cancel)
207
+ if timeout > 0 and thread is not None and threading.current_thread() is not thread:
208
+ thread.join(timeout=timeout)
209
+ with self._lifecycle_lock:
210
+ if self._thread is thread and (thread is None or not thread.is_alive()):
211
+ self._thread = None
212
+
213
+ def _run(self, previous: threading.Thread | None, closing: threading.Event) -> None:
214
+ # The previous session may still be closing its socket and mic. Wait
215
+ # here, off the UI thread, so two sessions never hold the device.
216
+ if previous is not None:
217
+ previous.join()
218
+ loop = asyncio.new_event_loop()
219
+ self._loop = loop
220
+ asyncio.set_event_loop(loop)
221
+ try:
222
+ self._session_task = loop.create_task(self._session())
223
+ if closing.is_set():
224
+ self._session_task.cancel()
225
+ loop.run_until_complete(self._session_task)
226
+ except asyncio.CancelledError:
227
+ pass
228
+ except Exception as exc:
229
+ self._error = f"Voice stream failed · {exc}"
230
+ finally:
231
+ pending = asyncio.all_tasks(loop)
232
+ for task in pending:
233
+ task.cancel()
234
+ if pending:
235
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
236
+ loop.run_until_complete(loop.shutdown_asyncgens())
237
+ self._socket = None
238
+ self._session_task = None
239
+ self._stop = None
240
+ self._loop = None
241
+ loop.close()
242
+ if self._thread is threading.current_thread():
243
+ self._closed.set()
244
+
245
+ async def _session(self) -> None:
246
+ try:
247
+ import websockets
248
+ except ImportError:
249
+ self._error = "Voice mode needs the websockets package."
250
+ return
251
+ try:
252
+ token, headers = grok_voice_credentials()
253
+ except Exception as exc: # UnavailableVoice or a credential read failure
254
+ self._error = str(exc)
255
+ return
256
+ query = urlencode({"model": VOICE_REALTIME_MODEL})
257
+ url = f"{REALTIME_URL}?{query}"
258
+ try:
259
+ socket = await websockets.connect(
260
+ url,
261
+ additional_headers={"Authorization": f"Bearer {token}", **headers},
262
+ max_size=8 * 1024 * 1024,
263
+ # The closing handshake must not hold the mic (and the next
264
+ # session) for websockets' default 10 s.
265
+ close_timeout=1,
266
+ )
267
+ except Exception as exc:
268
+ self._error = f"Voice stream failed · {exc}"
269
+ return
270
+ self._socket = socket
271
+ stop = self._stop = asyncio.Event()
272
+ tasks: list[asyncio.Task[Any]] = []
273
+ try:
274
+ await socket.send(json.dumps(_session_update()))
275
+ pending = self._pending_say
276
+ if pending:
277
+ self._pending_say = ""
278
+ await self._say(pending)
279
+ tasks = [
280
+ asyncio.create_task(self._pump_events(socket)),
281
+ asyncio.create_task(stop.wait()),
282
+ ]
283
+ # A summary connection has no microphone. Opening one would capture
284
+ # the room (and the speaker) for a line that is already text.
285
+ if not self._speak_only:
286
+ tasks.insert(0, asyncio.create_task(self._pump_mic(socket)))
287
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
288
+ for task in done:
289
+ task.result()
290
+ finally:
291
+ for task in tasks:
292
+ task.cancel()
293
+ await asyncio.gather(*tasks, return_exceptions=True)
294
+ self._socket = None
295
+ with contextlib.suppress(Exception):
296
+ await socket.close()
297
+
298
+ async def _say(self, text: str) -> None:
299
+ """Speak ``text`` on this same socket. It is not the chat answer.
300
+
301
+ The words are a separate spoken line. The coding transcript stays
302
+ whatever the chat model wrote.
303
+ """
304
+ socket = self._socket
305
+ if socket is None:
306
+ return
307
+ try:
308
+ await socket.send(
309
+ json.dumps(
310
+ {
311
+ "type": "conversation.item.create",
312
+ "item": {
313
+ "type": "message",
314
+ "role": "user",
315
+ "content": [{
316
+ "type": "input_text",
317
+ "text": f"Say this to the user, verbatim, and nothing else: {text}",
318
+ }],
319
+ },
320
+ }
321
+ )
322
+ )
323
+ await socket.send(json.dumps({
324
+ "type": "response.create",
325
+ "response": {"instructions": f"Say exactly: {text}"},
326
+ }))
327
+
328
+ except Exception as exc:
329
+ self._error = f"Voice stream failed · {exc}"
330
+ if self._stop is not None:
331
+ self._stop.set()
332
+
333
+ async def _pump_mic(self, socket: Any) -> None:
334
+ """Read PCM from the default input and append it to the voice buffer."""
335
+ try:
336
+ import sounddevice as sd
337
+ except ImportError as exc:
338
+ raise RuntimeError("Voice mode needs the sounddevice package.") from exc
339
+ except OSError as exc: # The binding imports, then cannot load PortAudio.
340
+ raise RuntimeError("Voice mode needs the PortAudio library.") from exc
341
+ loop = asyncio.get_running_loop()
342
+ queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=MIC_QUEUE_BLOCKS)
343
+ listen = self._listen
344
+ quiet_blocks = 0
345
+ heard_speech = False
346
+
347
+ def enqueue(chunk: bytes | None) -> None:
348
+ # This runs on the event loop, where QueueFull actually occurs.
349
+ # A commit marker is never dropped: it is what ends the utterance.
350
+ if chunk is None:
351
+ if queue.full():
352
+ queue.get_nowait()
353
+ queue.put_nowait(None)
354
+ return
355
+ if queue.full():
356
+ dropped = queue.get_nowait() # Keep recent audio, not the commit.
357
+ if dropped is None:
358
+ queue.get_nowait()
359
+ queue.put_nowait(None)
360
+ queue.put_nowait(chunk)
361
+
362
+ def _callback(indata: Any, frames: int, time_info: Any, status: Any) -> None:
363
+ nonlocal quiet_blocks, heard_speech
364
+ del frames, time_info, status
365
+ # Held or already committed: drop the block, no copy, no wakeup.
366
+ # resume() clears the committed flag, so the next utterance can
367
+ # be gathered without opening another microphone.
368
+ if not listen.is_set() or self._committed:
369
+ return
370
+ if self._commit_now:
371
+ self._commit_now = False
372
+ self._committed = True
373
+ try:
374
+ loop.call_soon_threadsafe(enqueue, None)
375
+ except RuntimeError:
376
+ return
377
+ return
378
+ # RMS reads the reused buffer; encoding is the one copy.
379
+ if _pcm16_rms(indata) >= SPEECH_RMS:
380
+ heard_speech = True
381
+ quiet_blocks = 0
382
+ elif heard_speech:
383
+ quiet_blocks += 1
384
+ chunk = base64.b64encode(indata)
385
+ try:
386
+ loop.call_soon_threadsafe(enqueue, chunk)
387
+ except RuntimeError:
388
+ return
389
+ if heard_speech and quiet_blocks >= PAUSE_COMMIT_BLOCKS and not self._committed:
390
+ self._committed = True
391
+ try:
392
+ loop.call_soon_threadsafe(enqueue, None)
393
+ except RuntimeError:
394
+ self._committed = False
395
+ return
396
+
397
+ try:
398
+ stream = sd.RawInputStream(
399
+ samplerate=SAMPLE_RATE,
400
+ channels=1,
401
+ dtype="int16",
402
+ callback=_callback,
403
+ blocksize=MIC_BLOCK_FRAMES,
404
+ )
405
+ except Exception as exc:
406
+ raise RuntimeError(f"Voice microphone failed · {exc}") from exc
407
+ with stream:
408
+ while True:
409
+ chunk = await queue.get()
410
+ if chunk is None:
411
+ await socket.send(_COMMIT)
412
+ await socket.send(_ACK)
413
+ # The mic stays open. Further blocks are dropped until the
414
+ # next listen calls resume(), so one commit does not kill
415
+ # the session and the next voice turn still has a socket.
416
+ self._listen.clear()
417
+ self._finish_commit()
418
+ continue
419
+ if not listen.is_set():
420
+ continue
421
+ await socket.send(_APPEND_HEAD + chunk.decode("ascii") + _APPEND_TAIL)
422
+
423
+ async def _pump_events(self, socket: Any) -> None:
424
+ player = _PcmPlayer(on_speaking=self._set_speaking)
425
+ try:
426
+ async for raw in socket:
427
+ if isinstance(raw, bytes):
428
+ player.write(raw)
429
+ continue
430
+ try:
431
+ event = json.loads(raw)
432
+ except json.JSONDecodeError:
433
+ continue
434
+ kind = str(event.get("type") or "")
435
+ if kind in {
436
+ "conversation.item.input_audio_transcription.delta",
437
+ "conversation.item.input_audio_transcription.completed",
438
+ }:
439
+ self._take_transcript(event, final=kind.endswith("completed"))
440
+ elif kind in {"response.output_audio.delta", "response.audio.delta"}:
441
+ audio = event.get("delta") or ""
442
+ if isinstance(audio, str) and audio:
443
+ player.write(base64.b64decode(audio))
444
+ elif kind in {"response.output_audio.done", "response.audio.done", "response.done"}:
445
+ # Play once. Some sessions send audio.done and then
446
+ # response.done; some send only response.done. The second
447
+ # call finds an empty buffer and does not cut the WAV.
448
+ # Hang up only after playback has started. The socket
449
+ # close that follows must not be what stops the sound.
450
+ if player.finish() and self._stop is not None:
451
+ self._stop.set()
452
+ finally:
453
+ # release() drops only audio that never got a done event. Playback
454
+ # that just started keeps running; close() would kill afplay and
455
+ # delete the WAV before the summary could be heard.
456
+ player.release()
457
+
458
+ def _take_transcript(self, event: dict[str, Any], *, final: bool) -> None:
459
+ """Publish the words heard so far. Deltas arrive while the user speaks."""
460
+ text = event.get("delta") or event.get("transcript") or event.get("text") or ""
461
+ if not isinstance(text, str) or not text:
462
+ return
463
+ if final or not self._transcript:
464
+ self._transcript = text
465
+ elif text.startswith(self._transcript):
466
+ self._transcript = text
467
+ else:
468
+ self._transcript = (self._transcript + text).strip()
469
+ if self._on_transcript is not None:
470
+ self._on_transcript(self._transcript)
471
+
472
+ def _finish_commit(self) -> None:
473
+ """The utterance is on the wire. Hand it to the caller once."""
474
+ if self._commit_sent or self._on_commit is None:
475
+ return
476
+ self._commit_sent = True
477
+ self._on_commit()
478
+
479
+ def _set_speaking(self, speaking: bool) -> None:
480
+ if speaking == self._speaking:
481
+ return
482
+ self._speaking = speaking
483
+ if self._on_speaking is not None:
484
+ self._on_speaking(speaking)
485
+
486
+
487
+ def _session_update() -> dict[str, Any]:
488
+ return {
489
+ "type": "session.update",
490
+ "session": {
491
+ "voice": GROK_TTS_VOICE,
492
+ "instructions": (
493
+ "You are Symphony's voice. Speak only a brief acknowledgement of what "
494
+ "the user just said. Do not answer the task, give steps, or continue "
495
+ "the conversation. When asked to say a specific sentence, say that "
496
+ "sentence and nothing else."
497
+ ),
498
+ # The client commits after a 3 s pause or Enter. Server VAD would
499
+ # cut the utterance on its own, shorter, silence.
500
+ "turn_detection": None,
501
+ "input_audio_transcription": {"model": VOICE_REALTIME_MODEL},
502
+ "audio": {
503
+ "input": {"format": {"type": "audio/pcm", "rate": SAMPLE_RATE}},
504
+ "output": {"format": {"type": "audio/pcm", "rate": SAMPLE_RATE}},
505
+ },
506
+ },
507
+ }
508
+
509
+
510
+ class _PcmPlayer:
511
+ """Play 16-bit PCM by writing a WAV and handing it to afplay or ffplay.
512
+
513
+ Streaming PCM into ffplay's stdin and then closing that stdin made ffplay
514
+ exit before the buffer played, so the reply produced no sound. One file
515
+ played after the response finishes does play, and it needs no NumPy.
516
+ """
517
+
518
+ def __init__(self, *, on_speaking: SpeakListener) -> None:
519
+ self._on_speaking = on_speaking
520
+ self._pcm = bytearray()
521
+ self._player: subprocess.Popen[bytes] | None = None
522
+ self._path: str | None = None
523
+ self._lock = threading.RLock()
524
+ self._closed = False
525
+
526
+ def write(self, pcm: bytes) -> None:
527
+ with self._lock:
528
+ if not self._closed:
529
+ self._pcm.extend(pcm)
530
+
531
+ def finish(self) -> bool:
532
+ """Start playback of the audio gathered so far. True once it is playing."""
533
+ with self._lock:
534
+ # audio.done and response.done can both arrive. Play whatever has
535
+ # arrived, once; the second call sees an empty buffer and leaves
536
+ # the player that is already running alone.
537
+ pcm = bytes(self._pcm)
538
+ self._pcm.clear()
539
+ if self._player is not None:
540
+ return True
541
+ if not pcm or self._closed:
542
+ return False
543
+ self._stop_player()
544
+ path = _write_wav(pcm)
545
+ try:
546
+ command = _wav_command(path)
547
+ if command is None:
548
+ return False
549
+ process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
550
+ self._player = process
551
+ self._path = path
552
+ self._on_speaking(True)
553
+ threading.Thread(target=self._wait, args=(process, path), daemon=True).start()
554
+ finally:
555
+ if self._path != path:
556
+ with contextlib.suppress(OSError):
557
+ os.remove(path)
558
+ return self._player is not None
559
+
560
+ def _wait(self, process: subprocess.Popen[bytes], path: str) -> None:
561
+ try:
562
+ process.wait(timeout=120)
563
+ except subprocess.TimeoutExpired:
564
+ with contextlib.suppress(OSError):
565
+ process.kill()
566
+ process.wait()
567
+ finally:
568
+ with self._lock:
569
+ with contextlib.suppress(OSError):
570
+ os.remove(path)
571
+ if self._player is process:
572
+ self._player = None
573
+ self._path = None
574
+ self._on_speaking(False)
575
+
576
+ def _stop_player(self) -> None:
577
+ process = self._player
578
+ path = self._path
579
+ self._player = None
580
+ self._path = None
581
+ if process is not None:
582
+ with contextlib.suppress(OSError):
583
+ process.kill()
584
+ process.wait()
585
+ if path is not None:
586
+ with contextlib.suppress(OSError):
587
+ os.remove(path)
588
+ if process is not None:
589
+ self._on_speaking(False)
590
+
591
+ def release(self) -> None:
592
+ """Drop audio that was never finished. Leave a playing WAV alone.
593
+
594
+ The socket closing is not the user cancelling. Killing the player here
595
+ is what made the spoken summary silent: finish() had just started
596
+ afplay, and this deleted its file.
597
+ """
598
+ with self._lock:
599
+ if self._closed:
600
+ return
601
+ self._closed = True
602
+ self._pcm.clear()
603
+
604
+ def close(self) -> None:
605
+ with self._lock:
606
+ if self._closed and self._player is None:
607
+ return
608
+ self._closed = True
609
+ self._pcm.clear()
610
+ self._stop_player()
611
+ self._on_speaking(False)
612
+
613
+
614
+ def _pcm16_rms(pcm: Any) -> int:
615
+ """RMS of one mono int16 block. No audioop and no NumPy: both cost an import."""
616
+ samples = memoryview(pcm).cast("B").cast("h")
617
+ count = len(samples)
618
+ if not count:
619
+ return 0
620
+ total = 0
621
+ for sample in samples:
622
+ total += sample * sample
623
+ return int((total / count) ** 0.5)
624
+
625
+
626
+ def _write_wav(pcm: bytes) -> str:
627
+ import tempfile
628
+ import wave
629
+
630
+ handle = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
631
+ path = handle.name
632
+ handle.close()
633
+ try:
634
+ with wave.open(path, "wb") as wav:
635
+ wav.setnchannels(1)
636
+ wav.setsampwidth(2)
637
+ wav.setframerate(SAMPLE_RATE)
638
+ wav.writeframes(pcm)
639
+ return path
640
+ except BaseException:
641
+ with contextlib.suppress(OSError):
642
+ os.remove(path)
643
+ raise
644
+
645
+
646
+ def _wav_command(path: str) -> list[str] | None:
647
+ if sys.platform == "darwin" and shutil.which("afplay"):
648
+ return ["afplay", path]
649
+ if shutil.which("ffplay"):
650
+ return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", path]
651
+ return None