audio.rs
16.1 kB · rust · 488 lines
1use mrlycore::rng::Rng;2use mrlycore::trig;3use mrlycore::{json, Json};45/// The home midi note, G2.6pub const ROOT: i64 = 43;78/// The major scale as semitone offsets from a root.9pub const MAJOR: [i64; 7] = [0, 2, 4, 5, 7, 9, 11];1011/// The twelve pitch class names, C first.12pub const NOTES: [&str; 12] = [13 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",14];1516/// The frequency of every midi note in millihertz, note 69 at 440000.17pub const MILLIHERTZ: [i64; 128] = [18 8176, 8662, 9177, 9723, 10301, 10913, 11562, 12250, 12978, 13750, 14568, 15434, 16352, 17324,19 18354, 19445, 20602, 21827, 23125, 24500, 25957, 27500, 29135, 30868, 32703, 34648, 36708,20 38891, 41203, 43654, 46249, 48999, 51913, 55000, 58270, 61735, 65406, 69296, 73416, 77782,21 82407, 87307, 92499, 97999, 103826, 110000, 116541, 123471, 130813, 138591, 146832, 155563,22 164814, 174614, 184997, 195998, 207652, 220000, 233082, 246942, 261626, 277183, 293665, 311127,23 329628, 349228, 369994, 391995, 415305, 440000, 466164, 493883, 523251, 554365, 587330, 622254,24 659255, 698456, 739989, 783991, 830609, 880000, 932328, 987767, 1046502, 1108731, 1174659,25 1244508, 1318510, 1396913, 1479978, 1567982, 1661219, 1760000, 1864655, 1975533, 2093005,26 2217461, 2349318, 2489016, 2637020, 2793826, 2959955, 3135963, 3322438, 3520000, 3729310,27 3951066, 4186009, 4434922, 4698636, 4978032, 5274041, 5587652, 5919911, 6271927, 6644875,28 7040000, 7458620, 7902133, 8372018, 8869844, 9397273, 9956063, 10548082, 11175303, 11839822,29 12543854,30];3132/// The four wave names.33pub const WAVES: [&str; 4] = ["sine", "triangle", "square", "sawtooth"];3435/// The sample rate in hertz.36pub const RATE: usize = 44100;3738/// The render volume as a percentage of full scale.39pub const VOLUME: i64 = 30;4041/// The number of harmonics summed per wave.42pub const VOICES: usize = 16;4344/// The sample count of a single-cycle wavetable.45pub const CYCLE: usize = 1024;4647/// The fade length at each end of a note, in seconds.48pub const FADE: f32 = 1.0 / 64.0;4950const PEAK: f32 = VOLUME as f32 / 100.0;5152const MILLI: f32 = 1000.0;5354/// The four waveform shapes.55#[derive(Clone, Copy, Debug, PartialEq)]56pub enum Wave {57 /// The fundamental alone.58 Sine,59 /// Odd harmonics fading as one over n squared.60 Triangle,61 /// Odd harmonics fading as one over n.62 Square,63 /// All harmonics fading as one over n.64 Sawtooth,65}6667impl Wave {68 /// Returns the wave one of the four names spells, or None for a stranger.69 pub fn parse(name: &str) -> Option<Wave> {70 match name {71 "sine" => Some(Wave::Sine),72 "triangle" => Some(Wave::Triangle),73 "square" => Some(Wave::Square),74 "sawtooth" => Some(Wave::Sawtooth),75 _ => None,76 }77 }78 /// Returns the wave's lowercase name.79 pub fn name(&self) -> &'static str {80 match self {81 Wave::Sine => "sine",82 Wave::Triangle => "triangle",83 Wave::Square => "square",84 Wave::Sawtooth => "sawtooth",85 }86 }87 /// Returns the wave's amplitude at a phase measured in turns, wrapping whole turns away.88 pub fn sample(&self, phase: f32) -> f32 {89 let t = phase - phase.floor();90 match self {91 Wave::Sine => ring(t),92 Wave::Triangle => 2.0 * (2.0 * (t - (t + 0.5).floor())).abs() - 1.0,93 Wave::Square => {94 let s = ring(t);95 if s > 0.0 {96 1.097 } else if s < 0.0 {98 -1.099 } else {100 0.0101 }102 }103 Wave::Sawtooth => 2.0 * (t - (t + 0.5).floor()),104 }105 }106 /// Returns the wave's additive recipe as pairs of harmonic multiple and weight.107 pub fn recipe(&self, voices: usize) -> Vec<(f32, f32)> {108 match self {109 Wave::Sine => vec![(1.0, 1.0)],110 Wave::Square => odds(voices).map(|n| (n, 1.0 / n)).collect(),111 Wave::Triangle => odds(voices).map(|n| (n, 1.0 / (n * n))).collect(),112 Wave::Sawtooth => (1..=voices).map(|i| (i as f32, 1.0 / i as f32)).collect(),113 }114 }115}116117/// One tone to render: a pitch, a shape, and a length.118pub struct Note {119 /// The midi note number.120 pub midi: i64,121 /// The waveform.122 pub wave: Wave,123 /// The duration in seconds.124 pub seconds: f32,125}126127impl Note {128 /// Builds a note from pitch, wave, and duration.129 pub fn new(midi: i64, wave: Wave, seconds: f32) -> Note {130 Note {131 midi,132 wave,133 seconds,134 }135 }136}137138/// A two-axis timbre: the shape each partial is drawn with, and the series that weights them.139#[derive(Clone, Copy, Debug, PartialEq)]140pub struct Timbre {141 /// The waveform each partial is drawn with.142 pub shape: Wave,143 /// The wave whose recipe picks the partials and their weights.144 pub series: Wave,145 /// The number of harmonics summed from the series.146 pub harmonics: usize,147}148149impl Timbre {150 /// Builds a timbre from shape, series, and harmonic count.151 pub fn new(shape: Wave, series: Wave, harmonics: usize) -> Timbre {152 Timbre {153 shape,154 series,155 harmonics,156 }157 }158}159160/// Returns a midi note's frequency in millihertz, clamped to the keyboard.161///162/// ```163/// assert_eq!(mrlymusic::audio::freq(69), 440_000);164/// ```165pub fn freq(midi: i64) -> i64 {166 MILLIHERTZ[midi.clamp(0, 127) as usize]167}168169/// Returns a midi note's name, class then octave, like A4 for 69.170pub fn name(midi: i64) -> String {171 format!(172 "{}{}",173 NOTES[midi.rem_euclid(12) as usize],174 midi.div_euclid(12) - 1175 )176}177178/// Returns the pitch class index of a note name, or None for a stranger.179pub fn class(name: &str) -> Option<i64> {180 NOTES.iter().position(|&n| n == name).map(|i| i as i64)181}182183/// Draws a scale degree from the rng, lifted up to octaves above the root.184pub fn pick(rng: &mut Rng, root: i64, scale: &[i64], octaves: i64) -> i64 {185 let degree = *rng.choice(scale);186 root + 12 * rng.range(0, octaves) + degree187}188189/// Renders a note to float samples, peaking at the volume and faded at both ends.190pub fn render(note: &Note) -> Vec<f32> {191 let mut out = partials(note.midi, Wave::Sine, note.wave, VOICES, note.seconds);192 let peak = out.iter().fold(0.0f32, |m, s| m.max(s.abs()));193 if peak > 0.0 {194 let k = PEAK / peak;195 for s in out.iter_mut() {196 *s *= k;197 }198 }199 let count = out.len();200 let ramp = ((FADE * RATE as f32) as usize).min(count / 2);201 for i in 0..ramp {202 let g = i as f32 / ramp as f32;203 out[i] *= g;204 out[count - 1 - i] *= g;205 }206 out207}208209/// Renders a midi note through a timbre to unit-peak float samples, with no fades.210pub fn tone(midi: i64, timbre: &Timbre, seconds: f32) -> Vec<f32> {211 let mut out = partials(midi, timbre.shape, timbre.series, timbre.harmonics, seconds);212 let peak = out.iter().fold(0.0f32, |m, s| m.max(s.abs()));213 if peak > 0.0 {214 for s in out.iter_mut() {215 *s /= peak;216 }217 }218 out219}220221/// Clamps float samples into 16-bit pcm.222pub fn pcm(samples: &[f32]) -> Vec<i16> {223 samples224 .iter()225 .map(|s| (s.clamp(-1.0, 1.0) * 32767.0) as i16)226 .collect()227}228229/// Builds a unit-peak single-cycle wavetable of the wave at a pitch, muting harmonics above Nyquist.230pub fn cycle(wave: &Wave, hz: f32) -> Vec<f32> {231 let mut out = vec![0.0f32; CYCLE];232 for (mult, weight) in wave.recipe(VOICES) {233 if hz * mult * 2.0 >= RATE as f32 {234 continue;235 }236 for (i, s) in out.iter_mut().enumerate() {237 *s += weight * Wave::Sine.sample(mult * i as f32 / CYCLE as f32);238 }239 }240 let peak = out.iter().fold(0.0f32, |m, s| m.max(s.abs()));241 if peak > 0.0 {242 for s in out.iter_mut() {243 *s /= peak;244 }245 }246 out247}248249/// Returns a named sound cue as a note op, falling back to the blip.250pub fn cue(name: &str) -> Json {251 let (offset, ms, gain) = match name {252 "good" => (31, 140, 30),253 "bad" => (13, 160, 30),254 "win" => (36, 320, 30),255 "lose" => (5, 380, 30),256 _ => (24, 90, 25),257 };258 json!({ "op": "note", "freq": freq(ROOT + offset), "ms": ms, "gain": gain })259}260261fn partials(midi: i64, shape: Wave, series: Wave, harmonics: usize, seconds: f32) -> Vec<f32> {262 let base = freq(midi) as f32 / MILLI;263 let count = (seconds * RATE as f32) as usize;264 let mut out = vec![0.0f32; count];265 for (mult, weight) in series.recipe(harmonics) {266 let pitch = base * mult;267 if pitch * 2.0 >= RATE as f32 {268 continue;269 }270 let step = pitch / RATE as f32;271 let mut phase = 0.0f32;272 for s in out.iter_mut() {273 *s += weight * shape.sample(phase);274 phase += step;275 if phase >= 1.0 {276 phase -= 1.0;277 }278 }279 }280 out281}282283fn odds(voices: usize) -> impl Iterator<Item = f32> {284 (0..voices).map(|i| (2 * i + 1) as f32)285}286287fn ring(t: f32) -> f32 {288 let x = t * trig::N as f32;289 let i = x.floor();290 let frac = x - i;291 let a = trig::SINE[(i as usize) % trig::N];292 let b = trig::SINE[(i as usize + 1) % trig::N];293 a + (b - a) * frac294}295296#[cfg(test)]297mod tests {298 use super::*;299300 #[test]301 fn parse_roundtrips_the_names() {302 for name in WAVES {303 assert_eq!(Wave::parse(name).unwrap().name(), name);304 }305 assert_eq!(Wave::parse("noise"), None);306 }307 #[test]308 fn samples_hit_the_landmarks() {309 assert!(Wave::Sine.sample(0.0).abs() < 1e-6);310 assert!((Wave::Sine.sample(0.25) - 1.0).abs() < 1e-4);311 assert!((Wave::Sine.sample(0.75) + 1.0).abs() < 1e-4);312 assert_eq!(Wave::Triangle.sample(0.0), -1.0);313 assert_eq!(Wave::Triangle.sample(0.5), 1.0);314 assert_eq!(Wave::Square.sample(0.25), 1.0);315 assert_eq!(Wave::Square.sample(0.75), -1.0);316 assert_eq!(Wave::Sawtooth.sample(0.25), 0.5);317 assert_eq!(Wave::Sawtooth.sample(0.75), -0.5);318 }319 #[test]320 fn samples_wrap_whole_turns() {321 for wave in [Wave::Sine, Wave::Triangle, Wave::Square, Wave::Sawtooth] {322 assert_eq!(wave.sample(0.25), wave.sample(3.25));323 }324 }325 #[test]326 fn recipes_carry_the_classic_weights() {327 assert_eq!(Wave::Sine.recipe(8), vec![(1.0, 1.0)]);328 assert_eq!(329 Wave::Square.recipe(3),330 vec![(1.0, 1.0), (3.0, 1.0 / 3.0), (5.0, 0.2)]331 );332 assert_eq!(333 Wave::Triangle.recipe(3),334 vec![(1.0, 1.0), (3.0, 1.0 / 9.0), (5.0, 1.0 / 25.0)]335 );336 assert_eq!(337 Wave::Sawtooth.recipe(3),338 vec![(1.0, 1.0), (2.0, 0.5), (3.0, 1.0 / 3.0)]339 );340 }341 #[test]342 fn freq_lands_the_tuning_fork() {343 assert_eq!(freq(69), 440_000);344 assert_eq!(freq(57), 220_000);345 assert_eq!(freq(81), 880_000);346 assert_eq!(freq(67), 391_995);347 assert_eq!(freq(43), 97_999);348 }349 #[test]350 fn freq_clamps_outside_the_keyboard() {351 assert_eq!(freq(-4), MILLIHERTZ[0]);352 assert_eq!(freq(900), MILLIHERTZ[127]);353 }354 #[test]355 fn octaves_double_the_millihertz() {356 for midi in 0..116 {357 let low = freq(midi);358 let high = freq(midi + 12);359 assert!((high - 2 * low).abs() <= 1, "midi {midi}: {low} {high}");360 }361 }362 #[test]363 fn names_roundtrip_the_classes() {364 assert_eq!(name(43), "G2");365 assert_eq!(name(60), "C4");366 assert_eq!(name(69), "A4");367 assert_eq!(class("C"), Some(0));368 assert_eq!(class("G"), Some(7));369 assert_eq!(class("H"), None);370 for (i, n) in NOTES.iter().enumerate() {371 assert_eq!(class(n), Some(i as i64));372 }373 }374 #[test]375 fn pick_is_seeded_and_in_range() {376 let mut a = Rng::new(7);377 let mut b = Rng::new(7);378 for _ in 0..32 {379 let x = pick(&mut a, ROOT, &MAJOR, 1);380 assert_eq!(x, pick(&mut b, ROOT, &MAJOR, 1));381 assert!((ROOT..=ROOT + 12 + 11).contains(&x));382 assert!(MAJOR.contains(&((x - ROOT) % 12)));383 }384 }385 #[test]386 fn render_fills_the_duration() {387 let note = Note::new(69, Wave::Sine, 0.15);388 assert_eq!(render(¬e).len(), (0.15 * RATE as f32) as usize);389 }390 #[test]391 fn render_peaks_at_the_volume() {392 for wave in [Wave::Sine, Wave::Triangle, Wave::Square, Wave::Sawtooth] {393 let samples = render(&Note::new(69, wave, 0.15));394 let peak = samples.iter().fold(0.0f32, |m, s| m.max(s.abs()));395 assert!((peak - PEAK).abs() < 1e-4, "{} {peak}", wave.name());396 }397 }398 #[test]399 fn render_fades_the_endpoints() {400 let samples = render(&Note::new(69, Wave::Square, 0.15));401 assert_eq!(samples[0], 0.0);402 assert_eq!(samples[samples.len() - 1], 0.0);403 let ramp = (FADE * RATE as f32) as usize;404 assert!(samples[..ramp].iter().all(|s| s.abs() <= PEAK));405 }406 #[test]407 fn tone_peaks_at_unity() {408 for wave in [Wave::Sine, Wave::Triangle, Wave::Square, Wave::Sawtooth] {409 let timbre = Timbre::new(Wave::Sine, wave, VOICES);410 let samples = tone(69, &timbre, 0.15);411 assert_eq!(samples.len(), (0.15 * RATE as f32) as usize);412 let peak = samples.iter().fold(0.0f32, |m, s| m.max(s.abs()));413 assert!((peak - 1.0).abs() < 1e-4, "{} {peak}", wave.name());414 }415 }416 #[test]417 fn tone_matches_render_inside_the_fades() {418 let rendered = render(&Note::new(69, Wave::Square, 0.15));419 let timbre = Timbre::new(Wave::Sine, Wave::Square, VOICES);420 let toned = tone(69, &timbre, 0.15);421 let ramp = (FADE * RATE as f32) as usize;422 for i in ramp..rendered.len() - ramp {423 assert!((rendered[i] - toned[i] * PEAK).abs() < 1e-4);424 }425 }426 #[test]427 fn tone_separates_the_axes() {428 let pure = tone(69, &Timbre::new(Wave::Sine, Wave::Sine, 1), 0.1);429 let bent = tone(69, &Timbre::new(Wave::Triangle, Wave::Sine, 1), 0.1);430 let rich = tone(69, &Timbre::new(Wave::Sine, Wave::Triangle, VOICES), 0.1);431 assert_ne!(pure, bent);432 assert_ne!(pure, rich);433 assert_ne!(bent, rich);434 }435 #[test]436 fn tone_thins_to_sine_near_nyquist() {437 assert_eq!(438 tone(127, &Timbre::new(Wave::Sine, Wave::Square, VOICES), 0.05),439 tone(127, &Timbre::new(Wave::Sine, Wave::Sine, 1), 0.05)440 );441 }442 #[test]443 fn pcm_clamps_to_i16() {444 assert_eq!(pcm(&[2.0, -2.0, 0.0, 1.0]), vec![32767, -32767, 0, 32767]);445 }446 #[test]447 fn cycle_peaks_at_unity() {448 for wave in [Wave::Sine, Wave::Triangle, Wave::Square, Wave::Sawtooth] {449 let table = cycle(&wave, 440.0);450 assert_eq!(table.len(), CYCLE);451 let peak = table.iter().fold(0.0f32, |m, s| m.max(s.abs()));452 assert!((peak - 1.0).abs() < 1e-4, "{} {peak}", wave.name());453 }454 }455 #[test]456 fn cycle_mutes_above_nyquist() {457 assert!(cycle(&Wave::Sine, 23000.0).iter().all(|s| *s == 0.0));458 }459 #[test]460 fn cycle_thins_to_sine_near_nyquist() {461 assert_eq!(cycle(&Wave::Square, 8000.0), cycle(&Wave::Sine, 8000.0));462 }463 #[test]464 fn cues_are_notes_without_wave() {465 for name in ["blip", "good", "bad", "win", "lose"] {466 let sound = cue(name);467 assert_eq!(sound["op"], "note");468 assert!(sound["freq"].as_i64().unwrap() > 0);469 assert!(sound["ms"].as_i64().unwrap() >= 90);470 assert!(sound["gain"].as_i64().unwrap() > 0);471 assert!(sound.get("wave").is_none());472 }473 }474 #[test]475 fn cues_land_their_offsets() {476 assert_eq!(cue("blip")["freq"], json!(391_995));477 assert_eq!(cue("good")["freq"], json!(freq(ROOT + 31)));478 assert_eq!(cue("bad")["freq"], json!(freq(ROOT + 13)));479 assert_eq!(cue("win")["freq"], json!(freq(ROOT + 36)));480 assert_eq!(cue("lose")["freq"], json!(freq(ROOT + 5)));481 assert_eq!(cue("mystery"), cue("blip"));482 }483 #[test]484 fn gains_are_centi_percent() {485 assert_eq!(cue("blip")["gain"], json!(25));486 assert_eq!(cue("win")["gain"], json!(30));487 }488}