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,287 @@
|
|
|
1
|
+
"""Speak the model's own text as it streams, not a canned line at the end.
|
|
2
|
+
|
|
3
|
+
One websocket to ``wss://api.x.ai/v1/tts`` stays open for the voice turn.
|
|
4
|
+
Completed sentences are sent as they arrive, and the MP3 frames that come back
|
|
5
|
+
are handed to the player immediately. Waiting for ``after_run`` and then
|
|
6
|
+
building one file is what made the reply feel late.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Callable
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
from coding_agent.addons.voice.credentials import GROK_TTS_VOICE, grok_voice_credentials
|
|
23
|
+
from coding_agent.addons.voice.playback import play_mp3
|
|
24
|
+
|
|
25
|
+
TTS_URL = "wss://api.x.ai/v1/tts"
|
|
26
|
+
SpeakListener = Callable[[bool], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StreamingSpeaker:
|
|
30
|
+
"""Background TTS socket. ``feed`` takes model text; ``finish`` ends it."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, on_speaking: SpeakListener | None = None) -> None:
|
|
33
|
+
self._on_speaking = on_speaking
|
|
34
|
+
self._loop = asyncio.new_event_loop()
|
|
35
|
+
self._thread = threading.Thread(target=self._run, name="grok-tts", daemon=True)
|
|
36
|
+
# call_soon_threadsafe queues callbacks even before run_until_complete starts.
|
|
37
|
+
# One sender preserves delta/done ordering while the connection is pending.
|
|
38
|
+
self._commands: asyncio.Queue[str | None] = asyncio.Queue()
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
self._closed = False
|
|
41
|
+
self._finished = False
|
|
42
|
+
self._sent = ""
|
|
43
|
+
self._pending = ""
|
|
44
|
+
self._error = ""
|
|
45
|
+
self._task: asyncio.Task[None] | None = None
|
|
46
|
+
self._thread.start()
|
|
47
|
+
|
|
48
|
+
def feed(self, text: str, *, force: bool = False) -> None:
|
|
49
|
+
"""Queue newly speakable text. ``force`` sends the current clause now."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
if self._closed or self._finished or not text:
|
|
52
|
+
return
|
|
53
|
+
self._feed(text, force=force)
|
|
54
|
+
|
|
55
|
+
def _feed(self, text: str, *, force: bool = False) -> None:
|
|
56
|
+
fresh = text[len(self._sent):] if text.startswith(self._sent) else text
|
|
57
|
+
self._pending += fresh
|
|
58
|
+
self._sent = text
|
|
59
|
+
speakable, rest = _split_ready(self._pending)
|
|
60
|
+
if force and not speakable and self._pending.strip():
|
|
61
|
+
speakable, rest = self._pending.strip(), ""
|
|
62
|
+
self._pending = rest
|
|
63
|
+
if speakable:
|
|
64
|
+
self._submit(speakable)
|
|
65
|
+
|
|
66
|
+
def finish(self, text: str = "") -> None:
|
|
67
|
+
"""Speak whatever remains, then close the socket after playback."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
if self._closed or self._finished:
|
|
70
|
+
return
|
|
71
|
+
if text:
|
|
72
|
+
self._feed(text)
|
|
73
|
+
if self._pending.strip():
|
|
74
|
+
self._submit(self._pending.strip())
|
|
75
|
+
self._pending = ""
|
|
76
|
+
self._finished = True
|
|
77
|
+
self._submit(None)
|
|
78
|
+
|
|
79
|
+
def close(self, *, timeout: float = 0.0) -> None:
|
|
80
|
+
"""Cancel the session. Idempotent; does not block unless ``timeout``.
|
|
81
|
+
|
|
82
|
+
The UI thread calls this, so by default it only queues cancellation
|
|
83
|
+
and the worker cleans up the socket and player on its own. App
|
|
84
|
+
shutdown passes a ``timeout`` to wait for that, bounded in case the
|
|
85
|
+
provider is unresponsive. A listener on the worker never joins itself.
|
|
86
|
+
"""
|
|
87
|
+
with self._lock:
|
|
88
|
+
if not self._closed:
|
|
89
|
+
self._closed = True
|
|
90
|
+
with contextlib.suppress(RuntimeError):
|
|
91
|
+
self._loop.call_soon_threadsafe(self._cancel)
|
|
92
|
+
if timeout > 0 and threading.current_thread() is not self._thread:
|
|
93
|
+
self._thread.join(timeout=timeout)
|
|
94
|
+
|
|
95
|
+
def _submit(self, text: str | None) -> None:
|
|
96
|
+
with contextlib.suppress(RuntimeError):
|
|
97
|
+
self._loop.call_soon_threadsafe(self._commands.put_nowait, text)
|
|
98
|
+
|
|
99
|
+
def _cancel(self) -> None:
|
|
100
|
+
if self._task is not None:
|
|
101
|
+
self._task.cancel()
|
|
102
|
+
|
|
103
|
+
def _run(self) -> None:
|
|
104
|
+
asyncio.set_event_loop(self._loop)
|
|
105
|
+
try:
|
|
106
|
+
self._task = self._loop.create_task(self._session())
|
|
107
|
+
if self._closed:
|
|
108
|
+
self._task.cancel()
|
|
109
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
110
|
+
self._loop.run_until_complete(self._task)
|
|
111
|
+
finally:
|
|
112
|
+
self._loop.close()
|
|
113
|
+
|
|
114
|
+
async def _session(self) -> None:
|
|
115
|
+
socket = None
|
|
116
|
+
player = None
|
|
117
|
+
tasks: list[asyncio.Task[None]] = []
|
|
118
|
+
try:
|
|
119
|
+
import websockets
|
|
120
|
+
|
|
121
|
+
token, headers = grok_voice_credentials()
|
|
122
|
+
query = urlencode({
|
|
123
|
+
"voice": GROK_TTS_VOICE,
|
|
124
|
+
"language": "en",
|
|
125
|
+
"codec": "mp3",
|
|
126
|
+
"optimize_streaming_latency": 1,
|
|
127
|
+
})
|
|
128
|
+
socket = await websockets.connect(
|
|
129
|
+
f"{TTS_URL}?{query}",
|
|
130
|
+
additional_headers={"Authorization": f"Bearer {token}", **headers},
|
|
131
|
+
max_size=8 * 1024 * 1024,
|
|
132
|
+
)
|
|
133
|
+
player = self._player = _Mp3Stream(on_speaking=self._mark)
|
|
134
|
+
tasks = [asyncio.create_task(self._listen(socket)), asyncio.create_task(self._send(socket))]
|
|
135
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
136
|
+
for task in done:
|
|
137
|
+
task.result()
|
|
138
|
+
except ImportError:
|
|
139
|
+
self._error = "Voice playback needs the websockets package."
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
self._error = f"Voice playback failed · {exc}"
|
|
142
|
+
finally:
|
|
143
|
+
for task in tasks:
|
|
144
|
+
task.cancel()
|
|
145
|
+
if player is not None:
|
|
146
|
+
with contextlib.suppress(Exception):
|
|
147
|
+
player.close()
|
|
148
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
149
|
+
if socket is not None:
|
|
150
|
+
with contextlib.suppress(Exception):
|
|
151
|
+
await socket.close()
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._closed = True
|
|
154
|
+
# Startup failures and empty audio still need to release the voice UI.
|
|
155
|
+
self._mark(False)
|
|
156
|
+
|
|
157
|
+
async def _send(self, socket: object) -> None:
|
|
158
|
+
while True:
|
|
159
|
+
text = await self._commands.get()
|
|
160
|
+
event = {"type": "text.done"} if text is None else {"type": "text.delta", "delta": text + " "}
|
|
161
|
+
await socket.send(json.dumps(event)) # type: ignore[attr-defined]
|
|
162
|
+
|
|
163
|
+
async def _listen(self, socket: object) -> None:
|
|
164
|
+
player: _Mp3Stream = self._player
|
|
165
|
+
async for raw in socket: # type: ignore[attr-defined]
|
|
166
|
+
if isinstance(raw, bytes):
|
|
167
|
+
player.write(raw)
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
event = json.loads(raw)
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
continue
|
|
173
|
+
kind = str(event.get("type") or "")
|
|
174
|
+
if kind == "audio.delta":
|
|
175
|
+
payload = event.get("delta") or event.get("audio") or ""
|
|
176
|
+
if isinstance(payload, str) and payload:
|
|
177
|
+
player.write(base64.b64decode(payload))
|
|
178
|
+
elif kind == "audio.done":
|
|
179
|
+
break
|
|
180
|
+
elif kind == "error":
|
|
181
|
+
self._error = str(event.get("message") or "voice playback failed")
|
|
182
|
+
break
|
|
183
|
+
# Always finish. An error or a missing streaming player leaves the MP3
|
|
184
|
+
# in the buffer, and finish() plays that file. Returning early was
|
|
185
|
+
# dropping it.
|
|
186
|
+
await asyncio.to_thread(player.finish)
|
|
187
|
+
|
|
188
|
+
def _mark(self, speaking: bool) -> None:
|
|
189
|
+
if self._on_speaking is not None:
|
|
190
|
+
self._on_speaking(speaking)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _split_ready(pending: str) -> tuple[str, str]:
|
|
194
|
+
"""Return text that ends on a sentence boundary, and the unfinished tail.
|
|
195
|
+
|
|
196
|
+
The boundary is the punctuation itself. Waiting for the space after it held
|
|
197
|
+
the whole sentence until the next word arrived.
|
|
198
|
+
"""
|
|
199
|
+
cut = max(pending.rfind("."), pending.rfind("?"), pending.rfind("!"), pending.rfind("\n"))
|
|
200
|
+
if cut < 0:
|
|
201
|
+
return "", pending
|
|
202
|
+
return pending[: cut + 1].strip(), pending[cut + 1:]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _Mp3Stream:
|
|
206
|
+
"""Play MP3 bytes as they arrive. ffmpeg reads the stream; afplay cannot."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, *, on_speaking: SpeakListener) -> None:
|
|
209
|
+
self._on_speaking = on_speaking
|
|
210
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
211
|
+
self._buffer = bytearray()
|
|
212
|
+
|
|
213
|
+
def write(self, mp3: bytes) -> None:
|
|
214
|
+
if not mp3:
|
|
215
|
+
return
|
|
216
|
+
if self._process is None:
|
|
217
|
+
command = _mp3_stream_command()
|
|
218
|
+
if command is None:
|
|
219
|
+
self._buffer.extend(mp3)
|
|
220
|
+
return
|
|
221
|
+
self._process = subprocess.Popen(
|
|
222
|
+
command,
|
|
223
|
+
stdin=subprocess.PIPE,
|
|
224
|
+
stdout=subprocess.DEVNULL,
|
|
225
|
+
stderr=subprocess.DEVNULL,
|
|
226
|
+
)
|
|
227
|
+
self._on_speaking(True)
|
|
228
|
+
assert self._process.stdin is not None
|
|
229
|
+
try:
|
|
230
|
+
self._process.stdin.write(mp3)
|
|
231
|
+
self._process.stdin.flush()
|
|
232
|
+
except (BrokenPipeError, OSError):
|
|
233
|
+
self.finish()
|
|
234
|
+
|
|
235
|
+
def finish(self) -> None:
|
|
236
|
+
process = self._process
|
|
237
|
+
if process is None:
|
|
238
|
+
# macOS has afplay and no ffplay or mpv. afplay cannot read an
|
|
239
|
+
# MP3 stream, so the bytes were buffered above and never played.
|
|
240
|
+
if self._buffer:
|
|
241
|
+
audio = bytes(self._buffer)
|
|
242
|
+
self._buffer.clear()
|
|
243
|
+
self._on_speaking(True)
|
|
244
|
+
try:
|
|
245
|
+
play_mp3(audio)
|
|
246
|
+
finally:
|
|
247
|
+
self._on_speaking(False)
|
|
248
|
+
return
|
|
249
|
+
try:
|
|
250
|
+
if process.stdin is not None:
|
|
251
|
+
with contextlib.suppress(OSError):
|
|
252
|
+
process.stdin.close()
|
|
253
|
+
try:
|
|
254
|
+
process.wait(timeout=30)
|
|
255
|
+
except subprocess.TimeoutExpired:
|
|
256
|
+
process.kill()
|
|
257
|
+
process.wait(timeout=1)
|
|
258
|
+
finally:
|
|
259
|
+
if self._process is process:
|
|
260
|
+
self._process = None
|
|
261
|
+
self._on_speaking(False)
|
|
262
|
+
|
|
263
|
+
def close(self) -> None:
|
|
264
|
+
process = self._process
|
|
265
|
+
self._process = None
|
|
266
|
+
self._buffer.clear()
|
|
267
|
+
try:
|
|
268
|
+
if process is not None:
|
|
269
|
+
with contextlib.suppress(OSError):
|
|
270
|
+
process.kill()
|
|
271
|
+
if process.stdin is not None:
|
|
272
|
+
with contextlib.suppress(OSError):
|
|
273
|
+
process.stdin.close()
|
|
274
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
275
|
+
process.wait(timeout=1)
|
|
276
|
+
finally:
|
|
277
|
+
self._on_speaking(False)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _mp3_stream_command() -> list[str] | None:
|
|
281
|
+
if shutil.which("ffplay"):
|
|
282
|
+
return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-"]
|
|
283
|
+
if shutil.which("mpv"):
|
|
284
|
+
return ["mpv", "--no-video", "--really-quiet", "-"]
|
|
285
|
+
if sys.platform != "darwin":
|
|
286
|
+
return None
|
|
287
|
+
return None
|