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
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
|