Source code for music.core.synths.bandlimited

"""Opt-in band-limited wavetable oscillator.

The MASS-compatible :func:`music.note` intentionally reads an unfiltered
table. This module leaves its samples unchanged: filtering a table according
to the *fundamental* would alter that reference behavior. The static-pitch
oscillator here removes harmonics above Nyquist before wavetable lookup,
then linearly interpolates rather than truncating lookup positions.

It is not a cure for aliasing caused by later nonlinear processing,
modulation, clipping, or abrupt pulse gates. Dynamic pitch sweeps need
frequency-dependent filtering, not this constant-frequency oscillator.
"""

from functools import lru_cache

import numpy as np
from numpy.typing import NDArray

from ...utils import waveform_table


@lru_cache(maxsize=128)
def _filtered_table(kind: str, freq: float, sample_rate: int,
                    table_size: int, rolloff_hz: float) -> NDArray[np.float64]:
    """FFT-low-pass one periodic primary waveform, cached by pitch."""
    spectrum = np.fft.rfft(waveform_table(kind, table_size))
    harmonic_hz = np.arange(len(spectrum)) * freq
    nyquist = sample_rate / 2
    if rolloff_hz:
        gain = np.clip((nyquist - harmonic_hz) / rolloff_hz, 0, 1)
    else:
        gain = (harmonic_hz < nyquist).astype(float)
    spectrum *= gain
    spectrum[0] = 0  # remove the sampled sawtooth's tiny DC offset
    table = np.fft.irfft(spectrum, n=table_size)
    table.flags.writeable = False
    return table


[docs] def bandlimited_note( freq: float = 220.0, duration: float = 2.0, waveform: str = "sine", number_of_samples: int = 0, sample_rate: int = 44100, table_size: int = 16384, rolloff_hz: float = 0.0) -> NDArray[np.float64]: """Render a static-pitch periodic note with prefiltered harmonics. Parameters ---------- freq : float Positive fundamental in Hz, strictly below Nyquist. duration : float Seconds, ignored when `number_of_samples` is nonzero. waveform : {'sine', 'sawtooth', 'square', 'triangle'} A primary waveform. Arbitrary arrays are not accepted because their spectrum and source-pitch semantics are unknown. number_of_samples : int Overrides `duration` when positive, matching `music.note`. sample_rate : int Samples per second. table_size : int Resolution of the periodic prefiltered table (at least 4). rolloff_hz : float Optional nonnegative linear transition width below Nyquist. The default zero keeps all allowed harmonics. A smooth rolloff avoids sudden harmonic changes between adjacent static pitches. Returns ------- ndarray One-dimensional float64 samples. The method preserves the periodic waveform's fundamental, not its original peak level; truncated discontinuous waves may exhibit Gibbs overshoot. Raises ------ ValueError For invalid sampling parameters, frequency, duration, table size, or waveform name.\n\n Notes ----- This is **opt-in**: `music.note` remains sample-identical to MASS. Band-limiting is frequency-dependent. Do not reuse one filtered table for glissandi or FM. Hard-gated isochronic envelopes, clipping, nonlinear effects and subsequent resampling can still introduce aliasing. Inspect downstream output separately. Examples -------- >>> x = bandlimited_note(10000, duration=0.01, waveform="sawtooth") >>> x.shape (441,) """ if sample_rate <= 0: raise ValueError("sample_rate must be positive") if not (0 < freq < sample_rate / 2): raise ValueError("freq must be positive and below Nyquist") if number_of_samples < 0: raise ValueError("number_of_samples cannot be negative") if not number_of_samples and (not np.isfinite(duration) or duration < 0): raise ValueError("duration must be finite and nonnegative") if rolloff_hz < 0 or not np.isfinite(rolloff_hz): raise ValueError("rolloff_hz must be finite and nonnegative") if table_size < 4: raise ValueError("table_size must be at least 4") count = (int(number_of_samples) if number_of_samples else int(duration * sample_rate)) if not count: return np.empty(0, dtype=np.float64) table = _filtered_table(waveform, float(freq), sample_rate, table_size, float(rolloff_hz)) position = (np.remainder(np.arange(count) * freq / sample_rate, 1) * table_size) index = np.floor(position).astype(np.intp) fraction = position - index return (table[index] * (1 - fraction) + table[(index + 1) % table_size] * fraction)