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.
626 lines
21 KiB
Rust
626 lines
21 KiB
Rust
//! 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)
|
||
}
|
||
}
|