Files
HMS MediaEngine Agent 362e089be0 AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell.
Jetzt ist sauber getrennt:

ROOT (was der Nutzer sieht und braucht):
- run.py                     = das Programm
- hms_app/                   = der Anwendungscode
- HMS MediaEngine.app        = macOS Doppelklick-Starter
- HMS-Start.vbs              = Windows Doppelklick-Starter
- HMS-Install.vbs             = Windows Erst-Installation
- HMS-Mac-Install.command     = macOS Homebrew-Installation
- HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix)
- installer_gui.py           = grafischer Installer
- launcher.pyw + launcher_core.py = interne Start-Logik
- LIESMICH.txt               = 10-Zeilen-Kurzanleitung
- .gitignore

_entwicklung/ (alles andere, NICHT benoetigt):
- packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/
  build/ fixture_profiles/
- PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md
- pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py

Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann
_entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne.

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
2026-09-11 23:44:06 +02:00

352 lines
11 KiB
Python

"""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"]