Files
wds/src/render/audio.rs
T
irrlicht 31e1942870 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>
2026-07-16 20:37:18 +02:00

152 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
}
}
}