c2071a9724
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
264 lines
8.5 KiB
Python
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 |