scsynth 0.1.0

A safe Rust wrapper for an embedded, in-process SuperCollider scsynth engine.
//! Proves the reply path: a realtime engine's OSC replies (`/done`, node notifications like
//! `/n_end`) are captured by the safe `World` API and can be drained with [`World::poll_reply`].
//!
//! This is the native verification of the logic that also drives the wasm build (where replies
//! arrive inline instead of on the NRT helper thread).

mod common;

use scsynth::{Options, World};

const SAMPLE_RATE: u32 = 44_100;
const BLOCK: usize = 64;
const CHANNELS: usize = 2;
const FREQ: f32 = 440.0;

#[test]
fn captures_done_and_node_replies() {
    let mut world = World::new(
        Options::new()
            .real_time(true)
            .input_channels(0)
            .output_channels(CHANNELS as u32)
            .sample_rate(SAMPLE_RATE)
            .block_size(BLOCK as u32)
            .load_synthdefs(false)
            .verbosity(-1),
    )
    .expect("World_New failed");

    // Register for node notifications (`/n_go`, `/n_end`, ...). This installs our reply address -
    // and so our reply context - into the server's user list, the channel node notifications are
    // broadcast over. It also replies `/done`.
    world.send_packet(&common::encode(common::notify(true)));
    common::pump_for_reply(&mut world, "/done", CHANNELS, 200);

    // `/d_recv` replies `/done` once the SynthDef has been parsed and installed.
    world.send_packet(&common::encode(common::d_recv(common::synthdef_sine(
        FREQ, CHANNELS,
    ))));
    let done = common::pump_for_reply(&mut world, "/done", CHANNELS, 200);
    assert_eq!(
        common::reply_addr(&done).as_deref(),
        Some("/done"),
        "expected a `/done` reply for `/d_recv`"
    );

    // Start the synth and confirm it produces the expected 440 Hz tone (the reply context must not
    // disturb synthesis).
    world.send_packet(&common::encode(common::s_new("sine", 1000, 0, 0)));
    let mut output = vec![0f32; BLOCK * 64 * CHANNELS];
    for _ in 0..10 {
        world.process(&[], 0, &mut output, CHANNELS);
    }
    let sr = SAMPLE_RATE as f32;
    for ch in 0..CHANNELS {
        let channel: Vec<f32> = output[ch..].iter().step_by(CHANNELS).copied().collect();
        let at_440 = common::goertzel(&channel, sr, FREQ);
        let at_1000 = common::goertzel(&channel, sr, 1000.0);
        let at_220 = common::goertzel(&channel, sr, 220.0);
        assert!(common::rms(&channel) > 0.1, "channel {ch} is ~silent");
        assert!(
            at_440 > 10.0 * at_1000 && at_440 > 10.0 * at_220,
            "channel {ch}: expected a dominant 440 Hz tone (440={at_440}, 1000={at_1000}, 220={at_220})"
        );
    }

    // Freeing the node emits an `/n_end` notification to every registered user (we registered
    // above), reaching our sink through the reply context stored on that user's address - proving
    // node notifications propagate, not just immediate command replies.
    world.send_packet(&common::encode(common::n_free(1000)));
    let n_end = common::pump_for_reply(&mut world, "/n_end", CHANNELS, 200);
    assert_eq!(
        common::reply_addr(&n_end).as_deref(),
        Some("/n_end"),
        "expected an `/n_end` reply for the freed node"
    );
}