diff --git a/src/main.rs b/src/main.rs index 42db7f9..9c7b2a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,9 @@ mod region; mod render; mod selection; mod shadow; +// The sound lands in two tasks; Task 5 of the polish plan removes this. +#[allow(dead_code)] +mod sound; mod stack; mod thumbnail; mod toolbar; diff --git a/src/sound/combo.rs b/src/sound/combo.rs new file mode 100644 index 0000000..5a587a1 --- /dev/null +++ b/src/sound/combo.rs @@ -0,0 +1,71 @@ +//! Which shot of a combo this is, shared between `valw` runs. + +use std::path::Path; + +use anyhow::Result; + +/// The number for a shot at `now_ms`, given the previous shot. +pub fn next(prev: Option<(u8, u64)>, now_ms: u64, reset_ms: u64) -> u8 { + match prev { + Some((n, at)) if now_ms >= at && now_ms - at < reset_ms => match n { + 7 => 6, + n => n + 1, + }, + _ => 1, + } +} + +/// `(number, unix ms)` of the last shot, if the file makes sense. +pub fn load(path: &Path) -> Option<(u8, u64)> { + let text = std::fs::read_to_string(path).ok()?; + let mut parts = text.split_whitespace(); + let n: u8 = parts.next()?.parse().ok()?; + let at: u64 = parts.next()?.parse().ok()?; + (1..=7).contains(&n).then_some((n, at)) +} + +pub fn save(path: &Path, n: u8, at_ms: u64) -> Result<()> { + crate::output::write_atomic(path, format!("{n} {at_ms}\n").as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_up_then_alternates_six_and_seven() { + let mut prev = None; + let mut seen = Vec::new(); + for i in 0..11u64 { + let n = next(prev, i * 1000, 5000); + seen.push(n); + prev = Some((n, i * 1000)); + } + assert_eq!(seen, [1, 2, 3, 4, 5, 6, 7, 6, 7, 6, 7]); + } + + #[test] + fn a_pause_resets() { + assert_eq!(next(Some((4, 1000)), 7000, 5000), 1); + assert_eq!(next(Some((4, 1000)), 5999, 5000), 5); + assert_eq!(next(None, 0, 5000), 1); + assert_eq!( + next(Some((3, 9000)), 1000, 5000), + 1, + "a clock going backwards" + ); + } + + #[test] + fn state_file_round_trip_and_broken_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw-combo"); + assert_eq!(load(&path), None); + save(&path, 6, 123456).unwrap(); + assert_eq!(load(&path), Some((6, 123456))); + std::fs::write(&path, "garbage").unwrap(); + assert_eq!(load(&path), None); + std::fs::write(&path, "9 5").unwrap(); + assert_eq!(load(&path), None, "out of range"); + } +} diff --git a/src/sound/mod.rs b/src/sound/mod.rs new file mode 100644 index 0000000..bbbf6d2 --- /dev/null +++ b/src/sound/mod.rs @@ -0,0 +1,4 @@ +//! The combo shutter sound. + +pub mod combo; +pub mod synth; diff --git a/src/sound/synth.rs b/src/sound/synth.rs new file mode 100644 index 0000000..bca7b29 --- /dev/null +++ b/src/sound/synth.rs @@ -0,0 +1,226 @@ +//! The shutter sounds, synthesised: no samples, no licences. + +use std::f32::consts::TAU; + +pub const RATE: u32 = 44_100; +const SPEEDS: [f32; 5] = [1.00, 1.12, 1.25, 1.40, 1.60]; +const LOOP_SPEED: f32 = 1.80; +const GLIDE_SECS: f32 = 0.4; +/// The Shepard tone: octave-spaced partials from BASE Hz under a bell +/// centred on CENTRE Hz (log scale). +const BASE: f32 = 55.0; +const PARTIALS: i32 = 9; +const CENTRE: f32 = 660.0; +const WIDTH_OCTAVES: f32 = 1.4; + +/// A deterministic noise source (xorshift), so every build sounds the same. +struct Noise(u32); + +impl Noise { + fn next(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 as f32 / u32::MAX as f32 * 2.0 - 1.0 + } +} + +fn secs(n: usize) -> f32 { + n as f32 / RATE as f32 +} + +/// A two-part mechanical "tak-shk", about 150 ms. +pub fn shutter() -> Vec { + let len = (0.150 * RATE as f32) as usize; + let mut noise = Noise(0x1234_5678); + let (mut low, mut high_prev, mut high) = (0.0f32, 0.0f32, 0.0f32); + let mut out = Vec::with_capacity(len); + for i in 0..len { + let t = secs(i); + let n = noise.next(); + // Click: bright, 0.5 ms attack, ~6 ms decay. + let click = n * (t / 0.0005).min(1.0) * (-t / 0.006).exp(); + // "shk": band-passed noise from 60 ms, ~30 ms decay, softer. + low += 0.25 * (n - low); + high = 0.9 * (high + low - high_prev); + high_prev = low; + let u = t - 0.060; + let shk = if u >= 0.0 { + high * 0.9 * (u / 0.002).min(1.0) * (-u / 0.03).exp() + } else { + 0.0 + }; + out.push(click * 0.8 + shk); + } + fade_out(&mut out, 0.010); + normalise(&mut out, 0.9); + out +} + +/// `samples` played `factor` times faster (shorter and higher). +pub fn speed(samples: &[f32], factor: f32) -> Vec { + let len = (samples.len() as f32 / factor).ceil() as usize; + (0..len) + .map(|i| { + let pos = i as f32 * factor; + let j = pos as usize; + let frac = pos - j as f32; + let a = samples.get(j).copied().unwrap_or(0.0); + let b = samples.get(j + 1).copied().unwrap_or(0.0); + a + (b - a) * frac + }) + .collect() +} + +/// The Shepard tone's partials `(frequency, amplitude)` at `position` +/// octaves (0..1) along the glide. Amplitude depends only on frequency, so +/// position 1 sounds like position 0. +pub fn partials(position: f32) -> Vec<(f32, f32)> { + (-1..PARTIALS) + .map(|k| { + let f = BASE * 2f32.powf(k as f32 + position); + let d = (f / CENTRE).log2() / WIDTH_OCTAVES; + (f, (-0.5 * d * d).exp()) + }) + .collect() +} + +/// A Shepard glide from `from` to `to` octaves over `secs` seconds. +pub fn glide(from: f32, to: f32, secs_long: f32) -> Vec { + let len = (secs_long * RATE as f32) as usize; + let count = partials(0.0).len(); + let mut phases = vec![0f32; count]; + let mut out = Vec::with_capacity(len); + for i in 0..len { + let position = from + (to - from) * i as f32 / len as f32; + let mut v = 0.0; + for (p, (f, a)) in phases.iter_mut().zip(partials(position)) { + *p = (*p + TAU * f / RATE as f32) % TAU; + v += a * p.sin(); + } + out.push(v); + } + fade_in(&mut out, 0.010); + fade_out(&mut out, 0.040); + normalise(&mut out, 0.5); + out +} + +/// The sound for shot `n` of a combo (1–7). +pub fn sound(n: u8) -> Vec { + match n { + 1..=5 => speed(&shutter(), SPEEDS[n as usize - 1]), + 6 | 7 => { + let mut s = speed(&shutter(), LOOP_SPEED); + let half = if n == 6 { (0.0, 0.5) } else { (0.5, 1.0) }; + s.extend(glide(half.0, half.1, GLIDE_SECS)); + s + } + _ => sound(1), + } +} + +/// 16-bit mono PCM WAV. +pub fn wav(samples: &[f32]) -> Vec { + let data = (samples.len() * 2) as u32; + let mut w = Vec::with_capacity(44 + data as usize); + w.extend_from_slice(b"RIFF"); + w.extend_from_slice(&(36 + data).to_le_bytes()); + w.extend_from_slice(b"WAVEfmt "); + w.extend_from_slice(&16u32.to_le_bytes()); + w.extend_from_slice(&1u16.to_le_bytes()); // PCM + w.extend_from_slice(&1u16.to_le_bytes()); // mono + w.extend_from_slice(&RATE.to_le_bytes()); + w.extend_from_slice(&(RATE * 2).to_le_bytes()); + w.extend_from_slice(&2u16.to_le_bytes()); + w.extend_from_slice(&16u16.to_le_bytes()); + w.extend_from_slice(b"data"); + w.extend_from_slice(&data.to_le_bytes()); + for s in samples { + let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16; + w.extend_from_slice(&v.to_le_bytes()); + } + w +} + +fn fade_in(s: &mut [f32], secs_long: f32) { + let n = ((secs_long * RATE as f32) as usize).min(s.len()); + for (i, v) in s[..n].iter_mut().enumerate() { + *v *= i as f32 / n as f32; + } +} + +fn fade_out(s: &mut [f32], secs_long: f32) { + let n = ((secs_long * RATE as f32) as usize).min(s.len()); + let len = s.len(); + for (i, v) in s[len - n..].iter_mut().enumerate() { + *v *= 1.0 - (i + 1) as f32 / n as f32; + } +} + +fn normalise(s: &mut [f32], peak: f32) { + let max = s.iter().fold(0.0f32, |m, v| m.max(v.abs())); + if max > 0.0 { + for v in s.iter_mut() { + *v *= peak / max; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn peak(s: &[f32]) -> f32 { + s.iter().fold(0.0, |m, v| m.max(v.abs())) + } + + #[test] + fn the_shutter_is_short_and_never_clips() { + let s = shutter(); + let ms = s.len() as f32 * 1000.0 / RATE as f32; + assert!((140.0..=160.0).contains(&ms), "{ms} ms"); + assert!(peak(&s) <= 0.95 && peak(&s) > 0.3); + assert!(s.last().unwrap().abs() < 0.01, "ends quietly (no pop)"); + } + + #[test] + fn faster_is_shorter() { + let s = shutter(); + assert_eq!(speed(&s, 1.6).len(), (s.len() as f32 / 1.6).ceil() as usize); + } + + #[test] + fn sounds_escalate_then_loop() { + let lens: Vec = (1..=5).map(|n| sound(n).len()).collect(); + assert!(lens.windows(2).all(|w| w[1] < w[0]), "{lens:?}"); + assert!(sound(6).len() > sound(5).len(), "6 adds the glide"); + assert_eq!(sound(6).len(), sound(7).len()); + for n in 1..=7 { + assert!(peak(&sound(n)) <= 0.95, "sound {n} clips"); + } + } + + #[test] + fn the_glide_returns_to_its_start_an_octave_up() { + let start = partials(0.0); + let end = partials(1.0); + for (f, a) in start.iter().filter(|p| p.1 > 0.01) { + let twin = end + .iter() + .find(|p| (p.0 - f).abs() < 0.01) + .expect("same frequency an octave later"); + assert!((twin.1 - a).abs() < 1e-3, "{f} Hz: {a} vs {}", twin.1); + } + } + + #[test] + fn wav_header() { + let w = wav(&[0.0, 0.5, -0.5]); + assert_eq!(&w[0..4], b"RIFF"); + assert_eq!(&w[8..16], b"WAVEfmt "); + assert_eq!(u32::from_le_bytes(w[24..28].try_into().unwrap()), RATE); + assert_eq!(w.len(), 44 + 3 * 2); + assert_eq!(i16::from_le_bytes(w[46..48].try_into().unwrap()), 16384); + } +}