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
|
@@ -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
|
echoact/text/__init__.py
ADDED
|
File without changes
|
echoact/text/language.py
ADDED
|
@@ -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"]
|