Audio: WAV + tinyaudio, Ambient-Emitter mit Distance ramps; artist.md

engine/wav.rs (Decoder) und render/audio.rs (Mixer, 8 SFX-Voices +
Loop-Voice je Emitter) aus irl3d eingebettet; Emitter-Logik headless in
engine/audio.rs. Empties mit sound/radius-Property senden positional,
play_sound-Signale laufen als SFX über Session::take_sounds.
Platzhalter-Sounds + Emitter in der Testszene; artist.md fasst die
Content-Regeln für die Autorin zusammen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:08:13 +02:00
parent 56d92cdb46
commit 31e1942870
16 changed files with 796 additions and 21 deletions
+125
View File
@@ -0,0 +1,125 @@
//! Ambient-Emitter aus Blender-Modellen: Empties mit `sound`-Custom-Property
//! sind positionale Dauerschleifen — `sound = <name>` löst zu
//! `assets/sounds/{name}.wav` auf (macht der Frontend-Treiber), `radius`
//! (Meter, optional) bestimmt die Distance ramp.
//!
//! Headless wie `trigger`: hier lebt nur die *Logik* — wer sendet wo, und
//! wie laut ist das gerade für den Hörer ([`Emitters::gains`], lineare
//! Rampe [`distance_gain`]). Gerätezugriff und Mixing sind Sache des
//! Frontend-Treibers (render::audio), der die Gains jeden Frame zieht und
//! auf seine Voices legt — die Engine ruft nie ins Frontend.
//!
//! Einmal-SFX laufen nicht hier, sondern über den Signal-Dispatcher
//! (`play_sound <name>` → `Action::PlaySound` → `Session::take_sounds`).
use crate::engine::model::Model;
/// Custom-Property-Schlüssel, der ein Empty zum Ambient-Emitter macht
/// (Wert = Sound-Name). Emitter loopen immer; „an/aus" macht die Autorin
/// über Distanz/Radius oder (später) per Signal.
pub const SOUND_PROP: &str = "sound";
/// Optionaler Hörradius in Metern (Custom Property am selben Empty).
pub const RADIUS_PROP: &str = "radius";
const DEFAULT_RADIUS: f32 = 8.0;
pub struct Emitter {
pub sound: String,
pos: [f32; 3],
radius: f32,
}
#[derive(Default)]
pub struct Emitters {
list: Vec<Emitter>,
}
impl Emitters {
pub fn new() -> Self { Self::default() }
/// Emitter eines Modells übernehmen: alle Empties mit `sound`-Property.
pub fn add_model(&mut self, model: &Model) {
for e in &model.empties {
let Some(sound) = e.props.get(SOUND_PROP) else { continue; };
let radius = e.props.get(RADIUS_PROP)
.and_then(|r| r.parse::<f32>().ok())
.filter(|r| *r > 0.0)
.unwrap_or(DEFAULT_RADIUS);
self.list.push(Emitter { sound: sound.clone(), pos: e.pos, radius });
}
}
/// Die Emitter in fester Reihenfolge (Voice-Zuordnung des Treibers).
pub fn list(&self) -> &[Emitter] { &self.list }
/// Aktueller Gain je Emitter (parallel zu [`Emitters::list`]) für einen
/// Hörer bei `listener` — jeden Frame ziehen, auf die Voices legen.
pub fn gains(&self, listener: [f32; 3]) -> Vec<f32> {
self.list.iter().map(|e| {
let d = [
e.pos[0] - listener[0],
e.pos[1] - listener[1],
e.pos[2] - listener[2],
];
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
distance_gain(dist, e.radius)
}).collect()
}
}
/// Lineare Distance ramp: volle Lautstärke am Emitter, Stille ab `radius`.
/// Bewusst linear statt 1/d² — bei Zimmer-Skalen klingt die gerade Rampe
/// kontrollierbarer, und die Autorin kann per `radius` direkt denken.
pub fn distance_gain(dist: f32, radius: f32) -> f32 {
(1.0 - dist / radius).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::model::{Empty, Props};
fn model(empties: Vec<Empty>) -> Model {
Model { objects: Vec::new(), empties, materials: Vec::new() }
}
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
let mut p = Props::new();
for (k, v) in props { p.insert((*k).into(), (*v).into()); }
Empty { name: name.into(), pos, props: p }
}
#[test]
fn collects_sound_empties_with_radius() {
let mut em = Emitters::new();
em.add_model(&model(vec![
empty("Radio", [5.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
empty("spawn", [0.0, 0.0, 0.0], &[("role", "spawn")]),
empty("Bach", [9.0, 0.0, 0.0], &[("sound", "wasser")]),
]));
assert_eq!(em.list().len(), 2, "nur Empties mit sound-Property");
assert_eq!(em.list()[0].sound, "hum");
assert_eq!(em.list()[1].radius, DEFAULT_RADIUS);
}
#[test]
fn gains_ramp_linearly_to_radius() {
let mut em = Emitters::new();
em.add_model(&model(vec![
empty("Radio", [0.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
]));
assert_eq!(em.gains([0.0, 0.0, 0.0]), vec![1.0]);
assert_eq!(em.gains([2.0, 0.0, 0.0]), vec![0.5]);
assert_eq!(em.gains([4.0, 0.0, 0.0]), vec![0.0]);
assert_eq!(em.gains([9.0, 0.0, 0.0]), vec![0.0], "hinter dem Radius still");
}
#[test]
fn broken_radius_falls_back_to_default() {
let mut em = Emitters::new();
em.add_model(&model(vec![
empty("A", [0.0; 3], &[("sound", "x"), ("radius", "kaputt")]),
empty("B", [0.0; 3], &[("sound", "y"), ("radius", "-2")]),
]));
assert!(em.list().iter().all(|e| e.radius == DEFAULT_RADIUS));
}
}
+7 -3
View File
@@ -16,17 +16,20 @@
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
//! signals::dispatch zu rufen).
//!
//! `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
//! einem Export) und produziert das neutrale `model::Model`.
//! einem Export) und produziert das neutrale `model::Model`. `audio` ist
//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber
//! render::audio.
//!
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
pub mod assets;
pub mod audio;
pub mod collision;
pub mod game;
pub mod gltf;
@@ -38,3 +41,4 @@ pub mod signals;
pub mod story_ctrl;
pub mod tga;
pub mod trigger;
pub mod wav;
+4 -3
View File
@@ -15,7 +15,8 @@
//! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1)
//! `clear <name>` KV-Eintrag entfernen
//! `hide_object <name>` → Action::HideObject (deferred)
//! `play_sound <file>` → Action::PlaySound (deferred)
//! `play_sound <name>` → Action::PlaySound (deferred): SFX aus
//! `assets/sounds/{name}.wav`
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
//! Eingabemodus wechseln
//!
@@ -182,7 +183,7 @@ mod tests {
let mut signals = Signals::new();
signals.insert("pickup".into(), vec![
"inc items".into(),
"play_sound pickup.wav".into(),
"play_sound pickup".into(),
"hide_object $self".into(),
]);
let mut game = Game::new(signals);
@@ -192,7 +193,7 @@ mod tests {
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
assert_eq!(game.actions, vec![
Action::PlaySound("pickup.wav".into()),
Action::PlaySound("pickup".into()),
Action::HideObject("Mushroom.005".into()),
]);
}
+132
View File
@@ -0,0 +1,132 @@
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
//!
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
//! File wird gemeldet und bleibt still, nie fatal.
//!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
//! mono 16-bit 44,1 kHz exportieren" ist die ganze Anleitung; alles andere
//! ist eine klare Fehlermeldung mit Pfad. Über unbekannte RIFF-Chunks
//! (`LIST`, `INFO`, `bext`, …) wird hinweggesprungen.
/// Sample-Rate des gesamten Audio-Pfads (Decoder-Kontrakt und Mixer-Takt).
pub const SAMPLE_RATE: u32 = 44100;
pub struct Wav {
pub samples: Vec<i16>,
}
pub fn load(path: &str) -> Result<Wav, String> {
let bytes = std::fs::read(path).map_err(|e| format!("wav load {path}: {e}"))?;
parse(&bytes).map_err(|e| format!("wav {path}: {e}"))
}
pub fn parse(data: &[u8]) -> Result<Wav, String> {
if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
return Err("kein RIFF/WAVE".into());
}
let mut fmt_found = false;
let mut samples: Option<Vec<i16>> = None;
let mut i = 12;
while i + 8 <= data.len() {
let id = &data[i..i + 4];
let size = u32::from_le_bytes(data[i + 4..i + 8].try_into().unwrap()) as usize;
let body = i + 8;
let end = body + size;
if end > data.len() { return Err("Chunk länger als Datei".into()); }
match id {
b"fmt " => {
if size < 16 { return Err("fmt-Chunk zu kurz".into()); }
let format = u16::from_le_bytes(data[body..body + 2].try_into().unwrap());
let channels = u16::from_le_bytes(data[body + 2..body + 4].try_into().unwrap());
let rate = u32::from_le_bytes(data[body + 4..body + 8].try_into().unwrap());
let bits = u16::from_le_bytes(data[body + 14..body + 16].try_into().unwrap());
if format != 1 || channels != 1 || bits != 16 || rate != SAMPLE_RATE {
return Err(format!(
"brauche PCM mono 16-bit {SAMPLE_RATE} Hz, ist format={format} \
channels={channels} bits={bits} rate={rate}"));
}
fmt_found = true;
}
b"data" => {
if !fmt_found { return Err("data-Chunk vor fmt".into()); }
samples = Some(data[body..end].chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect());
}
_ => {} // LIST, INFO, bext, … überspringen
}
// Chunks sind word-aligned (Padding-Byte bei ungerader Größe).
i = end + (size & 1);
}
match samples {
Some(samples) if !samples.is_empty() => Ok(Wav { samples }),
Some(_) => Err("data-Chunk leer".into()),
None => Err("kein data-Chunk".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimale WAV-Datei bauen, wie ein Exporter sie schriebe.
fn wav_bytes(format: u16, channels: u16, rate: u32, bits: u16, samples: &[i16]) -> Vec<u8> {
let data_len = samples.len() * 2;
let mut out = Vec::new();
out.extend(b"RIFF");
out.extend(((4 + 8 + 16 + 8 + data_len) as u32).to_le_bytes());
out.extend(b"WAVE");
out.extend(b"fmt ");
out.extend(16u32.to_le_bytes());
out.extend(format.to_le_bytes());
out.extend(channels.to_le_bytes());
out.extend(rate.to_le_bytes());
out.extend((rate * 2).to_le_bytes()); // byte rate
out.extend(2u16.to_le_bytes()); // block align
out.extend(bits.to_le_bytes());
out.extend(b"data");
out.extend((data_len as u32).to_le_bytes());
for s in samples { out.extend(s.to_le_bytes()); }
out
}
#[test]
fn parses_pcm_mono_16bit() {
let w = parse(&wav_bytes(1, 1, SAMPLE_RATE, 16, &[0, 1000, -1000])).unwrap();
assert_eq!(w.samples, vec![0, 1000, -1000]);
}
#[test]
fn skips_unknown_chunks() {
// LIST-Chunk zwischen fmt und data.
let base = wav_bytes(1, 1, SAMPLE_RATE, 16, &[7]);
let mut out = base[..36].to_vec(); // bis inkl. fmt
out.extend(b"LIST");
out.extend(4u32.to_le_bytes());
out.extend(b"INFO");
out.extend(&base[36..]); // data-Chunk
let len = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&len.to_le_bytes());
assert_eq!(parse(&out).unwrap().samples, vec![7]);
}
#[test]
fn rejects_wrong_formats() {
assert!(parse(b"OggS...nope").is_err());
assert!(parse(&wav_bytes(1, 2, SAMPLE_RATE, 16, &[0])).is_err(), "stereo");
assert!(parse(&wav_bytes(1, 1, 22050, 16, &[0])).is_err(), "falsche Rate");
assert!(parse(&wav_bytes(1, 1, SAMPLE_RATE, 8, &[0])).is_err(), "8 bit");
assert!(parse(&wav_bytes(3, 1, SAMPLE_RATE, 16, &[0])).is_err(), "float-PCM");
}
#[test]
fn load_missing_file_is_err() {
assert!(load("/nonexistent/x.wav").is_err());
}
}