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
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.
183 lines
7.0 KiB
Rust
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());
|
|
}
|