Phase 7: Audio-Analyse und Mapping-Engine (§20)
- AudioFeatures: Peak, RMS, FFT-Spektrum, Bass/Low-Mid/Mid/High-Mid/ Treble, Spectral Flux, Beat, BPM, Beat-Phase, Confidence, Stilleerkennung; timestamped mit monotoner Zeitbasis (§20.2, §20.3) - BeatDetector: adaptiver Schwellwert ueber Flux-Fenster, Mindestabstand, BPM-Median ueber Inter-Beat-Intervalle, Sentinel-Fix fuer ersten Beat - RingBuffer: begrenzter Kreisring statt unkontrollierter Queues (§20.3, §33) - AudioBinding (§20.4): Gate/Threshold, Normalisierung, Gain, Kurve (linear/quadratic/cubic/exponential), Attack/Release, Min/Max - Modulatoren ohne Audio (§20.5): LFO Sine/Triangle/Saw/Square, Random mit Seed (deterministisch), Step Sequencer BPM-synchron - ModulatorEngine: verwaltet Audio-Bindings und Modulatoren; Ergebnisse ueber Parameter-Engine mit AUDIO-Prioritaet 6 (§11.2) - 29 Unit-Tests: RMS/Peak, RingBuffer-Kapazitaet, FFT-Peak-Frequenz, Band-Energie, Flux, BPM-Recovery (120 BPM), Min-Interval, Silent-Tone-Analyse, Kurven, Binding-Pipeline, LFO-Periodizitaet, Random-Seed-Determinismus, Sequencer-Cycling - Gesamtsuite 586 gruen, Ruff gruen
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ _PACKAGE_DIRS = [
|
||||
"packages/cluster",
|
||||
"packages/media",
|
||||
"packages/content_sync",
|
||||
"packages/audio_analysis",
|
||||
"apps/renderer",
|
||||
"apps/control_server",
|
||||
"apps/launcher",
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user