scsynth 0.1.0

A safe Rust wrapper for an embedded, in-process SuperCollider scsynth engine.
//! End-to-end, device-free proof of offline synthesis: render `Out.ar(0, SinOsc.ar(440))` to a
//! WAV via `World_NonRealTimeSynthesis` and assert it is a non-silent 440 Hz tone.

mod common;

use rosc::{OscBundle, OscPacket};
use scsynth::{Options, World};

const SAMPLE_RATE: u32 = 44_100;
const FREQ: f32 = 440.0;

#[test]
fn renders_sine_tone() {
    let dir = std::env::temp_dir().join(format!("scsynth-nrt-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let cmd_path = dir.join("commands.osc");
    let wav_path = dir.join("out.wav");

    std::fs::write(&cmd_path, nrt_command_file()).unwrap();

    let options = Options::new()
        .real_time(false)
        .input_channels(0)
        .output_channels(1)
        .sample_rate(SAMPLE_RATE)
        .block_size(64)
        .load_synthdefs(false)
        .verbosity(-1)
        .nrt_command_file(&cmd_path)
        .unwrap()
        .nrt_output_file(&wav_path, "WAV", "int16")
        .unwrap();

    let world = World::new(options).expect("World_New failed");
    world.non_realtime_render();

    // Read the rendered audio back.
    let mut reader = hound::WavReader::open(&wav_path).expect("output WAV not written");
    assert_eq!(reader.spec().channels, 1);
    assert_eq!(reader.spec().sample_rate, SAMPLE_RATE);
    let samples: Vec<f32> = reader
        .samples::<i16>()
        .map(|s| s.unwrap() as f32 / i16::MAX as f32)
        .collect();

    assert!(
        samples.len() > (SAMPLE_RATE / 4) as usize,
        "too few samples rendered: {}",
        samples.len()
    );
    assert!(common::rms(&samples) > 0.1, "output is ~silent");

    let sr = SAMPLE_RATE as f32;
    let at_440 = common::goertzel(&samples, sr, FREQ);
    let at_1000 = common::goertzel(&samples, sr, 1000.0);
    let at_220 = common::goertzel(&samples, sr, 220.0);
    assert!(
        at_440 > 10.0 * at_1000 && at_440 > 10.0 * at_220,
        "expected a dominant 440 Hz tone (440={at_440}, 1000={at_1000}, 220={at_220})"
    );

    let _ = std::fs::remove_dir_all(&dir);
}

/// The NRT command file: `[u32 BE length][OSC bundle]` records.
///
/// Bundle @ t=0: receive the SynthDef and start a synth. Bundle @ t=0.5s: free it (this final
/// timestamp also bounds how long the engine renders).
fn nrt_command_file() -> Vec<u8> {
    let start = OscPacket::Bundle(OscBundle {
        timetag: rosc::OscTime {
            seconds: 0,
            fractional: 0,
        },
        content: vec![
            OscPacket::Message(common::d_recv(common::synthdef_sine(FREQ, 1))),
            OscPacket::Message(common::s_new("sine", 1000, 0, 0)),
        ],
    });
    let stop = OscPacket::Bundle(OscBundle {
        // 0.5 s as an NTP timetag (fractional = 0.5 * 2^32).
        timetag: rosc::OscTime {
            seconds: 0,
            fractional: 0x8000_0000,
        },
        content: vec![OscPacket::Message(common::n_free(1000))],
    });

    let mut file = Vec::new();
    for bundle in [start, stop] {
        let bytes = rosc::encoder::encode(&bundle).unwrap();
        file.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
        file.extend_from_slice(&bytes);
    }
    file
}