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
echoact/domain.py ADDED
@@ -0,0 +1,487 @@
1
+ """The vocabulary every layer shares: states, settings, and the four entities
2
+ Section 4.2 manages.
3
+
4
+ These types are deliberately free of I/O. The database stores them, the REST
5
+ layer projects them, the GUI renders them, and the engine produces them, but
6
+ none of those concerns appear here. That is what lets N-24 hold: one job
7
+ state machine, one segment shape, one definition of what a result is.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field, replace
14
+ from enum import StrEnum
15
+ from typing import Any
16
+
17
+ from .errors import Code, EchoActError
18
+ from .policy import TEMPO_MAX, TEMPO_MIN
19
+
20
+ # ======================================================================
21
+ # Enumerations
22
+ # ======================================================================
23
+
24
+
25
+ class JobKind(StrEnum):
26
+ """F-54's discriminator. Exactly one value is accepted in this release.
27
+
28
+ It exists so that a second kind is an additive change rather than a
29
+ breaking one; see A.3. Nothing in the product hints at what the second
30
+ value might be.
31
+ """
32
+
33
+ SPEECH = "speech"
34
+
35
+
36
+ class JobState(StrEnum):
37
+ """Section 5.1. ``Complete`` also implies any requested retention succeeded."""
38
+
39
+ ACCEPTED = "accepted"
40
+ PREPARING_MODEL = "preparing_model"
41
+ GENERATING = "generating"
42
+ CANCELING = "canceling"
43
+ COMPLETE = "complete"
44
+ FAILED = "failed"
45
+ CANCELED = "canceled"
46
+ INTERRUPTED = "interrupted"
47
+
48
+ @property
49
+ def is_terminal(self) -> bool:
50
+ return self in _TERMINAL_STATES
51
+
52
+ @property
53
+ def is_active(self) -> bool:
54
+ """True while the job may still be holding the generation slot."""
55
+ return self in _ACTIVE_STATES
56
+
57
+
58
+ _TERMINAL_STATES: frozenset[JobState] = frozenset(
59
+ {JobState.COMPLETE, JobState.FAILED, JobState.CANCELED, JobState.INTERRUPTED}
60
+ )
61
+ _ACTIVE_STATES: frozenset[JobState] = frozenset(
62
+ {
63
+ JobState.ACCEPTED,
64
+ JobState.PREPARING_MODEL,
65
+ JobState.GENERATING,
66
+ JobState.CANCELING,
67
+ }
68
+ )
69
+
70
+ # Section 5.1's legal transitions. Anything absent is a programming error,
71
+ # not a runtime condition, and ``check_transition`` says so loudly.
72
+ _TRANSITIONS: dict[JobState, frozenset[JobState]] = {
73
+ JobState.ACCEPTED: frozenset(
74
+ {JobState.PREPARING_MODEL, JobState.GENERATING, JobState.CANCELING,
75
+ JobState.FAILED, JobState.INTERRUPTED}
76
+ ),
77
+ JobState.PREPARING_MODEL: frozenset(
78
+ {JobState.GENERATING, JobState.CANCELING, JobState.FAILED, JobState.INTERRUPTED}
79
+ ),
80
+ JobState.GENERATING: frozenset(
81
+ {JobState.COMPLETE, JobState.CANCELING, JobState.FAILED, JobState.INTERRUPTED}
82
+ ),
83
+ # "If cancellation and completion race, whichever terminal state is
84
+ # confirmed first is kept. Completion is never overwritten by Canceled."
85
+ JobState.CANCELING: frozenset({JobState.CANCELED, JobState.COMPLETE, JobState.FAILED}),
86
+ JobState.COMPLETE: frozenset(),
87
+ JobState.FAILED: frozenset(),
88
+ JobState.CANCELED: frozenset(),
89
+ JobState.INTERRUPTED: frozenset(),
90
+ }
91
+
92
+
93
+ def can_transition(current: JobState, nxt: JobState) -> bool:
94
+ return nxt in _TRANSITIONS[current]
95
+
96
+
97
+ def check_transition(current: JobState, nxt: JobState) -> None:
98
+ if not can_transition(current, nxt):
99
+ raise AssertionError(f"illegal job transition {current.value} -> {nxt.value}")
100
+
101
+
102
+ class PlaybackState(StrEnum):
103
+ """Section 5.2."""
104
+
105
+ NOT_READY = "not_ready"
106
+ PLAYING = "playing"
107
+ PAUSED = "paused"
108
+ WAITING_FOR_SEGMENT = "waiting_for_segment"
109
+ STOPPED = "stopped"
110
+ ENDED = "ended"
111
+
112
+
113
+ class RequestPath(StrEnum):
114
+ """Which surface asked for the job. F-69 shows it; F-50 uses it for
115
+ ownership, though never for priority -- see A.3."""
116
+
117
+ GUI = "gui"
118
+ REST = "rest"
119
+ MCP = "mcp"
120
+
121
+
122
+ class Language(StrEnum):
123
+ """F-05. ``AUTO`` resolves per sentence on whether it contains Hangul."""
124
+
125
+ AUTO = "auto"
126
+ KO = "ko"
127
+ EN = "en"
128
+
129
+
130
+ class Gender(StrEnum):
131
+ FEMALE = "female"
132
+ MALE = "male"
133
+
134
+
135
+ class SpeakingStyle(StrEnum):
136
+ """F-08. A preset over tempo, inter-segment pause, and whatever
137
+ expression controls the model exposes. Never changes which characters
138
+ are spoken."""
139
+
140
+ NATURAL = "natural"
141
+ CALM = "calm"
142
+ BRIGHT = "bright"
143
+ NARRATION = "narration"
144
+
145
+
146
+ class Capability(StrEnum):
147
+ """F-61's separately grantable permissions. A credential carries a set
148
+ of these; the MCP server's effective permissions are exactly its REST
149
+ credential's, so revocation is one mechanism."""
150
+
151
+ GENERATE = "generate"
152
+ READ_RESULTS = "read_results"
153
+ READ_HISTORY = "read_history"
154
+ OWNER = "owner"
155
+
156
+
157
+ class RetentionMode(StrEnum):
158
+ """F-42. A one-off job's body text and audio never enter permanent
159
+ history; 4.2 fixes what each keeps."""
160
+
161
+ ONE_OFF = "one_off"
162
+ RETAINED = "retained"
163
+
164
+
165
+ # ======================================================================
166
+ # Voice settings
167
+ # ======================================================================
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class VoiceSettings:
172
+ """One fixed set of settings. A job freezes these at creation; F-10
173
+ forbids changing them mid-job."""
174
+
175
+ model_id: str
176
+ language: Language
177
+ gender: Gender
178
+ voice_id: str
179
+ style: SpeakingStyle
180
+ tempo: float
181
+
182
+ def __post_init__(self) -> None:
183
+ """Coerce the enum fields, because they arrive as plain strings.
184
+
185
+ Qt hands back a ``str`` for a ``StrEnum`` stored as item data, and
186
+ JSON gives one too. A ``StrEnum`` compares equal to its value, so a
187
+ plain string works everywhere until something asks for ``.value`` --
188
+ which then fails at the point of use rather than at the boundary.
189
+ Normalising here keeps the type honest for everyone downstream.
190
+ """
191
+ for name, kind in (
192
+ ("language", Language),
193
+ ("gender", Gender),
194
+ ("style", SpeakingStyle),
195
+ ):
196
+ value = getattr(self, name)
197
+ if not isinstance(value, kind):
198
+ try:
199
+ object.__setattr__(self, name, kind(value))
200
+ except ValueError as exc:
201
+ code = {
202
+ "language": Code.LANGUAGE_UNKNOWN,
203
+ "gender": Code.VOICE_UNKNOWN,
204
+ "style": Code.STYLE_UNKNOWN,
205
+ }[name]
206
+ raise EchoActError(code, detail={name: str(value)}, cause=exc) from exc
207
+
208
+ def validate(self) -> None:
209
+ if not (TEMPO_MIN - 1e-9 <= self.tempo <= TEMPO_MAX + 1e-9):
210
+ raise EchoActError(
211
+ Code.TEMPO_OUT_OF_RANGE,
212
+ detail={"tempo": self.tempo, "min": TEMPO_MIN, "max": TEMPO_MAX},
213
+ )
214
+
215
+ def to_dict(self) -> dict[str, Any]:
216
+ return {
217
+ "model_id": self.model_id,
218
+ "language": self.language.value,
219
+ "gender": self.gender.value,
220
+ "voice_id": self.voice_id,
221
+ "style": self.style.value,
222
+ "tempo": round(self.tempo, 3),
223
+ }
224
+
225
+ @classmethod
226
+ def from_dict(cls, d: dict[str, Any]) -> VoiceSettings:
227
+ return cls(
228
+ model_id=d["model_id"],
229
+ language=Language(d["language"]),
230
+ gender=Gender(d["gender"]),
231
+ voice_id=d["voice_id"],
232
+ style=SpeakingStyle(d["style"]),
233
+ tempo=float(d["tempo"]),
234
+ )
235
+
236
+ def with_(self, **kw: Any) -> VoiceSettings:
237
+ return replace(self, **kw)
238
+
239
+
240
+ @dataclass(frozen=True, slots=True)
241
+ class Budget:
242
+ """The resource ceiling actually applied to a job.
243
+
244
+ F-78 distinguishes the value on screen from the value in force, so a job
245
+ records the one it ran under and the GUI shows both.
246
+ """
247
+
248
+ cpu_percent: int
249
+ memory_bytes: int
250
+ #: Worker threads derived from ``cpu_percent`` per F-87, never left to
251
+ #: the runtime's own default. A.5 records that capping is also faster.
252
+ intra_op_threads: int
253
+ inter_op_threads: int = 1
254
+
255
+ def to_dict(self) -> dict[str, Any]:
256
+ return {
257
+ "cpu_percent": self.cpu_percent,
258
+ "memory_bytes": self.memory_bytes,
259
+ "intra_op_threads": self.intra_op_threads,
260
+ "inter_op_threads": self.inter_op_threads,
261
+ }
262
+
263
+ @classmethod
264
+ def from_dict(cls, d: dict[str, Any]) -> Budget:
265
+ return cls(
266
+ cpu_percent=int(d["cpu_percent"]),
267
+ memory_bytes=int(d["memory_bytes"]),
268
+ intra_op_threads=int(d["intra_op_threads"]),
269
+ inter_op_threads=int(d.get("inter_op_threads", 1)),
270
+ )
271
+
272
+
273
+ # ======================================================================
274
+ # Text ranges
275
+ # ======================================================================
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class TextRange:
280
+ """A half-open span of the job's source text, in Unicode code points.
281
+
282
+ 4.2 is explicit that these are code-point offsets, start-inclusive and
283
+ end-exclusive, and that they are never conflated with UI indices. Qt
284
+ counts UTF-16 units, so the reading surface converts at its boundary and
285
+ nowhere else.
286
+ """
287
+
288
+ start: int
289
+ end: int
290
+
291
+ def __post_init__(self) -> None:
292
+ if self.start < 0 or self.end < self.start:
293
+ raise AssertionError(f"degenerate text range [{self.start}, {self.end})")
294
+
295
+ @property
296
+ def length(self) -> int:
297
+ return self.end - self.start
298
+
299
+ def slice(self, text: str) -> str:
300
+ return text[self.start : self.end]
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class TimeRange:
305
+ """Milliseconds from the start of the job's audio (4.2)."""
306
+
307
+ start_ms: int
308
+ end_ms: int
309
+
310
+ def __post_init__(self) -> None:
311
+ if self.start_ms < 0 or self.end_ms < self.start_ms:
312
+ raise AssertionError(f"degenerate time range [{self.start_ms}, {self.end_ms})")
313
+
314
+ @property
315
+ def duration_ms(self) -> int:
316
+ return self.end_ms - self.start_ms
317
+
318
+ def contains(self, ms: int) -> bool:
319
+ return self.start_ms <= ms < self.end_ms
320
+
321
+
322
+ # ======================================================================
323
+ # Entities (Section 4.2)
324
+ # ======================================================================
325
+
326
+
327
+ @dataclass(slots=True)
328
+ class Segment:
329
+ """The smallest unit linking audio to source text.
330
+
331
+ ``source`` always refers to the *original* text the user entered, never to
332
+ the normalised form sent for synthesis -- F-27 makes that a correctness
333
+ requirement, not a nicety. ``spoken_text`` is what the engine received;
334
+ it may be empty for a segment that carries only unspoken characters.
335
+ """
336
+
337
+ index: int
338
+ source: TextRange
339
+ #: Normalised text handed to the engine. Empty means nothing is spoken
340
+ #: for this range; the range is still highlighted, per F-27.
341
+ spoken_text: str
342
+ language: str
343
+ #: Filled once generated. Times are relative to the job's full audio.
344
+ time: TimeRange | None = None
345
+ #: Silence the app appends after this segment, per F-82 and F-08. It is
346
+ #: attributed to the preceding segment, so it is inside ``time``.
347
+ trailing_silence_ms: int = 0
348
+ audio_path: str | None = None
349
+ frame_count: int = 0
350
+ ready: bool = False
351
+ segment_id: str = ""
352
+
353
+ @property
354
+ def is_spoken(self) -> bool:
355
+ return bool(self.spoken_text.strip())
356
+
357
+
358
+ @dataclass(slots=True)
359
+ class Result:
360
+ """4.2. Addressed by a permission-checkable identifier, never a path."""
361
+
362
+ result_id: str
363
+ job_id: str
364
+ sample_rate: int
365
+ channels: int
366
+ sample_width_bits: int
367
+ frame_count: int
368
+ byte_size: int
369
+ #: SHA-256 of the WAV bytes. 4.2 calls this integrity verification data.
370
+ digest: str
371
+ created_at: float
372
+ expires_at: float | None
373
+ #: Relative to the app's audio directory; never handed to a client.
374
+ relative_path: str = ""
375
+
376
+ @property
377
+ def duration_ms(self) -> int:
378
+ return int(round(self.frame_count * 1000 / self.sample_rate))
379
+
380
+
381
+ @dataclass(slots=True)
382
+ class Job:
383
+ """One generation request against one fixed text and one fixed setting."""
384
+
385
+ job_id: str
386
+ kind: JobKind
387
+ request_path: RequestPath
388
+ owner_client_id: str
389
+ state: JobState
390
+ #: The frozen source text. F-29 compares the live input against this;
391
+ #: a one-off job clears it once it reaches a terminal state and its
392
+ #: retention window closes.
393
+ source_text: str
394
+ settings: VoiceSettings
395
+ budget: Budget | None
396
+ retention: RetentionMode
397
+ created_at: float
398
+ started_at: float | None = None
399
+ ended_at: float | None = None
400
+ error_code: str | None = None
401
+ error_message: str | None = None
402
+ #: Set only for a job created by an integration; F-70 names the client
403
+ #: in the completion notice.
404
+ client_label: str | None = None
405
+ idempotency_key: str | None = None
406
+ segments: list[Segment] = field(default_factory=list)
407
+ result: Result | None = None
408
+ #: 0.0 to 1.0 over segments generated, which is generation progress and
409
+ #: not playback progress -- Section 1.2 keeps those separate.
410
+ generated_segments: int = 0
411
+ total_segments: int = 0
412
+
413
+ @property
414
+ def progress(self) -> float:
415
+ if self.total_segments <= 0:
416
+ return 0.0
417
+ return min(1.0, self.generated_segments / self.total_segments)
418
+
419
+ @property
420
+ def is_terminal(self) -> bool:
421
+ return self.state.is_terminal
422
+
423
+ def ready_segments(self) -> list[Segment]:
424
+ return [s for s in self.segments if s.ready]
425
+
426
+ def playable_ms(self) -> int:
427
+ """How much audio can be played right now.
428
+
429
+ F-14 forbids seeking past this, and F-12's player waits here rather
430
+ than at the end of the document.
431
+ """
432
+ ready = self.ready_segments()
433
+ return ready[-1].time.end_ms if ready and ready[-1].time else 0
434
+
435
+
436
+ @dataclass(slots=True)
437
+ class Document:
438
+ """4.2. Editing a document never touches an existing job's snapshot."""
439
+
440
+ document_id: str
441
+ title: str
442
+ body: str
443
+ created_at: float
444
+ modified_at: float
445
+ version: int = 1
446
+
447
+
448
+ # ======================================================================
449
+ # Helpers used by more than one layer
450
+ # ======================================================================
451
+
452
+ _HANGUL = re.compile(r"[가-힣ᄀ-ᇿ㄰-㆏]")
453
+
454
+
455
+ def contains_hangul(text: str) -> bool:
456
+ """F-05's automatic-mode test, in one place so the GUI, the segmenter,
457
+ and the estimator cannot disagree about what a Korean sentence is."""
458
+ return _HANGUL.search(text) is not None
459
+
460
+
461
+ def clamp(value: float, low: float, high: float) -> float:
462
+ return max(low, min(high, value))
463
+
464
+
465
+ __all__ = [
466
+ "Budget",
467
+ "Capability",
468
+ "Document",
469
+ "Gender",
470
+ "Job",
471
+ "JobKind",
472
+ "JobState",
473
+ "Language",
474
+ "PlaybackState",
475
+ "RequestPath",
476
+ "RetentionMode",
477
+ "Result",
478
+ "Segment",
479
+ "SpeakingStyle",
480
+ "TextRange",
481
+ "TimeRange",
482
+ "VoiceSettings",
483
+ "can_transition",
484
+ "check_transition",
485
+ "clamp",
486
+ "contains_hangul",
487
+ ]
File without changes