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 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
@@ -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
@@ -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
+ )