feat: Implement audio backend with subprocess support and refactor audio handling

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 17:10:07 +01:00
parent 26c738b702
commit b88475882b
12 changed files with 915 additions and 52 deletions
@@ -1,214 +0,0 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:wolf_3d_dart/src/synth/imf_renderer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class WolfAudio implements EngineAudio {
@override
Future<void> debugSoundTest() async {
// Play the first 50 sounds with a 2-second gap to identify them
for (int i = 0; i < 50; i++) {
Future.delayed(Duration(seconds: i * 2), () {
log("[AUDIO] Testing Sound ID: $i");
playSoundEffectId(i);
});
}
}
bool _isInitialized = false;
// --- Music State ---
final AudioPlayer _musicPlayer = AudioPlayer();
// --- SFX State ---
// A pool of players to allow overlapping sound effects.
static const int _maxSfxChannels = 8;
final List<AudioPlayer> _sfxPlayers = [];
int _currentSfxIndex = 0;
@override
WolfensteinData? activeGame;
/// Initializes the audio engine and pre-allocates the SFX pool.
@override
Future<void> init() async {
if (_isInitialized) return;
try {
// Set music player mode
await _musicPlayer.setPlayerMode(PlayerMode.mediaPlayer);
// Initialize the SFX pool
for (int i = 0; i < _maxSfxChannels; i++) {
final player = AudioPlayer();
// lowLatency mode is highly recommended for short game sounds
await player.setPlayerMode(PlayerMode.lowLatency);
await player.setReleaseMode(ReleaseMode.stop);
_sfxPlayers.add(player);
}
_isInitialized = true;
log(
"[AUDIO] AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.",
);
} catch (e) {
log("[AUDIO] Failed to initialize AudioPlayers - $e");
}
}
/// Disposes of the audio engine and frees resources.
@override
void dispose() {
stopAllAudio();
_musicPlayer.dispose();
for (final player in _sfxPlayers) {
player.stop();
player.dispose();
}
_sfxPlayers.clear();
_isInitialized = false;
}
// ==========================================
// MUSIC MANAGEMENT
// ==========================================
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
if (!_isInitialized) return;
await stopMusic();
try {
final pcmSamples = ImfRenderer.render(track);
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
await _musicPlayer.setReleaseMode(
looping ? ReleaseMode.loop : ReleaseMode.stop,
);
await _musicPlayer.play(BytesSource(wavBytes));
} catch (e) {
log("[AUDIO] Error playing music track - $e");
}
}
@override
Future<void> stopMusic() async {
if (!_isInitialized) return;
await _musicPlayer.stop();
}
@override
Future<void> stopAllAudio() async {
if (!_isInitialized) return;
await _musicPlayer.stop();
for (final player in _sfxPlayers) {
await player.stop();
}
}
Future<void> pauseMusic() async {
if (_isInitialized) await _musicPlayer.pause();
}
Future<void> resumeMusic() async {
if (_isInitialized) await _musicPlayer.resume();
}
@override
Future<void> playMenuMusic() async {
final data = activeGame;
final trackIndex = data == null
? null
: Music.menuTheme.trackIndexFor(data.version);
if (data == null || trackIndex == null || trackIndex >= data.music.length) {
return;
}
await playMusic(data.music[trackIndex]);
}
@override
Future<void> playLevelMusic(Music music) async {
final data = activeGame;
if (data == null || data.music.isEmpty) return;
final index = music.trackIndexFor(data.version) ?? 0;
if (index < data.music.length) {
await playMusic(data.music[index]);
} else {
log("[AUDIO] Warning - Track index $index out of bounds.");
}
}
// ==========================================
// SFX MANAGEMENT
// ==========================================
@override
Future<void> playSoundEffect(SoundEffect effect) async {
final data = activeGame;
if (data == null) return;
final resolved = data.registry.sfx.resolve(effect);
if (resolved != null) {
await playSoundEffectId(resolved.slotIndex);
return;
}
// Spear demo/shareware has a much smaller digitized table than retail.
// If a sound is not explicitly mapped for that variant, skip it rather
// than probing an invalid or unrelated slot.
if (data.version == GameVersion.spearOfDestinyDemo) {
return;
}
await playSoundEffectId(effect.idFor(data.version));
}
@override
Future<void> playSoundEffectId(int sfxId) async {
log("[AUDIO] Playing sfx id $sfxId");
// The original engine uses a specific starting chunk for digitized sounds.
// In many loaders, the 'sounds' list is already just the digitized ones.
// If your list contains EVERYTHING, you need to add the offset (174).
// If it's JUST digitized sounds, sfxId should work directly.
final data = activeGame;
if (data == null) return;
final soundsList = data.sounds;
if (sfxId < 0 || sfxId >= soundsList.length) return;
final raw8bitBytes = soundsList[sfxId].bytes;
if (raw8bitBytes.isEmpty) return;
// 2. Convert 8-bit Unsigned PCM -> 16-bit Signed PCM
// Wolf3D raw sounds are biased at 128.
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
// (sample - 128) shifts it to signed 8-bit range (-128 to 127)
// Multiplying by 256 scales it to 16-bit range (-32768 to 32512)
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
// 3. Wrap in a WAV header (Wolf3D digitized sounds are 7000Hz Mono)
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
try {
final player = _sfxPlayers[_currentSfxIndex];
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
// Note: We use BytesSource because createWavFile returns Uint8List (the file bytes)
await player.play(BytesSource(wavBytes));
} catch (e) {
log("[AUDIO] SFX Error - $e");
}
}
}