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.
479 lines
17 KiB
Rust
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));
|
|
}
|
|
}
|
|
}
|
|
}
|