echoact 0.1.0__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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
echoact/audio/player.py
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Streaming playback with a frame-accurate clock.
|
|
2
|
+
|
|
3
|
+
F-12 starts playback at the first ready segment and continues into segments
|
|
4
|
+
that do not exist yet, so this is not a file player with a progress bar: it
|
|
5
|
+
is a timeline that grows while it is being consumed.
|
|
6
|
+
|
|
7
|
+
Three decisions come straight from requirements.
|
|
8
|
+
|
|
9
|
+
**The clock is the audio callback's own frame counter, anchored to the
|
|
10
|
+
device's DAC time.** N-12 allows 300 ms between the sound and the
|
|
11
|
+
highlight, and it also asks for the output device's latency as a separate
|
|
12
|
+
figure. A player that reports "where I think I am" cannot supply either;
|
|
13
|
+
PortAudio hands the callback the moment its buffer will be *heard*, so
|
|
14
|
+
:meth:`Player.position_ms` extrapolates from that instant and is right to
|
|
15
|
+
within a buffer even between callbacks.
|
|
16
|
+
|
|
17
|
+
**Starvation does not advance the clock.** When the next segment is not
|
|
18
|
+
generated yet the callback emits silence, but the timeline stands still --
|
|
19
|
+
otherwise the mark would run off into text nobody has heard. Section 5.2's
|
|
20
|
+
"waiting for next segment" is that state, and F-28 keeps the previous
|
|
21
|
+
segment marked while it lasts.
|
|
22
|
+
|
|
23
|
+
**Audio is streamed from disk, never held whole.** N-21 forbids unbounded
|
|
24
|
+
in-memory loading and a 50,000 character document is around two hours of
|
|
25
|
+
44.1 kHz mono, so a feeder thread keeps a couple of seconds ahead and no
|
|
26
|
+
more.
|
|
27
|
+
|
|
28
|
+
This module is deliberately free of Qt: the GUI polls
|
|
29
|
+
:meth:`Player.position_ms` on its own repaint timer and pushes the value
|
|
30
|
+
into the reading surface. That is a repaint cadence, not a clock.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import threading
|
|
36
|
+
import wave
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from enum import StrEnum
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
from ..errors import Code, EchoActError
|
|
45
|
+
from ..util.logging import get_logger
|
|
46
|
+
from . import wav
|
|
47
|
+
|
|
48
|
+
log = get_logger("audio.player")
|
|
49
|
+
|
|
50
|
+
#: How far ahead the feeder reads. Large enough that a disk hiccup or a
|
|
51
|
+
#: scheduling gap cannot starve the device, small enough that a seek does
|
|
52
|
+
#: not have to discard much.
|
|
53
|
+
BUFFER_SECONDS = 1.5
|
|
54
|
+
#: Feeder read size. Roughly 25 ms at 44.1 kHz.
|
|
55
|
+
READ_FRAMES = 1024
|
|
56
|
+
#: Device buffer. PortAudio picks a default when this is 0, which on
|
|
57
|
+
#: Windows tends to be large; asking for a small one keeps the gap between
|
|
58
|
+
#: a pause and silence short enough not to be noticed.
|
|
59
|
+
BLOCKSIZE = 512
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PlayerState(StrEnum):
|
|
63
|
+
"""Section 5.2's playback states."""
|
|
64
|
+
|
|
65
|
+
NOT_READY = "not_ready"
|
|
66
|
+
PLAYING = "playing"
|
|
67
|
+
PAUSED = "paused"
|
|
68
|
+
WAITING = "waiting_for_segment"
|
|
69
|
+
STOPPED = "stopped"
|
|
70
|
+
ENDED = "ended"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Entry:
|
|
75
|
+
"""One segment's audio on the timeline, plus the silence after it.
|
|
76
|
+
|
|
77
|
+
The gap belongs to this entry, not to the next one: F-27 attributes
|
|
78
|
+
inter-segment silence to the preceding segment, so a position inside
|
|
79
|
+
the gap still marks the sentence that just finished.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
segment_index: int
|
|
83
|
+
path: str
|
|
84
|
+
frame_count: int
|
|
85
|
+
gap_frames: int
|
|
86
|
+
start_frame: int
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def total_frames(self) -> int:
|
|
90
|
+
return self.frame_count + self.gap_frames
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def end_frame(self) -> int:
|
|
94
|
+
return self.start_frame + self.total_frames
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Timeline:
|
|
99
|
+
"""The ordered, growing sequence of ready segments.
|
|
100
|
+
|
|
101
|
+
Appended to as generation proceeds (F-12) and never rewritten: a job's
|
|
102
|
+
segments arrive in order and their durations are fixed once measured.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
sample_rate: int
|
|
106
|
+
entries: list[Entry] = field(default_factory=list)
|
|
107
|
+
#: True once every segment of the job has been appended, so the player
|
|
108
|
+
#: can tell "the end" from "not generated yet".
|
|
109
|
+
complete: bool = False
|
|
110
|
+
|
|
111
|
+
def append(self, segment_index: int, path: str, frame_count: int, gap_frames: int) -> Entry:
|
|
112
|
+
entry = Entry(
|
|
113
|
+
segment_index=segment_index,
|
|
114
|
+
path=str(path),
|
|
115
|
+
frame_count=frame_count,
|
|
116
|
+
gap_frames=gap_frames,
|
|
117
|
+
start_frame=self.total_frames,
|
|
118
|
+
)
|
|
119
|
+
self.entries.append(entry)
|
|
120
|
+
return entry
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def total_frames(self) -> int:
|
|
124
|
+
return self.entries[-1].end_frame if self.entries else 0
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def duration_ms(self) -> int:
|
|
128
|
+
return wav.ms_for_frames(self.total_frames, self.sample_rate)
|
|
129
|
+
|
|
130
|
+
def locate(self, frame: int) -> tuple[int, int] | None:
|
|
131
|
+
"""Which entry covers ``frame``, and how far into it.
|
|
132
|
+
|
|
133
|
+
Returns ``None`` past the end of what exists, which is exactly the
|
|
134
|
+
condition F-14 uses to refuse a seek into ungenerated audio.
|
|
135
|
+
"""
|
|
136
|
+
if frame < 0 or frame >= self.total_frames:
|
|
137
|
+
return None
|
|
138
|
+
lo, hi = 0, len(self.entries) - 1
|
|
139
|
+
while lo <= hi:
|
|
140
|
+
mid = (lo + hi) // 2
|
|
141
|
+
e = self.entries[mid]
|
|
142
|
+
if frame < e.start_frame:
|
|
143
|
+
hi = mid - 1
|
|
144
|
+
elif frame >= e.end_frame:
|
|
145
|
+
lo = mid + 1
|
|
146
|
+
else:
|
|
147
|
+
return mid, frame - e.start_frame
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def segment_at(self, frame: int) -> int | None:
|
|
151
|
+
found = self.locate(frame)
|
|
152
|
+
return self.entries[found[0]].segment_index if found else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _Feeder:
|
|
156
|
+
"""Reads the timeline into a bounded byte ring, ahead of the callback.
|
|
157
|
+
|
|
158
|
+
Kept apart from the player so the one thing that touches the disk is
|
|
159
|
+
also the one thing that can be paused, flushed, and restarted on a seek
|
|
160
|
+
without the audio callback ever blocking on I/O.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, timeline: Timeline, capacity_frames: int) -> None:
|
|
164
|
+
self._timeline = timeline
|
|
165
|
+
self._capacity = capacity_frames * 2 # int16 mono
|
|
166
|
+
self._buf = bytearray()
|
|
167
|
+
self._lock = threading.Lock()
|
|
168
|
+
self._room = threading.Event()
|
|
169
|
+
self._room.set()
|
|
170
|
+
self._read_frame = 0 # next timeline frame to be read from disk
|
|
171
|
+
self._reader: wave.Wave_read | None = None
|
|
172
|
+
self._reader_index = -1
|
|
173
|
+
self._stop = threading.Event()
|
|
174
|
+
self._thread: threading.Thread | None = None
|
|
175
|
+
|
|
176
|
+
# -- lifecycle -----------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def start(self) -> None:
|
|
179
|
+
if self._thread is None:
|
|
180
|
+
self._thread = threading.Thread(target=self._run, name="echoact-feeder", daemon=True)
|
|
181
|
+
self._thread.start()
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
self._stop.set()
|
|
185
|
+
self._room.set()
|
|
186
|
+
t = self._thread
|
|
187
|
+
if t is not None:
|
|
188
|
+
t.join(timeout=2.0)
|
|
189
|
+
self._thread = None
|
|
190
|
+
self._close_reader()
|
|
191
|
+
|
|
192
|
+
# -- consumption ---------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def take(self, frames: int) -> bytes:
|
|
195
|
+
"""Up to ``frames`` frames. Short returns mean starvation."""
|
|
196
|
+
want = frames * 2
|
|
197
|
+
with self._lock:
|
|
198
|
+
out = bytes(self._buf[:want])
|
|
199
|
+
del self._buf[: len(out)]
|
|
200
|
+
if len(self._buf) < self._capacity:
|
|
201
|
+
self._room.set()
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
def seek(self, frame: int) -> None:
|
|
205
|
+
with self._lock:
|
|
206
|
+
self._buf.clear()
|
|
207
|
+
self._read_frame = frame
|
|
208
|
+
self._close_reader()
|
|
209
|
+
self._room.set()
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def read_frame(self) -> int:
|
|
213
|
+
return self._read_frame
|
|
214
|
+
|
|
215
|
+
# -- production ----------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def _run(self) -> None:
|
|
218
|
+
while not self._stop.is_set():
|
|
219
|
+
if len(self._buf) >= self._capacity:
|
|
220
|
+
self._room.clear()
|
|
221
|
+
self._room.wait(timeout=0.05)
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
chunk = self._read_next()
|
|
225
|
+
except EchoActError:
|
|
226
|
+
raise
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
log.warning("feeder read failed: %s", type(exc).__name__)
|
|
229
|
+
chunk = b""
|
|
230
|
+
if not chunk:
|
|
231
|
+
# Either the end of what exists, or a file that is not
|
|
232
|
+
# there yet. Either way, wait rather than spin.
|
|
233
|
+
self._stop.wait(0.02)
|
|
234
|
+
continue
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._buf += chunk
|
|
237
|
+
|
|
238
|
+
def _read_next(self) -> bytes:
|
|
239
|
+
found = self._timeline.locate(self._read_frame)
|
|
240
|
+
if found is None:
|
|
241
|
+
return b""
|
|
242
|
+
index, offset = found
|
|
243
|
+
entry = self._timeline.entries[index]
|
|
244
|
+
|
|
245
|
+
if offset >= entry.frame_count:
|
|
246
|
+
# Inside this segment's trailing silence. Silence is generated
|
|
247
|
+
# rather than stored: F-82 makes the gap the app's, and writing
|
|
248
|
+
# it to disk would make the segment file no longer a bit-exact
|
|
249
|
+
# part of the concatenation.
|
|
250
|
+
remaining = entry.total_frames - offset
|
|
251
|
+
n = min(remaining, READ_FRAMES)
|
|
252
|
+
self._read_frame += n
|
|
253
|
+
return b"\x00\x00" * n
|
|
254
|
+
|
|
255
|
+
reader = self._ensure_reader(index, offset)
|
|
256
|
+
n = min(entry.frame_count - offset, READ_FRAMES)
|
|
257
|
+
raw = reader.readframes(n)
|
|
258
|
+
got = len(raw) // 2
|
|
259
|
+
if got == 0:
|
|
260
|
+
return b""
|
|
261
|
+
self._read_frame += got
|
|
262
|
+
return raw
|
|
263
|
+
|
|
264
|
+
def _ensure_reader(self, index: int, offset: int) -> wave.Wave_read:
|
|
265
|
+
if self._reader is None or self._reader_index != index:
|
|
266
|
+
self._close_reader()
|
|
267
|
+
path = Path(self._timeline.entries[index].path)
|
|
268
|
+
self._reader = wave.open(str(path), "rb")
|
|
269
|
+
self._reader_index = index
|
|
270
|
+
self._reader.setpos(offset)
|
|
271
|
+
elif self._reader.tell() != offset:
|
|
272
|
+
self._reader.setpos(offset)
|
|
273
|
+
return self._reader
|
|
274
|
+
|
|
275
|
+
def _close_reader(self) -> None:
|
|
276
|
+
if self._reader is not None:
|
|
277
|
+
try:
|
|
278
|
+
self._reader.close()
|
|
279
|
+
except Exception: # noqa: BLE001 - closing must never raise onward
|
|
280
|
+
pass
|
|
281
|
+
self._reader = None
|
|
282
|
+
self._reader_index = -1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Player:
|
|
286
|
+
"""Plays a :class:`Timeline` through PortAudio.
|
|
287
|
+
|
|
288
|
+
Volume is applied to the outgoing samples only. F-67 requires playback
|
|
289
|
+
volume not to alter the generated WAV, and at 100% the samples are
|
|
290
|
+
passed through untouched rather than multiplied by one, so no rounding
|
|
291
|
+
happens on the ordinary path either.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
*,
|
|
297
|
+
on_state: Callable[[PlayerState], None] | None = None,
|
|
298
|
+
on_segment: Callable[[int], None] | None = None,
|
|
299
|
+
on_device_lost: Callable[[str], None] | None = None,
|
|
300
|
+
) -> None:
|
|
301
|
+
self._timeline: Timeline | None = None
|
|
302
|
+
self._feeder: _Feeder | None = None
|
|
303
|
+
self._stream = None # sounddevice.RawOutputStream
|
|
304
|
+
self._device: int | str | None = None
|
|
305
|
+
self._sample_rate = 0
|
|
306
|
+
|
|
307
|
+
self._state = PlayerState.NOT_READY
|
|
308
|
+
self._played_frames = 0
|
|
309
|
+
self._anchor_frames = 0
|
|
310
|
+
self._anchor_time = 0.0
|
|
311
|
+
self._current_segment: int | None = None
|
|
312
|
+
self._lock = threading.Lock()
|
|
313
|
+
|
|
314
|
+
self._volume = 1.0
|
|
315
|
+
self._muted = False
|
|
316
|
+
|
|
317
|
+
self._on_state = on_state
|
|
318
|
+
self._on_segment = on_segment
|
|
319
|
+
self._on_device_lost = on_device_lost
|
|
320
|
+
|
|
321
|
+
# -- configuration --------------------------------------------------
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def state(self) -> PlayerState:
|
|
325
|
+
return self._state
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def volume(self) -> float:
|
|
329
|
+
return self._volume
|
|
330
|
+
|
|
331
|
+
def set_volume(self, value: float) -> None:
|
|
332
|
+
self._volume = max(0.0, min(1.0, value))
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def muted(self) -> bool:
|
|
336
|
+
return self._muted
|
|
337
|
+
|
|
338
|
+
def set_muted(self, value: bool) -> None:
|
|
339
|
+
self._muted = value
|
|
340
|
+
|
|
341
|
+
def set_device(self, device: int | str | None) -> None:
|
|
342
|
+
"""Choose an output device (F-67).
|
|
343
|
+
|
|
344
|
+
Changing it while playing reopens the stream at the current
|
|
345
|
+
position rather than restarting, because the user chose a device,
|
|
346
|
+
not a rewind.
|
|
347
|
+
"""
|
|
348
|
+
if device == self._device:
|
|
349
|
+
return
|
|
350
|
+
self._device = device
|
|
351
|
+
if self._stream is not None:
|
|
352
|
+
was = self._state
|
|
353
|
+
at = self._played_frames
|
|
354
|
+
self._close_stream()
|
|
355
|
+
self._open_stream()
|
|
356
|
+
self.seek_frames(at)
|
|
357
|
+
if was is PlayerState.PLAYING:
|
|
358
|
+
self.play()
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def output_latency_ms(self) -> float:
|
|
362
|
+
"""N-12 asks for the device's latency as a separate, recorded
|
|
363
|
+
figure rather than folded into the synchronisation budget."""
|
|
364
|
+
if self._stream is None:
|
|
365
|
+
return 0.0
|
|
366
|
+
return float(self._stream.latency) * 1000.0
|
|
367
|
+
|
|
368
|
+
# -- the timeline ---------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def load(self, timeline: Timeline) -> None:
|
|
371
|
+
"""Attach a job's timeline. Does not start playback: F-51 forbids
|
|
372
|
+
an external request from auto-playing, and F-83 lets the user turn
|
|
373
|
+
auto-play off, so starting is always someone else's decision."""
|
|
374
|
+
self.stop()
|
|
375
|
+
self._timeline = timeline
|
|
376
|
+
self._sample_rate = timeline.sample_rate
|
|
377
|
+
capacity = int(BUFFER_SECONDS * timeline.sample_rate)
|
|
378
|
+
self._feeder = _Feeder(timeline, capacity)
|
|
379
|
+
self._played_frames = 0
|
|
380
|
+
self._current_segment = None
|
|
381
|
+
self._set_state(PlayerState.NOT_READY if not timeline.entries else PlayerState.STOPPED)
|
|
382
|
+
|
|
383
|
+
def timeline_grew(self) -> None:
|
|
384
|
+
"""Tell the player that segments were appended.
|
|
385
|
+
|
|
386
|
+
F-13 keeps the user's paused state even when new audio arrives, so
|
|
387
|
+
this never changes the state except to lift the waiting condition.
|
|
388
|
+
"""
|
|
389
|
+
if self._state is PlayerState.WAITING:
|
|
390
|
+
self._set_state(PlayerState.PLAYING)
|
|
391
|
+
elif self._state is PlayerState.NOT_READY and self._timeline and self._timeline.entries:
|
|
392
|
+
self._set_state(PlayerState.STOPPED)
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def playable_ms(self) -> int:
|
|
396
|
+
return self._timeline.duration_ms if self._timeline else 0
|
|
397
|
+
|
|
398
|
+
# -- transport -------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def play(self) -> None:
|
|
401
|
+
if self._timeline is None or not self._timeline.entries:
|
|
402
|
+
return
|
|
403
|
+
if self._stream is None:
|
|
404
|
+
self._open_stream()
|
|
405
|
+
assert self._feeder is not None
|
|
406
|
+
self._feeder.start()
|
|
407
|
+
with self._lock:
|
|
408
|
+
self._anchor_frames = self._played_frames
|
|
409
|
+
self._anchor_time = self._stream_time()
|
|
410
|
+
self._set_state(PlayerState.PLAYING)
|
|
411
|
+
|
|
412
|
+
def pause(self) -> None:
|
|
413
|
+
"""F-13: pausing does not halt generation, and the position stands."""
|
|
414
|
+
if self._state in (PlayerState.PLAYING, PlayerState.WAITING):
|
|
415
|
+
self._set_state(PlayerState.PAUSED)
|
|
416
|
+
|
|
417
|
+
def stop(self) -> None:
|
|
418
|
+
"""F-13 and F-28: stop clears the position and the mark."""
|
|
419
|
+
if self._feeder is not None:
|
|
420
|
+
self._feeder.seek(0)
|
|
421
|
+
with self._lock:
|
|
422
|
+
self._played_frames = 0
|
|
423
|
+
self._anchor_frames = 0
|
|
424
|
+
self._current_segment = None
|
|
425
|
+
if self._state is not PlayerState.NOT_READY:
|
|
426
|
+
self._set_state(PlayerState.STOPPED)
|
|
427
|
+
|
|
428
|
+
def close(self) -> None:
|
|
429
|
+
self._close_stream()
|
|
430
|
+
if self._feeder is not None:
|
|
431
|
+
self._feeder.close()
|
|
432
|
+
self._feeder = None
|
|
433
|
+
self._timeline = None
|
|
434
|
+
self._set_state(PlayerState.NOT_READY)
|
|
435
|
+
|
|
436
|
+
def seek_ms(self, ms: int) -> bool:
|
|
437
|
+
"""F-14: seeking is allowed only inside generated segments."""
|
|
438
|
+
if self._timeline is None:
|
|
439
|
+
return False
|
|
440
|
+
frame = wav.frames_for_ms(max(0, ms), self._timeline.sample_rate)
|
|
441
|
+
return self.seek_frames(frame)
|
|
442
|
+
|
|
443
|
+
def seek_frames(self, frame: int) -> bool:
|
|
444
|
+
if self._timeline is None:
|
|
445
|
+
return False
|
|
446
|
+
if frame < 0 or frame >= max(1, self._timeline.total_frames):
|
|
447
|
+
return False
|
|
448
|
+
assert self._feeder is not None
|
|
449
|
+
self._feeder.seek(frame)
|
|
450
|
+
with self._lock:
|
|
451
|
+
self._played_frames = frame
|
|
452
|
+
self._anchor_frames = frame
|
|
453
|
+
self._anchor_time = self._stream_time()
|
|
454
|
+
seg = self._timeline.segment_at(frame)
|
|
455
|
+
if seg is not None and seg != self._current_segment:
|
|
456
|
+
self._current_segment = seg
|
|
457
|
+
if self._on_segment:
|
|
458
|
+
self._on_segment(seg)
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
# -- the clock --------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def position_ms(self) -> int:
|
|
464
|
+
"""Where the listener is, now.
|
|
465
|
+
|
|
466
|
+
Extrapolated from the moment the last filled buffer will reach the
|
|
467
|
+
device, so it is continuous between callbacks rather than stepping
|
|
468
|
+
once per block. Clamped to what has actually been handed to the
|
|
469
|
+
device, so it can never report audio nobody has heard.
|
|
470
|
+
"""
|
|
471
|
+
if self._timeline is None or self._sample_rate == 0:
|
|
472
|
+
return 0
|
|
473
|
+
with self._lock:
|
|
474
|
+
anchor_f = self._anchor_frames
|
|
475
|
+
anchor_t = self._anchor_time
|
|
476
|
+
played = self._played_frames
|
|
477
|
+
if self._state is not PlayerState.PLAYING:
|
|
478
|
+
return wav.ms_for_frames(played, self._sample_rate)
|
|
479
|
+
elapsed = max(0.0, self._stream_time() - anchor_t)
|
|
480
|
+
frames = anchor_f + int(elapsed * self._sample_rate)
|
|
481
|
+
return wav.ms_for_frames(min(frames, played), self._sample_rate)
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def current_segment(self) -> int | None:
|
|
485
|
+
return self._current_segment
|
|
486
|
+
|
|
487
|
+
def _stream_time(self) -> float:
|
|
488
|
+
if self._stream is None:
|
|
489
|
+
return 0.0
|
|
490
|
+
try:
|
|
491
|
+
return float(self._stream.time)
|
|
492
|
+
except Exception: # noqa: BLE001 - a closing stream must not raise here
|
|
493
|
+
return 0.0
|
|
494
|
+
|
|
495
|
+
# -- the device --------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def _open_stream(self) -> None:
|
|
498
|
+
import sounddevice as sd
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
self._stream = sd.RawOutputStream(
|
|
502
|
+
samplerate=self._sample_rate,
|
|
503
|
+
channels=1,
|
|
504
|
+
dtype="int16",
|
|
505
|
+
blocksize=BLOCKSIZE,
|
|
506
|
+
device=self._device,
|
|
507
|
+
callback=self._callback,
|
|
508
|
+
finished_callback=self._finished,
|
|
509
|
+
)
|
|
510
|
+
self._stream.start()
|
|
511
|
+
except Exception as exc: # sounddevice raises several unrelated types
|
|
512
|
+
self._stream = None
|
|
513
|
+
raise EchoActError(
|
|
514
|
+
Code.OUTPUT_DEVICE_UNAVAILABLE,
|
|
515
|
+
"The audio output device could not be opened.",
|
|
516
|
+
cause=exc,
|
|
517
|
+
) from exc
|
|
518
|
+
|
|
519
|
+
def _close_stream(self) -> None:
|
|
520
|
+
stream, self._stream = self._stream, None
|
|
521
|
+
if stream is None:
|
|
522
|
+
return
|
|
523
|
+
try:
|
|
524
|
+
stream.stop()
|
|
525
|
+
stream.close()
|
|
526
|
+
except Exception: # noqa: BLE001 - shutdown must not raise onward
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
def _finished(self) -> None:
|
|
530
|
+
"""PortAudio aborted the stream, which on Windows is what a device
|
|
531
|
+
being unplugged looks like. F-67 pauses rather than switching to
|
|
532
|
+
another speaker without the user."""
|
|
533
|
+
if self._state in (PlayerState.PLAYING, PlayerState.WAITING):
|
|
534
|
+
self._set_state(PlayerState.PAUSED)
|
|
535
|
+
if self._on_device_lost:
|
|
536
|
+
self._on_device_lost("the output device stopped")
|
|
537
|
+
|
|
538
|
+
def _callback(self, outdata, frames: int, time_info, status) -> None:
|
|
539
|
+
"""Runs on PortAudio's thread. No allocation beyond the block, no
|
|
540
|
+
locks held across I/O, and no exceptions -- a raise here silences
|
|
541
|
+
the stream."""
|
|
542
|
+
try:
|
|
543
|
+
self._fill(outdata, frames, time_info)
|
|
544
|
+
except Exception as exc: # noqa: BLE001 - never let the device die
|
|
545
|
+
outdata[:] = b"\x00" * (frames * 2)
|
|
546
|
+
log.error("audio callback failed: %s", type(exc).__name__)
|
|
547
|
+
|
|
548
|
+
def _fill(self, outdata, frames: int, time_info) -> None:
|
|
549
|
+
silence = b"\x00\x00" * frames
|
|
550
|
+
with self._lock:
|
|
551
|
+
self._anchor_frames = self._played_frames
|
|
552
|
+
self._anchor_time = float(getattr(time_info, "outputBufferDacTime", 0.0)) or (
|
|
553
|
+
self._stream_time()
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
if self._state is not PlayerState.PLAYING or self._feeder is None:
|
|
557
|
+
outdata[:] = silence
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
raw = self._feeder.take(frames)
|
|
561
|
+
got = len(raw) // 2
|
|
562
|
+
if got:
|
|
563
|
+
raw = self._apply_gain(raw)
|
|
564
|
+
if got < frames:
|
|
565
|
+
raw = raw + b"\x00\x00" * (frames - got)
|
|
566
|
+
outdata[:] = raw
|
|
567
|
+
|
|
568
|
+
if got == 0:
|
|
569
|
+
self._starved()
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
with self._lock:
|
|
573
|
+
self._played_frames += got
|
|
574
|
+
self._note_position()
|
|
575
|
+
|
|
576
|
+
def _apply_gain(self, raw: bytes) -> bytes:
|
|
577
|
+
if self._muted:
|
|
578
|
+
return b"\x00" * len(raw)
|
|
579
|
+
if self._volume >= 0.999:
|
|
580
|
+
return raw # bit-exact passthrough at full volume
|
|
581
|
+
block = np.frombuffer(raw, dtype="<i2").astype(np.float32) * self._volume
|
|
582
|
+
return np.clip(block, -32768, 32767).astype("<i2").tobytes()
|
|
583
|
+
|
|
584
|
+
def _starved(self) -> None:
|
|
585
|
+
"""Nothing to play. Either the job ended or the next segment is
|
|
586
|
+
not generated yet, and Section 5.2 distinguishes those."""
|
|
587
|
+
tl = self._timeline
|
|
588
|
+
if tl is None:
|
|
589
|
+
return
|
|
590
|
+
at_end = self._played_frames >= tl.total_frames
|
|
591
|
+
if at_end and tl.complete:
|
|
592
|
+
self._set_state(PlayerState.ENDED)
|
|
593
|
+
elif at_end:
|
|
594
|
+
self._set_state(PlayerState.WAITING)
|
|
595
|
+
|
|
596
|
+
def _note_position(self) -> None:
|
|
597
|
+
tl = self._timeline
|
|
598
|
+
if tl is None:
|
|
599
|
+
return
|
|
600
|
+
seg = tl.segment_at(max(0, self._played_frames - 1))
|
|
601
|
+
if seg is not None and seg != self._current_segment:
|
|
602
|
+
self._current_segment = seg
|
|
603
|
+
if self._on_segment:
|
|
604
|
+
self._on_segment(seg)
|
|
605
|
+
|
|
606
|
+
def _set_state(self, state: PlayerState) -> None:
|
|
607
|
+
if state is self._state:
|
|
608
|
+
return
|
|
609
|
+
self._state = state
|
|
610
|
+
if self._on_state:
|
|
611
|
+
self._on_state(state)
|