//! Block-level proposals. Randomness chooses compositional alternatives, not independent pitches. use crate::{ Candidate, Ending, Harmony, Note, Phrase, Result, Role, Settings, Strategy, MAX_PLAYBACK_NOTES, MAX_SOURCE_NOTES, }; struct Random(u64); impl Random { fn next(&mut self) -> u64 { self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15); let mut z = self.0; z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); z ^ (z >> 31) } } fn same_music(a: &Phrase, b: &Phrase) -> bool { a.length == b.length && a.notes.len() == b.notes.len() && a.notes .iter() .zip(&b.notes) .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. 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; for (start, n) in &a { total += if let Some(other) = b.get(start) { (n.pitch.abs_diff(other.pitch) as u32).min(12) * 3 + (n.duration.abs_diff(other.duration) / 60).min(24) } else { 100 }; } total += b.keys().filter(|start| !a.contains_key(start)).count() as u32 * 100; total * 10 / (a.len().max(b.len()).max(1) as u32) } /// 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::ChangedEnding, ], Role::Answer => [ Strategy::CallResponse, Strategy::ChangedEnding, Strategy::DelayedResolution, ], Role::Departure => [ Strategy::LaterPeak, Strategy::NewRhythm, Strategy::FragmentAnswer, ], Role::Return => [ Strategy::HarmonicReturn, Strategy::Quote, Strategy::NegativeSpace, ], Role::Rest => [Strategy::Silence; 3], }; if family == preferred[0] { 300 } else if preferred.contains(&family) { 240 } else { 0 } } 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.", } } /// 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 { 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> { 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 = 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(take.wrapping_mul(1_000_003)) .wrapping_add(position.wrapping_mul(15_485_863)), ); 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 note = if harmony.spans.is_empty() { "" } else { " Strong notes follow the harmony." }; let candidate = Candidate { strategy: family, explanation: format!("{}{note}", explain(family)), phrase, }; if let Some(index) = pool .iter() .position(|c| same_music(&c.phrase, &candidate.phrase)) { if fit(role, family) > fit(role, pool[index].strategy) { pool[index] = candidate; } } else { pool.push(candidate); } } } let mut selected: Vec = Vec::new(); while selected.len() < 3 && !pool.is_empty() { let index = pool .iter() .enumerate() .max_by_key(|(i, c)| { let diversity = selected .iter() .map(|chosen| distance(&c.phrase, &chosen.phrase)) .min() .unwrap_or(0); let new_family = selected.iter().all(|chosen| chosen.strategy != c.strategy); ( fit(role, c.strategy) > 0, new_family, fit(role, c.strategy) + diversity, usize::MAX - *i, ) }) .map(|(i, _)| i) .unwrap(); selected.push(pool.remove(index)); } Ok(selected) } #[allow(clippy::too_many_arguments)] fn develop( motif: &Phrase, mut notes: Vec, length: u32, s: &Settings, harmony: &Harmony, role: Role, family: Strategy, place: Place, rng: &mut Random, ) -> Phrase { 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) }; 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); } } // 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 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; } 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 { 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; } 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; } } Phrase { notes, length } } #[cfg(test)] mod tests { use super::*; use crate::{demo_motif, Meter, BAR}; #[test] fn departure_peak_rises_above_the_motif() { let motif = demo_motif(); let s = Settings { preserve_rhythm: true, ..Settings::default() }; let phrase = develop( &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().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 motif = demo_motif(); let s = Settings { preserve_rhythm: true, preserve_contour: true, ..Settings::default() }; for family in Strategy::ALL { let phrase = develop( &motif, tile(&motif, 2 * BAR), 2 * BAR, &s, &Harmony::default(), Role::Answer, family, Place { offset: 2 * BAR, last: false, }, &mut Random(42), ); 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)); } } } }