abffff3711
- Master32Engine: 32-Kanal-Dekodierung: 16-Bit Intensitaet/BPM/Dauer, Blackout (hoechste Prioritaet), Freeze, Preset Bank/Index, Flanken-Trigger Preset Recall/Tap Tempo/Release, Audio/AI-Freigabe, Testpattern, Global Hue/Sat, Fallback; reservierte Kanaele neutral - Layer64Engine: 64-Kanal-Dekodierung: Enable, 16-Bit Opacity, Source-Type Media/Generator/Solid/Live, modusabhaengiger Source-Block (Kanal 13-20: Media=Speed/Position/In/Out, Generator=G1-G8), Transport-Enum, Blend, 16-Bit signed Position, Scale, Rotation, Crop, Color, FX1/FX2 mit P1-P8, Load/Commit-Semantik mit pending selection (§16.5: Fader bewegt nichts ohne Flanke), Retrigger - map_speed (§16.6): Mittelpunkt=Pause, untere Haelfte -4x..0, obere 0..+4x, definierter Wert 40960 = exakt 1x; monoton, dokumentiert und getestet (Fehler im ersten Entwurf korrigiert: 32768 war faelschlich 1x statt Pause) - UniversePlan: kollisionsfreie Bereiche je Node, Ueberschneidung = Blocker-Fehler (§16.2), Preflight-Check - 26 Unit-Tests: Speed-Referenzwerte/-Monotonie, Master32-Kanaele, Trigger-Flanken, reservierte Kanaele, Layer64-Modi, Load/Commit, Retrigger, FX-Bloecke, Universe-Kollisionen; 8x64=512-Verifikation - Gesamtsuite 532 gruen, Ruff gruen
424 lines
16 KiB
Python
424 lines
16 KiB
Python
"""Art-Net-Fixture-Engine: Master32- und Layer64-Dekodierung
|
||
(PLAN.md §16.3, §16.4, §16.5, §16.6).
|
||
|
||
Dekodiert DMX-Kanäle in strukturale Steuerdaten und Emitting von
|
||
Trigger-Ereignissen (Flanken). Rein datenverarbeitend; alle Ergebnisse
|
||
fließen über die Parameter-Engine (§11), nie direkt in den Renderer.
|
||
|
||
Fixtures:
|
||
- HMS MediaEngine Master 32ch (§16.3)
|
||
- HMS MediaEngine Layer 64ch (§16.4): ein Layer-Fixture = exakt 64 Kanäle,
|
||
8 Layer = exakt 1 Universe (§16.2)
|
||
|
||
Verhalten:
|
||
- 16-Bit: MSB zuerst (DMX-Konvention)
|
||
- Trigger: steigende Flanke über Schwelle, kein Dauerzustand (§16.3/§16.5)
|
||
- Load/Commit (§16.5): Bank/Folder/Index-Änderungen erzeugen pending
|
||
selection; erst Flanke auf Load/Commit löst Preload+Umschaltung aus;
|
||
optional Auto-Load mit Debounce (V1: manuell)
|
||
- Pickup/Takeover (§16.5): Wertänderungen nach Plugin-/Quellwechsel werden
|
||
erst nach neuem Load/Commit interpretiert – keine Parametersprünge
|
||
- Speed-Mapping (§16.6): signed 16-Bit; Mittelpunkt (32768) = Pause (0),
|
||
untere Hälfte −4x bis 0, obere Hälfte 0 bis +4x, definierter Wert 40960
|
||
entspricht exakt 1×
|
||
- reservierte Kanäle: neutral ignorieren (§16.3/§16.4), keine Fehler
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
|
||
# ---------- Master32 (§16.3) ----------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MasterEvent:
|
||
"""Ereignis des Master-Fixtures (nur bei Flanken)."""
|
||
|
||
kind: str # preset_recall | tap_tempo | release_overrides
|
||
|
||
|
||
@dataclass
|
||
class MasterControl:
|
||
"""Dekodierte Master32-Steuerdaten (§16.3)."""
|
||
|
||
master_intensity: float = 1.0 # 16 Bit Kanal 1-2 → 0..1
|
||
blackout: bool = False # Kanal 3: >=128, höchste Priorität
|
||
freeze_output: bool = False # Kanal 4
|
||
preset_bank: int = 0 # Kanal 5
|
||
preset_index: int = 0 # 16 Bit Kanal 6-7
|
||
transition_type: int = 0 # Kanal 9: Enum
|
||
transition_duration: float = 0.0 # 16 Bit Kanal 10-11 → 0..1 (Max konfiguriert)
|
||
global_speed: float = 1.0 # 16 Bit Kanal 12-13 (Speed-Mapping §16.6)
|
||
bpm: float = 120.0 # 16 Bit Kanal 14-15 → 20..300
|
||
audio_reactive: bool = False # Kanal 22
|
||
audio_gain: float = 1.0 # Kanal 23 → 0..2
|
||
automation_ai_enable: bool = False # Kanal 24: nur Freigabe
|
||
test_pattern: int = 0 # Kanal 25: Enum, 0 = aus
|
||
preview_enable: bool = False # Kanal 26
|
||
global_hue: float = 0.0 # Kanal 27 → -0.5..0.5
|
||
global_saturation: float = 1.0 # Kanal 28 → 0..2
|
||
fallback_preset: int = 0 # Kanal 29
|
||
events: list[MasterEvent] = field(default_factory=list)
|
||
|
||
|
||
class Master32Engine:
|
||
"""Dekodiert ein 32-Kanal-DMX-Segment in MasterControl.
|
||
|
||
Kanäle 17–21 und 31–32 sind reserviert und werden neutral ignoriert
|
||
(§16.3). Trigger-Kanäle (8 Preset Recall, 16 Tap Tempo, 30 Release)
|
||
emittieren Flanken-Ereignisse, gehaltene Werte nicht.
|
||
"""
|
||
|
||
TRIGGER_THRESHOLD = 128
|
||
|
||
def __init__(self) -> None:
|
||
self._prev: bytearray | None = None # letzter Kanalstand für Flanken
|
||
|
||
def decode(self, channels: bytes) -> MasterControl:
|
||
"""Dekodiert exakt 32 Kanäle; reservierte neutral ignorierend."""
|
||
if len(channels) < 32:
|
||
raise ValueError(f"Master32 benötigt 32 Kanäle, erhalten {len(channels)}")
|
||
prev = self._prev
|
||
curr = bytearray(channels[:32])
|
||
events: list[MasterEvent] = []
|
||
threshold = self.TRIGGER_THRESHOLD
|
||
|
||
def _rising(channel_index: int) -> bool:
|
||
"""Steigende Flanke: jetzt über Schwelle, vorher darunter."""
|
||
now_active = channels[channel_index] >= threshold
|
||
before_active = bool(prev and prev[channel_index] >= threshold)
|
||
return now_active and not before_active
|
||
|
||
if _rising(7): # Kanal 8: Preset Recall (§16.3: direkter Abruf)
|
||
events.append(MasterEvent(kind="preset_recall"))
|
||
if _rising(15): # Kanal 16: Tap Tempo
|
||
events.append(MasterEvent(kind="tap_tempo"))
|
||
if _rising(29): # Kanal 30: Release Manual Overrides (Schutzlogik §16.3)
|
||
events.append(MasterEvent(kind="release_overrides"))
|
||
self._prev = curr
|
||
|
||
speed_16 = decode_u16(channels, 11) # Kanal 12-13 (0-basiert 11-12)
|
||
bpm_16 = decode_u16(channels, 13) # Kanal 14-15
|
||
return MasterControl(
|
||
master_intensity=decode_u16(channels, 0) / 65535.0,
|
||
blackout=channels[2] >= self.TRIGGER_THRESHOLD,
|
||
freeze_output=channels[3] >= self.TRIGGER_THRESHOLD,
|
||
preset_bank=channels[4],
|
||
preset_index=decode_u16(channels, 5),
|
||
transition_type=channels[8],
|
||
transition_duration=decode_u16(channels, 9) / 65535.0,
|
||
global_speed=map_speed(speed_16),
|
||
bpm=20.0 + (bpm_16 / 65535.0) * 280.0, # 20..300 BPM
|
||
audio_reactive=channels[21] >= self.TRIGGER_THRESHOLD,
|
||
audio_gain=channels[22] / 255.0 * 2.0,
|
||
automation_ai_enable=channels[23] >= self.TRIGGER_THRESHOLD,
|
||
test_pattern=channels[24],
|
||
preview_enable=channels[25] >= self.TRIGGER_THRESHOLD,
|
||
global_hue=(channels[26] / 255.0) - 0.5,
|
||
global_saturation=channels[27] / 255.0 * 2.0,
|
||
fallback_preset=channels[28],
|
||
events=events,
|
||
)
|
||
|
||
|
||
# ---------- Layer64 (§16.4) ----------
|
||
|
||
|
||
class SourceType(StrEnum):
|
||
"""Kanal 4: Source Type (§16.4)."""
|
||
|
||
MEDIA = "media"
|
||
GENERATOR = "generator"
|
||
SOLID = "solid"
|
||
LIVE = "live" # später: Capture (§16.4)
|
||
|
||
|
||
class TransportCommand(StrEnum):
|
||
"""Kanal 10: Transport (§16.4)."""
|
||
|
||
STOP = "stop"
|
||
PLAY = "play"
|
||
PAUSE = "pause"
|
||
RETRIGGER = "retrigger"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LayerEvent:
|
||
"""Ereignisse des Layer-Fixtures (nur Flanken, §16.5)."""
|
||
|
||
kind: str # load_commit | retrigger
|
||
pending_bank: int = 0
|
||
pending_folder: int = 0
|
||
pending_index: int = 0
|
||
|
||
|
||
@dataclass
|
||
class LayerControl:
|
||
"""Dekodierte Layer64-Steuerdaten (§16.4).
|
||
|
||
source_block (Kanal 13-20) ist modusabhängig: bei Media =
|
||
Speed/Position/In/Out; bei Generator/Solid = G1..G8 (§16.4).
|
||
"""
|
||
|
||
enabled: bool = True # Kanal 1
|
||
opacity: float = 1.0 # 16 Bit Kanal 2-3
|
||
source_type: SourceType = SourceType.MEDIA # Kanal 4
|
||
media_bank: int = 0 # Kanal 5
|
||
media_folder: int = 0 # Kanal 6
|
||
media_index: int = 0 # 16 Bit Kanal 7-8 (pending bis Load)
|
||
transport: TransportCommand = TransportCommand.STOP # Kanal 10
|
||
loop_mode: int = 0 # Kanal 11: Enum
|
||
playback_direction: int = 0 # Kanal 12: Enum
|
||
speed: float = 1.0 # Kanal 13-14: signed Speed-Mapping (§16.6), Media-Modus
|
||
position: float = 0.0 # Kanal 15-16 → 0..1, Media-Modus
|
||
in_point: float = 0.0 # Kanal 17-18 → 0..1, Media-Modus
|
||
out_point: float = 1.0 # Kanal 19-20 → 0..1, Media-Modus
|
||
generator_params: tuple[float, ...] = () # G1..G8 (0..1), Generator/Solid
|
||
blend_mode: int = 0 # Kanal 21: Enum
|
||
position_x: float = 0.0 # 16 Bit signed Kanal 23-24 → -1..1
|
||
position_y: float = 0.0 # Kanal 25-26
|
||
scale_x: float = 1.0 # 16 Bit Kanal 27-28 → 0..4
|
||
scale_y: float = 1.0 # Kanal 29-30
|
||
rotation_deg: float = 0.0 # 16 Bit Kanal 31-32 → 0..360
|
||
crop_left: float = 0.0 # Kanal 33-36 → 0..1
|
||
crop_right: float = 0.0
|
||
crop_top: float = 0.0
|
||
crop_bottom: float = 0.0
|
||
hue: float = 0.0 # Kanal 37-40 → Reglerbereiche
|
||
saturation: float = 1.0
|
||
brightness: float = 1.0
|
||
contrast: float = 1.0
|
||
fx1_enabled: bool = False # Kanal 41
|
||
fx1_plugin: int = 0 # Kanal 42: Show-Registry
|
||
fx1_mix: float = 1.0 # Kanal 43 → 0..1
|
||
fx1_params: tuple[float, ...] = () # Kanal 44-51: P1-P8
|
||
fx2_enabled: bool = False # Kanal 52
|
||
fx2_plugin: int = 0 # Kanal 53
|
||
fx2_mix: float = 1.0 # Kanal 54
|
||
fx2_params: tuple[float, ...] = () # Kanal 55-62: P1-P8
|
||
events: list[LayerEvent] = field(default_factory=list)
|
||
|
||
|
||
def decode_u16(channels: bytes, msb_index: int) -> int:
|
||
"""16-Bit: MSB zuerst (DMX-Konvention)."""
|
||
return (channels[msb_index] << 8) | channels[msb_index + 1]
|
||
|
||
|
||
def decode_s16_normalized(channels: bytes, msb_index: int) -> float:
|
||
"""16-Bit signed → -1..1 (Position, §16.4)."""
|
||
raw = decode_u16(channels, msb_index)
|
||
return (raw / 32768.0) - 1.0
|
||
|
||
|
||
def map_speed(raw_u16: int) -> float:
|
||
"""Speed-Mapping nach §16.6 (signed 16-Bit):
|
||
|
||
- untere Hälfte (0..32767): −4× (bei 0) bis 0− (Richtung Mittelpunkt)
|
||
- Mittelpunkt (32768): Pause (0.0)
|
||
- obere Hälfte (32769..65535): 0+ bis +4× (Max konfiguriert)
|
||
- definierter Referenzwert 40960 entspricht exakt +1×
|
||
(denn (40960−32768)/32768 × 4 = 1.0; dokumentiert in fixtures und
|
||
Fixture-Handbuch)
|
||
- Totzone um Pause optional (hier nicht implementiert, §16.6)
|
||
"""
|
||
half = 32768
|
||
if raw_u16 == half:
|
||
return 0.0 # Mittelpunkt = Pause (§16.6)
|
||
if raw_u16 < half:
|
||
# untere Hälfte: 0 → −4x, Richtung 32768 → 0− (monoton steigend)
|
||
return -(1.0 - raw_u16 / half) * 4.0
|
||
# obere Hälfte: 32769 → 0+, Richtung 65535 → +4x (monoton steigend)
|
||
return ((raw_u16 - half) / half) * 4.0
|
||
|
||
|
||
class Layer64Engine:
|
||
"""Dekodiert ein 64-Kanal-DMX-Segment in LayerControl (§16.4/§16.5).
|
||
|
||
Load/Commit (§16.5):
|
||
- Änderungen an Bank/Folder/Index/SourceType erzeugen nur eine pending
|
||
selection (kein direktes Laden)
|
||
- erst eine steigende Flanke auf Kanal 9 (Load/Commit) emittiert ein
|
||
load_commit-Ereignis mit der vollständigen Auswahl
|
||
- Pickup/Takeover: nach Load/Commit gilt die Auswahl als bestätigt;
|
||
Parameteränderungen wirken sofort (keine Sprünge, da Werte erst
|
||
nach Commit neu interpretiert werden)
|
||
"""
|
||
|
||
TRIGGER_THRESHOLD = 128
|
||
|
||
def __init__(self) -> None:
|
||
self._prev: bytearray | None = None
|
||
self._pending_loaded = True # beim Start: keine pending selection
|
||
|
||
def decode(self, channels: bytes) -> LayerControl:
|
||
if len(channels) < 64:
|
||
raise ValueError(f"Layer64 benötigt 64 Kanäle, erhalten {len(channels)}")
|
||
prev = self._prev
|
||
curr = bytearray(channels[:64])
|
||
events: list[LayerEvent] = []
|
||
|
||
def _rising(idx: int) -> bool:
|
||
now = channels[idx] >= self.TRIGGER_THRESHOLD
|
||
before = bool(prev and prev[idx] >= self.TRIGGER_THRESHOLD)
|
||
return now and not before
|
||
|
||
source_type = _source_type_of(channels[3])
|
||
# pending selection (§16.5): Auswahl ändert sich, laden erst bei Flanke
|
||
pending_changed = (
|
||
prev is None
|
||
or curr[4] != prev[4]
|
||
or curr[5] != prev[5]
|
||
or decode_u16(curr, 6) != decode_u16(prev, 6)
|
||
or curr[3] != prev[3]
|
||
)
|
||
if pending_changed:
|
||
self._pending_loaded = False
|
||
|
||
if _rising(8): # Kanal 9: Load/Commit Selection
|
||
events.append(
|
||
LayerEvent(
|
||
kind="load_commit",
|
||
pending_bank=channels[4],
|
||
pending_folder=channels[5],
|
||
pending_index=decode_u16(channels, 6),
|
||
)
|
||
)
|
||
self._pending_loaded = True
|
||
if _rising(62): # Kanal 63: Layer Retrigger/Reset
|
||
events.append(LayerEvent(kind="retrigger"))
|
||
self._prev = curr
|
||
|
||
# modusabhängiger Source-Block (Kanäle 13-20, §16.4)
|
||
speed = 1.0
|
||
position = 0.0
|
||
in_point = 0.0
|
||
out_point = 1.0
|
||
generator_params: tuple[float, ...] = ()
|
||
if source_type is SourceType.MEDIA:
|
||
speed = map_speed(decode_u16(channels, 12))
|
||
position = decode_u16(channels, 14) / 65535.0
|
||
in_point = decode_u16(channels, 16) / 65535.0
|
||
out_point = decode_u16(channels, 18) / 65535.0
|
||
else:
|
||
# Generator/Solid/Live: G1..G8 über Kanäle 13-20 (§16.4)
|
||
generator_params = tuple(c / 255.0 for c in channels[12:20])
|
||
|
||
return LayerControl(
|
||
enabled=channels[0] >= self.TRIGGER_THRESHOLD,
|
||
opacity=decode_u16(channels, 1) / 65535.0,
|
||
source_type=source_type,
|
||
media_bank=channels[4],
|
||
media_folder=channels[5],
|
||
media_index=decode_u16(channels, 6),
|
||
transport=_transport_of(channels[9]),
|
||
loop_mode=channels[10],
|
||
playback_direction=channels[11],
|
||
speed=speed,
|
||
position=position,
|
||
in_point=in_point,
|
||
out_point=out_point,
|
||
generator_params=generator_params,
|
||
blend_mode=channels[20],
|
||
position_x=decode_s16_normalized(channels, 22),
|
||
position_y=decode_s16_normalized(channels, 24),
|
||
scale_x=decode_u16(channels, 26) / 65535.0 * 4.0,
|
||
scale_y=decode_u16(channels, 28) / 65535.0 * 4.0,
|
||
rotation_deg=decode_u16(channels, 30) / 65535.0 * 360.0,
|
||
crop_left=channels[32] / 255.0,
|
||
crop_right=channels[33] / 255.0,
|
||
crop_top=channels[34] / 255.0,
|
||
crop_bottom=channels[35] / 255.0,
|
||
hue=(channels[36] / 255.0) - 0.5,
|
||
saturation=channels[37] / 255.0 * 2.0,
|
||
brightness=channels[38] / 255.0 * 2.0,
|
||
contrast=channels[39] / 255.0 * 2.0,
|
||
fx1_enabled=channels[40] >= self.TRIGGER_THRESHOLD,
|
||
fx1_plugin=channels[41],
|
||
fx1_mix=channels[42] / 255.0,
|
||
fx1_params=tuple(c / 255.0 for c in channels[43:51]),
|
||
fx2_enabled=channels[51] >= self.TRIGGER_THRESHOLD,
|
||
fx2_plugin=channels[52],
|
||
fx2_mix=channels[53] / 255.0,
|
||
fx2_params=tuple(c / 255.0 for c in channels[54:62]),
|
||
events=events,
|
||
)
|
||
|
||
@property
|
||
def selection_pending(self) -> bool:
|
||
"""True, wenn eine Auswahl geändert, aber noch nicht committed wurde."""
|
||
return not self._pending_loaded
|
||
|
||
|
||
def _source_type_of(value: int) -> SourceType:
|
||
"""Kanal 4: 0=Media, 1=Generator, 2=Solid, 3=Live (§16.4)."""
|
||
mapping = {
|
||
0: SourceType.MEDIA,
|
||
1: SourceType.GENERATOR,
|
||
2: SourceType.SOLID,
|
||
3: SourceType.LIVE,
|
||
}
|
||
return mapping.get(value, SourceType.MEDIA)
|
||
|
||
|
||
def _transport_of(value: int) -> TransportCommand:
|
||
"""Kanal 10: 0=Stop, 1=Play, 2=Pause, 3=Retrigger (§16.4)."""
|
||
mapping = {
|
||
0: TransportCommand.STOP,
|
||
1: TransportCommand.PLAY,
|
||
2: TransportCommand.PAUSE,
|
||
3: TransportCommand.RETRIGGER,
|
||
}
|
||
return mapping.get(value, TransportCommand.STOP)
|
||
|
||
|
||
# ---------- Universe-Plan (§16.2 Mehrserver-Patch) ----------
|
||
|
||
|
||
class UniverseCollisionError(Exception):
|
||
"""Überlappende Universe-Bereiche zweier Nodes (§16.2: Blocker)."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class UniverseRange:
|
||
"""Zusammenhängender Universe-Bereich eines Nodes (§16.2)."""
|
||
|
||
node_id: str
|
||
first: int # inklusiv
|
||
last: int # inklusiv
|
||
|
||
|
||
class UniversePlan:
|
||
"""Zentraler Universe-Plan: kollisionsfreie Bereiche je Node (§16.2).
|
||
|
||
- add: registriert einen Bereich; Überschneidung = Fehler (Blocker vor
|
||
Show-Lock), kein stillsches Zusammenführen
|
||
- overlaps: Preflight-Prüfung vor dem Show-Lock
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._ranges: list[UniverseRange] = []
|
||
|
||
def add(self, rng: UniverseRange) -> None:
|
||
if rng.first > rng.last:
|
||
raise ValueError("first muss <= last sein")
|
||
for other in self._ranges:
|
||
if self._overlaps(rng, other):
|
||
raise UniverseCollisionError(
|
||
f"Universe-Kollision: {rng.node_id} [{rng.first}..{rng.last}] "
|
||
f"vs {other.node_id} [{other.first}..{other.last}] (§16.2)"
|
||
)
|
||
self._ranges.append(rng)
|
||
|
||
@staticmethod
|
||
def _overlaps(a: UniverseRange, b: UniverseRange) -> bool:
|
||
return not (a.last < b.first or b.last < a.first)
|
||
|
||
def overlaps(self, rng: UniverseRange) -> bool:
|
||
"""True, wenn der Bereich einen bestehenden schneidet (Preflight)."""
|
||
return any(self._overlaps(rng, other) for other in self._ranges)
|
||
|
||
def ranges(self) -> tuple[UniverseRange, ...]:
|
||
return tuple(self._ranges)
|