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