restructure
Check and Windows preview / check (patina) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m38s
Check and Windows preview / previews (push) Successful in 1m6s
Check and Windows preview / windows (push) Successful in 1m50s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 2m21s
Check and Windows preview / check (patina) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m38s
Check and Windows preview / previews (push) Successful in 1m6s
Check and Windows preview / windows (push) Successful in 1m50s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 2m21s
This commit is contained in:
1 parent
3c5b05fdec
commit
15853e61ac
87 files changed
+2065
-1198
No files matched your search
@@ -0,0 +1,214 @@
|
||||
use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE};
|
||||
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., egui::Color32::from_rgb(30,43,63));
|
||||
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 { egui::Color32::from_rgb(107,201,194) })); }
|
||||
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}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
//! Shared egui view. Also compiled by the offline visual QA harness against cached egui.
|
||||
use crate::egui::{self, Align2, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use rivet_app::{Output, Shared};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
#[path = "creative_editor.rs"]
|
||||
mod creative_editor;
|
||||
use rivet_core::{
|
||||
Audition, ChordQuality, Ending, Error, Harmony, Phrase, Result, Scale, Session, Task, BAR,
|
||||
};
|
||||
const INK: Color32 = Color32::from_rgb(225, 231, 243);
|
||||
const MUTED: Color32 = Color32::from_rgb(154, 172, 196);
|
||||
const NOTE: Color32 = Color32::from_rgb(178, 164, 237);
|
||||
const LOCK: Color32 = Color32::from_rgb(241, 192, 109);
|
||||
const SURFACE: Color32 = Color32::from_rgb(30, 43, 63);
|
||||
const GRID: Color32 = Color32::from_rgb(56, 72, 95);
|
||||
const KEYS: [&str; 12] = [
|
||||
"C", "C♯", "D", "E♭", "E", "F", "F♯", "G", "A♭", "A", "B♭", "B",
|
||||
];
|
||||
pub struct EditorState {
|
||||
status: String,
|
||||
error: bool,
|
||||
import_path: String,
|
||||
export_path: String,
|
||||
pending: Option<rivet_midi::MidiFile>,
|
||||
track: usize,
|
||||
preview_lane: Option<usize>,
|
||||
pending_lane: Option<usize>,
|
||||
harmony_import: bool,
|
||||
lock_mode: u8,
|
||||
initialized: bool,
|
||||
creative: creative_editor::CreativeUi,
|
||||
}
|
||||
impl Default for EditorState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: "Import a short MIDI motif, or load the demo to explore.".into(),
|
||||
error: false,
|
||||
import_path: String::new(),
|
||||
export_path: String::new(),
|
||||
pending: None,
|
||||
track: 0,
|
||||
preview_lane: None,
|
||||
pending_lane: None,
|
||||
harmony_import: false,
|
||||
lock_mode: 0,
|
||||
initialized: false,
|
||||
creative: creative_editor::CreativeUi::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut visuals = egui::Visuals::dark();
|
||||
visuals.panel_fill = Color32::from_rgb(23, 34, 51);
|
||||
visuals.window_fill = Color32::from_rgb(30, 43, 63);
|
||||
visuals.override_text_color = Some(INK);
|
||||
visuals.selection.bg_fill = Color32::from_rgb(82, 72, 125);
|
||||
visuals.widgets.inactive.bg_fill = Color32::from_rgb(48, 64, 87);
|
||||
visuals.widgets.hovered.bg_fill = Color32::from_rgb(68, 83, 108);
|
||||
visuals.widgets.active.bg_fill = Color32::from_rgb(91, 78, 135);
|
||||
ctx.set_visuals(visuals);
|
||||
let mut style = (*ctx.style_of(egui::Theme::Dark)).clone();
|
||||
style.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
style.spacing.button_padding = Vec2::new(12., 7.);
|
||||
style
|
||||
.text_styles
|
||||
.insert(egui::TextStyle::Body, FontId::proportional(14.));
|
||||
style
|
||||
.text_styles
|
||||
.insert(egui::TextStyle::Button, FontId::proportional(14.));
|
||||
ctx.set_style_of(egui::Theme::Dark, style);
|
||||
}
|
||||
fn report(state: &mut EditorState, result: Result<()>) {
|
||||
if let Err(e) = result {
|
||||
state.status = e.to_string();
|
||||
state.error = true;
|
||||
}
|
||||
}
|
||||
fn start(
|
||||
shared: &Arc<Shared>,
|
||||
state: &mut EditorState,
|
||||
job: impl FnOnce() -> Result<Output> + Send + 'static,
|
||||
) {
|
||||
start_at(shared, state, shared.revision.load(Ordering::Acquire), job);
|
||||
}
|
||||
fn start_at(
|
||||
shared: &Arc<Shared>,
|
||||
state: &mut EditorState,
|
||||
revision: u64,
|
||||
job: impl FnOnce() -> Result<Output> + Send + 'static,
|
||||
) {
|
||||
match shared.work(revision, job) {
|
||||
Ok(()) => {
|
||||
state.status = "Working…".into();
|
||||
state.error = false;
|
||||
}
|
||||
Err(e) => {
|
||||
state.status = e.to_string();
|
||||
state.error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn import(shared: &Arc<Shared>, state: &mut EditorState, path: Option<PathBuf>, harmony: bool) {
|
||||
state.harmony_import = harmony;
|
||||
state.pending = None;
|
||||
start(shared, state, move || {
|
||||
let path = match path {
|
||||
Some(p) => Some(p),
|
||||
None => rivet_app::dialog::choose(false, false)?,
|
||||
};
|
||||
let Some(path) = path else {
|
||||
return Ok(Output::Canceled);
|
||||
};
|
||||
let bytes = rivet_app::read_limited(&path, rivet_midi::MAX_FILE_BYTES)?;
|
||||
Ok(Output::Imported(rivet_midi::read(&bytes)?))
|
||||
});
|
||||
}
|
||||
fn export(
|
||||
shared: &Arc<Shared>,
|
||||
state: &mut EditorState,
|
||||
phrase: Option<Phrase>,
|
||||
tempo: f64,
|
||||
path: Option<PathBuf>,
|
||||
) {
|
||||
let Some(phrase) = phrase else {
|
||||
report(state, Err(Error("Select a phrase to export.".into())));
|
||||
return;
|
||||
};
|
||||
let performance = shared
|
||||
.session
|
||||
.read()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.performance
|
||||
.clone();
|
||||
start(shared, state, move || {
|
||||
let path = match path {
|
||||
Some(p) => Some(p),
|
||||
None => rivet_app::dialog::choose(true, false)?,
|
||||
};
|
||||
let Some(mut path) = path else {
|
||||
return Ok(Output::Canceled);
|
||||
};
|
||||
if path.extension().is_none() {
|
||||
path.set_extension("mid");
|
||||
}
|
||||
rivet_app::write_new(
|
||||
&path,
|
||||
&rivet_midi::write_performance(&phrase, &performance, tempo)?,
|
||||
)?;
|
||||
Ok(Output::Exported(path))
|
||||
});
|
||||
}
|
||||
fn apply_track(shared: &Arc<Shared>, state: &mut EditorState, index: usize) {
|
||||
let Some(file) = state.pending.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(track) = file.tracks.get(index) else {
|
||||
return;
|
||||
};
|
||||
let (revision, mut session) = shared.snapshot();
|
||||
if state.harmony_import {
|
||||
match Harmony::from_midi(&track.phrase.notes, track.phrase.length) {
|
||||
Ok(harmony) => session.harmony = harmony,
|
||||
Err(e) => {
|
||||
report(state, Err(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = track.phrase.validate_source() {
|
||||
report(state, Err(e));
|
||||
return;
|
||||
}
|
||||
session.source = Some(track.phrase.clone());
|
||||
session.pins.clear();
|
||||
}
|
||||
session.candidates.clear();
|
||||
session.selected = 0;
|
||||
let message = if state.harmony_import {
|
||||
format!(
|
||||
"Imported harmony from {}. Explore alternatives to use this context. {}",
|
||||
track.name,
|
||||
track.warnings.join(" ")
|
||||
)
|
||||
} else if track.warnings.is_empty() {
|
||||
format!(
|
||||
"Imported {}. Click notes to lock them, then generate continuations.",
|
||||
track.name
|
||||
)
|
||||
} else {
|
||||
track.warnings.join(" ")
|
||||
};
|
||||
match shared.edit(revision, session) {
|
||||
Ok(()) => {
|
||||
state.pending = None;
|
||||
state.status = message;
|
||||
state.error = false;
|
||||
state.preview_lane = None;
|
||||
}
|
||||
Err(e) => report(state, Err(e)),
|
||||
}
|
||||
}
|
||||
fn completions(shared: &Arc<Shared>, state: &mut EditorState) {
|
||||
let Some(completion) = shared.take_completion() else {
|
||||
return;
|
||||
};
|
||||
state.error = false;
|
||||
match completion.result {
|
||||
Err(e) => report(state, Err(e)),
|
||||
Ok(Output::Canceled) => state.status = "Canceled.".into(),
|
||||
Ok(Output::Exported(path)) => state.status = format!("Exported {}", path.display()),
|
||||
Ok(Output::Saved(path)) => state.status = format!("Saved {}", path.display()),
|
||||
Ok(Output::Loaded(session)) => {
|
||||
shared.stop();
|
||||
match shared.edit(completion.revision, *session) {
|
||||
Ok(()) => {
|
||||
state.pending = None;
|
||||
state.preview_lane = None;
|
||||
state.status = "Session restored. Preview is stopped.".into();
|
||||
}
|
||||
Err(e) => report(state, Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(Output::LibraryLoaded(gestures)) => {
|
||||
let (_, mut session) = shared.snapshot();
|
||||
match session.import_gesture_library(gestures) {
|
||||
Ok(count) => match shared.edit(completion.revision, session) {
|
||||
Ok(()) => {
|
||||
state.status = format!("Imported {count} gestures.");
|
||||
state.error = false;
|
||||
}
|
||||
Err(e) => report(state, Err(e)),
|
||||
},
|
||||
Err(e) => report(state, Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(Output::Imported(file)) => {
|
||||
if shared.revision.load(Ordering::Acquire) != completion.revision {
|
||||
report(
|
||||
state,
|
||||
Err(Error("Session changed during import. Import again.".into())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let single = file.tracks.len() == 1;
|
||||
state.pending = Some(file);
|
||||
state.track = 0;
|
||||
state.status = "Select the MIDI note track to import.".into();
|
||||
if single {
|
||||
apply_track(shared, state, 0);
|
||||
}
|
||||
}
|
||||
Ok(Output::Generated(candidates)) => {
|
||||
let (_, mut session) = shared.snapshot();
|
||||
session.candidates = candidates;
|
||||
let count = session.candidates.len();
|
||||
session.selected = 0;
|
||||
match shared.edit(completion.revision, session) {
|
||||
Ok(()) => {
|
||||
state.preview_lane = None;
|
||||
state.status=format!("{} distinct proposals ready. Pin sections to combine them; locks may limit variety.",count);
|
||||
}
|
||||
Err(e) => report(state, Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn show(ui: &mut egui::Ui, shared: &Arc<Shared>, state: &mut EditorState) {
|
||||
let ctx = ui.ctx().clone();
|
||||
completions(shared, state);
|
||||
if state.pending_lane.is_some() && !shared.queued.load(Ordering::Acquire) {
|
||||
state.preview_lane = state.pending_lane.take();
|
||||
}
|
||||
let busy = shared.busy.load(Ordering::Acquire);
|
||||
let (revision, mut session) = shared.snapshot();
|
||||
if !state.initialized {
|
||||
if session.source.is_some() {
|
||||
state.status = "Audition proposals, then pin the sections you want to keep.".into();
|
||||
}
|
||||
state.initialized = true;
|
||||
}
|
||||
let before = session.clone();
|
||||
let playing = shared.playing.load(Ordering::Acquire);
|
||||
let tick = f64::from_bits(shared.playhead.load(Ordering::Relaxed));
|
||||
let mut audition: Option<(usize, Phrase)> = None;
|
||||
let mut generate = false;
|
||||
let mut restored = false;
|
||||
egui::TopBottomPanel::bottom("commit-and-audition").show_inside(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Stop").clicked() {
|
||||
shared.stop();
|
||||
state.preview_lane = None;
|
||||
state.pending_lane = None;
|
||||
}
|
||||
let combined = session.composed_phrase().ok();
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy && combined.is_some(),
|
||||
egui::Button::new("Preview combined"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
audition = combined.clone().map(|p| (4, p));
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy && combined.is_some(),
|
||||
egui::Button::new("Export combined MIDI…"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
export(shared, state, combined, session.tempo, None);
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy && session.selected_phrase().is_some(),
|
||||
egui::Button::new("Keep combined"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
report(state, session.accept_composed());
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy && session.accepted.is_some(),
|
||||
egui::Button::new("Preview kept"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
audition = session.accepted.clone().map(|p| (5, p));
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy && (session.accepted.is_some() || session.previous_accepted.is_some()),
|
||||
egui::Button::new("Undo / redo keep"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
session.undo_accept();
|
||||
}
|
||||
if busy {
|
||||
ui.spinner();
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(&state.status).color(if state.error { LOCK } else { MUTED }));
|
||||
});
|
||||
egui::ScrollArea::vertical().show(ui,|ui|{
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(RichText::new("Rivet").size(28.).strong());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center),|ui|{
|
||||
if ui.add_enabled(!busy,egui::Button::new("Import motif…")).clicked(){
|
||||
import(shared,state,None,false);
|
||||
}
|
||||
if ui.add_enabled(!busy,egui::Button::new("Load demo")).clicked(){
|
||||
session.source=Some(rivet_core::demo_motif());
|
||||
session.pins.clear();
|
||||
state.status="Choose what to preserve, then explore a musical task.".into();
|
||||
state.error=false;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(RichText::new("Recognition. Space. Contrast. Return.").color(MUTED));
|
||||
ui.add_enabled_ui(!busy,|ui|{
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
egui::ComboBox::from_id_salt("task").selected_text(session.settings.task.name()).show_ui(ui,|ui|{
|
||||
for task in Task::ALL{
|
||||
ui.selectable_value(&mut session.settings.task,task,task.name());
|
||||
}
|
||||
});
|
||||
ui.label("Ending");
|
||||
ui.selectable_value(&mut session.settings.ending,Ending::Resolve,"Settled");
|
||||
ui.selectable_value(&mut session.settings.ending,Ending::Open,"Open");
|
||||
ui.label("Phrase");
|
||||
ui.selectable_value(&mut session.settings.bars,4,"4 bars");
|
||||
ui.selectable_value(&mut session.settings.bars,8,"8 bars");
|
||||
if ui.add_enabled(session.source.is_some(),egui::Button::new("Explore alternatives").fill(Color32::from_rgb(83,70,127))).clicked(){
|
||||
generate=true;
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
egui::ComboBox::from_id_salt("root").selected_text(KEYS[session.settings.root as usize]).width(45.).show_ui(ui,|ui|{
|
||||
for (i,key) in KEYS.iter().enumerate(){
|
||||
ui.selectable_value(&mut session.settings.root,i as u8,*key);
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("scale").selected_text(session.settings.scale.name()).show_ui(ui,|ui|{
|
||||
for scale in Scale::ALL{
|
||||
ui.selectable_value(&mut session.settings.scale,scale,scale.name());
|
||||
}
|
||||
});
|
||||
ui.checkbox(&mut session.settings.preserve_rhythm,"Keep groove");
|
||||
ui.checkbox(&mut session.settings.preserve_contour,"Keep contour");
|
||||
ui.add(egui::DragValue::new(&mut session.tempo).range(30.0..=300.0).speed(1.).suffix(" BPM"));
|
||||
});
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
ui.label("Audition");
|
||||
ui.selectable_value(&mut session.audition,Audition::Internal,"Internal voice");
|
||||
ui.selectable_value(&mut session.audition,Audition::Midi,"MIDI output");
|
||||
ui.checkbox(&mut session.looping,"Loop");
|
||||
ui.checkbox(&mut session.follow_host,"Host tempo");
|
||||
if ui.add_enabled(session.source.is_some(),egui::Button::new("Preview source")).clicked(){
|
||||
audition=session.source.clone().map(|p|(0,p));
|
||||
}
|
||||
});
|
||||
if session.audition==Audition::Midi{
|
||||
ui.label(RichText::new("Route each sound role to its configured MIDI channel. Map enabled CC lanes to synth macros. The internal voice is muted.").color(MUTED));
|
||||
}
|
||||
ui.collapsing("Variation and register",|ui|{
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
ui.add(egui::Slider::new(&mut session.settings.variation,0..=100).text("Reach"));
|
||||
ui.label("Register");
|
||||
ui.add(egui::DragValue::new(&mut session.settings.low).range(0..=127));
|
||||
ui.label("to");
|
||||
ui.add(egui::DragValue::new(&mut session.settings.high).range(0..=127));
|
||||
ui.label("Seed");
|
||||
ui.add(egui::DragValue::new(&mut session.settings.seed));
|
||||
if ui.button("Another take").clicked(){
|
||||
session.settings.seed=session.settings.seed.wrapping_add(1);
|
||||
generate=session.source.is_some();
|
||||
}
|
||||
});
|
||||
});
|
||||
harmony_editor(ui,shared,state,&mut session);
|
||||
creative_editor::performance(ui,&mut session.performance);
|
||||
});
|
||||
if let Some(file)=&state.pending{
|
||||
let names:Vec<_>=file.tracks.iter().map(|t|format!("{} ({} notes)",t.name,t.phrase.notes.len())).collect();
|
||||
let mut apply=false;
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(if state.harmony_import{
|
||||
"Harmony track"
|
||||
} else{
|
||||
"Motif track"
|
||||
});
|
||||
egui::ComboBox::from_id_salt("track").selected_text(&names[state.track]).show_ui(ui,|ui|{
|
||||
for (i,name) in names.iter().enumerate(){
|
||||
ui.selectable_value(&mut state.track,i,name);
|
||||
}
|
||||
});
|
||||
if ui.button("Use track").clicked(){
|
||||
apply=true;
|
||||
}
|
||||
if ui.button("Cancel").clicked(){
|
||||
state.pending=None;
|
||||
}
|
||||
});
|
||||
if apply{
|
||||
apply_track(shared,state,state.track);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut low=session.settings.low.min(session.settings.high);
|
||||
let mut high=session.settings.high.max(session.settings.low);
|
||||
for n in session.source.iter().flat_map(|p|&p.notes).chain(session.candidates.iter().flat_map(|c|&c.phrase.notes)).chain(session.pins.iter().flat_map(|p|&p.phrase.notes)){
|
||||
low=low.min(n.pitch);
|
||||
high=high.max(n.pitch);
|
||||
}
|
||||
let span=session.settings.bars as u32*BAR;
|
||||
let roll_style = RollStyle { register: (low, high), roles: session.performance.mode };
|
||||
ui.separator();
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(RichText::new("Source motif").strong().size(17.));
|
||||
ui.label("Click to lock");
|
||||
egui::ComboBox::from_id_salt("lock-mode").selected_text(["Whole note","Pitch only","Rhythm only"][state.lock_mode as usize]).show_ui(ui,|ui|{
|
||||
for (i,name) in ["Whole note","Pitch only","Rhythm only"].iter().enumerate(){
|
||||
ui.selectable_value(&mut state.lock_mode,i as u8,*name);
|
||||
}
|
||||
});
|
||||
if ui.add_enabled(!busy&&session.source.is_some(),egui::Button::new("Clear locks")).clicked(){
|
||||
if let Some(p)=session.source.as_mut(){
|
||||
for n in &mut p.notes{
|
||||
n.locked=false;
|
||||
n.pitch_locked=false;
|
||||
n.rhythm_locked=false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(source)=session.source.as_mut(){
|
||||
let cursor=(playing&&state.preview_lane==Some(0)).then_some(tick);
|
||||
if let Some(i)=roll(ui,source,span,roll_style,cursor,!busy,85.){
|
||||
let n=&mut source.notes[i];
|
||||
match state.lock_mode{
|
||||
1=>n.pitch_locked= !n.pitch_locked,2=>n.rhythm_locked= !n.rhythm_locked,_=>n.locked= !n.locked
|
||||
}
|
||||
}
|
||||
} else{
|
||||
ui.add_space(18.);
|
||||
ui.label("Import a monophonic motif of up to two bars, or load the demo.");
|
||||
ui.add_space(18.);
|
||||
}
|
||||
restored |= creative_editor::ideas(ui,shared,state,&mut session);
|
||||
if session.selected_phrase().is_some(){
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
ui.label(RichText::new("Your phrase").strong().size(17.));
|
||||
ui.label("Pin from the selected proposal:");
|
||||
for section in 0..session.settings.bars as usize/2{
|
||||
let pinned=session.pins.iter().any(|p|p.index==section);
|
||||
if ui.add_enabled(!busy,egui::Button::new(format!("{} {}–{}",if pinned{
|
||||
"Release"
|
||||
} else{
|
||||
"Pin"
|
||||
},section*2+1,section*2+2)).fill(if pinned{
|
||||
Color32::from_rgb(99,77,45)
|
||||
} else{
|
||||
SURFACE
|
||||
}
|
||||
)).clicked(){
|
||||
if pinned{
|
||||
session.pins.retain(|p|p.index!=section);
|
||||
} else{
|
||||
report(state,session.pin_selected(section));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Ok(combined)=session.composed_phrase(){
|
||||
roll(ui,&combined,span,roll_style,(playing&&state.preview_lane==Some(4)).then_some(tick),false,65.);
|
||||
}
|
||||
ui.label(RichText::new("Pinned sections survive new takes. Select another proposal to fill the unpinned sections.").color(MUTED));
|
||||
}
|
||||
for i in 0..session.candidates.len(){
|
||||
let candidate=&session.candidates[i];
|
||||
let phrase=candidate.phrase.clone();
|
||||
let name=candidate.strategy.name();
|
||||
let explanation=candidate.explanation.clone();
|
||||
ui.horizontal(|ui|{
|
||||
ui.selectable_value(&mut session.selected,i,RichText::new(name).size(17.));
|
||||
if ui.add_enabled(!busy,egui::Button::new("Audition")).clicked(){
|
||||
session.selected=i;
|
||||
audition=Some((i+1,phrase.clone()));
|
||||
}
|
||||
if ui.add_enabled(!busy,egui::Button::new("Export proposal…")).clicked(){
|
||||
export(shared,state,Some(phrase.clone()),session.tempo,None);
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(explanation).color(MUTED));
|
||||
roll(ui,&phrase,span,roll_style,(playing&&state.preview_lane==Some(i+1)).then_some(tick),false,70.);
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
if ui.add_enabled(!busy&&session.accepted.is_some(),egui::Button::new("Export kept MIDI…")).clicked(){
|
||||
export(shared,state,session.accepted.clone(),session.tempo,None);
|
||||
}
|
||||
if ui.add_enabled(!busy,egui::Button::new("Save session…")).clicked(){
|
||||
let saved=session.clone();
|
||||
start(shared,state,move||{
|
||||
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,&saved.to_json()?)?;
|
||||
Ok(Output::Saved(path))
|
||||
});
|
||||
}
|
||||
if ui.add_enabled(!busy,egui::Button::new("Load session…")).clicked(){
|
||||
start(shared,state,move||{
|
||||
let Some(path)=rivet_app::dialog::choose(false,true)? else{
|
||||
return Ok(Output::Canceled);
|
||||
};
|
||||
Ok(Output::Loaded(Box::new(Session::from_json(&rivet_app::read_limited(&path,rivet_core::MAX_STATE_BYTES)?)?)))
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.collapsing("File paths",|ui|{
|
||||
ui.add_enabled_ui(!busy,|ui|{
|
||||
ui.horizontal(|ui|{
|
||||
ui.label("Import");
|
||||
ui.text_edit_singleline(&mut state.import_path);
|
||||
if ui.button("Motif").clicked(){
|
||||
import(shared,state,Some(PathBuf::from(&state.import_path)),false);
|
||||
}
|
||||
if ui.button("Harmony").clicked(){
|
||||
import(shared,state,Some(PathBuf::from(&state.import_path)),true);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui|{
|
||||
ui.label("Export combined");
|
||||
ui.text_edit_singleline(&mut state.export_path);
|
||||
if ui.add_enabled(session.selected_phrase().is_some(),egui::Button::new("Export path")).clicked(){
|
||||
export(shared,state,session.composed_phrase().ok(),session.tempo,Some(PathBuf::from(&state.export_path)));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
if !restored
|
||||
&& (session.source != before.source || session.settings.bars != before.settings.bars)
|
||||
{
|
||||
session.pins.clear();
|
||||
}
|
||||
if !restored
|
||||
&& (session.settings != before.settings
|
||||
|| session.source != before.source
|
||||
|| session.harmony != before.harmony)
|
||||
{
|
||||
session.candidates.clear();
|
||||
session.selected = 0;
|
||||
}
|
||||
let mut generation_revision = revision;
|
||||
if session != before {
|
||||
if let Err(e) = shared.edit(revision, session.clone()) {
|
||||
report(state, Err(e));
|
||||
return;
|
||||
}
|
||||
generation_revision = revision.wrapping_add(1);
|
||||
}
|
||||
if restored {
|
||||
shared.stop();
|
||||
state.preview_lane = None;
|
||||
state.pending_lane = None;
|
||||
}
|
||||
if generate {
|
||||
if let Some(source) = session.source.clone() {
|
||||
let settings = session.settings.clone();
|
||||
let harmony = session.harmony.clone();
|
||||
start_at(shared, state, generation_revision, move || {
|
||||
Ok(Output::Generated(rivet_core::generate_with_harmony(
|
||||
&source, &settings, &harmony,
|
||||
)?))
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((lane, phrase)) = audition {
|
||||
match shared.audition(&phrase) {
|
||||
Ok(()) => {
|
||||
if shared.queued.load(Ordering::Acquire) {
|
||||
state.pending_lane = Some(lane);
|
||||
state.status = "Queued for the next two-bar boundary or phrase end.".into();
|
||||
} else {
|
||||
state.preview_lane = Some(lane);
|
||||
state.pending_lane = None;
|
||||
state.status =
|
||||
"Auditioning. Change proposals to compare at a phrase boundary.".into();
|
||||
}
|
||||
state.error = false;
|
||||
}
|
||||
Err(e) => report(state, Err(e)),
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(Duration::from_millis(if playing || busy {
|
||||
33
|
||||
} else {
|
||||
150
|
||||
}));
|
||||
}
|
||||
fn harmony_editor(
|
||||
ui: &mut egui::Ui,
|
||||
shared: &Arc<Shared>,
|
||||
state: &mut EditorState,
|
||||
session: &mut Session,
|
||||
) {
|
||||
let label = if session.harmony.spans.is_empty() {
|
||||
"Harmony: scale only".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Harmony: {} regions, repeats every {} bars",
|
||||
session.harmony.spans.len(),
|
||||
session.harmony.length / BAR
|
||||
)
|
||||
};
|
||||
egui::CollapsingHeader::new(label).id_salt("harmony-context").show(ui,|ui|{
|
||||
ui.horizontal_wrapped(|ui|{
|
||||
if ui.button("Import harmony MIDI…").clicked(){
|
||||
import(shared,state,None,true);
|
||||
}
|
||||
if ui.button("Editable progression").clicked(){
|
||||
session.harmony=Harmony::progression(session.settings.root,session.settings.scale);
|
||||
}
|
||||
if ui.button("Clear harmony").clicked(){
|
||||
session.harmony=Harmony::default();
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new("Harmony guides pitches; audition remains melody only. Imported voicings retain their pitch classes, without guessed chord names.").color(MUTED));
|
||||
egui::ScrollArea::vertical().id_salt("harmony-list").max_height(170.).show(ui,|ui|{
|
||||
for (index,span) in session.harmony.spans.iter_mut().enumerate(){
|
||||
ui.push_id(index,|ui|{
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(format!("Beat {:.2}",span.start as f32/960.+1.));
|
||||
let mut root=span.bass%12;
|
||||
let old=root;
|
||||
egui::ComboBox::from_id_salt("chord-root").selected_text(KEYS[root as usize]).show_ui(ui,|ui|{
|
||||
for (i,key) in KEYS.iter().enumerate(){
|
||||
ui.selectable_value(&mut root,i as u8,*key);
|
||||
}
|
||||
});
|
||||
if root!=old{
|
||||
let shift=(root+12-old)%12;
|
||||
span.tones=((span.tones<<shift)|(span.tones>>(12-shift)))&0xfff;
|
||||
span.bass=48+root;
|
||||
}
|
||||
let mut quality=ChordQuality::ALL.into_iter().find(|q|q.mask(root)==span.tones);
|
||||
egui::ComboBox::from_id_salt("chord-quality").selected_text(quality.map_or("Custom pitches",ChordQuality::name)).show_ui(ui,|ui|{
|
||||
for q in ChordQuality::ALL{
|
||||
ui.selectable_value(&mut quality,Some(q),q.name());
|
||||
}
|
||||
});
|
||||
if let Some(q)=quality{
|
||||
span.tones=q.mask(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct RollStyle {
|
||||
register: (u8, u8),
|
||||
roles: rivet_core::RoleMode,
|
||||
}
|
||||
fn roll(
|
||||
ui: &mut egui::Ui,
|
||||
phrase: &Phrase,
|
||||
span: u32,
|
||||
style: RollStyle,
|
||||
cursor: Option<f64>,
|
||||
editable: bool,
|
||||
height: f32,
|
||||
) -> Option<usize> {
|
||||
let (rect, response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), height),
|
||||
if editable {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(rect, 4., SURFACE);
|
||||
let plot = Rect::from_min_max(
|
||||
Pos2::new(rect.left() + 32., rect.top() + 17.),
|
||||
Pos2::new(rect.right() - 5., rect.bottom() - 5.),
|
||||
);
|
||||
let low = style.register.0.saturating_sub(1);
|
||||
let high = style.register.1.saturating_add(1).min(127);
|
||||
let rows = (high - low + 1) as f32;
|
||||
for pitch in low..=high {
|
||||
let y = plot.bottom() - (pitch - low) as f32 / rows * plot.height();
|
||||
if pitch % 12 == 0 {
|
||||
painter.line_segment(
|
||||
[Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
|
||||
Stroke::new(0.5_f32, GRID),
|
||||
);
|
||||
painter.text(
|
||||
Pos2::new(rect.left() + 3., y - 2.),
|
||||
Align2::LEFT_BOTTOM,
|
||||
format!("C{}", pitch as i16 / 12 - 1),
|
||||
FontId::proportional(9.),
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
for bar in 0..=span / BAR {
|
||||
let x = plot.left() + bar as f32 * BAR as f32 / span as f32 * plot.width();
|
||||
painter.line_segment(
|
||||
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
|
||||
Stroke::new(1_f32, GRID),
|
||||
);
|
||||
if bar < span / BAR {
|
||||
painter.text(
|
||||
Pos2::new(x + 4., rect.top() + 2.),
|
||||
Align2::LEFT_TOP,
|
||||
(bar + 1).to_string(),
|
||||
FontId::proportional(10.),
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
let click = if response.clicked() {
|
||||
response.interact_pointer_pos()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut clicked = None;
|
||||
for (i, n) in phrase.notes.iter().enumerate() {
|
||||
let x = plot.left() + n.start as f32 / span as f32 * plot.width();
|
||||
let right = plot.left() + n.end() as f32 / span as f32 * plot.width();
|
||||
let y = plot.bottom() - (n.pitch - low) as f32 / rows * plot.height();
|
||||
let note_rect = Rect::from_min_max(
|
||||
Pos2::new(x, y - (plot.height() / rows).max(4.)),
|
||||
Pos2::new(right.max(x + 3.), y),
|
||||
);
|
||||
painter.rect_filled(
|
||||
note_rect,
|
||||
1.,
|
||||
if n.is_anchor() {
|
||||
LOCK
|
||||
} else if style.roles.role(i, n) == 1 {
|
||||
Color32::from_rgb(107, 201, 194)
|
||||
} else {
|
||||
NOTE
|
||||
},
|
||||
);
|
||||
if editable && click.is_some_and(|pos| note_rect.expand(2.).contains(pos)) {
|
||||
clicked = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(tick) = cursor.filter(|t| *t >= 0.) {
|
||||
let x = plot.left() + (tick / span as f64) as f32 * plot.width();
|
||||
painter.line_segment(
|
||||
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
|
||||
Stroke::new(1.5_f32, INK),
|
||||
);
|
||||
}
|
||||
clicked
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use nih_plug::{params::persist::PersistentField, prelude::*};
|
||||
pub use nih_plug_egui::egui;
|
||||
use nih_plug_egui::{create_egui_editor, EguiState};
|
||||
use rivet_app::{AudioEngine, Shared};
|
||||
use rivet_core::Session;
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
mod editor;
|
||||
|
||||
pub struct SessionField(Arc<Shared>);
|
||||
impl<'a> PersistentField<'a, Session> for SessionField {
|
||||
fn set(&self, session: Session) {
|
||||
// State restoration always stops preview, including invalid/future state.
|
||||
self.0.stop();
|
||||
if self.0.replace(session).is_err() {
|
||||
let _ = self.0.replace(Session::default());
|
||||
}
|
||||
}
|
||||
fn map<F, R>(&self, f: F) -> R
|
||||
where
|
||||
F: Fn(&Session) -> R,
|
||||
{
|
||||
f(&self.0.session.read().unwrap_or_else(|p| p.into_inner()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
struct RivetParams {
|
||||
#[persist = "editor"]
|
||||
editor: Arc<EguiState>,
|
||||
#[persist = "story-session-v1"]
|
||||
session: SessionField,
|
||||
// Notifies the host of edits to non-parameter session data, including lock changes.
|
||||
#[id = "session-revision"]
|
||||
revision: IntParam,
|
||||
}
|
||||
|
||||
pub struct Rivet {
|
||||
params: Arc<RivetParams>,
|
||||
shared: Arc<Shared>,
|
||||
audio: AudioEngine,
|
||||
host_was_playing: bool,
|
||||
}
|
||||
impl Default for Rivet {
|
||||
fn default() -> Self {
|
||||
let shared = Arc::new(Shared::default());
|
||||
Self {
|
||||
params: Arc::new(RivetParams {
|
||||
editor: EguiState::from_size(1100, 850),
|
||||
session: SessionField(shared.clone()),
|
||||
revision: IntParam::new(
|
||||
"Session revision",
|
||||
0,
|
||||
IntRange::Linear {
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
},
|
||||
)
|
||||
.hide()
|
||||
.non_automatable(),
|
||||
}),
|
||||
shared,
|
||||
audio: AudioEngine::default(),
|
||||
host_was_playing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Rivet {
|
||||
const NAME: &'static str = "Rivet";
|
||||
const VENDOR: &'static str = "Rivet & Patina";
|
||||
const URL: &'static str = "";
|
||||
const EMAIL: &'static str = "";
|
||||
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout {
|
||||
main_input_channels: None,
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
}];
|
||||
const MIDI_OUTPUT: MidiConfig = MidiConfig::MidiCCs;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let shared = self.shared.clone();
|
||||
let params = self.params.clone();
|
||||
create_egui_editor(
|
||||
self.params.editor.clone(),
|
||||
editor::EditorState::default(),
|
||||
|ctx, _| editor::style(ctx),
|
||||
move |ctx, setter, state| {
|
||||
let before = shared.revision.load(Ordering::Acquire);
|
||||
egui::CentralPanel::default().show(ctx, |ui| editor::show(ui, &shared, state));
|
||||
if shared.revision.load(Ordering::Acquire) != before {
|
||||
setter.begin_set_parameter(¶ms.revision);
|
||||
setter
|
||||
.set_parameter(¶ms.revision, (params.revision.value() + 1) % 1_000_001);
|
||||
setter.end_set_parameter(¶ms.revision);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
_: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
_: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
self.audio
|
||||
.player
|
||||
.set_sample_rate(config.sample_rate as f64)
|
||||
.is_ok()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.shared.stop();
|
||||
self.audio.stop();
|
||||
self.host_was_playing = false;
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
self.audio.begin_block(&self.shared);
|
||||
let transport = context.transport();
|
||||
// A host stop silences a running audition; host start never initiates playback.
|
||||
if self.host_was_playing && !transport.playing {
|
||||
self.shared.stop();
|
||||
self.audio.stop();
|
||||
}
|
||||
self.host_was_playing = transport.playing;
|
||||
let local = f64::from_bits(self.shared.tempo.load(Ordering::Acquire));
|
||||
let bpm = if self.shared.follow_host.load(Ordering::Acquire) {
|
||||
transport.tempo.unwrap_or(local)
|
||||
} else {
|
||||
local
|
||||
};
|
||||
self.shared
|
||||
.audition_tempo
|
||||
.store(bpm.to_bits(), Ordering::Release);
|
||||
let midi = self.shared.midi.load(Ordering::Acquire);
|
||||
for (index, channels) in buffer.iter_samples().enumerate() {
|
||||
let frame = self.audio.frame(bpm, midi);
|
||||
for message in frame.events.events[..frame.events.len].iter().flatten() {
|
||||
match message.kind {
|
||||
rivet_core::MessageKind::Off => context.send_event(NoteEvent::NoteOff {
|
||||
timing: index as u32,
|
||||
voice_id: None,
|
||||
channel: message.channel,
|
||||
note: message.key,
|
||||
velocity: 0.,
|
||||
}),
|
||||
rivet_core::MessageKind::On => context.send_event(NoteEvent::NoteOn {
|
||||
timing: index as u32,
|
||||
voice_id: None,
|
||||
channel: message.channel,
|
||||
note: message.key,
|
||||
velocity: message.value as f32 / 127.,
|
||||
}),
|
||||
rivet_core::MessageKind::Control => context.send_event(NoteEvent::MidiCC {
|
||||
timing: index as u32,
|
||||
channel: message.channel,
|
||||
cc: message.key,
|
||||
value: message.value as f32 / 127.,
|
||||
}),
|
||||
}
|
||||
}
|
||||
let sample = if midi { 0. } else { frame.sample };
|
||||
for output in channels {
|
||||
*output = sample;
|
||||
}
|
||||
}
|
||||
self.audio.end_block(&self.shared);
|
||||
// Keep the host processing so a GUI Preview command can wake the voice.
|
||||
ProcessStatus::KeepAlive
|
||||
}
|
||||
}
|
||||
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_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::Instrument,
|
||||
ClapFeature::Synthesizer,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Utility,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Rivet);
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
nih_plug::prelude::nih_export_standalone::<rivet::Rivet>();
|
||||
}
|
||||
Reference in new issue
Block a user