continue popgen; plays music, not yet saving files
This commit is contained in:
parent
2eefec9b8e
commit
317edda770
@ -11,4 +11,5 @@ path = "src/popgen.rs"
|
|||||||
clap = { version = "4.5.23", features = ["derive"] }
|
clap = { version = "4.5.23", features = ["derive"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
|
rodio = "0.20.1"
|
||||||
|
|
||||||
|
@ -28,6 +28,26 @@ cargo run -- -b 80 --root C[2]
|
|||||||
[popgen.rs](./src/popgen.rs)
|
[popgen.rs](./src/popgen.rs)
|
||||||
|
|
||||||
## Access outputs
|
## Access outputs
|
||||||
|
TODO
|
||||||
|
|
||||||
## Reflections, Results, Analysis
|
## 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:
|
@ -1,6 +1,6 @@
|
|||||||
// "Pop Music Generator" - Rust Edition
|
// "Pop Music Generator" - Rust Edition
|
||||||
// David Westgate 2024
|
// 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
|
// https://github.com/pdx-cs-sound/popgen/blob/main/popgen.py
|
||||||
|
|
||||||
use clap::Parser;
|
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
|
// Given a MIDI key number and an optional number of beats of
|
||||||
// note duration, return a sine wave for that note.
|
// note duration, return a sine wave for that note.
|
||||||
fn make_note(key: i8, n: Option<u8>, beat_samples: u32, samplerate: u16) -> Vec<f32> {
|
fn make_note(key: i8, n: Option<u8>, beat_samples: u32, samplerate: u16) -> Vec<f32> {
|
||||||
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::<Vec<f32>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Given a scale note with root note 0, return a key offset
|
||||||
// from the corresponding root MIDI key.
|
// from the corresponding root MIDI key.
|
||||||
fn note_to_key_offset(note: i8) -> i8 {
|
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
|
// Given a position within a chord, return a scale note
|
||||||
// offset — zero-based.
|
// offset — zero-based.
|
||||||
fn chord_to_note_offset(posn: i8) -> i8 {
|
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] {
|
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::<f32>() > 0.5 {
|
||||||
|
p = p + 1;
|
||||||
|
} else {
|
||||||
|
p = p - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*position = p;
|
||||||
|
notes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turn a note name into a corresponding MIDI key number.
|
// 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
|
// "D" or "Eb[5]". Default is octave 4 if no octave is
|
||||||
// specified.
|
// specified.
|
||||||
fn parse_note(note_str: &str, regex: Regex) -> u32 {
|
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)]
|
#[derive(Parser, Debug)]
|
||||||
@ -87,7 +190,20 @@ struct Args {
|
|||||||
output: String,
|
output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rodio example https://github.com/RustAudio/rodio/blob/master/examples/basic.rs
|
||||||
|
fn play(samples: Vec<f32>) {
|
||||||
|
|
||||||
|
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() {
|
fn main() {
|
||||||
println!("Hello, pop!");
|
println!("Hello, pop!");
|
||||||
@ -113,7 +229,139 @@ fn main() {
|
|||||||
.try_into()
|
.try_into()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
// Samples per beat.
|
||||||
|
let beat_samples: u32 = ((samplerate as f32) / (f32::from(bpm) / 60f32)).round() as u32;
|
||||||
|
|
||||||
|
let mut sound: Vec<f32> = vec![];
|
||||||
|
|
||||||
|
for chord_root in CHORD_LOOP {
|
||||||
|
let notes = pick_notes(chord_root - 1, &mut position, &mut rng);
|
||||||
|
|
||||||
|
let mut melody: Vec<Vec<f32>> = vec![];
|
||||||
|
for note in notes {
|
||||||
|
let melody_note: Vec<f32> = 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<f32> = make_note(bass_note + bass_root, Some(4), beat_samples, samplerate);
|
||||||
|
let bass_gain = 1 - melody_gain;
|
||||||
|
|
||||||
|
// Scale the waveforms
|
||||||
|
let flattened_melody: Vec<f32> = melody.into_iter().flatten().collect();
|
||||||
|
let scaled_melody: Vec<f32> = flattened_melody.iter().map(|&x| x * melody_gain as f32).collect();
|
||||||
|
let scaled_bass: Vec<f32> = 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<f32> = 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<u8>, beat_samples: u32 , samplerate: u16) -> Vec<f64>
|
||||||
|
// 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<u8>, 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user