Skip to content

Editing and effects

kudio.effects.edit

Everyday editing moves: fades, reversal, DC removal.

None of these are clever. They are here because every audio editor has them and because doing them by hand is where the off-by-one lives — a fade that reaches zero one sample early leaves a click exactly where the fade was meant to prevent one.

Functions:

Name Description
fade_in

Ramp the first duration seconds up from silence.

fade_out

Ramp the last duration seconds down to silence.

fade

Fade both ends in one call.

crossfade

Join two clips through a short overlap, instead of butting them together.

splice

Join several clips end to end, crossfading every seam.

reverse

Play the waveform backwards. Multi-channel audio keeps its channels.

remove_dc

Subtract the mean, per channel.

DEFAULT_CROSSFADE module-attribute

DEFAULT_CROSSFADE = 0.005

Enough to remove the step at a cut, short enough that nothing is heard to happen. Below ~2 ms the click starts coming back.

fade_in

fade_in(
    y: ndarray,
    sr: int,
    duration: float = 0.01,
    shape: str = "linear",
) -> ndarray

Ramp the first duration seconds up from silence.

>>> y = kudio.fade_in(y, sr, duration=0.05, shape='cosine')

A 10 ms default is the usual anti-click amount: long enough to remove the step at the start of a cut, short enough not to be heard as a fade.

Source code in kudio/effects/edit.py
def fade_in(y: np.ndarray, sr: int, duration: float = 0.01,
            shape: str = 'linear') -> np.ndarray:
    """Ramp the first *duration* seconds up from silence.

    >>> y = kudio.fade_in(y, sr, duration=0.05, shape='cosine')

    A 10 ms default is the usual anti-click amount: long enough to remove the
    step at the start of a cut, short enough not to be heard as a fade.
    """
    y = np.asarray(y)
    n = _fade_samples(y.shape[0], duration, sr, "fade_in")
    if n == 0 or y.size == 0:
        return y.copy()
    out = y.astype(np.float64, copy=True)
    curve = _ramp(n, shape)
    out[:n] *= curve if y.ndim == 1 else curve[:, None]
    return _as_type(out, y.dtype)

fade_out

fade_out(
    y: ndarray,
    sr: int,
    duration: float = 0.01,
    shape: str = "linear",
) -> ndarray

Ramp the last duration seconds down to silence.

Source code in kudio/effects/edit.py
def fade_out(y: np.ndarray, sr: int, duration: float = 0.01,
             shape: str = 'linear') -> np.ndarray:
    """Ramp the last *duration* seconds down to silence."""
    y = np.asarray(y)
    n = _fade_samples(y.shape[0], duration, sr, "fade_out")
    if n == 0 or y.size == 0:
        return y.copy()
    out = y.astype(np.float64, copy=True)
    curve = _ramp(n, shape)[::-1]
    out[-n:] *= curve if y.ndim == 1 else curve[:, None]
    return _as_type(out, y.dtype)

fade

fade(
    y: ndarray,
    sr: int,
    fade_in_s: float = 0.01,
    fade_out_s: float = 0.01,
    shape: str = "linear",
) -> ndarray

Fade both ends in one call.

The two ramps are checked against the clip length together, so a fade in and a fade out that overlap is an error rather than a silently multiplied middle.

Source code in kudio/effects/edit.py
def fade(y: np.ndarray, sr: int, fade_in_s: float = 0.01,
         fade_out_s: float = 0.01, shape: str = 'linear') -> np.ndarray:
    """Fade both ends in one call.

    The two ramps are checked against the clip length **together**, so a fade
    in and a fade out that overlap is an error rather than a silently
    multiplied middle.
    """
    y = np.asarray(y)
    n_in = _fade_samples(y.shape[0], fade_in_s, sr, "fade_in")
    n_out = _fade_samples(y.shape[0], fade_out_s, sr, "fade_out")
    if n_in + n_out > y.shape[0]:
        raise FeatureError(
            f"fades overlap: {fade_in_s}s + {fade_out_s}s exceeds the clip "
            f"({y.shape[0] / sr:.3f}s)")
    return fade_out(fade_in(y, sr, fade_in_s, shape), sr, fade_out_s, shape)

crossfade

crossfade(
    first: ndarray,
    second: ndarray,
    sr: int,
    duration: float = DEFAULT_CROSSFADE,
    shape: str = "equal_power",
) -> ndarray

Join two clips through a short overlap, instead of butting them together.

A hard splice puts a step in the waveform, and a step is a click. Blending the last duration of first into the first duration of second removes it.

>>> joined = kudio.crossfade(before, after, sr)      # 5 ms

The result is shorter than the two parts by duration — the overlap is shared, not inserted. That is not a rounding error to be papered over: a caller tracking positions in the audio has to account for it, and hiding it would make those positions silently wrong.

Parameters:

Name Type Description Default
shape str

'equal_power' (sine/cosine) holds the perceived level steady when the two sides are unrelated — two different takes, two different sources. 'linear' holds the sum steady, which is right when both sides are the same signal continuing; equal-power on alike material bumps the level by up to 3 dB. splice therefore defaults to linear and this defaults to equal-power.

'equal_power'
Source code in kudio/effects/edit.py
def crossfade(first: np.ndarray, second: np.ndarray, sr: int,
              duration: float = DEFAULT_CROSSFADE,
              shape: str = 'equal_power') -> np.ndarray:
    """Join two clips through a short overlap, instead of butting them together.

    A hard splice puts a step in the waveform, and a step is a click. Blending
    the last *duration* of *first* into the first *duration* of *second*
    removes it.

    >>> joined = kudio.crossfade(before, after, sr)      # 5 ms

    **The result is shorter than the two parts** by *duration* — the overlap is
    shared, not inserted. That is not a rounding error to be papered over: a
    caller tracking positions in the audio has to account for it, and hiding it
    would make those positions silently wrong.

    :param shape: ``'equal_power'`` (sine/cosine) holds the perceived level
        steady when the two sides are **unrelated** — two different takes, two
        different sources. ``'linear'`` holds the *sum* steady, which is right
        when both sides are the same signal continuing; equal-power on alike
        material bumps the level by up to 3 dB. :func:`splice` therefore
        defaults to linear and this defaults to equal-power.
    """
    first = np.asarray(first)
    second = np.asarray(second)
    if first.size == 0:
        return second.copy()
    if second.size == 0:
        return first.copy()
    if duration < 0:
        raise FeatureError(f"crossfade duration must be >= 0, got {duration}")

    n = int(round(duration * sr))
    n = min(n, first.shape[0], second.shape[0])
    if n <= 0:
        return np.concatenate([first, second])
    if shape not in ('equal_power', 'linear'):
        raise FeatureError(
            f"shape must be 'equal_power' or 'linear', got {shape!r}")

    t = np.linspace(0.0, 1.0, n)
    if shape == 'equal_power':
        down, up = np.cos(t * np.pi / 2.0), np.sin(t * np.pi / 2.0)
    else:
        down, up = 1.0 - t, t
    if first.ndim > 1:
        down, up = down[:, None], up[:, None]

    blended = first[-n:] * down + second[:n] * up
    return _as_type(np.concatenate([first[:-n], blended, second[n:]]), first.dtype)

splice

splice(
    parts: Sequence[ndarray],
    sr: int,
    duration: float = DEFAULT_CROSSFADE,
    shape: str = "linear",
) -> ndarray

Join several clips end to end, crossfading every seam.

>>> merged = kudio.splice([before, replacement, after], sr)

Empty parts are skipped rather than producing a seam with nothing on one side. The result is shorter than the sum of the parts by one duration per seam; crossfade explains why that matters.

The default shape differs from crossfade's, deliberately. A splice joins two moments of the same recording, which are usually alike, and equal-power over-shoots on alike material: measured on a sine cut at a period boundary, equal-power raised the peak from 0.500 to 0.567 while linear left it at 0.500. A crossfade between genuinely different material is the other case, and defaults the other way.

Source code in kudio/effects/edit.py
def splice(parts: Sequence[np.ndarray], sr: int, duration: float = DEFAULT_CROSSFADE,
           shape: str = 'linear') -> np.ndarray:
    """Join several clips end to end, crossfading every seam.

    >>> merged = kudio.splice([before, replacement, after], sr)

    Empty parts are skipped rather than producing a seam with nothing on one
    side. The result is shorter than the sum of the parts by one *duration* per
    seam; :func:`crossfade` explains why that matters.

    **The default shape differs from `crossfade`'s, deliberately.** A splice
    joins two moments of the *same* recording, which are usually alike, and
    equal-power over-shoots on alike material: measured on a sine cut at a
    period boundary, equal-power raised the peak from 0.500 to 0.567 while
    linear left it at 0.500. A crossfade between genuinely different material
    is the other case, and defaults the other way.
    """
    usable = [np.asarray(p) for p in parts if np.asarray(p).size]
    if not usable:
        return np.zeros(0, dtype=np.float32)
    merged = usable[0].copy()                  # never hand back the caller's array
    for part in usable[1:]:
        merged = crossfade(merged, part, sr, duration, shape)
    return merged

reverse

reverse(y: ndarray) -> ndarray

Play the waveform backwards. Multi-channel audio keeps its channels.

Source code in kudio/effects/edit.py
def reverse(y: np.ndarray) -> np.ndarray:
    """Play the waveform backwards. Multi-channel audio keeps its channels."""
    y = np.asarray(y)
    return np.ascontiguousarray(y[::-1])

remove_dc

remove_dc(y: ndarray) -> ndarray

Subtract the mean, per channel.

A constant offset costs headroom and shows up as an audible thump at every cut. This removes a constant one; an offset that drifts over a long recording is a very low frequency, and kudio.highpass is the tool for that.

Source code in kudio/effects/edit.py
def remove_dc(y: np.ndarray) -> np.ndarray:
    """Subtract the mean, per channel.

    A constant offset costs headroom and shows up as an audible thump at every
    cut. This removes a *constant* one; an offset that drifts over a long
    recording is a very low frequency, and :func:`kudio.highpass` is the tool
    for that.
    """
    y = np.asarray(y)
    if y.size == 0:
        return y.copy()
    # accumulate in float64: a float32 mean over a long clip carries enough
    # rounding error to leave behind the offset it was asked to remove
    offset = np.mean(y, axis=0, dtype=np.float64, keepdims=True)
    return _as_type(y - offset, y.dtype)

kudio.effects.filters

Butterworth band filters.

The everyday shaping moves — drop the rumble below 80 Hz, throw away everything above the band you actually care about, isolate one. wavelet_low_pass_filter in kudio.enhance is a denoising algorithm that happens to be low-pass; this is the plain filter, and the two are not substitutes.

Filtering is zero-phase by default (scipy.signal.sosfiltfilt): the signal is run forwards and backwards, so nothing is shifted in time. That matters when a filtered copy has to line up sample-for-sample with the original — an A/B, a spectrogram overlay, a training target. Pass zero_phase=False for the causal, real-time-shaped version.

Functions:

Name Description
band_filter

Butterworth filter of any of the four kinds.

highpass

Remove everything below cutoff Hz — rumble, handling noise, DC drift.

lowpass

Remove everything above cutoff Hz — hiss, and anything past the band

bandpass

Keep only low..high Hz. The default is the telephone band.

bandstop

Remove low..high Hz. The default straddles 50 Hz mains hum; use

band_filter

band_filter(
    y: ndarray,
    sr: int,
    cutoff: Union[float, Sequence[float]],
    btype: str = "highpass",
    order: int = 4,
    zero_phase: bool = True,
) -> ndarray

Butterworth filter of any of the four kinds.

Parameters:

Name Type Description Default
cutoff Union[float, Sequence[float]]

one frequency in Hz, or (low, high) for band types.

required
order int

filter order. Zero-phase filtering applies it twice, so the effective roll-off is 2 * order.

4
zero_phase bool

forwards-and-backwards (no delay) when True.

A (frames, channels) array is filtered along its frames, each channel on its own. Integer audio comes back in its own type, rounded and held to the type's range: a filter overshoots at an edge, and cast straight back a near-full-scale int16 square wave through a 2 kHz low-pass had 1194 samples wrap round to the other sign.

True
Source code in kudio/effects/filters.py
def band_filter(y: np.ndarray, sr: int, cutoff: Union[float, Sequence[float]],
                btype: str = 'highpass',
                order: int = 4, zero_phase: bool = True) -> np.ndarray:
    """Butterworth filter of any of the four kinds.

    :param cutoff: one frequency in Hz, or ``(low, high)`` for band types.
    :param order: filter order. Zero-phase filtering applies it twice, so the
        effective roll-off is ``2 * order``.
    :param zero_phase: forwards-and-backwards (no delay) when True.

    A ``(frames, channels)`` array is filtered along its frames, each channel
    on its own. Integer audio comes back in its own type, rounded and held to
    the type's range: a filter overshoots at an edge, and cast straight back a
    near-full-scale int16 square wave through a 2 kHz low-pass had 1194
    samples wrap round to the other sign.
    """
    if btype not in _BTYPES:
        raise FeatureError(f"btype must be one of {_BTYPES}, got {btype!r}")
    if order < 1:
        raise FeatureError(f"order must be >= 1, got {order}")

    wn = _check_cutoff(cutoff, sr, btype)
    y = np.asarray(y)
    if y.ndim > 2:
        raise FeatureError(f"expected (frames,) or (frames, channels), got shape {y.shape}")
    if y.size == 0:
        return y.copy()

    # axis=0 throughout: scipy filters the last axis by default, which on a
    # channels-last array is the channel axis
    sos = butter(order, wn, btype=btype, fs=sr, output='sos')
    if not zero_phase:
        return _back(sosfilt(sos, y, axis=0), y.dtype)

    # sosfiltfilt reflects the signal at both ends by 3 * n_sections * 2
    # samples; a clip shorter than that cannot be padded and scipy raises.
    # Shrinking the pad is better than refusing to filter a short selection.
    padlen = min(3 * (2 * len(sos) + 1), y.shape[0] - 1)
    if padlen < 1:
        raise FeatureError(
            f"clip is too short to filter ({y.shape[0]} samples)")
    return _back(sosfiltfilt(sos, y, axis=0, padlen=padlen), y.dtype)

highpass

highpass(
    y: ndarray,
    sr: int,
    cutoff: float = 80.0,
    order: int = 4,
    zero_phase: bool = True,
) -> ndarray

Remove everything below cutoff Hz — rumble, handling noise, DC drift.

>>> clean = kudio.highpass(y, sr, cutoff=80)
Source code in kudio/effects/filters.py
def highpass(y: np.ndarray, sr: int, cutoff: float = 80.0, order: int = 4,
             zero_phase: bool = True) -> np.ndarray:
    """Remove everything below *cutoff* Hz — rumble, handling noise, DC drift.

    >>> clean = kudio.highpass(y, sr, cutoff=80)
    """
    return band_filter(y, sr, cutoff, 'highpass', order, zero_phase)

lowpass

lowpass(
    y: ndarray,
    sr: int,
    cutoff: float = 8000.0,
    order: int = 4,
    zero_phase: bool = True,
) -> ndarray

Remove everything above cutoff Hz — hiss, and anything past the band a downstream model was trained on.

A cutoff at or above Nyquist leaves nothing to remove, so the clip comes back unchanged (a copy). That is also what the 8 kHz default does to audio at 16 kHz and below.

Source code in kudio/effects/filters.py
def lowpass(y: np.ndarray, sr: int, cutoff: float = 8000.0, order: int = 4,
            zero_phase: bool = True) -> np.ndarray:
    """Remove everything above *cutoff* Hz — hiss, and anything past the band
    a downstream model was trained on.

    A *cutoff* at or above Nyquist leaves nothing to remove, so the clip comes
    back unchanged (a copy). That is also what the 8 kHz default does to audio
    at 16 kHz and below.
    """
    if not isinstance(cutoff, (tuple, list)) and cutoff >= sr / 2.0:
        return np.array(y, copy=True)
    return band_filter(y, sr, cutoff, 'lowpass', order, zero_phase)

bandpass

bandpass(
    y: ndarray,
    sr: int,
    low: float = 300.0,
    high: float = 3400.0,
    order: int = 4,
    zero_phase: bool = True,
) -> ndarray

Keep only low..high Hz. The default is the telephone band.

Source code in kudio/effects/filters.py
def bandpass(y: np.ndarray, sr: int, low: float = 300.0, high: float = 3400.0,
             order: int = 4, zero_phase: bool = True) -> np.ndarray:
    """Keep only ``low..high`` Hz. The default is the telephone band."""
    return band_filter(y, sr, (low, high), 'bandpass', order, zero_phase)

bandstop

bandstop(
    y: ndarray,
    sr: int,
    low: float = 45.0,
    high: float = 55.0,
    order: int = 4,
    zero_phase: bool = True,
) -> ndarray

Remove low..high Hz. The default straddles 50 Hz mains hum; use (55, 65) where the mains run at 60.

Source code in kudio/effects/filters.py
def bandstop(y: np.ndarray, sr: int, low: float = 45.0, high: float = 55.0,
             order: int = 4, zero_phase: bool = True) -> np.ndarray:
    """Remove ``low..high`` Hz. The default straddles 50 Hz mains hum; use
    ``(55, 65)`` where the mains run at 60."""
    return band_filter(y, sr, (low, high), 'bandstop', order, zero_phase)

kudio.effects.silence

Silence trimming and splitting.

Measured down from the loudest frame, so it suits a sound against a quiet background: a studio take, a knock in a still room. Speech over a room that is never quiet is kudio.vad's job.

Functions:

Name Description
trim_silence

Trim leading/trailing silence below top_db dB under peak.

split_on_silence

Split y into non-silent segments (each below-top_db gap is a cut).

trim_silence

trim_silence(
    y: ndarray,
    top_db: float = 30.0,
    frame_length: int = 2048,
    hop_length: int = 512,
) -> Tuple[ndarray, Tuple[int, int]]

Trim leading/trailing silence below top_db dB under peak.

Returns (trimmed_waveform, (start_sample, end_sample)). The waveform is a copy, and a (frames, channels) array is trimmed along its frames.

Source code in kudio/effects/silence.py
def trim_silence(y: np.ndarray, top_db: float = 30.0,
                 frame_length: int = 2048, hop_length: int = 512
                 ) -> Tuple[np.ndarray, Tuple[int, int]]:
    """Trim leading/trailing silence below ``top_db`` dB under peak.

    Returns ``(trimmed_waveform, (start_sample, end_sample))``. The waveform is
    a copy, and a ``(frames, channels)`` array is trimmed along its frames.
    """
    y = np.asarray(y)
    # found on the float reading, where uint8 silence is 0 rather than 128,
    # and cut from y itself, so the clip comes back in its own type
    _, index = librosa.effects.trim(
        _time_last(as_float(y)), top_db=top_db, frame_length=frame_length,
        hop_length=hop_length)
    start, end = int(index[0]), int(index[1])
    return y[start:end].copy(), (start, end)

split_on_silence

split_on_silence(
    y: ndarray,
    top_db: float = 30.0,
    frame_length: int = 2048,
    hop_length: int = 512,
) -> List[ndarray]

Split y into non-silent segments (each below-top_db gap is a cut).

Each segment is a copy; a (frames, channels) array is split along its frames.

Source code in kudio/effects/silence.py
def split_on_silence(y: np.ndarray, top_db: float = 30.0,
                     frame_length: int = 2048, hop_length: int = 512
                     ) -> List[np.ndarray]:
    """Split *y* into non-silent segments (each below-``top_db`` gap is a cut).

    Each segment is a copy; a ``(frames, channels)`` array is split along its
    frames.
    """
    y = np.asarray(y)
    intervals = librosa.effects.split(
        _time_last(as_float(y)), top_db=top_db, frame_length=frame_length,
        hop_length=hop_length)
    return [y[start:end].copy() for start, end in intervals]

kudio.effects.augment

Waveform / spectrogram data augmentation.

Every function is pure (returns a new array) and, where randomness is involved, accepts a seed for reproducibility.

Functions:

Name Description
time_stretch

Speed up (rate>1) or slow down (rate<1) without changing pitch.

pitch_shift

Shift pitch by n_steps semitones without changing duration.

gain

Apply a fixed gain in decibels.

normalize

Scale so the loudest sample sits at peak.

normalize_db

Scale so the peak sits at dbfs decibels below full scale.

random_gain

Apply a random gain sampled uniformly from db_range dB.

add_noise_snr

Mix noise into y at the target signal-to-noise ratio (dB).

reverb

Cheap synthetic reverb via an exponentially-decaying comb filter: an

spec_augment

SpecAugment: mask random frequency bands and time spans of a spectrogram.

time_stretch

time_stretch(y: ndarray, rate: float) -> ndarray

Speed up (rate>1) or slow down (rate<1) without changing pitch.

A (frames, channels) array is stretched channel by channel, and integer audio comes back in its own type, as gain does it.

Source code in kudio/effects/augment.py
def time_stretch(y: np.ndarray, rate: float) -> np.ndarray:
    """Speed up (``rate>1``) or slow down (``rate<1``) without changing pitch.

    A ``(frames, channels)`` array is stretched channel by channel, and
    integer audio comes back in its own type, as :func:`gain` does it.
    """
    if rate <= 0:
        raise ValueError(f"rate must be > 0, got {rate}")
    x, back = _to_float(y)
    return back(_each_channel(
        x, lambda c: _quietly(librosa.effects.time_stretch, c, rate=rate)))

pitch_shift

pitch_shift(y: ndarray, sr: int, n_steps: float) -> ndarray

Shift pitch by n_steps semitones without changing duration.

A (frames, channels) array is shifted channel by channel, and integer audio comes back in its own type, as gain does it.

Source code in kudio/effects/augment.py
def pitch_shift(y: np.ndarray, sr: int, n_steps: float) -> np.ndarray:
    """Shift pitch by *n_steps* semitones without changing duration.

    A ``(frames, channels)`` array is shifted channel by channel, and integer
    audio comes back in its own type, as :func:`gain` does it.
    """
    x, back = _to_float(y)
    return back(_each_channel(
        x, lambda c: _quietly(librosa.effects.pitch_shift, c, sr=sr, n_steps=n_steps)))

gain

gain(y: ndarray, db: float) -> ndarray

Apply a fixed gain in decibels.

Integer audio stays in its type, rounded and clipped at full scale rather than wrapped round, which would turn an int16 20000 at +6 dB into -25631.

Source code in kudio/effects/augment.py
def gain(y: np.ndarray, db: float) -> np.ndarray:
    """Apply a fixed gain in decibels.

    Integer audio stays in its type, rounded and clipped at full scale rather
    than wrapped round, which would turn an int16 20000 at +6 dB into -25631.
    """
    x, back = _to_float(y)
    return back(x * (10.0 ** (db / 20.0)))

normalize

normalize(y: ndarray, peak: float = 0.99) -> ndarray

Scale so the loudest sample sits at peak.

Silence is returned unchanged rather than divided by zero — the guard everyone writes inline and someone eventually forgets. For integer audio peak is a fraction of the type's full scale, as it is for float, and the result keeps the input's type.

Source code in kudio/effects/augment.py
def normalize(y: np.ndarray, peak: float = 0.99) -> np.ndarray:
    """Scale so the loudest sample sits at *peak*.

    Silence is returned unchanged rather than divided by zero — the guard
    everyone writes inline and someone eventually forgets. For integer audio
    *peak* is a fraction of the type's full scale, as it is for float, and the
    result keeps the input's type.
    """
    if peak <= 0:
        raise ValueError(f"peak must be > 0, got {peak}")
    y = np.asarray(y)
    x, back = _to_float(y)
    current = float(np.max(np.abs(x))) if x.size else 0.0
    if current == 0.0:
        return y.copy()
    return back(x * (peak / current))

normalize_db

normalize_db(y: ndarray, dbfs: float = -1.0) -> ndarray

Scale so the peak sits at dbfs decibels below full scale.

normalize_db(y, -1.0) is normalize(y, 10 ** (-1 / 20)); use whichever unit the rest of the pipeline speaks.

Source code in kudio/effects/augment.py
def normalize_db(y: np.ndarray, dbfs: float = -1.0) -> np.ndarray:
    """Scale so the peak sits at *dbfs* decibels below full scale.

    ``normalize_db(y, -1.0)`` is ``normalize(y, 10 ** (-1 / 20))``; use
    whichever unit the rest of the pipeline speaks.
    """
    if dbfs > 0:
        raise ValueError(f"dbfs must be <= 0 (full scale), got {dbfs}")
    return normalize(y, peak=10.0 ** (dbfs / 20.0))

random_gain

random_gain(
    y: ndarray,
    db_range: Tuple[float, float] = (-6.0, 6.0),
    seed: Optional[int] = None,
) -> ndarray

Apply a random gain sampled uniformly from db_range dB.

Source code in kudio/effects/augment.py
def random_gain(y: np.ndarray, db_range: Tuple[float, float] = (-6.0, 6.0),
                seed: Optional[int] = None) -> np.ndarray:
    """Apply a random gain sampled uniformly from *db_range* dB."""
    rng = np.random.default_rng(seed)
    return gain(y, rng.uniform(*db_range))

add_noise_snr

add_noise_snr(
    y: ndarray,
    noise: ndarray,
    snr_db: float,
    seed: Optional[int] = None,
) -> ndarray

Mix noise into y at the target signal-to-noise ratio (dB).

noise is tiled/cropped (with a random offset) to match len(y). Both are mono.

Raises:

Type Description
FeatureError

for noise with no variation in the stretch used -- silence or a constant. Its mean is removed first, so nothing would be left to scale, and the result would be y back with no noise in it at any SNR. Synthesizer refuses the same file for the same reason.

Source code in kudio/effects/augment.py
def add_noise_snr(y: np.ndarray, noise: np.ndarray, snr_db: float,
                  seed: Optional[int] = None) -> np.ndarray:
    """Mix *noise* into *y* at the target signal-to-noise ratio (dB).

    *noise* is tiled/cropped (with a random offset) to match ``len(y)``. Both
    are mono.

    :raises FeatureError: for noise with no variation in the stretch used --
        silence or a constant. Its mean is removed first, so nothing would be
        left to scale, and the result would be *y* back with no noise in it at
        any SNR. Synthesizer refuses the same file for the same reason.
    """
    y, noise = np.asarray(y), np.asarray(noise)
    if y.ndim != 1 or noise.ndim != 1:
        raise FeatureError(f"add_noise_snr mixes mono signals, got shapes "
                           f"{y.shape} and {noise.shape}")
    if not noise.size:
        raise FeatureError("noise is empty")
    rng = np.random.default_rng(seed)
    if len(noise) < len(y):
        noise = np.tile(noise, len(y) // len(noise) + 1)[:len(y)]
    elif len(noise) > len(y):
        off = rng.integers(0, len(noise) - len(y) + 1)
        noise = noise[off:off + len(y)]
    # integers in float before any arithmetic: int16 squared wraps round in
    # int16, and a quarter-scale int16 tone mixed at 10 dB came out at 55.9
    if np.issubdtype(noise.dtype, np.integer):
        noise = noise.astype(np.float64)
    if not np.ptp(noise) > 0:
        raise FeatureError("the noise has no variation (silence or a constant), "
                           "so there is nothing to mix in at any SNR")
    x, back = _to_float(y)
    noise = noise - np.mean(noise)
    sig_pwr = np.mean(x ** 2)
    noise_pwr = np.mean(noise ** 2) + 1e-12
    scale = np.sqrt(sig_pwr / (noise_pwr * 10 ** (snr_db / 10)))
    return back(x + scale * noise)

reverb

reverb(
    y: ndarray,
    sr: int,
    decay: float = 0.3,
    delay_ms: float = 50.0,
) -> ndarray

Cheap synthetic reverb via an exponentially-decaying comb filter: an echo every delay_ms, each decay times the one before. For a room that behaves like a room, use kudio.rir with kudio.apply_rir.

A (frames, channels) array gets the same echoes on every channel.

Source code in kudio/effects/augment.py
def reverb(y: np.ndarray, sr: int, decay: float = 0.3,
           delay_ms: float = 50.0) -> np.ndarray:
    """Cheap synthetic reverb via an exponentially-decaying comb filter: an
    echo every *delay_ms*, each *decay* times the one before. For a room that
    behaves like a room, use :func:`kudio.rir` with :func:`kudio.apply_rir`.

    A ``(frames, channels)`` array gets the same echoes on every channel.
    """
    delay = max(1, int(sr * delay_ms / 1000.0))
    ir_len = delay * 8
    impulse = np.zeros(ir_len, dtype=np.float32)
    t = 0
    amp = 1.0
    while t < ir_len:
        impulse[t] += amp
        amp *= decay
        t += delay
    # integer audio in float first: abs(-32768) stays -32768 in int16, so a
    # sample pinned there was missed as the peak (20000 came back, not 32768)
    x, back = _to_float(y)
    wet = _each_channel(x, lambda c: fftconvolve(c, impulse)[:len(c)])
    peak = np.max(np.abs(wet)) + 1e-12
    return back(wet / peak * np.max(np.abs(x)))

spec_augment

spec_augment(
    spec: ndarray,
    n_freq_masks: int = 1,
    n_time_masks: int = 1,
    freq_mask_width: int = 8,
    time_mask_width: int = 16,
    mask_value: Optional[float] = None,
    seed: Optional[int] = None,
) -> ndarray

SpecAugment: mask random frequency bands and time spans of a spectrogram.

spec is (frames, bins), the layout every spectrogram in kudio comes in (or squeezable to it). Returns a masked copy.

Parameters:

Name Type Description Default
mask_value Optional[float]

what a masked cell becomes. None (the default) uses the spectrogram's mean, which is SpecAugment's choice: 0 is the mean of a standardised spectrogram, but on kudio's log10 power it is a power of 1, louder than most of the cells it would hide.

None
Source code in kudio/effects/augment.py
def spec_augment(spec: np.ndarray, n_freq_masks: int = 1, n_time_masks: int = 1,
                 freq_mask_width: int = 8, time_mask_width: int = 16,
                 mask_value: Optional[float] = None,
                 seed: Optional[int] = None) -> np.ndarray:
    """SpecAugment: mask random frequency bands and time spans of a spectrogram.

    *spec* is ``(frames, bins)``, the layout every spectrogram in kudio comes
    in (or squeezable to it). Returns a masked copy.

    :param mask_value: what a masked cell becomes. ``None`` (the default) uses
        the spectrogram's mean, which is SpecAugment's choice: 0 is the mean of
        a standardised spectrogram, but on kudio's log10 power it is a power of
        1, louder than most of the cells it would hide.
    """
    rng = np.random.default_rng(seed)
    out = np.array(spec, copy=True)
    work = out if out.ndim == 2 else out.squeeze()
    if work.ndim != 2:
        raise ValueError(f"spec must be 2-D after squeeze, got shape {spec.shape}")
    n_time, n_freq = work.shape
    fill = float(np.mean(work)) if mask_value is None else mask_value

    for _ in range(n_freq_masks):
        w = int(rng.integers(0, min(freq_mask_width, n_freq) + 1))
        if w:
            f0 = int(rng.integers(0, n_freq - w + 1))
            work[:, f0:f0 + w] = fill
    for _ in range(n_time_masks):
        w = int(rng.integers(0, min(time_mask_width, n_time) + 1))
        if w:
            t0 = int(rng.integers(0, n_time - w + 1))
            work[t0:t0 + w, :] = fill
    return work.reshape(spec.shape)