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/__init__.py
ADDED
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
|
echoact/audio/devices.py
ADDED
|
@@ -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__)
|