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/audio/wav.py ADDED
@@ -0,0 +1,854 @@
1
+ """F-82's audio format: mono 16-bit PCM WAV at the model's native rate.
2
+
3
+ Every function here answers one sentence of F-82 -- "the full WAV is a
4
+ bit-exact concatenation of its segment audio together with any inter-segment
5
+ silence attributed by F-08" -- and F-16's partial export, which exists only
6
+ because that concatenation is already defined.
7
+
8
+ Two implementation choices are deliberate:
9
+
10
+ * The standard library's ``wave`` module plus numpy, not ``soundfile``. The
11
+ byte layout of a result is a requirement (F-82) and its digest is stored
12
+ and compared (4.2), so the bytes have to be ours rather than libsndfile's.
13
+ ``soundfile`` remains the right tool for reading a file a *user* supplies;
14
+ nothing about a file we wrote ourselves should need it.
15
+ * Concatenation copies raw frames block by block and never materialises a
16
+ job's audio. N-21 forbids unbounded in-memory loading, and it is reachable
17
+ here rather than theoretical: F-03's 50,000 code points is roughly two
18
+ hours of speech, about 700 MB of PCM.
19
+
20
+ Sample rate is a parameter everywhere and a constant nowhere. F-82 says the
21
+ rate is the model's and is never resampled, so a second model with a
22
+ different rate must not require a change in this file.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import errno
28
+ import hashlib
29
+ import os
30
+ import struct
31
+ import wave
32
+ from collections.abc import Iterator, Sequence
33
+ from contextlib import contextmanager
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Final
37
+
38
+ import numpy as np
39
+
40
+ from ..domain import Segment, TimeRange
41
+ from ..errors import Code, EchoActError
42
+ from ..paths import redact
43
+
44
+ #: F-82's format, fixed for every segment and every result.
45
+ CHANNELS: Final = 1
46
+ SAMPLE_WIDTH_BYTES: Final = 2
47
+ SAMPLE_WIDTH_BITS: Final = 16
48
+ FORMAT_NAME: Final = "wav_pcm_s16le_mono"
49
+
50
+ INT16_MIN: Final = -32768
51
+ INT16_MAX: Final = 32767
52
+ #: Full scale for float -> int16. 32767 rather than 32768 keeps +1.0 and
53
+ #: -1.0 symmetric, so a normalised signal never clips merely by reaching full
54
+ #: scale; genuine overshoot still clips and ``WriteReport.clipped`` says so.
55
+ FLOAT_FULL_SCALE: Final = 32767.0
56
+ #: Full scale for reporting a *peak*, which is a magnitude: int16 can hold
57
+ #: -32768, so dividing by 32768 keeps an untouched int16 signal at or below
58
+ #: 1.0 and reserves "above 1.0" to mean the source really did overshoot.
59
+ PEAK_FULL_SCALE: Final = 32768.0
60
+
61
+ # A RIFF header is 32-bit: the data chunk's size and the RIFF chunk's size
62
+ # (the file size minus 8, i.e. 36 + payload for the canonical header) must
63
+ # both fit in an unsigned 32-bit field. Beyond that the *format* cannot
64
+ # describe the file -- no amount of disk space helps -- so the payload is
65
+ # capped here and checked before anything is written. Section 8's inputs
66
+ # come nowhere near it, which ``test_wav.py`` asserts rather than assumes.
67
+ RIFF_MAX_CHUNK_BYTES: Final = 0xFFFF_FFFF
68
+ CANONICAL_HEADER_BYTES: Final = 44
69
+ MAX_PCM_BYTES: Final = RIFF_MAX_CHUNK_BYTES - (CANONICAL_HEADER_BYTES - 8)
70
+ MAX_FRAMES: Final = MAX_PCM_BYTES // (CHANNELS * SAMPLE_WIDTH_BYTES)
71
+
72
+ #: Frames copied per read/write while streaming. 1 MiB of PCM.
73
+ BLOCK_FRAMES: Final = 512 * 1024
74
+ #: Bytes hashed per read in :func:`digest`.
75
+ DIGEST_BLOCK_BYTES: Final = 1 << 20
76
+
77
+ _PART_SUFFIX: Final = ".part"
78
+
79
+
80
+ # ======================================================================
81
+ # Reports
82
+ # ======================================================================
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class WriteReport:
87
+ """What :func:`write_segment` put on disk.
88
+
89
+ ``frame_count`` is what a segment's time range is built from -- F-82
90
+ forbids resampling, so frames over the model's rate is the exact duration
91
+ and nothing has to trust the engine's own idea of seconds.
92
+ """
93
+
94
+ path: str
95
+ sample_rate: int
96
+ frame_count: int
97
+ byte_size: int
98
+ #: Largest absolute amplitude, 1.0 being full scale. Above 1.0 means the
99
+ #: source overshot and was clipped.
100
+ peak: float
101
+ #: Samples that had to be clipped. Silently flattening a signal is the
102
+ #: kind of defect Section 8.3 calls release-blocking, so it is counted.
103
+ clipped: int
104
+
105
+ @property
106
+ def duration_ms(self) -> int:
107
+ return ms_for_frames(self.frame_count, self.sample_rate)
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class WavInfo:
112
+ """Everything 4.2's Result entity records about a file except its digest.
113
+
114
+ ``format`` describes what was actually found, not what was expected: a
115
+ probe of a foreign file reports it honestly and lets the caller decide,
116
+ while :func:`read_wav` and :func:`concatenate` refuse it.
117
+ """
118
+
119
+ path: str
120
+ format: str
121
+ sample_rate: int
122
+ channels: int
123
+ sample_width_bits: int
124
+ frame_count: int
125
+ byte_size: int
126
+ duration_ms: int
127
+
128
+ @property
129
+ def is_output_format(self) -> bool:
130
+ """True when this is the format F-82 fixes for segments and results."""
131
+ return (
132
+ self.format == FORMAT_NAME
133
+ and self.channels == CHANNELS
134
+ and self.sample_width_bits == SAMPLE_WIDTH_BITS
135
+ )
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class ConcatReport:
140
+ """The result of :func:`concatenate`, including the time table.
141
+
142
+ ``spans`` is per input segment and each one *includes* the silence
143
+ written after it: F-27 attributes inter-segment silence to the preceding
144
+ segment, and 4.2's times are milliseconds from the start of the audio, so
145
+ the spans tile the file with no holes.
146
+ """
147
+
148
+ path: str
149
+ sample_rate: int
150
+ frame_count: int
151
+ byte_size: int
152
+ segment_frames: tuple[int, ...]
153
+ gap_frames: tuple[int, ...]
154
+ spans: tuple[TimeRange, ...]
155
+
156
+ @property
157
+ def duration_ms(self) -> int:
158
+ return ms_for_frames(self.frame_count, self.sample_rate)
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class PartialExport:
163
+ """F-16's numbers. Naming and labelling are the caller's business.
164
+
165
+ F-16 requires the user to be told how much of the source text the file
166
+ covers, and requires a file exported mid-job never to be mistaken for the
167
+ whole document. Both need a figure rather than a flag, so this carries
168
+ the counts and lets the GUI, REST, and MCP phrase them.
169
+ """
170
+
171
+ path: str
172
+ sample_rate: int
173
+ frame_count: int
174
+ byte_size: int
175
+ spans: tuple[TimeRange, ...]
176
+ segment_count: int
177
+ total_segments: int
178
+ covered_codepoints: int
179
+ total_codepoints: int
180
+ #: True only when every segment was exported and the file reaches the end
181
+ #: of the source text. Anything else is presented as partial.
182
+ complete: bool
183
+
184
+ @property
185
+ def duration_ms(self) -> int:
186
+ return ms_for_frames(self.frame_count, self.sample_rate)
187
+
188
+ @property
189
+ def coverage(self) -> float:
190
+ """Fraction of the source text's code points the file covers, 0 to 1."""
191
+ if self.total_codepoints <= 0:
192
+ return 1.0 if self.complete else 0.0
193
+ return min(1.0, self.covered_codepoints / self.total_codepoints)
194
+
195
+
196
+ # ======================================================================
197
+ # Frame arithmetic
198
+ # ======================================================================
199
+
200
+
201
+ def frames_for_ms(ms: int, sample_rate: int) -> int:
202
+ """Exact silence, rounded to the nearest frame.
203
+
204
+ F-08's pauses are given in milliseconds and a file is counted in frames;
205
+ at 44,100 Hz a 250 ms pause is exactly 11,025 frames, and the rounding
206
+ here only matters for a rate where it is not exact.
207
+ """
208
+ if ms < 0:
209
+ raise EchoActError(Code.INTERNAL, "A pause cannot be negative.", detail={"ms": ms})
210
+ return (ms * sample_rate + 500) // 1000
211
+
212
+
213
+ def ms_for_frames(frames: int, sample_rate: int) -> int:
214
+ """Milliseconds from the start of the audio, matching ``Result.duration_ms``."""
215
+ if sample_rate <= 0:
216
+ raise EchoActError(Code.INTERNAL, "A sample rate must be positive.")
217
+ return int(round(frames * 1000 / sample_rate))
218
+
219
+
220
+ def max_duration_ms(sample_rate: int) -> int:
221
+ """Longest audio a 32-bit RIFF header can describe at this rate."""
222
+ return ms_for_frames(MAX_FRAMES, sample_rate)
223
+
224
+
225
+ # ======================================================================
226
+ # Writing
227
+ # ======================================================================
228
+
229
+
230
+ def write_segment(
231
+ path: str | os.PathLike[str],
232
+ samples: np.ndarray,
233
+ sample_rate: int,
234
+ ) -> WriteReport:
235
+ """Write one segment's audio in F-82's format.
236
+
237
+ Accepts the engine's float output or int16 directly. Float is scaled and
238
+ rounded to nearest, then clipped -- ``astype(np.int16)`` alone truncates
239
+ toward zero *and* wraps on overflow, which turns a loud sample into an
240
+ equally loud sample of the opposite sign, so neither step is optional.
241
+
242
+ The file appears at ``path`` only once it is complete: it is written
243
+ beside the target and renamed. A worker killed mid-write therefore
244
+ leaves no file at all, which is stronger than the protocol's rule that
245
+ the parent discards a file it has not been told about.
246
+ """
247
+ pcm, frame_count, peak, clipped = _to_pcm(samples)
248
+ target = Path(path)
249
+ _check_capacity(len(pcm), target)
250
+ with _open_writer(target, sample_rate, expect_pcm_bytes=len(pcm)) as writer:
251
+ writer.writeframesraw(pcm)
252
+ return WriteReport(
253
+ path=str(target),
254
+ sample_rate=sample_rate,
255
+ frame_count=frame_count,
256
+ byte_size=_size_of(target),
257
+ peak=peak,
258
+ clipped=clipped,
259
+ )
260
+
261
+
262
+ def _to_pcm(samples: np.ndarray) -> tuple[bytes, int, float, int]:
263
+ arr = np.asarray(samples)
264
+ if arr.ndim == 2 and 1 in arr.shape:
265
+ arr = arr.reshape(-1)
266
+ if arr.ndim != 1:
267
+ raise EchoActError(
268
+ Code.INTERNAL,
269
+ "Segment audio must be mono.",
270
+ detail={"shape": list(arr.shape)},
271
+ )
272
+
273
+ if arr.dtype == np.int16:
274
+ peak = float(np.abs(arr.astype(np.int32)).max()) / PEAK_FULL_SCALE if arr.size else 0.0
275
+ pcm = np.ascontiguousarray(arr, dtype="<i2")
276
+ clipped = 0
277
+ elif np.issubdtype(arr.dtype, np.floating):
278
+ floats = arr if arr.dtype == np.float64 else arr.astype(np.float32, copy=False)
279
+ if arr.size and not bool(np.isfinite(floats).all()):
280
+ raise EchoActError(Code.INTERNAL, "Segment audio contains NaN or infinity.")
281
+ peak = float(np.abs(floats).max()) if arr.size else 0.0
282
+ scaled = np.rint(floats * FLOAT_FULL_SCALE)
283
+ clipped = int(np.count_nonzero((scaled < INT16_MIN) | (scaled > INT16_MAX)))
284
+ pcm = np.clip(scaled, INT16_MIN, INT16_MAX).astype("<i2")
285
+ else:
286
+ raise EchoActError(
287
+ Code.INTERNAL,
288
+ "Segment audio must be float or int16.",
289
+ detail={"dtype": str(arr.dtype)},
290
+ )
291
+ return pcm.tobytes(), int(pcm.size), peak, clipped
292
+
293
+
294
+ # ======================================================================
295
+ # Reading
296
+ # ======================================================================
297
+
298
+
299
+ def probe(path: str | os.PathLike[str]) -> WavInfo:
300
+ """Read only the header. Cheap enough to call on every segment.
301
+
302
+ This is how a Result's format, length, and size in 4.2 are obtained
303
+ without reading the audio, which N-21 requires for a file that can be
304
+ hundreds of megabytes.
305
+ """
306
+ target = Path(path)
307
+ size = _size_of(target)
308
+ with _open_reader(target) as reader:
309
+ channels = reader.getnchannels()
310
+ width = reader.getsampwidth()
311
+ rate = reader.getframerate()
312
+ frames = reader.getnframes()
313
+ comptype = reader.getcomptype()
314
+ if rate <= 0:
315
+ raise EchoActError(
316
+ Code.FILE_CORRUPT,
317
+ "The WAV header declares no sample rate.",
318
+ detail={"path": redact(target)},
319
+ )
320
+ return WavInfo(
321
+ path=str(target),
322
+ format=_format_name(comptype, width, channels),
323
+ sample_rate=rate,
324
+ channels=channels,
325
+ sample_width_bits=width * 8,
326
+ frame_count=frames,
327
+ byte_size=size,
328
+ duration_ms=ms_for_frames(frames, rate),
329
+ )
330
+
331
+
332
+ def read_wav(path: str | os.PathLike[str]) -> tuple[np.ndarray, int]:
333
+ """Read a whole file we wrote, as int16 samples and its rate.
334
+
335
+ Refuses anything that is not F-82's format rather than silently coping,
336
+ because coping would mean resampling or downmixing and F-82 forbids both.
337
+
338
+ This loads the file. That is right for a segment, which F-81 caps at
339
+ twenty seconds, and wrong for a job's full WAV; use :func:`iter_blocks`
340
+ or :func:`probe` for those, per N-21.
341
+ """
342
+ target = Path(path)
343
+ info = probe(target)
344
+ require_output_format(info)
345
+ with _open_reader(target) as reader:
346
+ raw = _read_all(reader, info.frame_count, target)
347
+ return np.frombuffer(raw, dtype="<i2").astype(np.int16, copy=True), info.sample_rate
348
+
349
+
350
+ def iter_blocks(
351
+ path: str | os.PathLike[str],
352
+ *,
353
+ block_frames: int = BLOCK_FRAMES,
354
+ ) -> Iterator[np.ndarray]:
355
+ """Stream a file in int16 blocks, for anything that must not hold it all.
356
+
357
+ N-21's "no unbounded in-memory loading" covers playback and result
358
+ retrieval as much as export, so the bounded read path is public.
359
+
360
+ The file is checked before the iterator is returned, not on the first
361
+ ``next``: a player that builds its source ahead of time should learn
362
+ about a missing or foreign file then, not mid-stream.
363
+
364
+ A file that runs out of audio early is reported once the stream ends
365
+ rather than passed off as a whole one, exactly as :func:`read_wav`
366
+ reports it. This is the path a retained result is streamed and played
367
+ through, so a truncated file would otherwise play short with nothing
368
+ said, which is what F-55 forbids. A caller that deliberately stops
369
+ early never reaches the check, which is right: it asked for a prefix.
370
+ """
371
+ if block_frames <= 0:
372
+ raise EchoActError(Code.INTERNAL, "A block must contain at least one frame.")
373
+ target = Path(path)
374
+ info = probe(target)
375
+ require_output_format(info)
376
+ return _iter_blocks(target, block_frames, info.frame_count)
377
+
378
+
379
+ def _iter_blocks(target: Path, block_frames: int, expected_frames: int) -> Iterator[np.ndarray]:
380
+ seen = 0
381
+ with _open_reader(target) as reader:
382
+ while True:
383
+ raw = _read_block(reader, block_frames, target)
384
+ if not raw:
385
+ break
386
+ seen += len(raw) // SAMPLE_WIDTH_BYTES
387
+ yield np.frombuffer(raw, dtype="<i2").astype(np.int16, copy=True)
388
+ _require_frame_count(seen, expected_frames, target)
389
+
390
+
391
+ def require_output_format(info: WavInfo) -> None:
392
+ """Raise unless ``info`` describes the format F-82 fixes."""
393
+ if info.is_output_format:
394
+ return
395
+ raise EchoActError(
396
+ Code.FILE_CORRUPT,
397
+ "That file is not the mono 16-bit PCM WAV this app writes.",
398
+ detail={
399
+ "path": redact(info.path),
400
+ "found": info.format,
401
+ "expected": FORMAT_NAME,
402
+ },
403
+ )
404
+
405
+
406
+ def digest(path: str | os.PathLike[str]) -> str:
407
+ """SHA-256 of the whole file, streamed.
408
+
409
+ 4.2 stores this as a Result's integrity verification data. It covers the
410
+ header as well as the audio, so a result whose header was rewritten is a
411
+ different result -- which is the point of recording it.
412
+ """
413
+ target = Path(path)
414
+ hasher = hashlib.sha256()
415
+ try:
416
+ with open(target, "rb") as handle:
417
+ while True:
418
+ block = handle.read(DIGEST_BLOCK_BYTES)
419
+ if not block:
420
+ break
421
+ hasher.update(block)
422
+ except FileNotFoundError as exc:
423
+ raise EchoActError(Code.FILE_NOT_FOUND, detail={"path": redact(target)}) from exc
424
+ except OSError as exc:
425
+ raise _os_error(exc, target) from exc
426
+ return hasher.hexdigest()
427
+
428
+
429
+ # ======================================================================
430
+ # Concatenation (F-82) and partial export (F-16)
431
+ # ======================================================================
432
+
433
+
434
+ def concatenate(
435
+ segment_paths: Sequence[str | os.PathLike[str] | None],
436
+ gaps_ms: Sequence[int],
437
+ out_path: str | os.PathLike[str],
438
+ sample_rate: int,
439
+ ) -> ConcatReport:
440
+ """Join segment audio into one file, bit-exactly.
441
+
442
+ ``gaps_ms[i]`` is written *after* segment ``i``, the last one included:
443
+ F-27 attributes inter-segment silence to the segment that precedes it, so
444
+ a segment's span ends where the next segment's audio begins. Whether the
445
+ final segment has a trailing pause is the caller's policy, expressed by
446
+ passing zero; deciding it here would put F-08's presets in the wrong
447
+ module and would break the property F-16 relies on, that exporting again
448
+ later yields a file with the same prefix.
449
+
450
+ A ``None`` path is a segment that produced no audio -- F-27's attachment
451
+ rule should make these rare -- and contributes only its silence, so the
452
+ spans still line up one-to-one with the segments.
453
+
454
+ Every input is probed before the output is opened, so a mixed rate or a
455
+ damaged segment fails without leaving a file behind. Frames are copied
456
+ in blocks and never accumulated (N-21).
457
+
458
+ The frames copied are counted against what each probe promised, and the
459
+ finished size is checked while the file is still the ``.part``: a short
460
+ join caught after the rename has already put a complete-looking,
461
+ playable, short result where the caller asked for the real one, which is
462
+ F-55's "incomplete file returned as finished" in its worst form, since
463
+ nothing about the file itself says it is short.
464
+ """
465
+ paths = [None if p is None else Path(p) for p in segment_paths]
466
+ gaps = [int(g) for g in gaps_ms]
467
+ if len(gaps) != len(paths):
468
+ raise EchoActError(
469
+ Code.INTERNAL,
470
+ "Each segment needs exactly one trailing pause.",
471
+ detail={"segments": len(paths), "gaps": len(gaps)},
472
+ )
473
+ if not paths:
474
+ raise EchoActError(
475
+ Code.SEGMENT_NOT_READY,
476
+ "There is no generated audio to write yet.",
477
+ )
478
+ if sample_rate <= 0:
479
+ raise EchoActError(Code.INTERNAL, "A sample rate must be positive.")
480
+
481
+ segment_frames: list[int] = []
482
+ for source in paths:
483
+ if source is None:
484
+ segment_frames.append(0)
485
+ continue
486
+ info = probe(source)
487
+ require_output_format(info)
488
+ if info.sample_rate != sample_rate:
489
+ # F-82: one job, one format. Resampling is the only alternative
490
+ # and the requirement rules it out, so this is a hard stop.
491
+ raise EchoActError(
492
+ Code.INTERNAL,
493
+ "Segment audio does not share the job's sample rate.",
494
+ detail={
495
+ "path": redact(source),
496
+ "found": info.sample_rate,
497
+ "expected": sample_rate,
498
+ },
499
+ )
500
+ segment_frames.append(info.frame_count)
501
+
502
+ gap_frames = [frames_for_ms(g, sample_rate) for g in gaps]
503
+ total_frames = sum(segment_frames) + sum(gap_frames)
504
+ target = Path(out_path)
505
+ _check_capacity(total_frames * SAMPLE_WIDTH_BYTES, target)
506
+
507
+ with _open_writer(
508
+ target, sample_rate, expect_pcm_bytes=total_frames * SAMPLE_WIDTH_BYTES
509
+ ) as writer:
510
+ for source, frames, silence in zip(paths, segment_frames, gap_frames, strict=True):
511
+ if source is not None:
512
+ _copy_frames(source, writer, frames)
513
+ _write_silence(writer, silence)
514
+
515
+ byte_size = _size_of(target)
516
+ return ConcatReport(
517
+ path=str(target),
518
+ sample_rate=sample_rate,
519
+ frame_count=total_frames,
520
+ byte_size=byte_size,
521
+ segment_frames=tuple(segment_frames),
522
+ gap_frames=tuple(gap_frames),
523
+ spans=_spans(segment_frames, gap_frames, sample_rate),
524
+ )
525
+
526
+
527
+ def export_partial(
528
+ segments: Sequence[Segment],
529
+ out_path: str | os.PathLike[str],
530
+ *,
531
+ sample_rate: int,
532
+ total_codepoints: int,
533
+ ) -> PartialExport:
534
+ """F-16: export what has been generated so far, and say how much that is.
535
+
536
+ Only the *leading* run of ready segments is written. Taking every ready
537
+ segment and skipping the holes would be wrong twice over: the file would
538
+ silently omit text from its middle, and every time range after the hole
539
+ would disagree with the job's, so the exported file could not be followed
540
+ against the highlighting the user already sees.
541
+
542
+ Each exported segment's trailing silence is included, the last one's too.
543
+ That is what makes a later export a longer file with the same prefix
544
+ rather than a differently aligned one -- F-16 asks for exactly that, and
545
+ forbids treating the second export as a continuation.
546
+
547
+ The digest 4.2 wants is not computed here; hashing hundreds of megabytes
548
+ is not free and an export to a user's folder never needs one. Call
549
+ :func:`digest` when a Result is being recorded.
550
+ """
551
+ ready = _leading_ready(segments)
552
+ if not ready:
553
+ raise EchoActError(
554
+ Code.SEGMENT_NOT_READY,
555
+ "No segment has been generated yet, so there is nothing to save.",
556
+ detail={"total_segments": len(segments)},
557
+ )
558
+ paths: list[str | None] = []
559
+ for segment in ready:
560
+ if segment.audio_path is None and segment.is_spoken:
561
+ raise EchoActError(
562
+ Code.SEGMENT_NOT_READY,
563
+ "A segment is marked ready but has no audio.",
564
+ detail={"segment_index": segment.index},
565
+ )
566
+ paths.append(segment.audio_path)
567
+ gaps = [segment.trailing_silence_ms for segment in ready]
568
+
569
+ report = concatenate(paths, gaps, out_path, sample_rate)
570
+
571
+ covered = min(max(ready[-1].source.end, 0), max(total_codepoints, 0))
572
+ complete = len(ready) == len(segments) and covered >= total_codepoints
573
+ return PartialExport(
574
+ path=report.path,
575
+ sample_rate=report.sample_rate,
576
+ frame_count=report.frame_count,
577
+ byte_size=report.byte_size,
578
+ spans=report.spans,
579
+ segment_count=len(ready),
580
+ total_segments=len(segments),
581
+ covered_codepoints=covered,
582
+ total_codepoints=total_codepoints,
583
+ complete=complete,
584
+ )
585
+
586
+
587
+ def _leading_ready(segments: Sequence[Segment]) -> list[Segment]:
588
+ run: list[Segment] = []
589
+ for segment in segments:
590
+ if not segment.ready:
591
+ break
592
+ run.append(segment)
593
+ return run
594
+
595
+
596
+ def _spans(
597
+ segment_frames: Sequence[int],
598
+ gap_frames: Sequence[int],
599
+ sample_rate: int,
600
+ ) -> tuple[TimeRange, ...]:
601
+ spans: list[TimeRange] = []
602
+ cursor = 0
603
+ for audio, silence in zip(segment_frames, gap_frames, strict=True):
604
+ start = cursor
605
+ cursor += audio + silence
606
+ spans.append(
607
+ TimeRange(
608
+ start_ms=ms_for_frames(start, sample_rate),
609
+ end_ms=ms_for_frames(cursor, sample_rate),
610
+ )
611
+ )
612
+ return tuple(spans)
613
+
614
+
615
+ # ======================================================================
616
+ # File plumbing. No OSError and no wave.Error leaves this module (rule 3).
617
+ # ======================================================================
618
+
619
+
620
+ @contextmanager
621
+ def _open_writer(
622
+ path: Path,
623
+ sample_rate: int,
624
+ *,
625
+ expect_pcm_bytes: int | None = None,
626
+ ) -> Iterator[wave.Wave_write]:
627
+ """Write beside ``path`` and publish by rename, never a partial file.
628
+
629
+ ``expect_pcm_bytes`` is checked on the temporary after ``close()`` has
630
+ patched the RIFF sizes and before the rename, so a body that wrote less
631
+ than it promised destroys its work instead of publishing it.
632
+ """
633
+ if sample_rate <= 0:
634
+ raise EchoActError(Code.INTERNAL, "A sample rate must be positive.")
635
+ temp = path.with_name(path.name + _PART_SUFFIX)
636
+ try:
637
+ path.parent.mkdir(parents=True, exist_ok=True)
638
+ writer = wave.open(str(temp), "wb")
639
+ except OSError as exc:
640
+ raise _os_error(exc, path) from exc
641
+ try:
642
+ try:
643
+ writer.setnchannels(CHANNELS)
644
+ writer.setsampwidth(SAMPLE_WIDTH_BYTES)
645
+ writer.setframerate(sample_rate)
646
+ yield writer
647
+ finally:
648
+ # close() is where ``wave`` patches the RIFF and data sizes, so a
649
+ # payload too large for a 32-bit field fails here, not earlier.
650
+ writer.close()
651
+ except OSError as exc:
652
+ _discard(temp)
653
+ raise _os_error(exc, path) from exc
654
+ except struct.error as exc:
655
+ # The only field that can overflow here is a 32-bit RIFF size, and
656
+ # ``_check_capacity`` should have caught it before a byte was written.
657
+ _discard(temp)
658
+ raise EchoActError(
659
+ Code.INTERNAL,
660
+ "The audio is longer than a WAV file can describe.",
661
+ detail={"path": redact(path), "max_pcm_bytes": MAX_PCM_BYTES},
662
+ ) from exc
663
+ except wave.Error as exc:
664
+ _discard(temp)
665
+ raise EchoActError(
666
+ Code.INTERNAL,
667
+ "The audio file could not be written.",
668
+ detail={"path": redact(path)},
669
+ ) from exc
670
+ except BaseException:
671
+ _discard(temp)
672
+ raise
673
+ try:
674
+ _require_pcm_bytes(temp, expect_pcm_bytes, path)
675
+ os.replace(temp, path)
676
+ except OSError as exc:
677
+ _discard(temp)
678
+ raise _os_error(exc, path) from exc
679
+ except BaseException:
680
+ _discard(temp)
681
+ raise
682
+
683
+
684
+ @contextmanager
685
+ def _open_reader(path: Path) -> Iterator[wave.Wave_read]:
686
+ try:
687
+ reader = wave.open(str(path), "rb")
688
+ except FileNotFoundError as exc:
689
+ raise EchoActError(Code.FILE_NOT_FOUND, detail={"path": redact(path)}) from exc
690
+ except OSError as exc:
691
+ raise _os_error(exc, path) from exc
692
+ except (wave.Error, EOFError) as exc:
693
+ raise EchoActError(
694
+ Code.FILE_CORRUPT,
695
+ "That file is not a readable WAV.",
696
+ detail={"path": redact(path)},
697
+ ) from exc
698
+ try:
699
+ yield reader
700
+ finally:
701
+ reader.close()
702
+
703
+
704
+ def _copy_frames(source: Path, writer: wave.Wave_write, expected_frames: int) -> None:
705
+ """Copy one segment, refusing to join a file that stops short.
706
+
707
+ ``readframes`` returns what is there and then returns nothing; it cannot
708
+ tell a truncated data chunk from the end of a whole one. Without the
709
+ count, a short segment would be joined happily and only the finished
710
+ size would betray it -- and a size checked after the rename is checked
711
+ on the caller's file, not on ours.
712
+ """
713
+ copied = 0
714
+ with _open_reader(source) as reader:
715
+ while True:
716
+ raw = _read_block(reader, BLOCK_FRAMES, source)
717
+ if not raw:
718
+ break
719
+ writer.writeframesraw(raw)
720
+ copied += len(raw) // SAMPLE_WIDTH_BYTES
721
+ _require_frame_count(copied, expected_frames, source)
722
+
723
+
724
+ def _write_silence(writer: wave.Wave_write, frames: int) -> None:
725
+ remaining = frames
726
+ while remaining > 0:
727
+ block = min(remaining, BLOCK_FRAMES)
728
+ writer.writeframesraw(bytes(block * SAMPLE_WIDTH_BYTES))
729
+ remaining -= block
730
+
731
+
732
+ def _read_block(reader: wave.Wave_read, frames: int, path: Path) -> bytes:
733
+ try:
734
+ return reader.readframes(frames)
735
+ except (wave.Error, EOFError) as exc:
736
+ raise EchoActError(
737
+ Code.FILE_CORRUPT,
738
+ "That WAV ends before its header says it should.",
739
+ detail={"path": redact(path)},
740
+ ) from exc
741
+ except OSError as exc:
742
+ raise _os_error(exc, path) from exc
743
+
744
+
745
+ def _read_all(reader: wave.Wave_read, frames: int, path: Path) -> bytes:
746
+ raw = _read_block(reader, frames, path)
747
+ _require_frame_count(len(raw) // SAMPLE_WIDTH_BYTES, frames, path)
748
+ return raw
749
+
750
+
751
+ def _require_frame_count(found: int, expected: int, path: Path) -> None:
752
+ """Hold a read to the frame count :func:`probe` reported for the file."""
753
+ if found == expected:
754
+ return
755
+ raise EchoActError(
756
+ Code.FILE_CORRUPT,
757
+ "That WAV holds less audio than its header declares.",
758
+ detail={"path": redact(path), "expected_frames": expected, "found_frames": found},
759
+ )
760
+
761
+
762
+ def _require_pcm_bytes(temp: Path, expected: int | None, path: Path) -> None:
763
+ """Hold a written file to the payload its caller said it would contain."""
764
+ if expected is None:
765
+ return
766
+ written = _size_of(temp) - CANONICAL_HEADER_BYTES
767
+ if written != expected:
768
+ raise EchoActError(
769
+ Code.INTERNAL,
770
+ "The file written is not the size its audio adds up to.",
771
+ detail={"path": redact(path), "expected": expected, "found": written},
772
+ )
773
+
774
+
775
+ def _check_capacity(pcm_bytes: int, path: Path) -> None:
776
+ if pcm_bytes > MAX_PCM_BYTES:
777
+ raise EchoActError(
778
+ Code.INTERNAL,
779
+ "The audio is longer than a WAV file can describe.",
780
+ detail={
781
+ "path": redact(path),
782
+ "pcm_bytes": pcm_bytes,
783
+ "max_pcm_bytes": MAX_PCM_BYTES,
784
+ },
785
+ )
786
+
787
+
788
+ def _size_of(path: Path) -> int:
789
+ try:
790
+ return path.stat().st_size
791
+ except FileNotFoundError as exc:
792
+ raise EchoActError(Code.FILE_NOT_FOUND, detail={"path": redact(path)}) from exc
793
+ except OSError as exc:
794
+ raise _os_error(exc, path) from exc
795
+
796
+
797
+ def _format_name(comptype: str, width_bytes: int, channels: int) -> str:
798
+ if comptype != "NONE":
799
+ return f"wav_{comptype.lower()}"
800
+ layout = {1: "mono", 2: "stereo"}.get(channels, f"{channels}ch")
801
+ return f"wav_pcm_s{width_bytes * 8}le_{layout}"
802
+
803
+
804
+ def _discard(path: Path) -> None:
805
+ try:
806
+ path.unlink(missing_ok=True)
807
+ except OSError:
808
+ # Discarding the half-written temporary is already the failure path;
809
+ # N-02's relaunch cleanup covers whatever survives in the temp tree.
810
+ pass
811
+
812
+
813
+ def _os_error(exc: OSError, path: Path) -> EchoActError:
814
+ """Map the filesystem's complaint onto a code the surfaces already know."""
815
+ detail = {"path": redact(path)}
816
+ if exc.errno in (errno.ENOSPC, errno.EDQUOT):
817
+ return EchoActError(Code.STORAGE_FULL, detail=detail, cause=exc)
818
+ if exc.errno in (errno.EACCES, errno.EPERM, errno.EROFS):
819
+ return EchoActError(Code.FILE_PERMISSION, detail=detail, cause=exc)
820
+ if exc.errno == errno.ENOENT:
821
+ return EchoActError(Code.FILE_NOT_FOUND, detail=detail, cause=exc)
822
+ return EchoActError(
823
+ Code.INTERNAL,
824
+ "The audio file could not be written or read.",
825
+ detail=detail,
826
+ cause=exc,
827
+ )
828
+
829
+
830
+ __all__ = [
831
+ "BLOCK_FRAMES",
832
+ "CANONICAL_HEADER_BYTES",
833
+ "CHANNELS",
834
+ "FORMAT_NAME",
835
+ "MAX_FRAMES",
836
+ "MAX_PCM_BYTES",
837
+ "SAMPLE_WIDTH_BITS",
838
+ "SAMPLE_WIDTH_BYTES",
839
+ "ConcatReport",
840
+ "PartialExport",
841
+ "WavInfo",
842
+ "WriteReport",
843
+ "concatenate",
844
+ "digest",
845
+ "export_partial",
846
+ "frames_for_ms",
847
+ "iter_blocks",
848
+ "max_duration_ms",
849
+ "ms_for_frames",
850
+ "probe",
851
+ "read_wav",
852
+ "require_output_format",
853
+ "write_segment",
854
+ ]