feat(rivet): storyboard composition with flexible lengths and meters
Check and Windows preview / check (patina) (push) Successful in 3m59s
Check and Windows preview / check (rivet) (push) Successful in 2m2s
Check and Windows preview / previews (push) Successful in 1m8s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m41s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 9s
Check and Windows preview / check (patina) (push) Successful in 3m59s
Check and Windows preview / check (rivet) (push) Successful in 2m2s
Check and Windows preview / previews (push) Successful in 1m8s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m41s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 9s
Rivet 0.4.0 tells a motif as a story. A timeline of blocks with roles (statement, answer, departure, return, rest) replaces tasks, pins and branches. Rivet writes each block from a motif and joins the seams with a cut, breath, pickup or approach. - Motifs of 1-8 bars in a bin of 16; storyboards up to 32 bars. - Meters 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 and 12/8 for import, writing, audition and export. Phrygian, harmonic minor and Mixolydian scales. - Per-block options, Another take and Keep; templates for new boards. - Editor rebuilt: motif bin, timeline, large piano roll, inspector, tabs for generation, harmony and sound roles, 32-step undo/redo. - Sessions from 0.1-0.3 open as a storyboard from their source motif.
This commit is contained in:
1 parent
abd37fd4fe
commit
f1366280ff
37 files changed
+3763
-2549
No files matched your search
@@ -1,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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user