music.rs
8.8 kB · rust · 277 lines
1use crate::config::{FPS, FREEZE_US, HEATMAP_FPS, INTER_FREEZE_US};2use crate::Way;3use mrlycore::state::{boolean, choice};4use mrlymusic::audio::{Timbre, Wave, MAJOR, ROOT};5use mrlymusic::music::{self, ChordType, Movement, Voice};67/// The twelve-bar blues progression every Conway segment plays.8pub const BLUES: &str = "CCCCFFCCGFCG";910const BAR: usize = 4;11const PEAK: f32 = 1.0;1213/// One segment's musical styling: progression, voices and timbre.14#[derive(Clone, Debug)]15pub struct Score {16 /// The chord letters, one beat each.17 pub progression: String,18 /// The voices walking the progression.19 pub voices: Vec<Voice>,20 /// The timbre every voice renders with.21 pub timbre: Timbre,22}2324fn octave(offset: i64) -> Vec<i64> {25 MAJOR.iter().map(|i| ROOT + i + offset).collect()26}2728fn build_voices() -> Vec<Voice> {29 let lows = octave(0);30 let mids = octave(12);31 let highs = octave(24);32 let mut bass = Voice::new(33 if boolean() {34 lows.clone()35 } else {36 [lows, mids.clone()].concat()37 },38 vec![Movement::Repeat, Movement::Up, Movement::Down],39 );40 bass.chord_type = Some(choice(&[ChordType::Triad, ChordType::Seventh]));41 let mut rhythm = Voice::new(42 if boolean() {43 mids.clone()44 } else {45 [mids, highs.clone()].concat()46 },47 vec![Movement::Repeat, Movement::Random],48 );49 rhythm.chord_type = Some(choice(&[ChordType::Triad, ChordType::Seventh]));50 rhythm.num_notes = vec![2, 3];51 let mut voices = vec![bass, rhythm];52 if boolean() {53 voices.push(Voice::new(54 highs,55 vec![56 Movement::Repeat,57 Movement::Random,58 Movement::Up,59 Movement::Down,60 Movement::Pause,61 ],62 ));63 }64 voices65}6667fn build_progression(way: Way) -> String {68 if way == Way::Conway {69 return BLUES.to_string();70 }71 let rhythms = [vec![8], vec![4, 4], vec![2, 2, 2, 2], vec![1; 8]];72 let rhythm = choice(&rhythms);73 let letters = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];74 let chords: Vec<char> = (0..rhythm.len()).map(|_| choice(&letters)).collect();75 let mut out = String::new();76 for (i, beats) in rhythm.iter().enumerate() {77 for _ in 0..*beats {78 out.push(chords[i]);79 }80 }81 out82}8384/// Draws a segment's score under its way: the blues under Conway, a drawn rhythm otherwise.85pub fn score(way: Way) -> Score {86 let progression = build_progression(way);87 let voices = build_voices();88 let shape = choice(&[Wave::Sine, Wave::Triangle]);89 let harmonics = choice(&[1usize, 3]);90 Score {91 progression,92 voices,93 timbre: Timbre::new(shape, Wave::Triangle, harmonics),94 }95}9697/// Times a chord track with one beat per chord and freezes on the held first and last chords.98pub fn assemble(track: &[Vec<i64>], beat: f32, open: f32, close: f32) -> Vec<(Vec<i64>, f32)> {99 let Some(first) = track.first() else {100 return Vec::new();101 };102 let mut timed = Vec::with_capacity(track.len() + 2);103 if open > 0.0 {104 timed.push((first.clone(), open));105 }106 for chord in track {107 timed.push((chord.clone(), beat));108 }109 if close > 0.0 {110 timed.push((track.last().expect("a first implies a last").clone(), close));111 }112 timed113}114115/// Steps the opening chord through a three-octave pool and returns the reversed trail.116pub fn heatmap_track(first: &[i64], count: usize) -> Vec<Vec<i64>> {117 if count == 0 {118 return Vec::new();119 }120 let mut pool = Vec::new();121 for offset in [0, 12, 24] {122 pool.extend(octave(offset));123 }124 let step: i64 = if choice(&["up", "down"]) == "up" {125 -1126 } else {127 1128 };129 let shift = |notes: &[i64]| -> Vec<i64> {130 notes131 .iter()132 .map(|n| {133 let at = pool.iter().position(|p| p == n).unwrap_or(0) as i64;134 pool[(at + step).rem_euclid(pool.len() as i64) as usize]135 })136 .collect()137 };138 let mut current = shift(first);139 let mut track = vec![current.clone()];140 for _ in 1..count {141 current = shift(¤t);142 track.push(current.clone());143 }144 track.reverse();145 track146}147148/// Splits the halved frame budget across segments by cumulative halving, without drift.149pub fn halve(lengths: &[usize]) -> Vec<usize> {150 let mut out = Vec::with_capacity(lengths.len());151 let mut frames = 0;152 let mut halved = 0;153 for &length in lengths {154 frames += length;155 let expected = frames / 2;156 out.push(expected - halved);157 halved = expected;158 }159 out160}161162fn seconds(us: u64) -> f32 {163 us as f32 / 1_000_000.0164}165166/// Renders the quest's two-part audio: the frames half, then the heatmap half.167pub fn soundtrack(scores: &[Score], lengths: &[usize]) -> Vec<f32> {168 let heat_lengths = halve(lengths);169 let last = scores.len().saturating_sub(1);170 let mut frames_half = Vec::new();171 let mut heat_half = Vec::new();172 for (i, (score, &count)) in scores.iter().zip(lengths).enumerate() {173 let track = music::compose(&score.progression, &score.voices, BAR, count, false);174 let open = if i == 0 { seconds(FREEZE_US) } else { 0.0 };175 let close = if i == last {176 seconds(FREEZE_US)177 } else {178 seconds(INTER_FREEZE_US)179 };180 let timed = assemble(&track, 1.0 / FPS as f32, open, close);181 frames_half.extend(music::track(&timed, &score.timbre, PEAK));182 let first = track.first().cloned().unwrap_or_default();183 let heat = heatmap_track(&first, heat_lengths[i]);184 let heat_timed = assemble(&heat, 2.0 / HEATMAP_FPS as f32, open, close);185 heat_half.extend(music::track(&heat_timed, &score.timbre, PEAK));186 }187 frames_half.extend(heat_half);188 frames_half189}190191#[cfg(test)]192mod tests {193 use super::*;194 use mrlycore::state::{guard, seed};195 #[test]196 fn conway_plays_the_blues() {197 let _g = guard();198 seed(1);199 assert_eq!(score(Way::Conway).progression, BLUES);200 }201 #[test]202 fn mrly_draws_an_eight_beat_progression() {203 let _g = guard();204 for s in 0..20 {205 seed(s);206 let drawn = score(Way::Mrly);207 assert_eq!(drawn.progression.len(), 8);208 assert!(drawn.progression.chars().all(|c| "CDEFGAB".contains(c)));209 }210 }211 #[test]212 fn conway_voices_hold_the_chord_and_note_shapes() {213 let _g = guard();214 for s in 0..20 {215 seed(s);216 let drawn = score(Way::Conway);217 assert!(drawn.voices.len() == 2 || drawn.voices.len() == 3);218 assert!(drawn.voices[0].chord_type.is_some());219 assert_eq!(drawn.voices[1].num_notes, vec![2, 3]);220 assert!(drawn.timbre.harmonics == 1 || drawn.timbre.harmonics == 3);221 }222 }223 #[test]224 fn assembly_freezes_the_held_ends() {225 let track = vec![vec![60], vec![62], vec![64]];226 let timed = assemble(&track, 0.125, 0.5, 0.5);227 assert_eq!(timed.len(), 5);228 assert_eq!(timed[0], (vec![60], 0.5));229 assert_eq!(timed[4], (vec![64], 0.5));230 let bare = assemble(&track, 0.125, 0.0, 0.5);231 assert_eq!(bare.len(), 4);232 assert!(assemble(&[], 0.125, 0.5, 0.5).is_empty());233 }234 #[test]235 fn the_heatmap_trail_reverses_a_steady_walk() {236 let _g = guard();237 seed(5);238 let trail = heatmap_track(&[ROOT, ROOT + 12], 6);239 assert_eq!(trail.len(), 6);240 let pool: Vec<i64> = [0, 12, 24]241 .iter()242 .flat_map(|&o| MAJOR.iter().map(move |i| ROOT + i + o))243 .collect();244 let places: Vec<usize> = trail245 .iter()246 .map(|chord| pool.iter().position(|p| *p == chord[0]).unwrap())247 .collect();248 let len = pool.len();249 for pair in places.windows(2) {250 let up = (pair[0] + 1) % len == pair[1];251 let down = (pair[1] + 1) % len == pair[0];252 assert!(up || down);253 }254 assert!(heatmap_track(&[ROOT], 0).is_empty());255 }256 #[test]257 fn halving_splits_without_drift() {258 assert_eq!(halve(&[8, 8, 8]), vec![4, 4, 4]);259 assert_eq!(halve(&[5, 5]), vec![2, 3]);260 assert_eq!(halve(&[1, 1, 1]), vec![0, 1, 0]);261 assert_eq!(halve(&[]), Vec::<usize>::new());262 }263 #[test]264 fn the_soundtrack_replays_and_carries_sound() {265 let _g = guard();266 seed(9);267 let scores = vec![score(Way::Conway), score(Way::Mrly)];268 seed(11);269 let a = soundtrack(&scores, &[4, 4]);270 seed(11);271 let b = soundtrack(&scores, &[4, 4]);272 assert_eq!(a, b);273 assert!(!a.is_empty());274 assert!(a.iter().any(|s| s.abs() > 0.1));275 assert!(a.iter().all(|s| s.abs() <= 1.0));276 }277}