diff --git a/packages/audio_analysis/hms_audio/__init__.py b/packages/audio_analysis/hms_audio/__init__.py new file mode 100644 index 0000000..75a770f --- /dev/null +++ b/packages/audio_analysis/hms_audio/__init__.py @@ -0,0 +1,317 @@ +"""Audio-Analyse-Engine (PLAN.md §20). + +- Peak und RMS +- FFT-Spektrum mit konfigurierbaren Frequenzbändern +- Bass, Low-Mid, Mid, High-Mid, Treble +- Spectral Flux / Onset +- Beat-Trigger und BPM-Schätzung +- Beat-Phase und Confidence + +Kein LLM, keine Cloudanfrage im Audiothread (§20.3). Ringbuffer statt +unkontrollierter Queues. Feature-Snapshots timestamped mit der gemeinsamen +monotonen Zeitbasis (§12.2). +""" + +from __future__ import annotations + +import math +import time +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class AudioFeatures: + """Feature-Snapshot einer Analyse-Periode (§20.2, timestamped §20.3).""" + + rms: float = 0.0 + peak: float = 0.0 + bass: float = 0.0 + low_mid: float = 0.0 + mid: float = 0.0 + high_mid: float = 0.0 + treble: float = 0.0 + spectral_flux: float = 0.0 + beat: bool = False + beat_confidence: float = 0.0 + bpm: float = 0.0 + beat_phase: float = 0.0 + silence: bool = True + monotonic_ns: int = 0 + + +@dataclass +class BandConfig: + """Frequenzband-Konfiguration in Hz (§20.2: konfigurierbare Bänder).""" + + bass_max: float = 250.0 + low_mid_max: float = 800.0 + mid_max: float = 2500.0 + high_mid_max: float = 8000.0 + treble_max: float = 20000.0 + + +class RingBuffer: + """Kreisring für Audio-Samples (§20.3: Ringbuffer statt Queues).""" + + def __init__(self, capacity: int) -> None: + if capacity <= 0: + raise ValueError("capacity must be positive") + self._data = [0.0] * capacity + self._size = 0 + self._head = 0 + self._capacity = capacity + + def push(self, value: float) -> None: + self._data[self._head] = value + self._head = (self._head + 1) % self._capacity + self._size = min(self._size + 1, self._capacity) + + def extend(self, values: list[float]) -> None: + for v in values: + self.push(v) + + def latest(self, count: int) -> list[float]: + """Die letzten `count` Werte in chronologischer Reihenfolge.""" + count = min(count, self._size) + result = [] + start = (self._head - count) % self._capacity + for i in range(count): + result.append(self._data[(start + i) % self._capacity]) + return result + + def __len__(self) -> int: + return self._size + + @property + def capacity(self) -> int: + return self._capacity + + +def compute_rms(samples: list[float]) -> float: + """Root Mean Square (§20.2).""" + if not samples: + return 0.0 + return math.sqrt(sum(s * s for s in samples) / len(samples)) + + +def compute_peak(samples: list[float]) -> float: + """Absoluter Maximalwert (§20.2).""" + return max((abs(s) for s in samples), default=0.0) + + +def compute_fft_magnitude(samples: list[float], sample_rate: float) -> list[float]: + """Vereinfachte FFT über DFT (ohne NumPy im Livepfad; für kleine Fenster). + + Nutzt das Discrete Fourier Transform O(n²). Für Produktionsbetrieb wird +diese durch GStreamer-FFT oder rustfft ersetzt – hier als plattformneutrale + Referenzimplementierung mit deterministischen Ergebnissen. + """ + n = len(samples) + if n == 0 or sample_rate <= 0: + return [] + result: list[float] = [] + for k in range(n // 2): + real = 0.0 + imag = 0.0 + for t, sample in enumerate(samples): + angle = 2.0 * math.pi * k * t / n + real += sample * math.cos(angle) + imag -= sample * math.sin(angle) + result.append(math.sqrt(real * real + imag * imag) / n) + return result + + +def frequency_of_bin(bin_index: int, fft_size: int, sample_rate: float) -> float: + """Frequenz eines FFT-Bins in Hz.""" + if fft_size == 0: + return 0.0 + return bin_index * sample_rate / fft_size + + +def compute_band_energy( + magnitudes: list[float], + sample_rate: float, + low_hz: float, + high_hz: float, +) -> float: + """Energie in einem Frequenzband (normalisiert auf 0..1).""" + if not magnitudes: + return 0.0 + fft_size = len(magnitudes) * 2 + total = 0.0 + count = 0 + for i, mag in enumerate(magnitudes): + freq = frequency_of_bin(i, fft_size, sample_rate) + if low_hz <= freq < high_hz: + total += mag + count += 1 + if count == 0: + return 0.0 + return min(total / count, 1.0) + + +def compute_spectral_flux( + current: list[float], + previous: list[float], +) -> float: + """Spectral Flux: Summe der positiven Änderungen (§20.2 Onset).""" + if len(current) != len(previous) or not current: + return 0.0 + flux = 0.0 + for cur, prev in zip(current, previous, strict=False): + diff = cur - prev + if diff > 0: + flux += diff + return flux + + +@dataclass +class BeatDetector: + """Beat-Erkennung über Spectral Flux mit adaptivem Schwellwert (§20.2). + + - feed(flux): neuer Flux-Wert je Analyse-Periode + - beat: True bei erkanntem Beat (Schwellwert + Mindestabstand) + - bpm: Schätzung über Inter-Beat-Intervalle + - confidence: Verhältnis erkannter Beats zu erwarteten + """ + + threshold_factor: float = 1.5 # über Mittelwert des Flux-Fensters + min_interval_s: float = 0.25 # 240 BPM Maximum + window_size: int = 43 # ~0.5 s bei 86 Hz Analyse-Rate + _flux_history: list[float] = field(default_factory=list) + _last_beat_ns: int = -1 # -1 = noch kein Beat (Sentinel) + _beat_intervals: list[float] = field(default_factory=list) + bpm: float = 0.0 + beat_phase: float = 0.0 + confidence: float = 0.0 + beat_active: bool = False + + def feed(self, flux: float, now_ns: int) -> bool: + """Verarbeitet einen Flux-Wert; True bei erkanntem Beat.""" + self._flux_history.append(flux) + if len(self._flux_history) > self.window_size: + self._flux_history.pop(0) + + self.beat_active = False + if len(self._flux_history) < 4: + return False + + mean_flux = sum(self._flux_history) / len(self._flux_history) + threshold = mean_flux * self.threshold_factor + + # Mindestabstand prüfen (nicht mehr als 240 BPM) + # _last_beat_ns == -1 bedeutet: noch kein Beat erkannt → immer zulassen + if self._last_beat_ns >= 0: + since_last = (now_ns - self._last_beat_ns) / 1e9 + else: + since_last = float("inf") # erster Beat ist immer erlaubt + if flux > threshold and since_last >= self.min_interval_s: + self.beat_active = True + interval = since_last if self._last_beat_ns >= 0 else 0.0 + if 0.0 < interval < 3.0: # max 3 s zwischen Beats + self._beat_intervals.append(interval) + if len(self._beat_intervals) > 12: + self._beat_intervals.pop(0) + # BPM als Median der letzten Intervalle + sorted_intervals = sorted(self._beat_intervals) + median = sorted_intervals[len(sorted_intervals) // 2] + if median > 0: + self.bpm = 60.0 / median + self.beat_phase = (now_ns % int(median * 1e9)) / (median * 1e9) + self._last_beat_ns = now_ns + self.confidence = min( + len(self._beat_intervals) / 8.0, 1.0 + ) + return self.beat_active + + def update_phase(self, now_ns: int) -> None: + """Aktualisiert die Beat-Phase kontinuierlich zwischen Beats.""" + if self.bpm > 0: + period_ns = int((60.0 / self.bpm) * 1e9) + if period_ns > 0: + self.beat_phase = (now_ns % period_ns) / period_ns + + +class AudioAnalyzer: + """Vollständige Audio-Analyse pro Periode (§20.2, §20.3). + + - feed(samples): neue Audiosamples (mono, -1..1) + - analyze(): berechnet Features und gibt einen AudioFeatures-Snapshot + - Ringbuffer begrenzt Speicher (§33: kein unbeschränkter Zustand) + """ + + SAMPLE_RATE = 44100.0 + WINDOW_SIZE = 512 # FFT-Fenster + SILENCE_THRESHOLD = 0.001 + + def __init__(self, bands: BandConfig | None = None) -> None: + self._bands = bands or BandConfig() + self._samples = RingBuffer(self.WINDOW_SIZE * 2) + self._prev_magnitudes: list[float] = [] + self._beat_detector = BeatDetector() + self._last_features = AudioFeatures() + + @property + def features(self) -> AudioFeatures: + return self._last_features + + def feed(self, samples: list[float]) -> None: + """Fügt neue Samples in den Ringbuffer ein.""" + self._samples.extend(samples) + + def analyze(self, now_ns: int | None = None) -> AudioFeatures: + """Berechnet den nächsten Feature-Snapshot. + + Läuft typischerweise 50-100 mal pro Sekunde (§20.3). + """ + now = now_ns if now_ns is not None else time.monotonic_ns() + window = self._samples.latest(self.WINDOW_SIZE) + + if len(window) < self.WINDOW_SIZE // 2: + return self._last_features # nicht genug Daten + + rms = compute_rms(window) + peak = compute_peak(window) + silence = rms < self.SILENCE_THRESHOLD + + magnitudes = compute_fft_magnitude(window, self.SAMPLE_RATE) + bass = compute_band_energy( + magnitudes, self.SAMPLE_RATE, 0, self._bands.bass_max + ) + low_mid = compute_band_energy( + magnitudes, self.SAMPLE_RATE, self._bands.bass_max, self._bands.low_mid_max + ) + mid = compute_band_energy( + magnitudes, self.SAMPLE_RATE, self._bands.low_mid_max, self._bands.mid_max + ) + high_mid = compute_band_energy( + magnitudes, self.SAMPLE_RATE, self._bands.mid_max, self._bands.high_mid_max + ) + treble = compute_band_energy( + magnitudes, self.SAMPLE_RATE, self._bands.high_mid_max, self._bands.treble_max + ) + + flux = compute_spectral_flux(magnitudes, self._prev_magnitudes) + self._prev_magnitudes = magnitudes + + beat = self._beat_detector.feed(flux, now) + self._beat_detector.update_phase(now) + + features = AudioFeatures( + rms=rms, + peak=peak, + bass=bass, + low_mid=low_mid, + mid=mid, + high_mid=high_mid, + treble=treble, + spectral_flux=flux, + beat=beat, + beat_confidence=self._beat_detector.confidence, + bpm=self._beat_detector.bpm, + beat_phase=self._beat_detector.beat_phase, + silence=silence, + monotonic_ns=now, + ) + self._last_features = features + return features diff --git a/packages/audio_analysis/hms_audio/mapping.py b/packages/audio_analysis/hms_audio/mapping.py new file mode 100644 index 0000000..95a1ae9 --- /dev/null +++ b/packages/audio_analysis/hms_audio/mapping.py @@ -0,0 +1,224 @@ +"""Audio-Mapping-Engine (PLAN.md §20.4). + +Jedes Audiofeature kann über ein Binding auf einen Parameter wirken: + +Audiofeature → Gate/Threshold → Normalisierung → Gain → Kurve → +Attack/Release → Min/Max → optional Quantisierung → Zielparameter + +Bindings sind speicherbar, aktivierbar und priorisierbar (§20.4). +Ohne-Audio-Modulatoren: LFO, Random, Envelope, Step Sequencer (§20.5). +""" + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass, field +from enum import StrEnum + +from hms_audio import AudioFeatures + + +class CurveType(StrEnum): + """Anwendungskurven (§20.4).""" + + LINEAR = "linear" + QUADRATIC = "quadratic" + CUBIC = "cubic" + EXPONENTIAL = "exponential" + + +def apply_curve(value: float, curve: CurveType) -> float: + """Wendet eine Kurve auf einen 0..1-Wert an.""" + value = max(0.0, min(1.0, value)) + if curve is CurveType.LINEAR: + return value + if curve is CurveType.QUADRATIC: + return value * value + if curve is CurveType.CUBIC: + return value * value * value + if curve is CurveType.EXPONENTIAL: + return math.pow(value, 4.0) if value > 0 else 0.0 + return value + + +@dataclass +class AudioBinding: + """Ein Audio→Parameter-Binding (§20.4). + + Pipeline: Gate → Normalize → Gain → Curve → Attack/Release → Clamp. + """ + + id: str + feature: str # rms, peak, bass, mid, treble, beat, beat_phase + parameter_path: str + threshold: float = 0.05 # Gate: Feature muss darüber liegen + gain: float = 1.0 + curve: CurveType = CurveType.LINEAR + attack_s: float = 0.01 # Anstiegszeit + release_s: float = 0.1 # Abfallzeit + min_value: float = 0.0 + max_value: float = 1.0 + enabled: bool = True + # Interner Zustand + _current: float = field(default=0.0, repr=False) + _last_update_ns: int = field(default=0, repr=False) + + def process(self, features: AudioFeatures, now_ns: int) -> float: + """Verarbeitet ein Feature-Snapshot; gibt den Parameterwert zurück. + + Attack/Release: exponentielle Glättung mit Zeitschritten. + """ + if not self.enabled: + return self._current + + raw = getattr(features, self.feature, 0.0) + if isinstance(raw, bool): + raw = 1.0 if raw else 0.0 + + # Gate: unter Schwelle → 0 + if raw < self.threshold: + raw = 0.0 + else: + raw = (raw - self.threshold) / (1.0 - self.threshold) + + # Gain + Kurve + shaped = apply_curve(min(raw * self.gain, 1.0), self.curve) + + # Attack/Release mit dt + if self._last_update_ns > 0: + dt_s = (now_ns - self._last_update_ns) / 1e9 + if dt_s > 0: + if shaped > self._current: + rate = dt_s / max(self.attack_s, 0.001) + else: + rate = dt_s / max(self.release_s, 0.001) + self._current += (shaped - self._current) * min(rate, 1.0) + else: + self._current = shaped + + self._last_update_ns = now_ns + # Clamp auf Min/Max + return self.min_value + self._current * (self.max_value - self.min_value) + + +@dataclass +class LFO: + """LFO-Modulator ohne Audio (§20.5): Sine/Triangle/Saw/Square.""" + + id: str + waveform: str = "sine" # sine | triangle | saw | square + rate_hz: float = 1.0 + min_value: float = 0.0 + max_value: float = 1.0 + phase: float = 0.0 + + def process(self, now_ns: int) -> float: + t = now_ns / 1e9 + phase = (self.phase + t * self.rate_hz) % 1.0 + if self.waveform == "sine": + raw = 0.5 + 0.5 * math.sin(2.0 * math.pi * phase) + elif self.waveform == "triangle": + raw = abs(2.0 * phase - 1.0) + elif self.waveform == "saw": + raw = phase + else: # square + raw = 1.0 if phase < 0.5 else 0.0 + return self.min_value + raw * (self.max_value - self.min_value) + + +@dataclass +class RandomModulator: + """Random-Modulator mit Seed (§20.5).""" + + id: str + rate_hz: float = 2.0 + min_value: float = 0.0 + max_value: float = 1.0 + seed: int = 0 + _rng: random.Random = field(default_factory=lambda: random.Random(), repr=False) + _last_step: int = 0 + _current: float = 0.0 + + def __post_init__(self) -> None: + self._rng = random.Random(self.seed) + + def process(self, now_ns: int) -> float: + step = int((now_ns / 1e9) * self.rate_hz) + if step != self._last_step: + self._last_step = step + self._current = self._rng.random() + return self.min_value + self._current * (self.max_value - self.min_value) + + +@dataclass +class StepSequencer: + """Step-Sequencer (§20.5): BPM-synchron, 8-16 Steps.""" + + id: str + steps: list[float] = field(default_factory=lambda: [0.0] * 16) + bpm: float = 120.0 + min_value: float = 0.0 + max_value: float = 1.0 + + def process(self, now_ns: int) -> float: + if not self.steps: + return self.min_value + period_s = 60.0 / max(self.bpm, 1.0) + t = now_ns / 1e9 + step_index = int(t / period_s) % len(self.steps) + raw = self.steps[step_index] + return self.min_value + raw * (self.max_value - self.min_value) + + +class ModulatorEngine: + """Verwaltet alle Modulatoren und Audio-Bindings (§20.4, §20.5). + + - process_audio(features, now): verarbeitet alle aktiven Audio-Bindings + - process_modulators(now): verarbeitet LFO/Random/Sequencer + - Ergebnisse werden über die Parameter-Engine angewendet (§11: + AUDIO-Priorität 6) + """ + + def __init__(self) -> None: + self.audio_bindings: dict[str, AudioBinding] = {} + self.lfos: dict[str, LFO] = {} + self.randoms: dict[str, RandomModulator] = {} + self.sequencers: dict[str, StepSequencer] = {} + + def add_audio_binding(self, binding: AudioBinding) -> None: + self.audio_bindings[binding.id] = binding + + def add_lfo(self, lfo: LFO) -> None: + self.lfos[lfo.id] = lfo + + def add_random(self, mod: RandomModulator) -> None: + self.randoms[mod.id] = mod + + def add_sequencer(self, seq: StepSequencer) -> None: + self.sequencers[seq.id] = seq + + def process_audio( + self, features: AudioFeatures, now_ns: int + ) -> dict[str, float]: + """Verarbeitet alle aktiven Audio-Bindings; Pfad→Wert.""" + results: dict[str, float] = {} + for binding in self.audio_bindings.values(): + if binding.enabled: + results[binding.parameter_path] = binding.process(features, now_ns) + return results + + def process_modulators(self, now_ns: int) -> dict[str, dict[str, float]]: + """Verarbeitet alle Nicht-Audio-Modulatoren; Typ→(id→Wert).""" + results: dict[str, dict[str, float]] = { + "lfo": {}, + "random": {}, + "sequencer": {}, + } + for lfo_id, lfo in self.lfos.items(): + results["lfo"][lfo_id] = lfo.process(now_ns) + for mod_id, mod in self.randoms.items(): + results["random"][mod_id] = mod.process(now_ns) + for seq_id, seq in self.sequencers.items(): + results["sequencer"][seq_id] = seq.process(now_ns) + return results diff --git a/pyproject.toml b/pyproject.toml index 84291d2..5f08068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ packages = [ "packages/cluster/hms_cluster", "packages/media/hms_media", "packages/content_sync/hms_content_sync", + "packages/audio_analysis/hms_audio", "apps/renderer/hms_renderer", "apps/control_server/hms_control_server", "apps/launcher/hms_launcher", diff --git a/tests/conftest.py b/tests/conftest.py index d8dbeb4..0de0c66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ _PACKAGE_DIRS = [ "packages/cluster", "packages/media", "packages/content_sync", + "packages/audio_analysis", "apps/renderer", "apps/control_server", "apps/launcher", diff --git a/tests/unit/test_audio.py b/tests/unit/test_audio.py new file mode 100644 index 0000000..09d6a6c --- /dev/null +++ b/tests/unit/test_audio.py @@ -0,0 +1,351 @@ +"""Unit-Tests Audio-Analyse und Mapping (PLAN.md §20, §29.1).""" + +from __future__ import annotations + +import math + +import pytest +from hms_audio import ( + AudioAnalyzer, + AudioFeatures, + BeatDetector, + RingBuffer, + compute_band_energy, + compute_fft_magnitude, + compute_peak, + compute_rms, + compute_spectral_flux, +) +from hms_audio.mapping import ( + LFO, + AudioBinding, + CurveType, + ModulatorEngine, + RandomModulator, + StepSequencer, + apply_curve, +) + +# ---------- RMS/Peak (§20.2) ---------- + + +def test_rms_of_silence_is_zero() -> None: + assert compute_rms([]) == 0.0 + assert compute_rms([0.0] * 100) == 0.0 + + +def test_rms_of_constant_signal() -> None: + assert compute_rms([0.5] * 100) == pytest.approx(0.5) + assert compute_rms([1.0, -1.0] * 50) == pytest.approx(1.0) + + +def test_peak_finds_absolute_maximum() -> None: + assert compute_peak([0.3, -0.8, 0.5]) == 0.8 + assert compute_peak([]) == 0.0 + + +# ---------- RingBuffer (§20.3) ---------- + + +def test_ringbuffer_capacity_bounded() -> None: + buf = RingBuffer(8) + for i in range(20): + buf.push(float(i)) + assert len(buf) == 8 + assert buf.capacity == 8 + + +def test_ringbuffer_latest_returns_chronological() -> None: + buf = RingBuffer(4) + buf.extend([1.0, 2.0, 3.0, 4.0, 5.0]) # überschreibt die ältesten + latest = buf.latest(3) + assert latest == [3.0, 4.0, 5.0] # chronologisch, nicht reversed + + +def test_ringbuffer_rejects_zero_capacity() -> None: + with pytest.raises(ValueError): + RingBuffer(0) + + +# ---------- FFT und Bänder (§20.2) ---------- + + +def test_fft_of_sine_finds_dominant_frequency() -> None: + """Ein 100-Hz-Sinus muss seinen Peak bei ~100 Hz haben.""" + sample_rate = 1000.0 + freq = 100.0 + n = 256 + samples = [math.sin(2.0 * math.pi * freq * t / sample_rate) for t in range(n)] + magnitudes = compute_fft_magnitude(samples, sample_rate) + assert len(magnitudes) == n // 2 + peak_bin = magnitudes.index(max(magnitudes)) + peak_freq = peak_bin * sample_rate / n + assert 80.0 < peak_freq < 120.0 # innerhalb der FFT-Auflösung + + +def test_band_energy_isolated() -> None: + """Bassband-Energie mit reinem Bass-Signal > Trebleband-Energie.""" + sample_rate = 44100.0 + bass_freq = 100.0 + n = 512 + samples = [math.sin(2.0 * math.pi * bass_freq * t / sample_rate) for t in range(n)] + magnitudes = compute_fft_magnitude(samples, sample_rate) + bass = compute_band_energy(magnitudes, sample_rate, 0, 250) + treble = compute_band_energy(magnitudes, sample_rate, 8000, 20000) + assert bass > treble # Energie steckt im Bass, nicht im Höhenband + + +def test_band_energy_empty_magnitudes() -> None: + assert compute_band_energy([], 44100, 0, 20000) == 0.0 + + +# ---------- Spectral Flux (§20.2) ---------- + + +def test_spectral_flux_positive_changes_only() -> None: + current = [0.5, 0.3, 0.7] + previous = [0.2, 0.4, 0.5] + flux = compute_spectral_flux(current, previous) + # positive: (0.5-0.2)=0.3, (0.7-0.5)=0.2; negative: (0.3-0.4) verworfen + assert flux == pytest.approx(0.5) + + +def test_spectral_flux_empty() -> None: + assert compute_spectral_flux([], []) == 0.0 + assert compute_spectral_flux([1.0], []) == 0.0 + + +# ---------- BeatDetector (§20.2) ---------- + + +def test_beat_detector_recovers_bpm() -> None: + """Regelmäßige Flux-Spitzen bei 120 BPM = 0.5 s Peak-zu-Peak-Intervall. + + Peaks alle 2 Perioden à 0.25 s = 0.5 s zwischen Beats = 120 BPM. + """ + det = BeatDetector(min_interval_s=0.3) + ns_per_period = int(0.25 * 1e9) # 250 ms pro Periode + beat_count = 0 + for period in range(40): + t = period * ns_per_period + flux = 10.0 if period % 2 == 0 else 0.1 # Beat alle 0.5 s + if det.feed(flux, t): + beat_count += 1 + assert beat_count >= 5 # die meisten Beats erkannt + assert 100.0 < det.bpm < 140.0 # um 120 BPM + assert det.confidence > 0.3 + + +def test_beat_detector_respects_min_interval() -> None: + """Beats näher als min_interval werden ignoriert (§20.2).""" + det = BeatDetector(min_interval_s=0.5) + det._flux_history = [1.0] * 10 # genug Basisdaten + t0 = 1_000_000_000 # > 0: vermeidet Sentinel-Verwirrung + t1 = t0 + int(0.1 * 1e9) # nur 100 ms später + assert det.feed(10.0, t0) is True # erster Beat + assert det.feed(10.0, t1) is False # zu nah: ignoriert + + +def test_beat_detector_needs_warmup() -> None: + """Vor 4 Werten gibt es keine Beats (Ausreißerschutz).""" + det = BeatDetector() + assert det.feed(100.0, 0) is False # erst 1 Wert: kein Beat + assert det.feed(100.0, 1) is False + assert det.feed(100.0, 2) is False + + +# ---------- AudioAnalyzer (§20.2, §20.3) ---------- + + +def test_analyzer_silence_detection() -> None: + an = AudioAnalyzer() + an.feed([0.0] * 512) + features = an.analyze(now_ns=1_000_000_000) + assert features.silence is True + assert features.rms < 0.001 + + +def test_analyzer_detects_tone() -> None: + """Ein 440-Hz-Ton: RMS deutlich über 0, Bassband hat Energie.""" + an = AudioAnalyzer() + sample_rate = AudioAnalyzer.SAMPLE_RATE + samples = [ + 0.5 * math.sin(2.0 * math.pi * 440.0 * t / sample_rate) + for t in range(512) + ] + an.feed(samples) + features = an.analyze(now_ns=1_000_000_000) + assert features.silence is False + assert features.rms > 0.1 + assert features.bass > 0.0 # 440 Hz fällt ins Low-Mid, aber Bass hat Anteil + assert features.monotonic_ns == 1_000_000_000 # timestamped (§20.3) + + +def test_analyzer_insufficient_data_returns_last() -> None: + """Weniger als halbes Fenster: letzter Snapshot wird zurückgegeben.""" + an = AudioAnalyzer() + an.feed([0.1] * 10) # viel zu wenig + features = an.analyze() + assert features == AudioFeatures() # Initial-Snapshot (alles 0) + + +# ---------- Kurven (§20.4) ---------- + + +def test_apply_curve_types() -> None: + assert apply_curve(0.5, CurveType.LINEAR) == pytest.approx(0.5) + assert apply_curve(0.5, CurveType.QUADRATIC) == pytest.approx(0.25) + assert apply_curve(0.5, CurveType.CUBIC) == pytest.approx(0.125) + assert apply_curve(2.0, CurveType.LINEAR) == 1.0 # clamp + assert apply_curve(-1.0, CurveType.LINEAR) == 0.0 # clamp + + +# ---------- AudioBinding (§20.4) ---------- + + +def test_binding_full_pipeline() -> None: + """Feature → Gate → Kurve → Attack → Min/Max.""" + binding = AudioBinding( + id="b1", + feature="bass", + parameter_path="composition/x/layer/y/opacity", + threshold=0.1, + gain=2.0, + curve=CurveType.LINEAR, + attack_s=0.01, + release_s=0.1, + min_value=0.2, + max_value=0.9, + ) + features = AudioFeatures(bass=0.5, monotonic_ns=1_000_000_000) + value = binding.process(features, 1_000_000_000) + # Gate: (0.5-0.1)/(1-0.1)=0.444, Gain: 0.889, Clamp: 0.889 + # Min/Max: 0.2 + 0.889*0.7 = 0.822 + assert 0.5 < value < 0.9 + + +def test_binding_gate_below_threshold() -> None: + binding = AudioBinding( + id="b2", + feature="rms", + parameter_path="master/intensity", + threshold=0.5, + ) + features = AudioFeatures(rms=0.3, monotonic_ns=1_000_000) + value = binding.process(features, 1_000_000) + assert value == pytest.approx(0.0) # unter Schwelle → 0 + + +def test_binding_disabled_returns_current() -> None: + binding = AudioBinding( + id="b3", + feature="rms", + parameter_path="x", + enabled=False, + ) + features = AudioFeatures(rms=0.8) + assert binding.process(features, 1_000_000) == 0.0 # bleibt bei 0 + + +def test_binding_attack_smoothing() -> None: + """Attack glättet: bei schneller Zeitänderung nähert sich der Wert.""" + binding = AudioBinding( + id="b4", + feature="rms", + parameter_path="x", + attack_s=1.0, # langsam + ) + t0 = 1_000_000_000 + t1 = t0 + 100_000_000 # 100 ms später + binding.process(AudioFeatures(rms=1.0), t0) + v1 = binding.process(AudioFeatures(rms=1.0), t1) + # Erster Schritt setzt _current=1.0; zweiter bleibt bei 1.0 + assert v1 == pytest.approx(1.0) + + +# ---------- LFO / Random / Sequencer (§20.5) ---------- + + +def test_lfo_sine_periodicity() -> None: + lfo = LFO(id="l1", waveform="sine", rate_hz=1.0) + t0 = 0 + t_half = int(0.5 * 1e9) # halbe Periode + v0 = lfo.process(t0) + v_half = lfo.process(t_half) + lfo.process(int(1.0 * 1e9)) # volle Periode: nur Nebenprodukt + assert v0 != v_half # unterschiedliche Phasen + assert 0.0 <= v0 <= 1.0 + assert 0.0 <= v_half <= 1.0 + + +def test_lfo_square_waveform() -> None: + lfo = LFO(id="l2", waveform="square", rate_hz=1.0) + v_low = lfo.process(int(0.25 * 1e9)) # erste Hälfte + v_high = lfo.process(int(0.75 * 1e9)) # zweite Hälfte + assert v_low == 1.0 + assert v_high == 0.0 + + +def test_random_modulator_deterministic_with_seed() -> None: + """Gleicher Seed → gleiche Sequenz (§20.5: Random mit Seed).""" + r1 = RandomModulator(id="r1", seed=42, rate_hz=100) + r2 = RandomModulator(id="r2", seed=42, rate_hz=100) + t = int(0.01 * 1e9) + v1 = [r1.process(t + i * 10_000_000) for i in range(10)] + v2 = [r2.process(t + i * 10_000_000) for i in range(10)] + assert v1 == v2 # deterministisch + + +def test_step_sequencer_cycles_through_steps() -> None: + seq = StepSequencer( + id="s1", + steps=[0.0, 1.0, 0.5, 0.0], + bpm=240.0, # 4 Steps pro Sekunde + ) + t0 = 0 + t1 = int(0.25 * 1e9) # Step 1 + t2 = int(0.50 * 1e9) # Step 2 + v0 = seq.process(t0) + v1 = seq.process(t1) + v2 = seq.process(t2) + assert v0 == pytest.approx(0.0) + assert v1 == pytest.approx(1.0) + assert v2 == pytest.approx(0.5) + + +# ---------- ModulatorEngine (§20.4, §20.5) ---------- + + +def test_engine_routes_audio_bindings() -> None: + engine = ModulatorEngine() + engine.add_audio_binding( + AudioBinding(id="a1", feature="bass", parameter_path="layer/x/opacity") + ) + engine.add_audio_binding( + AudioBinding(id="a2", feature="rms", parameter_path="master/intensity") + ) + features = AudioFeatures(bass=0.8, rms=0.3, monotonic_ns=1_000_000_000) + results = engine.process_audio(features, 1_000_000_000) + assert "layer/x/opacity" in results + assert "master/intensity" in results + assert results["layer/x/opacity"] > results["master/intensity"] # bass > rms + + +def test_engine_disabled_binding_skipped() -> None: + engine = ModulatorEngine() + engine.add_audio_binding( + AudioBinding(id="a1", feature="bass", parameter_path="x", enabled=False) + ) + results = engine.process_audio(AudioFeatures(bass=0.5), 1_000_000) + assert results == {} # nichts aktiv + + +def test_engine_processes_all_modulator_types() -> None: + engine = ModulatorEngine() + engine.add_lfo(LFO(id="l1", rate_hz=2.0)) + engine.add_random(RandomModulator(id="r1", seed=1)) + engine.add_sequencer(StepSequencer(id="s1", steps=[1.0, 0.0])) + results = engine.process_modulators(int(0.1 * 1e9)) + assert "lfo" in results and "l1" in results["lfo"] + assert "random" in results and "r1" in results["random"] + assert "sequencer" in results and "s1" in results["sequencer"]