feat(engine,assetgen): synthesized SFX + kira AudioPlugin

- New solitaire_assetgen crate with gen_sfx binary: synthesizes
  five 44.1kHz mono 16-bit PCM WAVs (flip/place/deal/invalid/fanfare)
  from an LCG noise source + sine/square synths. Output committed
  under assets/audio/.
- AudioPlugin (engine): embeds the WAVs via include_bytes!, decodes
  once with kira::StaticSoundData, plays on Draw / Move / NewGame /
  GameWon events. card_invalid is loaded but unused — wiring it
  needs a MoveRejectedEvent.
- AudioManager kept on the main thread (NonSend) since cpal is !Send
  on some platforms; degrades gracefully if no audio device present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-25 22:48:58 -07:00
parent 7dfbff45d1
commit adacdf533c
13 changed files with 421 additions and 8 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "solitaire_assetgen"
version.workspace = true
edition.workspace = true
publish = false
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`.
# Not depended on by any other workspace crate.
[[bin]]
name = "gen_sfx"
path = "src/bin/gen_sfx.rs"
+207
View File
@@ -0,0 +1,207 @@
//! Synthesize placeholder SFX into `assets/audio/`.
//!
//! Output: 44.1kHz mono 16-bit PCM WAV. Run with
//! `cargo run -p solitaire_assetgen --bin gen_sfx`. Files are committed to
//! the repo so end-users never need to run this generator.
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
const SAMPLE_RATE: u32 = 44_100;
type Generator = fn() -> Vec<i16>;
fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 5] = [
("card_flip.wav", card_flip),
("card_place.wav", card_place),
("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare),
];
for (name, gen) in &effects {
let samples = gen();
let path = out_dir.join(name);
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
println!("wrote {} ({} samples)", path.display(), samples.len());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Synth primitives
// ---------------------------------------------------------------------------
/// Simple deterministic noise source — LCG, no `rand` dep needed.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_f32(&mut self) -> f32 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 32) as i32 as f32) / (i32::MAX as f32)
}
}
fn duration_samples(seconds: f32) -> usize {
(seconds * SAMPLE_RATE as f32) as usize
}
/// Linear attack / exponential decay envelope. `attack` and length in seconds.
fn ar_envelope(t_secs: f32, attack: f32, total: f32, decay_rate: f32) -> f32 {
if t_secs < attack {
(t_secs / attack).clamp(0.0, 1.0)
} else {
(-decay_rate * (t_secs - attack)).exp() * (1.0 - (t_secs - total).max(0.0))
}
}
fn quantize(sample: f32) -> i16 {
let clipped = sample.clamp(-1.0, 1.0);
(clipped * 32_767.0) as i16
}
// ---------------------------------------------------------------------------
// Effect generators
// ---------------------------------------------------------------------------
fn card_flip() -> Vec<i16> {
let n = duration_samples(0.08);
let mut rng = Lcg::new(0x1234_5678_DEAD_BEEF);
let mut out = Vec::with_capacity(n);
let mut prev = 0.0f32;
let alpha = 0.35;
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let raw = rng.next_f32();
// High-pass-ish: subtract a low-pass-smoothed signal.
let lp = alpha * raw + (1.0 - alpha) * prev;
prev = lp;
let hp = raw - lp;
let env = ar_envelope(t, 0.005, 0.08, 60.0);
out.push(quantize(hp * env * 0.6));
}
out
}
fn card_place() -> Vec<i16> {
let n = duration_samples(0.14);
let mut rng = Lcg::new(0xCAFE_F00D_8BAD_F00D);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
// Low sine for body (~120 Hz) + filtered noise for click.
let body = (2.0 * std::f32::consts::PI * 120.0 * t).sin();
let click = rng.next_f32() * 0.5;
let env = ar_envelope(t, 0.003, 0.14, 35.0);
let sample = (body * 0.7 + click) * env * 0.55;
out.push(quantize(sample));
}
out
}
fn card_deal() -> Vec<i16> {
let n = duration_samples(0.18);
let mut rng = Lcg::new(0xFEE1_DEAD_DEAD_BEEF);
let mut out = Vec::with_capacity(n);
let mut lp = 0.0f32;
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let raw = rng.next_f32();
// Sweeping low-pass: cutoff falls over time → "whoosh".
let alpha = 0.6 - (t / 0.18) * 0.5;
lp = alpha * raw + (1.0 - alpha) * lp;
let env = ar_envelope(t, 0.01, 0.18, 18.0);
out.push(quantize(lp * env * 0.7));
}
out
}
fn card_invalid() -> Vec<i16> {
let n = duration_samples(0.18);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
// Two dissonant squarish tones — strong beat creates a buzz.
let a = (2.0 * std::f32::consts::PI * 196.0 * t).sin().signum();
let b = (2.0 * std::f32::consts::PI * 207.65 * t).sin().signum();
let env = ar_envelope(t, 0.005, 0.18, 12.0);
out.push(quantize((a + b) * env * 0.18));
}
out
}
fn win_fanfare() -> Vec<i16> {
// C major arpeggio: C5, E5, G5, C6.
let notes = [523.25_f32, 659.25, 783.99, 1046.50];
let note_dur = 0.18_f32;
let total = note_dur * notes.len() as f32 + 0.25;
let n = duration_samples(total);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let mut sample = 0.0f32;
for (idx, freq) in notes.iter().enumerate() {
let start = idx as f32 * note_dur;
let local = t - start;
if !(0.0..=0.4).contains(&local) {
continue;
}
// Layered sine + soft 2nd harmonic for warmth.
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
+ 0.3 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
let env = ar_envelope(local, 0.008, 0.4, 6.0);
sample += s * env;
}
out.push(quantize(sample * 0.22));
}
out
}
// ---------------------------------------------------------------------------
// Minimal WAV writer (mono 16-bit PCM)
// ---------------------------------------------------------------------------
fn write_wav_mono_pcm16(path: &Path, sample_rate: u32, samples: &[i16]) -> io::Result<()> {
let mut f = File::create(path)?;
let byte_rate = sample_rate * 2; // mono 16-bit
let data_bytes = samples.len() as u32 * 2;
let chunk_size = 36 + data_bytes;
f.write_all(b"RIFF")?;
f.write_all(&chunk_size.to_le_bytes())?;
f.write_all(b"WAVE")?;
f.write_all(b"fmt ")?;
f.write_all(&16u32.to_le_bytes())?; // PCM fmt chunk size
f.write_all(&1u16.to_le_bytes())?; // PCM
f.write_all(&1u16.to_le_bytes())?; // mono
f.write_all(&sample_rate.to_le_bytes())?;
f.write_all(&byte_rate.to_le_bytes())?;
f.write_all(&2u16.to_le_bytes())?; // block align
f.write_all(&16u16.to_le_bytes())?; // bits per sample
f.write_all(b"data")?;
f.write_all(&data_bytes.to_le_bytes())?;
for &s in samples {
f.write_all(&s.to_le_bytes())?;
}
Ok(())
}
fn workspace_root() -> PathBuf {
// CARGO_MANIFEST_DIR points at the assetgen crate; parent is workspace.
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir.parent().expect("workspace root").to_path_buf()
}