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(&current);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}