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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)