"""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