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

479 lines
17 KiB
Rust

//! Block-level proposals. Randomness chooses compositional alternatives, not independent pitches.
use crate::{
Candidate, Ending, Harmony, Note, Phrase, Result, Role, Settings, Strategy, MAX_PLAYBACK_NOTES,
MAX_SOURCE_NOTES,
};
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}
}
fn same_music(a: &Phrase, b: &Phrase) -> bool {
a.length == b.length
&& a.notes.len() == b.notes.len()
&& a.notes
.iter()
.zip(&b.notes)
.all(|(a, b)| (a.start, a.duration, a.pitch) == (b.start, b.duration, b.pitch))
}
/// Distance deliberately values timing/density differences above small pitch substitutions.
fn distance(a: &Phrase, b: &Phrase) -> u32 {
let a: std::collections::BTreeMap<_, _> = a.notes.iter().map(|n| (n.start, n)).collect();
let b: std::collections::BTreeMap<_, _> = b.notes.iter().map(|n| (n.start, n)).collect();
let mut total = 0;
for (start, n) in &a {
total += if let Some(other) = b.get(start) {
(n.pitch.abs_diff(other.pitch) as u32).min(12) * 3
+ (n.duration.abs_diff(other.duration) / 60).min(24)
} else {
100
};
}
total += b.keys().filter(|start| !a.contains_key(start)).count() as u32 * 100;
total * 10 / (a.len().max(b.len()).max(1) as u32)
}
/// How well a transformation serves a block's role. Zero means "only as a last resort".
fn fit(role: Role, family: Strategy) -> u32 {
let preferred = match role {
Role::Statement => [
Strategy::Quote,
Strategy::NewRhythm,
Strategy::ChangedEnding,
],
Role::Answer => [
Strategy::CallResponse,
Strategy::ChangedEnding,
Strategy::DelayedResolution,
],
Role::Departure => [
Strategy::LaterPeak,
Strategy::NewRhythm,
Strategy::FragmentAnswer,
],
Role::Return => [
Strategy::HarmonicReturn,
Strategy::Quote,
Strategy::NegativeSpace,
],
Role::Rest => [Strategy::Silence; 3],
};
if family == preferred[0] {
300
} else if preferred.contains(&family) {
240
} else {
0
}
}
fn explain(family: Strategy) -> &'static str {
match family {
Strategy::Quote => "The motif exactly as written.",
Strategy::Silence => "Silence. Lets the previous idea land.",
Strategy::SavedIdea => "Saved working gesture.",
Strategy::NewRhythm => "Same contour; entries move off the beat and pickups repeat.",
Strategy::FragmentAnswer => "Only the first half of the motif, leaving space after it.",
Strategy::NegativeSpace => "Connecting notes fall away; the signature anchors remain.",
Strategy::CallResponse => "A short call in the first half, answered with longer notes.",
Strategy::HarmonicReturn => "Close to the motif, landing where it began.",
Strategy::LaterPeak => "The register opens upward for contrast.",
Strategy::ChangedEnding => "Same groove; the second half bends downward.",
Strategy::DelayedResolution => "Stays a step away from rest so the phrase keeps moving.",
}
}
/// The motif repeated to fill `length`. Notes crossing the end are shortened, or
/// dropped when their rhythm is locked.
fn tile(motif: &Phrase, length: u32) -> Vec<Note> {
let mut notes = Vec::new();
let mut offset = 0;
while offset < length {
for original in &motif.notes {
let start = offset + original.start;
if start >= length {
break;
}
let mut n = Note { start, ..*original };
if n.end() > length {
if n.keeps_rhythm() {
continue;
}
n.duration = length - start;
}
notes.push(n);
}
offset += motif.length;
}
notes
}
/// Where a block sits in the storyboard.
#[derive(Debug, Clone, Copy)]
struct Place {
/// Start of the block, in ticks from the start of the storyboard.
offset: u32,
/// The final sounding block: its last note follows the ending choice.
last: bool,
}
/// Up to three distinct options for one block. The first option fits the role best.
#[allow(clippy::too_many_arguments)]
pub fn realize(
motif: &Phrase,
role: Role,
bars: u8,
offset: u32,
last: bool,
take: u64,
s: &Settings,
harmony: &Harmony,
) -> Result<Vec<Candidate>> {
s.validate()?;
let length = bars as u32 * s.meter.bar();
if role == Role::Rest {
return Ok(vec![Candidate {
strategy: Strategy::Silence,
explanation: explain(Strategy::Silence).into(),
phrase: Phrase {
notes: Vec::new(),
length,
},
}]);
}
motif.validate(MAX_SOURCE_NOTES)?;
harmony.validate()?;
let base = tile(motif, length);
if base.len() > MAX_PLAYBACK_NOTES {
return Err(crate::Error(
"This motif is too dense for a block this long. Shorten the block or the motif.".into(),
));
}
let place = Place { offset, last };
let mut pool: Vec<Candidate> = vec![Candidate {
strategy: Strategy::Quote,
explanation: explain(Strategy::Quote).into(),
phrase: Phrase {
notes: base.clone(),
length,
},
}];
// Blocks at different positions of the same role should not sound identical.
let position = (offset / s.meter.bar()) as u64;
for (family_index, family) in Strategy::ALL.into_iter().enumerate() {
for variant in 0..3u64 {
let mut rng = Random(
s.seed
.wrapping_add(family_index as u64 * 7919 + variant * 104729)
.wrapping_add(take.wrapping_mul(1_000_003))
.wrapping_add(position.wrapping_mul(15_485_863)),
);
let phrase = develop(
motif,
base.clone(),
length,
s,
harmony,
role,
family,
place,
&mut rng,
);
if phrase.notes.is_empty() || phrase.notes.len() > MAX_PLAYBACK_NOTES {
continue;
}
phrase.validate(MAX_PLAYBACK_NOTES)?;
let note = if harmony.spans.is_empty() {
""
} else {
" Strong notes follow the harmony."
};
let candidate = Candidate {
strategy: family,
explanation: format!("{}{note}", explain(family)),
phrase,
};
if let Some(index) = pool
.iter()
.position(|c| same_music(&c.phrase, &candidate.phrase))
{
if fit(role, family) > fit(role, pool[index].strategy) {
pool[index] = candidate;
}
} else {
pool.push(candidate);
}
}
}
let mut selected: Vec<Candidate> = Vec::new();
while selected.len() < 3 && !pool.is_empty() {
let index = pool
.iter()
.enumerate()
.max_by_key(|(i, c)| {
let diversity = selected
.iter()
.map(|chosen| distance(&c.phrase, &chosen.phrase))
.min()
.unwrap_or(0);
let new_family = selected.iter().all(|chosen| chosen.strategy != c.strategy);
(
fit(role, c.strategy) > 0,
new_family,
fit(role, c.strategy) + diversity,
usize::MAX - *i,
)
})
.map(|(i, _)| i)
.unwrap();
selected.push(pool.remove(index));
}
Ok(selected)
}
#[allow(clippy::too_many_arguments)]
fn develop(
motif: &Phrase,
mut notes: Vec<Note>,
length: u32,
s: &Settings,
harmony: &Harmony,
role: Role,
family: Strategy,
place: Place,
rng: &mut Random,
) -> Phrase {
let meter = s.meter;
let departure = role == Role::Departure;
let returning = role == Role::Return || place.last;
let pattern = (rng.next() % 3) as usize;
let original_count = notes.len();
let half = length / 2;
if !s.preserve_rhythm {
let mut i = 0;
notes.retain(|n| {
let index = i;
i += 1;
if n.is_anchor() {
return true;
}
match family {
Strategy::NegativeSpace => {
index == 0 || index + 1 == original_count || (index + pattern).is_multiple_of(3)
}
Strategy::FragmentAnswer if !returning => n.start < half.max(motif.length / 2),
Strategy::CallResponse if !returning => n.start < half,
_ => true,
}
});
}
for (i, n) in notes.iter_mut().enumerate() {
if !n.keeps_pitch() {
let steps = match family {
Strategy::LaterPeak if departure => 3 + s.variation as i16 / 25,
Strategy::LaterPeak if i < original_count / 2 => 2,
Strategy::HarmonicReturn if departure => 2,
Strategy::ChangedEnding if i >= original_count / 2 => -1 - pattern as i16,
Strategy::DelayedResolution if !returning => 1,
_ => 0,
};
n.pitch = s.degree(n.pitch, steps);
}
if !n.locked {
let accent = if meter.strong(n.start) { 5 } else { -5 };
let arc = if departure {
6
} else if returning {
-3
} else {
0
};
n.velocity = (n.velocity as i16 + accent + arc).clamp(1, 127) as u8;
}
}
if !s.preserve_rhythm {
let unit = meter.beat();
for i in 0..notes.len() {
if notes[i].keeps_rhythm() {
continue;
}
let previous = if i == 0 { 0 } else { notes[i - 1].end() };
let next = notes.get(i + 1).map_or(length, |n| n.start);
let n = &mut notes[i];
let shift = unit / 4 + (s.variation as u32 * unit / 400);
if matches!(
family,
Strategy::NewRhythm | Strategy::FragmentAnswer | Strategy::CallResponse
) {
let wanted = if (i + pattern).is_multiple_of(2) {
n.start.saturating_add(shift)
} else {
n.start.saturating_sub(shift)
};
n.start = wanted.clamp(previous, next.saturating_sub(1).max(previous));
}
let desired = match family {
Strategy::NegativeSpace | Strategy::FragmentAnswer => n.duration.min(unit / 2),
Strategy::CallResponse if n.start >= half => n.duration.saturating_add(unit / 2),
Strategy::NewRhythm if (i + pattern) % 2 == 1 => n.duration / 2,
_ => n.duration,
};
n.duration = desired.max(1).min(next.saturating_sub(n.start).max(1));
}
if family == Strategy::NewRhythm {
let mut extra = Vec::new();
for (i, n) in notes.iter().enumerate() {
let next = notes.get(i + 1).map_or(length, |n| n.start);
if !n.is_anchor() && next.saturating_sub(n.end()) >= unit && i % 2 == pattern % 2 {
extra.push(Note {
start: n.end() + unit / 4,
duration: unit / 4,
pitch: n.pitch,
velocity: n.velocity.saturating_sub(12).max(1),
..Note::default()
});
}
}
notes.extend(extra);
notes.sort_by_key(|n| n.start);
}
}
// Reference relationships constrain harmonic adjustments, preserving the chosen contour.
let reference: Vec<_> = notes.iter().map(|n| n.pitch).collect();
for i in 0..notes.len() {
if notes[i].keeps_pitch() {
continue;
}
let final_note = i + 1 == notes.len();
let global = notes[i].start + place.offset;
let strong = meter.strong(global);
let wants_open = final_note
&& ((place.last && s.ending == Ending::Open)
|| (!place.last
&& (family == Strategy::DelayedResolution || role == Role::Statement)));
let wants_closed = final_note && place.last && s.ending == Ending::Resolve;
let mask = harmony.at(global).map(|h| h.tones);
if !strong && !final_note {
continue;
}
let allowed = |pitch: u8| {
if !s.preserve_contour {
return true;
}
let previous = i.checked_sub(1).map(|p| (reference[p], notes[p].pitch));
let next = reference.get(i + 1).copied();
previous.is_none_or(|(old, new)| match reference[i].cmp(&old) {
std::cmp::Ordering::Greater => pitch >= new,
std::cmp::Ordering::Less => pitch <= new,
std::cmp::Ordering::Equal => pitch == new,
}) && next.is_none_or(|next| match reference[i].cmp(&next) {
std::cmp::Ordering::Greater => pitch >= next,
std::cmp::Ordering::Less => pitch <= next,
std::cmp::Ordering::Equal => pitch == next,
})
};
let target = if wants_closed && family == Strategy::HarmonicReturn {
motif.notes.last().map_or(notes[i].pitch, |n| n.pitch)
} else {
notes[i].pitch
};
let chosen = (s.low..=s.high)
.filter(|&pitch| {
if !allowed(pitch) {
return false;
}
match mask {
Some(mask) if wants_open => {
s.contains(pitch) && mask & (1 << (pitch % 12)) == 0
}
Some(mask) => mask & (1 << (pitch % 12)) != 0,
None if wants_open => s.contains(pitch) && pitch % 12 != s.root,
None if wants_closed => pitch % 12 == s.root,
None => false,
}
})
.min_by_key(|&pitch| pitch.abs_diff(target));
if let Some(pitch) = chosen {
notes[i].pitch = pitch;
}
}
Phrase { notes, length }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{demo_motif, Meter, BAR};
#[test]
fn departure_peak_rises_above_the_motif() {
let motif = demo_motif();
let s = Settings {
preserve_rhythm: true,
..Settings::default()
};
let phrase = develop(
&motif,
tile(&motif, 2 * BAR),
2 * BAR,
&s,
&Harmony::default(),
Role::Departure,
Strategy::LaterPeak,
Place {
offset: 4 * BAR,
last: false,
},
&mut Random(42),
);
let peak = phrase.notes.iter().map(|n| n.pitch).max().unwrap();
assert!(peak > motif.notes.iter().map(|n| n.pitch).max().unwrap());
}
#[test]
fn preserved_contour_has_no_direction_reversals_with_a_fixed_groove() {
let motif = demo_motif();
let s = Settings {
preserve_rhythm: true,
preserve_contour: true,
..Settings::default()
};
for family in Strategy::ALL {
let phrase = develop(
&motif,
tile(&motif, 2 * BAR),
2 * BAR,
&s,
&Harmony::default(),
Role::Answer,
family,
Place {
offset: 2 * BAR,
last: false,
},
&mut Random(42),
);
for (original, new) in motif.notes.windows(2).zip(phrase.notes.windows(2)) {
let a = original[1].pitch as i16 - original[0].pitch as i16;
let b = new[1].pitch as i16 - new[0].pitch as i16;
assert!(a * b >= 0, "{family:?}");
}
}
}
#[test]
fn tiling_fills_blocks_of_any_meter_without_crossing_the_end() {
let motif = demo_motif();
for meter in Meter::ALL {
for bars in 1..=8 {
let length = bars * meter.bar();
let notes = tile(&motif, length);
assert!(!notes.is_empty());
assert!(notes.iter().all(|n| n.end() <= length));
assert!(notes.windows(2).all(|w| w[0].end() <= w[1].start));
}
}
}
}