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.
- specux/__init__.py +94 -0
- specux/_audio.cp313-win_amd64.pyd +0 -0
- specux/_cpu.cp313-win_amd64.pyd +0 -0
- specux/_cuda.cp313-win_amd64.pyd +0 -0
- specux/audio/__init__.py +73 -0
- specux/audio/_io.py +400 -0
- specux/audio/_meta.py +124 -0
- specux/audio/_signal.py +198 -0
- specux/audio/_stream.py +204 -0
- specux/audio/_types.py +67 -0
- specux/core/__init__.py +1 -0
- specux/core/api.py +799 -0
- specux/core/backend.py +697 -0
- specux/core/cache.py +75 -0
- specux/core/config.py +83 -0
- specux/core/dispatch.py +171 -0
- specux/core/dtypes.py +8 -0
- specux/core/fallback.py +115 -0
- specux/core/fft.py +569 -0
- specux/core/library.py +1216 -0
- specux/core/plan.py +199 -0
- specux/core/typing.py +17 -0
- specux/core/version.py +6 -0
- specux/dsp/__init__.py +1 -0
- specux/dsp/cqt.py +365 -0
- specux/dsp/cqt_filters.py +184 -0
- specux/dsp/features.py +527 -0
- specux/dsp/filters.py +122 -0
- specux/dsp/transform.py +533 -0
- specux/interop/__init__.py +1 -0
- specux/interop/cuda.py +383 -0
- specux/interop/cupy.py +201 -0
- specux/interop/metal.py +96 -0
- specux/interop/metal_transport.py +818 -0
- specux/interop/torch.py +201 -0
- specux/py.typed +0 -0
- specux/transforms.py +250 -0
- specux-0.1.0.dev1.dist-info/DELVEWHEEL +2 -0
- specux-0.1.0.dev1.dist-info/METADATA +220 -0
- specux-0.1.0.dev1.dist-info/RECORD +49 -0
- specux-0.1.0.dev1.dist-info/WHEEL +5 -0
- specux-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- specux-0.1.0.dev1.dist-info/licenses/NOTICE +111 -0
- specux-0.1.0.dev1.dist-info/top_level.txt +1 -0
- specux.libs/avcodec-62-7751d471583ebec40409b295441b5ea0.dll +0 -0
- specux.libs/avformat-62-4fa6a77b648319730491e08e9bf61f01.dll +0 -0
- specux.libs/avutil-60-ad169984cd8743121d920891cf58690c.dll +0 -0
- specux.libs/msvcp140-8f141b4454fa78db34bc1f28c571b4da.dll +0 -0
- 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
|
specux/audio/__init__.py
ADDED
|
@@ -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
|
+
|