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
@@ -0,0 +1,5 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
abstract class DebugMusicPlayer {
Future<void> playMusic(ImfMusic track, {bool looping = true});
}
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_flutter/audio/desktop_subprocess_audio_stub.dart'
if (dart.library.io) 'package:wolf_3d_flutter/audio/desktop_subprocess_audio_io.dart';
import 'package:wolf_3d_flutter/audio/wolf_audio.dart';
EngineAudio createDefaultAudioBackend() {
if (!kIsWeb &&
_isDesktopTarget() &&
DesktopSubprocessAudio.supportsCurrentPlatform) {
return DesktopSubprocessAudio();
}
return WolfAudio();
}
bool _isDesktopTarget() {
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.linux:
return true;
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
return false;
}
}
@@ -0,0 +1,390 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_synth.dart';
import 'package:wolf_3d_flutter/audio/debug_music_player.dart';
class DesktopSubprocessAudio implements EngineAudio, DebugMusicPlayer {
DesktopSubprocessAudio({this.maxConcurrentSfx = 8});
static bool get supportsCurrentPlatform =>
Platform.isLinux || Platform.isMacOS || Platform.isWindows;
final int maxConcurrentSfx;
@override
WolfensteinData? activeGame;
bool _initialized = false;
bool _isSupported = false;
_AudioBackend _backend = _AudioBackend.none;
String _windowsShellCommand = 'powershell';
Process? _musicProcess;
int _musicLoopToken = 0;
String? _musicTempFilePath;
final List<Process> _sfxProcesses = <Process>[];
@override
Future<void> init() async {
if (_initialized) {
return;
}
_backend = await _detectBackend();
_isSupported = _backend != _AudioBackend.none;
_initialized = true;
if (_isSupported) {
log('[DESKTOP AUDIO] Subprocess backend enabled: ${_backend.name}');
} else {
log('[DESKTOP AUDIO] No supported audio backend found; running silent.');
}
}
@override
void dispose() {
unawaited(stopAllAudio());
}
@override
Future<void> debugSoundTest() async {
for (int i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await playSoundEffectId(i);
}
}
@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 < 0 || index >= data.music.length) {
return;
}
await playMusic(data.music[index]);
}
@override
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
if (!_isSupported) {
return;
}
final pcmSamples = ImfRenderer.render(track);
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
if (looping) {
await _startLoopingMusic(wavBytes);
return;
}
await stopMusic();
final process = await _startPlaybackProcess(
wavBytes: wavBytes,
role: _PlaybackRole.music,
);
if (process != null) {
_musicProcess = process;
}
}
Future<void> _startLoopingMusic(Uint8List wavBytes) async {
await stopMusic();
final int token = ++_musicLoopToken;
while (_musicLoopToken == token) {
final process = await _startPlaybackProcess(
wavBytes: wavBytes,
role: _PlaybackRole.music,
);
if (process == null) {
break;
}
_musicProcess = process;
await process.exitCode;
if (_musicLoopToken != token) {
break;
}
}
}
@override
Future<void> stopMusic() async {
_musicLoopToken++;
_musicProcess?.kill();
_musicProcess = null;
final path = _musicTempFilePath;
_musicTempFilePath = null;
if (path != null) {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
}
@override
Future<void> stopAllAudio() async {
await stopMusic();
for (final process in List<Process>.from(_sfxProcesses)) {
process.kill();
}
_sfxProcesses.clear();
}
@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;
}
if (data.version == GameVersion.spearOfDestinyDemo) {
return;
}
await playSoundEffectId(effect.idFor(data.version));
}
@override
Future<void> playSoundEffectId(int sfxId) async {
if (!_isSupported) {
return;
}
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;
}
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
if (_sfxProcesses.length >= maxConcurrentSfx) {
final oldest = _sfxProcesses.removeAt(0);
oldest.kill();
}
final process = await _startPlaybackProcess(
wavBytes: wavBytes,
role: _PlaybackRole.sfx,
);
if (process == null) {
return;
}
_sfxProcesses.add(process);
unawaited(
process.exitCode.then((_) {
_sfxProcesses.remove(process);
}),
);
}
Future<Process?> _startPlaybackProcess({
required Uint8List wavBytes,
required _PlaybackRole role,
}) async {
try {
switch (_backend) {
case _AudioBackend.linuxAplay:
return _startStdInPlaybackProcess('aplay', const [
'-q',
'-',
], wavBytes);
case _AudioBackend.macosAfplay:
return _startStdInPlaybackProcess('afplay', const ['-'], wavBytes);
case _AudioBackend.windowsPowerShell:
return _startWindowsPlaybackProcess(wavBytes, role: role);
case _AudioBackend.none:
return null;
}
} catch (error) {
log('[DESKTOP AUDIO] Failed to start playback process: $error');
return null;
}
}
Future<Process> _startStdInPlaybackProcess(
String executable,
List<String> arguments,
Uint8List wavBytes,
) async {
final process = await Process.start(executable, arguments);
try {
await process.stdin.addStream(
Stream<List<int>>.value(wavBytes),
);
} on SocketException catch (error) {
log('[DESKTOP AUDIO] Playback pipe write failed: $error');
} catch (error) {
log('[DESKTOP AUDIO] Playback pipe write failed: $error');
} finally {
try {
await process.stdin.close();
} on SocketException catch (error) {
log('[DESKTOP AUDIO] Playback pipe close failed: $error');
} catch (error) {
log('[DESKTOP AUDIO] Playback pipe close failed: $error');
}
}
return process;
}
Future<Process> _startWindowsPlaybackProcess(
Uint8List wavBytes, {
required _PlaybackRole role,
}) async {
final path = await _writeWindowsTempWav(wavBytes, role: role);
if (role == _PlaybackRole.music) {
final existing = _musicTempFilePath;
_musicTempFilePath = path;
if (existing != null && existing != path) {
final previous = File(existing);
if (await previous.exists()) {
await previous.delete();
}
}
}
final escapedPath = path.replaceAll("'", "''");
final script = "(New-Object Media.SoundPlayer '$escapedPath').PlaySync()";
final process = await Process.start(_windowsShellCommand, <String>[
'-NoProfile',
'-NonInteractive',
'-Command',
script,
]);
unawaited(
process.exitCode.then((_) async {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
final directory = file.parent;
if (await directory.exists()) {
await directory.delete();
}
}),
);
return process;
}
Future<String> _writeWindowsTempWav(
Uint8List wavBytes, {
required _PlaybackRole role,
}) async {
final tempDir = await Directory.systemTemp.createTemp(
'wolf3d_flutter_audio_',
);
final suffix = role == _PlaybackRole.music
? 'music_${DateTime.now().microsecondsSinceEpoch}.wav'
: 'sfx_${DateTime.now().microsecondsSinceEpoch}.wav';
final path = '${tempDir.path}${Platform.pathSeparator}$suffix';
await File(path).writeAsBytes(wavBytes, flush: true);
return path;
}
Future<_AudioBackend> _detectBackend() async {
if (Platform.isLinux) {
final hasAplay = await _commandExists('aplay');
if (hasAplay) {
return _AudioBackend.linuxAplay;
}
}
if (Platform.isMacOS) {
final hasAfplay = await _commandExists('afplay');
if (hasAfplay) {
return _AudioBackend.macosAfplay;
}
}
if (Platform.isWindows) {
final hasPowerShell = await _commandExists('powershell');
if (hasPowerShell) {
_windowsShellCommand = 'powershell';
return _AudioBackend.windowsPowerShell;
}
final hasPwsh = await _commandExists('pwsh');
if (hasPwsh) {
_windowsShellCommand = 'pwsh';
return _AudioBackend.windowsPowerShell;
}
}
return _AudioBackend.none;
}
Future<bool> _commandExists(String command) async {
final probe = Platform.isWindows
? await Process.run('where', <String>[command])
: await Process.run('which', <String>[command]);
return probe.exitCode == 0;
}
}
enum _AudioBackend { none, linuxAplay, macosAfplay, windowsPowerShell }
enum _PlaybackRole { music, sfx }
@@ -0,0 +1,40 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_flutter/audio/debug_music_player.dart';
class DesktopSubprocessAudio implements EngineAudio, DebugMusicPlayer {
static bool get supportsCurrentPlatform => false;
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void dispose() {}
@override
Future<void> playMenuMusic() async {}
@override
Future<void> playLevelMusic(Music music) async {}
@override
Future<void> stopMusic() async {}
@override
Future<void> stopAllAudio() async {}
@override
Future<void> playSoundEffect(SoundEffect effect) async {}
@override
Future<void> playSoundEffectId(int sfxId) async {}
@override
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {}
}
@@ -0,0 +1,184 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_synth.dart';
import 'package:wolf_3d_flutter/audio/debug_music_player.dart';
class WolfAudio implements EngineAudio, DebugMusicPlayer {
@override
Future<void> debugSoundTest() async {
for (int i = 0; i < 50; i++) {
Future.delayed(Duration(seconds: i * 2), () {
log('[AUDIO] Testing Sound ID: $i');
playSoundEffectId(i);
});
}
}
bool _isInitialized = false;
final AudioPlayer _musicPlayer = AudioPlayer();
static const int _maxSfxChannels = 8;
final List<AudioPlayer> _sfxPlayers = [];
int _currentSfxIndex = 0;
@override
WolfensteinData? activeGame;
@override
Future<void> init() async {
if (_isInitialized) return;
try {
await _musicPlayer.setPlayerMode(PlayerMode.mediaPlayer);
for (int i = 0; i < _maxSfxChannels; i++) {
final player = AudioPlayer();
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');
}
}
@override
void dispose() {
stopAllAudio();
_musicPlayer.dispose();
for (final player in _sfxPlayers) {
player.stop();
player.dispose();
}
_sfxPlayers.clear();
_isInitialized = false;
}
@override
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.');
}
}
@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;
}
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');
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;
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
try {
final player = _sfxPlayers[_currentSfxIndex];
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
await player.play(BytesSource(wavBytes));
} catch (e) {
log('[AUDIO] SFX Error - $e');
}
}
}
@@ -6,20 +6,24 @@ import 'package:flutter/services.dart';
import 'package:wolf_3d_dart/wolf_3d_data.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_synth.dart';
import 'package:wolf_3d_flutter/audio/default_audio_backend.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
export 'audio/debug_music_player.dart' show DebugMusicPlayer;
export 'audio/wolf_audio.dart' show WolfAudio;
/// Coordinates asset discovery, audio initialization, and input reuse for apps.
class Wolf3d {
/// Creates an empty facade that must be initialized with [init].
Wolf3d();
Wolf3d({EngineAudio? audioBackend})
: audio = audioBackend ?? createDefaultAudioBackend();
/// All successfully discovered or bundled game data sets.
final List<WolfensteinData> availableGames = [];
WolfensteinData? _activeGame;
/// Shared engine audio backend used by menus and gameplay sessions.
final EngineAudio audio = WolfAudio();
final EngineAudio audio;
/// Engine menu background color as 24-bit RGB.
int menuBackgroundRgb = 0x890000;
+1
View File
@@ -14,6 +14,7 @@ dependencies:
flutter:
sdk: flutter
wolf_3d_assets: any
audioplayers: ^6.6.0
dev_dependencies:
flutter_test: