use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
const RATE: u32 = 44_100;
const DESIGN: u32 = 2;
const VARIANTS: usize = 3;
struct Noise(u32);
impl Noise {
fn next(&mut self) -> f32 {
self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(self.0 >> 8) as f32 / 8_388_608.0 - 1.0
}
}
struct Mode {
b1: f32,
b2: f32,
gain: f32,
y1: f32,
y2: f32,
}
impl Mode {
fn new(f: f32, tau: f32, amp: f32) -> Mode {
let r = (-1.0 / (tau * RATE as f32)).exp();
let w = std::f32::consts::TAU * f / RATE as f32;
Mode {
b1: 2.0 * r * w.cos(),
b2: r * r,
gain: amp * (1.0 - r),
y1: 0.0,
y2: 0.0,
}
}
fn tick(&mut self, x: f32) -> f32 {
let y = x + self.b1 * self.y1 - self.b2 * self.y2;
self.y2 = self.y1;
self.y1 = y;
y * self.gain
}
}
fn dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join("Library/Application Support/ai.cyber.erga/sound"))
}
fn wav(samples: &[i16]) -> Vec<u8> {
let data_len = (samples.len() * 2) as u32;
let mut out = Vec::with_capacity(44 + data_len as usize);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(36 + data_len).to_le_bytes());
out.extend_from_slice(b"WAVEfmt ");
out.extend_from_slice(&16u32.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&RATE.to_le_bytes());
out.extend_from_slice(&(RATE * 2).to_le_bytes()); out.extend_from_slice(&2u16.to_le_bytes()); out.extend_from_slice(&16u16.to_le_bytes()); out.extend_from_slice(b"data");
out.extend_from_slice(&data_len.to_le_bytes());
for s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
out
}
fn normalize(mut v: Vec<f32>, peak: f32) -> Vec<i16> {
let max = v.iter().fold(0.0f32, |m, s| m.max(s.abs()));
if max > 1e-6 {
let k = peak / max;
for s in &mut v {
*s *= k;
}
}
v.iter().map(|s| (s * 32_000.0).clamp(-32_000.0, 32_000.0) as i16).collect()
}
fn press_samples(k: usize) -> Vec<i16> {
let n = (RATE as f32 * 0.14) as usize;
let mut rng = Noise(0x5eed_1234 ^ ((k as u32 + 1) * 0x9e37_79b9));
let detune = 1.0 + (k as f32 - 1.0) * 0.023;
let f0 = 322.0 * detune;
let mut modes: Vec<Mode> = [
(1.00f32, 1.00f32, 0.058f32),
(2.76, 0.46, 0.034),
(5.40, 0.19, 0.019),
(8.93, 0.07, 0.011),
]
.iter()
.map(|&(ratio, amp, tau)| Mode::new(f0 * ratio, tau, amp))
.collect();
let mut lp = 0.0f32; let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / RATE as f32;
let raw = rng.next();
lp += (raw - lp) * 0.34;
let hit = lp * (-t * 900.0).exp();
let mut v = 0.0;
for m in &mut modes {
v += m.tick(hit);
}
out.push(v);
}
normalize(out, 0.24)
}
fn share_samples(k: usize) -> Vec<i16> {
let total = (RATE as f32 * 0.62) as usize;
let mut rng = Noise(0x1d0b_9f21 ^ ((k as u32 + 1) * 0x85eb_ca6b));
let detune = 1.0 + (k as f32 - 1.0) * 0.031;
let drops = [
(0.00f32, 545.0f32 * detune, 0.055f32, 1.00f32),
(0.21, 762.0 * detune, 0.043, 0.52),
];
let mut bowl = [
Mode::new(404.0 * detune, 0.085, 1.00),
Mode::new(611.0 * detune, 0.055, 0.42),
];
let mut lp = 0.0f32;
let mut out = Vec::with_capacity(total);
for i in 0..total {
let t = i as f32 / RATE as f32;
let mut v = 0.0f32;
let mut splash = 0.0f32;
for (start, f0, tau_f, amp) in drops {
if t < start {
continue;
}
let u = t - start;
let phase = std::f32::consts::TAU * f0 * tau_f * ((u / tau_f).exp() - 1.0);
let env = (-u * 27.0).exp() * (1.0 - (-u * 900.0).exp());
v += phase.sin() * env * amp;
splash += (-u * 1_400.0).exp() * amp;
}
let raw = rng.next();
lp += (raw - lp) * 0.5;
let exc = lp * splash;
for m in &mut bowl {
v += m.tick(exc) * 0.5;
}
out.push(v);
}
normalize(out, 0.20)
}
fn file_name(kind: &str, k: usize) -> String {
format!("{kind}-{DESIGN}-{k}.wav")
}
pub fn ensure() {
let Some(d) = dir() else { return };
let _ = std::fs::create_dir_all(&d);
let mut wanted = Vec::new();
for k in 0..VARIANTS {
for (kind, samples) in
[("press", press_samples(k)), ("share", share_samples(k))]
{
let name = file_name(kind, k);
let p = d.join(&name);
if !p.exists() {
let _ = std::fs::write(&p, wav(&samples));
}
wanted.push(name);
}
}
if let Ok(entries) = std::fs::read_dir(&d) {
for e in entries.flatten() {
let n = e.file_name().to_string_lossy().to_string();
if n.ends_with(".wav") && !wanted.contains(&n) {
let _ = std::fs::remove_file(e.path());
}
}
}
}
static TURN: AtomicUsize = AtomicUsize::new(0);
fn play(kind: &str) {
let Some(d) = dir() else { return };
let k = TURN.fetch_add(1, Ordering::Relaxed) % VARIANTS;
let p = d.join(file_name(kind, k));
if !p.exists() {
return;
}
let _ = std::process::Command::new("afplay")
.arg(p)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
pub fn press() {
play("press");
}
pub fn share() {
play("share");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_decay() {
for &(f, tau) in &[(322.0f32, 0.058f32), (2857.0, 0.011), (404.0, 0.085)] {
let mut m = Mode::new(f, tau, 1.0);
let spent = (5.0 * tau * RATE as f32) as usize;
let mut peak_early = 0.0f32;
let mut peak_late = 0.0f32;
for i in 0..spent * 2 {
let x = if i == 0 { 1.0 } else { 0.0 };
let y = m.tick(x).abs();
if i < spent / 10 {
peak_early = peak_early.max(y);
} else if i > spent {
peak_late = peak_late.max(y);
}
}
assert!(peak_early > 0.0, "mode at {f} Hz never rang");
assert!(
peak_late < peak_early * 0.01,
"mode at {f} Hz still at {:.3}% after five time constants",
peak_late / peak_early * 100.0
);
}
}
#[test]
fn renders_are_audible_and_unclipped() {
for k in 0..VARIANTS {
for (name, s) in [("press", press_samples(k)), ("share", share_samples(k))] {
let peak = s.iter().map(|v| v.abs() as i32).max().unwrap_or(0);
assert!(peak > 3_000, "{name} #{k} is inaudible (peak {peak})");
assert!(peak < 32_000, "{name} #{k} clips (peak {peak})");
}
}
}
#[test]
fn strikes_differ() {
let a = press_samples(0);
let b = press_samples(1);
assert_ne!(a, b, "every strike rendered identically");
}
}