diff --git a/code/popgen/Cargo.toml b/code/popgen/Cargo.toml index 9a171b5..c642dc6 100644 --- a/code/popgen/Cargo.toml +++ b/code/popgen/Cargo.toml @@ -11,4 +11,5 @@ path = "src/popgen.rs" clap = { version = "4.5.23", features = ["derive"] } rand = "0.8.5" regex = "1.11.1" +rodio = "0.20.1" diff --git a/code/popgen/README.md b/code/popgen/README.md index 19968c2..4594f2c 100644 --- a/code/popgen/README.md +++ b/code/popgen/README.md @@ -28,6 +28,26 @@ cargo run -- -b 80 --root C[2] [popgen.rs](./src/popgen.rs) ## Access outputs - +TODO ## Reflections, Results, Analysis +The effort involved for this portfolio objective was non trivial. I'll briefy discuss a some of the techniques and challenges involved specific to this implementation in rust. For a "how it works" perspective, I feel the comments copied over from python source and my few additions allow the code to be self documenting + +### External Crates: +* [clap](https://crates.io/crates/clap): In order to not drop any functionality from the original source, I wanted to ensure my application could handle all of the command-line argument as is done in popgen.py. I noticed there, [argparse](https://docs.python.org/3/library/argparse.html) was doing some heaving lifting. I only briefy pondered rolling my own command line parsing, but then I remembered a past experience doing so with much simpler arguments. That made using `clap` a straightforward decision. +* [rand](https://crates.io/crates/rand): Picking the sequence of notes requires some randomness +* [regex](https://crates.io/crates/regex): The go-to for regex, validating and parsing the note from the command line arguments +* [rodio](https://crates.io/crates/rodio): Back in a portfolio objective rodio, cpal, portaudio-rs. I did a little research, and it seemed that rodio was the most abstract/easy to use, but still allowed me to manually set the sample rate as is necessary + +### Typing hell +The most tedious aspect of this objective was dealing with the typecasting needed for integer and float arithmetic. I won't discuss strong typing/rust arguments or any of that, but the crux of issues here came down to a few competing philosophies. +* I want to use the smallest integer units possible to handle the basic parameters. For example, a `u8` should easily contain the `bpm` +* Many parameters can be, and often are negative requiring the use of signed integers such as `melody_root`, `position`, etc. On one hand, I did not realize this in time, so some rework was required. On the other hand, there is a desire to capture initial values as unsigned if that seems appropriate, and cast them only as needed for signed arithmetic (Example: `chord_root` into `bass_note`). +* I want to use the highest precision possible floating point representation for the wave data samples which go to the buffer, to ensure the most accurate audio. I initially chose `f64` for this before realizing that `rodio` seems to only work with `f32`. Aside from that, all sorts of casting was needed up from the base integer units. +* Array indicies must be of type `usize` +While the code here works it containers several occourences of risky `as` casting, and `.try_into().unwrap()` which are not properly handled. As I have time, I will fix some of this stuff up. + +### Testing: +I brought in the provided unit tests for `note_to_key_offset` and `chord_to_note_offset`. These turned out to be rather useful for ensuring my functions of the same name worked correctly. I began adding some tests for `make_notes` and may do the same for other functional units. I could move the tests into another source file, but decided not to, as to better parallel the source material. + +### Results: \ No newline at end of file diff --git a/code/popgen/src/popgen.rs b/code/popgen/src/popgen.rs index 436f4f0..1bd4c74 100644 --- a/code/popgen/src/popgen.rs +++ b/code/popgen/src/popgen.rs @@ -1,6 +1,6 @@ // "Pop Music Generator" - Rust Edition // David Westgate 2024 -// Initial python implementation (and most comments) from Bart Massey +// Initial python implementation, tests and some comments from Bart Massey // https://github.com/pdx-cs-sound/popgen/blob/main/popgen.py use clap::Parser; @@ -32,26 +32,85 @@ const CHORD_LOOP: [u8; 4] = [8, 5, 6, 4]; // Given a MIDI key number and an optional number of beats of // note duration, return a sine wave for that note. fn make_note(key: i8, n: Option, beat_samples: u32, samplerate: u16) -> Vec { - todo!() + let num_beats: f32 = n.unwrap_or(1).try_into().unwrap(); + let samplerate_f: f32 = samplerate.try_into().unwrap(); + let beatsamples_f: f32 = beat_samples as f32; + let key_f: f32 = key.try_into().unwrap(); + let f: f32 = 440f32 * (2f32).powf((key_f - 69f32) / 12f32); + let b: u32 = (beat_samples as u32) * (n.unwrap_or(1) as u32); + let b_f: f32 = beatsamples_f * num_beats; + + let cycles: f32 = 2f32 * PI * f * b_f / samplerate_f; + (0..b) + .map(|v: u32| ((v as f32) / b_f * cycles).sin()) + .collect::>() } +// Floor division of possibly negative values (GPT generated) +// Intended to match pythons // operator +fn floor_div(a: i8, b: i8) -> i8 { + let div = a / b; + let rem = a % b; + if rem != 0 && (a < 0) != (b < 0) { + div - 1 + } else { + div + } +} +// modulus of possibly negative value (GPT generated) +fn safe_mod_index(value: i8, modulus: usize) -> usize { + assert!(modulus > 0, "Modulus must be positive"); + + // Compute the modulo + let remainder = value % modulus as i8; + + // Adjust for negative remainders + let adjusted = if remainder < 0 { + remainder + modulus as i8 + } else { + remainder + }; + + // Convert to usize + adjusted as usize +} // Given a scale note with root note 0, return a key offset // from the corresponding root MIDI key. fn note_to_key_offset(note: i8) -> i8 { - todo!() + let scale_degree: usize = safe_mod_index(note, 7); + let major_scale = MAJOR_SCALE[scale_degree]; + (floor_div(note, 7) * 12) + major_scale as i8 } // Given a position within a chord, return a scale note // offset — zero-based. fn chord_to_note_offset(posn: i8) -> i8 { - todo!() + let chord_posn: usize = safe_mod_index(posn, 3); + let major_chord = MAJOR_CHORD[chord_posn]; + floor_div(posn, 3) * 7 + (major_chord as i8) - 1 } fn pick_notes(chord_root: u8, position: &mut i8, rng: &mut rand::prelude::ThreadRng) -> [i8; 4] { - todo!() + let mut notes: [i8; 4] = [0; 4]; //Vec would work too, but less memory efficient + + let mut p: i8 = *position; + + for note in &mut notes { + let chord_note_offset: i8 = chord_to_note_offset(p); + let chord_note: i8 = note_to_key_offset((chord_root as i8) + chord_note_offset); + *note = chord_note; + if rng.gen::() > 0.5 { + p = p + 1; + } else { + p = p - 1; + } + } + + *position = p; + notes } // Turn a note name into a corresponding MIDI key number. @@ -59,7 +118,51 @@ fn pick_notes(chord_root: u8, position: &mut i8, rng: &mut rand::prelude::Thread // "D" or "Eb[5]". Default is octave 4 if no octave is // specified. fn parse_note(note_str: &str, regex: Regex) -> u32 { - todo!() + let mut octave: u32 = 4; + match regex.find(note_str) { + Some(_) => {} + None => { + eprintln!("Invalid Note: {}", note_str); + std::process::exit(1) + } + }; + let chars: std::str::Chars<'_> = note_str.chars(); + let mut it: std::str::Chars<'_> = chars.into_iter(); + let base_note: char = it.next().unwrap(); + let mut flat: bool = false; + match it.next() { + Some(c) => { + if c == 'b' { + flat = true; + match it.next() { + Some(c) => { + if c == '[' { + octave = it.next().unwrap().to_digit(10).unwrap(); + } else { + eprintln!("Invalid Notation: {}", note_str); + std::process::exit(1) + } + } + None => {} + } + } else if c == '[' { + octave = it.next().unwrap().to_digit(10).unwrap(); + } else { + eprintln!("Invalid Notation: {}", note_str); + std::process::exit(1) + } + } + None => {} + } + + let index: usize = match flat { + true => NAMES.iter().position(|&n| n == format!("{}b", base_note)), + false => NAMES.iter().position(|&n| n == base_note.to_string()), + } + .unwrap(); + let index_32: u32 = index as u32; + let r: u32 = index_32 + (12 * octave); + return r; } #[derive(Parser, Debug)] @@ -87,7 +190,20 @@ struct Args { output: String, } +// Rodio example https://github.com/RustAudio/rodio/blob/master/examples/basic.rs +fn play(samples: Vec) { + let (_stream, stream_handle) = OutputStream::try_default().unwrap(); + let source = SamplesBuffer::new(1,48_000, samples); + + let sink = Sink::try_new(&stream_handle).unwrap(); + + sink.append(source); + + sink.sleep_until_end(); + + +} fn main() { println!("Hello, pop!"); @@ -113,7 +229,139 @@ fn main() { .try_into() .unwrap(); - - + // Samples per beat. + let beat_samples: u32 = ((samplerate as f32) / (f32::from(bpm) / 60f32)).round() as u32; + + let mut sound: Vec = vec![]; + + for chord_root in CHORD_LOOP { + let notes = pick_notes(chord_root - 1, &mut position, &mut rng); + + let mut melody: Vec> = vec![]; + for note in notes { + let melody_note: Vec = make_note(note +melody_root_i8 , None, beat_samples, samplerate); + melody.push(melody_note); + } + + let bass_note: i8 = note_to_key_offset((chord_root - 1) as i8); + let bass: Vec = make_note(bass_note + bass_root, Some(4), beat_samples, samplerate); + let bass_gain = 1 - melody_gain; + + // Scale the waveforms + let flattened_melody: Vec = melody.into_iter().flatten().collect(); + let scaled_melody: Vec = flattened_melody.iter().map(|&x| x * melody_gain as f32).collect(); + let scaled_bass: Vec = bass.iter().map(|&x| x * bass_gain as f32).collect(); + + let paired_notes: Vec<(f32, f32)> = scaled_melody.iter().zip(scaled_bass.iter()).map(|(&m, &b)| (m, b)).collect(); + + let combined_waveform: Vec = paired_notes.iter().map(|(m, b)| m + b).collect(); + + sound.extend(combined_waveform); + + } + if args.output.eq(NO_OUTPUT) { + play(sound); + } + else { + todo!() + } } +#[test] +fn test() { + let note_tests: [(i8, i8); 11] = [ + (-9, -15), + (-8, -13), + (-7, -12), + (-6, -10), + (-2, -3), + (-1, -1), + (0, 0), + (6, 11), + (7, 12), + (8, 14), + (9, 16), + ]; + + for (n, k) in note_tests { + let k0 = note_to_key_offset(n); + assert!( + k0 == k, + "note_to_key_offset: for note {}, expected offet {}. Got offset {}", + n, + k, + k0 + ); + } + + let chord_tests: [(i8, i8); 8] = [ + (-3, -7), + (-2, -5), + (-1, -3), + (0, 0), + (1, 2), + (2, 4), + (3, 7), + (4, 9), + ]; + + for (n, c) in chord_tests { + let c0 = chord_to_note_offset(n); + assert!( + c0 == c, + "chord_to_note_offset: for chord posn {}, expected scale note {}. Got scale note {}", + n, + c, + c0 + ) + } + + // Random selction of notes to test parse + let parsed_notes_tests = [ + ("Db", 49), + ("Db[4]", 49), + ("C", 48), + ("C[5]", 60), + ("A[1]", 21), + ("F[7]", 89), + ("Gb[3]", 42), + ("Gb", 54), + ("G[1]", 19), + ]; + let note_name_regexp: Regex = Regex::new(REGEX_BASE).unwrap(); + for (n, v) in parsed_notes_tests { + let v0 = parse_note(n, note_name_regexp.clone()); + assert!(v0 == v, "parse note: {}, expected {}. Got {}", n, v, v0) + } + + // Random collection of some spot checks relative to make_note frequency generations + // make_note(key: i8, n: Option, beat_samples: u32 , samplerate: u16) -> Vec + // TODO: + // 1) More tests, with a variety of all parameters + // 2) Improve some sort of rounding/truncating with the long floats + let make_notes_tests: [(i8, Option, u32, u16, [f32; 4]); 1] = [( + 48, + Some(1), + 288000, + 48_000, + [ + 0.0, + 0.017122516431822686, + 0.034240012506541136, + 0.05134746933903016, + ], + )]; + for (i, (key, n, beat_samples, samplerate, results)) in make_notes_tests.iter().enumerate() { + let wave = make_note(*key, *n, *beat_samples, *samplerate); + for (j, expected_sample) in results.iter().enumerate() { + let result_sample = wave.get(j).unwrap(); + assert!( + *expected_sample == *result_sample, + "Make note: {:?}, expected {}. Got {}", + make_notes_tests[i], + *expected_sample, + *result_sample + ) + } + } +}