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,509 @@
1
+ """The wire shapes of Section 2.10's twelve operations.
2
+
3
+ These models exist for two reasons at once. They validate what arrives, and
4
+ they are what FastAPI turns into the machine-readable specification F-57
5
+ requires -- so a field that is absent here is absent from the contract, and
6
+ the twelve response models below are the whole of what a client may depend on.
7
+
8
+ Two rules run through all of them.
9
+
10
+ * Nothing carries a filesystem path. 4.2 says a result is "queried by a
11
+ permission-checkable identifier rather than an arbitrary path", and F-57
12
+ keeps internal paths out of responses, so audio is addressed by job and
13
+ segment identifier and the bytes arrive from a separate streaming route.
14
+ * Enumerated values are the domain's own ``StrEnum`` members. N-24 makes a
15
+ behaviour difference between the GUI, REST, and MCP a defect, and the
16
+ cheapest way to keep the vocabularies identical is to have one of them.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+
25
+ from ..domain import (
26
+ Gender,
27
+ JobKind,
28
+ JobState,
29
+ Language,
30
+ RequestPath,
31
+ RetentionMode,
32
+ SpeakingStyle,
33
+ )
34
+ from ..policy import TEMPO_DEFAULT, TEMPO_MAX, TEMPO_MIN
35
+
36
+ #: Longest duplicate-prevention key accepted. 4.1 scopes the key per client
37
+ #: and keeps it for an hour; it is an identifier the caller mints, so the only
38
+ #: bound that matters is that it cannot itself become a payload.
39
+ MAX_IDEMPOTENCY_KEY_CHARS = 200
40
+
41
+ def _strict(**extra: Any) -> ConfigDict:
42
+ """The configuration every model in this module shares.
43
+
44
+ ``extra="forbid"`` is the request half of F-54's "unknown models or
45
+ options are not arbitrarily substituted". Dropping an option this
46
+ version does not define substitutes its default just as surely as
47
+ replacing it would: a caller that wrote ``retainn`` gets a one-off job
48
+ whose text and audio 4.1 removes an hour after it ends, a caller that
49
+ wrote ``speed`` gets 1.0x, and neither is told. Section 2.10 is the
50
+ surface an automated client depends on, so an unrecognised word costs one
51
+ round trip and is named, rather than being silently reinterpreted.
52
+ """
53
+ return ConfigDict(extra="forbid", protected_namespaces=(), **extra)
54
+
55
+
56
+ _STRICT = _strict()
57
+
58
+
59
+ # ======================================================================
60
+ # Requests
61
+ # ======================================================================
62
+
63
+
64
+ class VoiceIn(BaseModel):
65
+ """F-04 to F-08's settings, stated in full.
66
+
67
+ Nothing defaults to the owner's current GUI selection. F-54 forbids
68
+ substituting an unknown model or option, and silently inheriting the
69
+ desktop's chosen voice would make an automated caller's output depend on
70
+ what the person at the keyboard last clicked.
71
+ """
72
+
73
+ model_config = _STRICT
74
+
75
+ model_id: str = Field(description="Model identifier from GET /api/v1/models.")
76
+ voice_id: str = Field(description="Voice identifier belonging to that model.")
77
+ gender: Gender = Field(description="Must match the voice's own gender (F-06).")
78
+ language: Language = Field(
79
+ default=Language.AUTO,
80
+ description="'auto' resolves per sentence on whether it contains Hangul.",
81
+ )
82
+ style: SpeakingStyle = Field(default=SpeakingStyle.NATURAL)
83
+ tempo: float = Field(default=TEMPO_DEFAULT, ge=TEMPO_MIN, le=TEMPO_MAX)
84
+
85
+
86
+ _EXAMPLE_VOICE = {
87
+ "model_id": "supertonic-3",
88
+ "voice_id": "F1",
89
+ "gender": "female",
90
+ "language": "auto",
91
+ "style": "narration",
92
+ "tempo": 1.0,
93
+ }
94
+
95
+
96
+ class EstimateRequest(BaseModel):
97
+ """F-88's pre-flight body. Creates no job and loads no model."""
98
+
99
+ model_config = _strict(
100
+ json_schema_extra={
101
+ "examples": [
102
+ {"kind": "speech", "text": "안녕하세요. 반갑습니다.", "voice": _EXAMPLE_VOICE}
103
+ ]
104
+ }
105
+ )
106
+
107
+ text: str = Field(description="The text that would be spoken.")
108
+ voice: VoiceIn
109
+ kind: JobKind = Field(default=JobKind.SPEECH)
110
+
111
+
112
+ class CreateJobRequest(BaseModel):
113
+ """F-54's generation request.
114
+
115
+ ``idempotency_key`` is required rather than optional: F-49 refuses a
116
+ request without one, because with a single generation slot a client that
117
+ retries after a slow response would otherwise queue a second rendering of
118
+ the same text behind the first.
119
+
120
+ ``text`` is absent when the request arrived as a multipart upload; F-54
121
+ makes the two alternatives and forbids both at once.
122
+ """
123
+
124
+ model_config = _strict(
125
+ json_schema_extra={
126
+ "examples": [
127
+ {
128
+ "kind": "speech",
129
+ "idempotency_key": "2f8c1d5e-report-2026-09-11",
130
+ "text": "EchoAct reads a document aloud.",
131
+ "voice": _EXAMPLE_VOICE,
132
+ "retain": False,
133
+ "wait_s": 10,
134
+ }
135
+ ]
136
+ },
137
+ )
138
+
139
+ kind: JobKind = Field(description="Exactly 'speech' in this release (F-54).")
140
+ idempotency_key: str = Field(
141
+ min_length=1,
142
+ max_length=MAX_IDEMPOTENCY_KEY_CHARS,
143
+ description="Duplicate-prevention key, scoped to this client (F-49).",
144
+ )
145
+ voice: VoiceIn
146
+ text: str | None = Field(
147
+ default=None, description="Inline text. Omit when uploading a file instead."
148
+ )
149
+ retain: bool = Field(
150
+ default=False,
151
+ description="Keep the job, its snapshot, and its audio in history (F-42).",
152
+ )
153
+ wait_s: float | None = Field(
154
+ default=None,
155
+ description=(
156
+ "Seconds to wait for a terminal state before answering (F-88). "
157
+ "Clamped to the service's ceiling; the job is unaffected either way."
158
+ ),
159
+ )
160
+ encoding: str | None = Field(
161
+ default=None,
162
+ description="F-34's confirmed encoding, honoured only for an upload whose own is unclear.",
163
+ )
164
+ confirm_unknown: bool = Field(
165
+ default=False,
166
+ description="F-35's confirmation that an upload with no known extension is text.",
167
+ )
168
+
169
+
170
+ # ======================================================================
171
+ # Shared projections
172
+ # ======================================================================
173
+
174
+
175
+ class ErrorBody(BaseModel):
176
+ """F-57's error shape. Carries no path, no credential, and no body text."""
177
+
178
+ model_config = _STRICT
179
+
180
+ code: str
181
+ message: str
182
+ retryable: bool
183
+ request_id: str
184
+ detail: dict[str, Any] | None = None
185
+ retry_after_s: float | None = None
186
+
187
+
188
+ class VoiceOut(BaseModel):
189
+ model_config = _STRICT
190
+
191
+ model_id: str
192
+ voice_id: str
193
+ gender: Gender
194
+ language: Language
195
+ style: SpeakingStyle
196
+ tempo: float
197
+
198
+
199
+ class BudgetOut(BaseModel):
200
+ """F-78's "value actually applied", never the value on the owner's screen."""
201
+
202
+ model_config = _STRICT
203
+
204
+ cpu_percent: int
205
+ memory_bytes: int
206
+ intra_op_threads: int
207
+ inter_op_threads: int
208
+
209
+
210
+ class JobError(BaseModel):
211
+ model_config = _STRICT
212
+
213
+ code: str
214
+ message: str | None = None
215
+ retryable: bool
216
+
217
+
218
+ class JobProgress(BaseModel):
219
+ model_config = _STRICT
220
+
221
+ generated_segments: int
222
+ total_segments: int
223
+ fraction: float
224
+
225
+
226
+ class JobOut(BaseModel):
227
+ """One job as F-54 and F-56 present it. Never the source text: F-56 puts
228
+ the snapshot behind its own separately authorised request."""
229
+
230
+ model_config = _STRICT
231
+
232
+ job_id: str
233
+ kind: JobKind
234
+ state: JobState
235
+ terminal: bool
236
+ request_path: RequestPath
237
+ owner_client_id: str
238
+ client_label: str | None
239
+ retention: RetentionMode
240
+ voice: VoiceOut
241
+ budget: BudgetOut | None
242
+ progress: JobProgress
243
+ created_at: float
244
+ started_at: float | None
245
+ ended_at: float | None
246
+ result_ready: bool
247
+ result_expired: bool
248
+ audio_duration_ms: int
249
+ error: JobError | None = None
250
+ #: Present only on a response to POST /api/v1/jobs. F-88 says waiting is
251
+ #: an optimisation, so a caller has to be able to tell that it happened
252
+ #: without inferring it from the elapsed time.
253
+ waited_s: float | None = None
254
+ #: True when this response returned an existing job for a repeated
255
+ #: duplicate-prevention key rather than creating one (F-49).
256
+ duplicate: bool | None = None
257
+
258
+
259
+ class JobPage(BaseModel):
260
+ """4.1's list page: 20 items by default, 100 at most."""
261
+
262
+ model_config = _STRICT
263
+
264
+ items: list[JobOut]
265
+ total: int
266
+ limit: int
267
+ offset: int
268
+ has_more: bool
269
+
270
+
271
+ class JobTextOut(BaseModel):
272
+ """F-56's separately authorised snapshot."""
273
+
274
+ model_config = _STRICT
275
+
276
+ job_id: str
277
+ text: str
278
+ codepoints: int
279
+ retention: RetentionMode
280
+
281
+
282
+ class SegmentOut(BaseModel):
283
+ """F-55's per-segment mapping.
284
+
285
+ ``source_start``/``source_end`` are Unicode code points into the job's
286
+ source text, start-inclusive and end-exclusive (4.2), and are never UI
287
+ indices. Times are milliseconds from the start of the job's audio.
288
+ """
289
+
290
+ model_config = _STRICT
291
+
292
+ segment_id: str
293
+ index: int
294
+ source_start: int
295
+ source_end: int
296
+ start_ms: int
297
+ end_ms: int
298
+ duration_ms: int
299
+ language: str
300
+ spoken: bool
301
+ has_audio: bool
302
+
303
+
304
+ class SegmentsOut(BaseModel):
305
+ model_config = _STRICT
306
+
307
+ job_id: str
308
+ segments: list[SegmentOut]
309
+ #: The index of the last ready segment, or null when none is ready. A
310
+ #: caller polls with this rather than re-reading the whole list.
311
+ last_sequence: int | None
312
+ ready_count: int
313
+ total_segments: int
314
+ complete: bool
315
+
316
+
317
+ class IntegrityOut(BaseModel):
318
+ """4.2's "integrity verification data", named rather than bare."""
319
+
320
+ model_config = _STRICT
321
+
322
+ algorithm: str
323
+ value: str
324
+ state: str
325
+
326
+
327
+ class ResultOut(BaseModel):
328
+ """4.2's Result entity, as F-55 hands it to a client."""
329
+
330
+ model_config = _STRICT
331
+
332
+ job_id: str
333
+ result_id: str
334
+ #: ``echoact.audio.wav.FORMAT_NAME``. F-82 fixes one output format and
335
+ #: this is its name, rather than a second vocabulary for the same thing.
336
+ format: str
337
+ media_type: str
338
+ sample_rate: int
339
+ channels: int
340
+ sample_width_bits: int
341
+ frame_count: int
342
+ duration_ms: int
343
+ byte_size: int
344
+ integrity: IntegrityOut
345
+ created_at: float
346
+ expires_at: float | None
347
+ expired: bool
348
+ available: bool
349
+
350
+
351
+ class EstimateOut(BaseModel):
352
+ """F-88's answer. ``approximate`` is always true and says so on the wire."""
353
+
354
+ model_config = _STRICT
355
+
356
+ valid: bool
357
+ codepoints: int
358
+ segment_count: int
359
+ audio_ms: int
360
+ synthesis_ms: int
361
+ first_audio_ms: int
362
+ slot_free: bool
363
+ model_ready: bool
364
+ approximate: bool
365
+ problems: list[dict[str, Any]]
366
+
367
+
368
+ class VoiceChoice(BaseModel):
369
+ """F-53: a bare identifier gives a caller that is not a person no basis
370
+ for choosing, so the manifest's description travels with it."""
371
+
372
+ model_config = _STRICT
373
+
374
+ voice_id: str
375
+ display_name: str
376
+ gender: Gender
377
+ description: str
378
+
379
+
380
+ class LicenseOut(BaseModel):
381
+ model_config = _STRICT
382
+
383
+ name: str
384
+ acceptance_required: bool
385
+ accepted: bool
386
+
387
+
388
+ class MinimumBudgetOut(BaseModel):
389
+ model_config = _STRICT
390
+
391
+ memory_bytes: int
392
+ cpu_percent: int
393
+
394
+
395
+ class ModelOut(BaseModel):
396
+ """F-53's model entry and F-63's row.
397
+
398
+ ``state`` distinguishes an undownloaded model from a server fault, which
399
+ F-53 requires outright, and ``unavailable_reason`` carries F-04's reason
400
+ rather than hiding a model that cannot run.
401
+ """
402
+
403
+ model_config = _STRICT
404
+
405
+ model_id: str
406
+ display_name: str
407
+ state: str
408
+ ready: bool
409
+ runnable: bool
410
+ unavailable_reason: str | None
411
+ sample_rate: int
412
+ languages: list[Language]
413
+ styles: list[SpeakingStyle]
414
+ voices: list[VoiceChoice]
415
+ minimum_budget: MinimumBudgetOut
416
+ bytes_total: int
417
+ bytes_present: int
418
+ license: LicenseOut
419
+ download_authorised: bool
420
+
421
+
422
+ class ModelsOut(BaseModel):
423
+ model_config = _STRICT
424
+
425
+ models: list[ModelOut]
426
+ languages: list[Language]
427
+ styles: list[SpeakingStyle]
428
+ genders: list[Gender]
429
+ input_formats: list[str]
430
+ encodings: list[str]
431
+ tempo_min: float
432
+ tempo_max: float
433
+ tempo_default: float
434
+
435
+
436
+ class ResourcePolicyOut(BaseModel):
437
+ """Section 4.1's limits, as the actual numbers in force.
438
+
439
+ F-53 asks for "the actual resource policy", which is why the applied
440
+ budget is here beside the fixed limits: the ceilings are constants, but
441
+ what a job may use depends on the machine at this moment (F-21, N-04).
442
+ """
443
+
444
+ model_config = _STRICT
445
+
446
+ max_input_codepoints: int
447
+ max_upload_bytes: int
448
+ max_request_body_bytes: int
449
+ concurrent_generation: int
450
+ generation_requests_per_minute: int
451
+ other_requests_per_minute: int
452
+ list_page_default: int
453
+ list_page_max: int
454
+ bounded_wait_default_s: float
455
+ bounded_wait_max_s: float
456
+ one_off_result_ttl_s: float
457
+ idempotency_ttl_s: float
458
+ tempo_min: float
459
+ tempo_max: float
460
+ applied_budget: BudgetOut | None
461
+ budget_unavailable_reason: str | None
462
+
463
+
464
+ class CapabilitiesOut(BaseModel):
465
+ model_config = _STRICT
466
+
467
+ granted: list[str]
468
+ job_kinds: list[JobKind]
469
+ languages: list[Language]
470
+ styles: list[SpeakingStyle]
471
+ input_formats: list[str]
472
+ bounded_wait: bool
473
+ estimate: bool
474
+ upload: bool
475
+ history: bool
476
+ results: bool
477
+ generation: bool
478
+
479
+
480
+ class GenerationStateOut(BaseModel):
481
+ """F-47's single slot, as a caller needs to see it before asking."""
482
+
483
+ model_config = _STRICT
484
+
485
+ busy: bool
486
+ #: Set only when the running job belongs to this caller; N-19 keeps
487
+ #: another owner's job identifier out of a status response.
488
+ current_job_id: str | None
489
+ current_job_is_mine: bool
490
+
491
+
492
+ class StatusOut(BaseModel):
493
+ model_config = _STRICT
494
+
495
+ service: str
496
+ version: str
497
+ api_version: str
498
+ state: str
499
+ started_at: float
500
+ uptime_s: float
501
+ bind_host: str
502
+ bind_port: int
503
+ mcp_enabled: bool
504
+ client_id: str
505
+ client_name: str
506
+ credential_expires_at: float
507
+ capabilities: CapabilitiesOut
508
+ resource_policy: ResourcePolicyOut
509
+ generation: GenerationStateOut