audiosig 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.
- audiosig/__init__.py +78 -0
- audiosig/_framing.py +35 -0
- audiosig/_resampling.py +132 -0
- audiosig/_spectral.py +139 -0
- audiosig/_validation.py +107 -0
- audiosig/_version.py +24 -0
- audiosig/amplitude.py +55 -0
- audiosig/effects.py +113 -0
- audiosig/exceptions.py +15 -0
- audiosig/py.typed +0 -0
- audiosig/silence.py +777 -0
- audiosig/speech.py +82 -0
- audiosig-0.1.0.dist-info/METADATA +169 -0
- audiosig-0.1.0.dist-info/RECORD +18 -0
- audiosig-0.1.0.dist-info/WHEEL +5 -0
- audiosig-0.1.0.dist-info/licenses/LICENSE +202 -0
- audiosig-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +11 -0
- audiosig-0.1.0.dist-info/top_level.txt +1 -0
audiosig/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Portable, dependency-light audio signal processing for NumPy arrays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from ._version import __version__ # type: ignore[import-not-found]
|
|
7
|
+
except ImportError: # pragma: no cover - only used in an unbuilt source checkout
|
|
8
|
+
try:
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
__version__ = version("audiosig")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0.1.dev0"
|
|
14
|
+
|
|
15
|
+
from ._resampling import resample, resample_speed, resample_to_length
|
|
16
|
+
from .amplitude import apply_gain_db, peak_normalize
|
|
17
|
+
from .effects import pitch_shift, time_stretch
|
|
18
|
+
from .exceptions import AudioShapeError, AudioSignalError, InvalidParameterError
|
|
19
|
+
from .silence import (
|
|
20
|
+
abs2,
|
|
21
|
+
activity_to_intervals,
|
|
22
|
+
amplitude_to_db,
|
|
23
|
+
energy_based_vad,
|
|
24
|
+
find_speech_bounds,
|
|
25
|
+
find_speech_start,
|
|
26
|
+
frame_rms,
|
|
27
|
+
frame_signal,
|
|
28
|
+
frames_to_samples,
|
|
29
|
+
median_filter_numpy,
|
|
30
|
+
minmax_normalize,
|
|
31
|
+
non_silent_frames,
|
|
32
|
+
normalized_energy_vad,
|
|
33
|
+
power_to_db,
|
|
34
|
+
relative_db_vad,
|
|
35
|
+
rms,
|
|
36
|
+
short_time_energy,
|
|
37
|
+
spectral_flux,
|
|
38
|
+
split,
|
|
39
|
+
trim,
|
|
40
|
+
zero_crossing_rate,
|
|
41
|
+
)
|
|
42
|
+
from .speech import apply_speech_effects
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AudioShapeError",
|
|
46
|
+
"AudioSignalError",
|
|
47
|
+
"InvalidParameterError",
|
|
48
|
+
"__version__",
|
|
49
|
+
"abs2",
|
|
50
|
+
"activity_to_intervals",
|
|
51
|
+
"amplitude_to_db",
|
|
52
|
+
"apply_gain_db",
|
|
53
|
+
"apply_speech_effects",
|
|
54
|
+
"energy_based_vad",
|
|
55
|
+
"find_speech_bounds",
|
|
56
|
+
"find_speech_start",
|
|
57
|
+
"frame_rms",
|
|
58
|
+
"frame_signal",
|
|
59
|
+
"frames_to_samples",
|
|
60
|
+
"median_filter_numpy",
|
|
61
|
+
"minmax_normalize",
|
|
62
|
+
"non_silent_frames",
|
|
63
|
+
"normalized_energy_vad",
|
|
64
|
+
"peak_normalize",
|
|
65
|
+
"pitch_shift",
|
|
66
|
+
"power_to_db",
|
|
67
|
+
"relative_db_vad",
|
|
68
|
+
"resample",
|
|
69
|
+
"resample_speed",
|
|
70
|
+
"resample_to_length",
|
|
71
|
+
"rms",
|
|
72
|
+
"short_time_energy",
|
|
73
|
+
"spectral_flux",
|
|
74
|
+
"split",
|
|
75
|
+
"time_stretch",
|
|
76
|
+
"trim",
|
|
77
|
+
"zero_crossing_rate",
|
|
78
|
+
]
|
audiosig/_framing.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""NumPy stride-based signal framing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._validation import validate_audio, validate_integer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def frame(
|
|
11
|
+
audio: np.ndarray,
|
|
12
|
+
*,
|
|
13
|
+
frame_length: int,
|
|
14
|
+
hop_length: int,
|
|
15
|
+
axis: int = -1,
|
|
16
|
+
) -> np.ndarray:
|
|
17
|
+
"""Return a read-only view shaped ``(..., frames, frame_length)``.
|
|
18
|
+
|
|
19
|
+
The sample axis is moved to the end before framing. Overlapping frames are
|
|
20
|
+
intentionally read-only so callers cannot accidentally corrupt the source
|
|
21
|
+
array through a strided view.
|
|
22
|
+
"""
|
|
23
|
+
source, normalized_axis = validate_audio(audio, axis=axis)
|
|
24
|
+
length = validate_integer(frame_length, "frame_length")
|
|
25
|
+
hop = validate_integer(hop_length, "hop_length")
|
|
26
|
+
moved = np.moveaxis(source, normalized_axis, -1)
|
|
27
|
+
sample_count = moved.shape[-1]
|
|
28
|
+
frame_count = 1 + (sample_count - length) // hop if sample_count >= length else 0
|
|
29
|
+
shape = (*moved.shape[:-1], frame_count, length)
|
|
30
|
+
if frame_count == 0:
|
|
31
|
+
return np.empty(shape, dtype=source.dtype)
|
|
32
|
+
strides = (*moved.strides[:-1], hop * moved.strides[-1], moved.strides[-1])
|
|
33
|
+
result = np.lib.stride_tricks.as_strided(moved, shape=shape, strides=strides)
|
|
34
|
+
result.setflags(write=False)
|
|
35
|
+
return result
|
audiosig/_resampling.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Chunked windowed-sinc resampling implemented with NumPy only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._validation import (
|
|
8
|
+
validate_audio,
|
|
9
|
+
validate_choices,
|
|
10
|
+
validate_filter,
|
|
11
|
+
validate_integer,
|
|
12
|
+
validate_positive,
|
|
13
|
+
)
|
|
14
|
+
from .exceptions import AudioShapeError, InvalidParameterError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resample(
|
|
18
|
+
audio: np.ndarray,
|
|
19
|
+
*,
|
|
20
|
+
source_rate: float,
|
|
21
|
+
target_rate: float,
|
|
22
|
+
axis: int = -1,
|
|
23
|
+
filter_width: int = 32,
|
|
24
|
+
rolloff: float = 0.945,
|
|
25
|
+
length_mode: str = "round",
|
|
26
|
+
) -> np.ndarray:
|
|
27
|
+
"""Resample audio with a finite, anti-aliased windowed-sinc kernel.
|
|
28
|
+
|
|
29
|
+
``length_mode='round'`` preserves AudioSig's historical sizing; ``ceil``
|
|
30
|
+
provides librosa-compatible output length semantics.
|
|
31
|
+
"""
|
|
32
|
+
source, normalized_axis = validate_audio(audio, axis=axis, allow_empty=True)
|
|
33
|
+
source_hz = validate_positive(source_rate, "source_rate")
|
|
34
|
+
target_hz = validate_positive(target_rate, "target_rate")
|
|
35
|
+
width, cutoff = validate_filter(filter_width, rolloff)
|
|
36
|
+
mode = validate_choices(length_mode, ("round", "ceil"), "length_mode")
|
|
37
|
+
input_length = source.shape[normalized_axis]
|
|
38
|
+
if input_length == 0:
|
|
39
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
40
|
+
requested_length = input_length * target_hz / source_hz
|
|
41
|
+
if not np.isfinite(requested_length) or requested_length > np.iinfo(np.intp).max:
|
|
42
|
+
raise InvalidParameterError("requested resampled length is too large")
|
|
43
|
+
output_length = max(
|
|
44
|
+
1, round(requested_length) if mode == "round" else int(np.ceil(requested_length))
|
|
45
|
+
)
|
|
46
|
+
if source_hz == target_hz and output_length == input_length:
|
|
47
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
48
|
+
moved = np.moveaxis(source, normalized_axis, -1)
|
|
49
|
+
flat = moved.reshape((-1, input_length))
|
|
50
|
+
ratio = target_hz / source_hz
|
|
51
|
+
effective_cutoff = cutoff * min(1.0, ratio)
|
|
52
|
+
offsets = np.arange(-width, width + 1, dtype=np.int64)
|
|
53
|
+
window = np.kaiser(offsets.size, beta=14.0)[None, :]
|
|
54
|
+
output = np.empty((moved.size // input_length, output_length), dtype=source.dtype)
|
|
55
|
+
chunk_size = 1024
|
|
56
|
+
for start in range(0, output_length, chunk_size):
|
|
57
|
+
stop = min(output_length, start + chunk_size)
|
|
58
|
+
positions = np.arange(start, stop, dtype=np.float64) / ratio
|
|
59
|
+
centers = np.floor(positions).astype(np.int64)
|
|
60
|
+
indices = centers[:, None] + offsets[None, :]
|
|
61
|
+
clipped = np.clip(indices, 0, input_length - 1)
|
|
62
|
+
distance = indices.astype(np.float64) - positions[:, None]
|
|
63
|
+
weights = np.sinc(distance * effective_cutoff) * effective_cutoff * window
|
|
64
|
+
weights /= np.maximum(np.sum(weights, axis=1, keepdims=True), np.finfo(np.float64).eps)
|
|
65
|
+
samples = flat[:, clipped]
|
|
66
|
+
output[:, start:stop] = np.sum(samples * weights[None, :, :], axis=-1, dtype=np.float64)
|
|
67
|
+
reshaped = output.reshape((*moved.shape[:-1], output_length))
|
|
68
|
+
return np.moveaxis(reshaped, -1, normalized_axis).astype(source.dtype, copy=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resample_to_length(
|
|
72
|
+
audio: np.ndarray,
|
|
73
|
+
length: int,
|
|
74
|
+
*,
|
|
75
|
+
axis: int = -1,
|
|
76
|
+
filter_width: int = 32,
|
|
77
|
+
rolloff: float = 0.945,
|
|
78
|
+
) -> np.ndarray:
|
|
79
|
+
"""Resample audio to exactly ``length`` samples along ``axis``."""
|
|
80
|
+
source, normalized_axis = validate_audio(audio, axis=axis, allow_empty=True)
|
|
81
|
+
target_length = validate_integer(length, "length", minimum=0)
|
|
82
|
+
validate_filter(filter_width, rolloff)
|
|
83
|
+
input_length = source.shape[normalized_axis]
|
|
84
|
+
if target_length == input_length:
|
|
85
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
86
|
+
if target_length == 0:
|
|
87
|
+
shape = list(source.shape)
|
|
88
|
+
shape[normalized_axis] = 0
|
|
89
|
+
return np.empty(shape, dtype=source.dtype)
|
|
90
|
+
if input_length == 0:
|
|
91
|
+
raise AudioShapeError("empty audio cannot be resampled to a non-zero length")
|
|
92
|
+
return resample(
|
|
93
|
+
source,
|
|
94
|
+
source_rate=float(input_length),
|
|
95
|
+
target_rate=float(target_length),
|
|
96
|
+
axis=normalized_axis,
|
|
97
|
+
filter_width=filter_width,
|
|
98
|
+
rolloff=rolloff,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resample_speed(
|
|
103
|
+
audio: np.ndarray,
|
|
104
|
+
speed: float,
|
|
105
|
+
*,
|
|
106
|
+
axis: int = -1,
|
|
107
|
+
filter_width: int = 32,
|
|
108
|
+
rolloff: float = 0.945,
|
|
109
|
+
) -> np.ndarray:
|
|
110
|
+
"""Change playback speed by resampling, including its pitch.
|
|
111
|
+
|
|
112
|
+
``speed > 1`` makes audio faster, shorter, and higher pitched. ``speed < 1``
|
|
113
|
+
makes audio slower, longer, and lower pitched. Use :func:`time_stretch`
|
|
114
|
+
when pitch should remain approximately unchanged.
|
|
115
|
+
"""
|
|
116
|
+
source, normalized_axis = validate_audio(audio, axis=axis, allow_empty=True)
|
|
117
|
+
multiplier = validate_positive(speed, "speed")
|
|
118
|
+
validate_filter(filter_width, rolloff)
|
|
119
|
+
input_length = source.shape[normalized_axis]
|
|
120
|
+
if input_length == 0:
|
|
121
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
122
|
+
requested = input_length / multiplier
|
|
123
|
+
if not np.isfinite(requested) or requested > np.iinfo(np.intp).max:
|
|
124
|
+
raise InvalidParameterError("requested playback-speed length is too large")
|
|
125
|
+
target_length = max(1, round(requested))
|
|
126
|
+
return resample_to_length(
|
|
127
|
+
source,
|
|
128
|
+
target_length,
|
|
129
|
+
axis=normalized_axis,
|
|
130
|
+
filter_width=filter_width,
|
|
131
|
+
rolloff=rolloff,
|
|
132
|
+
)
|
audiosig/_spectral.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Small NumPy-only STFT and phase-vocoder primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._framing import frame
|
|
8
|
+
from ._validation import validate_audio, validate_integer, validate_positive
|
|
9
|
+
from .exceptions import AudioShapeError, InvalidParameterError
|
|
10
|
+
|
|
11
|
+
_DEFAULT_DTYPE = np.dtype(np.float64)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fft_settings(n_fft: int, hop_length: int) -> tuple[int, int]:
|
|
15
|
+
fft_size = validate_integer(n_fft, "n_fft", minimum=2)
|
|
16
|
+
hop = validate_integer(hop_length, "hop_length")
|
|
17
|
+
if hop > fft_size:
|
|
18
|
+
raise InvalidParameterError("hop_length must not exceed n_fft")
|
|
19
|
+
return fft_size, hop
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pad_for_stft(audio: np.ndarray, n_fft: int, hop_length: int, *, center: bool) -> np.ndarray:
|
|
23
|
+
"""Pad short arrays safely and add centered analysis padding."""
|
|
24
|
+
source = audio
|
|
25
|
+
if center:
|
|
26
|
+
padding = n_fft // 2
|
|
27
|
+
mode = "reflect" if source.shape[-1] > 1 else "constant"
|
|
28
|
+
source = np.pad( # type: ignore[call-overload]
|
|
29
|
+
source, [(0, 0)] * (source.ndim - 1) + [(padding, padding)], mode=mode
|
|
30
|
+
)
|
|
31
|
+
if source.shape[-1] < n_fft:
|
|
32
|
+
source = np.pad(source, [(0, 0)] * (source.ndim - 1) + [(0, n_fft - source.shape[-1])])
|
|
33
|
+
remainder = (source.shape[-1] - n_fft) % hop_length
|
|
34
|
+
if remainder:
|
|
35
|
+
source = np.pad(source, [(0, 0)] * (source.ndim - 1) + [(0, hop_length - remainder)])
|
|
36
|
+
return source
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stft(
|
|
40
|
+
audio: np.ndarray,
|
|
41
|
+
*,
|
|
42
|
+
n_fft: int,
|
|
43
|
+
hop_length: int,
|
|
44
|
+
center: bool = True,
|
|
45
|
+
) -> np.ndarray:
|
|
46
|
+
"""Compute a centered Hann-windowed real-input STFT.
|
|
47
|
+
|
|
48
|
+
The returned shape is ``(..., frequency_bins, frames)``.
|
|
49
|
+
"""
|
|
50
|
+
source, axis = validate_audio(audio)
|
|
51
|
+
if axis != source.ndim - 1:
|
|
52
|
+
source = np.moveaxis(source, axis, -1)
|
|
53
|
+
fft_size, hop = _fft_settings(n_fft, hop_length)
|
|
54
|
+
padded = _pad_for_stft(source, fft_size, hop, center=center)
|
|
55
|
+
frames = frame(padded, frame_length=fft_size, hop_length=hop)
|
|
56
|
+
window = np.hanning(fft_size).astype(source.dtype, copy=False)
|
|
57
|
+
spectrum = np.fft.rfft(frames * window, n=fft_size, axis=-1)
|
|
58
|
+
return np.moveaxis(spectrum, -1, -2)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def istft(
|
|
62
|
+
spectrum: np.ndarray,
|
|
63
|
+
*,
|
|
64
|
+
n_fft: int,
|
|
65
|
+
hop_length: int,
|
|
66
|
+
length: int | None = None,
|
|
67
|
+
center: bool = True,
|
|
68
|
+
dtype: np.dtype = _DEFAULT_DTYPE,
|
|
69
|
+
) -> np.ndarray:
|
|
70
|
+
"""Invert a spectrum with overlap-add and squared-window normalization."""
|
|
71
|
+
fft_size, hop = _fft_settings(n_fft, hop_length)
|
|
72
|
+
if not isinstance(spectrum, np.ndarray) or spectrum.ndim < 2:
|
|
73
|
+
raise AudioShapeError("spectrum must be an array shaped (..., frequencies, frames)")
|
|
74
|
+
expected_bins = fft_size // 2 + 1
|
|
75
|
+
if spectrum.shape[-2] != expected_bins or spectrum.shape[-1] == 0:
|
|
76
|
+
raise AudioShapeError("spectrum has incompatible frequency or frame dimensions")
|
|
77
|
+
target_length = validate_integer(length, "length") if length is not None else None
|
|
78
|
+
frames = np.fft.irfft(np.moveaxis(spectrum, -2, -1), n=fft_size, axis=-1)
|
|
79
|
+
window = np.hanning(fft_size).astype(dtype, copy=False)
|
|
80
|
+
frame_count = frames.shape[-2]
|
|
81
|
+
base_length = (frame_count - 1) * hop + fft_size
|
|
82
|
+
output = np.zeros((*frames.shape[:-2], base_length), dtype=dtype)
|
|
83
|
+
envelope = np.zeros(base_length, dtype=dtype)
|
|
84
|
+
windowed = frames * window
|
|
85
|
+
for index in range(frame_count):
|
|
86
|
+
start = index * hop
|
|
87
|
+
output[..., start : start + fft_size] += windowed[..., index, :]
|
|
88
|
+
envelope[start : start + fft_size] += window * window
|
|
89
|
+
safe_envelope = np.where(envelope > np.finfo(dtype).eps, envelope, 1.0)
|
|
90
|
+
output /= safe_envelope
|
|
91
|
+
if center:
|
|
92
|
+
padding = fft_size // 2
|
|
93
|
+
if output.shape[-1] > 2 * padding:
|
|
94
|
+
output = output[..., padding:-padding]
|
|
95
|
+
else:
|
|
96
|
+
output = output[..., 0:0]
|
|
97
|
+
if target_length is not None:
|
|
98
|
+
if output.shape[-1] < target_length:
|
|
99
|
+
output = np.pad(
|
|
100
|
+
output,
|
|
101
|
+
[(0, 0)] * (output.ndim - 1) + [(0, target_length - output.shape[-1])],
|
|
102
|
+
)
|
|
103
|
+
output = output[..., :target_length]
|
|
104
|
+
return output.astype(dtype, copy=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def phase_vocoder(
|
|
108
|
+
spectrum: np.ndarray,
|
|
109
|
+
*,
|
|
110
|
+
rate: float,
|
|
111
|
+
hop_length: int,
|
|
112
|
+
n_fft: int,
|
|
113
|
+
) -> np.ndarray:
|
|
114
|
+
"""Time-scale an STFT by interpolating magnitudes and accumulating phase."""
|
|
115
|
+
stretch = validate_positive(rate, "rate")
|
|
116
|
+
fft_size, hop = _fft_settings(n_fft, hop_length)
|
|
117
|
+
if not isinstance(spectrum, np.ndarray) or spectrum.ndim < 2:
|
|
118
|
+
raise AudioShapeError("spectrum must be an array shaped (..., frequencies, frames)")
|
|
119
|
+
expected_bins = fft_size // 2 + 1
|
|
120
|
+
if spectrum.shape[-2] != expected_bins or spectrum.shape[-1] == 0:
|
|
121
|
+
raise AudioShapeError("spectrum has incompatible frequency or frame dimensions")
|
|
122
|
+
frame_count = spectrum.shape[-1]
|
|
123
|
+
time_steps = np.arange(0, frame_count, stretch, dtype=np.float64)
|
|
124
|
+
output = np.empty((*spectrum.shape[:-1], time_steps.size), dtype=spectrum.dtype)
|
|
125
|
+
phase_advance = 2.0 * np.pi * hop * np.arange(expected_bins, dtype=np.float64) / fft_size
|
|
126
|
+
phase = np.angle(spectrum[..., 0])
|
|
127
|
+
for output_index, time in enumerate(time_steps):
|
|
128
|
+
left = min(int(np.floor(time)), frame_count - 1)
|
|
129
|
+
right = min(left + 1, frame_count - 1)
|
|
130
|
+
fraction = time - left
|
|
131
|
+
left_spectrum = spectrum[..., left]
|
|
132
|
+
right_spectrum = spectrum[..., right]
|
|
133
|
+
magnitude = (1.0 - fraction) * np.abs(left_spectrum) + fraction * np.abs(right_spectrum)
|
|
134
|
+
output[..., output_index] = magnitude * np.exp(1j * phase)
|
|
135
|
+
if right != left:
|
|
136
|
+
delta = np.angle(right_spectrum) - np.angle(left_spectrum) - phase_advance
|
|
137
|
+
delta = (delta + np.pi) % (2.0 * np.pi) - np.pi
|
|
138
|
+
phase += phase_advance + delta
|
|
139
|
+
return output
|
audiosig/_validation.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shared validation helpers for public AudioSig operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .exceptions import AudioShapeError, InvalidParameterError
|
|
10
|
+
|
|
11
|
+
FLOAT_DTYPES = (np.dtype(np.float32), np.dtype(np.float64))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_audio(
|
|
15
|
+
audio: np.ndarray,
|
|
16
|
+
*,
|
|
17
|
+
axis: int = -1,
|
|
18
|
+
allow_empty: bool = False,
|
|
19
|
+
) -> tuple[np.ndarray, int]:
|
|
20
|
+
"""Validate an audio array and return it with a normalized axis."""
|
|
21
|
+
if not isinstance(audio, np.ndarray):
|
|
22
|
+
raise AudioShapeError("audio must be a NumPy array")
|
|
23
|
+
if audio.ndim == 0:
|
|
24
|
+
raise AudioShapeError("audio must have at least one dimension")
|
|
25
|
+
if audio.dtype not in FLOAT_DTYPES:
|
|
26
|
+
raise AudioShapeError("audio must have dtype float32 or float64")
|
|
27
|
+
normalized_axis = validate_axis(axis, audio.ndim)
|
|
28
|
+
if audio.shape[normalized_axis] == 0 and not allow_empty:
|
|
29
|
+
raise AudioShapeError("the sample axis cannot be empty")
|
|
30
|
+
if not np.all(np.isfinite(audio)):
|
|
31
|
+
raise AudioShapeError("audio must contain only finite values")
|
|
32
|
+
return audio, normalized_axis
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_positive(value: float, name: str) -> float:
|
|
36
|
+
"""Return a finite positive parameter."""
|
|
37
|
+
if isinstance(value, (bool, np.bool_)):
|
|
38
|
+
raise InvalidParameterError(f"{name} must be finite and positive")
|
|
39
|
+
result = float(value)
|
|
40
|
+
if not np.isfinite(result) or result <= 0:
|
|
41
|
+
raise InvalidParameterError(f"{name} must be finite and positive")
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_finite(value: float, name: str) -> float:
|
|
46
|
+
"""Return a finite scalar, rejecting booleans."""
|
|
47
|
+
if isinstance(value, (bool, np.bool_)):
|
|
48
|
+
raise InvalidParameterError(f"{name} must be finite")
|
|
49
|
+
result = float(value)
|
|
50
|
+
if not np.isfinite(result):
|
|
51
|
+
raise InvalidParameterError(f"{name} must be finite")
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_boolean(value: bool, name: str) -> bool:
|
|
56
|
+
"""Return a validated boolean scalar."""
|
|
57
|
+
if not isinstance(value, (bool, np.bool_)):
|
|
58
|
+
raise InvalidParameterError(f"{name} must be a boolean")
|
|
59
|
+
return bool(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_gain_db(value: float, name: str = "db") -> float:
|
|
63
|
+
"""Validate gain, allowing negative infinity as mute."""
|
|
64
|
+
if isinstance(value, (bool, np.bool_)):
|
|
65
|
+
raise InvalidParameterError(f"{name} must be finite or negative infinity")
|
|
66
|
+
result = float(value)
|
|
67
|
+
if np.isnan(result) or result == np.inf:
|
|
68
|
+
raise InvalidParameterError(f"{name} must be finite or negative infinity")
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_integer(value: int, name: str, *, minimum: int = 1) -> int:
|
|
73
|
+
"""Validate an integer-valued parameter without accepting booleans."""
|
|
74
|
+
if isinstance(value, bool) or not isinstance(value, (int, np.integer)):
|
|
75
|
+
raise InvalidParameterError(f"{name} must be an integer")
|
|
76
|
+
result = int(value)
|
|
77
|
+
if result < minimum:
|
|
78
|
+
raise InvalidParameterError(f"{name} must be at least {minimum}")
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_axis(axis: int, ndim: int) -> int:
|
|
83
|
+
"""Normalize an axis or raise an AudioShapeError."""
|
|
84
|
+
if not isinstance(axis, (int, np.integer)):
|
|
85
|
+
raise AudioShapeError("axis must be an integer")
|
|
86
|
+
normalized = int(axis)
|
|
87
|
+
if normalized < 0:
|
|
88
|
+
normalized += ndim
|
|
89
|
+
if not 0 <= normalized < ndim:
|
|
90
|
+
raise AudioShapeError(f"axis {axis} is invalid for {ndim}-D audio")
|
|
91
|
+
return normalized
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_filter(filter_width: int, rolloff: float) -> tuple[int, float]:
|
|
95
|
+
"""Validate resampling kernel settings."""
|
|
96
|
+
width = validate_integer(filter_width, "filter_width", minimum=1)
|
|
97
|
+
cutoff = float(rolloff)
|
|
98
|
+
if not np.isfinite(cutoff) or not 0 < cutoff <= 1:
|
|
99
|
+
raise InvalidParameterError("rolloff must be finite and in the interval (0, 1]")
|
|
100
|
+
return width, cutoff
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_choices(value: str, choices: Iterable[str], name: str) -> str:
|
|
104
|
+
"""Validate a string enum."""
|
|
105
|
+
if value not in choices:
|
|
106
|
+
raise InvalidParameterError(f"{name} must be one of {tuple(choices)!r}")
|
|
107
|
+
return value
|
audiosig/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g86082542a'
|
audiosig/amplitude.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Simple amplitude-domain transformations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._validation import validate_audio, validate_boolean, validate_gain_db
|
|
8
|
+
from .exceptions import InvalidParameterError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def apply_gain_db(audio: np.ndarray, db: float, *, clip: bool = False) -> np.ndarray:
|
|
12
|
+
"""Apply a decibel gain without modifying ``audio``."""
|
|
13
|
+
source, _ = validate_audio(audio, allow_empty=True)
|
|
14
|
+
gain_db = validate_gain_db(db)
|
|
15
|
+
clip_value = validate_boolean(clip, "clip")
|
|
16
|
+
if gain_db == -np.inf:
|
|
17
|
+
result = np.zeros_like(source)
|
|
18
|
+
else:
|
|
19
|
+
exponent = gain_db / 20.0
|
|
20
|
+
max_exponent = np.log10(np.finfo(np.float64).max)
|
|
21
|
+
if exponent > max_exponent:
|
|
22
|
+
raise InvalidParameterError("db produces an unrepresentable gain")
|
|
23
|
+
multiplier = 10.0**exponent
|
|
24
|
+
if source.size:
|
|
25
|
+
maximum = float(np.max(np.abs(source)))
|
|
26
|
+
dtype_limit = float(np.finfo(source.dtype).max)
|
|
27
|
+
if maximum and multiplier > dtype_limit / maximum:
|
|
28
|
+
raise InvalidParameterError("db produces a non-finite output")
|
|
29
|
+
result = np.multiply(source, multiplier, dtype=source.dtype)
|
|
30
|
+
if clip_value:
|
|
31
|
+
result = np.clip(result, -1.0, 1.0).astype(source.dtype, copy=False)
|
|
32
|
+
return np.array(result, dtype=source.dtype, copy=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def peak_normalize(audio: np.ndarray, *, peak: float = 1.0, eps: float = 1e-12) -> np.ndarray:
|
|
36
|
+
"""Scale audio so its absolute peak equals ``peak`` when audible."""
|
|
37
|
+
source, _ = validate_audio(audio, allow_empty=True)
|
|
38
|
+
target = float(peak)
|
|
39
|
+
tolerance = float(eps)
|
|
40
|
+
if not np.isfinite(target) or target <= 0:
|
|
41
|
+
raise InvalidParameterError("peak must be finite and positive")
|
|
42
|
+
dtype_limit = float(np.finfo(source.dtype).max)
|
|
43
|
+
if target > dtype_limit:
|
|
44
|
+
raise InvalidParameterError(f"peak exceeds the finite range of {source.dtype}")
|
|
45
|
+
if not np.isfinite(tolerance) or tolerance < 0:
|
|
46
|
+
raise InvalidParameterError("eps must be finite and non-negative")
|
|
47
|
+
if source.size == 0:
|
|
48
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
49
|
+
maximum = float(np.max(np.abs(source)))
|
|
50
|
+
if maximum <= tolerance:
|
|
51
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
52
|
+
result = np.asarray(source * (target / maximum), dtype=source.dtype)
|
|
53
|
+
if not np.isfinite(result).all():
|
|
54
|
+
raise InvalidParameterError("peak produces a non-finite output")
|
|
55
|
+
return np.array(result, dtype=source.dtype, copy=True)
|
audiosig/effects.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Speech-oriented time and pitch effects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._resampling import resample_to_length
|
|
8
|
+
from ._spectral import istft, phase_vocoder, stft
|
|
9
|
+
from ._validation import (
|
|
10
|
+
validate_audio,
|
|
11
|
+
validate_filter,
|
|
12
|
+
validate_finite,
|
|
13
|
+
validate_integer,
|
|
14
|
+
validate_positive,
|
|
15
|
+
)
|
|
16
|
+
from .exceptions import InvalidParameterError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def time_stretch(
|
|
20
|
+
audio: np.ndarray,
|
|
21
|
+
rate: float,
|
|
22
|
+
*,
|
|
23
|
+
axis: int = -1,
|
|
24
|
+
n_fft: int = 2048,
|
|
25
|
+
hop_length: int | None = None,
|
|
26
|
+
) -> np.ndarray:
|
|
27
|
+
"""Change duration while approximately preserving pitch.
|
|
28
|
+
|
|
29
|
+
Rates above one are faster and shorter; rates below one are slower and
|
|
30
|
+
longer. This phase-vocoder implementation is intended for speech and
|
|
31
|
+
moderate prosody changes.
|
|
32
|
+
"""
|
|
33
|
+
source, normalized_axis = validate_audio(audio, axis=axis, allow_empty=True)
|
|
34
|
+
stretch = validate_positive(rate, "rate")
|
|
35
|
+
fft_size = validate_integer(n_fft, "n_fft", minimum=2)
|
|
36
|
+
hop = validate_integer(hop_length if hop_length is not None else fft_size // 4, "hop_length")
|
|
37
|
+
if hop > fft_size:
|
|
38
|
+
raise InvalidParameterError("hop_length must not exceed n_fft")
|
|
39
|
+
input_length = source.shape[normalized_axis]
|
|
40
|
+
if input_length == 0:
|
|
41
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
42
|
+
requested_length = input_length / stretch
|
|
43
|
+
if not np.isfinite(requested_length) or requested_length > np.iinfo(np.intp).max:
|
|
44
|
+
raise InvalidParameterError("requested stretched length is too large")
|
|
45
|
+
target_length = max(1, round(requested_length))
|
|
46
|
+
if stretch == 1.0:
|
|
47
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
48
|
+
moved = np.moveaxis(source, normalized_axis, -1)
|
|
49
|
+
padded_length = max(input_length, fft_size)
|
|
50
|
+
if padded_length != input_length:
|
|
51
|
+
moved = np.pad(moved, [(0, 0)] * (moved.ndim - 1) + [(0, padded_length - input_length)])
|
|
52
|
+
spectrum = stft(moved, n_fft=fft_size, hop_length=hop, center=True)
|
|
53
|
+
transformed = phase_vocoder(spectrum, rate=stretch, hop_length=hop, n_fft=fft_size)
|
|
54
|
+
result = istft(
|
|
55
|
+
transformed,
|
|
56
|
+
n_fft=fft_size,
|
|
57
|
+
hop_length=hop,
|
|
58
|
+
length=target_length,
|
|
59
|
+
center=True,
|
|
60
|
+
dtype=source.dtype,
|
|
61
|
+
)
|
|
62
|
+
return np.moveaxis(result, -1, normalized_axis).astype(source.dtype, copy=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pitch_shift(
|
|
66
|
+
audio: np.ndarray,
|
|
67
|
+
*,
|
|
68
|
+
sample_rate: int,
|
|
69
|
+
semitones: float,
|
|
70
|
+
bins_per_octave: int = 12,
|
|
71
|
+
axis: int = -1,
|
|
72
|
+
n_fft: int = 2048,
|
|
73
|
+
hop_length: int | None = None,
|
|
74
|
+
filter_width: int = 32,
|
|
75
|
+
) -> np.ndarray:
|
|
76
|
+
"""Shift pitch by semitones while retaining the exact input duration."""
|
|
77
|
+
source, normalized_axis = validate_audio(audio, axis=axis, allow_empty=True)
|
|
78
|
+
validate_positive(sample_rate, "sample_rate")
|
|
79
|
+
bins = validate_integer(bins_per_octave, "bins_per_octave")
|
|
80
|
+
shift = validate_finite(semitones, "semitones")
|
|
81
|
+
fft_size = validate_integer(n_fft, "n_fft", minimum=2)
|
|
82
|
+
hop = validate_integer(hop_length if hop_length is not None else fft_size // 4, "hop_length")
|
|
83
|
+
if hop > fft_size:
|
|
84
|
+
raise InvalidParameterError("hop_length must not exceed n_fft")
|
|
85
|
+
validate_filter(filter_width, 0.945)
|
|
86
|
+
if source.shape[normalized_axis] == 0:
|
|
87
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
88
|
+
if shift == 0.0:
|
|
89
|
+
return np.array(source, dtype=source.dtype, copy=True)
|
|
90
|
+
octaves = shift / bins
|
|
91
|
+
max_octaves = np.log2(np.finfo(np.float64).max)
|
|
92
|
+
min_octaves = np.log2(np.nextafter(0.0, 1.0))
|
|
93
|
+
if not min_octaves <= octaves <= max_octaves:
|
|
94
|
+
raise InvalidParameterError("semitones produces an unrepresentable pitch ratio")
|
|
95
|
+
ratio = float(np.exp2(octaves))
|
|
96
|
+
if not np.isfinite(ratio) or ratio <= 0.0:
|
|
97
|
+
raise InvalidParameterError("semitones produces an invalid pitch ratio")
|
|
98
|
+
rate = 1.0 / ratio
|
|
99
|
+
if not np.isfinite(rate) or rate <= 0.0:
|
|
100
|
+
raise InvalidParameterError("semitones produces an invalid pitch rate")
|
|
101
|
+
stretched = time_stretch(
|
|
102
|
+
source,
|
|
103
|
+
rate=rate,
|
|
104
|
+
axis=normalized_axis,
|
|
105
|
+
n_fft=n_fft,
|
|
106
|
+
hop_length=hop_length,
|
|
107
|
+
)
|
|
108
|
+
return resample_to_length(
|
|
109
|
+
stretched,
|
|
110
|
+
source.shape[normalized_axis],
|
|
111
|
+
axis=normalized_axis,
|
|
112
|
+
filter_width=filter_width,
|
|
113
|
+
)
|