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
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:
1 parent
abd37fd4fe
commit
f1366280ff
37 files changed
+3763
-2549
No files matched your search
@@ -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}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
+1626
-601
File diff suppressed because it is too large.
Load diff
@@ -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] = &[
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user