From 5a06890c0ce9d41ba4e385658fa6760e828d7714 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sun, 4 Oct 2026 04:44:31 +0300 Subject: [PATCH] sound: macOS-like two-hit shutter, Balatro clatter, bell arpeggio tail, even loudness, volume 0.9 --- README.md | 2 +- docs/test-checklist.md | 3 +- src/config.rs | 4 +- src/sound/synth.rs | 273 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 243 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 778c7ba..288d3da 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ backend = "builtin" # or "satty" [sound] enabled = true -volume = 0.6 +volume = 0.9 combo_reset_secs = 5 ``` diff --git a/docs/test-checklist.md b/docs/test-checklist.md index 5321c28..0e39c7b 100644 --- a/docs/test-checklist.md +++ b/docs/test-checklist.md @@ -142,7 +142,7 @@ image, a cancel brought the thumbnail back, and a swipe still dismissed it. - [ ] Region: while dragging, a `W × H` label follows the pointer (physical pixels, same as the saved PNG), flipping near the edges. - [ ] Shift locks the dimension you move first; Alt grows from the centre; Space held moves the selection, and after letting go resizing continues without a jump; Space before a drag still switches to window mode. - [ ] `[capture] window_shadow = true`: window shots get a soft shadow following the rounded corners; preview, clipboard and file all have it. -- [ ] Shots in quick succession play sounds 1 → 5 getting faster and brighter, then 6, 7, 6, 7 rising endlessly; after a 5 s pause it starts at 1 again. `valw __combo-demo` plays the whole sequence for tuning. +- [ ] Each shot is a two-hit "ka-chunk" (macOS-like) with a metallic clatter that grows from shot 2; shots in quick succession play sounds 1 → 5 getting faster and brighter, then 6, 7, 6, 7 (a climbing bell arpeggio over a glide) rising endlessly; every shot about as loud; after a 5 s pause it starts at 1 again. `valw __combo-demo` plays the whole sequence for tuning. - [ ] `[sound] enabled = false`, `volume`, `combo_reset_secs` take effect; the toolbar's Play sound option overrides the config; no `pw-play` → no sound, capture still works (warning in the log). ### Known minors (deferred from the polish review) @@ -152,7 +152,6 @@ image, a cancel brought the thumbnail back, and a swipe still dismissed it. - `__combo-demo` and a capture regenerating the same WAV at once can race on the temp file (one silent shot). - Without `XDG_RUNTIME_DIR` the sound cache and combo state in `/tmp` are shared between users. - `pipewire` on the wrapper's PATH adds its whole closure to the package. -- Tuning by ear: shots 6–7 are about 9–10 dB louder than 1–5; adjust with `__combo-demo`. ## home-manager module diff --git a/src/config.rs b/src/config.rs index 4f58007..89f2226 100644 --- a/src/config.rs +++ b/src/config.rs @@ -84,7 +84,7 @@ impl Default for Sound { fn default() -> Self { Self { enabled: true, - volume: 0.6, + volume: 0.9, combo_reset_secs: 5, } } @@ -292,7 +292,7 @@ mod tests { fn sound_defaults_and_bounds() { let s = Config::default().sound; assert!(s.enabled); - assert_eq!((s.volume, s.combo_reset_secs), (0.6, 5)); + assert_eq!((s.volume, s.combo_reset_secs), (0.9, 5)); for bad in ["volume = 1.5", "volume = nan", "volume = -0.1"] { let err = parse(&format!("[sound]\n{bad}\n")).unwrap_err(); assert_eq!( diff --git a/src/sound/synth.rs b/src/sound/synth.rs index bca7b29..71d468d 100644 --- a/src/sound/synth.rs +++ b/src/sound/synth.rs @@ -1,11 +1,33 @@ //! The shutter sounds, synthesised: no samples, no licences. +//! +//! A two-hit mechanical shutter with a pitched body ("ka-chunk"), a metallic +//! clatter that grows with the combo, and on shots 6 and 7 a bell +//! arpeggio over a Shepard glide that seems to rise forever. 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 shutter's second hit, after the first. +const GAP: f32 = 0.150; +/// How much clatter each of shots 1–5 carries (6 and 7 take the last). +const CLATTER: [f32; 5] = [0.0, 0.08, 0.15, 0.22, 0.30]; +const CLATTER_HZ: f32 = 900.0; +/// The clatter's partials: (ratio to its fundamental, amplitude, decay s). +const CLATTER_PARTIALS: [(f32, f32, f32); 4] = [ + (1.0, 1.0, 0.060), + (1.50, 0.7, 0.050), + (4.40, 0.9, 0.030), + (22.1, 0.25, 0.012), +]; +/// Every sound is scaled to this short-term loudness (RMS). +const LOUDNESS: f32 = 0.30; +/// The tail of shots 6 and 7, relative to [`LOUDNESS`]. +const TAIL_LOUDNESS: f32 = 0.8; +const TAIL_SECS: f32 = 0.30; +const ARPEGGIO_NOTES: usize = 3; +const ARPEGGIO_STEP: f32 = 0.075; /// The Shepard tone: octave-spaced partials from BASE Hz under a bell /// centred on CENTRE Hz (log scale). const BASE: f32 = 55.0; @@ -29,32 +51,138 @@ 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); +fn samples(secs_long: f32) -> usize { + (secs_long * RATE as f32) as usize +} + +/// Adds `src` times `gain` into `dst` from `at` seconds, growing `dst`. +fn mix(dst: &mut Vec, src: &[f32], at: f32, gain: f32) { + let start = samples(at); + if dst.len() < start + src.len() { + dst.resize(start + src.len(), 0.0); } - fade_out(&mut out, 0.010); - normalise(&mut out, 0.9); - out + for (d, s) in dst[start..].iter_mut().zip(src) { + *d += s * gain; + } +} + +/// A damped resonance at `f` Hz with a small downward pitch blip and an +/// inharmonic overtone: the body of a hit. +fn thunk(f: f32, decay: f32) -> Vec { + let (mut p1, mut p2) = (0.0f32, 0.0f32); + (0..samples(decay * 7.0)) + .map(|i| { + let t = secs(i); + let env = (t / 0.0008).min(1.0) * (-t / decay).exp(); + let fi = f * (1.0 + 0.25 * (-t / 0.006).exp()); + p1 = (p1 + TAU * fi / RATE as f32) % TAU; + p2 = (p2 + TAU * fi * 2.76 / RATE as f32) % TAU; + env * (p1.sin() + 0.35 * p2.sin() * (-t / (decay * 0.4)).exp()) + }) + .collect() +} + +/// A burst of high-passed noise; `bright` (0–1) tames the very top. +fn burst(decay: f32, seed: u32, bright: f32) -> Vec { + let mut noise = Noise(seed); + let (mut low, mut high_prev, mut high) = (0.0f32, 0.0f32, 0.0f32); + (0..samples(decay * 7.0)) + .map(|i| { + let t = secs(i); + low += bright * (noise.next() - low); + high = 0.92 * (high + low - high_prev); + high_prev = low; + high * (t / 0.0005).min(1.0) * (-t / decay).exp() + }) + .collect() +} + +fn hit(f: f32, seed: u32, noise: f32) -> Vec { + let mut s = thunk(f, 0.035); + mix(&mut s, &burst(0.018, seed, 0.9), 0.0, noise); + s +} + +/// The mechanical "ka-chunk": two hits [`GAP`] apart, the second a fifth up. +pub fn shutter() -> Vec { + let mut s = hit(260.0, 0x0123_4567, 0.55); + mix(&mut s, &hit(390.0, 0x0765_4321, 0.65), GAP, 1.1); + s +} + +/// One metallic strike of the clatter at `f` Hz. +fn ping(f: f32) -> Vec { + let mut phases = [0f32; CLATTER_PARTIALS.len()]; + (0..samples(0.35)) + .map(|i| { + let t = secs(i); + let mut v = 0.0; + for (p, (ratio, amp, decay)) in phases.iter_mut().zip(CLATTER_PARTIALS) { + *p = (*p + TAU * f * ratio / RATE as f32) % TAU; + v += amp * p.sin() * (-t / decay).exp(); + } + v * (t / 0.0004).min(1.0) + }) + .collect() +} + +/// A coin-like clatter: four quick, fading metallic strikes. +fn clatter(f: f32) -> Vec { + let mut s = Vec::new(); + for (j, (at, amp)) in [(0.0, 1.0), (0.035, 0.7), (0.080, 0.5), (0.135, 0.35)] + .into_iter() + .enumerate() + { + mix(&mut s, &ping(f * (1.0 + 0.012 * j as f32)), at, amp); + mix( + &mut s, + &burst(0.006, 0x9e37_79b9 + j as u32, 0.6), + at, + 0.35 * amp, + ); + } + s +} + +/// The shutter with clatter `level` (1–5): more of it, and two semitones +/// higher, each level. +fn shot(level: usize) -> Vec { + let mut s = shutter(); + let amount = CLATTER[level - 1]; + if amount > 0.0 { + let f = CLATTER_HZ * 2f32.powf((level - 1) as f32 * 2.0 / 12.0); + mix(&mut s, &clatter(f), GAP, amount); + } + s +} + +/// The loudest 20 ms of `s` (RMS): what the ear compares between shots. +pub fn loudness(s: &[f32]) -> f32 { + let window = samples(0.020).min(s.len()).max(1); + let mut sum: f32 = s[..window.min(s.len())].iter().map(|v| v * v).sum(); + let mut best = sum; + for i in window..s.len() { + sum += s[i] * s[i] - s[i - window] * s[i - window]; + best = best.max(sum); + } + (best.max(0.0) / window as f32).sqrt() +} + +/// Scales `s` to `target` loudness through a soft limiter, keeps the peak +/// under 0.95 and fades the end. +fn finish(mut s: Vec, target: f32) -> Vec { + let gain = target / loudness(&s).max(1e-9); + for v in s.iter_mut() { + *v = (*v * gain * 1.2).tanh() / 1.2; + } + let max = s.iter().fold(0.0f32, |m, v| m.max(v.abs())); + if max > 0.95 { + for v in s.iter_mut() { + *v *= 0.95 / max; + } + } + fade_out(&mut s, 0.010); + s } /// `samples` played `factor` times faster (shorter and higher). @@ -106,14 +234,50 @@ pub fn glide(from: f32, to: f32, secs_long: f32) -> Vec { out } +/// One bell note of the Shepard scale at `position` octaves: octave +/// partials with a short metallic overtone. +fn bell(position: f32) -> Vec { + let partials = partials(position); + let mut phases = vec![(0f32, 0f32); partials.len()]; + (0..samples(0.16)) + .map(|i| { + let t = secs(i); + let mut v = 0.0; + for ((p, q), (f, a)) in phases.iter_mut().zip(&partials) { + *p = (*p + TAU * f / RATE as f32) % TAU; + *q = (*q + TAU * f * 4.4 / RATE as f32) % TAU; + v += a * (p.sin() + 0.25 * q.sin() * (-t / 0.015).exp()); + } + v * (t / 0.0015).min(1.0) * (-t / 0.05).exp() + }) + .collect() +} + +/// [`ARPEGGIO_NOTES`] bell notes climbing from `from` towards `to` +/// octaves, over a quiet Shepard glide. +fn tail(from: f32, to: f32) -> Vec { + let mut notes = Vec::new(); + for j in 0..ARPEGGIO_NOTES { + let position = from + (to - from) * j as f32 / ARPEGGIO_NOTES as f32; + mix(&mut notes, &bell(position), j as f32 * ARPEGGIO_STEP, 1.0); + } + let pad = glide(from, to, TAIL_SECS); + let gain = 0.45 / loudness(&pad); + let mut s: Vec = pad.iter().map(|v| v * gain).collect(); + mix(&mut s, ¬es, 0.0, 1.0 / loudness(¬es)); + s +} + /// 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]), + 1..=5 => finish(speed(&shot(n as usize), SPEEDS[n as usize - 1]), LOUDNESS), 6 | 7 => { - let mut s = speed(&shutter(), LOOP_SPEED); + let mut s = finish(speed(&shot(5), LOOP_SPEED), LOUDNESS); let half = if n == 6 { (0.0, 0.5) } else { (0.5, 1.0) }; - s.extend(glide(half.0, half.1, GLIDE_SECS)); + let t = tail(half.0, half.1); + let gain = LOUDNESS * TAIL_LOUDNESS / loudness(&t); + s.extend(t.iter().map(|v| v * gain)); s } _ => sound(1), @@ -175,15 +339,48 @@ mod tests { s.iter().fold(0.0, |m, v| m.max(v.abs())) } + fn rms(s: &[f32], from_ms: f32, to_ms: f32) -> f32 { + let at = |ms: f32| ((ms / 1000.0 * RATE as f32) as usize).min(s.len()); + let seg = &s[at(from_ms)..at(to_ms)]; + (seg.iter().map(|v| v * v).sum::() / seg.len().max(1) as f32).sqrt() + } + #[test] - fn the_shutter_is_short_and_never_clips() { + fn the_shutter_is_two_hits_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!((300.0..=450.0).contains(&ms), "{ms} ms"); + let (first, between, second) = ( + rms(&s, 0.0, 20.0), + rms(&s, 115.0, 140.0), + rms(&s, 150.0, 170.0), + ); + assert!(first > 3.0 * between, "{first} vs {between}"); + assert!( + second > 3.0 * between, + "a second hit at 150 ms: {second} vs {between}" + ); assert!(s.last().unwrap().abs() < 0.01, "ends quietly (no pop)"); } + #[test] + fn every_shot_is_about_as_loud() { + let db: Vec = (1..=7) + .map(|n| 20.0 * loudness(&sound(n)).log10()) + .collect(); + let (lo, hi) = db + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + assert!(hi - lo < 1.5, "{db:?}"); + } + + #[test] + fn every_shot_ends_quietly() { + for n in 1..=7 { + assert!(sound(n).last().unwrap().abs() < 0.01, "sound {n} pops"); + } + } + #[test] fn faster_is_shorter() { let s = shutter(); @@ -192,8 +389,16 @@ mod tests { #[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!(SPEEDS.windows(2).all(|w| w[1] > w[0]) && LOOP_SPEED > SPEEDS[4]); + for n in 1..=5u8 { + let s = sound(n); + let at = GAP * 1000.0 / SPEEDS[n as usize - 1]; + let (before, hit) = (rms(&s, at - 25.0, at - 5.0), rms(&s, at, at + 15.0)); + assert!( + hit > 2.0 * before, + "shot {n}: second hit at {at} ms ({hit} vs {before})" + ); + } assert!(sound(6).len() > sound(5).len(), "6 adds the glide"); assert_eq!(sound(6).len(), sound(7).len()); for n in 1..=7 {