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,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