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,278 @@
|
|
|
1
|
+
"""F-84's manifest: what a model *is*, as data that ships with the build.
|
|
2
|
+
|
|
3
|
+
F-84 requires every supported model to be defined by an entry recording its
|
|
4
|
+
source, pinned revision, per-file digests and total size, licence and usage
|
|
5
|
+
restrictions, native sample rate, supported languages, and the minimum
|
|
6
|
+
resource budget under which it is approved to run. F-63 to F-65 resolve
|
|
7
|
+
download, verification, and repair against these entries, and a model whose
|
|
8
|
+
files do not match one is *corrupted*, never used.
|
|
9
|
+
|
|
10
|
+
Two consequences shape this module:
|
|
11
|
+
|
|
12
|
+
* The manifest is a Python literal in :mod:`echoact.models.catalog`, not a
|
|
13
|
+
document fetched at start-up. F-84 forbids a silent remote update, and a
|
|
14
|
+
downloadable manifest is exactly that: it would let a changed digest
|
|
15
|
+
redefine "sound" for weights already on disk. Shipping it in the wheel
|
|
16
|
+
makes a manifest change a release.
|
|
17
|
+
* Nothing here touches the disk or the network. ``registry`` does that. A
|
|
18
|
+
manifest that could read a file would be tempted to trust what it read.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path, PurePosixPath
|
|
26
|
+
|
|
27
|
+
from ..domain import Budget, Gender, Language
|
|
28
|
+
from ..errors import Code, EchoActError
|
|
29
|
+
|
|
30
|
+
_HEX = frozenset("0123456789abcdef")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ModelFile:
|
|
35
|
+
"""One file of a model, with the two facts F-84 needs to judge it.
|
|
36
|
+
|
|
37
|
+
Size is checked before the digest because a truncated 256 MB download is
|
|
38
|
+
the common failure and hashing it to learn that is a wasted second. The
|
|
39
|
+
digest is what actually decides: F-84 wants a *tampered* file reported as
|
|
40
|
+
corrupt, and only the hash sees that.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
relative_path: str
|
|
44
|
+
sha256: str
|
|
45
|
+
byte_size: int
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
pure = PurePosixPath(self.relative_path)
|
|
49
|
+
parts = pure.parts
|
|
50
|
+
# A manifest path is joined onto the model cache root. It ships with
|
|
51
|
+
# the app, so a path escaping that root is a packaging mistake rather
|
|
52
|
+
# than a runtime condition -- but it would be a directory traversal
|
|
53
|
+
# if it ever got through, so it is refused here, not downstream.
|
|
54
|
+
if not parts or pure.is_absolute():
|
|
55
|
+
raise AssertionError(f"manifest path must be relative: {self.relative_path!r}")
|
|
56
|
+
if any(p in ("..", ".", "") for p in parts) or ":" in self.relative_path:
|
|
57
|
+
raise AssertionError(f"unsafe manifest path: {self.relative_path!r}")
|
|
58
|
+
if len(self.sha256) != 64 or not _HEX.issuperset(self.sha256):
|
|
59
|
+
raise AssertionError(f"sha256 must be 64 lowercase hex digits: {self.sha256!r}")
|
|
60
|
+
if self.byte_size <= 0:
|
|
61
|
+
raise AssertionError(f"non-positive size for {self.relative_path!r}")
|
|
62
|
+
|
|
63
|
+
def path_under(self, root: Path) -> Path:
|
|
64
|
+
"""Where this file belongs beneath a model directory."""
|
|
65
|
+
return root.joinpath(*PurePosixPath(self.relative_path).parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class VoiceEntry:
|
|
70
|
+
"""One selectable voice (F-06), described well enough for F-53.
|
|
71
|
+
|
|
72
|
+
F-53 requires a description because a caller that is not a person has no
|
|
73
|
+
basis for choosing between ``M1`` and ``M4`` otherwise. ``characterised``
|
|
74
|
+
records whether that description rests on anyone having listened: A.4
|
|
75
|
+
keeps N-08 open, so every shipped description today states only what is
|
|
76
|
+
verifiable -- gender, index, provenance -- and this flag is ``False``. A
|
|
77
|
+
later listening pass rewrites the text and flips the flag without a
|
|
78
|
+
schema change, and a surface that wants to say "not yet reviewed" can.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
voice_id: str
|
|
82
|
+
gender: Gender
|
|
83
|
+
display_name: str
|
|
84
|
+
description: str
|
|
85
|
+
characterised: bool = False
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
if not self.voice_id or not self.display_name or not self.description:
|
|
89
|
+
raise AssertionError(f"incomplete voice entry: {self.voice_id!r}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class LicenseTerms:
|
|
94
|
+
"""The licence as an obligation, not as a notice.
|
|
95
|
+
|
|
96
|
+
N-11 makes this a release blocker rather than a footnote: where a model's
|
|
97
|
+
licence obliges a distributor to bind its own users to the same use
|
|
98
|
+
restrictions, the product's terms must carry them through. So the
|
|
99
|
+
restrictions are held here as text the app can show verbatim, and
|
|
100
|
+
``acceptance_required`` drives F-80's "accepted before first preparation"
|
|
101
|
+
gate. Filing the licence under Help would satisfy neither requirement.
|
|
102
|
+
|
|
103
|
+
``restrictions`` quotes the licence rather than paraphrasing it; a
|
|
104
|
+
paraphrase is what a reader would hold the distributor to instead of the
|
|
105
|
+
real term.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
#: One entry per restriction, in the licence's own order and wording.
|
|
110
|
+
restrictions: tuple[str, ...]
|
|
111
|
+
#: The clause that makes those restrictions the distributor's problem.
|
|
112
|
+
pass_through_obligation: str
|
|
113
|
+
#: N-11 / F-80: accepted before the model is first prepared.
|
|
114
|
+
acceptance_required: bool
|
|
115
|
+
#: Facts a reviewer must see that are not themselves use restrictions.
|
|
116
|
+
notes: tuple[str, ...] = ()
|
|
117
|
+
#: Path of the full licence inside the source repository, for F-80's
|
|
118
|
+
#: "review the licence" affordance and for attribution.
|
|
119
|
+
source_file: str = "LICENSE"
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
if self.acceptance_required and not self.restrictions:
|
|
123
|
+
raise AssertionError("acceptance cannot be required with nothing to accept")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class MinimumBudget:
|
|
128
|
+
"""The floor F-84 calls the minimum budget a model is approved to run in.
|
|
129
|
+
|
|
130
|
+
Deliberately not a :class:`~echoact.domain.Budget`: that type is the
|
|
131
|
+
ceiling actually applied to a job and carries thread counts derived from
|
|
132
|
+
it. This is a claim about approval, and comparing the two is
|
|
133
|
+
``registry.can_run``'s job, which F-04 requires to answer with a reason.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
memory_bytes: int
|
|
137
|
+
cpu_percent: int
|
|
138
|
+
|
|
139
|
+
def fits(self, budget: Budget) -> bool:
|
|
140
|
+
return budget.memory_bytes >= self.memory_bytes and budget.cpu_percent >= self.cpu_percent
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class ModelEntry:
|
|
145
|
+
"""One model, completely described (F-84)."""
|
|
146
|
+
|
|
147
|
+
model_id: str
|
|
148
|
+
display_name: str
|
|
149
|
+
#: Source repository, and the commit the digests below were taken at. A
|
|
150
|
+
#: branch name would let the upstream redefine what "sound" means.
|
|
151
|
+
repo_id: str
|
|
152
|
+
revision: str
|
|
153
|
+
files: tuple[ModelFile, ...]
|
|
154
|
+
license: LicenseTerms
|
|
155
|
+
#: F-82 forbids resampling, so this is the rate of every result.
|
|
156
|
+
sample_rate: int
|
|
157
|
+
languages: tuple[Language, ...]
|
|
158
|
+
minimum_budget: MinimumBudget
|
|
159
|
+
voices: tuple[VoiceEntry, ...]
|
|
160
|
+
#: What the app asks the engine to load, when that differs from the id.
|
|
161
|
+
engine_model_name: str = ""
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
if not self.files:
|
|
165
|
+
raise AssertionError(f"{self.model_id}: a manifest entry with no files")
|
|
166
|
+
paths = [f.relative_path for f in self.files]
|
|
167
|
+
if len(set(paths)) != len(paths):
|
|
168
|
+
raise AssertionError(f"{self.model_id}: duplicate file path in manifest")
|
|
169
|
+
ids = [v.voice_id for v in self.voices]
|
|
170
|
+
if len(set(ids)) != len(ids):
|
|
171
|
+
raise AssertionError(f"{self.model_id}: duplicate voice id")
|
|
172
|
+
if Language.AUTO in self.languages:
|
|
173
|
+
# F-05's AUTO is a request-time mode resolved per sentence; it is
|
|
174
|
+
# never something a model "supports".
|
|
175
|
+
raise AssertionError(f"{self.model_id}: AUTO is not a model language")
|
|
176
|
+
if self.sample_rate <= 0:
|
|
177
|
+
raise AssertionError(f"{self.model_id}: bad sample rate")
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def total_bytes(self) -> int:
|
|
181
|
+
"""What F-63 reports as required storage before a download."""
|
|
182
|
+
return sum(f.byte_size for f in self.files)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def file_count(self) -> int:
|
|
186
|
+
return len(self.files)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def engine_name(self) -> str:
|
|
190
|
+
return self.engine_model_name or self.model_id
|
|
191
|
+
|
|
192
|
+
def file(self, relative_path: str) -> ModelFile | None:
|
|
193
|
+
for f in self.files:
|
|
194
|
+
if f.relative_path == relative_path:
|
|
195
|
+
return f
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def supports_language(self, language: Language) -> bool:
|
|
199
|
+
"""``AUTO`` is supported wherever anything is (F-05)."""
|
|
200
|
+
if language is Language.AUTO:
|
|
201
|
+
return bool(self.languages)
|
|
202
|
+
return language in self.languages
|
|
203
|
+
|
|
204
|
+
def voices_for(self, gender: Gender) -> tuple[VoiceEntry, ...]:
|
|
205
|
+
return tuple(v for v in self.voices if v.gender is gender)
|
|
206
|
+
|
|
207
|
+
def voice(self, voice_id: str) -> VoiceEntry:
|
|
208
|
+
for v in self.voices:
|
|
209
|
+
if v.voice_id == voice_id:
|
|
210
|
+
return v
|
|
211
|
+
raise EchoActError(
|
|
212
|
+
Code.VOICE_UNKNOWN, detail={"model_id": self.model_id, "voice_id": voice_id}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def validate_voice(self, voice_id: str, gender: Gender) -> VoiceEntry:
|
|
216
|
+
"""F-06: the voice must exist *and* belong to the requested gender.
|
|
217
|
+
|
|
218
|
+
Two codes rather than one, because "no such voice" and "that voice is
|
|
219
|
+
the other gender" are different mistakes for a caller to fix, and
|
|
220
|
+
F-57 makes the code the thing a client branches on.
|
|
221
|
+
"""
|
|
222
|
+
voice = self.voice(voice_id)
|
|
223
|
+
if voice.gender is not gender:
|
|
224
|
+
raise EchoActError(
|
|
225
|
+
Code.VOICE_GENDER_MISMATCH,
|
|
226
|
+
detail={
|
|
227
|
+
"voice_id": voice_id,
|
|
228
|
+
"voice_gender": voice.gender.value,
|
|
229
|
+
"requested_gender": gender.value,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
return voice
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True, slots=True)
|
|
236
|
+
class Manifest:
|
|
237
|
+
"""The lineup this build ships.
|
|
238
|
+
|
|
239
|
+
F-04 keeps the selection machinery even with one model, so that a second
|
|
240
|
+
one is data rather than structure; this container is that machinery's
|
|
241
|
+
read side.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
models: tuple[ModelEntry, ...] = field(default_factory=tuple)
|
|
245
|
+
|
|
246
|
+
def __post_init__(self) -> None:
|
|
247
|
+
ids = [m.model_id for m in self.models]
|
|
248
|
+
if len(set(ids)) != len(ids):
|
|
249
|
+
raise AssertionError("duplicate model id in manifest")
|
|
250
|
+
|
|
251
|
+
def __iter__(self) -> Iterator[ModelEntry]:
|
|
252
|
+
return iter(self.models)
|
|
253
|
+
|
|
254
|
+
def __len__(self) -> int:
|
|
255
|
+
return len(self.models)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def model_ids(self) -> tuple[str, ...]:
|
|
259
|
+
return tuple(m.model_id for m in self.models)
|
|
260
|
+
|
|
261
|
+
def has(self, model_id: str) -> bool:
|
|
262
|
+
return any(m.model_id == model_id for m in self.models)
|
|
263
|
+
|
|
264
|
+
def get(self, model_id: str) -> ModelEntry:
|
|
265
|
+
for m in self.models:
|
|
266
|
+
if m.model_id == model_id:
|
|
267
|
+
return m
|
|
268
|
+
raise EchoActError(Code.MODEL_UNKNOWN, detail={"model_id": model_id})
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
__all__ = [
|
|
272
|
+
"LicenseTerms",
|
|
273
|
+
"Manifest",
|
|
274
|
+
"MinimumBudget",
|
|
275
|
+
"ModelEntry",
|
|
276
|
+
"ModelFile",
|
|
277
|
+
"VoiceEntry",
|
|
278
|
+
]
|