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,270 @@
1
+ """Running the service inside the application process (A.2, F-52, F-79).
2
+
3
+ The listener is a worker thread of the GUI process, not a child process.
4
+ A.2 fixes that: the GUI must survive a failed bind and stay fully usable,
5
+ which means it cannot reach the engine over the network path -- if it did, a
6
+ port conflict would take the desktop application down with the service, and
7
+ F-79 says the opposite must happen. So the engine, the store, and the
8
+ service all live in one process and the service is the only part that can
9
+ fail to start.
10
+
11
+ Two consequences follow, and both are load-bearing.
12
+
13
+ * **The socket is bound here, on the caller's thread.** ``uvicorn.Server.run``
14
+ binds inside the thread it runs on and exits the process on failure, so a
15
+ conflict would surface as a dead thread rather than as an answer. Binding
16
+ first turns a busy port into ``EchoActError(SERVICE_PORT_UNAVAILABLE)``
17
+ raised from :meth:`ServiceRunner.start`, which ``Application.start_service``
18
+ turns into F-79's actionable notice while the GUI keeps running.
19
+ * **The host is never a parameter.** N-17 says binding a non-loopback
20
+ address is not configurable, so ``policy.REST_HOST`` is the only value that
21
+ reaches ``bind``. The owner chooses the port and nothing else.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import socket
27
+ import sys
28
+ import threading
29
+ from typing import Any
30
+
31
+ import uvicorn
32
+
33
+ from ..domain import RequestPath
34
+ from ..errors import Code, EchoActError
35
+ from ..policy import REST_HOST
36
+ from ..util.logging import get_logger
37
+ from .app import create_app
38
+ from .deps import ServiceContext
39
+
40
+ log = get_logger("service.server")
41
+
42
+ #: How long :meth:`ServiceRunner.start` waits for uvicorn to report itself
43
+ #: started. The socket is already bound and listening by then, so this only
44
+ #: covers the server's own set-up; a machine slow enough to exceed it would
45
+ #: have failed the bind first.
46
+ START_TIMEOUT_S = 10.0
47
+
48
+ #: How long :meth:`ServiceRunner.stop` waits for the serving thread to end.
49
+ #: Long enough for an in-flight audio stream to finish its current block and
50
+ #: short enough that closing the window is not held up by a client that has
51
+ #: stopped reading.
52
+ STOP_TIMEOUT_S = 5.0
53
+
54
+ #: The paths whose jobs belong to this integration. MCP is a REST client
55
+ #: (F-58), so its jobs arrive by the REST path and are cancelled with it;
56
+ #: turning REST off is exactly what makes MCP unavailable.
57
+ _INTEGRATION_PATHS = frozenset({RequestPath.REST, RequestPath.MCP})
58
+
59
+
60
+ class ServiceRunner:
61
+ """The local REST service's lifetime.
62
+
63
+ One instance per start. ``stop`` is final: uvicorn's ``Server`` is not
64
+ restartable and neither is a closed socket, so re-enabling the service
65
+ after the owner turned it off builds a new runner -- which is also what
66
+ 4.1 means by re-enabling being an explicit action.
67
+ """
68
+
69
+ def __init__(self, application: Any, *, port: int | None = None) -> None:
70
+ self.application = application
71
+ self.context = ServiceContext(application, port=port)
72
+ self._server: uvicorn.Server | None = None
73
+ self._thread: threading.Thread | None = None
74
+ self._socket: socket.socket | None = None
75
+ self._stopped = threading.Event()
76
+
77
+ # ------------------------------------------------------------------
78
+ # State
79
+ # ------------------------------------------------------------------
80
+
81
+ @property
82
+ def host(self) -> str:
83
+ """N-17: the loopback address. Read from policy, never from settings."""
84
+ return REST_HOST
85
+
86
+ @property
87
+ def port(self) -> int:
88
+ return self.context.port
89
+
90
+ @property
91
+ def running(self) -> bool:
92
+ server, thread = self._server, self._thread
93
+ return bool(
94
+ server is not None
95
+ and thread is not None
96
+ and thread.is_alive()
97
+ and server.started
98
+ and not self.context.draining
99
+ )
100
+
101
+ # ------------------------------------------------------------------
102
+ # Start (F-46, F-79)
103
+ # ------------------------------------------------------------------
104
+
105
+ def start(self) -> None:
106
+ """Bind, then serve on a worker thread.
107
+
108
+ Raises ``EchoActError(SERVICE_PORT_UNAVAILABLE)`` with the port in its
109
+ detail when the address is taken. F-79 forbids binding an alternative
110
+ port silently and forbids terminating whatever holds this one, so the
111
+ failure is reported and nothing else is attempted.
112
+ """
113
+ if self._thread is not None:
114
+ raise EchoActError(Code.INTERNAL, "This service runner has already been started.")
115
+ port = self.port
116
+ self._socket = _bind(port)
117
+ app = create_app(self.context)
118
+ config = uvicorn.Config(
119
+ app,
120
+ host=REST_HOST,
121
+ port=port,
122
+ log_level="warning",
123
+ access_log=False,
124
+ # The parts are built by ``Application`` before the service
125
+ # starts; there is nothing for a lifespan event to do, and a
126
+ # protocol that expects one would only add a way to fail.
127
+ lifespan="off",
128
+ # Bounded so that closing the window is not held up by a client
129
+ # that stopped reading mid-stream; F-77 confirms the exit with the
130
+ # user and then has to actually make it.
131
+ timeout_graceful_shutdown=int(STOP_TIMEOUT_S),
132
+ )
133
+ server = uvicorn.Server(config)
134
+ server.install_signal_handlers = _no_signal_handlers # type: ignore[method-assign]
135
+ self._server = server
136
+ self._thread = threading.Thread(
137
+ target=self._serve, name="echoact-rest", daemon=True
138
+ )
139
+ self._thread.start()
140
+
141
+ if not _await_started(server, self._thread, START_TIMEOUT_S):
142
+ self.stop()
143
+ raise EchoActError(
144
+ Code.SERVICE_PORT_UNAVAILABLE,
145
+ "The local service did not finish starting.",
146
+ detail={"host": REST_HOST, "port": port},
147
+ )
148
+ log.info("local service listening on %s:%d", REST_HOST, port)
149
+
150
+ def _serve(self) -> None:
151
+ server, sock = self._server, self._socket
152
+ if server is None or sock is None:
153
+ return
154
+ try:
155
+ server.run(sockets=[sock])
156
+ except BaseException as exc: # noqa: BLE001 - F-79: never take the GUI with it
157
+ log.warning("local service stopped: %s", type(exc).__name__)
158
+ finally:
159
+ self._stopped.set()
160
+
161
+ # ------------------------------------------------------------------
162
+ # Stop (F-52)
163
+ # ------------------------------------------------------------------
164
+
165
+ def stop(self) -> None:
166
+ """F-52's three steps, in the order the requirement gives them.
167
+
168
+ Block new requests, cancel this integration's in-progress jobs, then
169
+ shut down. The order matters: cancelling first would leave a window
170
+ in which a client could take the slot straight back, and shutting the
171
+ listener first would drop the connection of the very caller whose job
172
+ is about to be cancelled without ever telling it why.
173
+
174
+ A GUI job is untouched. F-52 says so outright, and it is why the
175
+ cancellation is filtered by request path rather than applied to
176
+ whatever happens to be running.
177
+ """
178
+ self.context.drain()
179
+ self._cancel_integration_jobs()
180
+
181
+ server = self._server
182
+ if server is not None:
183
+ server.should_exit = True
184
+ thread = self._thread
185
+ if thread is not None and thread.is_alive():
186
+ self._stopped.wait(STOP_TIMEOUT_S)
187
+ thread.join(STOP_TIMEOUT_S)
188
+ if self._socket is not None:
189
+ try:
190
+ self._socket.close()
191
+ except OSError:
192
+ pass
193
+ self._socket = None
194
+ self.context.close()
195
+
196
+ def _cancel_integration_jobs(self) -> None:
197
+ """Cancel what this integration started, and nothing else.
198
+
199
+ Failures are logged rather than raised: this runs on the way out, and
200
+ F-52 wants the servers cleaned up on exit even when a job is already
201
+ in a state that cannot be cancelled.
202
+ """
203
+ engine = self.application.engine
204
+ current = engine.current()
205
+ if current is None or current.request_path not in _INTEGRATION_PATHS:
206
+ return
207
+ try:
208
+ engine.cancel(current.job_id)
209
+ except EchoActError as exc:
210
+ log.warning("could not cancel %s on shutdown: %s", current.job_id, exc.code.value)
211
+
212
+
213
+ def _bind(port: int) -> socket.socket:
214
+ """Take the loopback address and port, or say why not.
215
+
216
+ ``SO_REUSEADDR`` is set only away from Windows. On Windows it does not
217
+ mean "reuse a socket in TIME_WAIT"; it means "bind even though another
218
+ process already holds this address", which would let EchoAct silently
219
+ take a port from -- or share it with -- whatever is listening there. On a
220
+ service whose entire security model is "loopback only, authenticated", a
221
+ second listener on the same port is the one thing that must fail loudly.
222
+ """
223
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
224
+ try:
225
+ if sys.platform != "win32":
226
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
227
+ sock.bind((REST_HOST, port))
228
+ sock.listen(128)
229
+ sock.set_inheritable(True)
230
+ except OSError as exc:
231
+ sock.close()
232
+ # F-79 distinguishes an occupied port from insufficient access
233
+ # permission, so the two are not folded into one message even though
234
+ # they share a code: the detail carries which errno it was.
235
+ raise EchoActError(
236
+ Code.SERVICE_PORT_UNAVAILABLE,
237
+ f"Port {port} on {REST_HOST} is not available ({exc.strerror or type(exc).__name__}).",
238
+ detail={"host": REST_HOST, "port": port, "errno": exc.errno},
239
+ cause=exc,
240
+ ) from exc
241
+ return sock
242
+
243
+
244
+ def _await_started(server: uvicorn.Server, thread: threading.Thread, timeout_s: float) -> bool:
245
+ """Wait for uvicorn to report itself started, or for the thread to die.
246
+
247
+ Polled against an event that is never set, which is simply a sleep that a
248
+ reader can see is bounded. ``uvicorn.Server`` offers no "started" event
249
+ to wait on, and the alternative -- ``time.sleep`` in a loop with no
250
+ liveness check -- would hang for the whole timeout when the serving
251
+ thread has already died.
252
+ """
253
+ idle = threading.Event()
254
+ step = 0.01
255
+ waited = 0.0
256
+ while waited < timeout_s:
257
+ if server.started:
258
+ return True
259
+ if not thread.is_alive():
260
+ return False
261
+ idle.wait(step)
262
+ waited += step
263
+ return bool(server.started)
264
+
265
+
266
+ def _no_signal_handlers() -> None:
267
+ """Uvicorn installs SIGINT and SIGTERM handlers, and only the main thread
268
+ may. The application owns its own shutdown (F-77), so the server is told
269
+ to install none rather than being run somewhere it could."""
270
+ return None
File without changes
@@ -0,0 +1,44 @@
1
+ """F-05's language resolution, per sentence.
2
+
3
+ The engine takes a two-letter code on every call, so the question "which
4
+ language is this sentence?" has to be answered somewhere; answering it here,
5
+ once, is what keeps the GUI's preview, the segmenter, and the duration
6
+ estimate from disagreeing. The Hangul test itself lives in
7
+ ``domain.contains_hangul`` for the same reason.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Final
13
+
14
+ from ..domain import Language, contains_hangul
15
+
16
+ #: The codes the engine accepts (`lang=` in every ``synthesize`` call).
17
+ KO: Final = "ko"
18
+ EN: Final = "en"
19
+
20
+ ENGINE_LANGUAGES: Final = frozenset({KO, EN})
21
+
22
+
23
+ def resolve_language(text: str, selected: Language = Language.AUTO) -> str:
24
+ """Return the engine language code for one sentence.
25
+
26
+ F-05: an explicit Korean or English selection wins outright -- the user
27
+ asked for a reading, not for a guess -- and automatic mode decides on
28
+ whether the sentence contains Hangul. The test is "contains", not
29
+ "is mostly": a Korean sentence quoting an English phrase is still read
30
+ by the Korean voice, and the alternative (a per-sentence majority vote)
31
+ would flip the voice mid-paragraph on a single loan word.
32
+ """
33
+ if selected is Language.KO:
34
+ return KO
35
+ if selected is Language.EN:
36
+ return EN
37
+ return KO if contains_hangul(text) else EN
38
+
39
+
40
+ def is_engine_language(code: str) -> bool:
41
+ return code in ENGINE_LANGUAGES
42
+
43
+
44
+ __all__ = ["EN", "ENGINE_LANGUAGES", "KO", "is_engine_language", "resolve_language"]