Files
bass-foundry/plugins/rivet/core/tests/storyboard.rs
T
nvrl f1366280ff
Check and Windows preview / check (patina) (push) Successful in 3m59s
Check and Windows preview / check (rivet) (push) Successful in 2m2s
Check and Windows preview / previews (push) Successful in 1m8s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m41s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 9s
feat(rivet): storyboard composition with flexible lengths and meters
Rivet 0.4.0 tells a motif as a story. A timeline of blocks with roles
(statement, answer, departure, return, rest) replaces tasks, pins and
branches. Rivet writes each block from a motif and joins the seams with
a cut, breath, pickup or approach.

- Motifs of 1-8 bars in a bin of 16; storyboards up to 32 bars.
- Meters 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 and 12/8 for import, writing,
  audition and export. Phrygian, harmonic minor and Mixolydian scales.
- Per-block options, Another take and Keep; templates for new boards.
- Editor rebuilt: motif bin, timeline, large piano roll, inspector,
  tabs for generation, harmony and sound roles, 32-step undo/redo.
- Sessions from 0.1-0.3 open as a storyboard from their source motif.
2026-11-06 02:15:18 +01:00

183 lines
7.0 KiB
Rust

use rivet_core::*;
fn demo() -> Session {
Session::demo()
}
#[test]
fn templates_build_the_story_from_one_motif() {
let s = demo();
let roles: Vec<_> = s.blocks.iter().map(|b| b.role).collect();
assert_eq!(
roles,
[Role::Statement, Role::Answer, Role::Departure, Role::Return]
);
assert_eq!(s.total_bars(), 8);
assert!(s.blocks.iter().all(|b| b.options.len() == 3));
let phrase = s.compose().unwrap();
assert_eq!(phrase.length, 8 * BAR);
assert_eq!(
&phrase.notes[..demo_motif().notes.len()],
demo_motif().notes.as_slice(),
"the story opens with the motif"
);
let mut s = demo();
let id = s.motifs[0].id;
s.apply_template(Template::Breathe, id).unwrap();
assert_eq!(s.blocks[1].role, Role::Rest);
assert_eq!(s.blocks[1].bars, 1);
assert_eq!(s.total_bars(), 8);
}
#[test]
fn blocks_have_free_lengths_up_to_the_storyboard_limit() {
let mut s = demo();
let motif = s.motifs[0].id;
s.insert_block(4, motif, Role::Departure, 5).unwrap();
s.insert_block(5, motif, Role::Rest, 1).unwrap();
assert_eq!(s.total_bars(), 14);
assert_eq!(s.compose().unwrap().length, 14 * BAR);
assert!(s.insert_block(0, motif, Role::Answer, 9).is_err());
while s.total_bars() + 8 <= MAX_BARS {
s.insert_block(0, motif, Role::Answer, 8).unwrap();
}
assert!(s.insert_block(0, motif, Role::Answer, 8).is_err());
s.validate().unwrap();
}
#[test]
fn joins_shape_the_seam_without_crossing_it() {
let mut s = demo();
let first = s.blocks[1].phrase().unwrap().notes[0];
let bar = s.settings.meter.bar();
for join in Join::ALL {
s.blocks[0].join = join;
let phrase = s.compose().unwrap();
let tail: Vec<_> = phrase
.notes
.iter()
.filter(|n| n.start < 2 * bar)
.copied()
.collect();
assert!(tail.iter().all(|n| n.end() <= 2 * bar), "{join:?}");
let last = tail.last().unwrap();
match join {
Join::Cut => assert_eq!(tail, s.blocks[0].phrase().unwrap().notes),
Join::Breath => assert!(last.end() <= 2 * bar - s.settings.meter.beat() / 2),
Join::Approach => {
assert_ne!(last.pitch, first.pitch);
assert!(last.pitch.abs_diff(first.pitch) <= 2);
}
Join::Pickup => {
let pickup: Vec<_> = tail
.iter()
.filter(|n| n.start >= 2 * bar - s.settings.meter.beat())
.collect();
assert_eq!(pickup.len(), 2);
let direction = (pickup[1].pitch as i16 - pickup[0].pitch as i16).signum();
let into = (first.pitch as i16 - pickup[1].pitch as i16).signum();
assert_eq!(direction, into, "the pickup moves toward the next block");
}
}
}
}
#[test]
fn kept_blocks_survive_new_settings_and_other_blocks_follow_them() {
let mut s = demo();
s.blocks[1].choice = 2;
s.blocks[1].kept = true;
let kept = s.blocks[1].phrase().unwrap().clone();
let free = s.blocks[2].options.clone();
s.settings.seed += 7;
s.realize_all();
assert_eq!(s.blocks[1].phrase().unwrap(), &kept);
assert_ne!(s.blocks[2].options, free);
s.another_take(1);
assert!(!s.blocks[1].kept);
assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s);
}
#[test]
fn compound_and_odd_meters_compose_whole_bars() {
for meter in [
Meter { beats: 3, unit: 4 },
Meter { beats: 6, unit: 8 },
Meter { beats: 7, unit: 8 },
] {
let mut s = Session::default();
s.settings.meter = meter;
let motif = Phrase {
length: meter.bar(),
notes: (0..3)
.map(|i| Note {
start: i * meter.beat() / 2,
duration: meter.beat() / 2,
pitch: 60 + 2 * i as u8,
velocity: 90,
..Default::default()
})
.collect(),
};
let id = s.add_motif("Waltz", motif).unwrap();
s.apply_template(Template::Build, id).unwrap();
let phrase = s.compose().unwrap();
assert_eq!(phrase.length, s.total_bars() * meter.bar());
s.validate().unwrap();
}
}
#[test]
fn removing_a_motif_removes_its_blocks_and_limits_hold() {
let mut s = demo();
let id = s.motifs[0].id;
let other = s.add_motif("Second", demo_motif()).unwrap();
s.insert_block(4, other, Role::Answer, 2).unwrap();
s.remove_motif(id);
assert_eq!(s.blocks.len(), 1);
assert_eq!(s.blocks[0].motif, other);
let long = Phrase {
length: 9 * BAR,
..demo_motif()
};
assert!(s.add_motif("Too long", long).is_err());
for i in s.motifs.len()..MAX_MOTIFS {
s.add_motif(&format!("M{i}"), demo_motif()).unwrap();
}
assert!(s.add_motif("One more", demo_motif()).is_err());
}
#[test]
fn version_three_sessions_become_a_storyboard_without_losing_notes() {
let mut source = demo_motif();
source.notes[2].locked = true;
for (bars, blocks) in [(8, 4), (4, 2)] {
let old = serde_json::json!({
"version": 3,
"source": source,
"settings": {"bars": bars, "root": 2, "scale": "Dorian", "variation": 40,
"preserve_rhythm": false, "preserve_contour": true, "task": "Answer",
"ending": "Open", "low": 50, "high": 80, "seed": 9},
"candidates": [], "selected": 0, "accepted": null, "previous_accepted": null,
"tempo": 128.0, "follow_host": true, "pins": [], "looping": true,
"gestures": [{"id": 1, "name": "Hook", "phrase": demo_motif(),
"performance": PerformanceSettings::default()}],
"branches": [], "active_branch": null, "next_id": 2
});
let s = Session::from_json(&serde_json::to_vec(&old).unwrap()).unwrap();
assert_eq!(s.version, STATE_VERSION);
assert_eq!(s.motifs[0].phrase, source);
assert_eq!(s.motifs[1].name, "Hook");
assert_eq!(s.blocks.len(), blocks);
assert_eq!(s.blocks.last().unwrap().role, Role::Return);
assert_eq!(s.settings.root, 2);
assert_eq!(s.settings.scale, Scale::Dorian);
assert_eq!(s.settings.ending, Ending::Open);
assert_eq!(s.tempo, 128.);
assert!(s.follow_host);
assert!(s.compose().is_ok());
}
let v1 = serde_json::json!({"version": 1, "source": demo_motif(),
"settings": {"bars": 8}, "candidates": [], "selected": 0, "accepted": null,
"previous_accepted": null, "tempo": 140.0, "follow_host": false});
let s = Session::from_json(&serde_json::to_vec(&v1).unwrap()).unwrap();
assert!(!s.looping, "version-1 sessions kept one-shot audition");
assert!(s.harmony.spans.is_empty());
let mut future = serde_json::to_value(Session::default()).unwrap();
future["version"] = 99.into();
assert!(Session::from_json(&serde_json::to_vec(&future).unwrap()).is_err());
}