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
+151
View File
@@ -0,0 +1,151 @@
//! Audio-Treiber auf tinyaudio — Frontend-Gegenstück zur headless
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
//! dieses Modul nie).
//!
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
//!
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
//! wird die am weitesten gespielte verdrängt (`play`).
//! - Ambient-Voices: eine je Emitter, loopen immer; ihr Gain ist die
//! Distance ramp, die das Frontend jeden Frame aus
//! `Session::emitter_gains` zieht ([`Audio::set_ambient_gains`]).
//!
//! Gemischt wird im Callback-Thread in f32 über alle aktiven Voices
//! (Sample × Gain), am Ende auf [-1, 1] geklemmt. Der Mixer hängt hinter
//! einem Mutex; Frontend-Seite fasst ihn nur kurz pro Frame/SFX an.
use std::sync::{Arc, Mutex};
use tinyaudio::{run_output_device, OutputDevice, OutputDeviceParameters};
use crate::engine::wav;
const SFX_VOICES: usize = 8;
/// Callback-Puffergröße in Samples (~6 ms bei 44,1 kHz).
const BUFFER_LEN: usize = 256;
/// Dekodierte Mono-Samples, geteilt zwischen Cache und laufenden Voices.
#[derive(Clone)]
pub struct Sound {
samples: Arc<Vec<i16>>,
}
impl Sound {
pub fn new(w: wav::Wav) -> Self {
Sound { samples: Arc::new(w.samples) }
}
}
struct Voice {
samples: Arc<Vec<i16>>,
cursor: usize,
gain: f32,
}
#[derive(Default)]
struct Mixer {
sfx: Vec<Option<Voice>>,
/// Loop-Voices der Ambient-Emitter, Index = Emitter-Reihenfolge.
ambient: Vec<Voice>,
}
impl Mixer {
fn mix(&mut self, out: &mut [f32]) {
for s in out.iter_mut() {
let mut acc = 0.0f32;
for slot in self.sfx.iter_mut() {
if let Some(v) = slot {
acc += v.samples[v.cursor] as f32 * v.gain;
v.cursor += 1;
if v.cursor >= v.samples.len() { *slot = None; }
}
}
for v in self.ambient.iter_mut() {
// Auch bei Gain 0 weiterlaufen lassen: ein Emitter, in dessen
// Radius man zurückkehrt, spielt mitten in seiner Schleife —
// nicht jedes Mal von vorn.
acc += v.samples[v.cursor] as f32 * v.gain;
v.cursor = (v.cursor + 1) % v.samples.len();
}
*s = (acc / 32768.0).clamp(-1.0, 1.0);
}
}
}
pub struct Audio {
mixer: Arc<Mutex<Mixer>>,
_device: OutputDevice, // lebt, solange Audio lebt
}
impl Audio {
/// Ausgabegerät öffnen. `None`, wenn keins da ist (Meldung, Spiel läuft
/// stumm weiter).
pub fn open() -> Option<Self> {
// pipewire-pulse/PulseAudio respektiert diese Variable als
// Ziel-Latenz des Streams (Default ~50200 ms) — herabsetzen für
// snappige SFX. Muss vor `run_output_device` gesetzt sein; auf
// Windows/macOS ignoriert.
// SAFETY: einmalig beim Start, bevor weitere Threads laufen (wird
// in render::run vor dem stdin-Thread gerufen).
unsafe { std::env::set_var("PULSE_LATENCY_MSEC", "15"); }
let mixer = Arc::new(Mutex::new(Mixer {
sfx: (0..SFX_VOICES).map(|_| None).collect(),
ambient: Vec::new(),
}));
let cb_mixer = Arc::clone(&mixer);
let device = run_output_device(
OutputDeviceParameters {
channels_count: 1,
sample_rate: wav::SAMPLE_RATE as usize,
channel_sample_count: BUFFER_LEN,
},
move |buf: &mut [f32]| { cb_mixer.lock().unwrap().mix(buf); },
);
match device {
Ok(device) => Some(Audio { mixer, _device: device }),
Err(e) => {
eprintln!("[audio] kein Ausgabegerät ({e}) — Spiel läuft stumm");
None
}
}
}
/// Einmal-SFX starten: freier Slot, sonst wird die am weitesten
/// gespielte Voice verdrängt.
pub fn play(&self, s: &Sound) {
if s.samples.is_empty() { return; }
let mut m = self.mixer.lock().unwrap();
let target = m.sfx.iter().enumerate()
.max_by_key(|(_, slot)| slot.as_ref().map_or(usize::MAX, |v| v.cursor))
.map_or(0, |(i, _)| i);
m.sfx[target] = Some(Voice {
samples: Arc::clone(&s.samples), cursor: 0, gain: 1.0,
});
}
/// Die Ambient-Loop-Voices anlegen — einmal beim Start, Reihenfolge =
/// `Session::emitters` (die Gains kommen dann parallel dazu, deshalb
/// bekommt auch ein nicht ladbarer Sound seine — stille — Voice).
pub fn set_ambients(&self, sounds: &[Option<Sound>]) {
let mut m = self.mixer.lock().unwrap();
m.ambient = sounds.iter().map(|s| Voice {
samples: s.as_ref().map_or_else(|| Arc::new(vec![0]), |s| Arc::clone(&s.samples)),
cursor: 0,
gain: 0.0,
}).collect();
}
/// Distance-ramp-Gains auf die Ambient-Voices legen — jeden Frame, aus
/// `Session::emitter_gains`.
pub fn set_ambient_gains(&self, gains: &[f32]) {
let mut m = self.mixer.lock().unwrap();
for (v, g) in m.ambient.iter_mut().zip(gains) {
v.gain = *g;
}
}
}
+51 -4
View File
@@ -14,6 +14,7 @@
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt.
mod audio;
mod camera;
mod font;
mod gpu;
@@ -23,6 +24,7 @@ mod scene;
mod sprite;
mod ui;
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::time::Instant;
@@ -35,7 +37,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga};
use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
@@ -79,10 +81,22 @@ pub fn run(mut session: Session) {
println!("[scene] {} Modelle, Texturen {:?}{} Vertices, {} Dreiecke",
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
// Sim-Welt (Collider, Trigger, Spawn-Empty) in die Session einspielen —
// die Simulation gehört der Session, nicht dem Render-Frontend.
// Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
// einspielen — die Simulation gehört der Session, nicht dem
// Render-Frontend.
for (_, m) in &models { session.load_props(m); }
// Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je
// Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw()
// dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm.
let audio = audio::Audio::open();
if let Some(a) = &audio {
let sounds: Vec<Option<audio::Sound>> = session.emitters().iter()
.map(|e| load_sound(&e.sound))
.collect();
a.set_ambients(&sounds);
}
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
@@ -126,7 +140,7 @@ pub fn run(mut session: Session) {
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
// pollen nebenbei den Befehls-Channel.
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts);
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts, audio);
event_loop.run_app(&mut app).expect("winit: run");
}
@@ -170,6 +184,18 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> {
match wav::load(&assets::path(&format!("assets/sounds/{name}.wav"))) {
Ok(w) => Some(audio::Sound::new(w)),
Err(e) => {
eprintln!("[audio] {e}");
None
}
}
}
fn load_texture(name: &str) -> Image {
let path = assets::path(&format!("assets/textures/{name}.tga"));
if std::path::Path::new(&path).exists() {
@@ -220,6 +246,11 @@ struct App {
images: Vec<Image>,
ui_textures: Vec<Image>,
fonts: font::Fonts,
/// Audio-Treiber (`None` = kein Gerät, Spiel läuft stumm) und der
/// Lazy-Cache der Einmal-SFX (`None` = Laden schlug fehl, nur einmal
/// melden, nicht jeden Klick).
audio: Option<audio::Audio>,
sfx_cache: HashMap<String, Option<audio::Sound>>,
}
impl App {
@@ -230,6 +261,7 @@ impl App {
images: Vec<Image>,
ui_textures: Vec<Image>,
fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
@@ -249,6 +281,8 @@ impl App {
images,
ui_textures,
fonts,
audio,
sfx_cache: HashMap::new(),
}
}
@@ -412,6 +446,19 @@ impl App {
self.input.mouse_dy = 0.0;
self.input.jump = false;
// Audio: Distance ramps der Ambient-Emitter nachführen und
// aufgelaufene Einmal-SFX (play_sound-Actions) starten. Die Queue
// immer leeren, auch ohne Gerät — sonst wüchse sie unbegrenzt.
let sfx = self.session.take_sounds();
if let Some(a) = &self.audio {
a.set_ambient_gains(&self.session.emitter_gains());
for name in sfx {
let sound = self.sfx_cache.entry(name.clone())
.or_insert_with(|| load_sound(&name));
if let Some(s) = sound { a.play(s); }
}
}
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).