sim-lib-stream-bridge 0.1.0

MIDI/audio bridge helpers for STREAM 6.
Documentation
use sim_kernel::{Expr, Symbol};
use sim_lib_midi_core::{
    Channel, ChannelMessage, MetaEvent, MidiEvent, MidiPayload, TickTime, U7, synthetic_origin,
};
use sim_lib_stream_core::{
    BufferPolicy, MidiPacket, PcmPacket, StreamDirection, StreamItem, StreamMedia, StreamMetadata,
    StreamPacket, StreamValue,
};
use sim_lib_stream_midi::{midi_event_to_packet_event, midi_packet_to_events};

use crate::{
    StreamBridgeLiftMidiOptions, StreamBridgeRenderOptions, lift_pcm_stream_to_midi,
    render_midi_stream_to_pcm,
};

#[test]
fn note_on_off_renders_non_empty_pcm() {
    let output = render_midi_stream_to_pcm(
        &midi_stream(vec![note_on(0, 69), note_off(480, 69)]),
        StreamBridgeRenderOptions::default(),
    )
    .unwrap();

    let samples = pcm_samples(&output.stream);

    assert!(samples.iter().any(|sample| *sample != 0));
}

#[test]
fn tempo_change_moves_frame_placement_deterministically() {
    let fast = first_nonzero_frame(&rendered_samples(500_000));
    let slow = first_nonzero_frame(&rendered_samples(1_000_000));
    let slow_again = first_nonzero_frame(&rendered_samples(1_000_000));

    assert_eq!(slow, slow_again);
    assert!(slow > fast + 20_000, "fast={fast} slow={slow}");
}

#[test]
fn sine_input_lifts_to_expected_midi_note_range() {
    let output = lift_pcm_stream_to_midi(
        &pcm_stream(&sine(440.0, 48_000, 0.25, 1.0)),
        StreamBridgeLiftMidiOptions::default(),
    )
    .unwrap();

    let notes = midi_note_ons(&output.stream);

    assert!(
        notes.iter().any(|note| (68..=70).contains(note)),
        "{notes:?}"
    );
}

#[test]
fn silence_lifts_to_no_notes() {
    let output = lift_pcm_stream_to_midi(
        &pcm_stream(&vec![0; 48_000 / 4]),
        StreamBridgeLiftMidiOptions::default(),
    )
    .unwrap();

    assert!(midi_note_ons(&output.stream).is_empty());
}

#[test]
fn low_confidence_emits_diagnostics() {
    let output = lift_pcm_stream_to_midi(
        &pcm_stream(&sine(440.0, 48_000, 0.25, 1.0)),
        StreamBridgeLiftMidiOptions {
            min_confidence: 0.99,
            ..StreamBridgeLiftMidiOptions::default()
        },
    )
    .unwrap();

    assert!(
        diagnostic_messages(&output.stream)
            .iter()
            .any(|message| message.contains("no note candidates"))
    );
}

#[test]
fn bridge_media_adapters_reject_data_packets() {
    let render_err =
        match render_midi_stream_to_pcm(&data_stream(), StreamBridgeRenderOptions::default()) {
            Ok(_) => panic!("MIDI render unexpectedly accepted data packets"),
            Err(error) => error,
        };
    assert!(format!("{render_err}").contains("expects MIDI stream packets"));

    let lift_err =
        match lift_pcm_stream_to_midi(&data_stream(), StreamBridgeLiftMidiOptions::default()) {
            Ok(_) => panic!("PCM lift unexpectedly accepted data packets"),
            Err(error) => error,
        };
    assert!(format!("{lift_err}").contains("expects PCM stream packets"));
}

#[test]
fn citizen_bridge_option_descriptors_round_trip() {
    let mut cx = sim_kernel::Cx::new(
        std::sync::Arc::new(sim_kernel::NoopEvalPolicy),
        std::sync::Arc::new(sim_kernel::DefaultFactory),
    );
    cx.load_lib(&sim_citizen::CitizenLib::all()).unwrap();

    sim_citizen::check_default_fixture::<StreamBridgeRenderOptions>(&mut cx).unwrap();
    sim_citizen::check_default_fixture::<StreamBridgeLiftMidiOptions>(&mut cx).unwrap();
}

fn rendered_samples(us_per_quarter: u32) -> Vec<i16> {
    let output = render_midi_stream_to_pcm(
        &midi_stream(vec![
            tempo(0, us_per_quarter),
            note_on(480, 69),
            note_off(960, 69),
        ]),
        StreamBridgeRenderOptions::default(),
    )
    .unwrap();
    pcm_samples(&output.stream)
}

fn midi_stream(events: Vec<MidiEvent>) -> StreamValue {
    let packet = MidiPacket::new(
        events
            .iter()
            .map(midi_event_to_packet_event)
            .collect::<sim_kernel::Result<Vec<_>>>()
            .unwrap(),
    )
    .unwrap();
    StreamValue::pull(
        metadata(StreamMedia::Midi, SymbolSuffix::Midi),
        vec![StreamItem::new(StreamPacket::Midi(packet))],
    )
}

fn pcm_stream(samples: &[i16]) -> StreamValue {
    let packet = PcmPacket::i16(1, samples.len(), samples.to_vec()).unwrap();
    StreamValue::pull(
        metadata(StreamMedia::Pcm, SymbolSuffix::Pcm),
        vec![StreamItem::new(StreamPacket::Pcm(packet))],
    )
}

fn data_stream() -> StreamValue {
    StreamValue::pull(
        metadata(StreamMedia::Data, SymbolSuffix::Data),
        vec![StreamItem::new(StreamPacket::data(
            Symbol::qualified("stream/data", "expr"),
            Expr::String("payload".to_owned()),
        ))],
    )
}

fn pcm_samples(stream: &StreamValue) -> Vec<i16> {
    let mut out = Vec::new();
    while let Some(item) = stream.next_packet().unwrap() {
        if let StreamPacket::Pcm(packet) = item.packet() {
            out.extend_from_slice(packet.samples_i16());
        }
    }
    out
}

fn midi_note_ons(stream: &StreamValue) -> Vec<u8> {
    let mut out = Vec::new();
    while let Some(item) = stream.next_packet().unwrap() {
        let StreamPacket::Midi(packet) = item.packet() else {
            continue;
        };
        for event in midi_packet_to_events(packet).unwrap() {
            if let MidiPayload::Channel(ChannelMessage::NoteOn { key, vel, .. }) = event.payload
                && vel.0 > 0
            {
                out.push(key.0);
            }
        }
    }
    out
}

fn diagnostic_messages(stream: &StreamValue) -> Vec<String> {
    let mut out = Vec::new();
    while let Some(item) = stream.next_packet().unwrap() {
        if let StreamPacket::Diagnostic(packet) = item.packet() {
            out.push(packet.message().to_owned());
        }
    }
    out
}

fn first_nonzero_frame(samples: &[i16]) -> usize {
    samples
        .chunks(2)
        .position(|frame| frame.iter().any(|sample| *sample != 0))
        .unwrap()
}

fn sine(hz: f64, sample_rate: u32, seconds: f64, gain: f64) -> Vec<i16> {
    let samples = (seconds * f64::from(sample_rate)).round() as usize;
    (0..samples)
        .map(|index| {
            let t = index as f64 / f64::from(sample_rate);
            ((std::f64::consts::TAU * hz * t).sin() * gain * f64::from(i16::MAX)).round() as i16
        })
        .collect()
}

fn note_on(ticks: i64, key: u8) -> MidiEvent {
    event(
        ticks,
        MidiPayload::Channel(ChannelMessage::NoteOn {
            ch: Channel(0),
            key: U7(key),
            vel: U7(100),
        }),
    )
}

fn note_off(ticks: i64, key: u8) -> MidiEvent {
    event(
        ticks,
        MidiPayload::Channel(ChannelMessage::NoteOff {
            ch: Channel(0),
            key: U7(key),
            vel: U7(0),
        }),
    )
}

fn tempo(ticks: i64, us_per_quarter: u32) -> MidiEvent {
    event(
        ticks,
        MidiPayload::Meta(MetaEvent::Tempo { us_per_quarter }),
    )
}

fn event(ticks: i64, payload: MidiPayload) -> MidiEvent {
    MidiEvent {
        time: TickTime::new(ticks, 480).unwrap(),
        origin: synthetic_origin(),
        payload,
    }
}

enum SymbolSuffix {
    Midi,
    Pcm,
    Data,
}

fn metadata(media: StreamMedia, suffix: SymbolSuffix) -> StreamMetadata {
    let suffix = match suffix {
        SymbolSuffix::Midi => "test-midi",
        SymbolSuffix::Pcm => "test-pcm",
        SymbolSuffix::Data => "test-data",
    };
    StreamMetadata::new(
        sim_kernel::Symbol::qualified("stream/bridge", suffix),
        media,
        StreamDirection::Source,
        sim_kernel::Symbol::qualified("stream/clock", "test"),
        BufferPolicy::bounded(8).unwrap(),
    )
}