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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user