specux 0.1.0.dev1__cp313-cp313-win_amd64.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 (49) hide show
  1. specux/__init__.py +94 -0
  2. specux/_audio.cp313-win_amd64.pyd +0 -0
  3. specux/_cpu.cp313-win_amd64.pyd +0 -0
  4. specux/_cuda.cp313-win_amd64.pyd +0 -0
  5. specux/audio/__init__.py +73 -0
  6. specux/audio/_io.py +400 -0
  7. specux/audio/_meta.py +124 -0
  8. specux/audio/_signal.py +198 -0
  9. specux/audio/_stream.py +204 -0
  10. specux/audio/_types.py +67 -0
  11. specux/core/__init__.py +1 -0
  12. specux/core/api.py +799 -0
  13. specux/core/backend.py +697 -0
  14. specux/core/cache.py +75 -0
  15. specux/core/config.py +83 -0
  16. specux/core/dispatch.py +171 -0
  17. specux/core/dtypes.py +8 -0
  18. specux/core/fallback.py +115 -0
  19. specux/core/fft.py +569 -0
  20. specux/core/library.py +1216 -0
  21. specux/core/plan.py +199 -0
  22. specux/core/typing.py +17 -0
  23. specux/core/version.py +6 -0
  24. specux/dsp/__init__.py +1 -0
  25. specux/dsp/cqt.py +365 -0
  26. specux/dsp/cqt_filters.py +184 -0
  27. specux/dsp/features.py +527 -0
  28. specux/dsp/filters.py +122 -0
  29. specux/dsp/transform.py +533 -0
  30. specux/interop/__init__.py +1 -0
  31. specux/interop/cuda.py +383 -0
  32. specux/interop/cupy.py +201 -0
  33. specux/interop/metal.py +96 -0
  34. specux/interop/metal_transport.py +818 -0
  35. specux/interop/torch.py +201 -0
  36. specux/py.typed +0 -0
  37. specux/transforms.py +250 -0
  38. specux-0.1.0.dev1.dist-info/DELVEWHEEL +2 -0
  39. specux-0.1.0.dev1.dist-info/METADATA +220 -0
  40. specux-0.1.0.dev1.dist-info/RECORD +49 -0
  41. specux-0.1.0.dev1.dist-info/WHEEL +5 -0
  42. specux-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  43. specux-0.1.0.dev1.dist-info/licenses/NOTICE +111 -0
  44. specux-0.1.0.dev1.dist-info/top_level.txt +1 -0
  45. specux.libs/avcodec-62-7751d471583ebec40409b295441b5ea0.dll +0 -0
  46. specux.libs/avformat-62-4fa6a77b648319730491e08e9bf61f01.dll +0 -0
  47. specux.libs/avutil-60-ad169984cd8743121d920891cf58690c.dll +0 -0
  48. specux.libs/msvcp140-8f141b4454fa78db34bc1f28c571b4da.dll +0 -0
  49. specux.libs/swresample-6-92e7955b606c012fad22fbb6d5013a17.dll +0 -0
specux/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """Differentiable audio DSP for Python: fast, fused kernels on GPU and CPU.
2
+
3
+ Array-agnostic surface: numpy-in/numpy-out, torch-in/torch-out (resident on the
4
+ GPU), cupy-in/cupy-out. The CUDA and Metal backends generate kernel source in
5
+ C++ (src/codegen.h) and compile it at runtime (NVRTC / MSL); the CPU backend
6
+ runs on the AVX2 engine (src/cpu/fft). torch inputs that require grad flow
7
+ through autograd (see interop/torch.py, interop/metal.py).
8
+
9
+ `import specux` pulls only numpy; torch and the CUDA extension load lazily on
10
+ first use of a torch array or the CUDA backend.
11
+ """
12
+ from __future__ import annotations
13
+
14
+
15
+ # start delvewheel patch
16
+ def _delvewheel_patch_1_13_0():
17
+ import os
18
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'specux.libs'))):
19
+ os.add_dll_directory(libs_dir)
20
+
21
+
22
+ _delvewheel_patch_1_13_0()
23
+ del _delvewheel_patch_1_13_0
24
+ # end delvewheel patch
25
+
26
+ from typing import TYPE_CHECKING
27
+
28
+ from .core import config as _config
29
+ from .dsp import filters as _filters
30
+ from .core.version import __version__ as __version__
31
+
32
+ if TYPE_CHECKING:
33
+ from .core.plan import STFTPlan
34
+ from .dsp.transform import CQT, ISTFT, STFT, VQT, Chroma, MelSpectrogram, Transform
35
+
36
+ __all__ = ["stft", "istft", "melspectrogram", "cqt", "vqt", "chroma",
37
+ "fft", "ifft", "rfft", "irfft", "fft_plan", "FFTPlan", "mel_filterbank",
38
+ "hz_to_mel", "mel_to_hz", "mfcc", "lfcc", "fftconvolve",
39
+ "MFCC", "LFCC", "FFTConvolve",
40
+ "STFT", "ISTFT", "MelSpectrogram", "CQT", "VQT",
41
+ "Chroma", "Transform", "FFT", "IFFT", "RFFT", "IRFFT",
42
+ "stft_plan", "autotune",
43
+ "STFTPlan", "deterministic", "benchmark", "set_benchmark", "set_num_threads",
44
+ "get_num_threads", "clear_cache", "set_deterministic"]
45
+ mel_filterbank = _filters.mel_filterbank
46
+ hz_to_mel = _filters.hz_to_mel
47
+ mel_to_hz = _filters.mel_to_hz
48
+ deterministic = _config.deterministic
49
+ benchmark = _config.benchmark
50
+
51
+ def __getattr__(name):
52
+ if name == "audio":
53
+ # audio I/O + signal toolbox subpackage;
54
+ # resolves once the package is present, AttributeError until then.
55
+ import importlib
56
+ try:
57
+ return importlib.import_module(".audio", __name__)
58
+ except ModuleNotFoundError:
59
+ raise AttributeError(
60
+ "specux.audio is unavailable: this build has no audio I/O "
61
+ "module (build with FFmpeg dev libraries)") from None
62
+ except ImportError as e:
63
+ # the module exists but its extension failed to load (typically
64
+ # a shared-library resolution problem, e.g. an OpenSSL older
65
+ # than FFmpeg's already loaded in-process); surface the loader's
66
+ # message instead of misreporting a missing build
67
+ raise AttributeError(
68
+ "specux.audio failed to import: " + str(e)) from e
69
+ if name in ("stft_plan", "autotune", "STFTPlan"):
70
+ from .core import plan as _plan
71
+ return getattr(_plan, name)
72
+ if name in ("STFT", "ISTFT", "MelSpectrogram", "CQT", "VQT", "Chroma", "Transform",
73
+ "FFT", "IFFT", "RFFT", "IRFFT"):
74
+ from .dsp import transform as _transform
75
+ return getattr(_transform, name)
76
+ if name in ("mfcc", "lfcc", "fftconvolve"):
77
+ from .dsp import features as _features
78
+ return getattr(_features, name)
79
+ if name in ("MFCC", "LFCC", "FFTConvolve"):
80
+ from .dsp import transform as _transform
81
+ return getattr(_transform, name)
82
+ if name in ("fft", "ifft", "rfft", "irfft", "fft_plan", "FFTPlan"):
83
+ from .core import fft as _fft
84
+ return getattr(_fft, name)
85
+ if name == "set_benchmark":
86
+ from .core import cache as _cache
87
+ return _cache.set_benchmark
88
+ raise AttributeError(f"module 'specux' has no attribute '{name}'")
89
+
90
+
91
+ from .core.api import (stft, istft, melspectrogram, cqt, vqt, chroma, # noqa: E402
92
+ clear_cache, set_num_threads, get_num_threads,
93
+ set_deterministic,
94
+ _check_win_length, _hop) # noqa: F401 (_plan reaches these via specux)
Binary file
Binary file
Binary file
@@ -0,0 +1,73 @@
1
+ """specux audio: fast audio I/O with frame-accurate offset/duration.
2
+
3
+ y, sr = specux.audio.load("clip.flac") # full decode, float32
4
+ y, sr = specux.audio.load("clip.flac", offset=5.0, duration=2.0) # seek + window (seconds)
5
+ y, sr = specux.audio.load("clip.flac", sr=16000) # resample (vendored soxr)
6
+ y, sr = specux.audio.load("clip.wav", dtype="int16") # native int16 fast path
7
+ y, sr = specux.audio.load("song.m4a", track=1) # select an audio track
8
+ specux.audio.save("out.flac", y, sr) # encode (fmt from extension)
9
+ ys = specux.audio.load_many(paths, workers=8) # parallel load -> [(y,sr),...]
10
+ specux.audio.save_many([(p, y, sr), ...]) # parallel save
11
+ for blk in specux.audio.blocks("4hr.flac", sr*30): ... # stream-read a huge file
12
+ with specux.audio.StreamWriter("out.mp3", sr, 2) as w: w.write(blk) # stream-write
13
+ y, sr = specux.audio.load(open("clip.mp3","rb").read()) # decode from memory (bytes)
14
+ data = specux.audio.save_bytes(y, sr, "mp3") # encode to memory
15
+ y2 = specux.audio.codec_roundtrip(y, sr, "mp3", bitrate=64000) # augmentation (same shape)
16
+ y = specux.audio.normalize(y, mode="lufs", target_db=-14, sr=sr) # loudness-normalize
17
+ lv = specux.audio.level(y); lufs = specux.audio.loudness(y, sr) # peak/RMS + LUFS metering
18
+ nfo = specux.audio.info("clip.flac") # frames/samplerate/channels/format/tracks
19
+ specux.audio.write_tags("x.flac", {"title": "Take 3"}); specux.audio.tags("x.flac")
20
+ specux.audio.write_cover("x.flac", open("art.png", "rb").read()) # album art
21
+ y, sr = specux.audio.load("damaged.mp3", strict=False) # best-effort decode
22
+ # every function above also accepts bytes; no filesystem needed anywhere
23
+
24
+ Citations use keys from docs/references.bib (specux convention): standards
25
+ (BS.1770 loudness, EBU R128), algorithms (De Man K-weighting) and vendored
26
+ libraries (soxr, dr_libs, minimp3, TagLib) are all resolvable there.
27
+
28
+ `y` is shape (channels, frames), or (frames,) for mono / single-channel; the same
29
+ layout save() expects. dtype is float32 (default) or int16. Backends are chosen by
30
+ sniffing the header: wav -> dr_wav, mp3 -> dr_mp3, flac/ogg/mp4/aac/other -> libav;
31
+ save uses the vendored dr_wav writer for WAV and libav for compressed formats.
32
+ """
33
+
34
+ import os as _os
35
+
36
+ if _os.name == "nt":
37
+ # dev machines: point SPECUX_FFMPEG_BIN at the FFmpeg DLL directory, or
38
+ # let a repo checkout fall back to the scripts/get_ffmpeg.ps1 prefix
39
+ # (wheels bundle the DLLs next to the extension instead)
40
+ _ffbin = _os.environ.get("SPECUX_FFMPEG_BIN")
41
+ if not (_ffbin and _os.path.isdir(_ffbin)):
42
+ import glob as _glob
43
+ _repo = _os.path.dirname(_os.path.dirname(_os.path.dirname(
44
+ _os.path.abspath(__file__))))
45
+ _hits = sorted(_glob.glob(_os.path.join(
46
+ _repo, ".ffmpeg", "*", "bin", "avformat-*.dll")), reverse=True)
47
+ _ffbin = _os.path.dirname(_hits[0]) if _hits else None
48
+ del _glob, _repo, _hits
49
+ if _ffbin and _os.path.isdir(_ffbin):
50
+ _os.add_dll_directory(_ffbin) # type: ignore[attr-defined] # Windows-only
51
+ del _ffbin
52
+ del _os
53
+
54
+ from .. import _audio as _audio
55
+ from ._io import (codec_roundtrip, load, load_many, save, save_bytes,
56
+ save_many)
57
+ from ._meta import (cover, info, tags, write_cover, write_cover_bytes,
58
+ write_tags, write_tags_bytes)
59
+ from ._signal import (level, loudness, loudness_range, normalize, resample,
60
+ to_mono, to_stereo, true_peak)
61
+ from ._stream import StreamReader, StreamWriter, blocks
62
+ from ._types import QUALITY, Cover, Info, Level, Quality, Subtype
63
+
64
+ __all__ = [
65
+ "load", "save", "save_bytes", "codec_roundtrip",
66
+ "load_many", "save_many",
67
+ "StreamReader", "StreamWriter", "blocks",
68
+ "resample", "to_mono", "to_stereo",
69
+ "level", "loudness", "loudness_range", "true_peak", "normalize",
70
+ "info", "tags", "write_tags", "write_tags_bytes",
71
+ "cover", "write_cover", "write_cover_bytes",
72
+ "Info", "Level", "Cover", "Quality", "Subtype", "QUALITY",
73
+ ]
specux/audio/_io.py ADDED
@@ -0,0 +1,400 @@
1
+ """specux audio I/O: decode, encode, and batch load / save variants."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import struct
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Literal
8
+
9
+ import numpy as np
10
+
11
+ from .. import _audio
12
+ from ._types import Quality, Subtype, _recipe
13
+
14
+ _WAV_DTYPES = {(1, 8): np.uint8, (1, 16): np.int16, (1, 32): np.int32,
15
+ (3, 32): np.float32, (3, 64): np.float64}
16
+
17
+
18
+ _SAVE_EXT = {
19
+ "wav": "wav", "wave": "wav",
20
+ "flac": "flac", "mp3": "mp3", "ogg": "ogg", "oga": "ogg",
21
+ "m4a": "mp4", "mp4": "mp4", "aac": "aac", "opus": "opus",
22
+ }
23
+
24
+
25
+ def _cpu_count() -> int:
26
+ return os.cpu_count() or 1
27
+
28
+
29
+ def _container_for(name, *, is_path: bool) -> str:
30
+ """Container key for an output path's extension (or a bare format name)."""
31
+ name = str(name)
32
+ key = (name.rsplit(".", 1)[-1].lower() if "." in name else
33
+ ("" if is_path else name.lower()))
34
+ container = _SAVE_EXT.get(key)
35
+ if container is None:
36
+ what = (f"extension '.{key}'" if is_path and key
37
+ else f"path {name!r} (no extension)" if is_path
38
+ else f"format {name!r}")
39
+ raise ValueError(f"unsupported output {what} "
40
+ f"(one of: {', '.join(sorted(set(_SAVE_EXT)))})")
41
+ return container
42
+
43
+
44
+ def _as_float32(y):
45
+ """Input audio (any float/int dtype) -> float32 scaled to [-1, 1)."""
46
+ y = np.asarray(y)
47
+ if np.issubdtype(y.dtype, np.unsignedinteger):
48
+ # offset-binary: the zero point sits at half range (128 for uint8)
49
+ half = float(np.iinfo(y.dtype).max + 1) / 2.0
50
+ return (y.astype(np.float32) - half) / half
51
+ if np.issubdtype(y.dtype, np.integer):
52
+ return y.astype(np.float32) / float(np.iinfo(y.dtype).max + 1)
53
+ return np.asarray(y, dtype=np.float32)
54
+
55
+
56
+ def _wav_map_raw(path):
57
+ """Parse an uncompressed WAV header and memory-map its PCM as an interleaved
58
+ (frames, channels) view. Returns (mm, sr, ch, npdt) or None if not a plain
59
+ PCM/float WAV we can map."""
60
+ with open(path, "rb") as f:
61
+ head = f.read(12)
62
+ if len(head) < 12 or head[:4] != b"RIFF" or head[8:12] != b"WAVE":
63
+ return None
64
+ fmt = None
65
+ data_off = data_len = None
66
+ while True:
67
+ hdr = f.read(8)
68
+ if len(hdr) < 8:
69
+ break
70
+ cid, size = hdr[:4], struct.unpack("<I", hdr[4:])[0]
71
+ if cid == b"fmt ":
72
+ b = f.read(size)
73
+ afmt, ch, sr, _, _, bits = struct.unpack("<HHIIHH", b[:16])
74
+ fmt = (afmt, ch, sr, bits)
75
+ elif cid == b"data":
76
+ data_off, data_len = f.tell(), size
77
+ break
78
+ else:
79
+ f.seek(size + (size & 1), 1)
80
+ if fmt is None or data_off is None:
81
+ return None
82
+ afmt, ch, sr, bits = fmt
83
+ npdt = _WAV_DTYPES.get((afmt, bits))
84
+ if npdt is None or ch <= 0:
85
+ return None
86
+ # Clamp to the real file size: truncated downloads and streaming writers
87
+ # (data size left as 0xFFFFFFFF) overstate the chunk; trust the bytes on
88
+ # disk, not the header, so np.memmap can't raise "length greater than file".
89
+ data_len = min(data_len, os.path.getsize(path) - data_off)
90
+ total = data_len // (ch * np.dtype(npdt).itemsize)
91
+ if total <= 0:
92
+ return None
93
+ mm = np.memmap(path, dtype=npdt, mode="r", offset=data_off, shape=(total, ch))
94
+ return mm, sr, ch, npdt
95
+
96
+
97
+ def _wav_memmap(path, offset, duration, mono):
98
+ """Zero-copy WAV read: a (channels, frames) view over the file's PCM (read
99
+ deferred until touched), or None. Ideal for large files sliced downstream."""
100
+ got = _wav_map_raw(path)
101
+ if got is None:
102
+ return None
103
+ mm, sr, ch, _ = got
104
+ # A stereo->mono downmix must read every sample; numpy's strided mean over a
105
+ # transposed memmap is far slower than the NEON downmix in _audio.load, so
106
+ # bail and let the caller take that path. Mono files (ch==1) stay zero-copy.
107
+ if mono and ch > 1:
108
+ return None
109
+ start = int(offset * sr) if offset > 0 else 0
110
+ stop = mm.shape[0] if duration is None else min(mm.shape[0], start + int(duration * sr))
111
+ view = mm[start:stop] # zero-copy slice
112
+ view = view.T if ch > 1 else view[:, 0] # channels-first view
113
+ return view, sr
114
+
115
+
116
+ def _wav_downmix(path, offset, duration):
117
+ """Stereo/multichannel WAV -> mono float32 without a full-stereo decode: mmap
118
+ the PCM and fuse the downmix (NEON, parallel) in a single pass over the mapped
119
+ int16/float samples. Returns (mono, sr) or None if not a mappable PCM WAV."""
120
+ got = _wav_map_raw(path)
121
+ if got is None:
122
+ return None
123
+ mm, sr, ch, npdt = got
124
+ if ch == 1:
125
+ return None # already mono; mmap view is better
126
+ if npdt not in (np.int16, np.float32):
127
+ return None # _audio.downmix dtypes only; fall back
128
+ start = int(offset * sr) if offset > 0 else 0
129
+ stop = mm.shape[0] if duration is None else min(mm.shape[0], start + int(duration * sr))
130
+ y = _audio.downmix(np.ascontiguousarray(mm[start:stop]))
131
+ return y, sr
132
+
133
+
134
+ def _wav_resample(path, out_sr, res_type, mono):
135
+ """WAV resample without the decode threadpool: mmap the PCM, convert to
136
+ float32, and resample with the native soxr. Returns (samples, out_sr) or
137
+ None. Skipping the threadpool keeps it immune to a perturbed thread
138
+ environment."""
139
+ got = _wav_map_raw(path)
140
+ if got is None:
141
+ return None
142
+ mm, sr, ch, npdt = got
143
+ y = np.asarray(mm, dtype=np.float32) # (frames, ch), materialises
144
+ if npdt == np.uint8:
145
+ y = (y - 128.0) / 128.0 # offset-binary: zero point is 128
146
+ elif np.issubdtype(npdt, np.integer):
147
+ y = y / float(np.iinfo(npdt).max + 1)
148
+ if mono and ch > 1:
149
+ y = y.mean(axis=1, keepdims=True)
150
+ out, _ = _audio.resample(np.ascontiguousarray(y), sr, out_sr, res_type)
151
+ out = out[:, 0] if (out.ndim == 2 and out.shape[1] == 1) else out
152
+ return (out.T if out.ndim == 2 else out), out_sr
153
+
154
+
155
+ def load(path, *, sr: int | None = None, offset: float = 0.0,
156
+ duration: float | None = None, mono: bool = False,
157
+ dtype: Literal["float32", "int16"] = "float32",
158
+ quality: Quality = "high",
159
+ backend: Literal["auto", "dr", "libav"] = "auto", threads: int = 0,
160
+ mmap: bool = False, track: int = 0, strict: bool = True,
161
+ _serial: bool = False):
162
+ """Decode `path` to a numpy array; returns (samples, samplerate).
163
+
164
+ sr: target sample rate (None = native).
165
+ offset: start position in seconds (frame-accurate).
166
+ duration: seconds to read (None = to end).
167
+ mono: downmix to a single channel.
168
+ dtype: "float32" (default) or "int16".
169
+ quality: resample quality when `sr` is set; one of QUALITY
170
+ ("quick".."very_high", default "high").
171
+ backend: "auto" (sniff+route), "dr" (force dr_wav/dr_mp3), or "libav".
172
+ threads: libav decode threads (0 = auto); ignored by the dr backends.
173
+ mmap: for uncompressed WAV, return a zero-copy memory-mapped VIEW in the
174
+ file's native dtype (read deferred until touched). Ignores sr /
175
+ dtype conversion; falls back to a normal decode if unsupported.
176
+ track: audio-track index to decode (0 = first). Multi-track containers
177
+ like Native-Instruments .stem.m4a expose several; see info().tracks.
178
+ strict: True (default) surfaces decode errors. strict=False is
179
+ best-effort for messy datasets: mid-file corruption already
180
+ yields the decodable prefix, and on a hard open failure the
181
+ other backend is tried before raising; an exception then means
182
+ nothing was recoverable. Pairs with load_many(on_error="skip").
183
+
184
+ `path` may also be a bytes-like object (bytes / bytearray / memoryview) -
185
+ audio already in memory (archive members, network payloads) decodes through
186
+ the same engines with no filesystem involved; `mmap` is meaningless there.
187
+ """
188
+ recipe = _recipe(quality)
189
+ if isinstance(path, (bytes, bytearray, memoryview)):
190
+ return _audio.load_bytes(
191
+ path, sr=0 if sr is None else int(sr), offset=float(offset),
192
+ duration=-1.0 if duration is None else float(duration),
193
+ mono=bool(mono), dtype=dtype, res_type=recipe,
194
+ threads=int(threads), stream=int(track), strict=bool(strict))
195
+ spath = str(path)
196
+ # The three fast paths below are WAV-only (they map raw PCM). Gate them on the
197
+ # extension so a compressed file (mp3/flac/ogg) doesn't pay an open+header-read
198
+ # just to be rejected; that wasted probe is ~as costly as a short mp3 decode.
199
+ # A mis-named WAV simply misses the zero-copy path; _audio.load still sniffs by
200
+ # content and decodes it correctly. (A non-zero track is libav-only, so the
201
+ # single-stream WAV fast paths never apply.)
202
+ maybe_wav = (track == 0 and
203
+ (spath.rsplit(".", 1)[-1].lower() in ("wav", "wave")
204
+ if "." in spath else True))
205
+ if maybe_wav:
206
+ if mmap and sr is None:
207
+ got = _wav_memmap(spath, float(offset), duration, bool(mono))
208
+ if got is not None:
209
+ return got
210
+ # WAV mono fast path: mmap the PCM and fuse the downmix (NEON); one pass
211
+ # over the mapped samples, no full-stereo decode.
212
+ if mono and sr is None and dtype == "float32" and backend == "auto":
213
+ got = _wav_downmix(spath, float(offset), duration)
214
+ if got is not None:
215
+ return got
216
+ # WAV resample fast path: mmap + native soxr, off the decode threadpool.
217
+ if (sr is not None and dtype == "float32" and offset == 0.0
218
+ and duration is None and backend == "auto"):
219
+ got = _wav_resample(spath, int(sr), recipe, bool(mono))
220
+ if got is not None:
221
+ return got
222
+ try:
223
+ return _audio.load(
224
+ spath, sr=0 if sr is None else int(sr), offset=float(offset),
225
+ duration=-1.0 if duration is None else float(duration),
226
+ mono=bool(mono), dtype=dtype, res_type=recipe, backend=backend,
227
+ threads=int(threads), stream=int(track), serial=bool(_serial))
228
+ except Exception as first:
229
+ # Best-effort salvage: a damaged header can fail the dr tier while
230
+ # libav's deeper probing still recovers the audio. Only on hard
231
+ # failures; short/corrupt-mid-file reads never raise in the first
232
+ # place; and only when the libav tier wasn't already the one that
233
+ # failed. A missing file is not salvageable, so that error stands.
234
+ if strict or backend == "libav" or not os.path.exists(spath):
235
+ raise
236
+ try:
237
+ return _audio.load(
238
+ spath, sr=0 if sr is None else int(sr), offset=float(offset),
239
+ duration=-1.0 if duration is None else float(duration),
240
+ mono=bool(mono), dtype=dtype, res_type=recipe, backend="libav",
241
+ threads=int(threads), stream=int(track), serial=bool(_serial))
242
+ except Exception:
243
+ raise first from None
244
+
245
+
246
+ def save(path, y, sr: int, *, subtype: Subtype | None = None,
247
+ bitrate: int | None = None, compression_level: int | None = None):
248
+ """Write audio `y` (sample rate `sr`) to `path`; the format is chosen from the
249
+ extension. Mirrors `load`'s layout: a 2-D array is (channels, frames), a 1-D
250
+ array is mono. Any float or int dtype is accepted (scaled to float32).
251
+
252
+ path: output file; extension selects the codec/container
253
+ (.wav .flac .mp3 .ogg .m4a/.aac .opus).
254
+ subtype: WAV sample format; "PCM_16" (default), "PCM_24", "PCM_32",
255
+ "FLOAT". Ignored by compressed formats.
256
+ bitrate: target bits/s for the lossy codecs (mp3/aac/ogg/opus); None =
257
+ codec default. Ignored by WAV and FLAC (lossless).
258
+ """
259
+ y = _as_float32(y)
260
+ if y.ndim not in (1, 2):
261
+ raise ValueError(f"save expects a 1-D or 2-D array, got {y.ndim}-D")
262
+ # Pass the (channels, frames) planar array straight through; _audio.save
263
+ # interleaves it in C++ (NEON), so no strided transpose happens here.
264
+ arr = np.ascontiguousarray(y, dtype=np.float32)
265
+
266
+ ext = str(path).rsplit(".", 1)[-1].lower() if "." in str(path) else ""
267
+ container = _SAVE_EXT.get(ext)
268
+ if container is None:
269
+ raise ValueError(f"unsupported output extension '.{ext}' "
270
+ f"(one of: {', '.join(sorted(set(_SAVE_EXT)))})")
271
+ _audio.save(str(path), arr, int(sr), container,
272
+ subtype or "", int(bitrate or 0),
273
+ -1 if compression_level is None else int(compression_level))
274
+
275
+
276
+ def _run_batch(work, n, workers, on_error):
277
+ """Run `work(i)` for i in range(n) across a thread pool (each task releases
278
+ the GIL in C++). Returns a list of results; on_error='skip' puts the caught
279
+ Exception in that slot (so you get both *which* failed and *why*), 'raise'
280
+ re-raises the first failure. Order is preserved."""
281
+ if on_error not in ("raise", "skip"):
282
+ raise ValueError(f"on_error must be 'raise' or 'skip'; got '{on_error}'")
283
+ out: list = [None] * n
284
+ with ThreadPoolExecutor(max(1, workers)) as ex:
285
+ futs = {ex.submit(work, i): i for i in range(n)}
286
+ for fut in futs:
287
+ i = futs[fut]
288
+ try:
289
+ out[i] = fut.result()
290
+ except Exception as e: # noqa: BLE001
291
+ if on_error == "raise":
292
+ raise
293
+ out[i] = e
294
+ return out
295
+
296
+
297
+ def save_bytes(y, sr: int, format: str, *,
298
+ subtype: Subtype | None = None, bitrate: int | None = None,
299
+ compression_level: int | None = None) -> bytes:
300
+ """Encode `y` to an in-memory container and return the bytes; the mirror
301
+ of ``load(bytes)``; nothing touches the filesystem.
302
+
303
+ format: "wav", "flac", "mp3", "ogg", or "m4a"/"mp4"/"aac"; other kwargs as
304
+ in save(). `load(save_bytes(y, sr, fmt))` round-trips.
305
+ """
306
+ y = _as_float32(y)
307
+ if y.ndim not in (1, 2):
308
+ raise ValueError(f"save_bytes expects a 1-D or 2-D array, got {y.ndim}-D")
309
+ return _audio.save_bytes(
310
+ np.ascontiguousarray(y), int(sr), _container_for(format, is_path=False),
311
+ subtype or "", int(bitrate or 0),
312
+ -1 if compression_level is None else int(compression_level))
313
+
314
+
315
+ def codec_roundtrip(y, sr: int, format: str = "mp3", *,
316
+ bitrate: int | None = None,
317
+ compression_level: int | None = None):
318
+ """Encode+decode `y` through a lossy codec entirely in memory and return an
319
+ array with the SAME shape; codec-artifact augmentation for training
320
+ (simulate MP3/OGG compression). Length differences from encoder
321
+ delay/padding are trimmed / zero-padded back to the input length.
322
+ """
323
+ y = np.asarray(y, dtype=np.float32)
324
+ out, _ = load(save_bytes(y, sr, format, bitrate=bitrate,
325
+ compression_level=compression_level))
326
+ out = np.asarray(out, dtype=np.float32)
327
+ if y.ndim == 2 and out.ndim == 1:
328
+ out = out[None, :]
329
+ elif y.ndim == 1 and out.ndim == 2:
330
+ out = out.mean(axis=0)
331
+ n, m = y.shape[-1], out.shape[-1]
332
+ if m > n:
333
+ out = out[..., :n]
334
+ elif m < n:
335
+ pad = [(0, 0)] * (out.ndim - 1) + [(0, n - m)]
336
+ out = np.pad(out, pad)
337
+ return out
338
+
339
+
340
+ def load_many(paths, *, workers: int = 0,
341
+ on_error: Literal["raise", "skip"] = "raise", **kwargs):
342
+ """Load several files in parallel; returns a list of ``(samples, samplerate)``
343
+ in the same order as `paths`. `kwargs` are passed to `load` (sr, offset,
344
+ duration, mono, dtype, …) and apply to every file.
345
+
346
+ workers: thread count (0 = auto: all cores). Decode releases the GIL, so
347
+ this scales across files.
348
+ on_error: 'raise' (default) aborts on the first failure; 'skip' completes the
349
+ batch and returns the Exception object in that file's slot instead
350
+ of a result; so a failed file is both detectable (`isinstance(r,
351
+ Exception)`) and inspectable (the error itself).
352
+
353
+ Files are decoded serially-per-file and parallelised across files (the right
354
+ model for many files); for fewer files than cores, each file keeps its own
355
+ internal parallelism.
356
+ """
357
+ paths = list(paths)
358
+ n = len(paths)
359
+ if n == 0:
360
+ return []
361
+ ncpu = _cpu_count()
362
+ if n < ncpu and workers == 0: # few files: per-file parallelism, in order
363
+ out: list = []
364
+ for p in paths:
365
+ try:
366
+ out.append(load(p, **kwargs))
367
+ except Exception as e: # noqa: BLE001
368
+ if on_error == "raise":
369
+ raise
370
+ out.append(e)
371
+ return out
372
+ nworkers = workers or min(n, ncpu)
373
+ return _run_batch(lambda i: load(paths[i], _serial=True, **kwargs),
374
+ n, nworkers, on_error)
375
+
376
+
377
+ def save_many(items, *, workers: int = 0,
378
+ on_error: Literal["raise", "skip"] = "raise", **kwargs):
379
+ """Save several ``(path, y, sr)`` items in parallel. `kwargs` are passed to
380
+ `save` (subtype, bitrate, …) and apply to every item.
381
+
382
+ Returns None on full success. With on_error='skip' the batch always completes
383
+ and a list is returned whose entries are None (ok) or the Exception for a
384
+ failed item. Encoding releases the GIL, so this scales across files.
385
+ """
386
+ items = list(items)
387
+ n = len(items)
388
+ if n == 0:
389
+ return None
390
+ nworkers = workers or min(n, _cpu_count())
391
+
392
+ def one(i):
393
+ path, y, sr = items[i]
394
+ save(path, y, sr, **kwargs)
395
+
396
+ res = _run_batch(one, n, nworkers, on_error)
397
+ if on_error == "skip":
398
+ return [r if isinstance(r, Exception) else None for r in res]
399
+ return None
400
+