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,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
+ ]