use super::*;
use crate::dsl::Adsr;
use crate::patch::Patch;
fn saw_patch() -> Patch {
serde_json::from_str(
r#"{ "doc": { "name":"lead", "duration":1.0, "engine":2, "root": { "type":"chain", "stages": [
{ "type":"sawtooth", "freq":220 },
{ "type":"lowpass", "cutoff":1800, "q":0.8 } ] } },
"params": [ { "name":"pitch", "paths":["root.stages[0].freq"], "min":20, "max":8000, "default":220 } ] }"#,
)
.unwrap()
}
fn peak(b: &[f32]) -> f32 {
b.iter().fold(0.0f32, |m, &x| m.max(x.abs()))
}
fn bits(b: &[f32]) -> Vec<u32> {
b.iter().map(|x| x.to_bits()).collect()
}
#[test]
fn note_maths() {
assert!((Note::A4.freq() - 440.0).abs() < 1e-3);
assert!((Note::C4.freq() - 261.6256).abs() < 1e-2);
assert_eq!(Note::parse("A4"), Some(Note::A4));
assert_eq!(Note::parse("midi:60"), Some(Note::C4));
assert_eq!(Note::C4.transpose(12), Note(72));
}
#[test]
fn plays_polyphonic_pitched_notes() {
let mut inst = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
inst.note_on(Note::C4, 0.9);
inst.note_on(Note(64), 0.8); inst.note_on(Note(67), 0.7); assert_eq!(inst.active_voices(), 3);
let mut out = vec![0.0f32; 512 * 2];
assert_eq!(inst.fill(&mut out), 512);
assert!(peak(&out) > 0.0, "chord makes sound");
assert!((0..512).all(|f| out[f * 2] == out[f * 2 + 1]));
}
#[test]
fn note_off_releases_then_culls() {
let amp = Adsr {
a: 0.001,
d: 0.001,
s: 0.8,
r: 0.01,
punch: 0.0,
};
let design = InstrumentDesign::new(saw_patch()).with_amp(amp);
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::A4, 1.0);
assert_eq!(inst.active_voices(), 1);
let mut out = vec![0.0f32; 256 * 2];
inst.fill(&mut out);
inst.note_off(Note::A4);
let mut tail = vec![0.0f32; 2048 * 2];
inst.fill(&mut tail);
assert_eq!(inst.active_voices(), 0, "released voice is culled");
}
#[test]
fn transpose_makes_any_sound_playable() {
let patch: Patch = serde_json::from_str(
r#"{ "doc": { "name":"buzz", "duration":1.0, "engine":2, "root": { "type":"sawtooth", "freq":220 } } }"#,
)
.unwrap();
let design = InstrumentDesign::new(patch); assert!(matches!(design.pitch, PitchMap::Transpose { .. }));
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::C4, 1.0);
inst.note_on(Note(72), 1.0); let mut out = vec![0.0f32; 256 * 2];
inst.fill(&mut out);
assert!(peak(&out) > 0.0);
}
#[test]
fn pitch_bend_repitches_live() {
let mut a = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
a.note_on(Note::A4, 1.0);
a.set_bend(12.0);
let mut b = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
b.note_on(Note(81), 1.0); let (mut oa, mut ob) = (vec![0.0f32; 2048], vec![0.0f32; 2048]);
a.fill(&mut oa);
b.fill(&mut ob);
assert_eq!(bits(&oa), bits(&ob), "A4 + octave bend == A5");
}
#[test]
fn centered_bend_is_a_no_op() {
let mut bent = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
bent.note_on(Note::C4, 0.8);
bent.set_bend(0.0); let mut plain = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
plain.note_on(Note::C4, 0.8);
let (mut ob, mut op) = (vec![0.0f32; 1024], vec![0.0f32; 1024]);
bent.fill(&mut ob);
plain.fill(&mut op);
assert_eq!(bits(&ob), bits(&op), "a centered wheel changes nothing");
}
#[test]
fn brightness_sweeps_the_voice_filter() {
let rms = |s: &[f32]| (s.iter().map(|x| x * x).sum::<f32>() / s.len() as f32).sqrt();
let mut bright = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
bright.note_on(Note::C4, 0.9);
let mut a = vec![0.0f32; 1024 * 2];
bright.fill(&mut a);
let mut dark = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
dark.set_brightness(0.15);
dark.note_on(Note::C4, 0.9);
let mut b = vec![0.0f32; 1024 * 2];
dark.fill(&mut b);
assert!(rms(&b) < rms(&a), "lower brightness darkens a new note");
bright.set_brightness(0.15);
let mut c = vec![0.0f32; 1024 * 2];
bright.fill(&mut c);
assert!(
rms(&c) < rms(&a),
"live brightness sweep darkens a held note"
);
}
#[test]
fn vibrato_moves_the_sound_wobble_still_sounds() {
let mut dry = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
let mut vib = Instrument::new(
InstrumentDesign::new(saw_patch()).with_vibrato(6.0, 50.0),
48_000,
)
.unwrap();
dry.note_on(Note::A4, 1.0);
vib.note_on(Note::A4, 1.0);
let (mut a, mut b) = (vec![0.0f32; 4096 * 2], vec![0.0f32; 4096 * 2]);
dry.fill(&mut a);
vib.fill(&mut b);
assert!(bits(&a) != bits(&b), "vibrato changes the sound");
assert!(peak(&b) > 0.0);
let mut wob = Instrument::new(
InstrumentDesign::new(saw_patch()).with_wobble(4.0, 1.5),
48_000,
)
.unwrap();
wob.note_on(Note::C4, 0.9);
let mut w = vec![0.0f32; 4096 * 2];
wob.fill(&mut w);
assert!(peak(&w) > 0.0, "wobble instrument sounds");
}
#[test]
fn voice_stealing_caps_polyphony() {
let design = InstrumentDesign::new(saw_patch()).with_max_voices(4);
let mut inst = Instrument::new(design, 48_000).unwrap();
for n in 60..70 {
inst.note_on(Note(n), 0.8);
}
let mut out = vec![0.0f32; 1024 * 2];
inst.fill(&mut out);
assert_eq!(
inst.active_voices(),
4,
"capped at max_voices once steals declick"
);
}
#[test]
fn voice_stealing_ramps_instead_of_cutting() {
let amp = Adsr {
a: 0.001,
d: 0.0,
s: 1.0,
r: 0.3,
punch: 0.0,
};
let design = InstrumentDesign::new(saw_patch())
.with_amp(amp)
.with_max_voices(1);
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::A4, 1.0);
let mut warm = vec![0.0f32; 512 * 2];
inst.fill(&mut warm); inst.note_on(Note::C4, 1.0); let mut fade = vec![0.0f32; 512 * 2];
inst.fill(&mut fade);
let mut max_jump = 0.0f32;
let mut prev = warm[warm.len() - 2];
for f in 0..512 {
max_jump = max_jump.max((fade[f * 2] - prev).abs());
prev = fade[f * 2];
}
assert!(max_jump < 0.5, "steal clicked: max sample jump {max_jump}");
}
#[test]
fn note_names_round_trip_and_convert() {
assert_eq!(Note::A4.to_string(), "A4");
assert_eq!(Note::C4.to_string(), "C4");
assert_eq!("F#3".parse::<Note>().unwrap().to_string(), "F#3");
assert_eq!(Note::from(60u8), Note::C4);
assert!("nonsense".parse::<Note>().is_err());
}
#[test]
fn transpose_scales_all_pitched_nodes() {
let mut doc: SoundDoc = serde_json::from_str(
r#"{ "name":"b", "duration":0.1, "root": { "type":"chain", "stages": [
{ "type":"sawtooth", "freq":100 },
{ "type":"ringmod", "freq":200 },
{ "type":"modal", "modes":[{ "freq":300, "decay":0.3, "gain":1.0 }], "mix":0.5 } ] } }"#,
)
.unwrap();
transpose(&mut doc.root, 2.0);
let v = serde_json::to_value(&doc).unwrap();
let stages = &v["root"]["stages"];
assert_eq!(stages[0]["freq"], 200.0);
assert_eq!(stages[1]["freq"], 400.0);
assert_eq!(stages[2]["modes"][0]["freq"], 600.0);
}
#[test]
fn handles_and_note_off_count() {
let mut inst = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
let h = inst.note_on(Note::C4, 0.9);
assert!(inst.is_active(h));
assert_eq!(inst.voice_note(h), Some(Note::C4));
assert!(inst.note_off(Note::C4));
assert!(!inst.note_off(Note::C4), "already releasing");
assert!(!inst.note_off(Note(80)), "no such note");
}
#[test]
fn set_param_validates_and_note_on_never_panics() {
let mut inst = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
assert!(!inst.set_param("nope", 1.0), "unknown param rejected");
assert!(inst.set_param("pitch", 300.0), "valid value accepted");
inst.note_on(Note::A4, 1.0);
assert_eq!(inst.active_voices(), 1);
}
#[test]
fn percussive_voice_culls_without_note_off() {
let amp = Adsr {
a: 0.001,
d: 0.02,
s: 0.0,
r: 0.05,
punch: 0.0,
};
let design = InstrumentDesign::new(saw_patch()).with_amp(amp);
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::C4, 1.0);
let mut out = vec![0.0f32; 2048 * 2];
inst.fill(&mut out); assert_eq!(
inst.active_voices(),
0,
"percussive one-shot reclaims its voice"
);
}
#[test]
fn master_reverb_tail_outlives_the_voice() {
let reverb: Node =
serde_json::from_str(r#"{ "type":"reverb", "room":0.8, "mix":0.6 }"#).unwrap();
let design = InstrumentDesign::new(saw_patch()).with_master(vec![reverb]);
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::A4, 1.0);
let mut out = vec![0.0f32; 256 * 2];
inst.fill(&mut out);
inst.all_notes_off();
for _ in 0..40 {
inst.fill(&mut out); }
assert_eq!(inst.active_voices(), 0);
let mut tail = vec![0.0f32; 256 * 2];
inst.fill(&mut tail);
assert!(
peak(&tail) > 0.0,
"shared reverb tail continues past the note"
);
}
#[test]
fn sustain_pedal_defers_release() {
let amp = Adsr {
a: 0.001,
d: 0.001,
s: 0.8,
r: 0.01,
punch: 0.0,
};
let design = InstrumentDesign::new(saw_patch()).with_amp(amp);
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.set_sustain(true);
inst.note_on(Note::A4, 1.0);
let mut out = vec![0.0f32; 256 * 2];
inst.fill(&mut out);
inst.note_off(Note::A4); for _ in 0..40 {
inst.fill(&mut out);
}
assert_eq!(inst.active_voices(), 1, "held by the sustain pedal");
inst.set_sustain(false); for _ in 0..40 {
inst.fill(&mut out);
}
assert_eq!(inst.active_voices(), 0, "released on pedal-up");
}
#[test]
fn unison_spreads_detuned_copies_across_stereo() {
let design = InstrumentDesign::new(saw_patch()).with_unison(4, 22.0, 1.0);
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::C4, 0.9);
let mut out = vec![0.0f32; 2048 * 2];
inst.fill(&mut out);
assert!(peak(&out) > 0.0, "unison makes sound");
let differs = (0..2048).any(|f| out[f * 2] != out[f * 2 + 1]);
assert!(differs, "unison + width produces a stereo image");
}
#[test]
fn no_unison_stays_centered_mono() {
let mut inst = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
inst.note_on(Note::C4, 0.9);
let mut out = vec![0.0f32; 512 * 2];
inst.fill(&mut out);
assert!((0..512).all(|f| out[f * 2] == out[f * 2 + 1]), "centered");
}
#[test]
fn mono_mode_reuses_one_voice_with_last_note_priority() {
let design = InstrumentDesign::new(saw_patch()).with_mode(PlayMode::Mono { legato: true });
let mut inst = Instrument::new(design, 48_000).unwrap();
let h = inst.note_on(Note::C4, 0.9);
inst.note_on(Note(64), 0.9); assert_eq!(inst.active_voices(), 1, "mono holds a single voice");
assert_eq!(
inst.voice_note(h),
Some(Note(64)),
"voice follows the new note"
);
assert!(inst.note_off(Note(64)));
assert_eq!(
inst.voice_note(h),
Some(Note::C4),
"last-note priority: falls back to still-held C4"
);
assert_eq!(inst.active_voices(), 1);
assert!(inst.note_off(Note::C4));
for _ in 0..4 {
inst.fill(&mut vec![0.0f32; 4096 * 2]); }
assert_eq!(inst.active_voices(), 0, "released once nothing is held");
}
#[test]
fn mono_glide_eases_between_notes() {
let design = InstrumentDesign::new(saw_patch())
.with_mode(PlayMode::Mono { legato: true })
.with_glide(0.1);
let mut inst = Instrument::new(design, 48_000).unwrap();
let h = inst.note_on(Note::C4, 0.9); assert_eq!(inst.voice_pitch_scale(h), Some(1.0));
inst.note_on(Note(72), 0.9); let mut blk = vec![0.0f32; 64 * 2];
inst.fill(&mut blk);
let p = inst.voice_pitch_scale(h).unwrap();
assert!(p > 1.0 && p < 1.5, "eases up rather than jumping: {p}");
for _ in 0..6 {
inst.fill(&mut vec![0.0f32; 4096 * 2]);
}
assert!(
inst.voice_pitch_scale(h).unwrap() > 1.9,
"arrives near the octave"
);
}
#[test]
fn design_round_trips_through_serde() {
let design = InstrumentDesign::new(saw_patch());
let json = serde_json::to_string(&design).unwrap();
let recalled: InstrumentDesign = serde_json::from_str(&json).unwrap();
assert!(
Instrument::new(recalled, 48_000).is_ok(),
"preset recall works"
);
}
#[test]
fn velocity_zero_note_on_is_a_note_off() {
let amp = Adsr {
a: 0.001,
d: 0.001,
s: 0.8,
r: 0.05,
punch: 0.0,
};
let design = InstrumentDesign::new(saw_patch()).with_amp(amp);
let mut inst = Instrument::new(design, 48_000).unwrap();
let h = inst.note_on(Note::A4, 1.0);
assert!(inst.is_active(h));
let inert = inst.note_on(Note::A4, 0.0);
assert!(
!inst.is_active(inert),
"velocity-0 returns the inert handle"
);
for _ in 0..40 {
inst.fill(&mut vec![0.0f32; 512 * 2]);
}
assert!(!inst.is_active(h), "the voice released and culled");
inst.note_on(Note::C4, 0.0);
assert_eq!(inst.active_voices(), 0, "no stuck silent voice");
}
#[test]
fn velocity_zero_note_on_releases_in_mono_too() {
let design = InstrumentDesign::new(saw_patch()).with_mode(PlayMode::Mono { legato: true });
let mut inst = Instrument::new(design, 48_000).unwrap();
inst.note_on(Note::A4, 1.0);
assert_eq!(inst.active_voices(), 1);
inst.note_on(Note::A4, 0.0); for _ in 0..60 {
inst.fill(&mut vec![0.0f32; 512 * 2]);
}
assert_eq!(
inst.active_voices(),
0,
"the mono voice released and culled"
);
}
#[test]
fn tremolo_with_zero_rate_is_off_byte_identically() {
let mut plain = Instrument::new(InstrumentDesign::new(saw_patch()), 48_000).unwrap();
plain.note_on(Note::A4, 1.0);
let mut a = vec![0.0f32; 512 * 2];
plain.fill(&mut a);
let design = InstrumentDesign::new(saw_patch()).with_tremolo(0.0, 0.8);
assert!(!design.modulation.is_active(), "rate 0 disables the LFO");
let mut trem = Instrument::new(design, 48_000).unwrap();
trem.note_on(Note::A4, 1.0);
let mut b = vec![0.0f32; 512 * 2];
trem.fill(&mut b);
assert_eq!(
bits(&a),
bits(&b),
"zero-rate tremolo must be byte-identical to no modulation"
);
}
#[test]
fn transpose_leaves_unparseable_notes_as_authored() {
let mut node: Node = serde_json::from_str(r#"{ "type":"sine", "freq":"H9" }"#).unwrap();
transpose(&mut node, 2.0);
let Node::Sine {
freq: crate::dsl::Value::Note(s),
} = node
else {
panic!("an unparseable note stays a note");
};
assert_eq!(s, "H9");
let mut node: Node = serde_json::from_str(r#"{ "type":"sine", "freq":"A4" }"#).unwrap();
transpose(&mut node, 2.0);
let Node::Sine {
freq: crate::dsl::Value::Const(c),
} = node
else {
panic!("a parseable note resolves to a transposed constant");
};
assert_eq!(c, 880.0);
}