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

662 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The storyboard: a bin of motifs, and a timeline of blocks that develop them.
//! Rivet joins neighbouring blocks at their seams.
use crate::{
err, legacy, realize, Audition, Candidate, Error, Harmony, Note, PerformanceSettings, Phrase,
Result, Settings, MAX_PLAYBACK_NOTES, MAX_SOURCE_NOTES, MAX_STATE_BYTES, STATE_VERSION,
};
use serde::{Deserialize, Deserializer, Serialize};
pub const MAX_MOTIFS: usize = 16;
pub const MAX_BLOCKS: usize = 32;
/// Longest storyboard, in bars.
pub const MAX_BARS: u32 = 32;
/// Longest motif and longest block, in bars.
pub const MAX_MOTIF_BARS: u32 = 8;
/// A short idea the producer brought in. Locks live on its notes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Motif {
pub id: u64,
pub name: String,
pub phrase: Phrase,
}
/// What a block does in the story.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum Role {
/// Present the idea so it can be recognized later.
#[default]
Statement,
/// Reply to what came before.
Answer,
/// Move away: new register, new rhythm.
Departure,
/// Bring the idea back.
Return,
/// Silence.
Rest,
}
impl Role {
pub const ALL: [Self; 5] = [
Self::Statement,
Self::Answer,
Self::Departure,
Self::Return,
Self::Rest,
];
pub fn name(self) -> &'static str {
match self {
Self::Statement => "Statement",
Self::Answer => "Answer",
Self::Departure => "Departure",
Self::Return => "Return",
Self::Rest => "Rest",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Statement => "Present the motif so it can be recognized later.",
Self::Answer => "Reply to what came before: shorter call, longer arrival.",
Self::Departure => "Move away: higher register, displaced rhythm, fragments.",
Self::Return => "Bring the motif back and land it.",
Self::Rest => "Silence. Space makes the next entry count.",
}
}
}
/// How a block hands over to the next one.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum Join {
/// No change at the seam.
#[default]
Cut,
/// Leave half a beat of air before the next block.
Breath,
/// Two short notes in the last beat climb or fall into the next block's first note.
Pickup,
/// The last note moves one scale step next to the next block's first note.
Approach,
}
impl Join {
pub const ALL: [Self; 4] = [Self::Cut, Self::Breath, Self::Pickup, Self::Approach];
pub fn name(self) -> &'static str {
match self {
Self::Cut => "Cut",
Self::Breath => "Breath",
Self::Pickup => "Pickup",
Self::Approach => "Approach",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Cut => "Blocks meet as written.",
Self::Breath => "The last note is shortened to leave air before the next block.",
Self::Pickup => "Two short notes lead into the next block's first note.",
Self::Approach => "The last note steps next to the next block's first note.",
}
}
/// A musical default for a transition between two roles.
pub fn between(from: Role, to: Role) -> Self {
match (from, to) {
(Role::Rest, _) | (_, Role::Rest) => Self::Cut,
(_, Role::Departure) => Self::Pickup,
(_, Role::Return) => Self::Approach,
_ => Self::Breath,
}
}
}
/// One section of the storyboard.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Block {
pub id: u64,
/// Motif this block develops. Ignored for rests.
pub motif: u64,
pub role: Role,
pub bars: u8,
/// Hand-over into the next block.
pub join: Join,
/// Increment for another set of options.
#[serde(default)]
pub take: u64,
/// Up to three realized options; the first fits the role best.
#[serde(default)]
pub options: Vec<Candidate>,
#[serde(default)]
pub choice: usize,
/// Kept blocks are not regenerated when other inputs change.
#[serde(default)]
pub kept: bool,
}
impl Block {
pub fn phrase(&self) -> Option<&Phrase> {
self.options.get(self.choice).map(|c| &c.phrase)
}
}
/// Starting shapes for a storyboard built from one motif.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Template {
Arc,
QuestionAnswer,
Breathe,
Build,
}
impl Template {
pub const ALL: [Self; 4] = [Self::Arc, Self::QuestionAnswer, Self::Breathe, Self::Build];
pub fn name(self) -> &'static str {
match self {
Self::Arc => "Arc: statement, answer, departure, return",
Self::QuestionAnswer => "Question and answer, twice",
Self::Breathe => "Breathe: ideas separated by rests",
Self::Build => "Build: two departures before the return",
}
}
fn roles(self) -> &'static [Role] {
use Role::*;
match self {
Self::Arc => &[Statement, Answer, Departure, Return],
Self::QuestionAnswer => &[Statement, Answer, Statement, Answer],
Self::Breathe => &[Statement, Rest, Answer, Rest, Return],
Self::Build => &[Statement, Answer, Departure, Departure, Answer, Return],
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Session {
pub version: u32,
pub settings: Settings,
pub motifs: Vec<Motif>,
pub blocks: Vec<Block>,
pub tempo: f64,
pub follow_host: bool,
pub harmony: Harmony,
pub audition: Audition,
pub looping: bool,
pub performance: PerformanceSettings,
pub next_id: u64,
}
impl Default for Session {
fn default() -> Self {
Self {
version: STATE_VERSION,
settings: Settings::default(),
motifs: Vec::new(),
blocks: Vec::new(),
tempo: 140.0,
follow_host: false,
harmony: Harmony::default(),
audition: Audition::Internal,
looping: true,
performance: PerformanceSettings::default(),
next_id: 1,
}
}
}
/// Current-format fields, read leniently so added fields default.
#[derive(Deserialize)]
#[serde(default)]
struct Current {
version: u32,
settings: Settings,
motifs: Vec<Motif>,
blocks: Vec<Block>,
tempo: f64,
follow_host: bool,
harmony: Harmony,
audition: Audition,
looping: bool,
performance: PerformanceSettings,
next_id: u64,
}
impl Default for Current {
fn default() -> Self {
let s = Session::default();
Self {
version: s.version,
settings: s.settings,
motifs: s.motifs,
blocks: s.blocks,
tempo: s.tempo,
follow_host: s.follow_host,
harmony: s.harmony,
audition: s.audition,
looping: s.looping,
performance: s.performance,
next_id: s.next_id,
}
}
}
/// Sessions from version 1–3 (single motif, fixed two-bar sections) become a
/// storyboard: the motif goes into the bin and the old phrase length becomes blocks.
impl<'de> Deserialize<'de> for Session {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
use serde::de::Error as _;
let value = serde_json::Value::deserialize(d)?;
let version = value
.get("version")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
if version < STATE_VERSION as u64 {
let old: legacy::Session = serde_json::from_value(value).map_err(D::Error::custom)?;
return Ok(old.upgrade());
}
let c: Current = serde_json::from_value(value).map_err(D::Error::custom)?;
Ok(Self {
version: c.version,
settings: c.settings,
motifs: c.motifs,
blocks: c.blocks,
tempo: c.tempo,
follow_host: c.follow_host,
harmony: c.harmony,
audition: c.audition,
looping: c.looping,
performance: c.performance,
next_id: c.next_id,
})
}
}
impl Session {
pub fn migrate(&mut self) -> Result<()> {
if self.version != STATE_VERSION {
return Err(err("Unsupported session version."));
}
Ok(())
}
fn id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn motif(&self, id: u64) -> Option<&Motif> {
self.motifs.iter().find(|m| m.id == id)
}
pub fn motif_mut(&mut self, id: u64) -> Option<&mut Motif> {
self.motifs.iter_mut().find(|m| m.id == id)
}
pub fn total_bars(&self) -> u32 {
self.blocks.iter().map(|b| b.bars as u32).sum()
}
/// Start of block `index` in ticks.
pub fn block_start(&self, index: usize) -> u32 {
self.blocks[..index.min(self.blocks.len())]
.iter()
.map(|b| b.bars as u32 * self.settings.meter.bar())
.sum()
}
fn last_sounding(&self) -> Option<usize> {
self.blocks.iter().rposition(|b| b.role != Role::Rest)
}
fn check_motif(&self, phrase: &Phrase) -> Result<()> {
phrase.validate(MAX_SOURCE_NOTES)?;
if phrase.length > MAX_MOTIF_BARS * self.settings.meter.bar() {
return Err(Error(format!(
"A motif can be at most {MAX_MOTIF_BARS} bars of {}.",
self.settings.meter.name()
)));
}
Ok(())
}
/// Adds a motif to the bin. Its length is rounded up to whole bars.
pub fn add_motif(&mut self, name: &str, mut phrase: Phrase) -> Result<u64> {
if self.motifs.len() >= MAX_MOTIFS {
return Err(Error(format!(
"The bin holds {MAX_MOTIFS} motifs. Remove one first."
)));
}
let bar = self.settings.meter.bar();
phrase.length = self.settings.meter.bars_for(phrase.length) * bar;
self.check_motif(&phrase)?;
let id = self.id();
let name = name.trim();
self.motifs.push(Motif {
id,
name: if name.is_empty() {
format!("Motif {}", self.motifs.len() + 1)
} else {
name.chars().take(60).collect()
},
phrase,
});
Ok(id)
}
/// Removes a motif and every block that develops it.
pub fn remove_motif(&mut self, id: u64) {
self.motifs.retain(|m| m.id != id);
self.blocks
.retain(|b| b.role == Role::Rest || b.motif != id);
self.realize_all();
}
/// Motif length in whole bars of the current meter.
pub fn motif_bars(&self, id: u64) -> u8 {
self.motif(id).map_or(2, |m| {
self.settings
.meter
.bars_for(m.phrase.length)
.min(MAX_MOTIF_BARS) as u8
})
}
/// Inserts a block at `index` and realizes the storyboard.
pub fn insert_block(&mut self, index: usize, motif: u64, role: Role, bars: u8) -> Result<u64> {
if self.blocks.len() >= MAX_BLOCKS {
return Err(Error(format!("A storyboard holds {MAX_BLOCKS} blocks.")));
}
if !(1..=MAX_MOTIF_BARS as u8).contains(&bars) {
return Err(err("A block is 1–8 bars long."));
}
if self.total_bars() + bars as u32 > MAX_BARS {
return Err(Error(format!(
"The storyboard is limited to {MAX_BARS} bars."
)));
}
if role != Role::Rest && self.motif(motif).is_none() {
return Err(err("Choose a motif from the bin first."));
}
let index = index.min(self.blocks.len());
let id = self.id();
let join = self
.blocks
.get(index)
.map_or(Join::Cut, |next| Join::between(role, next.role));
if let Some(previous) = index.checked_sub(1).and_then(|i| self.blocks.get_mut(i)) {
previous.join = Join::between(previous.role, role);
}
self.blocks.insert(
index,
Block {
id,
motif,
role,
bars,
join,
take: 0,
options: Vec::new(),
choice: 0,
kept: false,
},
);
self.realize_all();
Ok(id)
}
pub fn remove_block(&mut self, index: usize) {
if index < self.blocks.len() {
self.blocks.remove(index);
self.realize_all();
}
}
pub fn move_block(&mut self, from: usize, to: usize) {
if from < self.blocks.len() && to < self.blocks.len() && from != to {
let block = self.blocks.remove(from);
self.blocks.insert(to, block);
self.realize_all();
}
}
pub fn duplicate_block(&mut self, index: usize) -> Result<()> {
let Some(block) = self.blocks.get(index).cloned() else {
return Ok(());
};
if self.blocks.len() >= MAX_BLOCKS || self.total_bars() + block.bars as u32 > MAX_BARS {
return Err(Error(format!(
"The storyboard is limited to {MAX_BLOCKS} blocks and {MAX_BARS} bars."
)));
}
let id = self.id();
self.blocks.insert(index + 1, Block { id, ..block });
self.realize_all();
Ok(())
}
/// Replaces the timeline with a template built from one motif.
pub fn apply_template(&mut self, template: Template, motif: u64) -> Result<()> {
if self.motif(motif).is_none() {
return Err(err("Choose a motif from the bin first."));
}
let size = self.motif_bars(motif);
let roles = template.roles();
let mut blocks = Vec::new();
let mut total = 0;
for (i, &role) in roles.iter().enumerate() {
let bars = if role == Role::Rest { 1 } else { size };
if total + bars as u32 > MAX_BARS {
break;
}
total += bars as u32;
let join = roles
.get(i + 1)
.map_or(Join::Cut, |&next| Join::between(role, next));
let id = self.id();
blocks.push(Block {
id,
motif,
role,
bars,
join,
take: 0,
options: Vec::new(),
choice: 0,
kept: false,
});
}
self.blocks = blocks;
self.realize_all();
Ok(())
}
/// New options for one block; it is no longer kept.
pub fn another_take(&mut self, index: usize) {
if let Some(block) = self.blocks.get_mut(index) {
block.take = block.take.wrapping_add(1);
block.kept = false;
block.choice = 0;
self.realize(index);
}
}
fn realize(&mut self, index: usize) {
let offset = self.block_start(index);
let last = self.last_sounding() == Some(index);
let block = &self.blocks[index];
let motif = self
.motif(block.motif)
.map(|m| m.phrase.clone())
.unwrap_or_default();
let options = if block.role == Role::Rest || !motif.notes.is_empty() {
realize(
&motif,
block.role,
block.bars,
offset,
last,
block.take,
&self.settings,
&self.harmony,
)
.unwrap_or_default()
} else {
Vec::new()
};
let block = &mut self.blocks[index];
block.choice = block.choice.min(options.len().saturating_sub(1));
block.options = options;
}
/// Realizes every block that is not kept. Kept blocks are realized only when
/// their stored options no longer fit (for example after a meter change).
pub fn realize_all(&mut self) {
let bar = self.settings.meter.bar();
for index in 0..self.blocks.len() {
let block = &self.blocks[index];
let fits = block
.phrase()
.is_some_and(|p| p.length == block.bars as u32 * bar);
if !block.kept || !fits {
self.realize(index);
}
}
}
/// The whole storyboard as one phrase, with joins applied at every seam.
pub fn compose(&self) -> Result<Phrase> {
if self.blocks.is_empty() {
return Err(err("Add blocks to the storyboard first."));
}
let mut notes = Vec::new();
let mut offset = 0;
for (i, block) in self.blocks.iter().enumerate() {
let length = block.bars as u32 * self.settings.meter.bar();
let mut local = block.phrase().map(|p| p.notes.clone()).unwrap_or_default();
let next = self
.blocks
.get(i + 1)
.and_then(Block::phrase)
.and_then(|p| p.notes.first().copied());
if i + 1 < self.blocks.len() {
self.join(&mut local, length, block.join, next);
}
notes.extend(local.into_iter().map(|n| Note {
start: n.start + offset,
..n
}));
offset += length;
}
if notes.is_empty() {
return Err(err("The storyboard is silent. Add a block with notes."));
}
let phrase = Phrase {
notes,
length: offset,
};
phrase.validate(MAX_PLAYBACK_NOTES)?;
Ok(phrase)
}
/// Applies a hand-over to the end of one block. Never touches locked attributes
/// and never moves notes across the block boundary.
fn join(&self, notes: &mut Vec<Note>, length: u32, join: Join, next: Option<Note>) {
let beat = self.settings.meter.beat();
let Some(last) = notes.last().copied() else {
return;
};
match join {
Join::Cut => {}
Join::Breath => {
let limit = length - beat / 2;
let n = notes.last_mut().unwrap();
if !n.keeps_rhythm() && n.start < limit && n.end() > limit {
n.duration = limit - n.start;
}
}
Join::Approach => {
let Some(target) = next else { return };
let n = notes.last_mut().unwrap();
if n.keeps_pitch() {
return;
}
let below = self.settings.degree(target.pitch, -1);
let above = self.settings.degree(target.pitch, 1);
n.pitch = if n.pitch > target.pitch && above != target.pitch {
above
} else if below != target.pitch {
below
} else {
above
};
}
Join::Pickup => {
let Some(target) = next else { return };
let region = length - beat;
if notes.iter().any(|n| n.end() > region && n.is_anchor()) {
return;
}
notes.retain(|n| n.start < region);
if let Some(n) = notes.last_mut() {
if n.end() > region {
n.duration = region - n.start;
}
}
let reference = notes.last().unwrap_or(&last).pitch;
let direction: i16 = if reference > target.pitch { 1 } else { -1 };
let half = beat / 2;
for (k, steps) in [(0, 2 * direction), (1, direction)] {
notes.push(Note {
start: region + k * half,
duration: half,
pitch: self.settings.degree(target.pitch, steps),
velocity: (target.velocity as i16 - 18 + 8 * k as i16).clamp(1, 127) as u8,
..Note::default()
});
}
}
}
}
pub fn validate(&self) -> Result<()> {
if self.version != STATE_VERSION {
return Err(err("Unsupported session version."));
}
self.settings.validate()?;
self.performance.validate()?;
self.harmony.validate()?;
if !self.tempo.is_finite() || !(30.0..=300.0).contains(&self.tempo) {
return Err(err("Tempo must be between 30 and 300 BPM."));
}
if self.motifs.len() > MAX_MOTIFS || self.blocks.len() > MAX_BLOCKS {
return Err(err("Too many motifs or blocks."));
}
let mut ids = std::collections::BTreeSet::new();
for m in &self.motifs {
if m.id >= self.next_id || !ids.insert(m.id) || m.name.chars().count() > 60 {
return Err(err("Invalid motif."));
}
self.check_motif(&m.phrase)?;
}
let bar = self.settings.meter.bar();
for b in &self.blocks {
if b.id >= self.next_id
|| !ids.insert(b.id)
|| !(1..=MAX_MOTIF_BARS as u8).contains(&b.bars)
|| (b.role != Role::Rest && self.motif(b.motif).is_none())
|| b.options.len() > 3
|| (!b.options.is_empty() && b.choice >= b.options.len())
{
return Err(err("Invalid storyboard block."));
}
for option in &b.options {
option.phrase.check(MAX_PLAYBACK_NOTES, true)?;
if option.phrase.length != b.bars as u32 * bar {
return Err(err("A block option does not match its length."));
}
}
}
if self.total_bars() > MAX_BARS {
return Err(Error(format!(
"The storyboard is limited to {MAX_BARS} bars."
)));
}
Ok(())
}
pub fn from_json(bytes: &[u8]) -> Result<Self> {
if bytes.len() > MAX_STATE_BYTES {
return Err(err("Session exceeds the 16 MiB limit."));
}
let mut session: Self = serde_json::from_slice(bytes).map_err(|e| Error(e.to_string()))?;
session.migrate()?;
session.validate()?;
Ok(session)
}
pub fn to_json(&self) -> Result<Vec<u8>> {
self.validate()?;
let bytes = serde_json::to_vec(self).map_err(|e| Error(e.to_string()))?;
if bytes.len() > MAX_STATE_BYTES {
return Err(err("Session exceeds the 16 MiB limit."));
}
Ok(bytes)
}
/// The demo motif and an arc storyboard built from it.
pub fn demo() -> Self {
let mut s = Self::default();
let id = s
.add_motif("Demo motif", crate::demo_motif())
.expect("valid demo");
s.apply_template(Template::Arc, id).expect("valid demo");
s
}
}