use super::Pattern;
use crate::dsl::{SeqNote, Value};
use crate::dsp::Rng;
use crate::music::{MusicError, Pitch};
fn total_steps(p: &Pattern, steps_per_bar: u32) -> u64 {
p.bars as u64 * steps_per_bar.max(1) as u64
}
fn pitch_label(v: &Value) -> String {
match v {
Value::Note(s) => format!("\"{s}\""),
Value::Const(hz) => format!("{hz} Hz"),
Value::Modulated(_) => "a modulated pitch".into(),
}
}
const PATTERN_SEED_SALT: u64 = 0x6A09_E667;
fn note_identity(n: &SeqNote) -> u64 {
let mut id = (n.step as u64) << 32 ^ (n.len as u64) << 8 ^ PATTERN_SEED_SALT;
id ^= pitch_identity(&n.pitch).rotate_left(17);
id
}
fn pitch_identity(v: &Value) -> u64 {
match v {
Value::Const(c) => c.to_bits() as u64,
Value::Note(s) => crate::dsp::layer_stream_key(s),
Value::Modulated(_) => 0x4D4F_4455,
}
}
pub fn repeat(p: &Pattern, steps_per_bar: u32, times: u32) -> Pattern {
let stride = total_steps(p, steps_per_bar);
let mut notes = Vec::with_capacity(p.notes.len().saturating_mul(times as usize));
for i in 0..times as u64 {
let off = i * stride;
for n in &p.notes {
let mut m = n.clone();
m.step = (n.step as u64 + off).min(u32::MAX as u64) as u32;
notes.push(m);
}
}
Pattern {
name: format!("{}_x{times}", p.name),
bars: p.bars.saturating_mul(times),
notes,
}
}
pub fn concat(a: &Pattern, b: &Pattern, steps_per_bar: u32) -> Pattern {
let off = a.bars as u64 * steps_per_bar.max(1) as u64;
let mut notes = a.notes.clone();
notes.extend(b.notes.iter().map(|n| {
let mut m = n.clone();
m.step = (n.step as u64 + off).min(u32::MAX as u64) as u32;
m
}));
Pattern {
name: format!("{}_{}", a.name, b.name),
bars: a.bars.saturating_add(b.bars),
notes,
}
}
pub fn layer(a: &Pattern, b: &Pattern) -> Pattern {
let mut notes = a.notes.clone();
notes.extend(b.notes.iter().cloned());
notes.sort_by_key(|n| n.step);
Pattern {
name: format!("{}_layer_{}", a.name, b.name),
bars: a.bars.max(b.bars),
notes,
}
}
pub fn slice(p: &Pattern, start_step: u32, len_steps: u32, steps_per_bar: u32) -> Pattern {
let end = start_step as u64 + len_steps as u64;
let notes = p
.notes
.iter()
.filter(|n| start_step as u64 <= n.step as u64 && (n.step as u64) < end)
.map(|n| {
let mut m = n.clone();
m.step -= start_step;
m
})
.collect();
let bars = (len_steps as u64)
.div_ceil(steps_per_bar.max(1) as u64)
.clamp(1, u32::MAX as u64) as u32;
Pattern {
name: format!("{}_slice_{start_step}_{len_steps}", p.name),
bars,
notes,
}
}
pub fn transpose(p: &Pattern, semitones: i16) -> Result<Pattern, PatternError> {
let factor = 2f32.powf(semitones as f32 / 12.0);
let mut notes = Vec::with_capacity(p.notes.len());
for n in &p.notes {
let mut m = n.clone();
m.pitch = match &n.pitch {
Value::Note(s) => {
let midi_form = s.starts_with("midi:");
let pitch = Pitch::from_name(s)?;
let shifted = pitch.add_semitones(semitones).map_err(|_| {
PatternError::BadTranspose(format!(
"transposing the note at step {} ({}) by {semitones:+} semitones leaves \
the MIDI range 0..=127 — transpose fewer semitones, or drop or rewrite \
that note first",
n.step,
pitch_label(&n.pitch),
))
})?;
if midi_form {
Value::Note(format!("midi:{}", shifted.to_midi()))
} else {
Value::Note(shifted.to_string())
}
}
Value::Const(hz) => {
let scaled = hz * factor;
if !scaled.is_finite() || scaled <= 0.0 {
return Err(PatternError::BadTranspose(format!(
"transposing the note at step {} ({}) by {semitones:+} semitones \
overflowed: a Hz constant scales by 2^(semitones/12) — use fewer \
semitones, or rewrite the pitch as a note name",
n.step,
pitch_label(&n.pitch),
)));
}
Value::Const(scaled)
}
Value::Modulated(_) => n.pitch.clone(),
};
notes.push(m);
}
Ok(Pattern {
name: format!("{}_t{semitones:+}", p.name),
bars: p.bars,
notes,
})
}
pub fn stretch(
p: &Pattern,
num: u32,
den: u32,
steps_per_bar: u32,
) -> Result<Pattern, PatternError> {
let _ = steps_per_bar;
if num == 0 || den == 0 {
return Err(PatternError::BadParams(format!(
"stretch ratio {num}/{den} is degenerate — numerator and denominator must both be \
≥ 1 (2/1 doubles time, 1/2 halves it)"
)));
}
let (num64, den64) = (num as u64, den as u64);
let scaled_bars = p.bars as u64 * num64;
if !scaled_bars.is_multiple_of(den64) {
return Err(PatternError::OffGrid {
what: "the bar count".into(),
value: p.bars,
num,
den,
});
}
let mut notes = Vec::with_capacity(p.notes.len());
for n in &p.notes {
let step = n.step as u64 * num64;
if !step.is_multiple_of(den64) {
return Err(PatternError::OffGrid {
what: format!(
"the note at step {} (pitch {})",
n.step,
pitch_label(&n.pitch)
),
value: n.step,
num,
den,
});
}
let len = n.len as u64 * num64;
if !len.is_multiple_of(den64) {
return Err(PatternError::OffGrid {
what: format!(
"the length of the note at step {} (pitch {})",
n.step,
pitch_label(&n.pitch)
),
value: n.len,
num,
den,
});
}
let mut m = n.clone();
m.step = (step / den64).min(u32::MAX as u64) as u32;
m.len = (len / den64).min(u32::MAX as u64) as u32;
notes.push(m);
}
Ok(Pattern {
name: format!("{}_stretch_{num}_{den}", p.name),
bars: (scaled_bars / den64).min(u32::MAX as u64) as u32,
notes,
})
}
pub fn rotate(p: &Pattern, shift_steps: i64, steps_per_bar: u32) -> Pattern {
let total = total_steps(p, steps_per_bar) as i128;
let mut notes: Vec<SeqNote> = p
.notes
.iter()
.map(|n| {
let mut m = n.clone();
if total > 0 {
m.step = (n.step as i128 + shift_steps as i128).rem_euclid(total) as u32;
}
m
})
.collect();
notes.sort_by_key(|n| n.step);
Pattern {
name: format!("{}_rot{shift_steps:+}", p.name),
bars: p.bars,
notes,
}
}
pub fn reverse(p: &Pattern, steps_per_bar: u32) -> Pattern {
let total = total_steps(p, steps_per_bar);
let mut notes: Vec<SeqNote> = p
.notes
.iter()
.map(|n| {
let mut m = n.clone();
m.step = total
.saturating_sub(n.step as u64 + n.len as u64)
.min(u32::MAX as u64) as u32;
m
})
.collect();
notes.sort_by_key(|n| n.step);
Pattern {
name: format!("{}_rev", p.name),
bars: p.bars,
notes,
}
}
pub fn quantize(p: &Pattern, grid_steps: u32) -> Pattern {
let grid = grid_steps.max(1) as u64;
let mut notes: Vec<SeqNote> = p
.notes
.iter()
.map(|n| {
let mut m = n.clone();
let s = n.step as u64;
m.step = (((s + grid / 2) / grid) * grid).min(u32::MAX as u64) as u32;
m
})
.collect();
notes.sort_by_key(|n| n.step);
Pattern {
name: format!("{}_q{grid_steps}", p.name),
bars: p.bars,
notes,
}
}
pub fn vel(p: &Pattern, scale: f32) -> Pattern {
let notes = p
.notes
.iter()
.map(|n| {
let mut m = n.clone();
m.gain = (n.gain * scale).clamp(0.0, 1.0);
m
})
.collect();
Pattern {
name: format!("{}_vel{scale}", p.name),
bars: p.bars,
notes,
}
}
pub fn gate(p: &Pattern, factor: f32) -> Pattern {
let notes = p
.notes
.iter()
.map(|n| {
let mut m = n.clone();
m.len = ((n.len as f32 * factor).round() as u32).max(1);
m
})
.collect();
Pattern {
name: format!("{}_gate{factor}", p.name),
bars: p.bars,
notes,
}
}
pub fn probability(p: &Pattern, keep: f32, seed: u64) -> Pattern {
let keep = keep.clamp(0.0, 1.0);
let notes = p
.notes
.iter()
.filter(|n| Rng::new(seed ^ note_identity(n)).unit() < keep)
.cloned()
.collect();
Pattern {
name: format!("{}_prob{keep}", p.name),
bars: p.bars,
notes,
}
}
pub fn euclidean(
name: &str,
pulses: u32,
steps: u32,
pitch: &str,
len: u32,
steps_per_bar: u32,
) -> Result<Pattern, PatternError> {
if steps == 0 {
return Err(PatternError::BadParams(
"euclidean needs at least 1 grid step — steps is the cycle length".into(),
));
}
if pulses > steps {
return Err(PatternError::BadParams(format!(
"euclidean({pulses} pulses, {steps} steps) packs more pulses than grid positions — \
pulses must be ≤ steps; for denser hits, layer two patterns"
)));
}
let notes = (0..steps)
.filter(|i| (*i as u64 * pulses as u64) % (steps as u64) < pulses as u64)
.map(|i| SeqNote {
step: i,
len: len.max(1),
pitch: Value::Note(pitch.into()),
gain: 1.0,
})
.collect();
let bars = (steps as u64)
.div_ceil(steps_per_bar.max(1) as u64)
.clamp(1, u32::MAX as u64) as u32;
Ok(Pattern {
name: name.into(),
bars,
notes,
})
}
pub fn tuplet(name: &str, count: u32, in_steps: u32, pitch: &str, len_steps: u32) -> Pattern {
let notes = (0..count)
.map(|i| {
let num = 2 * i as u64 * in_steps as u64 + count as u64;
let step = (num / (2 * count as u64)).min(u32::MAX as u64) as u32;
SeqNote {
step,
len: len_steps.max(1),
pitch: Value::Note(pitch.into()),
gain: 1.0,
}
})
.collect();
Pattern {
name: name.into(),
bars: 1,
notes,
}
}
pub fn humanize(p: &Pattern, timing: f32, velocity: f32, seed: u64) -> Pattern {
let timing = timing.max(0.0);
let velocity = velocity.max(0.0);
let mut notes: Vec<SeqNote> = p
.notes
.iter()
.map(|n| {
let mut rng = Rng::new(seed ^ note_identity(n));
let (timing_draw, gain_draw) = (rng.bi(), rng.bi());
let mut m = n.clone();
let jitter = (timing_draw * timing).round() as i64;
m.step = (n.step as i64)
.saturating_add(jitter)
.clamp(0, u32::MAX as i64) as u32;
m.gain = (n.gain + gain_draw * velocity).clamp(0.0, 1.0);
m
})
.collect();
notes.sort_by_key(|n| n.step);
Pattern {
name: format!("{}_hum", p.name),
bars: p.bars,
notes,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatternError {
OffGrid {
what: String,
value: u32,
num: u32,
den: u32,
},
BadTranspose(String),
BadParams(String),
Pitch(MusicError),
}
impl std::fmt::Display for PatternError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PatternError::OffGrid {
what,
value,
num,
den,
} => write!(
f,
"stretch {num}/{den} pulls {what} off the grid: {value} × {num} isn't divisible \
by {den} — pick a ratio that keeps every step, len, and the bar count integral, \
or quantize first"
),
PatternError::BadTranspose(msg) | PatternError::BadParams(msg) => f.write_str(msg),
PatternError::Pitch(e) => e.fmt(f),
}
}
}
impl std::error::Error for PatternError {}
impl From<MusicError> for PatternError {
fn from(e: MusicError) -> Self {
PatternError::Pitch(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dsl::{Adsr, Modulator, SeqWave};
use crate::song::{Song, note, note_vel};
fn riff() -> Pattern {
Pattern {
name: "riff".into(),
bars: 1,
notes: vec![note(0, 4, "C2"), note_vel(8, 4, "G2", 0.8)],
}
}
fn steps(p: &Pattern) -> Vec<u32> {
p.notes.iter().map(|n| n.step).collect()
}
fn lens(p: &Pattern) -> Vec<u32> {
p.notes.iter().map(|n| n.len).collect()
}
fn names(p: &Pattern) -> Vec<String> {
p.notes
.iter()
.map(|n| match &n.pitch {
Value::Note(s) => s.clone(),
Value::Const(hz) => format!("{hz}Hz"),
Value::Modulated(_) => "mod".into(),
})
.collect()
}
#[test]
fn repeat_offsets_each_copy_by_the_pattern_length() {
let p = repeat(&riff(), 16, 3);
assert_eq!(p.name, "riff_x3");
assert_eq!(p.bars, 3);
assert_eq!(steps(&p), [0, 8, 16, 24, 32, 40]);
assert_eq!(names(&p), ["C2", "G2", "C2", "G2", "C2", "G2"]);
let zero = repeat(&riff(), 16, 0);
assert_eq!(zero.name, "riff_x0");
assert_eq!(zero.bars, 0);
assert!(zero.notes.is_empty());
}
#[test]
fn concat_appends_b_after_a() {
let mut b = riff();
b.name = "fill".into();
b.bars = 2;
b.notes = vec![note(0, 1, "C3"), note(4, 1, "D3")];
let p = concat(&riff(), &b, 16);
assert_eq!(p.name, "riff_fill");
assert_eq!(p.bars, 3);
assert_eq!(steps(&p), [0, 8, 16, 20]);
}
#[test]
fn layer_merges_sorted_with_a_leading_on_ties() {
let drums = Pattern {
name: "drums".into(),
bars: 2,
notes: vec![note(4, 1, "midi:36"), note(8, 1, "midi:38")],
};
let p = layer(&riff(), &drums);
assert_eq!(p.name, "riff_layer_drums");
assert_eq!(p.bars, 2, "bars is the longer of the two");
assert_eq!(steps(&p), [0, 4, 8, 8]);
assert_eq!(names(&p), ["C2", "midi:36", "G2", "midi:38"]);
}
#[test]
fn slice_rebases_and_lets_tails_overrun() {
let p = Pattern {
name: "line".into(),
bars: 1,
notes: vec![
note(0, 2, "C4"),
note(4, 8, "E4"),
note(8, 8, "G4"),
note(12, 2, "B4"),
],
};
let s = slice(&p, 4, 8, 16);
assert_eq!(s.name, "line_slice_4_8");
assert_eq!(s.bars, 1);
assert_eq!(steps(&s), [0, 4]);
assert_eq!(lens(&s), [8, 8], "tails overrun the slice end");
assert_eq!(slice(&p, 0, 20, 16).bars, 2);
let empty = slice(&p, 100, 8, 16);
assert_eq!(empty.bars, 1);
assert!(empty.notes.is_empty());
}
#[test]
fn transpose_respells_names_and_preserves_the_midi_form() {
let p = Pattern {
name: "riff".into(),
bars: 1,
notes: vec![note(0, 1, "C4"), note(2, 1, "Gb3"), note(4, 1, "midi:36")],
};
let up = transpose(&p, 2).unwrap();
assert_eq!(up.name, "riff_t+2");
assert_eq!(names(&up), ["D4", "G#3", "midi:38"]);
assert!(matches!(&up.notes[2].pitch, Value::Note(s) if s == "midi:38"));
assert_eq!(names(&transpose(&p, 0).unwrap()), ["C4", "F#3", "midi:36"]);
assert_eq!(transpose(&p, -12).unwrap().name, "riff_t-12");
assert_eq!(
names(&transpose(&p, -12).unwrap()),
["C3", "F#2", "midi:24"]
);
}
#[test]
fn transpose_scales_hz_and_leaves_modulators_alone() {
let slide = Value::Modulated(Modulator::Slide {
from: 100.0,
to: 200.0,
secs: 0.1,
curve: crate::dsl::Curve::Lin,
});
let p = Pattern {
name: "fx".into(),
bars: 1,
notes: vec![
SeqNote {
step: 0,
len: 1,
pitch: Value::Const(220.0),
gain: 1.0,
},
SeqNote {
step: 4,
len: 1,
pitch: slide,
gain: 1.0,
},
],
};
let up = transpose(&p, 12).unwrap();
assert!(
matches!(up.notes[0].pitch, Value::Const(hz) if (hz - 440.0).abs() < 1e-4),
"Hz scales by 2^(12/12) = 2"
);
assert!(
matches!(up.notes[1].pitch, Value::Modulated(_)),
"a modulated pitch is untouched"
);
}
#[test]
fn transpose_errors_loudly_instead_of_skipping() {
let bad = Pattern {
name: "bad".into(),
bars: 1,
notes: vec![note(0, 1, "H4")],
};
let err = transpose(&bad, 2).unwrap_err();
assert!(
matches!(&err, PatternError::Pitch(MusicError::BadName(m)) if m.contains("\"H4\"")),
"unexpected: {err}"
);
let lenient = Pattern {
name: "bad".into(),
bars: 1,
notes: vec![note(0, 1, "m69")],
};
assert!(matches!(
transpose(&lenient, 0).unwrap_err(),
PatternError::Pitch(MusicError::BadName(_))
));
let high = Pattern {
name: "high".into(),
bars: 1,
notes: vec![note(3, 1, "midi:127")],
};
let err = transpose(&high, 1).unwrap_err();
assert!(
matches!(&err, PatternError::BadTranspose(m) if m.contains("step 3") && m.contains("midi:127")),
"unexpected: {err}"
);
let loud = Pattern {
name: "loud".into(),
bars: 1,
notes: vec![SeqNote {
step: 0,
len: 1,
pitch: Value::Const(1e38),
gain: 1.0,
}],
};
assert!(matches!(
transpose(&loud, 120).unwrap_err(),
PatternError::BadTranspose(_)
));
}
#[test]
fn stretch_scales_time_exactly() {
let p = Pattern {
name: "riff".into(),
bars: 2,
notes: vec![note(0, 4, "C2"), note(6, 2, "G2")],
};
let wide = stretch(&p, 2, 1, 16).unwrap();
assert_eq!(wide.name, "riff_stretch_2_1");
assert_eq!(wide.bars, 4);
assert_eq!(steps(&wide), [0, 12]);
assert_eq!(lens(&wide), [8, 4]);
let tight = stretch(&p, 1, 2, 16).unwrap();
assert_eq!(tight.bars, 1);
assert_eq!(steps(&tight), [0, 3]);
assert_eq!(lens(&tight), [2, 1]);
let hemi = stretch(&p, 3, 2, 16).unwrap();
assert_eq!(hemi.bars, 3);
assert_eq!(steps(&hemi), [0, 9]);
assert_eq!(lens(&hemi), [6, 3]);
}
#[test]
fn stretch_refuses_off_grid_results_naming_the_note() {
let p = Pattern {
name: "riff".into(),
bars: 2,
notes: vec![note(1, 2, "C2"), note(4, 1, "G2")],
};
let err = stretch(&p, 1, 2, 16).unwrap_err();
assert_eq!(
err.to_string(),
"stretch 1/2 pulls the note at step 1 (pitch \"C2\") off the grid: 1 × 1 isn't \
divisible by 2 — pick a ratio that keeps every step, len, and the bar count \
integral, or quantize first"
);
assert!(matches!(
err,
PatternError::OffGrid {
value: 1,
num: 1,
den: 2,
..
}
));
let one_bar = Pattern {
name: "one".into(),
bars: 1,
notes: vec![note(0, 2, "C2")],
};
let err = stretch(&one_bar, 1, 2, 16).unwrap_err();
assert!(
matches!(&err, PatternError::OffGrid { what, value: 1, .. } if what == "the bar count"),
"unexpected: {err}"
);
let p2 = Pattern {
name: "riff".into(),
bars: 2,
notes: vec![note(2, 1, "G2")],
};
let err = stretch(&p2, 1, 2, 16).unwrap_err();
assert!(
matches!(&err, PatternError::OffGrid { what, value: 1, .. } if what.contains("step 2")),
"unexpected: {err}"
);
assert!(matches!(
stretch(&p, 0, 2, 16).unwrap_err(),
PatternError::BadParams(_)
));
assert!(matches!(
stretch(&p, 1, 0, 16).unwrap_err(),
PatternError::BadParams(_)
));
}
#[test]
fn rotate_wraps_around_the_pattern_length() {
let p = Pattern {
name: "riff".into(),
bars: 1,
notes: vec![note(0, 2, "C2"), note(4, 2, "E2"), note(12, 2, "G2")],
};
let r = rotate(&p, 4, 16);
assert_eq!(r.name, "riff_rot+4");
assert_eq!(r.bars, 1);
assert_eq!(steps(&r), [0, 4, 8]);
assert_eq!(names(&r), ["G2", "C2", "E2"]);
let back = rotate(&p, -4, 16);
assert_eq!(steps(&back), [0, 8, 12]);
assert_eq!(names(&back), ["E2", "G2", "C2"]);
assert_eq!(steps(&rotate(&p, 16, 16)), [0, 4, 12]);
assert_eq!(steps(&rotate(&p, 20, 16)), steps(&rotate(&p, 4, 16)));
let round = rotate(&rotate(&p, 7, 16), -7, 16);
assert_eq!(steps(&round), [0, 4, 12]);
assert_eq!(names(&round), ["C2", "E2", "G2"]);
}
#[test]
fn reverse_mirrors_note_intervals() {
let p = Pattern {
name: "riff".into(),
bars: 1,
notes: vec![note(0, 4, "C2"), note(8, 2, "G2")],
};
let r = reverse(&p, 16);
assert_eq!(r.name, "riff_rev");
assert_eq!(r.bars, 1);
assert_eq!(steps(&r), [6, 12]);
assert_eq!(lens(&r), [2, 4], "lengths mirror with their notes");
assert_eq!(names(&r), ["G2", "C2"]);
let twice = reverse(&reverse(&p, 16), 16);
assert_eq!(steps(&twice), [0, 8]);
assert_eq!(lens(&twice), [4, 2]);
}
#[test]
fn quantize_snaps_starts_halves_away_from_zero() {
let p = Pattern {
name: "loose".into(),
bars: 1,
notes: vec![
note(1, 3, "C4"),
note(2, 3, "D4"),
note(3, 3, "E4"),
note(5, 3, "F4"),
note(6, 3, "G4"),
note(7, 3, "A4"),
],
};
let q = quantize(&p, 4);
assert_eq!(q.name, "loose_q4");
assert_eq!(steps(&q), [0, 4, 4, 4, 8, 8]);
assert_eq!(lens(&q), [3; 6], "lengths are untouched");
assert_eq!(steps(&quantize(&p, 1)), [1, 2, 3, 5, 6, 7]);
}
#[test]
fn vel_scales_and_clamps_gains() {
let p = Pattern {
name: "riff".into(),
bars: 1,
notes: vec![note_vel(0, 1, "C2", 1.0), note_vel(4, 1, "G2", 0.5)],
};
let up = vel(&p, 1.5);
assert_eq!(up.name, "riff_vel1.5");
assert_eq!(up.notes[0].gain, 1.0, "clamped at the 0..1 convention");
assert_eq!(up.notes[1].gain, 0.75);
let down = vel(&p, 0.5);
assert_eq!(down.notes[0].gain, 0.5);
assert_eq!(down.notes[1].gain, 0.25);
}
#[test]
fn gate_shortens_lengths_with_a_floor_of_one() {
let p = Pattern {
name: "riff".into(),
bars: 1,
notes: vec![note(0, 4, "C2"), note(4, 2, "E2"), note(8, 1, "G2")],
};
let staccato = gate(&p, 0.5);
assert_eq!(staccato.name, "riff_gate0.5");
assert_eq!(lens(&staccato), [2, 1, 1], "0.5 = staccato, floored at 1");
assert_eq!(lens(&gate(&p, 2.0)), [8, 4, 2]);
assert_eq!(lens(&gate(&p, 0.0)), [1, 1, 1]);
assert_eq!(steps(&staccato), [0, 4, 8], "starts are untouched");
}
#[test]
fn probability_is_deterministic_per_note_identity() {
let p = Pattern {
name: "line".into(),
bars: 2,
notes: (0..32).map(|i| note(i, 1, "C4")).collect(),
};
let a = probability(&p, 0.5, 7);
let b = probability(&p, 0.5, 7);
assert_eq!(
steps(&a),
steps(&b),
"same pattern + same seed ⇒ same drops"
);
assert_eq!(a.name, "line_prob0.5");
let c = probability(&p, 0.5, 8);
assert_ne!(steps(&a), steps(&c), "a different seed redraws");
let mut shuffled = p.clone();
shuffled.notes.reverse();
let mut kept_a = steps(&a);
kept_a.sort_unstable();
let mut kept_shuffled = steps(&probability(&shuffled, 0.5, 7));
kept_shuffled.sort_unstable();
assert_eq!(kept_a, kept_shuffled);
assert_eq!(probability(&p, 1.0, 7).notes.len(), 32);
assert!(probability(&p, 0.0, 7).notes.is_empty());
let dupes = Pattern {
name: "d".into(),
bars: 1,
notes: vec![note(4, 2, "C4"), note(4, 2, "C4")],
};
let kept = probability(&dupes, 0.5, 7).notes.len();
assert!(kept == 0 || kept == 2, "duplicates drop or keep together");
}
#[test]
fn euclidean_spreads_pulses_bresenham_evenly() {
let clave = euclidean("clave", 3, 8, "midi:36", 2, 16).unwrap();
assert_eq!(clave.name, "clave");
assert_eq!(clave.bars, 1);
assert_eq!(steps(&clave), [0, 3, 6], "the tresillo");
assert_eq!(lens(&clave), [2, 2, 2]);
assert_eq!(names(&clave), ["midi:36"; 3]);
assert!(clave.notes.iter().all(|n| n.gain == 1.0));
let cinq = euclidean("cinq", 5, 8, "midi:42", 1, 16).unwrap();
assert_eq!(steps(&cinq), [0, 2, 4, 5, 7]);
assert_eq!(
steps(&euclidean("full", 8, 8, "midi:36", 1, 16).unwrap()),
[0, 1, 2, 3, 4, 5, 6, 7]
);
assert!(
euclidean("none", 0, 8, "midi:36", 1, 16)
.unwrap()
.notes
.is_empty()
);
assert_eq!(euclidean("long", 3, 32, "midi:36", 1, 16).unwrap().bars, 2);
let err = euclidean("dense", 9, 8, "midi:36", 1, 16).unwrap_err();
assert!(
matches!(&err, PatternError::BadParams(m) if m.contains("9") && m.contains("8")),
"unexpected: {err}"
);
assert!(matches!(
euclidean("zero", 1, 0, "midi:36", 1, 16).unwrap_err(),
PatternError::BadParams(_)
));
}
#[test]
fn tuplet_spaces_notes_evenly() {
let triplet = tuplet("trip", 3, 8, "C5", 1);
assert_eq!(triplet.name, "trip");
assert_eq!(triplet.bars, 1);
assert_eq!(steps(&triplet), [0, 3, 5]);
assert_eq!(lens(&triplet), [1; 3]);
assert_eq!(names(&triplet), ["C5"; 3]);
assert_eq!(steps(&tuplet("du", 2, 3, "C5", 1)), [0, 2]);
assert_eq!(steps(&tuplet("q", 4, 16, "C5", 1)), [0, 4, 8, 12]);
assert!(tuplet("none", 0, 8, "C5", 1).notes.is_empty());
}
#[test]
fn humanize_is_deterministic_bounded_and_identity_based() {
let p = Pattern {
name: "line".into(),
bars: 2,
notes: (0..32).map(|i| note_vel(i, 1, "C4", 0.8)).collect(),
};
let noop = humanize(&p, 0.0, 0.0, 42);
assert_eq!(noop.name, "line_hum");
assert_eq!(steps(&noop), steps(&p));
assert_eq!(lens(&noop), lens(&p));
let a = humanize(&p, 1.0, 0.5, 42);
assert_eq!(steps(&a), steps(&humanize(&p, 1.0, 0.5, 42)));
assert_ne!(
steps(&a),
steps(&humanize(&p, 1.0, 0.5, 43)),
"a different seed redraws"
);
for seed in 0..8 {
let h = humanize(&p, 2.0, 1.0, seed);
for n in &h.notes {
assert!((0.0..=1.0).contains(&n.gain));
assert!(n.step < 34, "jitter is bounded by ±timing");
}
}
let mut shuffled = p.clone();
shuffled.notes.reverse();
let mut ja = steps(&humanize(&p, 1.0, 0.0, 9));
ja.sort_unstable();
let mut jb = steps(&humanize(&shuffled, 1.0, 0.0, 9));
jb.sort_unstable();
assert_eq!(ja, jb);
}
#[test]
fn transform_names_read_as_derived() {
let p = riff();
assert_eq!(repeat(&p, 16, 2).name, "riff_x2");
assert_eq!(reverse(&p, 16).name, "riff_rev");
assert_eq!(transpose(&p, 5).unwrap().name, "riff_t+5");
assert_eq!(rotate(&p, 3, 16).name, "riff_rot+3");
assert_eq!(rotate(&p, -2, 16).name, "riff_rot-2");
assert_eq!(quantize(&p, 4).name, "riff_q4");
assert_eq!(vel(&p, 0.9).name, "riff_vel0.9");
assert_eq!(gate(&p, 0.75).name, "riff_gate0.75");
assert_eq!(probability(&p, 0.5, 1).name, "riff_prob0.5");
assert_eq!(humanize(&p, 0.5, 0.1, 1).name, "riff_hum");
assert_eq!(slice(&p, 4, 8, 16).name, "riff_slice_4_8");
assert_eq!(stretch(&p, 2, 1, 16).unwrap().name, "riff_stretch_2_1");
}
#[test]
fn transforms_compose_into_a_pattern_that_still_compiles() {
let mut p = Pattern {
name: "riff".into(),
bars: 1,
notes: vec![
note(0, 4, "C3"),
note_vel(4, 2, "E3", 0.9),
note(8, 4, "G3"),
note_vel(12, 2, "B3", 0.7),
],
};
p = repeat(&p, 16, 2);
p = transpose(&p, 5).unwrap();
p = reverse(&p, 16);
p = rotate(&p, 3, 16);
p = quantize(&p, 1);
p = gate(&p, 0.75);
p = vel(&p, 0.9);
p = humanize(&p, 0.0, 0.0, 42); p = probability(&p, 1.0, 99); let drums = euclidean("kick", 4, 32, "midi:36", 1, 16).unwrap();
p = layer(&p, &drums);
p = concat(&p, &tuplet("trip", 3, 8, "C5", 1), 16);
p = slice(&p, 0, 40, 16);
p = stretch(&p, 1, 1, 16).unwrap();
assert_eq!(p.bars, 3);
assert!(!p.notes.is_empty());
let total = p.bars as u64 * 16;
for n in &p.notes {
assert!(n.len >= 1);
assert!((0.0..=1.0).contains(&n.gain));
assert!((n.step as u64) < total);
if let Value::Note(name) = &n.pitch {
Pitch::from_name(name)
.unwrap_or_else(|e| panic!("{name} must stay parseable: {e}"));
}
}
let mut song = Song::new("roundtrip", 120.0);
song.add_track("keys", SeqWave::Epiano, Adsr::new(0.005, 0.2, 0.6, 0.2));
song.add_pattern(p.name.clone(), p.bars, p.notes.clone());
song.arrange("keys", p.name.clone(), 0);
song.to_doc().unwrap();
}
}