Files
shawn c2071a9724 feat(audio): Focusrite 2i2 stereo JACK config for 4CM
Adds:
- AudioConfig.mode field: 'mono' (default) or 'stereo_4cm'
- capture_channels / playback_channels properties for dynamic JACK -i/-o counts
- FOCUSRITE_PROFILES dict with focusrite_2i2_3gen entry
- AudioSystem.channel_mapping_help() static method
- stereo_4cm port auto-connect wiring (4 cable method)
- Systemd service content uses dynamic channel counts
- Default config YAML includes 'mode: mono'
- 11 new tests (31 total pass)
- Focusrite 4CM wiring docs in docs/config-audio.md
2026-06-08 10:40:38 -04:00

264 lines
8.5 KiB
Python

"""Tests for system/audio.py - AudioSystem, AudioConfig, and helpers."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from system.audio import (
AudioConfig,
AudioSystem,
LATENCY_PROFILES,
I2S_CONFIGS,
FOCUSRITE_PROFILES,
CONFIG_TXT,
JACK_SERVICE_PATH,
LIMITS_CONF,
)
# ── AudioConfig tests ─────────────────────────────────────────────
def test_audio_config_defaults():
cfg = AudioConfig()
assert cfg.hat_type == "audioinjector"
assert cfg.profile == "standard"
assert cfg.input_device == "hw:0,0"
assert cfg.output_device == "hw:0,0"
assert cfg.jack_enabled is True
assert cfg.auto_connect is True
assert cfg.xrun_warn_only is True
def test_audio_config_overlay_line():
for key, expected_line in [
("audioinjector", "dtoverlay=audioinjector-wm8731"),
("pcm1808_pcm5102", "dtoverlay=audiosense-pi"),
("iqaudio_codec", "dtoverlay=iqaudio-codec"),
("justboom", "dtoverlay=justboom-dac"),
("wm8731", "dtoverlay=wm8731"),
]:
cfg = AudioConfig(hat_type=key)
assert cfg.overlay_line == expected_line, f"{key}: {cfg.overlay_line}"
def test_audio_config_unknown_hat():
cfg = AudioConfig(hat_type="nonexistent-hat")
assert cfg.overlay_line is None
def test_audio_config_latency_profile():
cfg = AudioConfig(profile="standard")
assert cfg.latency_profile["period"] == 128
assert cfg.latency_profile["nperiods"] == 2
assert cfg.latency_profile["rate"] == 48000
assert cfg.latency_profile["rt_priority"] == 70
cfg2 = AudioConfig(profile="low")
assert cfg2.latency_profile["period"] == 64
assert cfg2.latency_profile["rt_priority"] == 80
def test_audio_config_unknown_profile():
cfg = AudioConfig(profile="bogus")
# Should fall back to standard
assert cfg.latency_profile["period"] == 128
def test_audio_config_jack_device_arg():
cfg = AudioConfig(output_device="hw:1,0")
assert cfg.jack_device_arg == "dhw:1,0"
# ── Latency buffer time computation ───────────────────────────────
def test_latency_profiles_buffer_times():
for name, profile in LATENCY_PROFILES.items():
period_ms = (profile["period"] / profile["rate"]) * 1000
buffer_ms = period_ms * profile["nperiods"]
# Standard: 128/48000 * 2 = 5.33ms; Low: 64/48000 * 2 = 2.67ms
assert buffer_ms < 10.0, f"{name}: {buffer_ms:.2f} ms >= 10 ms"
assert buffer_ms > 0
# ── I2S config entries ────────────────────────────────────────────
def test_i2s_configs_have_all_fields():
for key, (line, card, desc) in I2S_CONFIGS.items():
assert line.startswith("dtoverlay="), f"{key}: bad overlay line"
assert isinstance(card, int) and card >= 0, f"{key}: bad card index"
assert len(desc) > 10, f"{key}: missing description"
# ── AudioSystem tests ─────────────────────────────────────────────
def test_audio_system_init():
sys = AudioSystem()
assert sys.config.jack_enabled is True
assert isinstance(sys.config, AudioConfig)
def test_audio_system_custom_config():
cfg = AudioConfig(hat_type="justboom", profile="low")
sys = AudioSystem(config=cfg)
assert sys.config.hat_type == "justboom"
assert sys.config.profile == "low"
def test_audio_system_list_devices_no_alsa():
# On a non-RPi / without ALSA tools, list_devices returns []
sys = AudioSystem()
devices = sys.list_devices()
assert isinstance(devices, list)
def test_audio_system_stop_jack_no_crash():
"""stop_jack should not crash even if jackd is not running."""
sys = AudioSystem()
# Should finish without exception
sys.stop_jack()
def test_audio_system_read_xrun_count_no_jack():
"""read_xrun_count returns None when JACK isn't running."""
result = AudioSystem.read_xrun_count()
assert result is None or isinstance(result, int)
def test_audio_system_get_active_overlay():
"""get_active_overlay should not crash on a non-RPi."""
result = AudioSystem.get_active_overlay()
# May be None or a string — just shouldn't crash
assert result is None or isinstance(result, str)
def test_audio_system_monitor_xruns_no_jack():
"""monitor_xruns should not crash when JACK isn't running."""
sys = AudioSystem()
result = sys.monitor_xruns(duration=1, interval=1)
assert isinstance(result, dict)
assert "xrun_total" in result
assert "stable" in result
def test_audio_system_measure_roundtrip_latency():
"""measure_roundtrip_latency returns None when jack_iodelay is missing."""
result = AudioSystem.measure_roundtrip_latency(samples=1, timeout=2)
assert result is None # jack_iodelay not installed on dev machine
# ── Systemd service content tests ─────────────────────────────────
def test_systemd_service_content_default():
cfg = AudioConfig()
content = AudioSystem.systemd_service_content(cfg)
assert "[Unit]" in content
assert "Description=JACK Audio Server" in content
assert "ExecStart=/usr/bin/jackd" in content
assert "-P70" in content
assert "-p128" in content
assert "-n2" in content
assert "-r48000" in content
assert "LimitRTPRIO=95" in content
assert "LimitMEMLOCK=infinity" in content
assert "WantedBy=multi-user.target" in content
def test_systemd_service_content_low_latency():
cfg = AudioConfig(profile="low")
content = AudioSystem.systemd_service_content(cfg)
assert "-P80" in content # higher RT priority for low profile
assert "-p64" in content # smaller period
def test_systemd_service_content_different_hat():
cfg = AudioConfig(hat_type="justboom")
content = AudioSystem.systemd_service_content(cfg)
assert "-dhw:0" in content # same device default
def test_systemd_service_content_custom_device():
cfg = AudioConfig(output_device="hw:2,0")
content = AudioSystem.systemd_service_content(cfg)
assert "-dhw:2,0" in content
# ── Mode / stereo_4cm tests ───────────────────────────────────────
def test_audio_config_mode_default():
cfg = AudioConfig()
assert cfg.mode == "mono"
def test_audio_config_mode_stereo_4cm():
cfg = AudioConfig(mode="stereo_4cm")
assert cfg.mode == "stereo_4cm"
def test_audio_config_channel_counts_mono():
cfg = AudioConfig(mode="mono")
assert cfg.capture_channels == 1
assert cfg.playback_channels == 1
def test_audio_config_channel_counts_stereo_4cm():
cfg = AudioConfig(mode="stereo_4cm")
assert cfg.capture_channels == 2
assert cfg.playback_channels == 2
def test_audio_config_focusrite_profile_hit():
cfg = AudioConfig(hat_type="focusrite_2i2_3gen")
prof = cfg.focusrite_profile
assert prof is not None
desc, cap, play = prof
assert "Focusrite" in desc
assert cap == 2
assert play == 2
def test_audio_config_focusrite_profile_miss():
cfg = AudioConfig()
assert cfg.focusrite_profile is None
def test_focusrite_profiles_entries():
for key, (desc, cap, play) in FOCUSRITE_PROFILES.items():
assert len(desc) > 10
assert isinstance(cap, int) and cap > 0
assert isinstance(play, int) and play > 0
def test_channel_mapping_help_mono():
mapping = AudioSystem.channel_mapping_help("mono")
assert len(mapping) == 1
assert mapping[0]["capture_port"] == "system:capture_1"
assert mapping[0]["playback_port"] == "system:playback_1"
def test_channel_mapping_help_stereo_4cm():
mapping = AudioSystem.channel_mapping_help("stereo_4cm")
assert len(mapping) == 2
assert "Guitar" in mapping[0]["description"]
assert "FX Send" in mapping[1]["description"]
def test_channel_mapping_help_unknown():
import pytest
with pytest.raises(ValueError, match="Unknown mode"):
AudioSystem.channel_mapping_help("bogus")
# ── Module exports ────────────────────────────────────────────────
def test_module_exports():
from system import AudioConfig as AC, AudioSystem as AS
assert AC is AudioConfig
assert AS is AudioSystem