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
echoact/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """EchoAct — local Korean/English speech generation."""
2
+
3
+ __version__ = "0.1.0"
echoact/__main__.py ADDED
@@ -0,0 +1,117 @@
1
+ """Launching EchoAct.
2
+
3
+ F-85 allows one instance per user account, so the very first thing is to
4
+ ask whether one is already running -- before Qt is initialised, before the
5
+ database is opened, and above all before a second worker or a second REST
6
+ listener could exist.
7
+
8
+ F-79 then decides the shape of the rest: the service is started after the
9
+ window, and a failure to start it is a notice on that window rather than a
10
+ reason not to have one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+
17
+ from .errors import EchoActError
18
+ from .instance import AlreadyRunning, acquire
19
+ from .util.logging import configure, get_logger
20
+
21
+ log = get_logger("main")
22
+
23
+
24
+ def _fatal(message: str, lock) -> None:
25
+ """Report and let go of the single-instance lock.
26
+
27
+ Releasing matters: a failed start that keeps the lock makes the next
28
+ attempt look like F-85's "another instance is running", and the user
29
+ would then be told the opposite of what happened.
30
+ """
31
+ try:
32
+ from PySide6.QtWidgets import QMessageBox
33
+
34
+ QMessageBox.critical(None, "EchoAct", message)
35
+ finally:
36
+ lock.release()
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ configure()
41
+ argv = list(sys.argv if argv is None else argv)
42
+
43
+ try:
44
+ lock = acquire()
45
+ except AlreadyRunning:
46
+ # F-85: surface the window that exists. Nothing is started here,
47
+ # and the exit is a success -- the user asked to see EchoAct and
48
+ # EchoAct is now in front of them.
49
+ log.info("another instance is running; asked it to show itself")
50
+ return 0
51
+
52
+ from PySide6.QtCore import Qt
53
+ from PySide6.QtWidgets import QApplication
54
+
55
+ from .app import Application
56
+ from .ui import theme
57
+ from .ui.main_window import MainWindow
58
+
59
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
60
+ qt = QApplication(argv)
61
+ qt.setApplicationName("EchoAct")
62
+ qt.setOrganizationName("EchoAct")
63
+
64
+ # Before anything measures a glyph: a face registered later would not
65
+ # be the one the first layout used, and N-13's stability is about the
66
+ # layout not changing under the reader.
67
+ from .ui import fonts
68
+
69
+ report = fonts.load()
70
+ if not report.metrics_are_portable:
71
+ log.info("no bundled typeface; layout measurements are machine-specific")
72
+
73
+ try:
74
+ app = Application()
75
+ except EchoActError as exc:
76
+ # Logged before it is shown. A dialog is seen once by whoever is
77
+ # at the machine; F-25 wants a failure reported, and F-72's
78
+ # diagnostic export can only carry what reached the log.
79
+ log.error("startup failed: %s: %s", exc.code.value, exc.message)
80
+ _fatal(exc.message, lock)
81
+ return 1
82
+ except Exception as exc: # noqa: BLE001 - the last thing between us and silence
83
+ # Anything that is not an EchoActError is a defect rather than a
84
+ # condition, and until now it left the process holding a dialog
85
+ # with nothing in the log to say why.
86
+ log.exception("startup failed unexpectedly")
87
+ _fatal(f"EchoAct could not start ({type(exc).__name__}).", lock)
88
+ return 1
89
+
90
+ window = MainWindow(app, theme.Mode.SYSTEM)
91
+
92
+ def raise_window() -> None:
93
+ """Called from the instance listener's thread.
94
+
95
+ ``QMetaObject.invokeMethod`` with a queued connection is the only
96
+ safe way to touch a widget from there.
97
+ """
98
+ from PySide6.QtCore import QMetaObject
99
+
100
+ QMetaObject.invokeMethod(window, "show", Qt.ConnectionType.QueuedConnection)
101
+ QMetaObject.invokeMethod(window, "raise_", Qt.ConnectionType.QueuedConnection)
102
+ QMetaObject.invokeMethod(window, "activateWindow", Qt.ConnectionType.QueuedConnection)
103
+
104
+ lock._on_activate = raise_window # noqa: SLF001 - set once, before any use
105
+
106
+ window.show()
107
+ app.start_service()
108
+ window._refresh_service_label() # noqa: SLF001 - the window is ours
109
+
110
+ try:
111
+ return qt.exec()
112
+ finally:
113
+ lock.release()
114
+
115
+
116
+ if __name__ == "__main__":
117
+ raise SystemExit(main())
echoact/app.py ADDED
@@ -0,0 +1,315 @@
1
+ """Composition root: builds the parts and wires them together once.
2
+
3
+ Everything that has a lifetime is created here, in an order that follows
4
+ from the requirements rather than from convenience:
5
+
6
+ * Storage first, because F-45's reconciliation has to run before anything
7
+ can create a new job, and N-02's cleanup of what a forced termination
8
+ left behind has to run before anything writes into the scratch tree.
9
+ * The engine next, because the GUI, the REST service, and MCP all route
10
+ through the one generation slot F-47 allows.
11
+ * The local service last, and separately, because F-79 requires the GUI,
12
+ generation, playback, and the library to stay fully usable when the port
13
+ cannot be bound -- so a service that fails to start is a notice, never
14
+ an exception that reaches this far.
15
+
16
+ No Qt import appears in this module. The service and the engine must run
17
+ in a headless test, and a GUI import here would make that impossible.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .audio.player import Player
27
+ from .config.settings import Settings, SettingsModelPreferences, SettingsStore
28
+ from .db.backup import BackupScheduler
29
+ from .db.store import Store
30
+ from .domain import Capability
31
+ from .engine.supervisor import WorkerSupervisor
32
+ from .errors import Code, EchoActError, Problem
33
+ from .jobs.engine import JobEngine, clear_temp_tree, expire_one_off_results
34
+ from .models.catalog import MANIFEST
35
+ from .models.registry import ModelRegistry
36
+ from .paths import audio_dir, db_path, ensure_tree, temp_dir
37
+ from .security.credentials import CredentialStore, IssuedCredential
38
+ from .security.ratelimit import RateLimiter
39
+ from .util import ids
40
+ from .util.logging import configure, get_logger, prune_old_logs
41
+
42
+ log = get_logger("app")
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class Startup:
47
+ """What happened on the way up, for the first screen to report.
48
+
49
+ F-25 wants failures reported rather than swallowed, and F-45 wants an
50
+ interrupted job shown as interrupted. Both are collected here instead
51
+ of being logged and forgotten.
52
+ """
53
+
54
+ problems: list[Problem] = field(default_factory=list)
55
+ interrupted_jobs: tuple[str, ...] = ()
56
+ missing_results: tuple[str, ...] = ()
57
+ temp_entries_removed: int = 0
58
+ expired_results_removed: int = 0
59
+ owner_credential: IssuedCredential | None = None
60
+
61
+ @property
62
+ def needs_attention(self) -> bool:
63
+ return bool(self.problems or self.interrupted_jobs or self.missing_results)
64
+
65
+
66
+ class Application:
67
+ """The parts, and their lifetime."""
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ data_root: Path | None = None,
73
+ settings_store: SettingsStore | None = None,
74
+ supervisor: WorkerSupervisor | None = None,
75
+ ) -> None:
76
+ configure()
77
+ ensure_tree()
78
+ prune_old_logs(ids.now())
79
+
80
+ self.startup = Startup()
81
+ self.settings_store = settings_store or SettingsStore()
82
+ self.settings: Settings = self.settings_store.load()
83
+ self.startup.problems.extend(self.settings_store.problems)
84
+
85
+ self.store = Store(
86
+ db_path() if data_root is None else data_root / "echoact.sqlite3",
87
+ audio_root=audio_dir() if data_root is None else data_root / "audio",
88
+ retention_limit_bytes=self.settings.retention_bytes,
89
+ )
90
+ # The owner's two model decisions -- accepted licence and
91
+ # authorised download -- live in the settings file rather than in a
92
+ # second file of the registry's own. Two homes would mean the F-80
93
+ # policy screen and the registry each reporting a licence the other
94
+ # had never seen.
95
+ self.registry = ModelRegistry(
96
+ MANIFEST, preferences=SettingsModelPreferences(self.settings_store)
97
+ )
98
+ self.credentials = CredentialStore.load()
99
+ self.limiter = RateLimiter()
100
+ self.supervisor = supervisor or WorkerSupervisor()
101
+ self.engine = JobEngine(
102
+ store=self.store,
103
+ supervisor=self.supervisor,
104
+ registry=self.registry,
105
+ manifest=MANIFEST,
106
+ settings=self.settings,
107
+ )
108
+ self.player = Player()
109
+ self.scheduler = BackupScheduler()
110
+ self.service: Any = None # set by start_service, if it starts
111
+
112
+ self._recover()
113
+ self._ensure_owner_credential()
114
+
115
+ # ------------------------------------------------------------------
116
+ # Start-up housekeeping
117
+ # ------------------------------------------------------------------
118
+
119
+ def _recover(self) -> None:
120
+ """F-45 and N-02, in that order.
121
+
122
+ Reconciliation first: it reads job rows that may still name files
123
+ in the scratch tree, so clearing the tree before reconciling would
124
+ turn "interrupted" into "result missing" for the same job.
125
+ """
126
+ try:
127
+ report = self.store.reconcile_on_start()
128
+ self.startup.interrupted_jobs = tuple(report.interrupted_job_ids)
129
+ self.startup.missing_results = tuple(report.missing_result_ids) + tuple(
130
+ report.corrupt_result_ids
131
+ )
132
+ except EchoActError as exc:
133
+ self.startup.problems.append(Problem(exc.code, exc.message))
134
+
135
+ try:
136
+ self.startup.expired_results_removed = expire_one_off_results(self.store, temp_dir())
137
+ except EchoActError as exc:
138
+ self.startup.problems.append(Problem(exc.code, exc.message))
139
+
140
+ # 4.1: a one-off GUI result is cleaned up when the app exits
141
+ # normally, and a forced termination leaves the rest behind. What
142
+ # survives here is only what the sweep above did not claim, so it
143
+ # is scratch by definition.
144
+ self.startup.temp_entries_removed = clear_temp_tree(temp_dir())
145
+
146
+ def _ensure_owner_credential(self) -> None:
147
+ """F-71 and N-31.
148
+
149
+ The service listens from first launch, so a credential has to exist
150
+ from first launch -- and it must be minted here rather than shipped,
151
+ because a distribution containing one would be a well-known
152
+ credential, which N-31 forbids outright.
153
+ """
154
+ if self.credentials.owner_credential() is not None:
155
+ return
156
+ try:
157
+ issued = self.credentials.issue(
158
+ name="EchoAct (this computer)",
159
+ capabilities={Capability.OWNER},
160
+ days=self.settings.credential_days,
161
+ )
162
+ self.startup.owner_credential = issued
163
+ except EchoActError as exc:
164
+ self.startup.problems.append(Problem(exc.code, exc.message))
165
+
166
+ # ------------------------------------------------------------------
167
+ # Scheduled backup (F-74, N-28)
168
+ # ------------------------------------------------------------------
169
+
170
+ def run_due_backup(self, now: float | None = None) -> Any:
171
+ """Take the daily backup if one is due. Returns the outcome or None.
172
+
173
+ Called on a timer by whoever owns one, and never on the Qt main
174
+ thread: a backup copies the whole library. Everything F-74 asks
175
+ for is decided inside the scheduler -- once a day, only while the
176
+ app is running, deferred during generation and during a restore, a
177
+ missed schedule made up once rather than accumulating -- so this is
178
+ only the tick and the two facts the scheduler cannot see for
179
+ itself: whether a job is running, and where the owner wants it.
180
+
181
+ N-28 puts a scheduled backup behind the user's generation and
182
+ playback, which is why ``generating`` is passed rather than
183
+ inferred: deferring is the scheduler's decision, but knowing is
184
+ this object's.
185
+ """
186
+ settings = self.settings
187
+ if not settings.scheduled_backup:
188
+ return None
189
+ try:
190
+ return self.scheduler.run_due(
191
+ self.store,
192
+ ids.now() if now is None else now,
193
+ enabled=True,
194
+ location=settings.scheduled_backup_location,
195
+ generating=self.engine.busy,
196
+ )
197
+ except EchoActError as exc:
198
+ # F-70 notifies a backup failure; it is never a reason to stop.
199
+ log.warning("scheduled backup: %s", exc.code.value)
200
+ self.startup.problems.append(Problem(exc.code, exc.message))
201
+ return None
202
+
203
+ # ------------------------------------------------------------------
204
+ # Settings
205
+ # ------------------------------------------------------------------
206
+
207
+ def update_settings(self, **changes: Any) -> Settings:
208
+ """Change settings and tell everyone who cares.
209
+
210
+ F-78: the running job is untouched. It recorded the budget it
211
+ started under, and nothing here reaches into it.
212
+ """
213
+ self.settings = self.settings.with_(**changes)
214
+ self.settings_store.save(self.settings)
215
+ self.engine.apply_settings(self.settings)
216
+ self.store.set_retention_limit(self.settings.retention_bytes)
217
+ return self.settings
218
+
219
+ # ------------------------------------------------------------------
220
+ # Model preparation (F-09, N-11, F-80)
221
+ # ------------------------------------------------------------------
222
+
223
+ def licence_pending(self, model_id: str | None = None) -> str | None:
224
+ """The model whose terms have not been accepted, if any.
225
+
226
+ N-11 makes acceptance a condition of first preparation, so the
227
+ window asks this before it starts a job rather than letting the
228
+ engine refuse one and reporting a code.
229
+ """
230
+ target = model_id or self.settings.voice.model_id
231
+ return target if self.registry.license_acceptance_required(target) else None
232
+
233
+ def accept_licence(self, model_id: str) -> None:
234
+ """Record that the owner accepted this model's restrictions.
235
+
236
+ Recorded against a fingerprint of the terms themselves, so a
237
+ release that amends them asks again instead of inheriting consent
238
+ given to different words.
239
+ """
240
+ self.registry.accept_license(model_id)
241
+ self.settings = self.settings_store.load()
242
+ self.engine.apply_settings(self.settings)
243
+
244
+ # ------------------------------------------------------------------
245
+ # The local service (F-46, F-79, N-31)
246
+ # ------------------------------------------------------------------
247
+
248
+ def start_service(self) -> Problem | None:
249
+ """Start the REST service if the owner has it on.
250
+
251
+ Returns a problem instead of raising. F-79 is explicit that a bind
252
+ failure disables the integrations only and must be shown as an
253
+ actionable notice rather than a startup failure, so this cannot be
254
+ allowed to propagate.
255
+ """
256
+ if not self.settings.rest_enabled:
257
+ return None
258
+ try:
259
+ from .service.server import ServiceRunner
260
+
261
+ self.service = ServiceRunner(self)
262
+ self.service.start()
263
+ return None
264
+ except EchoActError as exc:
265
+ self.service = None
266
+ problem = Problem(exc.code, exc.message)
267
+ self.startup.problems.append(problem)
268
+ return problem
269
+ except Exception as exc: # noqa: BLE001 - the GUI must still run
270
+ self.service = None
271
+ problem = Problem(
272
+ Code.SERVICE_PORT_UNAVAILABLE,
273
+ f"The local service could not start ({type(exc).__name__}).",
274
+ )
275
+ self.startup.problems.append(problem)
276
+ return problem
277
+
278
+ def stop_service(self) -> None:
279
+ if self.service is not None:
280
+ try:
281
+ self.service.stop()
282
+ finally:
283
+ self.service = None
284
+
285
+ @property
286
+ def service_running(self) -> bool:
287
+ return self.service is not None and getattr(self.service, "running", False)
288
+
289
+ # ------------------------------------------------------------------
290
+ # Shutdown (F-52, F-77)
291
+ # ------------------------------------------------------------------
292
+
293
+ def shutdown(self) -> None:
294
+ """Stop in the reverse order of construction.
295
+
296
+ The service first, so no new request can arrive while the engine is
297
+ being torn down; then the engine, which kills the worker; then
298
+ playback; and the database last, because everything above may want
299
+ to write a final row.
300
+ """
301
+ self.stop_service()
302
+ try:
303
+ self.engine.shutdown()
304
+ except Exception as exc: # noqa: BLE001
305
+ log.warning("engine shutdown: %s", type(exc).__name__)
306
+ try:
307
+ self.player.close()
308
+ except Exception as exc: # noqa: BLE001
309
+ log.warning("player shutdown: %s", type(exc).__name__)
310
+ try:
311
+ # 4.1: a GUI one-off result is cleaned up on a normal exit.
312
+ clear_temp_tree(temp_dir())
313
+ except OSError:
314
+ pass
315
+ self.store.close()
File without changes
@@ -0,0 +1,192 @@
1
+ """Audio output devices: enumeration, selection, and loss.
2
+
3
+ F-67 needs a device list the user can choose from, and requires that a
4
+ device disappearing pauses playback rather than moving the sound to another
5
+ speaker. F-68 adds that after the machine resumes from sleep, the device
6
+ list and the system resources are re-checked and playback is left paused.
7
+
8
+ PortAudio caches its device list at initialisation, so a device plugged in
9
+ or removed after start-up is invisible until the library is reinitialised.
10
+ That is why :func:`refresh` exists and why it is called on resume rather
11
+ than merely re-querying.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+
20
+ from ..errors import Code, EchoActError
21
+ from ..util.logging import get_logger
22
+
23
+ log = get_logger("audio.devices")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class OutputDevice:
28
+ index: int
29
+ name: str
30
+ host_api: str
31
+ max_channels: int
32
+ default_samplerate: float
33
+ is_default: bool
34
+
35
+ @property
36
+ def key(self) -> str:
37
+ """A stable-ish identifier to persist in settings.
38
+
39
+ PortAudio indices are positional and shift when devices come and
40
+ go, so a remembered index would silently select a different
41
+ speaker. The name plus host API survives a reboot; the index does
42
+ not, and is resolved fresh each time.
43
+ """
44
+ return f"{self.host_api}::{self.name}"
45
+
46
+
47
+ def _sd():
48
+ import sounddevice as sd
49
+
50
+ return sd
51
+
52
+
53
+ def refresh() -> None:
54
+ """Re-read the device list from the operating system.
55
+
56
+ Needed after a resume (F-68) and after the user plugs something in;
57
+ without it PortAudio keeps answering from the list it built at start-up.
58
+ """
59
+ sd = _sd()
60
+ try:
61
+ sd._terminate()
62
+ sd._initialize()
63
+ except Exception as exc: # noqa: BLE001 - a failed refresh is not fatal
64
+ log.warning("device refresh failed: %s", type(exc).__name__)
65
+
66
+
67
+ def list_output_devices() -> list[OutputDevice]:
68
+ sd = _sd()
69
+ try:
70
+ devices = sd.query_devices()
71
+ apis = sd.query_hostapis()
72
+ default_index = sd.default.device[1]
73
+ except Exception as exc: # noqa: BLE001
74
+ raise EchoActError(
75
+ Code.OUTPUT_DEVICE_UNAVAILABLE,
76
+ "The audio system could not be queried.",
77
+ cause=exc,
78
+ ) from exc
79
+
80
+ out: list[OutputDevice] = []
81
+ for i, d in enumerate(devices):
82
+ if int(d.get("max_output_channels", 0)) <= 0:
83
+ continue
84
+ api = apis[int(d.get("hostapi", 0))]["name"] if apis else ""
85
+ out.append(
86
+ OutputDevice(
87
+ index=i,
88
+ name=str(d.get("name", "")).strip(),
89
+ host_api=str(api),
90
+ max_channels=int(d["max_output_channels"]),
91
+ default_samplerate=float(d.get("default_samplerate", 0.0)),
92
+ is_default=(i == default_index),
93
+ )
94
+ )
95
+ return out
96
+
97
+
98
+ def default_output_device() -> OutputDevice | None:
99
+ for d in list_output_devices():
100
+ if d.is_default:
101
+ return d
102
+ devices = list_output_devices()
103
+ return devices[0] if devices else None
104
+
105
+
106
+ def resolve(key: str | None) -> OutputDevice | None:
107
+ """Find the remembered device, or report that it is gone.
108
+
109
+ Returning ``None`` for a key that no longer matches is deliberate: F-67
110
+ forbids switching to another speaker without the user's confirmation,
111
+ so the caller has to decide rather than being handed a substitute.
112
+ """
113
+ if not key:
114
+ return default_output_device()
115
+ for d in list_output_devices():
116
+ if d.key == key:
117
+ return d
118
+ return None
119
+
120
+
121
+ def supports_rate(device_index: int, sample_rate: int) -> bool:
122
+ """Whether the device can take our output format.
123
+
124
+ F-82 forbids resampling, so a device that cannot accept the model's
125
+ native rate is a problem to report rather than to paper over.
126
+ """
127
+ sd = _sd()
128
+ try:
129
+ sd.check_output_settings(
130
+ device=device_index, channels=1, dtype="int16", samplerate=sample_rate
131
+ )
132
+ return True
133
+ except Exception: # noqa: BLE001 - any refusal is a refusal
134
+ return False
135
+
136
+
137
+ class DeviceWatcher:
138
+ """Polls for a change in the set of output devices.
139
+
140
+ Polling rather than an OS notification because the two supported
141
+ platforms signal this differently and neither reaches Python without a
142
+ native extension; the cost is one cheap query every few seconds, which
143
+ N-28 tolerates because it yields to nothing.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ on_change: Callable[[list[OutputDevice]], None],
149
+ *,
150
+ interval_s: float = 3.0,
151
+ ) -> None:
152
+ self._on_change = on_change
153
+ self._interval = interval_s
154
+ self._stop = threading.Event()
155
+ self._thread: threading.Thread | None = None
156
+ self._seen: tuple[str, ...] = ()
157
+
158
+ def start(self) -> None:
159
+ if self._thread is not None:
160
+ return
161
+ self._seen = self._snapshot()
162
+ self._thread = threading.Thread(target=self._run, name="echoact-devices", daemon=True)
163
+ self._thread.start()
164
+
165
+ def stop(self) -> None:
166
+ self._stop.set()
167
+ t, self._thread = self._thread, None
168
+ if t is not None:
169
+ t.join(timeout=2.0)
170
+
171
+ def _snapshot(self) -> tuple[str, ...]:
172
+ try:
173
+ return tuple(d.key for d in list_output_devices())
174
+ except EchoActError:
175
+ return ()
176
+
177
+ def _run(self) -> None:
178
+ while not self._stop.wait(self._interval):
179
+ current = self._snapshot()
180
+ if current == self._seen:
181
+ continue
182
+ # A change in the list is only trustworthy after a refresh;
183
+ # PortAudio would otherwise keep reporting the stale set.
184
+ refresh()
185
+ current = self._snapshot()
186
+ if current == self._seen:
187
+ continue
188
+ self._seen = current
189
+ try:
190
+ self._on_change(list_output_devices())
191
+ except Exception as exc: # noqa: BLE001
192
+ log.warning("device change handler failed: %s", type(exc).__name__)