Source code for music.core.synths.frequency_path

"""Frequency-aware additive synthesis for arbitrary positive pitch paths.

Unlike a static prefiltered wavetable, this method recalculates each partial's
Nyquist gain at *every sample*. An integrated fundamental phase keeps the
partials coherent through continuous pitch changes. This is opt-in, does not
alter the MASS reference implementation, and does not claim to eliminate
sidebands caused by rapid modulation or abrupt changes in the pitch path.
"""

import numpy as np
from numpy.typing import ArrayLike, NDArray


[docs] def bandlimited_frequency_path( frequencies: ArrayLike, *, sample_rate: int = 44100, waveform: str = "sawtooth", transition: float = 0.15, max_harmonics: int = 256) -> NDArray[np.float64]: """Synthesize a variable-pitch sine, sawtooth, square or triangle. Parameters ---------- frequencies : array_like One finite, positive instantaneous fundamental frequency in Hz per output sample. Every value must be strictly below Nyquist. sample_rate : int Output sample rate in Hz. waveform : {'sine', 'sawtooth', 'square', 'triangle'} The periodic harmonic series used for synthesis. transition : float Fraction of Nyquist (0 to 1) used to fade each harmonic to zero at Nyquist. A nonzero fade avoids clicks when a partial disappears during a continuous glissando. max_harmonics : int Computation limit, between 1 and 2048; partials above this value are intentionally omitted, including at low fundamentals. Returns ------- ndarray Mono float64 audio, preserving its fundamental phase across changes in frequency; no peak normalization is performed. Raises ------ ValueError For unsupported waveforms, invalid frequency paths, or an excessive sample-by-harmonic computation. Notes ----- Phase is trapezoidally integrated, assuming linear interpolation of frequency between samples. The algorithm changes harmonic gains continuously near Nyquist, reducing high-pitch wavetable folding. Fast FM still produces spectral sidebands that are not bounded by instantaneous harmonic frequencies. For steep modulations, hard gates and nonlinear effects also use :func:`music.render_oversampled` around the complete generator. Examples -------- >>> hz = np.linspace(1000, 10000, 480, dtype=float) >>> bandlimited_frequency_path(hz, sample_rate=48000).shape (480,) """ fs = sample_rate if not isinstance(fs, int) or isinstance(fs, bool) or fs <= 0: raise ValueError("sample_rate must be a positive integer") if waveform not in ("sine", "sawtooth", "square", "triangle"): raise ValueError("unsupported waveform") if not np.isfinite(transition) or not 0 <= transition <= 1: raise ValueError("transition must be between 0 and 1") if (not isinstance(max_harmonics, int) or isinstance(max_harmonics, bool) or not 1 <= max_harmonics <= 2048): raise ValueError("max_harmonics must be in [1, 2048]") f = np.asarray(frequencies, dtype=np.float64) if f.ndim != 1: raise ValueError("frequencies must be a 1D array") if not np.isfinite(f).all() or np.any((f <= 0) | (f >= fs / 2)): raise ValueError("all frequencies must be finite and inside Nyquist") if not len(f): return np.empty(0, dtype=np.float64) highest = min(max_harmonics, int(np.floor((fs / 2) / f.min()))) if len(f) * highest > 50_000_000: raise ValueError("pitch path exceeds 50 million harmonic-samples") # Frequencies are instantaneous samples of a continuous trajectory. # Trapezoidal integration is exact for a linear chirp between samples; # a left-endpoint sum accumulates a phase bias during pitch sweeps. intervals = 0.5 * (f[:-1] + f[1:]) phase = (2 * np.pi / fs) * np.r_[ 0.0, np.cumsum(intervals, dtype=np.float64)] output = np.zeros_like(f) nyquist = fs / 2 floor = nyquist * (1 - transition) for harmonic in range(1, highest + 1): if waveform == "sine" and harmonic != 1: break if waveform in ("square", "triangle") and harmonic % 2 == 0: continue if waveform == "sine": coefficient = 1.0 elif waveform == "sawtooth": coefficient = 2 * (-1)**(harmonic + 1) / (np.pi * harmonic) elif waveform == "square": coefficient = 4 / (np.pi * harmonic) else: coefficient = (8 * (-1)**((harmonic - 1) // 2) / (np.pi**2 * harmonic**2)) partial_hz = harmonic * f if transition: weights = np.clip((nyquist - partial_hz) / (nyquist - floor), 0.0, 1.0) else: weights = (partial_hz < nyquist).astype(np.float64) if np.any(weights): output += coefficient * weights * np.sin(harmonic * phase) return output