sim-lib-stream-alsa 0.1.0

Modeled ALSA stream-host adapter (in-process simulation; no direct Linux PCM I/O).
Documentation
use std::sync::Arc;

use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Symbol};
use sim_lib_audio_graph_core::{Graph, PrepareConfig, ProcessBlock, Processor};
use sim_lib_stream_audio::{PcmSampleFormat, PcmSpec};
use sim_lib_stream_core::{BufferPolicy, PcmPacket, PushResult, StreamMedia, StreamPacket};
use sim_lib_stream_host::{
    AudioDeviceCard, AudioPlacementRequest, AudioRouter, AudioSiteKey, HostBackend,
    HostBackendRegistry, HostCallbackCassette, HostDirection, HostStreamConfigRequest,
    ModeledAudioSite,
};

use crate::{
    AlsaBackend, AlsaCaptureBridge, AlsaPcmDevice, AlsaPcmName, AlsaPcmNameKind,
    AlsaPlaybackBridge, alsa_backend_symbol, default_modeled_alsa_site, install_stream_alsa_lib,
};

#[derive(Debug)]
struct ConstantOutput {
    value: f32,
    prepared: Option<PrepareConfig>,
}

impl Processor for ConstantOutput {
    fn prepare(&mut self, cfg: PrepareConfig) {
        self.prepared = Some(cfg);
    }

    fn reset(&mut self) {
        self.prepared = None;
    }

    fn process(&mut self, block: &mut ProcessBlock<'_>) {
        let frames = block.frames as usize;
        for output in block.out_audio.iter_mut() {
            for sample in output.iter_mut().take(frames) {
                *sample = self.value;
            }
        }
    }
}

#[test]
fn fake_backend_enumerates_default_hw_and_plughw_pcm_devices() {
    let backend = AlsaBackend::fake();
    let inventory = backend.enumerate().unwrap();

    assert_eq!(inventory.backend(), &alsa_backend_symbol());
    assert!(inventory.devices().iter().any(|device| {
        device.id() == &Symbol::new("alsa/default/playback")
            && device.direction() == HostDirection::Output
    }));
    assert!(inventory.devices().iter().any(|device| {
        device.id() == &Symbol::new("alsa/default/capture")
            && device.direction() == HostDirection::Input
    }));
    assert!(
        inventory
            .devices()
            .iter()
            .any(|device| device.id() == &Symbol::new("alsa/hw:0,0/playback"))
    );
    assert!(
        inventory
            .devices()
            .iter()
            .any(|device| device.id() == &Symbol::new("alsa/plughw:1,0/capture"))
    );
}

#[test]
fn backend_opens_default_playback_and_capture_by_backend_id() {
    let mut registry = HostBackendRegistry::new();
    registry.register(AlsaBackend::fake()).unwrap();
    let backend = registry.backend(&alsa_backend_symbol()).unwrap();
    let inventory = backend.enumerate().unwrap();
    assert_eq!(inventory.devices().len(), 4);

    let playback = registry
        .open(HostStreamConfigRequest::new(
            alsa_backend_symbol(),
            Symbol::new("alsa/default/playback"),
            StreamMedia::Pcm,
            HostDirection::Output,
            BufferPolicy::bounded(8).unwrap(),
        ))
        .unwrap();
    assert_eq!(playback.config().backend(), &alsa_backend_symbol());
    assert_eq!(playback.config().clock().sample_rate_hz(), Some(48_000));

    let capture = registry
        .open(HostStreamConfigRequest::new(
            alsa_backend_symbol(),
            Symbol::new("alsa/default/capture"),
            StreamMedia::Pcm,
            HostDirection::Input,
            BufferPolicy::bounded(8).unwrap(),
        ))
        .unwrap();
    assert_eq!(
        capture.config().device(),
        &Symbol::new("alsa/default/capture")
    );
}

#[test]
fn default_open_helpers_use_default_pcm_fallbacks() {
    let backend = AlsaBackend::fake();

    assert_eq!(
        backend.open_default_playback(4).unwrap().config().device(),
        &Symbol::new("alsa/default/playback")
    );
    assert_eq!(
        backend.open_default_capture(4).unwrap().config().device(),
        &Symbol::new("alsa/default/capture")
    );
}

#[test]
fn modeled_alsa_site_placement_round_trip() {
    let mut router = AudioRouter::new();
    router.register(default_modeled_alsa_site());

    let key = AudioSiteKey::new("sim:alsa-modeled");
    assert!(router.site(&key).is_some());
    let opened = router
        .open_placement(AudioPlacementRequest {
            site_key: key,
            stream_request: HostStreamConfigRequest::new(
                alsa_backend_symbol(),
                Symbol::new("alsa/default/playback"),
                StreamMedia::Pcm,
                HostDirection::Output,
                BufferPolicy::bounded(8).unwrap(),
            ),
        })
        .unwrap();

    assert_eq!(opened.config().media(), StreamMedia::Pcm);
    opened.close().unwrap();
}

#[test]
fn same_graph_two_modeled_sites() {
    let mono_key = AudioSiteKey::new("sim:test-mono");
    let stereo_key = AudioSiteKey::new("sim:test-stereo");
    let mut router = AudioRouter::new();
    router.register(Arc::new(ModeledAudioSite::new(
        AudioDeviceCard {
            key: mono_key.clone(),
            display_name: "Modeled Mono".to_owned(),
            channels_out: 1,
            channels_in: 0,
            sample_rates: vec![48_000],
            hardware_required: false,
        },
        Arc::new(AlsaBackend::fake()),
    )));
    router.register(Arc::new(ModeledAudioSite::new(
        AudioDeviceCard {
            key: stereo_key.clone(),
            display_name: "Modeled Stereo".to_owned(),
            channels_out: 2,
            channels_in: 0,
            sample_rates: vec![48_000],
            hardware_required: false,
        },
        Arc::new(AlsaBackend::fake()),
    )));
    assert_eq!(
        router.sites_by_capability(2, &[48_000]),
        vec![stereo_key.clone()]
    );

    let mut graph = make_test_pcm_graph();
    let frames = 3;
    graph.prepare(48_000, frames).unwrap();
    let rendered = graph.process_offline(&[], frames).unwrap();

    for key in [mono_key, stereo_key] {
        let site = router.site(&key).unwrap();
        let expected_channels = usize::from(site.card().channels_out);
        let opened = router
            .open_placement(AudioPlacementRequest {
                site_key: key,
                stream_request: HostStreamConfigRequest::new(
                    alsa_backend_symbol(),
                    Symbol::new("alsa/default/playback"),
                    StreamMedia::Pcm,
                    HostDirection::Output,
                    BufferPolicy::bounded(8).unwrap(),
                ),
            })
            .unwrap();
        let expected_packet =
            packet_from_graph_output(&rendered, expected_channels, frames as usize);

        opened
            .queue()
            .callback_packet(StreamPacket::Pcm(expected_packet.clone()))
            .unwrap();
        let delivered = opened.queue().drain(1).unwrap();
        let StreamPacket::Pcm(packet) = delivered[0].packet() else {
            panic!("expected PCM packet");
        };
        assert_eq!(packet, &expected_packet);
        assert_eq!(packet.channels(), expected_channels);
        opened.close().unwrap();
    }
}

#[test]
fn pcm_name_parser_accepts_documented_alsa_names() {
    let default = AlsaPcmName::parse("default").unwrap();
    let hw = AlsaPcmName::parse("hw:2,0").unwrap();
    let plughw = AlsaPcmName::parse("plughw:USB").unwrap();

    assert_eq!(default.kind(), AlsaPcmNameKind::Default);
    assert_eq!(hw.kind(), AlsaPcmNameKind::Hw);
    assert_eq!(plughw.kind(), AlsaPcmNameKind::PlugHw);
    assert!(AlsaPcmName::parse("pulse").is_err());
    assert!(AlsaPcmName::parse("hw:").is_err());
}

fn make_test_pcm_graph() -> Graph {
    let mut graph = Graph::new();
    graph
        .add_node(
            "constant",
            Box::new(ConstantOutput {
                value: 0.25,
                prepared: None,
            }),
            0,
            2,
        )
        .unwrap();
    graph
}

fn packet_from_graph_output(output: &[Vec<f32>], channels: usize, frames: usize) -> PcmPacket {
    let mut samples = Vec::with_capacity(channels * frames);
    for frame in 0..frames {
        for channel in output.iter().take(channels) {
            samples.push(channel[frame]);
        }
    }
    PcmPacket::f32(channels, frames, samples).unwrap()
}

#[test]
fn explicit_open_rejects_wrong_direction() {
    let backend = AlsaBackend::new(vec![
        AlsaPcmDevice::playback("hw:0,0", "Output", 2, 48_000).unwrap(),
    ]);
    let request = HostStreamConfigRequest::new(
        alsa_backend_symbol(),
        Symbol::new("alsa/hw:0,0/playback"),
        StreamMedia::Pcm,
        HostDirection::Input,
        BufferPolicy::bounded(8).unwrap(),
    );

    assert!(backend.open(request).is_err());
}

#[test]
fn playback_bridge_drives_processor_and_returns_pcm_buffer() {
    let spec = PcmSpec::f32(2, 48_000).unwrap();
    let mut bridge = AlsaPlaybackBridge::new(
        ConstantOutput {
            value: 0.25,
            prepared: None,
        },
        spec,
        8,
    )
    .unwrap();
    let rendered = bridge.render_buffer(3).unwrap();

    assert_eq!(rendered.spec().sample_format(), PcmSampleFormat::F32);
    assert_eq!(rendered.frames(), 3);
    assert_eq!(rendered.samples_f32(), &[0.25; 6]);
    assert_eq!(bridge.sample_pos(), 3);
}

#[test]
fn capture_bridge_records_host_input_as_pcm_packets() {
    let opened = AlsaBackend::fake().open_default_capture(4).unwrap();
    let bridge = AlsaCaptureBridge::new(opened.queue().clone(), PcmSpec::f32(2, 48_000).unwrap());

    assert_eq!(
        bridge
            .capture_interleaved_i16(&[i16::MAX, 0, 0, i16::MIN])
            .unwrap(),
        PushResult::Accepted
    );
    let items = opened.queue().drain(4).unwrap();
    assert_eq!(items.len(), 1);
    let StreamPacket::Pcm(packet) = items[0].packet() else {
        panic!("expected PCM packet");
    };
    assert_eq!(packet.frames(), 2);
    assert_eq!(packet.channels(), 2);
    assert_eq!(packet.sample_format(), PcmSampleFormat::F32);
}

#[test]
fn capture_queue_replays_pcm_cassette_without_hardware() {
    let opened = AlsaBackend::fake().open_default_capture(4).unwrap();
    let mut cassette = HostCallbackCassette::new();
    cassette.record_packet(StreamPacket::Pcm(
        sim_lib_stream_core::PcmPacket::f32(1, 2, vec![0.0, 0.5]).unwrap(),
    ));

    cassette.replay(opened.queue()).unwrap();

    assert_eq!(opened.queue().drain(4).unwrap().len(), 1);
}

#[test]
fn install_stream_alsa_lib_registers_runtime_exports() {
    let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
    sim_test_support::assert_lib_exports(
        &mut cx,
        install_stream_alsa_lib,
        &Symbol::new("stream-alsa"),
        &[Symbol::qualified("stream", "AlsaBackend")],
    );
}

#[test]
#[ignore = "hardware smoke test requires an operator-provided ALSA PCM device"]
fn alsa_hardware_smoke_test_is_ignored_by_default() {
    let backend = AlsaBackend::default();
    let _devices = backend.list_devices();
}