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

626 lines
21 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.
//! Explicit sound roles and controller gestures, compiled away from the audio thread.
use crate::{err, Meter, Note, Phrase, Result, MAX_LENGTH, MAX_PLAYBACK_NOTES, PPQ};
use serde::{Deserialize, Serialize};
use std::f64::consts::TAU;
pub const MAX_PERFORMANCE_EVENTS: usize = 5120;
pub const MAX_FRAME_EVENTS: usize = 64;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum RoleMode {
#[default]
Solo,
Alternating,
CallResponse,
Interlock,
}
impl RoleMode {
pub const ALL: [Self; 4] = [
Self::Solo,
Self::Alternating,
Self::CallResponse,
Self::Interlock,
];
pub fn name(self) -> &'static str {
match self {
Self::Solo => "One voice",
Self::Alternating => "Alternate events",
Self::CallResponse => "Question and answer",
Self::Interlock => "Strong beats and replies",
}
}
/// Which of the two voices plays note `index`. Question/answer alternates bars;
/// strong beats follow the meter's pulse.
pub fn role(self, index: usize, n: &Note, meter: Meter) -> usize {
match self {
Self::Solo => 0,
Self::Alternating => index % 2,
Self::CallResponse => ((n.start / meter.bar()) % 2) as usize,
Self::Interlock => usize::from(!meter.strong(n.start)),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct CurvePoint {
pub position: u16,
pub value: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControllerCurve {
pub enabled: bool,
pub cc: u8,
pub rest: u8,
pub points: Vec<CurvePoint>,
}
impl Default for ControllerCurve {
fn default() -> Self {
Self {
enabled: false,
cc: 1,
rest: 0,
points: vec![
CurvePoint {
position: 0,
value: 0,
},
CurvePoint {
position: 1000,
value: 127,
},
],
}
}
}
impl ControllerCurve {
pub fn validate(&self) -> Result<()> {
if self.cc == 0 || self.cc >= 120 || [32, 64, 66, 69].contains(&self.cc) || self.rest > 127
{
return Err(err("Choose a continuous controller in 1–119 (excluding bank/hold pedals) and a valid rest value."));
}
if !(2..=16).contains(&self.points.len())
|| self.points.first().unwrap().position != 0
|| self.points.last().unwrap().position != 1000
|| self.points.iter().any(|p| p.value > 127)
|| self
.points
.windows(2)
.any(|p| p[0].position >= p[1].position)
{
return Err(err("A controller curve needs 2–16 ordered points from 0 to 1000, with MIDI values 0–127."));
}
Ok(())
}
pub fn value_at(&self, position: u16) -> u8 {
for pair in self.points.windows(2) {
if position <= pair[1].position {
let p = position.saturating_sub(pair[0].position) as f64
/ (pair[1].position - pair[0].position) as f64;
return (pair[0].value as f64 + (pair[1].value as f64 - pair[0].value as f64) * p)
.round()
.clamp(0., 127.) as u8;
}
}
self.points.last().map_or(0, |p| p.value)
}
pub fn shape(&mut self, preset: usize) {
self.points = match preset {
1 => vec![(0, 127), (1000, 0)],
2 => vec![(0, 0), (500, 127), (1000, 0)],
3 => vec![
(0, 0),
(125, 127),
(250, 0),
(375, 127),
(500, 0),
(625, 127),
(750, 0),
(875, 127),
(1000, 0),
],
_ => vec![(0, 0), (1000, 127)],
}
.into_iter()
.map(|(position, value)| CurvePoint { position, value })
.collect();
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VoiceProfile {
pub name: String,
pub channel: u8,
pub transpose: i8,
pub gate: u16,
pub velocity: u16,
pub attack_ms: u16,
pub release_ms: u16,
pub controller: ControllerCurve,
}
impl Default for VoiceProfile {
fn default() -> Self {
Self {
name: "Primary".into(),
channel: 0,
transpose: 0,
gate: 100,
velocity: 100,
attack_ms: 0,
release_ms: 0,
controller: ControllerCurve::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerformanceSettings {
pub mode: RoleMode,
pub voices: [VoiceProfile; 2],
pub compensated_export: bool,
}
impl Default for PerformanceSettings {
fn default() -> Self {
Self {
mode: RoleMode::Solo,
voices: [
VoiceProfile::default(),
VoiceProfile {
name: "Answer".into(),
channel: 1,
..VoiceProfile::default()
},
],
compensated_export: false,
}
}
}
impl PerformanceSettings {
pub fn validate(&self) -> Result<()> {
if self.mode != RoleMode::Solo && self.voices[0].channel == self.voices[1].channel {
return Err(err("Use separate MIDI channels for the two sound roles."));
}
for v in &self.voices {
if v.name.chars().count() > 48
|| v.channel > 15
|| !(-24..=24).contains(&v.transpose)
|| !(10..=200).contains(&v.gate)
|| !(1..=200).contains(&v.velocity)
|| v.attack_ms > 500
|| v.release_ms > 2000
{
return Err(err("Invalid patch profile: check channel, register offset, gate, velocity, and timing limits."));
}
v.controller.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum MessageKind {
#[default]
Off,
Control,
On,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MidiMessage {
pub kind: MessageKind,
pub channel: u8,
pub key: u8,
pub value: u8,
pub rest: u8,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TimedMessage {
pub tick: i32,
pub role: u8,
pub message: MidiMessage,
pub next_cycle: bool,
}
#[derive(Debug, Clone)]
pub struct Performance {
pub length: u32,
pub lead_in: u32,
pub events: Vec<TimedMessage>,
}
impl Performance {
pub fn compile(
phrase: &Phrase,
settings: &PerformanceSettings,
bpm: f64,
compensate: bool,
meter: Meter,
) -> Result<Self> {
phrase.validate(MAX_PLAYBACK_NOTES)?;
settings.validate()?;
if !bpm.is_finite() || !(30.0..=300.0).contains(&bpm) {
return Err(err("Tempo must be 30–300 BPM."));
}
let to_ticks = |ms: u16| (ms as f64 * bpm * PPQ as f64 / 60000.).round() as i32;
let mut events = Vec::new();
let mut lead_in = 0;
for role in 0..2 {
if role == 1 && settings.mode == RoleMode::Solo {
continue;
}
let profile = &settings.voices[role];
let attack = if compensate {
to_ticks(profile.attack_ms)
} else {
0
};
let release = if compensate {
to_ticks(profile.release_ms)
} else {
0
};
let routed: Vec<_> = phrase
.notes
.iter()
.enumerate()
.filter(|(i, n)| settings.mode.role(*i, n, meter) == role)
.map(|(_, n)| n)
.collect();
if routed.is_empty() && !profile.controller.enabled {
continue;
}
lead_in = lead_in.max(attack as u32);
for (index, n) in routed.iter().enumerate() {
let pitch = if n.keeps_pitch() {
n.pitch as i16
} else {
n.pitch as i16 + profile.transpose as i16
};
if !(0..=127).contains(&pitch) {
return Err(err(
"A sound-role register offset exceeds MIDI pitch limits.",
));
}
let on = n.start as i32 - attack;
let duration = if n.keeps_rhythm() {
n.duration
} else {
(n.duration as u64 * profile.gate as u64 / 100).max(1) as u32
};
let wrap = routed.first().map_or(phrase.length as i32, |first| {
phrase.length as i32 + (first.start as i32 - attack).min(0)
});
let next = routed
.get(index + 1)
.map_or(wrap, |n| n.start as i32 - attack);
let off = (n.start as i32 + duration as i32 - release)
.min(next)
.min(phrase.length as i32)
.max(on + 1);
let velocity = if n.locked {
n.velocity
} else {
(n.velocity as u32 * profile.velocity as u32 / 100).clamp(1, 127) as u8
};
events.push(TimedMessage {
tick: on,
role: role as u8,
message: MidiMessage {
kind: MessageKind::On,
channel: profile.channel,
key: pitch as u8,
value: velocity,
rest: 0,
},
next_cycle: false,
});
events.push(TimedMessage {
tick: off,
role: role as u8,
message: MidiMessage {
kind: MessageKind::Off,
channel: profile.channel,
key: pitch as u8,
value: 0,
rest: 0,
},
next_cycle: false,
});
}
if profile.controller.enabled {
let curve = &profile.controller;
let mut points = std::collections::BTreeMap::new();
for tick in (0..=phrase.length).step_by((PPQ / 8) as usize) {
points.insert(
tick as i32,
curve.value_at((tick as u64 * 1000 / phrase.length as u64) as u16),
);
}
for p in &curve.points {
points.insert(
(p.position as u64 * phrase.length as u64 / 1000) as i32,
p.value,
);
}
if attack > 0 {
points.insert(-attack, curve.value_at(0));
}
for (tick, value) in points {
events.push(TimedMessage {
tick,
role: role as u8,
message: MidiMessage {
kind: MessageKind::Control,
channel: profile.channel,
key: curve.cc,
value,
rest: curve.rest,
},
next_cycle: false,
});
}
}
}
// Negative early triggers are played once during warm-up, and before each subsequent wrap.
let wrapped: Vec<_> = events
.iter()
.filter(|e| e.tick < 0)
.map(|e| TimedMessage {
tick: e.tick + phrase.length as i32,
next_cycle: true,
..*e
})
.collect();
// Very short synthetic clips cannot represent their own warm-up as one loop.
if lead_in >= phrase.length {
return Err(err("Attack compensation exceeds this phrase's length. Reduce it or use a longer phrase."));
}
events.extend(wrapped);
events.sort_by_key(|e| (e.tick, e.message.kind, e.message.channel, e.message.key));
if events.len() > MAX_PERFORMANCE_EVENTS {
return Err(err("Performance exceeds the bounded event limit. Simplify the phrase or controller curve."));
}
Ok(Self {
length: phrase.length,
lead_in,
events,
})
}
pub fn clip(&self) -> Result<PerformanceClip> {
if self.events.len() > MAX_PERFORMANCE_EVENTS
|| self.length == 0
|| self.length > MAX_LENGTH
|| self.lead_in >= self.length
{
return Err(err("Invalid performance clip."));
}
let mut per_tick = std::collections::BTreeMap::new();
for event in &self.events {
if event.role > 1
|| event.message.channel > 15
|| event.message.key > 127
|| event.message.value > 127
|| event.message.rest > 127
|| event.message.kind == MessageKind::On && event.message.value == 0
|| event.tick < -(self.lead_in as i32)
|| event.tick > self.length as i32
|| event.next_cycle && event.tick < 0
{
return Err(err("Invalid scheduled MIDI event."));
}
*per_tick.entry(event.tick).or_insert(0) += 1;
}
if per_tick.values().any(|&count| count > 16)
|| self.events.windows(2).any(|p| p[0].tick > p[1].tick)
{
return Err(err("Unsorted or excessively dense performance events."));
}
let mut clip = PerformanceClip {
events: [TimedMessage::default(); MAX_PERFORMANCE_EVENTS],
count: self.events.len(),
length: self.length,
lead_in: self.lead_in,
};
clip.events[..clip.count].copy_from_slice(&self.events);
Ok(clip)
}
}
#[derive(Clone, Copy)]
pub struct PerformanceClip {
events: [TimedMessage; MAX_PERFORMANCE_EVENTS],
count: usize,
pub length: u32,
pub lead_in: u32,
}
pub struct EventBatch {
pub events: [Option<MidiMessage>; MAX_FRAME_EVENTS],
pub len: usize,
}
impl Default for EventBatch {
fn default() -> Self {
Self {
events: [None; MAX_FRAME_EVENTS],
len: 0,
}
}
}
impl EventBatch {
pub fn push(&mut self, message: MidiMessage) {
assert!(self.len < MAX_FRAME_EVENTS, "bounded MIDI batch overflow");
self.events[self.len] = Some(message);
self.len += 1;
}
}
#[derive(Clone, Copy, Default)]
struct Voice {
held: Option<(u8, u8)>,
frequency: f64,
phase: f64,
gain: f64,
level: f64,
}
impl Voice {
fn sample(&mut self, rate: f64, role: usize) -> f32 {
let target = if self.held.is_some() { 1. } else { 0. };
let speed = if target > self.gain {
1. / (0.005 * rate)
} else {
1. / (0.012 * rate)
};
self.gain = if target > self.gain {
(self.gain + speed).min(target)
} else {
(self.gain - speed).max(target)
};
let sine = self.phase.sin();
let triangle = 2. / std::f64::consts::PI * sine.asin();
let wave = if role == 0 {
sine * 0.8 + triangle * 0.2
} else {
triangle * 0.65 + (self.phase * 2.).sin() * 0.35
};
self.phase = (self.phase + TAU * self.frequency / rate) % TAU;
(wave * self.gain * 0.1 * self.level) as f32
}
}
pub struct PerformancePlayer {
clip: PerformanceClip,
tick: f64,
index: usize,
cycle: u64,
playing: bool,
looping: bool,
sample_rate: f64,
voices: [Voice; 2],
controls: [Option<MidiMessage>; 2],
}
impl Default for PerformancePlayer {
fn default() -> Self {
Self {
clip: PerformanceClip {
events: [TimedMessage::default(); MAX_PERFORMANCE_EVENTS],
count: 0,
length: 0,
lead_in: 0,
},
tick: 0.,
index: 0,
cycle: 0,
playing: false,
looping: false,
sample_rate: 44100.,
voices: [Voice::default(); 2],
controls: [None; 2],
}
}
}
impl PerformancePlayer {
pub fn set_sample_rate(&mut self, rate: f64) -> Result<()> {
if !rate.is_finite() || !(8000.0..=384000.0).contains(&rate) {
return Err(err("Unsupported sample rate."));
}
self.sample_rate = rate;
self.stop();
Ok(())
}
pub fn set_looping(&mut self, value: bool) {
self.looping = value;
}
pub fn is_playing(&self) -> bool {
self.playing
}
pub fn length(&self) -> u32 {
self.clip.length
}
pub fn tick(&self) -> f64 {
self.tick
}
pub fn absolute_tick(&self) -> f64 {
self.cycle as f64 * self.clip.length as f64 + self.tick
}
pub fn play(&mut self, clip: PerformanceClip) {
self.stop();
self.clip = clip;
self.tick = -(clip.lead_in as f64);
self.playing = true;
}
pub fn stop(&mut self) {
self.playing = false;
self.tick = 0.;
self.index = 0;
self.cycle = 0;
self.voices = [Voice::default(); 2];
self.controls = [None; 2];
}
fn dispatch_until(&mut self, tick: f64, batch: &mut EventBatch) {
while self.index < self.clip.count && self.clip.events[self.index].tick as f64 <= tick {
let event = self.clip.events[self.index];
self.index += 1;
if event.next_cycle && !self.looping {
continue;
}
let voice = &mut self.voices[event.role as usize];
match event.message.kind {
MessageKind::On => {
voice.held = Some((event.message.key, event.message.value));
voice.level = event.message.value as f64 / 127.;
voice.frequency = 440. * 2.0_f64.powf((event.message.key as f64 - 69.) / 12.);
voice.phase = 0.;
}
MessageKind::Off => {
if voice.held.is_some_and(|n| n.0 == event.message.key) {
voice.held = None;
}
}
MessageKind::Control => self.controls[event.role as usize] = Some(event.message),
}
batch.push(event.message);
}
}
pub fn current_messages(&self) -> EventBatch {
let mut batch = EventBatch::default();
for role in 0..2 {
if let Some(message) = self.controls[role] {
batch.push(message);
}
if let Some((key, value)) = self.voices[role].held {
let channel = self.clip.events[..self.clip.count]
.iter()
.find(|e| e.role as usize == role && e.message.kind == MessageKind::On)
.map_or(role as u8, |e| e.message.channel);
batch.push(MidiMessage {
kind: MessageKind::On,
channel,
key,
value,
rest: 0,
});
}
}
batch
}
pub fn next_frame(&mut self, bpm: f64) -> (f32, EventBatch) {
let mut batch = EventBatch::default();
if !self.playing {
return (0., batch);
}
if self.tick >= self.clip.length as f64 {
self.dispatch_until(self.clip.length as f64, &mut batch);
if self.looping {
self.tick -= self.clip.length as f64;
self.cycle = self.cycle.wrapping_add(1);
self.index = self.clip.events[..self.clip.count].partition_point(|e| e.tick < 0);
} else {
self.playing = false;
return (0., batch);
}
}
self.dispatch_until(self.tick, &mut batch);
let sample = self
.voices
.iter_mut()
.enumerate()
.map(|(role, v)| v.sample(self.sample_rate, role))
.sum();
self.tick += (if bpm.is_finite() {
bpm.clamp(30., 300.)
} else {
140.
}) * PPQ as f64
/ (60. * self.sample_rate);
(sample, batch)
}
}