Files
bass-foundry/plugins/rivet/core/tests/musical.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

204 lines
6.4 KiB
Rust

use rivet_core::*;
fn rhythm(p: &Phrase) -> Vec<(u32, u32)> {
p.notes.iter().map(|n| (n.start, n.duration)).collect()
}
fn options(motif: &Phrase, role: Role, s: &Settings) -> Vec<Candidate> {
realize(motif, role, 2, 2 * BAR, false, 0, s, &Harmony::default()).unwrap()
}
#[test]
fn each_role_offers_distinct_reproducible_options_led_by_its_best_fit() {
let motif = demo_motif();
let s = Settings::default();
for (role, first) in [
(Role::Statement, Strategy::Quote),
(Role::Answer, Strategy::CallResponse),
(Role::Departure, Strategy::LaterPeak),
(Role::Return, Strategy::HarmonicReturn),
] {
let a = options(&motif, role, &s);
assert_eq!(a, options(&motif, role, &s), "{role:?} is deterministic");
assert_eq!(a.len(), 3, "{role:?}");
assert_eq!(a[0].strategy, first, "{role:?}");
for pair in a.windows(2) {
assert_ne!(pair[0].phrase, pair[1].phrase);
}
for c in &a {
c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap();
assert_eq!(c.phrase.length, 2 * BAR);
}
}
let quote = &options(&motif, Role::Statement, &s)[0];
assert_eq!(
quote.phrase.notes, motif.notes,
"a statement quotes the motif"
);
let rest = realize(&motif, Role::Rest, 1, 0, false, 0, &s, &Harmony::default()).unwrap();
assert_eq!(rest.len(), 1);
assert!(rest[0].phrase.notes.is_empty());
}
#[test]
fn options_change_rhythm_and_density_not_only_pitch() {
let motif = demo_motif();
let s = Settings::default();
let mut signatures = std::collections::BTreeSet::new();
for seed in 0..12 {
for role in [Role::Answer, Role::Departure] {
for c in options(&motif, role, &Settings { seed, ..s.clone() }) {
signatures.insert(rhythm(&c.phrase));
}
}
}
assert!(
signatures.len() > 3,
"seeds should explore rhythmic decisions"
);
assert!(options(&motif, Role::Answer, &s)
.iter()
.any(|c| c.phrase.notes.len() != motif.notes.len()));
}
#[test]
fn fixed_groove_and_separate_note_locks_take_priority() {
let mut motif = demo_motif();
motif.notes[1].pitch_locked = true;
motif.notes[1].pitch = 99;
motif.notes[3].rhythm_locked = true;
for role in [Role::Answer, Role::Departure, Role::Return] {
for c in realize(
&motif,
role,
4,
0,
false,
0,
&Settings::default(),
&Harmony::default(),
)
.unwrap()
{
let locked: Vec<_> = c.phrase.notes.iter().filter(|n| n.pitch_locked).collect();
assert_eq!(locked.len(), 2, "{role:?} {:?}", c.strategy);
assert!(locked.iter().all(|n| n.pitch == 99));
for rep in 0..2 {
assert!(c.phrase.notes.iter().any(|n| n.rhythm_locked
&& n.start == motif.notes[3].start + rep * motif.length
&& n.duration == motif.notes[3].duration));
}
}
}
let motif = demo_motif();
let s = Settings {
preserve_rhythm: true,
..Settings::default()
};
for c in options(&motif, Role::Departure, &s) {
assert_eq!(rhythm(&c.phrase), rhythm(&motif));
}
}
#[test]
fn fully_locked_motif_returns_one_honest_option() {
let mut motif = demo_motif();
for n in &mut motif.notes {
n.locked = true;
}
let all = options(&motif, Role::Answer, &Settings::default());
assert_eq!(all.len(), 1);
assert_eq!(all[0].phrase.notes, motif.notes);
}
#[test]
fn harmony_drives_strong_notes_and_the_final_ending() {
let harmony = Harmony {
length: BAR,
spans: vec![HarmonySpan {
start: 0,
duration: BAR,
tones: ChordQuality::Major.mask(0),
bass: 48,
}],
};
for ending in [Ending::Open, Ending::Resolve] {
let s = Settings {
preserve_contour: false,
ending,
..Settings::default()
};
for c in realize(
&demo_motif(),
Role::Return,
2,
6 * BAR,
true,
0,
&s,
&harmony,
)
.unwrap()
{
if c.strategy == Strategy::Quote {
continue;
}
let last = c.phrase.notes.last().unwrap();
let chord = ChordQuality::Major.mask(0) & (1 << (last.pitch % 12)) != 0;
assert_eq!(chord, ending == Ending::Resolve, "{:?}", c.strategy);
for n in c
.phrase
.notes
.iter()
.filter(|n| n.start.is_multiple_of(PPQ) && n.start != last.start)
{
assert_ne!(
harmony.at(n.start).unwrap().tones & (1 << (n.pitch % 12)),
0
);
}
}
}
}
#[test]
fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() {
let notes: Vec<_> = [64, 67, 72]
.into_iter()
.map(|pitch| Note {
start: 0,
duration: BAR,
pitch,
velocity: 90,
..Default::default()
})
.collect();
let h = Harmony::from_midi(&notes, 2 * BAR).unwrap();
assert_eq!(h.spans.len(), 1);
assert_eq!(h.spans[0].bass, 64);
assert_eq!(h.spans[0].tones, ChordQuality::Major.mask(0));
assert!(h.at(BAR + 1).is_none());
assert_eq!(h.at(2 * BAR).unwrap().bass, 64);
}
#[test]
fn every_meter_key_and_block_length_realizes_valid_options() {
let motif = demo_motif();
for meter in Meter::ALL {
for root in [0, 5, 11] {
for scale in Scale::ALL {
let s = Settings {
root,
scale,
meter,
low: 60,
high: 72,
..Settings::default()
};
for bars in [1, 3, 8] {
for role in Role::ALL {
for c in realize(&motif, role, bars, 0, true, 0, &s, &Harmony::default())
.unwrap()
{
c.phrase.check(MAX_PLAYBACK_NOTES, true).unwrap();
assert_eq!(c.phrase.length, bars as u32 * meter.bar());
}
}
}
}
}
}
}