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,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 ~50–200 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user