feat(rivet): storyboard composition with flexible lengths and meters
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.
This commit is contained in:
nvrl committed 2026-11-06 02:15:18 +01:00
1 parent abd37fd4fe
commit f1366280ff
37 files changed
+3763 -2549

No files matched your search

-214
View File
@@ -1,214 +0,0 @@
use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE, ROLE, SURFACE};
use rivet_app::{Output, Shared};
use rivet_core::{CreativeMove, PerformanceSettings, Result, RoleMode, Session};
use std::sync::Arc;
pub struct CreativeUi {
gesture_name: String,
branch_name: String,
scope: usize,
save_source: bool,
}
impl Default for CreativeUi {
fn default() -> Self {
Self {
gesture_name: "New gesture".into(),
branch_name: "New idea".into(),
scope: 0,
save_source: true,
}
}
}
fn feedback(state: &mut EditorState, result: Result<String>) {
match result {
Ok(message) => {
state.status = message;
state.error = false;
}
Err(error) => report(state, Err(error)),
}
}
pub fn ideas(
ui: &mut egui::Ui,
shared: &Arc<Shared>,
state: &mut EditorState,
session: &mut Session,
) -> bool {
let mut restored = false;
let busy = shared.busy.load(std::sync::atomic::Ordering::Acquire);
egui::CollapsingHeader::new("Gestures, moves, and branches")
.id_salt("creative-history")
.default_open(!session.gestures.is_empty() || !session.branches.is_empty())
.show(ui, |ui| {
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("Scope");
state.creative.scope = state.creative.scope.min(session.settings.bars as usize / 2);
let selected = if state.creative.scope == 0 { "Whole phrase".into() } else { format!("Bars {}–{}", state.creative.scope * 2 - 1, state.creative.scope * 2) };
egui::ComboBox::from_id_salt("move-scope").selected_text(selected).show_ui(ui, |ui| {
ui.selectable_value(&mut state.creative.scope, 0, "Whole phrase");
for i in 1..=session.settings.bars as usize / 2 {
ui.selectable_value(&mut state.creative.scope, i, format!("Bars {}–{}", i * 2 - 1, i * 2));
}
});
let scope = state.creative.scope.checked_sub(1);
for (label, operation) in [
("Subtract", CreativeMove::Thin), ("Delay 1/16", CreativeMove::Delay),
("Longer gates", CreativeMove::Lengthen), ("Up semitone", CreativeMove::Transpose(1)),
("Down semitone", CreativeMove::Transpose(-1)),
] {
if ui.add_enabled(session.selected_phrase().is_some(), egui::Button::new(label)).clicked() {
feedback(state, session.apply_move(operation, scope).map(|_| "New branch created. Its parent and pinned material are preserved.".into()));
}
}
});
ui.label(egui::RichText::new("Moves respect pins and note locks. Release Keep groove to change timing or articulation.").color(MUTED));
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut state.creative.save_source, "Save source motif");
ui.add(egui::TextEdit::singleline(&mut state.creative.gesture_name).char_limit(64).desired_width(170.));
let section = if state.creative.save_source { None } else { state.creative.scope.checked_sub(1) };
let enabled = session.source.is_some() && (state.creative.save_source || section.is_some() && session.selected_phrase().is_some());
if ui.add_enabled(enabled, egui::Button::new("Save gesture")).clicked() {
let name = state.creative.gesture_name.clone();
feedback(state, session.save_gesture(&name, section).map(|_| "Gesture saved with its sound-role and controller choices.".into()));
}
if ui.add_enabled(!session.gestures.is_empty(), egui::Button::new("Export gestures…")).clicked() {
let saved = session.export_gesture_library();
start(shared, state, move || {
let bytes = saved?;
let Some(mut path) = rivet_app::dialog::choose(true, true)? else { return Ok(Output::Canceled); };
if path.extension().is_none() { path.set_extension("json"); }
rivet_app::write_new(&path, &bytes)?; Ok(Output::Saved(path))
});
}
if ui.button("Import gestures…").clicked() {
start(shared, state, move || {
let Some(path) = rivet_app::dialog::choose(false, true)? else { return Ok(Output::Canceled); };
let bytes = rivet_app::read_limited(&path, 2 * 1024 * 1024)?;
Ok(Output::LibraryLoaded(rivet_core::read_gesture_library(&bytes)?))
});
}
});
if !state.creative.save_source && state.creative.scope == 0 {
ui.label(egui::RichText::new("Choose a two-bar scope to save a developed section.").color(MUTED));
}
let gestures = session.gestures.clone();
for gesture in gestures {
ui.horizontal(|ui| {
ui.label(&gesture.name);
ui.label(egui::RichText::new(format!("{} notes", gesture.phrase.notes.len())).color(MUTED));
if ui.button("Recall as motif").clicked() {
let result = session.recall_gesture(gesture.id);
restored |= result.is_ok();
feedback(state, result.map(|_| "Gesture recalled with its performance choices. Current harmony is retained; preview is stopped.".into()));
}
if ui.small_button("Remove").clicked() { session.gestures.retain(|g| g.id != gesture.id); }
});
}
ui.separator();
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut state.creative.branch_name).char_limit(64).desired_width(190.));
if ui.button("Save branch").clicked() {
let name = state.creative.branch_name.clone();
feedback(state, session.save_branch(&name).map(|_| "Working idea saved as a branch. Future moves can fork from here.".into()));
}
});
let branches = session.branches.clone();
egui::ScrollArea::vertical().id_salt("branch-list").max_height(170.).show(ui, |ui| {
for branch in &branches {
let mut depth = 0; let mut parent = branch.parent;
while let Some(id) = parent {
depth += 1;
parent = branches.iter().find(|b| b.id == id).and_then(|b| b.parent);
if depth >= 16 { break; }
}
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 12.);
ui.label(egui::RichText::new(&branch.name).color(if session.active_branch == Some(branch.id) { LOCK } else { NOTE }));
if ui.button("Restore").clicked() {
let result = session.restore_branch(branch.id);
restored |= result.is_ok();
feedback(state, result.map(|_| "Branch restored. Other branches and gestures remain available.".into()));
}
let leaf = !branches.iter().any(|b| b.parent == Some(branch.id));
if ui.add_enabled(leaf, egui::Button::new("Remove leaf")).clicked() {
feedback(state, session.remove_branch(branch.id).map(|_| "Leaf branch removed; working material is retained.".into()));
}
});
}
});
});
});
restored
}
pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) {
egui::CollapsingHeader::new("Sound roles and expressive gestures").id_salt("performance-settings").default_open(settings.mode != RoleMode::Solo).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt("role-mode").selected_text(settings.mode.name()).show_ui(ui, |ui| {
for mode in RoleMode::ALL { ui.selectable_value(&mut settings.mode, mode, mode.name()); }
});
ui.checkbox(&mut settings.compensated_export, "Export compensation with a one-bar lead-in");
});
ui.label(egui::RichText::new("Assign the channels to separate synth parts. CC curves require explicit macro mapping. Internal preview uses simple reference voices.").color(MUTED));
for role in 0..if settings.mode == RoleMode::Solo { 1 } else { 2 } {
let voice = &mut settings.voices[role];
ui.push_id(role, |ui| {
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.label(if role == 0 { "Primary" } else { "Answer" });
ui.add(egui::TextEdit::singleline(&mut voice.name).char_limit(48).desired_width(130.));
let mut channel = voice.channel as u16 + 1;
ui.label("Channel"); ui.add(egui::DragValue::new(&mut channel).range(1..=16)); voice.channel = (channel - 1) as u8;
ui.label("Transpose"); ui.add(egui::DragValue::new(&mut voice.transpose).range(-24..=24));
ui.label("Gate"); ui.add(egui::DragValue::new(&mut voice.gate).range(10..=200).suffix("%"));
ui.label("Velocity"); ui.add(egui::DragValue::new(&mut voice.velocity).range(1..=200).suffix("%"));
});
ui.horizontal_wrapped(|ui| {
ui.label("Estimated attack"); ui.add(egui::DragValue::new(&mut voice.attack_ms).range(0..=500).suffix(" ms"));
ui.label("Estimated release"); ui.add(egui::DragValue::new(&mut voice.release_ms).range(0..=2000).suffix(" ms"));
ui.label(egui::RichText::new("MIDI audition warms up before beat 1. Re-audition after tempo changes.").color(MUTED));
});
let curve = &mut voice.controller;
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut curve.enabled, "Send controller gesture");
ui.label("CC"); ui.add(egui::DragValue::new(&mut curve.cc).range(1..=119));
ui.label("Stop value"); ui.add(egui::DragValue::new(&mut curve.rest).range(0..=127));
for (index, label) in ["Rise", "Fall", "Arch", "Pulse"].iter().enumerate() {
if ui.button(*label).clicked() { curve.shape(index); }
}
});
if curve.enabled {
let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 58.), egui::Sense::hover());
let painter = ui.painter(); painter.rect_filled(rect, 3., SURFACE);
let points: Vec<_> = curve.points.iter().map(|p| egui::pos2(rect.left() + p.position as f32 / 1000. * rect.width(), rect.bottom() - p.value as f32 / 127. * rect.height())).collect();
for pair in points.windows(2) { painter.line_segment([pair[0],pair[1]], egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { ROLE })); }
egui::ScrollArea::vertical().id_salt("curve-points").max_height(115.).show(ui, |ui| {
let mut remove = None;
for index in 0..curve.points.len() {
let low = if index == 0 { 0 } else { curve.points[index - 1].position + 1 };
let high = curve.points.get(index + 1).map_or(1000, |p| p.position - 1);
let interior = index != 0 && index + 1 != curve.points.len();
let point = &mut curve.points[index];
ui.horizontal(|ui| {
let mut percent = point.position as f64 / 10.;
ui.add_enabled(interior, egui::DragValue::new(&mut percent).range(low as f64/10. ..=high as f64/10.).speed(0.1).suffix("%"));
point.position = (percent * 10.).round() as u16;
ui.label("Value"); ui.add(egui::DragValue::new(&mut point.value).range(0..=127));
if ui.add_enabled(interior, egui::Button::new("Remove point")).clicked() { remove = Some(index); }
});
}
if let Some(index) = remove { curve.points.remove(index); }
});
if ui.add_enabled(curve.points.len()<16, egui::Button::new("Add point in largest gap")).clicked() {
if let Some((index,pair)) = curve.points.windows(2).enumerate().max_by_key(|(_,p)|p[1].position-p[0].position) {
let position = (pair[0].position+pair[1].position)/2;
if position > pair[0].position { let value = curve.value_at(position); curve.points.insert(index+1,rivet_core::CurvePoint {position,value}); }
}
}
}
});
}
});
}
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -178,8 +178,9 @@ impl Plugin for Rivet {
}
impl ClapPlugin for Rivet {
const CLAP_ID: &'static str = "io.storyboard.motif";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Develop a MIDI motif into a connected phrase");
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Tell a MIDI motif as a story: a storyboard of statement, answer, departure and return",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
+173
View File
@@ -0,0 +1,173 @@
//! Sound roles: split a phrase across two synth parts with their own channels and CC gestures.
use super::{egui, MUTED, NOTE, ROLE, SURFACE};
use rivet_core::{PerformanceSettings, RoleMode};
pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt("role-mode")
.selected_text(settings.mode.name())
.show_ui(ui, |ui| {
for mode in RoleMode::ALL {
ui.selectable_value(&mut settings.mode, mode, mode.name());
}
});
ui.checkbox(
&mut settings.compensated_export,
"Export compensation with a one-bar lead-in",
);
});
ui.label(egui::RichText::new("Assign the channels to separate synth parts. CC curves require explicit macro mapping. Internal preview uses simple reference voices.").color(MUTED));
for role in 0..if settings.mode == RoleMode::Solo {
1
} else {
2
} {
let voice = &mut settings.voices[role];
ui.push_id(role, |ui| {
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.label(if role == 0 { "Primary" } else { "Answer" });
ui.add(
egui::TextEdit::singleline(&mut voice.name)
.char_limit(48)
.desired_width(130.),
);
let mut channel = voice.channel as u16 + 1;
ui.label("Channel");
ui.add(egui::DragValue::new(&mut channel).range(1..=16));
voice.channel = (channel - 1) as u8;
ui.label("Transpose");
ui.add(egui::DragValue::new(&mut voice.transpose).range(-24..=24));
ui.label("Gate");
ui.add(
egui::DragValue::new(&mut voice.gate)
.range(10..=200)
.suffix("%"),
);
ui.label("Velocity");
ui.add(
egui::DragValue::new(&mut voice.velocity)
.range(1..=200)
.suffix("%"),
);
});
ui.horizontal_wrapped(|ui| {
ui.label("Estimated attack");
ui.add(
egui::DragValue::new(&mut voice.attack_ms)
.range(0..=500)
.suffix(" ms"),
);
ui.label("Estimated release");
ui.add(
egui::DragValue::new(&mut voice.release_ms)
.range(0..=2000)
.suffix(" ms"),
);
ui.label(
egui::RichText::new(
"MIDI audition warms up before beat 1. Re-audition after tempo changes.",
)
.color(MUTED),
);
});
let curve = &mut voice.controller;
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut curve.enabled, "Send controller gesture");
ui.label("CC");
ui.add(egui::DragValue::new(&mut curve.cc).range(1..=119));
ui.label("Stop value");
ui.add(egui::DragValue::new(&mut curve.rest).range(0..=127));
for (index, label) in ["Rise", "Fall", "Arch", "Pulse"].iter().enumerate() {
if ui.button(*label).clicked() {
curve.shape(index);
}
}
});
if curve.enabled {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 58.),
egui::Sense::hover(),
);
let painter = ui.painter();
painter.rect_filled(rect, 3., SURFACE);
let points: Vec<_> = curve
.points
.iter()
.map(|p| {
egui::pos2(
rect.left() + p.position as f32 / 1000. * rect.width(),
rect.bottom() - p.value as f32 / 127. * rect.height(),
)
})
.collect();
for pair in points.windows(2) {
painter.line_segment(
[pair[0], pair[1]],
egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { ROLE }),
);
}
egui::ScrollArea::vertical()
.id_salt("curve-points")
.max_height(115.)
.show(ui, |ui| {
let mut remove = None;
for index in 0..curve.points.len() {
let low = if index == 0 {
0
} else {
curve.points[index - 1].position + 1
};
let high = curve.points.get(index + 1).map_or(1000, |p| p.position - 1);
let interior = index != 0 && index + 1 != curve.points.len();
let point = &mut curve.points[index];
ui.horizontal(|ui| {
let mut percent = point.position as f64 / 10.;
ui.add_enabled(
interior,
egui::DragValue::new(&mut percent)
.range(low as f64 / 10. ..=high as f64 / 10.)
.speed(0.1)
.suffix("%"),
);
point.position = (percent * 10.).round() as u16;
ui.label("Value");
ui.add(egui::DragValue::new(&mut point.value).range(0..=127));
if ui
.add_enabled(interior, egui::Button::new("Remove point"))
.clicked()
{
remove = Some(index);
}
});
}
if let Some(index) = remove {
curve.points.remove(index);
}
});
if ui
.add_enabled(
curve.points.len() < 16,
egui::Button::new("Add point in largest gap"),
)
.clicked()
{
if let Some((index, pair)) = curve
.points
.windows(2)
.enumerate()
.max_by_key(|(_, p)| p[1].position - p[0].position)
{
let position = (pair[0].position + pair[1].position) / 2;
if position > pair[0].position {
let value = curve.value_at(position);
curve
.points
.insert(index + 1, rivet_core::CurvePoint { position, value });
}
}
}
}
});
}
}