Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled

P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
This commit is contained in:
2026-05-19 20:39:17 -04:00
parent 7b123762b5
commit 9cd8292acc
97 changed files with 26545 additions and 5 deletions
+977
View File
@@ -0,0 +1,977 @@
"""Tests for the mixer DSP engine components.
Tests are designed to run without JACK or Carla — they validate
the logic, state management, and parameter dispatch without
requiring actual audio hardware.
"""
from __future__ import annotations
import pytest
import time
import math
from src.mixer.osc_client import (
encode_osc,
decode_osc,
CarlaOSCClient,
CarlaPluginInfo,
DEFAULT_PLUGIN_LAYOUT,
linear_to_db,
db_to_linear,
freq_to_normalized,
normalized_to_freq,
time_ms_to_normalized,
mix_to_pan,
)
from src.mixer.channel_strip import (
ChannelStrip,
ChannelState,
_apply_state,
_transform_value,
_get_osc_commands,
_normalize_param,
)
from src.mixer.routing_matrix import (
RoutingMatrix,
RouteNode,
RoutingEdge,
NodeType,
)
from src.mixer.bus_manager import (
BusManager,
AuxBus,
SubgroupBus,
VCAGroup,
MasterBus,
BusType,
)
from src.mixer.fader_automation import (
FaderAutomation,
AutomationLane,
AutomationPoint,
Scene,
InterpolationMode,
_interpolate,
)
from src.mixer.dsp_engine import (
DSPEngine,
DSPEngineConfig,
create_default_engine,
_make_param_key,
_parse_param_key,
)
from src.midi.types import (
ParameterType,
ParameterCategory,
MixerParameter,
)
# ═════════════════════════════════════════════════════════════════════════════
# OSC Protocol Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestOSCEncoding:
"""Test OSC message encoding/decoding."""
def test_encode_float(self):
packet = encode_osc("/test", 1.0)
assert b"/test" in packet
assert b",f" in packet
def test_encode_int(self):
packet = encode_osc("/test", 42)
assert b"/test" in packet
assert b",i" in packet
def test_encode_string(self):
packet = encode_osc("/test", "hello")
assert b"/test" in packet
assert b",s" in packet
assert b"hello" in packet
def test_encode_multiple_args(self):
packet = encode_osc("/Carla/1/set_parameter_value", 3, 0, 0.5)
assert b"/Carla/1/set_parameter_value" in packet
assert b",iif" in packet
def test_decode_float(self):
packet = encode_osc("/test", 3.14)
addr, args = decode_osc(packet)
assert addr == "/test"
assert len(args) == 1
assert abs(args[0] - 3.14) < 0.001
def test_decode_int(self):
packet = encode_osc("/test", 99)
addr, args = decode_osc(packet)
assert addr == "/test"
assert args[0] == 99
def test_decode_string(self):
packet = encode_osc("/test", "world")
addr, args = decode_osc(packet)
assert addr == "/test"
assert args[0] == "world"
def test_decode_multiple(self):
packet = encode_osc("/path", 1.0, 42, "abc")
addr, args = decode_osc(packet)
assert addr == "/path"
assert len(args) == 3
assert abs(args[0] - 1.0) < 0.001
assert args[1] == 42
assert args[2] == "abc"
def test_roundtrip(self):
"""Encode then decode should return the same values."""
original_addr = "/some/path"
original_args = [0.75, 128, "test_value"]
packet = encode_osc(original_addr, *original_args)
addr, args = decode_osc(packet)
assert addr == original_addr
assert len(args) == len(original_args)
for a, b in zip(args, original_args):
if isinstance(a, float) and isinstance(b, float):
assert abs(a - b) < 0.001
else:
assert a == b
class TestCarlaOSCClient:
"""Test the OSC client (without actual network)."""
def test_client_creation(self):
client = CarlaOSCClient()
assert client.host == "127.0.0.1"
assert client.port == 22752
client.close()
def test_client_context_manager(self):
with CarlaOSCClient() as client:
assert client.host == "127.0.0.1"
def test_stats(self):
client = CarlaOSCClient()
stats = client.stats
assert stats["host"] == "127.0.0.1"
assert stats["sends"] == 0
assert stats["errors"] == 0
client.close()
# ═════════════════════════════════════════════════════════════════════════════
# Value Conversion Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestValueConversions:
def test_linear_to_db_silence(self):
# With default min_db=-60.0, linear=0 returns -inf (true digital silence)
assert linear_to_db(0.0) == float("-inf")
def test_linear_to_db_unity(self):
assert abs(linear_to_db(0.5) - (-24.0)) < 1.0
def test_linear_to_db_max(self):
assert abs(linear_to_db(1.0) - 12.0) < 0.1
def test_db_to_linear_roundtrip(self):
# Skip -60 (floor): mathematically db_to_linear(-60)=0.0, linear_to_db(0.0)=-inf
for db in [-40, -20, 0, 6, 12]:
lin = db_to_linear(db)
db2 = linear_to_db(lin)
assert abs(db - db2) < 0.5, f"Roundtrip failed at {db} dB"
def test_freq_to_normalized(self):
assert freq_to_normalized(20) < 0.01
assert freq_to_normalized(20000) > 0.99
assert 0.4 < freq_to_normalized(1000) < 0.6
def test_normalized_to_freq(self):
for hz in [50, 200, 1000, 5000, 15000]:
norm = freq_to_normalized(hz)
hz2 = normalized_to_freq(norm)
ratio = hz2 / hz
assert 0.8 < ratio < 1.25, f"Failed at {hz} Hz: got {hz2:.0f}"
def test_time_ms_to_normalized(self):
assert time_ms_to_normalized(0.1) < 0.01
assert time_ms_to_normalized(2000) > 0.99
assert 0.0 < time_ms_to_normalized(100) < 0.5
def test_mix_to_pan_center(self):
l, r = mix_to_pan(1.0, 0.0) # mix full, pan center
assert abs(l - r) < 0.001
assert abs(l - 0.707) < 0.01 # cos(pi/4)
def test_mix_to_pan_left(self):
l, r = mix_to_pan(1.0, -1.0)
assert l > 0.99
assert r < 0.01
def test_mix_to_pan_right(self):
l, r = mix_to_pan(1.0, 1.0)
assert l < 0.01
assert r > 0.99
# ═════════════════════════════════════════════════════════════════════════════
# Channel Strip Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestChannelStrip:
def test_default_state(self):
strip = ChannelStrip(index=0)
assert strip.index == 0
assert strip.state.volume == 0.0
assert strip.state.mute is False
assert strip.state.pan == 0.0
def test_set_volume(self):
strip = ChannelStrip(index=0)
strip.set_parameter(ParameterType.VOLUME, -6.0)
assert strip.state.volume == -6.0
def test_set_mute(self):
strip = ChannelStrip(index=0)
strip.set_parameter(ParameterType.MUTE, 1.0)
assert strip.state.mute is True
strip.set_parameter(ParameterType.MUTE, 0.0)
assert strip.state.mute is False
def test_set_eq(self):
strip = ChannelStrip(index=3)
strip.set_parameter(ParameterType.EQ_LOW_FREQ, 120.0)
strip.set_parameter(ParameterType.EQ_LOW_GAIN, 3.0)
strip.set_parameter(ParameterType.EQ_LOW_Q, 1.5)
assert strip.state.eq_low_freq == 120.0
assert strip.state.eq_low_gain == 3.0
assert strip.state.eq_low_q == 1.5
def test_set_compressor(self):
strip = ChannelStrip(index=1)
strip.set_parameter(ParameterType.COMP_THRESHOLD, -25.0)
strip.set_parameter(ParameterType.COMP_RATIO, 4.0)
strip.set_parameter(ParameterType.COMP_ATTACK, 5.0)
assert strip.state.comp_threshold == -25.0
assert strip.state.comp_ratio == 4.0
assert strip.state.comp_attack == 5.0
def test_set_gate(self):
strip = ChannelStrip(index=2)
strip.set_parameter(ParameterType.GATE_THRESHOLD, -50.0)
strip.set_parameter(ParameterType.GATE_RANGE, -70.0)
assert strip.state.gate_threshold == -50.0
assert strip.state.gate_range == -70.0
def test_set_fx_sends(self):
strip = ChannelStrip(index=0)
strip.set_parameter(ParameterType.FX_SEND_A, -10.0)
strip.set_parameter(ParameterType.FX_SEND_B, -20.0)
assert strip.state.fx_send_a == -10.0
assert strip.state.fx_send_b == -20.0
def test_snapshot_restore(self):
strip = ChannelStrip(index=5)
strip.set_parameter(ParameterType.VOLUME, -12.0)
strip.set_parameter(ParameterType.PAN, 0.5)
snap = strip.snapshot()
assert snap.volume == -12.0
assert snap.pan == 0.5
# Modify
strip.set_parameter(ParameterType.VOLUME, 0.0)
assert strip.state.volume == 0.0
# Restore
strip.restore(snap, send_osc=False)
assert strip.state.volume == -12.0
assert strip.state.pan == 0.5
def test_reset(self):
strip = ChannelStrip(index=0)
strip.set_parameter(ParameterType.VOLUME, 6.0)
strip.set_parameter(ParameterType.MUTE, 1.0)
strip.reset()
assert strip.state.volume == 0.0
assert strip.state.mute is False
def test_has_dsp(self):
strip = ChannelStrip(index=0)
assert strip.has_dsp is False
plugin = CarlaPluginInfo(1, "Test Gate", "gate", 0, {"threshold": 0})
strip.register_plugin(plugin)
assert strip.has_dsp is True
def test_set_many(self):
strip = ChannelStrip(index=0)
strip.set_many({
ParameterType.VOLUME: -3.0,
ParameterType.PAN: 0.5,
ParameterType.MUTE: 1.0,
})
assert strip.state.volume == -3.0
assert strip.state.pan == 0.5
assert strip.state.mute is True
def test_plugin_registration(self):
strip = ChannelStrip(index=3)
plugin = CarlaPluginInfo(10, "Ch4 EQ", "eq", 3, {"low_freq": 0, "low_gain": 1})
strip.register_plugin(plugin)
assert "eq" in strip._plugins
assert strip._plugins["eq"].plugin_id == 10
# ═════════════════════════════════════════════════════════════════════════════
# Routing Matrix Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestRoutingMatrix:
def test_add_node(self):
rm = RoutingMatrix()
node = rm.add_node(RouteNode("test", NodeType.CHANNEL_INPUT, "Test"))
assert rm.get_node("test") is node
def test_connect(self):
rm = RoutingMatrix()
src = rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
dst = rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
edge = rm.connect("src", "dst", gain_db=-6.0, apply_jack=False)
assert edge is not None
assert edge.gain_db == -6.0
def test_connect_nonexistent(self):
rm = RoutingMatrix()
edge = rm.connect("a", "b")
assert edge is None
def test_disconnect(self):
rm = RoutingMatrix()
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
rm.connect("src", "dst", apply_jack=False)
assert len(rm._edges) == 1
assert rm.disconnect("src", "dst", apply_jack=False)
assert len(rm._edges) == 0
def test_set_gain(self):
rm = RoutingMatrix()
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
rm.connect("src", "dst", apply_jack=False)
assert rm.set_gain("src", "dst", -12.0)
edge = rm.get_edges("src")[0]
assert edge.gain_db == -12.0
def test_set_mute(self):
rm = RoutingMatrix()
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
rm.connect("src", "dst", apply_jack=False)
rm.set_mute("src", "dst", True)
edge = rm.get_edges("src")[0]
assert edge.muted is True
def test_solo_logic(self):
rm = RoutingMatrix()
src1 = rm.add_node(RouteNode("src1", NodeType.CHANNEL_INPUT))
src2 = rm.add_node(RouteNode("src2", NodeType.CHANNEL_INPUT))
dst = rm.add_node(RouteNode("dst", NodeType.MASTER_INPUT))
rm.connect("src1", "dst", apply_jack=False)
rm.connect("src2", "dst", apply_jack=False)
rm.set_solo("src1", True)
edges = rm.get_edges()
edge1 = [e for e in edges if e.source.node_id == "src1"][0]
edge2 = [e for e in edges if e.source.node_id == "src2"][0]
assert edge1.soloed is True
assert edge2.soloed is False
rm.clear_solo()
edges = rm.get_edges()
assert all(not e.soloed for e in edges)
def test_remove_node_cleans_edges(self):
rm = RoutingMatrix()
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
rm.connect("src", "dst", apply_jack=False)
assert len(rm._edges) == 1
rm.remove_node("src")
assert len(rm._edges) == 0
assert rm.get_node("src") is None
def test_build_default_8ch(self):
rm = RoutingMatrix()
rm.build_default_8ch_matrix()
# Should have system inputs (8), system outputs (2), channel nodes (16),
# aux nodes (8: 4 buses × 2), subgroups (2), master (3)
assert len(rm._nodes) >= 8 + 2
# Should have connections: 8 sys_in → channels, 8 channels → master, 2 master → sys_out
assert len(rm._edges) >= 8 + 8 + 2
def test_serialization_roundtrip(self):
rm = RoutingMatrix()
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT, "Input"))
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT, "Channel"))
rm.connect("src", "dst", gain_db=-3.0, apply_jack=False)
data = rm.to_dict()
rm2 = RoutingMatrix()
rm2.from_dict(data)
assert len(rm2._nodes) == 2
assert len(rm2._edges) == 1
assert rm2.get_node("src") is not None
assert rm2._edges[0].gain_db == -3.0
# ═════════════════════════════════════════════════════════════════════════════
# Bus Manager Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestBusManager:
def test_default_initialization(self):
bm = BusManager(num_channels=16, num_aux=4, num_subgroups=2, num_vca=2)
assert len(bm.aux_buses) == 4
assert len(bm.subgroups) == 2
assert len(bm.vca_groups) == 2
assert bm.master.volume_db == 0.0
def test_aux_send(self):
bm = BusManager(num_channels=8, num_aux=4)
bm.set_aux_send(0, 3, -10.0)
aux = bm.get_aux(0)
assert aux.get_send(3) == -10.0
def test_aux_return(self):
bm = BusManager(num_channels=8, num_aux=4)
bm.set_aux_return(0, -5.0)
assert bm.get_aux(0).return_gain_db == -5.0
def test_aux_mute(self):
bm = BusManager(num_channels=8, num_aux=4)
bm.set_aux_mute(1, True)
assert bm.get_aux(1).muted is True
def test_aux_out_of_range(self):
bm = BusManager(num_channels=8, num_aux=2)
assert bm.get_aux(5) is None
def test_subgroup_membership(self):
bm = BusManager(num_channels=8, num_aux=2, num_subgroups=2)
assert bm.subgroup_add_channel(0, 3)
assert 3 in bm.subgroups[0].members
assert bm.subgroup_remove_channel(0, 3)
assert 3 not in bm.subgroups[0].members
def test_vca_membership(self):
bm = BusManager(num_channels=8, num_aux=2, num_vca=2)
bm.vca_add_channel(0, 5)
bm.vca_add_channel(1, 5)
vcas = bm.get_channel_vcas(5)
assert len(vcas) == 2
def test_vca_offset(self):
bm = BusManager(num_channels=8, num_vca=2)
bm.vca_add_channel(0, 2)
vca = bm.get_vca(0)
vca.master_db = -5.0
assert bm.get_channel_vca_offset(2) == -5.0
def test_vca_multiple_offset(self):
bm = BusManager(num_channels=8, num_vca=2)
bm.vca_add_channel(0, 2)
bm.vca_add_channel(1, 2)
bm.get_vca(0).master_db = -3.0
bm.get_vca(1).master_db = -2.0
assert bm.get_channel_vca_offset(2) == -5.0
def test_vca_muted_offset(self):
bm = BusManager(num_channels=8, num_vca=2)
bm.vca_add_channel(0, 2)
bm.get_vca(0).master_db = -5.0
bm.get_vca(0).muted = True
assert bm.get_channel_vca_offset(2) == 0.0 # muted VCA doesn't contribute
def test_master_operations(self):
bm = BusManager()
bm.set_master_volume(-10.0)
bm.set_master_mute(True)
bm.set_master_dim(True)
assert bm.master.volume_db == -10.0
assert bm.master.muted is True
assert bm.master.dim_active is True
# Effective gain with mute = 0
assert bm.master.effective_gain == 0.0
def test_master_dim_gain(self):
bm = BusManager()
bm.master.dim_db = -20.0
bm.master.dim_active = True
# -20 dB dim
assert abs(bm.master.effective_gain - 0.1) < 0.001
def test_serialization_roundtrip(self):
bm = BusManager(num_channels=4, num_aux=2, num_subgroups=1, num_vca=1)
bm.set_aux_send(0, 2, -6.0)
bm.subgroup_add_channel(0, 1)
bm.vca_add_channel(0, 0)
bm.get_vca(0).master_db = -3.0
bm.set_master_volume(-12.0)
data = bm.to_dict()
bm2 = BusManager(num_channels=4, num_aux=2, num_subgroups=1, num_vca=1)
bm2.from_dict(data)
assert bm2.get_aux(0).get_send(2) == -6.0
assert 1 in bm2.subgroups[0].members
assert 0 in bm2.vca_groups[0].members
assert bm2.master.volume_db == -12.0
# ═════════════════════════════════════════════════════════════════════════════
# Fader Automation Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestInterpolation:
def test_linear(self):
assert _interpolate(0.0, 0, 10, InterpolationMode.LINEAR) == 0
assert _interpolate(0.5, 0, 10, InterpolationMode.LINEAR) == 5
assert _interpolate(1.0, 0, 10, InterpolationMode.LINEAR) == 10
def test_s_curve(self):
assert _interpolate(0.0, 0, 10, InterpolationMode.S_CURVE) == 0
assert _interpolate(0.5, 0, 10, InterpolationMode.S_CURVE) == 5 # smoothstep at 0.5 = 0.5
assert _interpolate(1.0, 0, 10, InterpolationMode.S_CURVE) == 10
def test_logarithmic(self):
v = _interpolate(0.5, 1, 100, InterpolationMode.LOGARITHMIC)
assert 5 < v < 15 # roughly sqrt(100) = 10
def test_clamp(self):
assert _interpolate(-0.5, 0, 10, InterpolationMode.LINEAR) == 0
assert _interpolate(1.5, 0, 10, InterpolationMode.LINEAR) == 10
class TestAutomationLane:
def test_add_point_sorted(self):
lane = AutomationLane("test")
lane.add_point(2.0, 0.5)
lane.add_point(1.0, 0.2)
lane.add_point(3.0, 0.8)
assert [p.time_sec for p in lane.points] == [1.0, 2.0, 3.0]
def test_get_value_at_exact(self):
lane = AutomationLane("test")
lane.add_point(1.0, 0.2)
lane.add_point(2.0, 0.8)
assert abs(lane.get_value_at(1.5) - 0.5) < 0.01
def test_get_value_at_before(self):
lane = AutomationLane("test")
lane.add_point(1.0, 0.2)
assert abs(lane.get_value_at(0.0) - 0.2) < 0.01
def test_get_value_at_after(self):
lane = AutomationLane("test")
lane.add_point(1.0, 0.2)
lane.add_point(2.0, 0.8)
assert abs(lane.get_value_at(3.0) - 0.8) < 0.01
def test_get_value_empty(self):
lane = AutomationLane("test")
assert lane.get_value_at(1.0, default=-1.0) == -1.0
def test_clone(self):
lane = AutomationLane("test")
lane.add_point(1.0, 0.5)
clone = lane.clone()
clone.points[0].value = 0.9
assert lane.points[0].value == 0.5 # original unchanged
class TestFaderAutomation:
def test_recording(self):
fa = FaderAutomation()
fa.start_recording()
assert fa.is_recording
time.sleep(0.01)
fa.record("test_param", 0.5)
fa.stop_recording()
assert not fa.is_recording
assert "test_param" in fa._lanes
assert len(fa._lanes["test_param"].points) > 0
def test_not_recording_when_stopped(self):
fa = FaderAutomation()
fa.record("test", 0.5)
assert "test" not in fa._lanes
def test_playback(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("vol", 0.0)
time.sleep(0.02)
fa.record("vol", 1.0)
fa.stop_recording()
lane = fa._lanes["vol"]
assert len(lane.points) == 2
def test_scene_save_load(self):
fa = FaderAutomation()
scene = fa.save_scene("TestScene", {0: {"volume": -6.0}}, {}, {})
assert "TestScene" in fa.list_scenes()
loaded = fa.load_scene("TestScene")
assert loaded.name == "TestScene"
def test_scene_delete(self):
fa = FaderAutomation()
fa.save_scene("DeleteMe", {}, {}, {})
assert fa.delete_scene("DeleteMe")
assert "DeleteMe" not in fa.list_scenes()
def test_scene_recall(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("param", 0.25)
fa.stop_recording()
fa.save_scene("S1", {0: {"volume": -6.0}}, {}, {})
fa.recall_scene("S1")
assert fa._current_scene == "S1"
def test_crossfade(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("param", 0.0)
fa.stop_recording()
fa.save_scene("Target", {}, {}, {})
# Start very short crossfade
result = fa.crossfade_to("Target", 0.05)
assert result is True
assert fa._crossfade_active
# Let it complete
time.sleep(0.1)
fa.update_crossfade()
assert not fa._crossfade_active
def test_crossfade_missing_scene(self):
fa = FaderAutomation()
assert fa.crossfade_to("Nonexistent", 1.0) is False
def test_cancel_crossfade(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("param", 0.0)
fa.stop_recording()
fa.save_scene("Target", {}, {}, {})
fa.crossfade_to("Target", 10.0)
assert fa._crossfade_active
fa.cancel_crossfade()
assert not fa._crossfade_active
def test_seek(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("param", 0.0)
time.sleep(0.02)
fa.record("param", 1.0)
fa.stop_recording()
fa.start_playback()
fa.seek(1000.0) # seek way past
val = fa.get_value("param")
assert abs(val - 1.0) < 0.01
def test_get_duration(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("a", 0.0)
time.sleep(0.05)
fa.record("a", 0.5)
time.sleep(0.05)
fa.record("b", 1.0)
fa.stop_recording()
duration = fa.get_duration()
assert duration > 0.08 # at least 100ms total
def test_serialization_roundtrip(self):
fa = FaderAutomation()
fa.start_recording()
fa.record("param1", 0.3)
fa.stop_recording()
fa.save_scene("Test", {}, {}, {})
data = fa.to_dict()
fa2 = FaderAutomation()
fa2.from_dict(data)
assert len(fa2._lanes) == 1
assert "Test" in fa2.list_scenes()
# ═════════════════════════════════════════════════════════════════════════════
# DSP Engine Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestDSPEngine:
def test_create_default_engine(self):
engine = create_default_engine(num_channels=8, osc_enabled=False)
assert len(engine.channels) == 8
assert engine.config.num_aux == 4
assert not engine.running
def test_start_stop(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
assert engine.running
engine.stop()
assert not engine.running
def test_handle_channel_volume(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
param = MixerParameter(
param_type=ParameterType.VOLUME,
category=ParameterCategory.CHANNEL,
channel=2,
)
engine.handle_parameter(param, -12.0)
assert engine.channels[2].state.volume == -12.0
def test_handle_mute(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
param = MixerParameter(
param_type=ParameterType.MUTE,
category=ParameterCategory.CHANNEL,
channel=0,
)
engine.handle_parameter(param, 1.0)
assert engine.channels[0].state.mute is True
def test_handle_master_volume(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
param = MixerParameter(
param_type=ParameterType.MASTER_VOLUME,
category=ParameterCategory.MASTER,
channel=-1,
)
engine.handle_parameter(param, -20.0)
assert engine.buses.master.volume_db == -20.0
def test_handle_fx_return(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
param = MixerParameter(
param_type=ParameterType.FX_RETURN_A,
category=ParameterCategory.FX,
channel=-1,
)
engine.handle_parameter(param, -6.0)
assert engine.buses.get_aux(0).return_gain_db == -6.0
def test_handle_eq_params(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
param = MixerParameter(
param_type=ParameterType.EQ_MID_GAIN,
category=ParameterCategory.CHANNEL,
channel=1,
)
engine.handle_parameter(param, 4.0)
assert engine.channels[1].state.eq_mid_gain == 4.0
def test_snapshot_save_load(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
# Set some state
engine.channels[0].set_parameter(ParameterType.VOLUME, -6.0)
engine.buses.set_master_volume(-3.0)
# Save
engine.save_snapshot("test_snap")
# Modify
engine.channels[0].set_parameter(ParameterType.VOLUME, 0.0)
engine.buses.set_master_volume(0.0)
# Load
assert engine.load_snapshot("test_snap")
assert engine.channels[0].state.volume == -6.0
assert engine.buses.master.volume_db == -3.0
def test_param_key_helpers(self):
key = _make_param_key(ParameterCategory.CHANNEL, 3, ParameterType.VOLUME)
cat, ch, pt = _parse_param_key(key)
assert cat == ParameterCategory.CHANNEL
assert ch == 3
assert pt == ParameterType.VOLUME
def test_param_key_master(self):
key = _make_param_key(ParameterCategory.MASTER, -1, ParameterType.MASTER_VOLUME)
cat, ch, pt = _parse_param_key(key)
assert cat == ParameterCategory.MASTER
assert ch == -1
assert pt == ParameterType.MASTER_VOLUME
def test_stats(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
stats = engine.stats
assert stats["running"]
assert stats["num_channels"] == 4
assert not stats["osc_connected"]
def test_to_dict_from_dict(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
engine.channels[0].set_parameter(ParameterType.VOLUME, -10.0)
data = engine.to_dict()
engine2 = create_default_engine(num_channels=4, osc_enabled=False)
engine2.from_dict(data)
assert engine2.channels[0].state.volume == -10.0
def test_next_prev_scene(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
engine.save_snapshot("A")
engine.save_snapshot("B")
engine.save_snapshot("C")
assert engine.load_snapshot("B")
assert engine.automation._current_scene == "B"
# Bound to the scenes
assert engine.next_scene() # goes to C
assert engine.next_scene() # wraps to A
assert engine.prev_scene() # goes to C
def test_automation_recording_handling(self):
engine = create_default_engine(num_channels=4, osc_enabled=False)
engine.start(connect_osc=False)
engine.automation.start_recording()
param = MixerParameter(
param_type=ParameterType.VOLUME,
category=ParameterCategory.CHANNEL,
channel=0,
)
engine.handle_parameter(param, -5.0)
key = _make_param_key(ParameterCategory.CHANNEL, 0, ParameterType.VOLUME)
assert key in engine.automation._lanes
# ═════════════════════════════════════════════════════════════════════════════
# Integration Tests
# ═════════════════════════════════════════════════════════════════════════════
class TestIntegration:
def test_full_signal_chain_state(self):
"""Test that the full signal chain can be configured through the engine."""
engine = create_default_engine(num_channels=16, osc_enabled=False)
engine.start(connect_osc=False)
# Configure channel 0
params_to_set = [
(ParameterType.GAIN, 20.0),
(ParameterType.EQ_LOW_FREQ, 80.0),
(ParameterType.EQ_LOW_GAIN, 3.0),
(ParameterType.EQ_MID_FREQ, 2500.0),
(ParameterType.EQ_MID_GAIN, -2.0),
(ParameterType.EQ_HIGH_FREQ, 8000.0),
(ParameterType.EQ_HIGH_GAIN, 1.5),
(ParameterType.COMP_THRESHOLD, -18.0),
(ParameterType.COMP_RATIO, 3.0),
(ParameterType.GATE_THRESHOLD, -45.0),
(ParameterType.FX_SEND_A, -8.0),
(ParameterType.VOLUME, -3.0),
(ParameterType.PAN, 0.2),
]
for pt, val in params_to_set:
param = MixerParameter(pt, ParameterCategory.CHANNEL, 0)
engine.handle_parameter(param, val)
ch0 = engine.channels[0]
assert ch0.state.gain == 20.0
assert ch0.state.eq_low_freq == 80.0
assert ch0.state.comp_threshold == -18.0
assert ch0.state.volume == -3.0
def test_multiple_channels_independent(self):
"""Test that channels maintain independent state."""
engine = create_default_engine(num_channels=8, osc_enabled=False)
engine.start(connect_osc=False)
# Set different volumes on different channels
for ch, vol in [(0, -6.0), (3, 0.0), (7, 6.0)]:
param = MixerParameter(ParameterType.VOLUME, ParameterCategory.CHANNEL, ch)
engine.handle_parameter(param, vol)
assert engine.channels[0].state.volume == -6.0
assert engine.channels[3].state.volume == 0.0
assert engine.channels[7].state.volume == 6.0
assert engine.channels[1].state.volume == 0.0 # unchanged
def test_bus_routing_persistence(self):
"""Test that bus state persists across snapshot save/load."""
engine = create_default_engine(num_channels=8, osc_enabled=False)
engine.start(connect_osc=False)
# Set up complex bus state
engine.buses.set_aux_send(0, 1, -12.0)
engine.buses.set_aux_send(2, 3, -4.0)
engine.buses.subgroup_add_channel(0, 0)
engine.buses.subgroup_add_channel(0, 4)
engine.buses.vca_add_channel(0, 2)
engine.buses.get_vca(0).master_db = -5.0
# Save and verify restore
snap = engine.save_snapshot("complex")
engine.load_snapshot("complex")
assert engine.buses.get_aux(0).get_send(1) == -12.0
assert engine.buses.get_aux(2).get_send(3) == -4.0
assert 0 in engine.buses.subgroups[0].members
assert 2 in engine.buses.vca_groups[0].members