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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- 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
|