lumon-macrodata-refine / src/sfx.rs

raw file 10122 bytes
//! 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<Option<Sound>>,
    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<f32>); 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<Option<Sound>> = 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<f32> {
    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<f32> {
    vec![0.0; (dur * RATE as f32) as usize]
}

fn blip(freq: f32, dur: f32, vol: f32) -> Vec<f32> {
    tone(freq, dur, vol)
}

fn rise(freqs: &[f32], step: f32, vol: f32) -> Vec<f32> {
    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<f32> {
    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<f32> {
    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<f32> {
    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<f32> {
    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<f32> {
    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<f32> {
    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<u8> {
    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");
        }
    }
}