225 lines
7.1 KiB
Python
225 lines
7.1 KiB
Python
|
|
"""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
|