//! 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");
}
}
}