feat(rivet): storyboard composition with flexible lengths and meters
Check and Windows preview / check (patina) (push) Successful in 3m59s
Check and Windows preview / check (rivet) (push) Successful in 2m2s
Check and Windows preview / previews (push) Successful in 1m8s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m41s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 9s

Rivet 0.4.0 tells a motif as a story. A timeline of blocks with roles
(statement, answer, departure, return, rest) replaces tasks, pins and
branches. Rivet writes each block from a motif and joins the seams with
a cut, breath, pickup or approach.

- Motifs of 1-8 bars in a bin of 16; storyboards up to 32 bars.
- Meters 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 and 12/8 for import, writing,
  audition and export. Phrygian, harmonic minor and Mixolydian scales.
- Per-block options, Another take and Keep; templates for new boards.
- Editor rebuilt: motif bin, timeline, large piano roll, inspector,
  tabs for generation, harmony and sound roles, 32-step undo/redo.
- Sessions from 0.1-0.3 open as a storyboard from their source motif.
This commit is contained in:
nvrl committed 2026-11-06 02:15:18 +01:00
1 parent abd37fd4fe
commit f1366280ff
37 files changed
+3763 -2549

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-app"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
+12 -13
View File
@@ -1,15 +1,15 @@
//! Shared application state and bounded control messages. The audio callback never locks a session.
use crossbeam_queue::ArrayQueue;
use rivet_core::{
Audition, Candidate, Error, EventBatch, MessageKind, MidiMessage, Performance, PerformanceClip,
PerformancePlayer, Result, Session, SECTION,
Audition, Error, EventBatch, MessageKind, MidiMessage, Performance, PerformanceClip,
PerformancePlayer, Result, Session,
};
use std::{
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
Arc, Mutex, RwLock,
},
};
@@ -34,17 +34,17 @@ pub struct Shared {
pub queued: AtomicBool,
pub playing: AtomicBool,
pub playhead: AtomicU64,
/// Bar length in ticks; queued auditions switch on the next bar line.
pub bar: AtomicU32,
pub commands: ArrayQueue<PlayRequest>,
pub busy: AtomicBool,
results: Mutex<Vec<Completion>>,
}
pub enum Output {
Imported(rivet_midi::MidiFile),
Generated(Vec<Candidate>),
Exported(PathBuf),
Saved(PathBuf),
Loaded(Box<Session>),
LibraryLoaded(Vec<rivet_core::Gesture>),
Canceled,
}
pub struct Completion {
@@ -65,6 +65,7 @@ impl Default for Shared {
queued: AtomicBool::new(false),
playing: AtomicBool::new(false),
playhead: AtomicU64::new(0),
bar: AtomicU32::new(rivet_core::BAR),
commands: ArrayQueue::new(2),
busy: AtomicBool::new(false),
results: Mutex::new(Vec::new()),
@@ -87,6 +88,7 @@ impl Shared {
self.midi
.store(s.audition == Audition::Midi, Ordering::Release);
self.looping.store(s.looping, Ordering::Release);
self.bar.store(s.settings.meter.bar(), Ordering::Release);
self.revision.fetch_add(1, Ordering::AcqRel);
}
pub fn replace(&self, mut session: Session) -> Result<()> {
@@ -107,10 +109,7 @@ impl Shared {
"Session changed while this operation was running. Try again.".into(),
));
}
if current.source != session.source
|| current.settings != session.settings
|| current.harmony != session.harmony
|| current.candidates != session.candidates
if current.settings.meter != session.settings.meter
|| current.performance != session.performance
|| current.audition != session.audition
{
@@ -130,8 +129,9 @@ impl Shared {
};
let settings = state.performance.clone();
let compensate = state.audition == Audition::Midi;
let meter = state.settings.meter;
drop(state);
Performance::compile(phrase, &settings, tempo, compensate)?.clip()
Performance::compile(phrase, &settings, tempo, compensate, meter)?.clip()
}
pub fn preview(&self, phrase: &rivet_core::Phrase) -> Result<()> {
let epoch = self.epoch.load(Ordering::Acquire);
@@ -279,9 +279,8 @@ impl AudioEngine {
if request.quantized && self.player.is_playing() {
let now = self.player.absolute_tick();
let lead = request.clip.lead_in as f64;
let boundary = ((now + lead) / SECTION as f64)
.floor()
.mul_add(SECTION as f64, SECTION as f64);
let bar = shared.bar.load(Ordering::Acquire).max(1) as f64;
let boundary = ((now + lead) / bar).floor().mul_add(bar, bar);
self.pending = Some((boundary - lead, request.clip));
} else {
self.player.play(request.clip);
+7 -7
View File
@@ -1,5 +1,5 @@
use rivet_app::{AudioEngine, Shared};
use rivet_core::{Note, Phrase, PPQ, SECTION};
use rivet_core::{Note, Phrase, BAR, PPQ};
fn clip(pitch: u8, length: u32) -> Phrase {
Phrase {
@@ -90,20 +90,20 @@ fn adjacent_same_pitch_notes_have_separate_sample_accurate_attacks() {
assert_eq!(events[2].2, None);
}
#[test]
fn queued_audition_switches_at_section_boundary_and_stop_cancels_it() {
fn queued_audition_switches_at_the_next_bar_line_and_stop_cancels_it() {
let shared = Shared::default();
let mut a = AudioEngine::default();
a.player.set_sample_rate(8000.).unwrap();
shared.preview(&clip(60, 2 * SECTION)).unwrap();
shared.preview(&clip(60, 4 * BAR)).unwrap();
a.begin_block(&shared);
for _ in 0..100 {
a.frame(120., true);
}
a.end_block(&shared);
shared.audition(&clip(67, 2 * SECTION)).unwrap();
shared.audition(&clip(67, 4 * BAR)).unwrap();
a.begin_block(&shared);
let mut switched = None;
for index in 100i32..32003 {
for index in 100i32..16003 {
let f = a.frame(120., true);
if f.on == Some((67, 90)) {
assert_eq!(f.off, Some(60));
@@ -111,9 +111,9 @@ fn queued_audition_switches_at_section_boundary_and_stop_cancels_it() {
break;
}
}
assert!((switched.unwrap() - 32000).abs() <= 1);
assert!((switched.unwrap() - 16000).abs() <= 1);
a.end_block(&shared);
shared.audition(&clip(72, SECTION)).unwrap();
shared.audition(&clip(72, 2 * BAR)).unwrap();
a.begin_block(&shared);
shared.stop();
a.begin_block(&shared);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-core"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
-407
View File
@@ -1,407 +0,0 @@
use crate::*;
use serde::{Deserialize, Serialize};
pub const MAX_GESTURES: usize = 16;
pub const MAX_BRANCHES: usize = 16;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Gesture {
pub id: u64,
pub name: String,
pub phrase: Phrase,
pub performance: PerformanceSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdeaSnapshot {
pub source: Option<Phrase>,
pub settings: Settings,
pub harmony: Harmony,
pub phrase: Option<Phrase>,
pub pins: Vec<PinnedSection>,
pub performance: PerformanceSettings,
}
impl IdeaSnapshot {
fn validate(&self) -> Result<()> {
self.settings.validate()?;
self.harmony.validate()?;
self.performance.validate()?;
if let Some(p) = &self.source {
p.validate_source()?;
}
if let Some(p) = &self.phrase {
p.validate(MAX_PLAYBACK_NOTES)?;
}
let mut seen = 0u8;
for pin in &self.pins {
if pin.index >= self.settings.bars as usize / 2
|| pin.phrase.length != SECTION
|| seen & (1 << pin.index) != 0
{
return Err(err("Invalid branch pins."));
}
seen |= 1 << pin.index;
if !pin.phrase.notes.is_empty() {
pin.phrase.validate(MAX_PLAYBACK_NOTES)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Branch {
pub id: u64,
pub parent: Option<u64>,
pub name: String,
pub idea: IdeaSnapshot,
}
#[derive(Serialize, Deserialize)]
struct LibraryFile {
version: u32,
gestures: Vec<Gesture>,
}
pub fn read_gesture_library(bytes: &[u8]) -> Result<Vec<Gesture>> {
if bytes.len() > 2 * 1024 * 1024 {
return Err(err("Gesture library exceeds 2 MiB."));
}
let file: LibraryFile = serde_json::from_slice(bytes).map_err(|e| Error(e.to_string()))?;
if file.version != 1 || file.gestures.len() > MAX_GESTURES {
return Err(err("Unsupported or oversized gesture library."));
}
for g in &file.gestures {
if !name_valid(&g.name) {
return Err(err("Invalid gesture name."));
}
g.phrase.validate_source()?;
g.performance.validate()?;
}
Ok(file.gestures)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreativeMove {
Thin,
Delay,
Lengthen,
Transpose(i8),
}
impl CreativeMove {
pub fn name(self) -> String {
match self {
Self::Thin => "Remove connecting events".into(),
Self::Delay => "Delay the gesture".into(),
Self::Lengthen => "Lengthen arrivals".into(),
Self::Transpose(n) => format!("Transpose {n:+} semitones"),
}
}
}
fn name_valid(name: &str) -> bool {
!name.trim().is_empty() && name.chars().count() <= 64
}
impl Session {
pub fn export_gesture_library(&self) -> Result<Vec<u8>> {
self.validate_creative()?;
serde_json::to_vec(&LibraryFile {
version: 1,
gestures: self.gestures.clone(),
})
.map_err(|e| Error(e.to_string()))
}
pub fn import_gesture_library(&mut self, gestures: Vec<Gesture>) -> Result<usize> {
if self.gestures.len() + gestures.len() > MAX_GESTURES {
return Err(err("The combined gesture library would exceed 16 entries."));
}
let mut next = self.clone();
let count = gestures.len();
for mut g in gestures {
g.id = next.allocate_id()?;
next.gestures.push(g);
}
next.validate()?;
*self = next;
Ok(count)
}
fn allocate_id(&mut self) -> Result<u64> {
let highest = self
.gestures
.iter()
.map(|g| g.id)
.chain(self.branches.iter().map(|b| b.id))
.max()
.unwrap_or(0);
let id = self.next_id.max(highest.saturating_add(1));
self.next_id = id
.checked_add(1)
.ok_or_else(|| err("Idea identifier limit reached."))?;
Ok(id)
}
pub(crate) fn validate_creative(&self) -> Result<()> {
if self.gestures.len() > MAX_GESTURES || self.branches.len() > MAX_BRANCHES {
return Err(err(
"At most 16 gestures and 16 branches can be stored per session.",
));
}
let mut ids = std::collections::BTreeSet::new();
for g in &self.gestures {
if g.id == 0 || !ids.insert(g.id) || !name_valid(&g.name) {
return Err(err("Invalid gesture name or identifier."));
}
g.phrase.validate_source()?;
g.performance.validate()?;
}
for b in &self.branches {
if b.id == 0 || !ids.insert(b.id) || !name_valid(&b.name) {
return Err(err("Invalid branch name or identifier."));
}
if b.parent.is_some_and(|parent| {
parent >= b.id || !self.branches.iter().any(|p| p.id == parent)
}) {
return Err(err("Invalid branch ancestry."));
}
b.idea.validate()?;
}
if self
.active_branch
.is_some_and(|id| !self.branches.iter().any(|b| b.id == id))
{
return Err(err("Active branch does not exist."));
}
Ok(())
}
pub fn save_gesture(&mut self, name: &str, section: Option<usize>) -> Result<u64> {
self.performance.validate()?;
if !name_valid(name) {
return Err(err("Name the gesture using 1–64 characters."));
}
if self.gestures.len() >= MAX_GESTURES {
return Err(err(
"Gesture library is full. Remove an unused gesture or save another session.",
));
}
let phrase = if let Some(index) = section {
let p = self.composed_phrase()?;
let start = index as u32 * SECTION;
let end = start + SECTION;
if index >= self.settings.bars as usize / 2
|| p.notes.iter().any(|n| {
(n.start < start && n.end() > start) || (n.start < end && n.end() > end)
})
{
return Err(err("Choose a two-bar section without crossing notes."));
}
Phrase {
length: SECTION,
notes: p
.notes
.iter()
.filter(|n| n.start >= start && n.start < end)
.map(|n| Note {
start: n.start - start,
..*n
})
.collect(),
}
} else {
self.source
.clone()
.ok_or_else(|| err("Import a motif first."))?
};
phrase.validate_source()?;
let id = self.allocate_id()?;
self.gestures.push(Gesture {
id,
name: name.trim().into(),
phrase,
performance: self.performance.clone(),
});
Ok(id)
}
pub fn recall_gesture(&mut self, id: u64) -> Result<()> {
let gesture = self
.gestures
.iter()
.find(|g| g.id == id)
.cloned()
.ok_or_else(|| err("Gesture does not exist."))?;
self.source = Some(gesture.phrase);
self.performance = gesture.performance;
self.pins.clear();
self.candidates.clear();
self.selected = 0;
self.active_branch = None;
Ok(())
}
pub fn save_branch(&mut self, name: &str) -> Result<u64> {
if !name_valid(name) {
return Err(err("Name the branch using 1–64 characters."));
}
if self.branches.len() >= MAX_BRANCHES {
return Err(err(
"Branch history is full. Remove an unused leaf branch or save another session.",
));
}
let idea = IdeaSnapshot {
source: self.source.clone(),
settings: self.settings.clone(),
harmony: self.harmony.clone(),
phrase: if self.candidates.is_empty() {
None
} else {
Some(self.composed_phrase()?)
},
pins: self.pins.clone(),
performance: self.performance.clone(),
};
idea.validate()?;
let id = self.allocate_id()?;
self.branches.push(Branch {
id,
parent: self.active_branch,
name: name.trim().into(),
idea,
});
self.active_branch = Some(id);
Ok(id)
}
pub fn restore_branch(&mut self, id: u64) -> Result<()> {
let b = self
.branches
.iter()
.find(|b| b.id == id)
.cloned()
.ok_or_else(|| err("Branch does not exist."))?;
b.idea.validate()?;
self.source = b.idea.source;
self.settings = b.idea.settings;
self.harmony = b.idea.harmony;
self.pins = b.idea.pins;
self.performance = b.idea.performance;
self.candidates = b
.idea
.phrase
.into_iter()
.map(|phrase| Candidate {
strategy: Strategy::SavedIdea,
explanation: format!("Restored branch: {}", b.name),
phrase,
})
.collect();
self.selected = 0;
self.active_branch = Some(id);
Ok(())
}
pub fn remove_branch(&mut self, id: u64) -> Result<()> {
if self.branches.iter().any(|b| b.parent == Some(id)) {
return Err(err("Remove child branches first."));
}
let index = self
.branches
.iter()
.position(|b| b.id == id)
.ok_or_else(|| err("Branch does not exist."))?;
if self.active_branch == Some(id) {
self.active_branch = self.branches[index].parent;
}
self.branches.remove(index);
Ok(())
}
/// Moves operate on heard selections, respect pinned material/locks, and preserve their parent.
pub fn apply_move(&mut self, operation: CreativeMove, section: Option<usize>) -> Result<()> {
if section.is_some_and(|i| i >= self.settings.bars as usize / 2) {
return Err(err("Section is outside the phrase."));
}
if self.settings.preserve_rhythm && !matches!(operation, CreativeMove::Transpose(_)) {
return Err(err(
"Release Keep groove before changing rhythm or articulation.",
));
}
let old = self.composed_phrase()?;
let mut phrase = old.clone();
let in_selection = |n: &Note| {
section.is_none_or(|i| n.start / SECTION == i as u32)
&& !self
.pins
.iter()
.any(|p| n.start / SECTION == p.index as u32)
};
match operation {
CreativeMove::Thin => {
let mut index = 0;
phrase.notes.retain(|n| {
if !in_selection(n) {
return true;
}
let keep = index % 2 == 0 || n.is_anchor();
index += 1;
keep
});
}
CreativeMove::Transpose(steps) => {
for n in phrase
.notes
.iter_mut()
.filter(|n| in_selection(n) && !n.keeps_pitch())
{
let pitch = n.pitch as i16 + steps as i16;
if pitch < self.settings.low as i16 || pitch > self.settings.high as i16 {
return Err(err("The transposition exceeds the chosen register. Widen it or choose a smaller move."));
}
n.pitch = pitch as u8;
}
}
CreativeMove::Delay => {
for n in phrase
.notes
.iter_mut()
.filter(|n| in_selection(n) && !n.keeps_rhythm())
{
let boundary = ((n.start / SECTION) + 1) * SECTION;
if n.end() + PPQ / 4 > boundary.min(phrase.length) {
return Err(err(
"The delayed gesture would cross the selected section boundary.",
));
}
n.start += PPQ / 4;
}
phrase.notes.sort_by_key(|n| n.start);
phrase.validate(MAX_PLAYBACK_NOTES).map_err(|_| {
err("The delayed gesture would overlap protected or unselected material.")
})?;
}
CreativeMove::Lengthen => {
for i in 0..phrase.notes.len() {
let boundary = ((phrase.notes[i].start / SECTION) + 1) * SECTION;
let next = phrase
.notes
.get(i + 1)
.map_or(phrase.length, |n| n.start)
.min(boundary);
let n = &mut phrase.notes[i];
if !in_selection(n) || n.keeps_rhythm() {
continue;
}
n.duration = (n.duration + n.duration / 2).min(next - n.start);
}
}
}
phrase.validate(MAX_PLAYBACK_NOTES)?;
if phrase == old {
return Err(err(
"The chosen move is blocked by locks, pins, or available space.",
));
}
let mut next = self.clone();
if next.active_branch.is_none() {
next.save_branch("Starting idea")?;
}
let label = operation.name();
next.candidates = vec![Candidate {
strategy: Strategy::SavedIdea,
explanation: label.clone(),
phrase,
}];
next.selected = 0;
next.save_branch(&label)?;
next.validate()?;
*self = next;
Ok(())
}
}
+15 -10
View File
@@ -1,4 +1,4 @@
use crate::{err, Note, Result, Scale, BAR, MAX_PLAYBACK_NOTES};
use crate::{err, Meter, Note, Result, Scale, MAX_LENGTH, MAX_PLAYBACK_NOTES};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -64,8 +64,8 @@ impl Harmony {
Err(err("Empty harmony must have zero length."))
};
}
if self.length == 0 || self.length > 8 * BAR || self.spans.len() > 256 {
return Err(err("Harmony must fit eight bars and at most 256 changes."));
if self.length == 0 || self.length > MAX_LENGTH || self.spans.len() > 256 {
return Err(err("Harmony must fit 32 bars and at most 256 changes."));
}
let mut end = 0;
for span in &self.spans {
@@ -92,8 +92,12 @@ impl Harmony {
.find(|s| tick >= s.start && tick < s.start + s.duration)
}
pub fn from_midi(notes: &[Note], length: u32) -> Result<Self> {
if notes.is_empty() || notes.len() > MAX_PLAYBACK_NOTES || length == 0 || length > 8 * BAR {
return Err(err("Import a harmony track of up to eight bars."));
if notes.is_empty()
|| notes.len() > MAX_PLAYBACK_NOTES
|| length == 0
|| length > MAX_LENGTH
{
return Err(err("Import a harmony track of up to 32 bars."));
}
let mut edges = std::collections::BTreeSet::new();
for n in notes {
@@ -133,8 +137,9 @@ impl Harmony {
context.validate()?;
Ok(context)
}
pub fn progression(root: u8, scale: Scale) -> Self {
let minor = scale != Scale::Major;
pub fn progression(root: u8, scale: Scale, meter: Meter) -> Self {
let minor = scale.minor();
let bar = meter.bar();
let chords = if minor {
[
(0, ChordQuality::Minor),
@@ -151,15 +156,15 @@ impl Harmony {
]
};
Self {
length: 4 * BAR,
length: 4 * bar,
spans: chords
.into_iter()
.enumerate()
.map(|(i, (step, quality))| {
let root = (root + step) % 12;
HarmonySpan {
start: i as u32 * BAR,
duration: BAR,
start: i as u32 * bar,
duration: bar,
tones: quality.mask(root),
bass: 48 + root,
}
+124
View File
@@ -0,0 +1,124 @@
//! Sessions from versions 1–3: one source motif developed into a four- or
//! eight-bar phrase of fixed two-bar sections. Read leniently, then upgraded.
use crate::{
Audition, Ending, Harmony, Meter, PerformanceSettings, Phrase, Role, Scale, Template,
STATE_VERSION,
};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
bars: u8,
root: u8,
scale: Scale,
variation: u8,
preserve_rhythm: bool,
preserve_contour: bool,
ending: Ending,
low: u8,
high: u8,
seed: u64,
}
impl Default for Settings {
fn default() -> Self {
let s = crate::Settings::default();
Self {
bars: 8,
root: s.root,
scale: s.scale,
variation: s.variation,
preserve_rhythm: s.preserve_rhythm,
preserve_contour: s.preserve_contour,
ending: s.ending,
low: s.low,
high: s.high,
seed: s.seed,
}
}
}
#[derive(Deserialize)]
pub(crate) struct Gesture {
name: String,
phrase: Phrase,
}
#[derive(Deserialize)]
#[serde(default)]
pub(crate) struct Session {
source: Option<Phrase>,
settings: Settings,
accepted: Option<Phrase>,
tempo: f64,
follow_host: bool,
harmony: Harmony,
audition: Audition,
/// Version-1 sessions had one-shot audition.
looping: bool,
performance: PerformanceSettings,
gestures: Vec<Gesture>,
}
impl Default for Session {
fn default() -> Self {
Self {
source: None,
settings: Settings::default(),
accepted: None,
tempo: 140.,
follow_host: false,
harmony: Harmony::default(),
audition: Audition::Internal,
looping: false,
performance: PerformanceSettings::default(),
gestures: Vec::new(),
}
}
}
impl Session {
/// The source becomes the first motif; a kept phrase and saved gestures follow it
/// in the bin. Four-bar phrases become question/answer, eight-bar phrases an arc.
pub(crate) fn upgrade(self) -> crate::Session {
let old = self.settings;
let mut s = crate::Session {
version: STATE_VERSION,
settings: crate::Settings {
root: old.root,
scale: old.scale,
meter: Meter::default(),
variation: old.variation,
preserve_rhythm: old.preserve_rhythm,
preserve_contour: old.preserve_contour,
ending: old.ending,
low: old.low,
high: old.high,
seed: old.seed,
},
tempo: self.tempo,
follow_host: self.follow_host,
harmony: self.harmony,
audition: self.audition,
looping: self.looping,
performance: self.performance,
..crate::Session::default()
};
let source = self.source.and_then(|p| s.add_motif("Motif", p).ok());
if let Some(kept) = self.accepted {
let _ = s.add_motif("Kept phrase", kept);
}
for gesture in self.gestures {
let _ = s.add_motif(&gesture.name, gesture.phrase);
}
if let Some(id) = source {
if old.bars == 4 {
let _ = s.apply_template(Template::QuestionAnswer, id);
s.blocks.truncate(2);
if let Some(last) = s.blocks.last_mut() {
last.role = Role::Return;
}
s.realize_all();
} else {
let _ = s.apply_template(Template::Arc, id);
}
}
s
}
}
+110 -409
View File
@@ -1,25 +1,29 @@
//! Musical time and deterministic motif development, independent of the host and UI.
mod creative;
//! Musical time, motifs, and storyboard composition, independent of the host and UI.
mod harmony;
mod legacy;
mod performance;
mod playback;
mod story;
mod variation;
pub use creative::{
read_gesture_library, Branch, CreativeMove, Gesture, IdeaSnapshot, MAX_BRANCHES, MAX_GESTURES,
};
pub use harmony::{ChordQuality, Harmony, HarmonySpan};
pub use performance::*;
pub use playback::{PlaybackClip, PreviewPlayer, MAX_PLAYBACK_NOTES};
pub use story::{
Block, Join, Motif, Role, Session, Template, MAX_BARS, MAX_BLOCKS, MAX_MOTIFS, MAX_MOTIF_BARS,
};
pub use variation::realize;
use serde::{Deserialize, Serialize};
use std::fmt;
pub const PPQ: u32 = 960;
/// One 4/4 bar. Use [`Meter::bar`] for the session's actual bar length.
pub const BAR: u32 = PPQ * 4;
pub const MAX_SOURCE_NOTES: usize = 256;
pub const STATE_VERSION: u32 = 3;
pub const STATE_VERSION: u32 = 4;
pub const MAX_STATE_BYTES: usize = 16 * 1024 * 1024;
pub const SECTION: u32 = 2 * BAR;
/// Longest phrase in ticks: 32 bars of 12/8.
pub const MAX_LENGTH: u32 = 48 * BAR;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error(pub String);
@@ -67,16 +71,15 @@ pub struct Phrase {
pub length: u32,
}
impl Phrase {
pub fn validate(&self, max_notes: usize) -> Result<()> {
if self.notes.is_empty() || self.notes.len() > max_notes {
/// Checks timing and MIDI ranges. An empty note list is allowed only when `allow_empty`.
pub fn check(&self, max_notes: usize, allow_empty: bool) -> Result<()> {
if (!allow_empty && self.notes.is_empty()) || self.notes.len() > max_notes {
return Err(err(
"Choose a phrase containing between 1 and the supported maximum number of notes.",
));
}
if self.length == 0 || self.length > 8 * BAR {
return Err(err(
"Phrase length must be positive and at most eight bars.",
));
if self.length == 0 || self.length > MAX_LENGTH {
return Err(err("Phrase length must be positive and at most 32 bars."));
}
let mut end = 0;
for n in &self.notes {
@@ -97,12 +100,60 @@ impl Phrase {
}
Ok(())
}
pub fn validate_source(&self) -> Result<()> {
self.validate(MAX_SOURCE_NOTES)?;
if self.length > 2 * BAR {
return Err(err("Select a motif of at most two bars in 4/4."));
pub fn validate(&self, max_notes: usize) -> Result<()> {
self.check(max_notes, false)
}
}
/// Time signature. Compound meters (6/8, 12/8) pulse in dotted quarters.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct Meter {
pub beats: u8,
pub unit: u8,
}
impl Default for Meter {
fn default() -> Self {
Self { beats: 4, unit: 4 }
}
}
impl Meter {
pub const ALL: [Self; 7] = [
Self { beats: 2, unit: 4 },
Self { beats: 3, unit: 4 },
Self { beats: 4, unit: 4 },
Self { beats: 5, unit: 4 },
Self { beats: 6, unit: 8 },
Self { beats: 7, unit: 8 },
Self { beats: 12, unit: 8 },
];
pub fn validate(self) -> Result<()> {
if Self::ALL.contains(&self) {
Ok(())
} else {
Err(err("Supported meters: 2/4, 3/4, 4/4, 5/4, 6/8, 7/8, 12/8."))
}
Ok(())
}
pub fn name(self) -> String {
format!("{}/{}", self.beats, self.unit)
}
/// Bar length in ticks.
pub fn bar(self) -> u32 {
PPQ * 4 * self.beats as u32 / self.unit as u32
}
/// The felt pulse: a quarter in simple meters, a dotted quarter in compound ones.
pub fn beat(self) -> u32 {
if self.unit == 8 && self.beats.is_multiple_of(3) {
PPQ * 3 / 2
} else {
PPQ * 4 / self.unit as u32
}
}
pub fn strong(self, tick: u32) -> bool {
tick.is_multiple_of(self.beat())
}
/// Whole bars needed to hold `ticks`, at least one.
pub fn bars_for(self, ticks: u32) -> u32 {
ticks.div_ceil(self.bar()).max(1)
}
}
@@ -112,15 +163,29 @@ pub enum Scale {
Major,
Dorian,
Pentatonic,
Phrygian,
HarmonicMinor,
Mixolydian,
}
impl Scale {
pub const ALL: [Self; 4] = [Self::Minor, Self::Major, Self::Dorian, Self::Pentatonic];
pub const ALL: [Self; 7] = [
Self::Minor,
Self::Major,
Self::Dorian,
Self::Phrygian,
Self::HarmonicMinor,
Self::Mixolydian,
Self::Pentatonic,
];
pub fn name(self) -> &'static str {
match self {
Self::Minor => "Natural minor",
Self::Major => "Major",
Self::Dorian => "Dorian",
Self::Pentatonic => "Minor pentatonic",
Self::Phrygian => "Phrygian",
Self::HarmonicMinor => "Harmonic minor",
Self::Mixolydian => "Mixolydian",
}
}
pub fn intervals(self) -> &'static [u8] {
@@ -129,20 +194,27 @@ impl Scale {
Self::Major => &[0, 2, 4, 5, 7, 9, 11],
Self::Dorian => &[0, 2, 3, 5, 7, 9, 10],
Self::Pentatonic => &[0, 3, 5, 7, 10],
Self::Phrygian => &[0, 1, 3, 5, 7, 8, 10],
Self::HarmonicMinor => &[0, 2, 3, 5, 7, 8, 11],
Self::Mixolydian => &[0, 2, 4, 5, 7, 9, 10],
}
}
pub fn minor(self) -> bool {
!matches!(self, Self::Major | Self::Mixolydian)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
pub bars: u8,
pub root: u8,
pub scale: Scale,
pub meter: Meter,
/// How far proposals may move away from the motif, 0–100.
pub variation: u8,
pub preserve_rhythm: bool,
pub preserve_contour: bool,
pub task: Task,
/// How the final block lands.
pub ending: Ending,
pub low: u8,
pub high: u8,
@@ -151,13 +223,12 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
bars: 8,
root: 0,
scale: Scale::Minor,
meter: Meter::default(),
variation: 55,
preserve_rhythm: false,
preserve_contour: true,
task: Task::Develop,
ending: Ending::Resolve,
low: 48,
high: 84,
@@ -165,26 +236,6 @@ impl Default for Settings {
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum Task {
#[default]
Develop,
Answer,
MakeSpace,
Return,
}
impl Task {
pub const ALL: [Self; 4] = [Self::Develop, Self::Answer, Self::MakeSpace, Self::Return];
pub fn name(self) -> &'static str {
match self {
Self::Develop => "Develop the motif",
Self::Answer => "Write an answer",
Self::MakeSpace => "Make room",
Self::Return => "Find a return",
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum Ending {
#[default]
@@ -199,13 +250,11 @@ pub enum Audition {
}
impl Settings {
pub fn validate(&self) -> Result<()> {
if ![4, 8].contains(&self.bars)
|| self.root > 11
|| self.variation > 100
|| self.low > self.high
|| self.high > 127
{
return Err(err("Use four or eight bars, a valid key, variation 0–100, and an ordered MIDI register."));
self.meter.validate()?;
if self.root > 11 || self.variation > 100 || self.low > self.high || self.high > 127 {
return Err(err(
"Use a valid key, variation 0–100, and an ordered MIDI register.",
));
}
if !(self.low..=self.high).any(|p| self.contains(p)) {
return Err(err(
@@ -219,7 +268,8 @@ impl Settings {
.intervals()
.contains(&((p + 12 - self.root) % 12))
}
fn degree(&self, p: u8, steps: i16) -> u8 {
/// Scale step `steps` away from the scale note nearest `p`, inside the register.
pub fn degree(&self, p: u8, steps: i16) -> u8 {
let choices: Vec<_> = (self.low..=self.high)
.filter(|&n| self.contains(n))
.collect();
@@ -244,6 +294,8 @@ pub enum Strategy {
CallResponse,
HarmonicReturn,
SavedIdea,
Quote,
Silence,
}
impl Strategy {
pub const ALL: [Self; 8] = [
@@ -259,17 +311,20 @@ impl Strategy {
pub fn name(self) -> &'static str {
match self {
Self::ChangedEnding => "Changed ending",
Self::LaterPeak => "Later peak",
Self::DelayedResolution => "Delayed resolution",
Self::LaterPeak => "Higher peak",
Self::DelayedResolution => "Held back",
Self::NewRhythm => "Displaced groove",
Self::FragmentAnswer => "Fragment and answer",
Self::FragmentAnswer => "Fragment",
Self::NegativeSpace => "Room to breathe",
Self::CallResponse => "Short question, long answer",
Self::CallResponse => "Short call, long answer",
Self::HarmonicReturn => "Familiar return",
Self::SavedIdea => "Working gesture",
Self::Quote => "Quote",
Self::Silence => "Silence",
}
}
}
/// One realized option for a block.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Candidate {
pub strategy: Strategy,
@@ -277,238 +332,6 @@ pub struct Candidate {
pub phrase: Phrase,
}
/// Generate context-free proposals. Use `generate_with_harmony` when harmony is available.
pub fn generate(source: &Phrase, settings: &Settings) -> Result<Vec<Candidate>> {
generate_with_harmony(source, settings, &Harmony::default())
}
pub fn generate_with_harmony(
source: &Phrase,
settings: &Settings,
harmony: &Harmony,
) -> Result<Vec<Candidate>> {
variation::generate(source, settings, harmony)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PinnedSection {
pub index: usize,
pub phrase: Phrase,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Session {
pub version: u32,
pub source: Option<Phrase>,
pub settings: Settings,
pub candidates: Vec<Candidate>,
pub selected: usize,
pub accepted: Option<Phrase>,
pub previous_accepted: Option<Phrase>,
pub tempo: f64,
pub follow_host: bool,
#[serde(default)]
pub harmony: Harmony,
#[serde(default)]
pub pins: Vec<PinnedSection>,
#[serde(default)]
pub audition: Audition,
#[serde(default)]
pub looping: bool,
#[serde(default)]
pub performance: PerformanceSettings,
#[serde(default)]
pub gestures: Vec<Gesture>,
#[serde(default)]
pub branches: Vec<Branch>,
#[serde(default)]
pub active_branch: Option<u64>,
#[serde(default)]
pub next_id: u64,
}
impl Default for Session {
fn default() -> Self {
Self {
version: STATE_VERSION,
source: None,
settings: Settings::default(),
candidates: Vec::new(),
selected: 0,
accepted: None,
previous_accepted: None,
tempo: 140.0,
follow_host: false,
harmony: Harmony::default(),
pins: Vec::new(),
audition: Audition::Internal,
looping: true,
performance: PerformanceSettings::default(),
gestures: Vec::new(),
branches: Vec::new(),
active_branch: None,
next_id: 1,
}
}
}
impl Session {
pub fn migrate(&mut self) -> Result<()> {
match self.version {
1 | 2 => self.version = STATE_VERSION,
STATE_VERSION => {}
_ => return Err(err("Unsupported session version.")),
}
Ok(())
}
fn validate_pins(&self) -> Result<()> {
let mut seen = std::collections::BTreeSet::new();
for pin in &self.pins {
if pin.index >= self.settings.bars as usize / 2
|| !seen.insert(pin.index)
|| pin.phrase.length != SECTION
{
return Err(err("Invalid pinned section."));
}
if !pin.phrase.notes.is_empty() {
pin.phrase.validate(MAX_PLAYBACK_NOTES)?;
}
}
Ok(())
}
pub fn pin_selected(&mut self, index: usize) -> Result<()> {
if index >= self.settings.bars as usize / 2 {
return Err(err("Section is outside the phrase."));
}
let source = self
.selected_phrase()
.ok_or_else(|| err("Generate a candidate first."))?;
let start = index as u32 * SECTION;
let end = start + SECTION;
if source
.notes
.iter()
.any(|n| (n.start < start && n.end() > start) || (n.start < end && n.end() > end))
{
return Err(err(
"A note crosses this section boundary. Edit the seam before pinning.",
));
}
let phrase = Phrase {
length: SECTION,
notes: source
.notes
.iter()
.filter(|n| n.start >= start && n.start < end)
.map(|n| Note {
start: n.start - start,
..*n
})
.collect(),
};
self.pins.retain(|pin| pin.index != index);
self.pins.push(PinnedSection { index, phrase });
self.pins.sort_by_key(|p| p.index);
Ok(())
}
pub fn composed_phrase(&self) -> Result<Phrase> {
self.validate_pins()?;
let mut phrase = self
.selected_phrase()
.cloned()
.ok_or_else(|| err("Generate a candidate first."))?;
for pin in &self.pins {
let start = pin.index as u32 * SECTION;
let end = start + SECTION;
if phrase
.notes
.iter()
.any(|n| (n.start < start && n.end() > start) || (n.start < end && n.end() > end))
{
return Err(err("A candidate note crosses a pinned section boundary."));
}
phrase.notes.retain(|n| n.start < start || n.start >= end);
phrase.notes.extend(pin.phrase.notes.iter().map(|n| Note {
start: n.start + start,
..*n
}));
}
phrase.notes.sort_by_key(|n| n.start);
phrase.validate(MAX_PLAYBACK_NOTES)?;
Ok(phrase)
}
pub fn accept_composed(&mut self) -> Result<()> {
let phrase = self.composed_phrase()?;
self.previous_accepted = self.accepted.replace(phrase);
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.version != STATE_VERSION {
return Err(err("Unsupported session version."));
}
self.settings.validate()?;
self.performance.validate()?;
self.validate_creative()?;
self.harmony.validate()?;
self.validate_pins()?;
if !self.tempo.is_finite() || !(30.0..=300.0).contains(&self.tempo) {
return Err(err("Tempo must be between 30 and 300 BPM."));
}
if let Some(p) = &self.source {
p.validate_source()?;
}
if self.candidates.len() > 3
|| (!self.candidates.is_empty() && self.selected >= self.candidates.len())
{
return Err(err("Invalid candidate selection."));
}
for p in self
.candidates
.iter()
.map(|c| &c.phrase)
.chain(self.accepted.iter())
.chain(self.previous_accepted.iter())
{
p.validate(MAX_PLAYBACK_NOTES)?;
}
Ok(())
}
pub fn from_json(bytes: &[u8]) -> Result<Self> {
if bytes.len() > MAX_STATE_BYTES {
return Err(err("Session exceeds the 16 MiB limit."));
}
let mut session: Self = serde_json::from_slice(bytes).map_err(|e| Error(e.to_string()))?;
session.migrate()?;
session.validate()?;
Ok(session)
}
pub fn to_json(&self) -> Result<Vec<u8>> {
self.validate()?;
let bytes = serde_json::to_vec(self).map_err(|e| Error(e.to_string()))?;
if bytes.len() > MAX_STATE_BYTES {
return Err(err("Session exceeds the 16 MiB limit."));
}
Ok(bytes)
}
pub fn regenerate(&mut self) -> Result<()> {
let source = self
.source
.as_ref()
.ok_or_else(|| err("Import a MIDI motif first."))?;
self.candidates = generate_with_harmony(source, &self.settings, &self.harmony)?;
self.selected = 0;
Ok(())
}
pub fn selected_phrase(&self) -> Option<&Phrase> {
self.candidates.get(self.selected).map(|c| &c.phrase)
}
pub fn accept(&mut self) {
if let Some(p) = self.selected_phrase().cloned() {
self.previous_accepted = self.accepted.replace(p);
}
}
pub fn undo_accept(&mut self) {
std::mem::swap(&mut self.accepted, &mut self.previous_accepted);
}
}
pub fn demo_motif() -> Phrase {
Phrase {
length: 2 * BAR,
@@ -533,125 +356,3 @@ pub fn demo_motif() -> Phrase {
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reproducible_distinct_and_bounded() {
let source = demo_motif();
let s = Settings::default();
let a = generate(&source, &s).unwrap();
assert_eq!(a, generate(&source, &s).unwrap());
assert_ne!(a[0].phrase, a[1].phrase);
assert_ne!(a[1].phrase, a[2].phrase);
for c in a {
c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap();
assert_eq!(c.phrase.length, 8 * BAR);
assert_eq!(
&c.phrase.notes[..source.notes.len()],
source.notes.as_slice()
);
for n in c.phrase.notes.iter().filter(|n| n.start >= source.length) {
assert!((s.low..=s.high).contains(&n.pitch));
assert!(s.contains(n.pitch));
}
}
}
#[test]
fn locks_preserve_all_attributes_even_outside_scale_and_register() {
let mut source = demo_motif();
source.notes[2].locked = true;
source.notes[2].pitch = 99;
for c in generate(
&source,
&Settings {
preserve_rhythm: false,
..Settings::default()
},
)
.unwrap()
{
for rep in 0..4 {
let mut expected = source.notes[2];
expected.start += rep * source.length;
assert!(c.phrase.notes.contains(&expected));
}
}
}
#[test]
fn all_keys_register_edges_and_seeds() {
for root in 0..12 {
for scale in Scale::ALL {
for seed in 0..12 {
for bars in [4, 8] {
let s = Settings {
root,
scale,
seed,
bars,
low: 60,
high: 72,
preserve_rhythm: false,
..Settings::default()
};
for c in generate(&demo_motif(), &s).unwrap() {
c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap();
}
}
}
}
}
}
#[test]
fn session_round_trip_and_accept_history() {
let mut s = Session {
source: Some(demo_motif()),
..Session::default()
};
s.regenerate().unwrap();
s.accept();
let first = s.accepted.clone();
s.selected = 1;
s.accept();
s.undo_accept();
assert_eq!(s.accepted, first);
s.settings.seed += 1;
s.regenerate().unwrap();
assert_eq!(s.accepted, first);
assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s);
s.version = 99;
assert!(s.to_json().is_err());
}
#[test]
fn invalid_inputs_and_non_divisible_lengths() {
let mut source = demo_motif();
source.notes[1].start = 1;
assert!(generate(&source, &Settings::default()).is_err());
let source = Phrase {
notes: vec![Note {
start: 0,
duration: 300,
pitch: 60,
velocity: 80,
locked: true,
..Note::default()
}],
length: 1000,
};
for c in generate(&source, &Settings::default()).unwrap() {
c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap();
}
assert!(generate(
&source,
&Settings {
low: 61,
high: 61,
root: 0,
scale: Scale::Major,
..Settings::default()
}
)
.is_err());
}
}
+9 -6
View File
@@ -1,5 +1,5 @@
//! Explicit sound roles and controller gestures, compiled away from the audio thread.
use crate::{err, Note, Phrase, Result, BAR, MAX_PLAYBACK_NOTES, PPQ, SECTION};
use crate::{err, Meter, Note, Phrase, Result, MAX_LENGTH, MAX_PLAYBACK_NOTES, PPQ};
use serde::{Deserialize, Serialize};
use std::f64::consts::TAU;
@@ -28,12 +28,14 @@ impl RoleMode {
Self::Interlock => "Strong beats and replies",
}
}
pub fn role(self, index: usize, n: &Note) -> usize {
/// 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 => usize::from(n.start % SECTION >= BAR),
Self::Interlock => usize::from(!n.start.is_multiple_of(PPQ)),
Self::CallResponse => ((n.start / meter.bar()) % 2) as usize,
Self::Interlock => usize::from(!meter.strong(n.start)),
}
}
}
@@ -224,6 +226,7 @@ impl Performance {
settings: &PerformanceSettings,
bpm: f64,
compensate: bool,
meter: Meter,
) -> Result<Self> {
phrase.validate(MAX_PLAYBACK_NOTES)?;
settings.validate()?;
@@ -252,7 +255,7 @@ impl Performance {
.notes
.iter()
.enumerate()
.filter(|(i, n)| settings.mode.role(*i, n) == role)
.filter(|(i, n)| settings.mode.role(*i, n, meter) == role)
.map(|(_, n)| n)
.collect();
if routed.is_empty() && !profile.controller.enabled {
@@ -378,7 +381,7 @@ impl Performance {
pub fn clip(&self) -> Result<PerformanceClip> {
if self.events.len() > MAX_PERFORMANCE_EVENTS
|| self.length == 0
|| self.length > 8 * BAR
|| self.length > MAX_LENGTH
|| self.lead_in >= self.length
{
return Err(err("Invalid performance clip."));
+661
View File
@@ -0,0 +1,661 @@
//! The storyboard: a bin of motifs, and a timeline of blocks that develop them.
//! Rivet joins neighbouring blocks at their seams.
use crate::{
err, legacy, realize, Audition, Candidate, Error, Harmony, Note, PerformanceSettings, Phrase,
Result, Settings, MAX_PLAYBACK_NOTES, MAX_SOURCE_NOTES, MAX_STATE_BYTES, STATE_VERSION,
};
use serde::{Deserialize, Deserializer, Serialize};
pub const MAX_MOTIFS: usize = 16;
pub const MAX_BLOCKS: usize = 32;
/// Longest storyboard, in bars.
pub const MAX_BARS: u32 = 32;
/// Longest motif and longest block, in bars.
pub const MAX_MOTIF_BARS: u32 = 8;
/// A short idea the producer brought in. Locks live on its notes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Motif {
pub id: u64,
pub name: String,
pub phrase: Phrase,
}
/// What a block does in the story.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum Role {
/// Present the idea so it can be recognized later.
#[default]
Statement,
/// Reply to what came before.
Answer,
/// Move away: new register, new rhythm.
Departure,
/// Bring the idea back.
Return,
/// Silence.
Rest,
}
impl Role {
pub const ALL: [Self; 5] = [
Self::Statement,
Self::Answer,
Self::Departure,
Self::Return,
Self::Rest,
];
pub fn name(self) -> &'static str {
match self {
Self::Statement => "Statement",
Self::Answer => "Answer",
Self::Departure => "Departure",
Self::Return => "Return",
Self::Rest => "Rest",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Statement => "Present the motif so it can be recognized later.",
Self::Answer => "Reply to what came before: shorter call, longer arrival.",
Self::Departure => "Move away: higher register, displaced rhythm, fragments.",
Self::Return => "Bring the motif back and land it.",
Self::Rest => "Silence. Space makes the next entry count.",
}
}
}
/// How a block hands over to the next one.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum Join {
/// No change at the seam.
#[default]
Cut,
/// Leave half a beat of air before the next block.
Breath,
/// Two short notes in the last beat climb or fall into the next block's first note.
Pickup,
/// The last note moves one scale step next to the next block's first note.
Approach,
}
impl Join {
pub const ALL: [Self; 4] = [Self::Cut, Self::Breath, Self::Pickup, Self::Approach];
pub fn name(self) -> &'static str {
match self {
Self::Cut => "Cut",
Self::Breath => "Breath",
Self::Pickup => "Pickup",
Self::Approach => "Approach",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Cut => "Blocks meet as written.",
Self::Breath => "The last note is shortened to leave air before the next block.",
Self::Pickup => "Two short notes lead into the next block's first note.",
Self::Approach => "The last note steps next to the next block's first note.",
}
}
/// A musical default for a transition between two roles.
pub fn between(from: Role, to: Role) -> Self {
match (from, to) {
(Role::Rest, _) | (_, Role::Rest) => Self::Cut,
(_, Role::Departure) => Self::Pickup,
(_, Role::Return) => Self::Approach,
_ => Self::Breath,
}
}
}
/// One section of the storyboard.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Block {
pub id: u64,
/// Motif this block develops. Ignored for rests.
pub motif: u64,
pub role: Role,
pub bars: u8,
/// Hand-over into the next block.
pub join: Join,
/// Increment for another set of options.
#[serde(default)]
pub take: u64,
/// Up to three realized options; the first fits the role best.
#[serde(default)]
pub options: Vec<Candidate>,
#[serde(default)]
pub choice: usize,
/// Kept blocks are not regenerated when other inputs change.
#[serde(default)]
pub kept: bool,
}
impl Block {
pub fn phrase(&self) -> Option<&Phrase> {
self.options.get(self.choice).map(|c| &c.phrase)
}
}
/// Starting shapes for a storyboard built from one motif.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Template {
Arc,
QuestionAnswer,
Breathe,
Build,
}
impl Template {
pub const ALL: [Self; 4] = [Self::Arc, Self::QuestionAnswer, Self::Breathe, Self::Build];
pub fn name(self) -> &'static str {
match self {
Self::Arc => "Arc: statement, answer, departure, return",
Self::QuestionAnswer => "Question and answer, twice",
Self::Breathe => "Breathe: ideas separated by rests",
Self::Build => "Build: two departures before the return",
}
}
fn roles(self) -> &'static [Role] {
use Role::*;
match self {
Self::Arc => &[Statement, Answer, Departure, Return],
Self::QuestionAnswer => &[Statement, Answer, Statement, Answer],
Self::Breathe => &[Statement, Rest, Answer, Rest, Return],
Self::Build => &[Statement, Answer, Departure, Departure, Answer, Return],
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Session {
pub version: u32,
pub settings: Settings,
pub motifs: Vec<Motif>,
pub blocks: Vec<Block>,
pub tempo: f64,
pub follow_host: bool,
pub harmony: Harmony,
pub audition: Audition,
pub looping: bool,
pub performance: PerformanceSettings,
pub next_id: u64,
}
impl Default for Session {
fn default() -> Self {
Self {
version: STATE_VERSION,
settings: Settings::default(),
motifs: Vec::new(),
blocks: Vec::new(),
tempo: 140.0,
follow_host: false,
harmony: Harmony::default(),
audition: Audition::Internal,
looping: true,
performance: PerformanceSettings::default(),
next_id: 1,
}
}
}
/// Current-format fields, read leniently so added fields default.
#[derive(Deserialize)]
#[serde(default)]
struct Current {
version: u32,
settings: Settings,
motifs: Vec<Motif>,
blocks: Vec<Block>,
tempo: f64,
follow_host: bool,
harmony: Harmony,
audition: Audition,
looping: bool,
performance: PerformanceSettings,
next_id: u64,
}
impl Default for Current {
fn default() -> Self {
let s = Session::default();
Self {
version: s.version,
settings: s.settings,
motifs: s.motifs,
blocks: s.blocks,
tempo: s.tempo,
follow_host: s.follow_host,
harmony: s.harmony,
audition: s.audition,
looping: s.looping,
performance: s.performance,
next_id: s.next_id,
}
}
}
/// Sessions from version 1–3 (single motif, fixed two-bar sections) become a
/// storyboard: the motif goes into the bin and the old phrase length becomes blocks.
impl<'de> Deserialize<'de> for Session {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
use serde::de::Error as _;
let value = serde_json::Value::deserialize(d)?;
let version = value
.get("version")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
if version < STATE_VERSION as u64 {
let old: legacy::Session = serde_json::from_value(value).map_err(D::Error::custom)?;
return Ok(old.upgrade());
}
let c: Current = serde_json::from_value(value).map_err(D::Error::custom)?;
Ok(Self {
version: c.version,
settings: c.settings,
motifs: c.motifs,
blocks: c.blocks,
tempo: c.tempo,
follow_host: c.follow_host,
harmony: c.harmony,
audition: c.audition,
looping: c.looping,
performance: c.performance,
next_id: c.next_id,
})
}
}
impl Session {
pub fn migrate(&mut self) -> Result<()> {
if self.version != STATE_VERSION {
return Err(err("Unsupported session version."));
}
Ok(())
}
fn id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn motif(&self, id: u64) -> Option<&Motif> {
self.motifs.iter().find(|m| m.id == id)
}
pub fn motif_mut(&mut self, id: u64) -> Option<&mut Motif> {
self.motifs.iter_mut().find(|m| m.id == id)
}
pub fn total_bars(&self) -> u32 {
self.blocks.iter().map(|b| b.bars as u32).sum()
}
/// Start of block `index` in ticks.
pub fn block_start(&self, index: usize) -> u32 {
self.blocks[..index.min(self.blocks.len())]
.iter()
.map(|b| b.bars as u32 * self.settings.meter.bar())
.sum()
}
fn last_sounding(&self) -> Option<usize> {
self.blocks.iter().rposition(|b| b.role != Role::Rest)
}
fn check_motif(&self, phrase: &Phrase) -> Result<()> {
phrase.validate(MAX_SOURCE_NOTES)?;
if phrase.length > MAX_MOTIF_BARS * self.settings.meter.bar() {
return Err(Error(format!(
"A motif can be at most {MAX_MOTIF_BARS} bars of {}.",
self.settings.meter.name()
)));
}
Ok(())
}
/// Adds a motif to the bin. Its length is rounded up to whole bars.
pub fn add_motif(&mut self, name: &str, mut phrase: Phrase) -> Result<u64> {
if self.motifs.len() >= MAX_MOTIFS {
return Err(Error(format!(
"The bin holds {MAX_MOTIFS} motifs. Remove one first."
)));
}
let bar = self.settings.meter.bar();
phrase.length = self.settings.meter.bars_for(phrase.length) * bar;
self.check_motif(&phrase)?;
let id = self.id();
let name = name.trim();
self.motifs.push(Motif {
id,
name: if name.is_empty() {
format!("Motif {}", self.motifs.len() + 1)
} else {
name.chars().take(60).collect()
},
phrase,
});
Ok(id)
}
/// Removes a motif and every block that develops it.
pub fn remove_motif(&mut self, id: u64) {
self.motifs.retain(|m| m.id != id);
self.blocks
.retain(|b| b.role == Role::Rest || b.motif != id);
self.realize_all();
}
/// Motif length in whole bars of the current meter.
pub fn motif_bars(&self, id: u64) -> u8 {
self.motif(id).map_or(2, |m| {
self.settings
.meter
.bars_for(m.phrase.length)
.min(MAX_MOTIF_BARS) as u8
})
}
/// Inserts a block at `index` and realizes the storyboard.
pub fn insert_block(&mut self, index: usize, motif: u64, role: Role, bars: u8) -> Result<u64> {
if self.blocks.len() >= MAX_BLOCKS {
return Err(Error(format!("A storyboard holds {MAX_BLOCKS} blocks.")));
}
if !(1..=MAX_MOTIF_BARS as u8).contains(&bars) {
return Err(err("A block is 1–8 bars long."));
}
if self.total_bars() + bars as u32 > MAX_BARS {
return Err(Error(format!(
"The storyboard is limited to {MAX_BARS} bars."
)));
}
if role != Role::Rest && self.motif(motif).is_none() {
return Err(err("Choose a motif from the bin first."));
}
let index = index.min(self.blocks.len());
let id = self.id();
let join = self
.blocks
.get(index)
.map_or(Join::Cut, |next| Join::between(role, next.role));
if let Some(previous) = index.checked_sub(1).and_then(|i| self.blocks.get_mut(i)) {
previous.join = Join::between(previous.role, role);
}
self.blocks.insert(
index,
Block {
id,
motif,
role,
bars,
join,
take: 0,
options: Vec::new(),
choice: 0,
kept: false,
},
);
self.realize_all();
Ok(id)
}
pub fn remove_block(&mut self, index: usize) {
if index < self.blocks.len() {
self.blocks.remove(index);
self.realize_all();
}
}
pub fn move_block(&mut self, from: usize, to: usize) {
if from < self.blocks.len() && to < self.blocks.len() && from != to {
let block = self.blocks.remove(from);
self.blocks.insert(to, block);
self.realize_all();
}
}
pub fn duplicate_block(&mut self, index: usize) -> Result<()> {
let Some(block) = self.blocks.get(index).cloned() else {
return Ok(());
};
if self.blocks.len() >= MAX_BLOCKS || self.total_bars() + block.bars as u32 > MAX_BARS {
return Err(Error(format!(
"The storyboard is limited to {MAX_BLOCKS} blocks and {MAX_BARS} bars."
)));
}
let id = self.id();
self.blocks.insert(index + 1, Block { id, ..block });
self.realize_all();
Ok(())
}
/// Replaces the timeline with a template built from one motif.
pub fn apply_template(&mut self, template: Template, motif: u64) -> Result<()> {
if self.motif(motif).is_none() {
return Err(err("Choose a motif from the bin first."));
}
let size = self.motif_bars(motif);
let roles = template.roles();
let mut blocks = Vec::new();
let mut total = 0;
for (i, &role) in roles.iter().enumerate() {
let bars = if role == Role::Rest { 1 } else { size };
if total + bars as u32 > MAX_BARS {
break;
}
total += bars as u32;
let join = roles
.get(i + 1)
.map_or(Join::Cut, |&next| Join::between(role, next));
let id = self.id();
blocks.push(Block {
id,
motif,
role,
bars,
join,
take: 0,
options: Vec::new(),
choice: 0,
kept: false,
});
}
self.blocks = blocks;
self.realize_all();
Ok(())
}
/// New options for one block; it is no longer kept.
pub fn another_take(&mut self, index: usize) {
if let Some(block) = self.blocks.get_mut(index) {
block.take = block.take.wrapping_add(1);
block.kept = false;
block.choice = 0;
self.realize(index);
}
}
fn realize(&mut self, index: usize) {
let offset = self.block_start(index);
let last = self.last_sounding() == Some(index);
let block = &self.blocks[index];
let motif = self
.motif(block.motif)
.map(|m| m.phrase.clone())
.unwrap_or_default();
let options = if block.role == Role::Rest || !motif.notes.is_empty() {
realize(
&motif,
block.role,
block.bars,
offset,
last,
block.take,
&self.settings,
&self.harmony,
)
.unwrap_or_default()
} else {
Vec::new()
};
let block = &mut self.blocks[index];
block.choice = block.choice.min(options.len().saturating_sub(1));
block.options = options;
}
/// Realizes every block that is not kept. Kept blocks are realized only when
/// their stored options no longer fit (for example after a meter change).
pub fn realize_all(&mut self) {
let bar = self.settings.meter.bar();
for index in 0..self.blocks.len() {
let block = &self.blocks[index];
let fits = block
.phrase()
.is_some_and(|p| p.length == block.bars as u32 * bar);
if !block.kept || !fits {
self.realize(index);
}
}
}
/// The whole storyboard as one phrase, with joins applied at every seam.
pub fn compose(&self) -> Result<Phrase> {
if self.blocks.is_empty() {
return Err(err("Add blocks to the storyboard first."));
}
let mut notes = Vec::new();
let mut offset = 0;
for (i, block) in self.blocks.iter().enumerate() {
let length = block.bars as u32 * self.settings.meter.bar();
let mut local = block.phrase().map(|p| p.notes.clone()).unwrap_or_default();
let next = self
.blocks
.get(i + 1)
.and_then(Block::phrase)
.and_then(|p| p.notes.first().copied());
if i + 1 < self.blocks.len() {
self.join(&mut local, length, block.join, next);
}
notes.extend(local.into_iter().map(|n| Note {
start: n.start + offset,
..n
}));
offset += length;
}
if notes.is_empty() {
return Err(err("The storyboard is silent. Add a block with notes."));
}
let phrase = Phrase {
notes,
length: offset,
};
phrase.validate(MAX_PLAYBACK_NOTES)?;
Ok(phrase)
}
/// Applies a hand-over to the end of one block. Never touches locked attributes
/// and never moves notes across the block boundary.
fn join(&self, notes: &mut Vec<Note>, length: u32, join: Join, next: Option<Note>) {
let beat = self.settings.meter.beat();
let Some(last) = notes.last().copied() else {
return;
};
match join {
Join::Cut => {}
Join::Breath => {
let limit = length - beat / 2;
let n = notes.last_mut().unwrap();
if !n.keeps_rhythm() && n.start < limit && n.end() > limit {
n.duration = limit - n.start;
}
}
Join::Approach => {
let Some(target) = next else { return };
let n = notes.last_mut().unwrap();
if n.keeps_pitch() {
return;
}
let below = self.settings.degree(target.pitch, -1);
let above = self.settings.degree(target.pitch, 1);
n.pitch = if n.pitch > target.pitch && above != target.pitch {
above
} else if below != target.pitch {
below
} else {
above
};
}
Join::Pickup => {
let Some(target) = next else { return };
let region = length - beat;
if notes.iter().any(|n| n.end() > region && n.is_anchor()) {
return;
}
notes.retain(|n| n.start < region);
if let Some(n) = notes.last_mut() {
if n.end() > region {
n.duration = region - n.start;
}
}
let reference = notes.last().unwrap_or(&last).pitch;
let direction: i16 = if reference > target.pitch { 1 } else { -1 };
let half = beat / 2;
for (k, steps) in [(0, 2 * direction), (1, direction)] {
notes.push(Note {
start: region + k * half,
duration: half,
pitch: self.settings.degree(target.pitch, steps),
velocity: (target.velocity as i16 - 18 + 8 * k as i16).clamp(1, 127) as u8,
..Note::default()
});
}
}
}
}
pub fn validate(&self) -> Result<()> {
if self.version != STATE_VERSION {
return Err(err("Unsupported session version."));
}
self.settings.validate()?;
self.performance.validate()?;
self.harmony.validate()?;
if !self.tempo.is_finite() || !(30.0..=300.0).contains(&self.tempo) {
return Err(err("Tempo must be between 30 and 300 BPM."));
}
if self.motifs.len() > MAX_MOTIFS || self.blocks.len() > MAX_BLOCKS {
return Err(err("Too many motifs or blocks."));
}
let mut ids = std::collections::BTreeSet::new();
for m in &self.motifs {
if m.id >= self.next_id || !ids.insert(m.id) || m.name.chars().count() > 60 {
return Err(err("Invalid motif."));
}
self.check_motif(&m.phrase)?;
}
let bar = self.settings.meter.bar();
for b in &self.blocks {
if b.id >= self.next_id
|| !ids.insert(b.id)
|| !(1..=MAX_MOTIF_BARS as u8).contains(&b.bars)
|| (b.role != Role::Rest && self.motif(b.motif).is_none())
|| b.options.len() > 3
|| (!b.options.is_empty() && b.choice >= b.options.len())
{
return Err(err("Invalid storyboard block."));
}
for option in &b.options {
option.phrase.check(MAX_PLAYBACK_NOTES, true)?;
if option.phrase.length != b.bars as u32 * bar {
return Err(err("A block option does not match its length."));
}
}
}
if self.total_bars() > MAX_BARS {
return Err(Error(format!(
"The storyboard is limited to {MAX_BARS} bars."
)));
}
Ok(())
}
pub fn from_json(bytes: &[u8]) -> Result<Self> {
if bytes.len() > MAX_STATE_BYTES {
return Err(err("Session exceeds the 16 MiB limit."));
}
let mut session: Self = serde_json::from_slice(bytes).map_err(|e| Error(e.to_string()))?;
session.migrate()?;
session.validate()?;
Ok(session)
}
pub fn to_json(&self) -> Result<Vec<u8>> {
self.validate()?;
let bytes = serde_json::to_vec(self).map_err(|e| Error(e.to_string()))?;
if bytes.len() > MAX_STATE_BYTES {
return Err(err("Session exceeds the 16 MiB limit."));
}
Ok(bytes)
}
/// The demo motif and an arc storyboard built from it.
pub fn demo() -> Self {
let mut s = Self::default();
let id = s
.add_motif("Demo motif", crate::demo_motif())
.expect("valid demo");
s.apply_template(Template::Arc, id).expect("valid demo");
s
}
}
+329 -278
View File
@@ -1,10 +1,9 @@
//! Phrase-level proposals. Randomness chooses compositional alternatives, not independent pitches.
//! Block-level proposals. Randomness chooses compositional alternatives, not independent pitches.
use crate::{
Candidate, Ending, Harmony, Note, Phrase, Result, Settings, Strategy, Task, BAR,
MAX_PLAYBACK_NOTES, PPQ, SECTION,
Candidate, Ending, Harmony, Note, Phrase, Result, Role, Settings, Strategy, MAX_PLAYBACK_NOTES,
MAX_SOURCE_NOTES,
};
const FAMILIES: [Strategy; 8] = Strategy::ALL;
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
@@ -24,7 +23,7 @@ fn same_music(a: &Phrase, b: &Phrase) -> bool {
.all(|(a, b)| (a.start, a.duration, a.pitch) == (b.start, b.duration, b.pitch))
}
/// Distance deliberately values timing/density differences above small pitch substitutions.
pub fn distance(a: &Phrase, b: &Phrase) -> u32 {
fn distance(a: &Phrase, b: &Phrase) -> u32 {
let a: std::collections::BTreeMap<_, _> = a.notes.iter().map(|n| (n.start, n)).collect();
let b: std::collections::BTreeMap<_, _> = b.notes.iter().map(|n| (n.start, n)).collect();
let mut total = 0;
@@ -39,28 +38,30 @@ pub fn distance(a: &Phrase, b: &Phrase) -> u32 {
total += b.keys().filter(|start| !a.contains_key(start)).count() as u32 * 100;
total * 10 / (a.len().max(b.len()).max(1) as u32)
}
fn fit(task: Task, family: Strategy) -> u32 {
let preferred = match task {
Task::Develop => [
/// How well a transformation serves a block's role. Zero means "only as a last resort".
fn fit(role: Role, family: Strategy) -> u32 {
let preferred = match role {
Role::Statement => [
Strategy::Quote,
Strategy::NewRhythm,
Strategy::FragmentAnswer,
Strategy::LaterPeak,
Strategy::ChangedEnding,
],
Task::Answer => [
Role::Answer => [
Strategy::CallResponse,
Strategy::ChangedEnding,
Strategy::DelayedResolution,
],
Task::MakeSpace => [
Strategy::NegativeSpace,
Strategy::FragmentAnswer,
Strategy::CallResponse,
],
Task::Return => [
Strategy::HarmonicReturn,
Role::Departure => [
Strategy::LaterPeak,
Strategy::ChangedEnding,
Strategy::NewRhythm,
Strategy::FragmentAnswer,
],
Role::Return => [
Strategy::HarmonicReturn,
Strategy::Quote,
Strategy::NegativeSpace,
],
Role::Rest => [Strategy::Silence; 3],
};
if family == preferred[0] {
300
@@ -70,70 +71,137 @@ fn fit(task: Task, family: Strategy) -> u32 {
0
}
}
pub fn generate(source: &Phrase, s: &Settings, harmony: &Harmony) -> Result<Vec<Candidate>> {
source.validate_source()?;
s.validate()?;
harmony.validate()?;
if source.notes.len() as u64 * (s.bars as u64 * BAR as u64).div_ceil(source.length as u64)
> MAX_PLAYBACK_NOTES as u64
{
return Err(crate::Error("This motif is too dense to develop within the 2048-note limit. Shorten or simplify it.".into()));
fn explain(family: Strategy) -> &'static str {
match family {
Strategy::Quote => "The motif exactly as written.",
Strategy::Silence => "Silence. Lets the previous idea land.",
Strategy::SavedIdea => "Saved working gesture.",
Strategy::NewRhythm => "Same contour; entries move off the beat and pickups repeat.",
Strategy::FragmentAnswer => "Only the first half of the motif, leaving space after it.",
Strategy::NegativeSpace => "Connecting notes fall away; the signature anchors remain.",
Strategy::CallResponse => "A short call in the first half, answered with longer notes.",
Strategy::HarmonicReturn => "Close to the motif, landing where it began.",
Strategy::LaterPeak => "The register opens upward for contrast.",
Strategy::ChangedEnding => "Same groove; the second half bends downward.",
Strategy::DelayedResolution => "Stays a step away from rest so the phrase keeps moving.",
}
let mut pool: Vec<Candidate> = Vec::new();
for (family_index, family) in FAMILIES.into_iter().enumerate() {
for variant in 0..3 {
}
/// The motif repeated to fill `length`. Notes crossing the end are shortened, or
/// dropped when their rhythm is locked.
fn tile(motif: &Phrase, length: u32) -> Vec<Note> {
let mut notes = Vec::new();
let mut offset = 0;
while offset < length {
for original in &motif.notes {
let start = offset + original.start;
if start >= length {
break;
}
let mut n = Note { start, ..*original };
if n.end() > length {
if n.keeps_rhythm() {
continue;
}
n.duration = length - start;
}
notes.push(n);
}
offset += motif.length;
}
notes
}
/// Where a block sits in the storyboard.
#[derive(Debug, Clone, Copy)]
struct Place {
/// Start of the block, in ticks from the start of the storyboard.
offset: u32,
/// The final sounding block: its last note follows the ending choice.
last: bool,
}
/// Up to three distinct options for one block. The first option fits the role best.
#[allow(clippy::too_many_arguments)]
pub fn realize(
motif: &Phrase,
role: Role,
bars: u8,
offset: u32,
last: bool,
take: u64,
s: &Settings,
harmony: &Harmony,
) -> Result<Vec<Candidate>> {
s.validate()?;
let length = bars as u32 * s.meter.bar();
if role == Role::Rest {
return Ok(vec![Candidate {
strategy: Strategy::Silence,
explanation: explain(Strategy::Silence).into(),
phrase: Phrase {
notes: Vec::new(),
length,
},
}]);
}
motif.validate(MAX_SOURCE_NOTES)?;
harmony.validate()?;
let base = tile(motif, length);
if base.len() > MAX_PLAYBACK_NOTES {
return Err(crate::Error(
"This motif is too dense for a block this long. Shorten the block or the motif.".into(),
));
}
let place = Place { offset, last };
let mut pool: Vec<Candidate> = vec![Candidate {
strategy: Strategy::Quote,
explanation: explain(Strategy::Quote).into(),
phrase: Phrase {
notes: base.clone(),
length,
},
}];
// Blocks at different positions of the same role should not sound identical.
let position = (offset / s.meter.bar()) as u64;
for (family_index, family) in Strategy::ALL.into_iter().enumerate() {
for variant in 0..3u64 {
let mut rng = Random(
s.seed
.wrapping_add(family_index as u64 * 7919 + variant * 104729),
.wrapping_add(family_index as u64 * 7919 + variant * 104729)
.wrapping_add(take.wrapping_mul(1_000_003))
.wrapping_add(position.wrapping_mul(15_485_863)),
);
let phrase = develop(source, s, harmony, family, &mut rng);
if phrase.notes.len() > MAX_PLAYBACK_NOTES {
let phrase = develop(
motif,
base.clone(),
length,
s,
harmony,
role,
family,
place,
&mut rng,
);
if phrase.notes.is_empty() || phrase.notes.len() > MAX_PLAYBACK_NOTES {
continue;
}
phrase.validate(MAX_PLAYBACK_NOTES)?;
let description = match family {
Strategy::SavedIdea => "Saved working gesture.",
Strategy::NewRhythm => {
"Recognizable contour; displaced entries and repeated pickups."
}
Strategy::FragmentAnswer => "A short fragment leaves space before a fuller return.",
Strategy::NegativeSpace => {
"Connecting notes fall away; the signature anchors remain."
}
Strategy::CallResponse => "A shorter question is answered with longer arrivals.",
Strategy::HarmonicReturn => {
"The opening gesture returns after a contrasting middle."
}
Strategy::LaterPeak => {
"Register opens in the middle and returns before the ending."
}
Strategy::ChangedEnding => {
"The groove stays recognizable while answer endings change."
}
Strategy::DelayedResolution => {
"Earlier answers stay open; the last arrival follows your ending choice."
}
};
let note = if harmony.spans.is_empty() {
" Scale guides pitches; no chord context supplied."
} else {
" Strong notes and arrivals follow the supplied harmony where locks and contour allow."
};
let constraints = if s.preserve_rhythm {
" Groove is fixed."
} else {
""
} else {
" Strong notes follow the harmony."
};
let candidate = Candidate {
strategy: family,
explanation: format!("{description}{note}{constraints}"),
explanation: format!("{}{note}", explain(family)),
phrase,
};
if let Some(index) = pool
.iter()
.position(|c| same_music(&c.phrase, &candidate.phrase))
{
if fit(s.task, family) > fit(s.task, pool[index].strategy) {
if fit(role, family) > fit(role, pool[index].strategy) {
pool[index] = candidate;
}
} else {
@@ -152,15 +220,11 @@ pub fn generate(source: &Phrase, s: &Settings, harmony: &Harmony) -> Result<Vec<
.map(|chosen| distance(&c.phrase, &chosen.phrase))
.min()
.unwrap_or(0);
let new_family = if selected.iter().all(|chosen| chosen.strategy != c.strategy) {
120
} else {
0
};
let new_family = selected.iter().all(|chosen| chosen.strategy != c.strategy);
(
fit(s.task, c.strategy) > 0,
new_family > 0,
fit(s.task, c.strategy) + diversity,
fit(role, c.strategy) > 0,
new_family,
fit(role, c.strategy) + diversity,
usize::MAX - *i,
)
})
@@ -171,256 +235,243 @@ pub fn generate(source: &Phrase, s: &Settings, harmony: &Harmony) -> Result<Vec<
Ok(selected)
}
#[allow(clippy::too_many_arguments)]
fn develop(
source: &Phrase,
motif: &Phrase,
mut notes: Vec<Note>,
length: u32,
s: &Settings,
harmony: &Harmony,
role: Role,
family: Strategy,
place: Place,
rng: &mut Random,
) -> Phrase {
let sections = s.bars as u32 / 2;
let mut result = Vec::new();
for section in 0..sections {
let offset = section * SECTION;
let mut notes = Vec::new();
for repeat in 0..SECTION.div_ceil(source.length) {
for original in &source.notes {
let n = Note {
start: original.start + repeat * source.length,
..*original
let meter = s.meter;
let departure = role == Role::Departure;
let returning = role == Role::Return || place.last;
let pattern = (rng.next() % 3) as usize;
let original_count = notes.len();
let half = length / 2;
if !s.preserve_rhythm {
let mut i = 0;
notes.retain(|n| {
let index = i;
i += 1;
if n.is_anchor() {
return true;
}
match family {
Strategy::NegativeSpace => {
index == 0 || index + 1 == original_count || (index + pattern).is_multiple_of(3)
}
Strategy::FragmentAnswer if !returning => n.start < half.max(motif.length / 2),
Strategy::CallResponse if !returning => n.start < half,
_ => true,
}
});
}
for (i, n) in notes.iter_mut().enumerate() {
if !n.keeps_pitch() {
let steps = match family {
Strategy::LaterPeak if departure => 3 + s.variation as i16 / 25,
Strategy::LaterPeak if i < original_count / 2 => 2,
Strategy::HarmonicReturn if departure => 2,
Strategy::ChangedEnding if i >= original_count / 2 => -1 - pattern as i16,
Strategy::DelayedResolution if !returning => 1,
_ => 0,
};
n.pitch = s.degree(n.pitch, steps);
}
if !n.locked {
let accent = if meter.strong(n.start) { 5 } else { -5 };
let arc = if departure {
6
} else if returning {
-3
} else {
0
};
n.velocity = (n.velocity as i16 + accent + arc).clamp(1, 127) as u8;
}
}
if !s.preserve_rhythm {
let unit = meter.beat();
for i in 0..notes.len() {
if notes[i].keeps_rhythm() {
continue;
}
let previous = if i == 0 { 0 } else { notes[i - 1].end() };
let next = notes.get(i + 1).map_or(length, |n| n.start);
let n = &mut notes[i];
let shift = unit / 4 + (s.variation as u32 * unit / 400);
if matches!(
family,
Strategy::NewRhythm | Strategy::FragmentAnswer | Strategy::CallResponse
) {
let wanted = if (i + pattern).is_multiple_of(2) {
n.start.saturating_add(shift)
} else {
n.start.saturating_sub(shift)
};
if n.end() <= SECTION {
notes.push(n);
n.start = wanted.clamp(previous, next.saturating_sub(1).max(previous));
}
let desired = match family {
Strategy::NegativeSpace | Strategy::FragmentAnswer => n.duration.min(unit / 2),
Strategy::CallResponse if n.start >= half => n.duration.saturating_add(unit / 2),
Strategy::NewRhythm if (i + pattern) % 2 == 1 => n.duration / 2,
_ => n.duration,
};
n.duration = desired.max(1).min(next.saturating_sub(n.start).max(1));
}
if family == Strategy::NewRhythm {
let mut extra = Vec::new();
for (i, n) in notes.iter().enumerate() {
let next = notes.get(i + 1).map_or(length, |n| n.start);
if !n.is_anchor() && next.saturating_sub(n.end()) >= unit && i % 2 == pattern % 2 {
extra.push(Note {
start: n.end() + unit / 4,
duration: unit / 4,
pitch: n.pitch,
velocity: n.velocity.saturating_sub(12).max(1),
..Note::default()
});
}
}
notes.extend(extra);
notes.sort_by_key(|n| n.start);
}
if section == 0 {
result.extend(notes.into_iter().map(|n| Note {
start: n.start + offset,
..n
}));
}
// Reference relationships constrain harmonic adjustments, preserving the chosen contour.
let reference: Vec<_> = notes.iter().map(|n| n.pitch).collect();
for i in 0..notes.len() {
if notes[i].keeps_pitch() {
continue;
}
let returning = section + 1 == sections;
let departure = sections == 4 && section == 2;
let pattern = (rng.next() % 3) as usize;
let original_count = notes.len();
if !s.preserve_rhythm {
let mut i = 0;
notes.retain(|n| {
let index = i;
i += 1;
if n.is_anchor() {
return true;
}
match family {
Strategy::NegativeSpace => {
index == 0
|| index + 1 == original_count
|| (index + pattern).is_multiple_of(3)
}
Strategy::FragmentAnswer if !returning => index < original_count.div_ceil(2),
Strategy::CallResponse if !returning => n.start < BAR,
_ => true,
}
});
let final_note = i + 1 == notes.len();
let global = notes[i].start + place.offset;
let strong = meter.strong(global);
let wants_open = final_note
&& ((place.last && s.ending == Ending::Open)
|| (!place.last
&& (family == Strategy::DelayedResolution || role == Role::Statement)));
let wants_closed = final_note && place.last && s.ending == Ending::Resolve;
let mask = harmony.at(global).map(|h| h.tones);
if !strong && !final_note {
continue;
}
for (i, n) in notes.iter_mut().enumerate() {
if !n.keeps_pitch() {
let steps = match family {
Strategy::LaterPeak if departure => 3 + s.variation as i16 / 25,
Strategy::LaterPeak if sections == 2 && i < original_count / 2 => 2,
Strategy::HarmonicReturn if departure => 2,
Strategy::ChangedEnding if i >= original_count / 2 => -1 - pattern as i16,
Strategy::DelayedResolution if !returning => 1,
_ => 0,
};
n.pitch = s.degree(n.pitch, steps);
let allowed = |pitch: u8| {
if !s.preserve_contour {
return true;
}
if !n.locked {
let accent = if n.start.is_multiple_of(PPQ) { 5 } else { -5 };
let arc = if departure {
6
} else if returning {
-3
} else {
0
};
n.velocity = (n.velocity as i16 + accent + arc).clamp(1, 127) as u8;
}
}
if !s.preserve_rhythm {
for i in 0..notes.len() {
if notes[i].keeps_rhythm() {
continue;
let previous = i.checked_sub(1).map(|p| (reference[p], notes[p].pitch));
let next = reference.get(i + 1).copied();
previous.is_none_or(|(old, new)| match reference[i].cmp(&old) {
std::cmp::Ordering::Greater => pitch >= new,
std::cmp::Ordering::Less => pitch <= new,
std::cmp::Ordering::Equal => pitch == new,
}) && next.is_none_or(|next| match reference[i].cmp(&next) {
std::cmp::Ordering::Greater => pitch >= next,
std::cmp::Ordering::Less => pitch <= next,
std::cmp::Ordering::Equal => pitch == next,
})
};
let target = if wants_closed && family == Strategy::HarmonicReturn {
motif.notes.last().map_or(notes[i].pitch, |n| n.pitch)
} else {
notes[i].pitch
};
let chosen = (s.low..=s.high)
.filter(|&pitch| {
if !allowed(pitch) {
return false;
}
let previous = if i == 0 { 0 } else { notes[i - 1].end() };
let next = notes.get(i + 1).map_or(SECTION, |n| n.start);
let n = &mut notes[i];
let shift = PPQ / 4 + (s.variation as u32 * PPQ / 400);
if matches!(
family,
Strategy::NewRhythm | Strategy::FragmentAnswer | Strategy::CallResponse
) {
let wanted = if (i + pattern).is_multiple_of(2) {
n.start.saturating_add(shift)
} else {
n.start.saturating_sub(shift)
};
n.start = wanted.clamp(previous, next.saturating_sub(1).max(previous));
}
let desired = match family {
Strategy::NegativeSpace | Strategy::FragmentAnswer => n.duration.min(PPQ / 2),
Strategy::CallResponse if returning => n.duration.saturating_add(PPQ / 2),
Strategy::NewRhythm if (i + pattern) % 2 == 1 => n.duration / 2,
_ => n.duration,
};
n.duration = desired.max(1).min(next - n.start);
}
if family == Strategy::NewRhythm {
let mut extra = Vec::new();
for (i, n) in notes.iter().enumerate() {
let next = notes.get(i + 1).map_or(SECTION, |n| n.start);
if !n.is_anchor() && next.saturating_sub(n.end()) >= PPQ && i % 2 == pattern % 2
{
extra.push(Note {
start: n.end() + PPQ / 4,
duration: PPQ / 4,
pitch: n.pitch,
velocity: n.velocity.saturating_sub(12).max(1),
..Note::default()
});
match mask {
Some(mask) if wants_open => {
s.contains(pitch) && mask & (1 << (pitch % 12)) == 0
}
Some(mask) => mask & (1 << (pitch % 12)) != 0,
None if wants_open => s.contains(pitch) && pitch % 12 != s.root,
None if wants_closed => pitch % 12 == s.root,
None => false,
}
notes.extend(extra);
notes.sort_by_key(|n| n.start);
}
})
.min_by_key(|&pitch| pitch.abs_diff(target));
if let Some(pitch) = chosen {
notes[i].pitch = pitch;
}
// Reference relationships constrain harmonic adjustments, preserving the chosen contour.
let reference: Vec<_> = notes.iter().map(|n| n.pitch).collect();
for i in 0..notes.len() {
if notes[i].keeps_pitch() {
continue;
}
let last = i + 1 == notes.len();
let global = notes[i].start + offset;
let strong = global.is_multiple_of(PPQ);
let wants_open = last
&& (family == Strategy::DelayedResolution && !returning
|| returning && s.ending == Ending::Open);
let wants_closed = last && returning && s.ending == Ending::Resolve;
let region = harmony.at(global);
let mask = region.map(|h| h.tones);
if !strong && !last {
continue;
}
let allowed = |pitch: u8| {
if !s.preserve_contour {
return true;
}
let previous = i.checked_sub(1).map(|p| (reference[p], notes[p].pitch));
let next = reference.get(i + 1).copied();
previous.is_none_or(|(old, new)| match reference[i].cmp(&old) {
std::cmp::Ordering::Greater => pitch >= new,
std::cmp::Ordering::Less => pitch <= new,
std::cmp::Ordering::Equal => pitch == new,
}) && next.is_none_or(|next| match reference[i].cmp(&next) {
std::cmp::Ordering::Greater => pitch >= next,
std::cmp::Ordering::Less => pitch <= next,
std::cmp::Ordering::Equal => pitch == next,
})
};
let target = if wants_closed && family == Strategy::HarmonicReturn {
source.notes.last().unwrap().pitch
} else {
notes[i].pitch
};
let chosen = (s.low..=s.high)
.filter(|&pitch| {
if !allowed(pitch) {
return false;
}
match mask {
Some(mask) if wants_open => {
s.contains(pitch) && mask & (1 << (pitch % 12)) == 0
}
Some(mask) => mask & (1 << (pitch % 12)) != 0,
None if wants_open => s.contains(pitch) && pitch % 12 != s.root,
None if wants_closed => pitch % 12 == s.root,
None => false,
}
})
.min_by_key(|&pitch| pitch.abs_diff(target));
if let Some(pitch) = chosen {
notes[i].pitch = pitch;
}
}
result.extend(notes.into_iter().map(|n| Note {
start: n.start + offset,
..n
}));
}
Phrase {
notes: result,
length: s.bars as u32 * BAR,
}
Phrase { notes, length }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{demo_motif, Meter, BAR};
#[test]
fn later_peak_returns_before_the_final_note() {
let source = crate::demo_motif();
let settings = Settings {
fn departure_peak_rises_above_the_motif() {
let motif = demo_motif();
let s = Settings {
preserve_rhythm: true,
..Settings::default()
};
let phrase = develop(
&source,
&settings,
&motif,
tile(&motif, 2 * BAR),
2 * BAR,
&s,
&Harmony::default(),
Role::Departure,
Strategy::LaterPeak,
Place {
offset: 4 * BAR,
last: false,
},
&mut Random(42),
);
let peak = phrase
.notes
.iter()
.filter(|n| n.start >= 4 * BAR && n.start < 6 * BAR)
.map(|n| n.pitch)
.max()
.unwrap();
let returned = phrase
.notes
.iter()
.filter(|n| n.start >= 6 * BAR)
.map(|n| n.pitch)
.max()
.unwrap();
assert!(peak > returned);
let peak = phrase.notes.iter().map(|n| n.pitch).max().unwrap();
assert!(peak > motif.notes.iter().map(|n| n.pitch).max().unwrap());
}
#[test]
fn preserved_contour_has_no_direction_reversals_with_a_fixed_groove() {
let source = crate::demo_motif();
let settings = Settings {
let motif = demo_motif();
let s = Settings {
preserve_rhythm: true,
preserve_contour: true,
..Settings::default()
};
for family in FAMILIES {
for family in Strategy::ALL {
let phrase = develop(
&source,
&settings,
&motif,
tile(&motif, 2 * BAR),
2 * BAR,
&s,
&Harmony::default(),
Role::Answer,
family,
Place {
offset: 2 * BAR,
last: false,
},
&mut Random(42),
);
for section in phrase.notes.chunks_exact(source.notes.len()) {
for (original, new) in source.notes.windows(2).zip(section.windows(2)) {
let a = original[1].pitch as i16 - original[0].pitch as i16;
let b = new[1].pitch as i16 - new[0].pitch as i16;
assert!(a * b >= 0);
}
for (original, new) in motif.notes.windows(2).zip(phrase.notes.windows(2)) {
let a = original[1].pitch as i16 - original[0].pitch as i16;
let b = new[1].pitch as i16 - new[0].pitch as i16;
assert!(a * b >= 0, "{family:?}");
}
}
}
#[test]
fn tiling_fills_blocks_of_any_meter_without_crossing_the_end() {
let motif = demo_motif();
for meter in Meter::ALL {
for bars in 1..=8 {
let length = bars * meter.bar();
let notes = tile(&motif, length);
assert!(!notes.is_empty());
assert!(notes.iter().all(|n| n.end() <= length));
assert!(notes.windows(2).all(|w| w[0].end() <= w[1].start));
}
}
}
-138
View File
@@ -1,138 +0,0 @@
use rivet_core::*;
fn session() -> Session {
let mut s = Session {
source: Some(demo_motif()),
..Default::default()
};
s.regenerate().unwrap();
s
}
#[test]
fn moves_fork_and_restore_context_without_losing_siblings() {
let mut s = session();
s.pin_selected(0).unwrap();
let root = s.save_branch("Original").unwrap();
let original = s.composed_phrase().unwrap();
s.apply_move(CreativeMove::Thin, Some(1)).unwrap();
let thin = s.active_branch.unwrap();
let thinned = s.composed_phrase().unwrap();
assert_ne!(thinned, original);
assert_eq!(
&thinned.notes[..demo_motif().notes.len()],
demo_motif().notes.as_slice()
);
s.restore_branch(root).unwrap();
assert_eq!(s.composed_phrase().unwrap(), original);
assert_eq!(s.pins.len(), 1);
s.apply_move(CreativeMove::Lengthen, Some(2)).unwrap();
let long = s.active_branch.unwrap();
assert_ne!(thin, long);
assert_eq!(s.branches.len(), 3);
assert_eq!(
s.branches.iter().find(|b| b.id == thin).unwrap().parent,
Some(root)
);
assert_eq!(
s.branches.iter().find(|b| b.id == long).unwrap().parent,
Some(root)
);
s.restore_branch(thin).unwrap();
assert_eq!(s.composed_phrase().unwrap(), thinned);
assert!(s.remove_branch(root).is_err());
assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s);
}
#[test]
fn gesture_recall_and_cross_project_library_preserve_performance() {
let mut s = session();
s.performance.mode = RoleMode::CallResponse;
s.performance.voices[1].controller.enabled = true;
s.performance.voices[1].controller.cc = 20;
s.performance.voices[1].controller.shape(2);
let id = s.save_gesture("Floating answer", Some(1)).unwrap();
let saved = s.gestures[0].clone();
let bytes = s.export_gesture_library().unwrap();
let mut other = Session {
source: Some(demo_motif()),
harmony: Harmony::progression(2, Scale::Major),
..Default::default()
};
other.save_gesture("Existing", None).unwrap();
let context = other.harmony.clone();
other
.import_gesture_library(read_gesture_library(&bytes).unwrap())
.unwrap();
assert_eq!(other.gestures.len(), 2);
assert_ne!(other.gestures[0].id, other.gestures[1].id);
let imported = other.gestures[1].id;
other.recall_gesture(imported).unwrap();
assert_eq!(other.source, Some(saved.phrase));
assert_eq!(other.performance, saved.performance);
assert_eq!(other.harmony, context);
assert!(other.candidates.is_empty());
assert!(other.pins.is_empty());
assert!(s.gestures.iter().any(|g| g.id == id));
}
#[test]
fn blocked_moves_and_full_histories_are_atomic() {
let mut s = session();
s.settings.preserve_rhythm = true;
let before = s.clone();
assert!(s.apply_move(CreativeMove::Thin, None).is_err());
assert_eq!(s, before);
s.settings.preserve_rhythm = false;
for i in 0..MAX_BRANCHES {
s.save_branch(&format!("Idea {i}")).unwrap();
}
let before = s.clone();
assert!(s.apply_move(CreativeMove::Thin, Some(1)).is_err());
assert_eq!(s, before);
let leaf = s.active_branch.unwrap();
s.remove_branch(leaf).unwrap();
assert_eq!(s.branches.len(), MAX_BRANCHES - 1);
}
#[test]
fn malformed_ancestry_libraries_and_version_two_state_are_handled() {
let mut s = session();
let id = s.save_branch("Root").unwrap();
s.branches[0].parent = Some(id);
assert!(s.validate().is_err());
assert!(read_gesture_library(br#"{"version":99,"gestures":[]}"#).is_err());
let old = session();
let mut value = serde_json::to_value(&old).unwrap();
value["version"] = 2.into();
for key in [
"performance",
"gestures",
"branches",
"active_branch",
"next_id",
] {
value.as_object_mut().unwrap().remove(key);
}
let restored = Session::from_json(&serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(restored.version, 3);
assert_eq!(restored.source, old.source);
assert_eq!(restored.performance, PerformanceSettings::default());
}
#[test]
fn delay_moves_adjacent_notes_together_and_rejects_protected_collisions() {
let mut s = session();
let make_note = |start| Note {
start,
duration: PPQ,
pitch: 60,
velocity: 90,
..Default::default()
};
s.candidates[s.selected].phrase.notes = vec![make_note(SECTION), make_note(SECTION + PPQ)];
s.apply_move(CreativeMove::Delay, Some(1)).unwrap();
let moved = s.composed_phrase().unwrap();
assert_eq!(moved.notes[0].start, SECTION + PPQ / 4);
assert_eq!(moved.notes[1].start, SECTION + PPQ + PPQ / 4);
s.candidates[0].phrase.notes[1].rhythm_locked = true;
let before = s.clone();
assert!(s.apply_move(CreativeMove::Delay, Some(1)).is_err());
assert_eq!(s, before);
}
+135 -160
View File
@@ -3,85 +3,110 @@ use rivet_core::*;
fn rhythm(p: &Phrase) -> Vec<(u32, u32)> {
p.notes.iter().map(|n| (n.start, n.duration)).collect()
}
fn options(motif: &Phrase, role: Role, s: &Settings) -> Vec<Candidate> {
realize(motif, role, 2, 2 * BAR, false, 0, s, &Harmony::default()).unwrap()
}
#[test]
fn alternatives_change_density_and_rhythm_instead_of_only_pitch() {
let source = demo_motif();
fn each_role_offers_distinct_reproducible_options_led_by_its_best_fit() {
let motif = demo_motif();
let s = Settings::default();
let candidates = generate(&source, &s).unwrap();
assert_eq!(candidates.len(), 3);
assert!(candidates
.iter()
.any(|c| c.phrase.notes.len() != source.notes.len() * 4));
assert!(candidates
.windows(2)
.any(|pair| rhythm(&pair[0].phrase) != rhythm(&pair[1].phrase)));
for c in &candidates {
assert_eq!(
&c.phrase.notes[..source.notes.len()],
source.notes.as_slice()
);
for (role, first) in [
(Role::Statement, Strategy::Quote),
(Role::Answer, Strategy::CallResponse),
(Role::Departure, Strategy::LaterPeak),
(Role::Return, Strategy::HarmonicReturn),
] {
let a = options(&motif, role, &s);
assert_eq!(a, options(&motif, role, &s), "{role:?} is deterministic");
assert_eq!(a.len(), 3, "{role:?}");
assert_eq!(a[0].strategy, first, "{role:?}");
for pair in a.windows(2) {
assert_ne!(pair[0].phrase, pair[1].phrase);
}
for c in &a {
c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap();
assert_eq!(c.phrase.length, 2 * BAR);
}
}
assert_eq!(candidates, generate(&source, &s).unwrap());
let quote = &options(&motif, Role::Statement, &s)[0];
assert_eq!(
quote.phrase.notes, motif.notes,
"a statement quotes the motif"
);
let rest = realize(&motif, Role::Rest, 1, 0, false, 0, &s, &Harmony::default()).unwrap();
assert_eq!(rest.len(), 1);
assert!(rest[0].phrase.notes.is_empty());
}
#[test]
fn options_change_rhythm_and_density_not_only_pitch() {
let motif = demo_motif();
let s = Settings::default();
let mut signatures = std::collections::BTreeSet::new();
for seed in 0..12 {
for c in generate(&source, &Settings { seed, ..s.clone() }).unwrap() {
signatures.insert(rhythm(&c.phrase));
for role in [Role::Answer, Role::Departure] {
for c in options(&motif, role, &Settings { seed, ..s.clone() }) {
signatures.insert(rhythm(&c.phrase));
}
}
}
assert!(
signatures.len() > 3,
"seeds should explore multiple rhythmic decisions"
"seeds should explore rhythmic decisions"
);
assert!(options(&motif, Role::Answer, &s)
.iter()
.any(|c| c.phrase.notes.len() != motif.notes.len()));
}
#[test]
fn fixed_groove_and_separate_note_locks_take_priority() {
let mut source = demo_motif();
source.notes[1].pitch_locked = true;
source.notes[1].pitch = 99;
source.notes[3].rhythm_locked = true;
for c in generate(&source, &Settings::default()).unwrap() {
let pitch: Vec<_> = c.phrase.notes.iter().filter(|n| n.pitch_locked).collect();
assert_eq!(pitch.len(), 4);
assert!(pitch.iter().all(|n| n.pitch == 99));
for rep in 0..4 {
assert!(c.phrase.notes.iter().any(|n| n.rhythm_locked
&& n.start == source.notes[3].start + rep * source.length
&& n.duration == source.notes[3].duration));
let mut motif = demo_motif();
motif.notes[1].pitch_locked = true;
motif.notes[1].pitch = 99;
motif.notes[3].rhythm_locked = true;
for role in [Role::Answer, Role::Departure, Role::Return] {
for c in realize(
&motif,
role,
4,
0,
false,
0,
&Settings::default(),
&Harmony::default(),
)
.unwrap()
{
let locked: Vec<_> = c.phrase.notes.iter().filter(|n| n.pitch_locked).collect();
assert_eq!(locked.len(), 2, "{role:?} {:?}", c.strategy);
assert!(locked.iter().all(|n| n.pitch == 99));
for rep in 0..2 {
assert!(c.phrase.notes.iter().any(|n| n.rhythm_locked
&& n.start == motif.notes[3].start + rep * motif.length
&& n.duration == motif.notes[3].duration));
}
}
}
let source = demo_motif();
let repeated: Vec<_> = (0..4)
.flat_map(|rep| {
source
.notes
.iter()
.map(move |n| (n.start + rep * SECTION, n.duration))
})
.collect();
for c in generate(
&source,
&Settings {
preserve_rhythm: true,
..Settings::default()
},
)
.unwrap()
{
assert_eq!(rhythm(&c.phrase), repeated);
let motif = demo_motif();
let s = Settings {
preserve_rhythm: true,
..Settings::default()
};
for c in options(&motif, Role::Departure, &s) {
assert_eq!(rhythm(&c.phrase), rhythm(&motif));
}
}
#[test]
fn impossible_diversity_returns_one_honest_proposal() {
let mut source = demo_motif();
for n in &mut source.notes {
fn fully_locked_motif_returns_one_honest_option() {
let mut motif = demo_motif();
for n in &mut motif.notes {
n.locked = true;
}
let candidates = generate(&source, &Settings::default()).unwrap();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].phrase.notes.len(), source.notes.len() * 4);
let all = options(&motif, Role::Answer, &Settings::default());
assert_eq!(all.len(), 1);
assert_eq!(all[0].phrase.notes, motif.notes);
}
#[test]
fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
fn harmony_drives_strong_notes_and_the_final_ending() {
let harmony = Harmony {
length: BAR,
spans: vec![HarmonySpan {
@@ -97,13 +122,30 @@ fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
ending,
..Settings::default()
};
for c in generate_with_harmony(&demo_motif(), &s, &harmony).unwrap() {
for c in realize(
&demo_motif(),
Role::Return,
2,
6 * BAR,
true,
0,
&s,
&harmony,
)
.unwrap()
{
if c.strategy == Strategy::Quote {
continue;
}
let last = c.phrase.notes.last().unwrap();
let chord = ChordQuality::Major.mask(0) & (1 << (last.pitch % 12)) != 0;
assert_eq!(chord, ending == Ending::Resolve);
for n in c.phrase.notes.iter().filter(|n| {
n.start >= SECTION && n.start.is_multiple_of(PPQ) && n.start != last.start
}) {
assert_eq!(chord, ending == Ending::Resolve, "{:?}", c.strategy);
for n in c
.phrase
.notes
.iter()
.filter(|n| n.start.is_multiple_of(PPQ) && n.start != last.start)
{
assert_ne!(
harmony.at(n.start).unwrap().tones & (1 << (n.pitch % 12)),
0
@@ -114,29 +156,16 @@ fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
}
#[test]
fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() {
let notes = vec![
Note {
let notes: Vec<_> = [64, 67, 72]
.into_iter()
.map(|pitch| Note {
start: 0,
duration: BAR,
pitch: 64,
pitch,
velocity: 90,
..Default::default()
},
Note {
start: 0,
duration: BAR,
pitch: 67,
velocity: 90,
..Default::default()
},
Note {
start: 0,
duration: BAR,
pitch: 72,
velocity: 90,
..Default::default()
},
];
})
.collect();
let h = Harmony::from_midi(&notes, 2 * BAR).unwrap();
assert_eq!(h.spans.len(), 1);
assert_eq!(h.spans[0].bass, 64);
@@ -145,84 +174,30 @@ fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() {
assert_eq!(h.at(2 * BAR).unwrap().bass, 64);
}
#[test]
fn pins_combine_candidates_and_survive_regeneration_exactly() {
let mut s = Session {
source: Some(demo_motif()),
..Default::default()
};
s.regenerate().unwrap();
s.pin_selected(1).unwrap();
let saved = s.pins[0].clone();
s.selected = 1;
let expected: Vec<_> = saved
.phrase
.notes
.iter()
.map(|n| Note {
start: n.start + SECTION,
..*n
})
.collect();
let combined = s.composed_phrase().unwrap();
assert_eq!(
combined
.notes
.iter()
.filter(|n| n.start >= SECTION && n.start < 2 * SECTION)
.copied()
.collect::<Vec<_>>(),
expected
);
s.settings.seed += 7;
s.regenerate().unwrap();
assert_eq!(s.pins[0], saved);
s.accept_composed().unwrap();
assert_eq!(s.accepted, Some(s.composed_phrase().unwrap()));
assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s);
}
#[test]
fn version_one_sessions_migrate_without_losing_existing_notes() {
let s = Session {
source: Some(demo_motif()),
..Default::default()
};
let mut value = serde_json::to_value(&s).unwrap();
value["version"] = 1.into();
let object = value.as_object_mut().unwrap();
for key in ["harmony", "pins", "audition", "looping"] {
object.remove(key);
fn every_meter_key_and_block_length_realizes_valid_options() {
let motif = demo_motif();
for meter in Meter::ALL {
for root in [0, 5, 11] {
for scale in Scale::ALL {
let s = Settings {
root,
scale,
meter,
low: 60,
high: 72,
..Settings::default()
};
for bars in [1, 3, 8] {
for role in Role::ALL {
for c in realize(&motif, role, bars, 0, true, 0, &s, &Harmony::default())
.unwrap()
{
c.phrase.check(MAX_PLAYBACK_NOTES, true).unwrap();
assert_eq!(c.phrase.length, bars as u32 * meter.bar());
}
}
}
}
}
}
let settings = object.get_mut("settings").unwrap().as_object_mut().unwrap();
for key in ["preserve_contour", "task", "ending"] {
settings.remove(key);
}
for n in object.get_mut("source").unwrap()["notes"]
.as_array_mut()
.unwrap()
{
n.as_object_mut().unwrap().remove("pitch_locked");
n.as_object_mut().unwrap().remove("rhythm_locked");
}
let migrated = Session::from_json(&serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(migrated.version, STATE_VERSION);
assert_eq!(migrated.source, s.source);
assert!(!migrated.looping);
assert!(migrated.harmony.spans.is_empty());
}
#[test]
fn invalid_pins_and_unsafe_legacy_seams_are_rejected() {
let mut s = Session {
source: Some(demo_motif()),
..Default::default()
};
s.regenerate().unwrap();
s.candidates[0].phrase.notes = vec![Note {
start: SECTION - 100,
duration: 200,
pitch: 60,
velocity: 80,
..Default::default()
}];
assert!(s.pin_selected(1).is_err());
assert!(s.pin_selected(9).is_err());
}
+12 -6
View File
@@ -34,7 +34,7 @@ fn role_handoffs_preserve_event_identity_and_respect_locks() {
s.voices[1].transpose = 12;
s.voices[0].velocity = 50;
s.voices[1].velocity = 50;
let p = Performance::compile(&source, &s, 140., false).unwrap();
let p = Performance::compile(&source, &s, 140., false, Meter::default()).unwrap();
let on: Vec<_> = p
.events
.iter()
@@ -42,7 +42,7 @@ fn role_handoffs_preserve_event_identity_and_respect_locks() {
.collect();
assert_eq!(on.len(), source.notes.len());
for (i, n) in source.notes.iter().enumerate() {
let role = mode.role(i, n);
let role = mode.role(i, n, Meter::default());
let actual = on.iter().find(|e| e.tick == n.start as i32).unwrap();
assert_eq!(actual.message.channel, s.voices[role].channel);
assert_eq!(
@@ -69,7 +69,7 @@ fn controller_interpolation_and_validation_are_explicit() {
let mut s = PerformanceSettings::default();
s.voices[0].controller.enabled = true;
s.voices[0].controller.cc = 20;
let p = Performance::compile(&held(), &s, 120., false).unwrap();
let p = Performance::compile(&held(), &s, 120., false, Meter::default()).unwrap();
assert!(p
.events
.iter()
@@ -83,7 +83,7 @@ fn controller_interpolation_and_validation_are_explicit() {
fn attack_compensation_warms_up_once_and_wraps_without_cutting_the_next_note() {
let mut s = PerformanceSettings::default();
s.voices[0].attack_ms = 100;
let performance = Performance::compile(&held(), &s, 120., true).unwrap();
let performance = Performance::compile(&held(), &s, 120., true, Meter::default()).unwrap();
assert_eq!(performance.lead_in, 192);
assert!(performance
.events
@@ -127,8 +127,14 @@ fn attack_compensation_warms_up_once_and_wraps_without_cutting_the_next_note() {
}
#[test]
fn public_clip_validation_rejects_bad_roles_and_dense_events() {
let mut p =
Performance::compile(&held(), &PerformanceSettings::default(), 120., false).unwrap();
let mut p = Performance::compile(
&held(),
&PerformanceSettings::default(),
120.,
false,
Meter::default(),
)
.unwrap();
p.events[0].role = 9;
assert!(p.clip().is_err());
let event = TimedMessage {
+182
View File
@@ -0,0 +1,182 @@
use rivet_core::*;
fn demo() -> Session {
Session::demo()
}
#[test]
fn templates_build_the_story_from_one_motif() {
let s = demo();
let roles: Vec<_> = s.blocks.iter().map(|b| b.role).collect();
assert_eq!(
roles,
[Role::Statement, Role::Answer, Role::Departure, Role::Return]
);
assert_eq!(s.total_bars(), 8);
assert!(s.blocks.iter().all(|b| b.options.len() == 3));
let phrase = s.compose().unwrap();
assert_eq!(phrase.length, 8 * BAR);
assert_eq!(
&phrase.notes[..demo_motif().notes.len()],
demo_motif().notes.as_slice(),
"the story opens with the motif"
);
let mut s = demo();
let id = s.motifs[0].id;
s.apply_template(Template::Breathe, id).unwrap();
assert_eq!(s.blocks[1].role, Role::Rest);
assert_eq!(s.blocks[1].bars, 1);
assert_eq!(s.total_bars(), 8);
}
#[test]
fn blocks_have_free_lengths_up_to_the_storyboard_limit() {
let mut s = demo();
let motif = s.motifs[0].id;
s.insert_block(4, motif, Role::Departure, 5).unwrap();
s.insert_block(5, motif, Role::Rest, 1).unwrap();
assert_eq!(s.total_bars(), 14);
assert_eq!(s.compose().unwrap().length, 14 * BAR);
assert!(s.insert_block(0, motif, Role::Answer, 9).is_err());
while s.total_bars() + 8 <= MAX_BARS {
s.insert_block(0, motif, Role::Answer, 8).unwrap();
}
assert!(s.insert_block(0, motif, Role::Answer, 8).is_err());
s.validate().unwrap();
}
#[test]
fn joins_shape_the_seam_without_crossing_it() {
let mut s = demo();
let first = s.blocks[1].phrase().unwrap().notes[0];
let bar = s.settings.meter.bar();
for join in Join::ALL {
s.blocks[0].join = join;
let phrase = s.compose().unwrap();
let tail: Vec<_> = phrase
.notes
.iter()
.filter(|n| n.start < 2 * bar)
.copied()
.collect();
assert!(tail.iter().all(|n| n.end() <= 2 * bar), "{join:?}");
let last = tail.last().unwrap();
match join {
Join::Cut => assert_eq!(tail, s.blocks[0].phrase().unwrap().notes),
Join::Breath => assert!(last.end() <= 2 * bar - s.settings.meter.beat() / 2),
Join::Approach => {
assert_ne!(last.pitch, first.pitch);
assert!(last.pitch.abs_diff(first.pitch) <= 2);
}
Join::Pickup => {
let pickup: Vec<_> = tail
.iter()
.filter(|n| n.start >= 2 * bar - s.settings.meter.beat())
.collect();
assert_eq!(pickup.len(), 2);
let direction = (pickup[1].pitch as i16 - pickup[0].pitch as i16).signum();
let into = (first.pitch as i16 - pickup[1].pitch as i16).signum();
assert_eq!(direction, into, "the pickup moves toward the next block");
}
}
}
}
#[test]
fn kept_blocks_survive_new_settings_and_other_blocks_follow_them() {
let mut s = demo();
s.blocks[1].choice = 2;
s.blocks[1].kept = true;
let kept = s.blocks[1].phrase().unwrap().clone();
let free = s.blocks[2].options.clone();
s.settings.seed += 7;
s.realize_all();
assert_eq!(s.blocks[1].phrase().unwrap(), &kept);
assert_ne!(s.blocks[2].options, free);
s.another_take(1);
assert!(!s.blocks[1].kept);
assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s);
}
#[test]
fn compound_and_odd_meters_compose_whole_bars() {
for meter in [
Meter { beats: 3, unit: 4 },
Meter { beats: 6, unit: 8 },
Meter { beats: 7, unit: 8 },
] {
let mut s = Session::default();
s.settings.meter = meter;
let motif = Phrase {
length: meter.bar(),
notes: (0..3)
.map(|i| Note {
start: i * meter.beat() / 2,
duration: meter.beat() / 2,
pitch: 60 + 2 * i as u8,
velocity: 90,
..Default::default()
})
.collect(),
};
let id = s.add_motif("Waltz", motif).unwrap();
s.apply_template(Template::Build, id).unwrap();
let phrase = s.compose().unwrap();
assert_eq!(phrase.length, s.total_bars() * meter.bar());
s.validate().unwrap();
}
}
#[test]
fn removing_a_motif_removes_its_blocks_and_limits_hold() {
let mut s = demo();
let id = s.motifs[0].id;
let other = s.add_motif("Second", demo_motif()).unwrap();
s.insert_block(4, other, Role::Answer, 2).unwrap();
s.remove_motif(id);
assert_eq!(s.blocks.len(), 1);
assert_eq!(s.blocks[0].motif, other);
let long = Phrase {
length: 9 * BAR,
..demo_motif()
};
assert!(s.add_motif("Too long", long).is_err());
for i in s.motifs.len()..MAX_MOTIFS {
s.add_motif(&format!("M{i}"), demo_motif()).unwrap();
}
assert!(s.add_motif("One more", demo_motif()).is_err());
}
#[test]
fn version_three_sessions_become_a_storyboard_without_losing_notes() {
let mut source = demo_motif();
source.notes[2].locked = true;
for (bars, blocks) in [(8, 4), (4, 2)] {
let old = serde_json::json!({
"version": 3,
"source": source,
"settings": {"bars": bars, "root": 2, "scale": "Dorian", "variation": 40,
"preserve_rhythm": false, "preserve_contour": true, "task": "Answer",
"ending": "Open", "low": 50, "high": 80, "seed": 9},
"candidates": [], "selected": 0, "accepted": null, "previous_accepted": null,
"tempo": 128.0, "follow_host": true, "pins": [], "looping": true,
"gestures": [{"id": 1, "name": "Hook", "phrase": demo_motif(),
"performance": PerformanceSettings::default()}],
"branches": [], "active_branch": null, "next_id": 2
});
let s = Session::from_json(&serde_json::to_vec(&old).unwrap()).unwrap();
assert_eq!(s.version, STATE_VERSION);
assert_eq!(s.motifs[0].phrase, source);
assert_eq!(s.motifs[1].name, "Hook");
assert_eq!(s.blocks.len(), blocks);
assert_eq!(s.blocks.last().unwrap().role, Role::Return);
assert_eq!(s.settings.root, 2);
assert_eq!(s.settings.scale, Scale::Dorian);
assert_eq!(s.settings.ending, Ending::Open);
assert_eq!(s.tempo, 128.);
assert!(s.follow_host);
assert!(s.compose().is_ok());
}
let v1 = serde_json::json!({"version": 1, "source": demo_motif(),
"settings": {"bars": 8}, "candidates": [], "selected": 0, "accepted": null,
"previous_accepted": null, "tempo": 140.0, "follow_host": false});
let s = Session::from_json(&serde_json::to_vec(&v1).unwrap()).unwrap();
assert!(!s.looping, "version-1 sessions kept one-shot audition");
assert!(s.harmony.spans.is_empty());
let mut future = serde_json::to_value(Session::default()).unwrap();
future["version"] = 99.into();
assert!(Session::from_json(&serde_json::to_vec(&future).unwrap()).is_err());
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-midi"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
+112 -62
View File
@@ -1,6 +1,6 @@
//! Strict Standard MIDI File import/export. Unsupported data is reported, never flattened.
use rivet_core::{
Error, MessageKind, Note, Performance, PerformanceSettings, Phrase, Result, BAR,
Error, MessageKind, Meter, Note, Performance, PerformanceSettings, Phrase, Result,
MAX_PLAYBACK_NOTES, PPQ,
};
@@ -16,6 +16,8 @@ pub struct MidiTrack {
pub struct MidiFile {
pub tracks: Vec<MidiTrack>,
pub tempo: Option<f64>,
/// The file's time signature; track lengths are rounded to its bars.
pub meter: Option<Meter>,
}
fn error(s: &str) -> Error {
Error(s.into())
@@ -108,6 +110,8 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
r.take(header_len - 6)?;
let mut tracks = Vec::new();
let mut tempo = None;
let mut meter: Option<Meter> = None;
let mut lengths = Vec::new();
for track_index in 0..count {
if r.take(4)? != b"MTrk" {
return Err(error("Missing MIDI track chunk."));
@@ -179,11 +183,17 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
if body.len() != 4 {
return Err(error("Invalid time signature."));
}
if body[0] != 4 || body[1] != 2 {
let found = Meter {
beats: body[0],
unit: 1u8.checked_shl(body[1] as u32).unwrap_or(0),
};
found.validate()?;
if meter.is_some_and(|m| m != found) {
return Err(error(
"Only 4/4 motifs are supported in this version.",
"Meter changes inside one file are unsupported. Export one section per meter.",
));
}
meter = Some(found);
}
_ => {}
}
@@ -250,11 +260,7 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
if !notes.is_empty() {
notes.sort_by_key(|n| (n.start, n.pitch));
let end = ticks(clock, division)?.max(notes.iter().map(Note::end).max().unwrap_or(0));
let length = end
.checked_add(BAR - 1)
.ok_or_else(|| error("MIDI timeline overflow."))?
/ BAR
* BAR;
lengths.push(end);
let warnings = if ignored {
vec!["Controllers, pitch bend, program changes, SysEx, or later tempo events are not imported. Preview uses a single tempo.".into()]
} else {
@@ -262,7 +268,7 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
};
tracks.push(MidiTrack {
name,
phrase: Phrase { notes, length },
phrase: Phrase { notes, length: 0 },
warnings,
channels: (0..16).filter(|c| channels & (1 << c) != 0).collect(),
});
@@ -271,7 +277,20 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
if tracks.is_empty() {
return Err(error("This MIDI file has no note tracks."));
}
Ok(MidiFile { tracks, tempo })
// The time signature may follow the notes in another track, so bars are known only now.
let bar = meter.unwrap_or_default().bar();
for (track, end) in tracks.iter_mut().zip(lengths) {
track.phrase.length = end
.checked_add(bar - 1)
.ok_or_else(|| error("MIDI timeline overflow."))?
/ bar
* bar;
}
Ok(MidiFile {
tracks,
tempo,
meter,
})
}
fn vlq(mut value: u32, out: &mut Vec<u8>) {
@@ -287,13 +306,21 @@ fn vlq(mut value: u32, out: &mut Vec<u8>) {
out.extend_from_slice(&b[i..]);
}
pub fn write(phrase: &Phrase, bpm: f64) -> Result<Vec<u8>> {
phrase.validate(MAX_PLAYBACK_NOTES)?;
if !bpm.is_finite() || !(30.0..=300.0).contains(&bpm) {
return Err(error("Tempo must be 30–300 BPM."));
}
fn time_signature(meter: Meter) -> [u8; 8] {
[
0,
0xff,
0x58,
4,
meter.beats,
meter.unit.trailing_zeros() as u8,
24,
8,
]
}
fn tempo_event(bpm: f64) -> [u8; 7] {
let us = (60_000_000. / bpm).round() as u32;
let mut track = vec![
[
0,
0xff,
0x51,
@@ -301,15 +328,17 @@ pub fn write(phrase: &Phrase, bpm: f64) -> Result<Vec<u8>> {
(us >> 16) as u8,
(us >> 8) as u8,
us as u8,
0,
0xff,
0x58,
4,
4,
2,
24,
8,
];
]
}
pub fn write(phrase: &Phrase, bpm: f64, meter: Meter) -> Result<Vec<u8>> {
phrase.validate(MAX_PLAYBACK_NOTES)?;
meter.validate()?;
if !bpm.is_finite() || !(30.0..=300.0).contains(&bpm) {
return Err(error("Tempo must be 30–300 BPM."));
}
let mut track = tempo_event(bpm).to_vec();
track.extend_from_slice(&time_signature(meter));
let mut events = Vec::new();
for n in &phrase.notes {
events.push((n.start, 1, n.pitch, n.velocity));
@@ -338,31 +367,17 @@ pub fn write_performance(
phrase: &Phrase,
settings: &PerformanceSettings,
bpm: f64,
meter: Meter,
) -> Result<Vec<u8>> {
let p = Performance::compile(phrase, settings, bpm, settings.compensated_export)?;
meter.validate()?;
let p = Performance::compile(phrase, settings, bpm, settings.compensated_export, meter)?;
let shift = if settings.compensated_export && p.lead_in > 0 {
BAR
meter.bar()
} else {
0
};
let us = (60_000_000. / bpm).round() as u32;
let mut conductor = vec![
0,
0xff,
0x51,
3,
(us >> 16) as u8,
(us >> 8) as u8,
us as u8,
0,
0xff,
0x58,
4,
4,
2,
24,
8,
];
let mut conductor = tempo_event(bpm).to_vec();
conductor.extend_from_slice(&time_signature(meter));
vlq(phrase.length + shift, &mut conductor);
conductor.extend_from_slice(&[0xff, 0x2f, 0]);
let mut tracks = vec![conductor];
@@ -432,16 +447,19 @@ mod tests {
#[test]
fn round_trip_source_and_generated_phrase() {
let source = rivet_core::demo_motif();
let bytes = write(&source, 140.).unwrap();
let bytes = write(&source, 140., Meter::default()).unwrap();
let parsed = read(&bytes).unwrap();
assert_eq!(parsed.tracks[0].phrase, source);
assert!((parsed.tempo.unwrap() - 140.).abs() < 0.001);
for c in rivet_core::generate(&source, &rivet_core::Settings::default()).unwrap() {
assert_eq!(
read(&write(&c.phrase, 140.).unwrap()).unwrap().tracks[0].phrase,
c.phrase
);
}
assert_eq!(parsed.meter, Some(Meter::default()));
let story = rivet_core::Session::demo().compose().unwrap();
assert_eq!(
read(&write(&story, 140., Meter::default()).unwrap())
.unwrap()
.tracks[0]
.phrase,
story
);
}
#[test]
fn running_status_and_zero_velocity_off() {
@@ -453,7 +471,7 @@ mod tests {
}
#[test]
fn malformed_and_unsupported_files() {
let good = write(&rivet_core::demo_motif(), 140.).unwrap();
let good = write(&rivet_core::demo_motif(), 140., Meter::default()).unwrap();
for n in 0..good.len() {
assert!(read(&good[..n]).is_err());
}
@@ -462,10 +480,10 @@ mod tests {
assert!(read(&smpte).unwrap_err().0.contains("SMPTE"));
assert!(read(&file(&[0, 0x90, 60, 80, 0, 0xff, 0x2f, 0])).is_err());
assert!(
read(&file(&[0, 0xff, 0x58, 4, 3, 2, 24, 8, 0, 0xff, 0x2f, 0]))
read(&file(&[0, 0xff, 0x58, 4, 11, 3, 24, 8, 0, 0xff, 0x2f, 0]))
.unwrap_err()
.0
.contains("4/4")
.contains("Supported meters")
);
assert!(read(&file(&[0x81, 0x81, 0x81, 0x81, 0, 0xff, 0x2f, 0])).is_err());
}
@@ -477,14 +495,14 @@ mod tests {
.unwrap();
assert!(p.tracks[0]
.phrase
.validate_source()
.validate(rivet_core::MAX_SOURCE_NOTES)
.unwrap_err()
.0
.contains("Overlapping"));
}
#[test]
fn separate_tracks_and_timing_resolution() {
let a = write(&rivet_core::demo_motif(), 140.).unwrap();
let a = write(&rivet_core::demo_motif(), 140., Meter::default()).unwrap();
let mut two = a[..14].to_vec();
two[9] = 1;
two[11] = 2;
@@ -508,7 +526,7 @@ mod tests {
v.controller.rest = 5 + role as u8;
}
let source = rivet_core::demo_motif();
let bytes = write_performance(&source, &settings, 120.).unwrap();
let bytes = write_performance(&source, &settings, 120., Meter::default()).unwrap();
let file = read(&bytes).unwrap();
assert_eq!(file.tracks.len(), 2);
assert_eq!(file.tracks[0].channels, vec![0]);
@@ -536,11 +554,43 @@ mod tests {
let source = rivet_core::demo_motif();
let mut settings = PerformanceSettings::default();
settings.voices[0].attack_ms = 100;
let direct = read(&write_performance(&source, &settings, 120.).unwrap()).unwrap();
let direct =
read(&write_performance(&source, &settings, 120., Meter::default()).unwrap()).unwrap();
assert_eq!(direct.tracks[0].phrase.notes[0].start, 0);
settings.compensated_export = true;
let compensated = read(&write_performance(&source, &settings, 120.).unwrap()).unwrap();
assert_eq!(compensated.tracks[0].phrase.notes[0].start, BAR - 192);
assert_eq!(compensated.tracks[0].phrase.length, source.length + BAR);
let compensated =
read(&write_performance(&source, &settings, 120., Meter::default()).unwrap()).unwrap();
assert_eq!(
compensated.tracks[0].phrase.notes[0].start,
rivet_core::BAR - 192
);
assert_eq!(
compensated.tracks[0].phrase.length,
source.length + rivet_core::BAR
);
}
#[test]
fn compound_meter_round_trips_and_sets_bar_length() {
let meter = Meter { beats: 6, unit: 8 };
let phrase = Phrase {
length: 2 * meter.bar(),
notes: vec![Note {
start: meter.beat(),
duration: 480,
pitch: 62,
velocity: 80,
..Default::default()
}],
};
let parsed = read(&write(&phrase, 120., meter).unwrap()).unwrap();
assert_eq!(parsed.meter, Some(meter));
assert_eq!(parsed.tracks[0].phrase, phrase);
let three = read(&file(&[
0, 0xff, 0x58, 4, 3, 2, 24, 8, 0, 0x90, 60, 80, 0x83, 0x60, 0x80, 60, 0, 0, 0xff, 0x2f,
0,
]))
.unwrap();
assert_eq!(three.meter, Some(Meter { beats: 3, unit: 4 }));
assert_eq!(three.tracks[0].phrase.length, 3 * PPQ);
}
}
+8 -8
View File
@@ -855,7 +855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2054,7 +2054,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rivet"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"clap-sys",
"nih_plug",
@@ -2067,7 +2067,7 @@ dependencies = [
[[package]]
name = "rivet-app"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"crossbeam-queue",
"rivet-core",
@@ -2077,7 +2077,7 @@ dependencies = [
[[package]]
name = "rivet-core"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -2085,7 +2085,7 @@ dependencies = [
[[package]]
name = "rivet-midi"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"rivet-core",
]
@@ -2143,7 +2143,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2329,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.6",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2617,7 +2617,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "ISC"
-214
View File
@@ -1,214 +0,0 @@
use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE, ROLE, SURFACE};
use rivet_app::{Output, Shared};
use rivet_core::{CreativeMove, PerformanceSettings, Result, RoleMode, Session};
use std::sync::Arc;
pub struct CreativeUi {
gesture_name: String,
branch_name: String,
scope: usize,
save_source: bool,
}
impl Default for CreativeUi {
fn default() -> Self {
Self {
gesture_name: "New gesture".into(),
branch_name: "New idea".into(),
scope: 0,
save_source: true,
}
}
}
fn feedback(state: &mut EditorState, result: Result<String>) {
match result {
Ok(message) => {
state.status = message;
state.error = false;
}
Err(error) => report(state, Err(error)),
}
}
pub fn ideas(
ui: &mut egui::Ui,
shared: &Arc<Shared>,
state: &mut EditorState,
session: &mut Session,
) -> bool {
let mut restored = false;
let busy = shared.busy.load(std::sync::atomic::Ordering::Acquire);
egui::CollapsingHeader::new("Gestures, moves, and branches")
.id_salt("creative-history")
.default_open(!session.gestures.is_empty() || !session.branches.is_empty())
.show(ui, |ui| {
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("Scope");
state.creative.scope = state.creative.scope.min(session.settings.bars as usize / 2);
let selected = if state.creative.scope == 0 { "Whole phrase".into() } else { format!("Bars {}–{}", state.creative.scope * 2 - 1, state.creative.scope * 2) };
egui::ComboBox::from_id_salt("move-scope").selected_text(selected).show_ui(ui, |ui| {
ui.selectable_value(&mut state.creative.scope, 0, "Whole phrase");
for i in 1..=session.settings.bars as usize / 2 {
ui.selectable_value(&mut state.creative.scope, i, format!("Bars {}–{}", i * 2 - 1, i * 2));
}
});
let scope = state.creative.scope.checked_sub(1);
for (label, operation) in [
("Subtract", CreativeMove::Thin), ("Delay 1/16", CreativeMove::Delay),
("Longer gates", CreativeMove::Lengthen), ("Up semitone", CreativeMove::Transpose(1)),
("Down semitone", CreativeMove::Transpose(-1)),
] {
if ui.add_enabled(session.selected_phrase().is_some(), egui::Button::new(label)).clicked() {
feedback(state, session.apply_move(operation, scope).map(|_| "New branch created. Its parent and pinned material are preserved.".into()));
}
}
});
ui.label(egui::RichText::new("Moves respect pins and note locks. Release Keep groove to change timing or articulation.").color(MUTED));
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut state.creative.save_source, "Save source motif");
ui.add(egui::TextEdit::singleline(&mut state.creative.gesture_name).char_limit(64).desired_width(170.));
let section = if state.creative.save_source { None } else { state.creative.scope.checked_sub(1) };
let enabled = session.source.is_some() && (state.creative.save_source || section.is_some() && session.selected_phrase().is_some());
if ui.add_enabled(enabled, egui::Button::new("Save gesture")).clicked() {
let name = state.creative.gesture_name.clone();
feedback(state, session.save_gesture(&name, section).map(|_| "Gesture saved with its sound-role and controller choices.".into()));
}
if ui.add_enabled(!session.gestures.is_empty(), egui::Button::new("Export gestures…")).clicked() {
let saved = session.export_gesture_library();
start(shared, state, move || {
let bytes = saved?;
let Some(mut path) = rivet_app::dialog::choose(true, true)? else { return Ok(Output::Canceled); };
if path.extension().is_none() { path.set_extension("json"); }
rivet_app::write_new(&path, &bytes)?; Ok(Output::Saved(path))
});
}
if ui.button("Import gestures…").clicked() {
start(shared, state, move || {
let Some(path) = rivet_app::dialog::choose(false, true)? else { return Ok(Output::Canceled); };
let bytes = rivet_app::read_limited(&path, 2 * 1024 * 1024)?;
Ok(Output::LibraryLoaded(rivet_core::read_gesture_library(&bytes)?))
});
}
});
if !state.creative.save_source && state.creative.scope == 0 {
ui.label(egui::RichText::new("Choose a two-bar scope to save a developed section.").color(MUTED));
}
let gestures = session.gestures.clone();
for gesture in gestures {
ui.horizontal(|ui| {
ui.label(&gesture.name);
ui.label(egui::RichText::new(format!("{} notes", gesture.phrase.notes.len())).color(MUTED));
if ui.button("Recall as motif").clicked() {
let result = session.recall_gesture(gesture.id);
restored |= result.is_ok();
feedback(state, result.map(|_| "Gesture recalled with its performance choices. Current harmony is retained; preview is stopped.".into()));
}
if ui.small_button("Remove").clicked() { session.gestures.retain(|g| g.id != gesture.id); }
});
}
ui.separator();
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut state.creative.branch_name).char_limit(64).desired_width(190.));
if ui.button("Save branch").clicked() {
let name = state.creative.branch_name.clone();
feedback(state, session.save_branch(&name).map(|_| "Working idea saved as a branch. Future moves can fork from here.".into()));
}
});
let branches = session.branches.clone();
egui::ScrollArea::vertical().id_salt("branch-list").max_height(170.).show(ui, |ui| {
for branch in &branches {
let mut depth = 0; let mut parent = branch.parent;
while let Some(id) = parent {
depth += 1;
parent = branches.iter().find(|b| b.id == id).and_then(|b| b.parent);
if depth >= 16 { break; }
}
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 12.);
ui.label(egui::RichText::new(&branch.name).color(if session.active_branch == Some(branch.id) { LOCK } else { NOTE }));
if ui.button("Restore").clicked() {
let result = session.restore_branch(branch.id);
restored |= result.is_ok();
feedback(state, result.map(|_| "Branch restored. Other branches and gestures remain available.".into()));
}
let leaf = !branches.iter().any(|b| b.parent == Some(branch.id));
if ui.add_enabled(leaf, egui::Button::new("Remove leaf")).clicked() {
feedback(state, session.remove_branch(branch.id).map(|_| "Leaf branch removed; working material is retained.".into()));
}
});
}
});
});
});
restored
}
pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) {
egui::CollapsingHeader::new("Sound roles and expressive gestures").id_salt("performance-settings").default_open(settings.mode != RoleMode::Solo).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt("role-mode").selected_text(settings.mode.name()).show_ui(ui, |ui| {
for mode in RoleMode::ALL { ui.selectable_value(&mut settings.mode, mode, mode.name()); }
});
ui.checkbox(&mut settings.compensated_export, "Export compensation with a one-bar lead-in");
});
ui.label(egui::RichText::new("Assign the channels to separate synth parts. CC curves require explicit macro mapping. Internal preview uses simple reference voices.").color(MUTED));
for role in 0..if settings.mode == RoleMode::Solo { 1 } else { 2 } {
let voice = &mut settings.voices[role];
ui.push_id(role, |ui| {
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.label(if role == 0 { "Primary" } else { "Answer" });
ui.add(egui::TextEdit::singleline(&mut voice.name).char_limit(48).desired_width(130.));
let mut channel = voice.channel as u16 + 1;
ui.label("Channel"); ui.add(egui::DragValue::new(&mut channel).range(1..=16)); voice.channel = (channel - 1) as u8;
ui.label("Transpose"); ui.add(egui::DragValue::new(&mut voice.transpose).range(-24..=24));
ui.label("Gate"); ui.add(egui::DragValue::new(&mut voice.gate).range(10..=200).suffix("%"));
ui.label("Velocity"); ui.add(egui::DragValue::new(&mut voice.velocity).range(1..=200).suffix("%"));
});
ui.horizontal_wrapped(|ui| {
ui.label("Estimated attack"); ui.add(egui::DragValue::new(&mut voice.attack_ms).range(0..=500).suffix(" ms"));
ui.label("Estimated release"); ui.add(egui::DragValue::new(&mut voice.release_ms).range(0..=2000).suffix(" ms"));
ui.label(egui::RichText::new("MIDI audition warms up before beat 1. Re-audition after tempo changes.").color(MUTED));
});
let curve = &mut voice.controller;
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut curve.enabled, "Send controller gesture");
ui.label("CC"); ui.add(egui::DragValue::new(&mut curve.cc).range(1..=119));
ui.label("Stop value"); ui.add(egui::DragValue::new(&mut curve.rest).range(0..=127));
for (index, label) in ["Rise", "Fall", "Arch", "Pulse"].iter().enumerate() {
if ui.button(*label).clicked() { curve.shape(index); }
}
});
if curve.enabled {
let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 58.), egui::Sense::hover());
let painter = ui.painter(); painter.rect_filled(rect, 3., SURFACE);
let points: Vec<_> = curve.points.iter().map(|p| egui::pos2(rect.left() + p.position as f32 / 1000. * rect.width(), rect.bottom() - p.value as f32 / 127. * rect.height())).collect();
for pair in points.windows(2) { painter.line_segment([pair[0],pair[1]], egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { ROLE })); }
egui::ScrollArea::vertical().id_salt("curve-points").max_height(115.).show(ui, |ui| {
let mut remove = None;
for index in 0..curve.points.len() {
let low = if index == 0 { 0 } else { curve.points[index - 1].position + 1 };
let high = curve.points.get(index + 1).map_or(1000, |p| p.position - 1);
let interior = index != 0 && index + 1 != curve.points.len();
let point = &mut curve.points[index];
ui.horizontal(|ui| {
let mut percent = point.position as f64 / 10.;
ui.add_enabled(interior, egui::DragValue::new(&mut percent).range(low as f64/10. ..=high as f64/10.).speed(0.1).suffix("%"));
point.position = (percent * 10.).round() as u16;
ui.label("Value"); ui.add(egui::DragValue::new(&mut point.value).range(0..=127));
if ui.add_enabled(interior, egui::Button::new("Remove point")).clicked() { remove = Some(index); }
});
}
if let Some(index) = remove { curve.points.remove(index); }
});
if ui.add_enabled(curve.points.len()<16, egui::Button::new("Add point in largest gap")).clicked() {
if let Some((index,pair)) = curve.points.windows(2).enumerate().max_by_key(|(_,p)|p[1].position-p[0].position) {
let position = (pair[0].position+pair[1].position)/2;
if position > pair[0].position { let value = curve.value_at(position); curve.points.insert(index+1,rivet_core::CurvePoint {position,value}); }
}
}
}
});
}
});
}
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -178,8 +178,9 @@ impl Plugin for Rivet {
}
impl ClapPlugin for Rivet {
const CLAP_ID: &'static str = "io.storyboard.motif";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Develop a MIDI motif into a connected phrase");
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Tell a MIDI motif as a story: a storyboard of statement, answer, departure and return",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
+173
View File
@@ -0,0 +1,173 @@
//! Sound roles: split a phrase across two synth parts with their own channels and CC gestures.
use super::{egui, MUTED, NOTE, ROLE, SURFACE};
use rivet_core::{PerformanceSettings, RoleMode};
pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt("role-mode")
.selected_text(settings.mode.name())
.show_ui(ui, |ui| {
for mode in RoleMode::ALL {
ui.selectable_value(&mut settings.mode, mode, mode.name());
}
});
ui.checkbox(
&mut settings.compensated_export,
"Export compensation with a one-bar lead-in",
);
});
ui.label(egui::RichText::new("Assign the channels to separate synth parts. CC curves require explicit macro mapping. Internal preview uses simple reference voices.").color(MUTED));
for role in 0..if settings.mode == RoleMode::Solo {
1
} else {
2
} {
let voice = &mut settings.voices[role];
ui.push_id(role, |ui| {
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.label(if role == 0 { "Primary" } else { "Answer" });
ui.add(
egui::TextEdit::singleline(&mut voice.name)
.char_limit(48)
.desired_width(130.),
);
let mut channel = voice.channel as u16 + 1;
ui.label("Channel");
ui.add(egui::DragValue::new(&mut channel).range(1..=16));
voice.channel = (channel - 1) as u8;
ui.label("Transpose");
ui.add(egui::DragValue::new(&mut voice.transpose).range(-24..=24));
ui.label("Gate");
ui.add(
egui::DragValue::new(&mut voice.gate)
.range(10..=200)
.suffix("%"),
);
ui.label("Velocity");
ui.add(
egui::DragValue::new(&mut voice.velocity)
.range(1..=200)
.suffix("%"),
);
});
ui.horizontal_wrapped(|ui| {
ui.label("Estimated attack");
ui.add(
egui::DragValue::new(&mut voice.attack_ms)
.range(0..=500)
.suffix(" ms"),
);
ui.label("Estimated release");
ui.add(
egui::DragValue::new(&mut voice.release_ms)
.range(0..=2000)
.suffix(" ms"),
);
ui.label(
egui::RichText::new(
"MIDI audition warms up before beat 1. Re-audition after tempo changes.",
)
.color(MUTED),
);
});
let curve = &mut voice.controller;
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut curve.enabled, "Send controller gesture");
ui.label("CC");
ui.add(egui::DragValue::new(&mut curve.cc).range(1..=119));
ui.label("Stop value");
ui.add(egui::DragValue::new(&mut curve.rest).range(0..=127));
for (index, label) in ["Rise", "Fall", "Arch", "Pulse"].iter().enumerate() {
if ui.button(*label).clicked() {
curve.shape(index);
}
}
});
if curve.enabled {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 58.),
egui::Sense::hover(),
);
let painter = ui.painter();
painter.rect_filled(rect, 3., SURFACE);
let points: Vec<_> = curve
.points
.iter()
.map(|p| {
egui::pos2(
rect.left() + p.position as f32 / 1000. * rect.width(),
rect.bottom() - p.value as f32 / 127. * rect.height(),
)
})
.collect();
for pair in points.windows(2) {
painter.line_segment(
[pair[0], pair[1]],
egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { ROLE }),
);
}
egui::ScrollArea::vertical()
.id_salt("curve-points")
.max_height(115.)
.show(ui, |ui| {
let mut remove = None;
for index in 0..curve.points.len() {
let low = if index == 0 {
0
} else {
curve.points[index - 1].position + 1
};
let high = curve.points.get(index + 1).map_or(1000, |p| p.position - 1);
let interior = index != 0 && index + 1 != curve.points.len();
let point = &mut curve.points[index];
ui.horizontal(|ui| {
let mut percent = point.position as f64 / 10.;
ui.add_enabled(
interior,
egui::DragValue::new(&mut percent)
.range(low as f64 / 10. ..=high as f64 / 10.)
.speed(0.1)
.suffix("%"),
);
point.position = (percent * 10.).round() as u16;
ui.label("Value");
ui.add(egui::DragValue::new(&mut point.value).range(0..=127));
if ui
.add_enabled(interior, egui::Button::new("Remove point"))
.clicked()
{
remove = Some(index);
}
});
}
if let Some(index) = remove {
curve.points.remove(index);
}
});
if ui
.add_enabled(
curve.points.len() < 16,
egui::Button::new("Add point in largest gap"),
)
.clicked()
{
if let Some((index, pair)) = curve
.points
.windows(2)
.enumerate()
.max_by_key(|(_, p)| p[1].position - p[0].position)
{
let position = (pair[0].position + pair[1].position) / 2;
if position > pair[0].position {
let value = curve.value_at(position);
curve
.points
.insert(index + 1, rivet_core::CurvePoint { position, value });
}
}
}
}
});
}
}
+11 -17
View File
@@ -97,28 +97,22 @@ fn exported_factory_state_and_silent_audio_lifecycle() {
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
let mut session = rivet_core::Session {
source: Some(rivet_core::demo_motif()),
tempo: 150.,
..Default::default()
};
session.source.as_mut().unwrap().notes[1].locked = true;
session.harmony = rivet_core::Harmony::progression(0, rivet_core::Scale::Minor);
let mut session = rivet_core::Session::demo();
session.tempo = 150.;
session.motifs[0].phrase.notes[1].locked = true;
session.settings.meter = rivet_core::Meter { beats: 6, unit: 8 };
session.harmony =
rivet_core::Harmony::progression(0, rivet_core::Scale::Minor, session.settings.meter);
session.audition = rivet_core::Audition::Midi;
session.regenerate().unwrap();
session.pin_selected(1).unwrap();
session.accept();
session.selected = 2;
session.accept();
session.realize_all();
session.blocks[1].choice = 1;
session.blocks[1].kept = true;
session.blocks[2].join = rivet_core::Join::Breath;
session.another_take(3);
session.performance.mode = rivet_core::RoleMode::CallResponse;
session.performance.voices[1].controller.enabled = true;
session.performance.voices[1].controller.cc = 20;
session.performance.voices[1].attack_ms = 75;
session.save_gesture("Airy question", None).unwrap();
session.save_branch("Original call").unwrap();
session
.apply_move(rivet_core::CreativeMove::Thin, Some(0))
.unwrap();
envelope.fields.insert(
"story-session-v1".into(),
String::from_utf8(session.to_json().unwrap()).unwrap(),
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-preview"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
+38 -35
View File
@@ -1,5 +1,5 @@
//! Headless end-to-end development harness; uses exactly the plugin's composition and voice.
use rivet_core::{demo_motif, Harmony, Performance, PerformancePlayer, Session};
use rivet_core::{Harmony, Performance, PerformancePlayer, Session, Template};
use std::{env, fs, io::Write, path::PathBuf};
fn main() {
@@ -11,13 +11,13 @@ fn main() {
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = env::args().skip(1).collect();
if args.is_empty() || args[0] == "--help" {
println!("Rivet preview\n rivet-preview demo <output-directory>\n rivet-preview develop <input.mid> <track-index> <output-directory> [harmony.mid harmony-track-index]\n rivet-preview session <session.json> <output-directory>\nWrites source.mid, up to three distinct candidates, session.json, and selected.wav. Track indices start at 0.");
println!("Rivet preview\n rivet-preview demo <output-directory>\n rivet-preview develop <input.mid> <track-index> <output-directory> [harmony.mid harmony-track-index]\n rivet-preview session <session.json> <output-directory>\nBuilds an arc storyboard from the motif (or uses the saved one) and writes motif.mid, storyboard.mid, session.json, and storyboard.wav. Track indices start at 0.");
return Ok(());
}
let mut session = Session::default();
let output = match args[0].as_str() {
"demo" if args.len() == 2 => {
session.source = Some(demo_motif());
session = Session::demo();
PathBuf::from(&args[1])
}
"session" if args.len() == 3 => {
@@ -25,62 +25,65 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
PathBuf::from(&args[2])
}
"develop" if args.len() == 4 || args.len() == 6 => {
let bytes = fs::read(&args[1])?;
let file = rivet_midi::read(&bytes)?;
let file = rivet_midi::read(&fs::read(&args[1])?)?;
let index: usize = args[2].parse()?;
let track = file.tracks.get(index).ok_or("Track index out of range")?;
track.phrase.validate_source()?;
for w in &track.warnings {
eprintln!("{w}");
}
session.source = Some(track.phrase.clone());
session.settings.meter = file.meter.unwrap_or_default();
if args.len() == 6 {
let file = rivet_midi::read(&fs::read(&args[4])?)?;
let index: usize = args[5].parse()?;
let track = file
.tracks
.get(index)
.ok_or("Harmony track index out of range")?;
session.harmony = Harmony::from_midi(&track.phrase.notes, track.phrase.length)?;
}
let id = session.add_motif(&track.name, track.phrase.clone())?;
session.apply_template(Template::Arc, id)?;
PathBuf::from(&args[3])
}
_ => return Err("Use --help for usage.".into()),
};
if args.len() == 6 {
let file = rivet_midi::read(&fs::read(&args[4])?)?;
let index: usize = args[5].parse()?;
let track = file
.tracks
.get(index)
.ok_or("Harmony track index out of range")?;
session.harmony = Harmony::from_midi(&track.phrase.notes, track.phrase.length)?;
}
if args[0] != "session" {
session.regenerate()?;
session.accept();
}
let selected = session.composed_phrase()?;
let meter = session.settings.meter;
let selected = session.compose()?;
fs::create_dir_all(&output)?;
if let Some(source) = &session.source {
if let Some(motif) = session.motifs.first() {
fs::write(
output.join("source.mid"),
rivet_midi::write(source, session.tempo)?,
output.join("motif.mid"),
rivet_midi::write(&motif.phrase, session.tempo, meter)?,
)?;
}
for (i, c) in session.candidates.iter().enumerate() {
fs::write(
output.join(format!("candidate-{}.mid", i + 1)),
rivet_midi::write_performance(&c.phrase, &session.performance, session.tempo)?,
)?;
println!("{}: {}", c.strategy.name(), c.explanation);
for (i, block) in session.blocks.iter().enumerate() {
let option = block.options.get(block.choice);
println!(
"{:>2}. {:<9} {} bars {:<22} → {}",
i + 1,
block.role.name(),
block.bars,
option.map_or("(empty)", |c| c.strategy.name()),
block.join.name()
);
}
fs::write(output.join("session.json"), session.to_json()?)?;
fs::write(
output.join("selected.mid"),
rivet_midi::write_performance(&selected, &session.performance, session.tempo)?,
output.join("storyboard.mid"),
rivet_midi::write_performance(&selected, &session.performance, session.tempo, meter)?,
)?;
let mut player = PerformancePlayer::default();
player.set_sample_rate(44100.)?;
player
.play(Performance::compile(&selected, &session.performance, session.tempo, false)?.clip()?);
player.play(
Performance::compile(&selected, &session.performance, session.tempo, false, meter)?
.clip()?,
);
let mut samples = Vec::new();
while player.is_playing() {
samples.push((player.next_frame(session.tempo).0 * i16::MAX as f32) as i16);
}
let len = (samples.len() * 2) as u32;
let mut wav = fs::File::create(output.join("selected.wav"))?;
let mut wav = fs::File::create(output.join("storyboard.wav"))?;
wav.write_all(b"RIFF")?;
wav.write_all(&(36 + len).to_le_bytes())?;
wav.write_all(b"WAVEfmt ")?;