//! Synthesized sound effects — tones, not music. //! //! Every effect is generated as PCM16 WAV bytes at startup (no asset files) //! and loaded through macroquad's audio module. The vendored quad-snd backend //! soft-fails when no audio device exists, so this degrades to silence //! instead of crashing. use macroquad::audio::{load_sound_from_bytes, play_sound, PlaySoundParams, Sound}; const RATE: u32 = 44100; #[derive(Clone, Copy, PartialEq, Debug)] pub enum SfxId { /// A plain box committed. Draw, /// Boxes fused into one. Merge, /// A subset carved out of a box. Carve, /// An exact reselect toggled a box off. Remove, /// Space filed the selection — the chime. Refine, /// Quota met, shift settling. Quota, /// A fresh sheet arrived. Sheet, Undo, Clear, /// Settings row changed. Tick, /// Temper-cluster trail started. Trail, } const SFX_COUNT: usize = 11; pub struct Sfx { sounds: Vec>, pub enabled: bool, } impl Default for Sfx { fn default() -> Self { Sfx { sounds: Vec::new(), enabled: true, } } } impl Sfx { /// Synthesize every effect and load it. Individual failures degrade to /// silence for that effect only. pub async fn load(enabled: bool) -> Sfx { let bank: [(SfxId, Vec); SFX_COUNT] = [ (SfxId::Draw, blip(620.0, 0.09, 0.50)), (SfxId::Merge, rise(&[440.0, 660.0], 0.09, 0.60)), (SfxId::Carve, fall(&[520.0, 340.0], 0.08, 0.45)), (SfxId::Remove, blip(260.0, 0.07, 0.40)), (SfxId::Refine, arpeggio()), (SfxId::Quota, chord_swells()), (SfxId::Sheet, shimmer()), (SfxId::Undo, fall(&[660.0, 392.0], 0.06, 0.40)), (SfxId::Clear, thud()), (SfxId::Tick, blip(1400.0, 0.035, 0.30)), (SfxId::Trail, ping()), ]; let mut sounds: Vec> = vec![None; SFX_COUNT]; for (id, samples) in bank { match load_sound_from_bytes(&wav_pcm16_stereo(&samples)).await { Ok(s) => sounds[id as usize] = Some(s), Err(_) => eprintln!("warn: could not load sfx {:?}", id), } } Sfx { sounds, enabled } } pub fn play(&self, id: SfxId, volume: f32) { if !self.enabled { return; } if let Some(s) = self.sounds.get(id as usize).and_then(|s| s.as_ref()) { play_sound( s, PlaySoundParams { looped: false, volume, }, ); } } } // --------------------------------------------------------------------------- // Synthesis // --------------------------------------------------------------------------- /// One tone: sine plus warm harmonics, short attack, exponential release. fn tone(freq: f32, dur: f32, vol: f32) -> Vec { let n = (dur * RATE as f32) as usize; let mut out = Vec::with_capacity(n); for i in 0..n { let t = i as f32 / RATE as f32; let u = t / dur; // 0..1 progress // Attack ramp then exponential fall — no clicks, soft tail. let env = (u / 0.06).min(1.0) * (-4.0 * u).exp(); let s = (t * freq * std::f32::consts::TAU).sin() + 0.30 * (t * freq * 2.0 * std::f32::consts::TAU).sin() + 0.10 * (t * freq * 3.0 * std::f32::consts::TAU).sin(); out.push(s * env * vol * 0.4); } out } fn silence(dur: f32) -> Vec { vec![0.0; (dur * RATE as f32) as usize] } fn blip(freq: f32, dur: f32, vol: f32) -> Vec { tone(freq, dur, vol) } fn rise(freqs: &[f32], step: f32, vol: f32) -> Vec { let mut out = Vec::new(); for &f in freqs { out.extend_from_slice(&tone(f, step * 1.8, vol)); out.extend_from_slice(&silence(0.01)); } out } fn fall(freqs: &[f32], step: f32, vol: f32) -> Vec { let mut out = Vec::new(); for &f in freqs.iter().rev() { out.extend_from_slice(&tone(f, step * 1.8, vol)); out.extend_from_slice(&silence(0.01)); } out } /// The refinement chime: an arpeggio over a sustained fifth. fn arpeggio() -> Vec { let notes = [523.25, 659.25, 783.99, 1046.50]; // C5 E5 G5 C6 let step = 0.07; let total = step * notes.len() as f32 + 0.45; let n = (total * RATE as f32) as usize; let mut out = vec![0.0f32; n]; // Sustained fifth underneath (G5), slow swell. for (i, sample) in out.iter_mut().enumerate() { let t = i as f32 / RATE as f32; let env = (t / 0.10).min(1.0) * (-(t / 0.6)).exp(); *sample += 0.10 * env * (t * 783.99 * std::f32::consts::TAU).sin(); } // Arpeggio notes, each ringing out. for (k, &f) in notes.iter().enumerate() { let start = (k as f32 * step * RATE as f32) as usize; let len = ((total - k as f32 * step) * RATE as f32) as usize; for i in 0..len.min(n - start) { let t = i as f32 / RATE as f32; let u = t / (total - k as f32 * step); let env = (t / 0.006).min(1.0) * (-3.0 * u).exp(); let s = (t * f * std::f32::consts::TAU).sin() + 0.25 * (t * f * 2.0 * std::f32::consts::TAU).sin(); out[start + i] += 0.30 * env * s; } } out } /// Quota chord: a warm C-major swell with slight detune. fn chord_swells() -> Vec { let freqs = [130.81, 164.81, 196.00, 261.63]; // C3 E3 G3 C4 let dur = 1.3f32; let n = (dur * RATE as f32) as usize; let mut out = Vec::with_capacity(n); for i in 0..n { let t = i as f32 / RATE as f32; let u = t / dur; let env = (t / 0.08).min(1.0) * (-(u * 2.2)).exp(); let mut s = 0.0; for (k, &f) in freqs.iter().enumerate() { let det = 1.0 + 0.0015 * (k as f32 - 1.5); // gentle beating s += (t * f * det * std::f32::consts::TAU).sin() + 0.15 * (t * f * 2.0 * std::f32::consts::TAU).sin(); } out.push(0.13 * env * s); } out } /// New sheet: an airy upward gliss over a soft sub thump. fn shimmer() -> Vec { let dur = 0.9f32; let n = (dur * RATE as f32) as usize; let mut out = Vec::with_capacity(n); for i in 0..n { let t = i as f32 / RATE as f32; let u = t / dur; let freq = 800.0 + 800.0 * u; // gliss up let env = (t / 0.15).min(1.0) * (-(u * 1.6)).exp(); let s = (t * freq * std::f32::consts::TAU).sin() + 0.20 * (t * freq * 3.0 * std::f32::consts::TAU).sin(); out.push(0.10 * env * s); } // Sub thump at the head. for (i, sample) in out.iter_mut().enumerate().take((0.30 * RATE as f32) as usize) { let t = i as f32 / RATE as f32; let env = (-10.0 * t).exp(); *sample += 0.35 * env * (t * 120.0 * std::f32::consts::TAU).sin(); } out } fn thud() -> Vec { let dur = 0.25f32; let n = (dur * RATE as f32) as usize; let mut out = Vec::with_capacity(n); for i in 0..n { let t = i as f32 / RATE as f32; let env = (-9.0 * t).exp(); let s = (t * 180.0 * std::f32::consts::TAU).sin() + 0.6 * (t * 90.0 * std::f32::consts::TAU).sin(); out.push(0.30 * env * s); } out } /// Temper-trail ping: a clear high bell. fn ping() -> Vec { let dur = 0.35f32; let n = (dur * RATE as f32) as usize; let mut out = Vec::with_capacity(n); for i in 0..n { let t = i as f32 / RATE as f32; let u = t / dur; let env = (t / 0.004).min(1.0) * (-3.5 * u).exp(); let s = (t * 1568.0 * std::f32::consts::TAU).sin() // G6 + 0.18 * (t * 3136.0 * std::f32::consts::TAU).sin(); out.push(0.22 * env * s); } out } /// Wrap mono f32 samples as 16-bit stereo PCM WAV (44.1 kHz). fn wav_pcm16_stereo(samples: &[f32]) -> Vec { let n = samples.len() as u32; let data_len = n * 4; // stereo * 16-bit let mut w = Vec::with_capacity(44 + data_len as usize); w.extend_from_slice(b"RIFF"); w.extend_from_slice(&(36 + data_len).to_le_bytes()); w.extend_from_slice(b"WAVEfmt "); w.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size w.extend_from_slice(&1u16.to_le_bytes()); // PCM w.extend_from_slice(&2u16.to_le_bytes()); // stereo w.extend_from_slice(&RATE.to_le_bytes()); w.extend_from_slice(&(RATE * 4).to_le_bytes()); // byte rate w.extend_from_slice(&4u16.to_le_bytes()); // block align w.extend_from_slice(&16u16.to_le_bytes()); // bits per sample w.extend_from_slice(b"data"); w.extend_from_slice(&data_len.to_le_bytes()); for &s in samples { let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; w.extend_from_slice(&v.to_le_bytes()); w.extend_from_slice(&v.to_le_bytes()); } w } #[cfg(test)] mod tests { use super::*; #[test] fn wav_header_is_well_formed() { let wav = wav_pcm16_stereo(&tone(440.0, 0.1, 0.5)); assert_eq!(&wav[0..4], b"RIFF"); assert_eq!(&wav[8..12], b"WAVE"); assert_eq!(&wav[12..16], b"fmt "); assert_eq!(&wav[36..40], b"data"); let riff_len = u32::from_le_bytes(wav[4..8].try_into().unwrap()) as usize; assert_eq!(riff_len + 8, wav.len(), "RIFF size must match file"); let data_len = u32::from_le_bytes(wav[40..44].try_into().unwrap()) as usize; assert_eq!(44 + data_len, wav.len(), "data chunk must match file"); } #[test] fn tones_are_non_silent_and_bounded() { for samples in [ blip(620.0, 0.09, 0.5), arpeggio(), chord_swells(), shimmer(), thud(), ping(), ] { assert!(!samples.is_empty()); let peak = samples.iter().map(|s| s.abs()).fold(0.0f32, f32::max); assert!(peak > 0.01, "tone should be audible"); assert!(peak <= 1.0, "tone must not clip"); } } }