From b88475882b388bc071a93ec362f59c4c3d637aac Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 17:10:07 +0100 Subject: [PATCH] feat: Implement audio backend with subprocess support and refactor audio handling Signed-off-by: Hans Kokx --- apps/wolf_3d_cli/bin/main.dart | 7 +- .../wolf_3d_cli/lib/cli_subprocess_audio.dart | 422 ++++++++++++++++++ .../lib/screens/audio_gallery.dart | 6 +- packages/wolf_3d_dart/lib/wolf_3d_synth.dart | 5 +- packages/wolf_3d_dart/pubspec.yaml | 1 - .../lib/audio/debug_music_player.dart | 5 + .../lib/audio/default_audio_backend.dart | 28 ++ .../audio/desktop_subprocess_audio_io.dart | 390 ++++++++++++++++ .../audio/desktop_subprocess_audio_stub.dart | 40 ++ .../lib/audio/wolf_audio.dart} | 52 +-- .../wolf_3d_flutter/lib/wolf_3d_flutter.dart | 10 +- packages/wolf_3d_flutter/pubspec.yaml | 1 + 12 files changed, 915 insertions(+), 52 deletions(-) create mode 100644 apps/wolf_3d_cli/lib/cli_subprocess_audio.dart create mode 100644 packages/wolf_3d_flutter/lib/audio/debug_music_player.dart create mode 100644 packages/wolf_3d_flutter/lib/audio/default_audio_backend.dart create mode 100644 packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_io.dart create mode 100644 packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_stub.dart rename packages/{wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart => wolf_3d_flutter/lib/audio/wolf_audio.dart} (66%) diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index f00d59f..0daf4c1 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -7,6 +7,7 @@ library; import 'dart:io'; import 'package:wolf_3d_cli/cli_game_loop.dart'; +import 'package:wolf_3d_cli/cli_subprocess_audio.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'; @@ -46,13 +47,15 @@ void main() async { } CliGameLoop? gameLoop; + late final WolfEngine engine; void stopAndExit(int code) { gameLoop?.stop(); + engine.audio.dispose(); exitCleanly(code); } - final engine = WolfEngine( + engine = WolfEngine( availableGames: availableGames.values.toList(growable: false), startingEpisode: 0, frameBuffer: FrameBuffer( @@ -60,11 +63,13 @@ void main() async { stdout.terminalLines, ), input: CliInput(), + engineAudio: CliSubprocessAudio(), onGameWon: () => stopAndExit(0), onQuit: () => stopAndExit(0), saveGamePersistence: DefaultSaveGamePersistence(), ); + await engine.audio.init(); engine.init(); final persistence = DefaultRendererSettingsPersistence(); diff --git a/apps/wolf_3d_cli/lib/cli_subprocess_audio.dart b/apps/wolf_3d_cli/lib/cli_subprocess_audio.dart new file mode 100644 index 0000000..3580d59 --- /dev/null +++ b/apps/wolf_3d_cli/lib/cli_subprocess_audio.dart @@ -0,0 +1,422 @@ +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'; + +class CliSubprocessAudio implements EngineAudio { + CliSubprocessAudio({this.maxConcurrentSfx = 8}); + + 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 _sfxProcesses = []; + + @override + Future init() async { + if (_initialized) { + return; + } + + _backend = await _detectBackend(); + _isSupported = _backend != _AudioBackend.none; + _initialized = true; + + if (_isSupported) { + log('[CLI AUDIO] Subprocess backend enabled: ${_backend.name}'); + } else { + log('[CLI AUDIO] No supported audio backend found; running silent.'); + } + } + + @override + void dispose() { + unawaited(stopAllAudio()); + } + + @override + Future debugSoundTest() async { + for (int i = 0; i < 50; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + await playSoundEffectId(i); + } + } + + @override + Future 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 _playMusicTrack(data.music[trackIndex]); + } + + @override + Future 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 _playMusicTrack(data.music[index]); + } + + Future _playMusicTrack(ImfMusic track) async { + if (!_isSupported) { + return; + } + + final pcmSamples = ImfRenderer.render(track); + final wavBytes = ImfRenderer.createWavFile(pcmSamples); + + await _startLoopingMusic(wavBytes); + } + + Future _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 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 stopAllAudio() async { + await stopMusic(); + + for (final process in List.from(_sfxProcesses)) { + process.kill(); + } + _sfxProcesses.clear(); + } + + @override + Future 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 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 _startPlaybackProcess({ + required Uint8List wavBytes, + required _PlaybackRole role, + }) async { + try { + switch (_backend) { + case _AudioBackend.linuxPipeWire: + return _startFilePlaybackProcess('pw-play', const [], wavBytes, role); + case _AudioBackend.linuxPulseAudio: + return _startFilePlaybackProcess('paplay', const [], wavBytes, role); + case _AudioBackend.linuxAplay: + return _startFilePlaybackProcess( + 'aplay', + const ['-q'], + wavBytes, + role, + ); + case _AudioBackend.macosAfplay: + return _startFilePlaybackProcess('afplay', const [], wavBytes, role); + case _AudioBackend.windowsPowerShell: + return _startWindowsPlaybackProcess(wavBytes, role: role); + case _AudioBackend.none: + return null; + } + } catch (error) { + log('[CLI AUDIO] Failed to start playback process: $error'); + return null; + } + } + + Future _startFilePlaybackProcess( + String executable, + List baseArguments, + Uint8List wavBytes, + _PlaybackRole role, + ) async { + final path = await _writeTempWav(wavBytes, prefix: 'wolf3d_cli_audio_'); + + 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 process = await Process.start(executable, [ + ...baseArguments, + path, + ]); + + unawaited( + process.exitCode.then((code) async { + if (code != 0) { + log('[CLI AUDIO] Player exited with code $code: $executable'); + } + + await _cleanupTempWav(path); + }), + ); + + return process; + } + + Future _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, [ + '-NoProfile', + '-NonInteractive', + '-Command', + script, + ]); + + unawaited( + process.exitCode.then((_) async { + await _cleanupTempWav(path); + }), + ); + + return process; + } + + Future _writeWindowsTempWav( + Uint8List wavBytes, { + required _PlaybackRole role, + }) async { + final tempDir = await Directory.systemTemp.createTemp('wolf3d_cli_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 hasPwPlay = await _commandExists('pw-play'); + if (hasPwPlay) { + return _AudioBackend.linuxPipeWire; + } + + final hasPaplay = await _commandExists('paplay'); + if (hasPaplay) { + return _AudioBackend.linuxPulseAudio; + } + + 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 _commandExists(String command) async { + final probe = Platform.isWindows + ? await Process.run('where', [command]) + : await Process.run('which', [command]); + return probe.exitCode == 0; + } + + Future _writeTempWav( + Uint8List wavBytes, { + required String prefix, + }) async { + final tempDir = await Directory.systemTemp.createTemp(prefix); + final path = + '${tempDir.path}${Platform.pathSeparator}audio_${DateTime.now().microsecondsSinceEpoch}.wav'; + await File(path).writeAsBytes(wavBytes, flush: true); + return path; + } + + Future _cleanupTempWav(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + + final directory = file.parent; + if (await directory.exists()) { + await directory.delete(); + } + } catch (error) { + log('[CLI AUDIO] Temp WAV cleanup failed: $error'); + } + } +} + +enum _AudioBackend { + none, + linuxPipeWire, + linuxPulseAudio, + linuxAplay, + macosAfplay, + windowsPowerShell, +} + +enum _PlaybackRole { music, sfx } diff --git a/apps/wolf_3d_gui/lib/screens/audio_gallery.dart b/apps/wolf_3d_gui/lib/screens/audio_gallery.dart index a1925d9..55c556b 100644 --- a/apps/wolf_3d_gui/lib/screens/audio_gallery.dart +++ b/apps/wolf_3d_gui/lib/screens/audio_gallery.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -import 'package:wolf_3d_dart/wolf_3d_synth.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_gui/screens/gallery_game_selector.dart'; @@ -142,15 +141,16 @@ class _AudioGalleryState extends State { } final engineAudio = widget.wolf3d.audio; - if (engineAudio is! WolfAudio) { + if (engineAudio is! DebugMusicPlayer) { return; } + final debugAudio = engineAudio as DebugMusicPlayer; if (trackIndex < 0 || trackIndex >= _selectedGame.music.length) { return; } - await engineAudio.playMusic(_selectedGame.music[trackIndex]); + await debugAudio.playMusic(_selectedGame.music[trackIndex]); if (!mounted) { return; } diff --git a/packages/wolf_3d_dart/lib/wolf_3d_synth.dart b/packages/wolf_3d_dart/lib/wolf_3d_synth.dart index bcffb04..20e4f07 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_synth.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_synth.dart @@ -1,7 +1,6 @@ /// Entry point for the sound synthesis module of Wolf3D. /// -/// This library provides access to audio functionalities, primarily by exporting -/// the [WolfAudio] class. +/// This library provides access to audio synthesis and WAV encoding helpers. library; -export 'src/synth/wolf_3d_audio.dart' show WolfAudio; +export 'src/synth/imf_renderer.dart' show ImfRenderer; diff --git a/packages/wolf_3d_dart/pubspec.yaml b/packages/wolf_3d_dart/pubspec.yaml index 1c79277..ea7c1f0 100644 --- a/packages/wolf_3d_dart/pubspec.yaml +++ b/packages/wolf_3d_dart/pubspec.yaml @@ -8,7 +8,6 @@ environment: dependencies: arcane_helper_utils: ^1.4.7 - audioplayers: ^6.6.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/wolf_3d_flutter/lib/audio/debug_music_player.dart b/packages/wolf_3d_flutter/lib/audio/debug_music_player.dart new file mode 100644 index 0000000..c10dcc4 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/audio/debug_music_player.dart @@ -0,0 +1,5 @@ +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +abstract class DebugMusicPlayer { + Future playMusic(ImfMusic track, {bool looping = true}); +} diff --git a/packages/wolf_3d_flutter/lib/audio/default_audio_backend.dart b/packages/wolf_3d_flutter/lib/audio/default_audio_backend.dart new file mode 100644 index 0000000..3040e64 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/audio/default_audio_backend.dart @@ -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; + } +} diff --git a/packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_io.dart b/packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_io.dart new file mode 100644 index 0000000..d35f780 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_io.dart @@ -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 _sfxProcesses = []; + + @override + Future 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 debugSoundTest() async { + for (int i = 0; i < 50; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + await playSoundEffectId(i); + } + } + + @override + Future 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 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 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 _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 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 stopAllAudio() async { + await stopMusic(); + + for (final process in List.from(_sfxProcesses)) { + process.kill(); + } + _sfxProcesses.clear(); + } + + @override + Future 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 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 _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 _startStdInPlaybackProcess( + String executable, + List arguments, + Uint8List wavBytes, + ) async { + final process = await Process.start(executable, arguments); + + try { + await process.stdin.addStream( + Stream>.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 _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, [ + '-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 _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 _commandExists(String command) async { + final probe = Platform.isWindows + ? await Process.run('where', [command]) + : await Process.run('which', [command]); + return probe.exitCode == 0; + } +} + +enum _AudioBackend { none, linuxAplay, macosAfplay, windowsPowerShell } + +enum _PlaybackRole { music, sfx } diff --git a/packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_stub.dart b/packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_stub.dart new file mode 100644 index 0000000..c1b7718 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/audio/desktop_subprocess_audio_stub.dart @@ -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 debugSoundTest() async {} + + @override + Future init() async {} + + @override + void dispose() {} + + @override + Future playMenuMusic() async {} + + @override + Future playLevelMusic(Music music) async {} + + @override + Future stopMusic() async {} + + @override + Future stopAllAudio() async {} + + @override + Future playSoundEffect(SoundEffect effect) async {} + + @override + Future playSoundEffectId(int sfxId) async {} + + @override + Future playMusic(ImfMusic track, {bool looping = true}) async {} +} diff --git a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart b/packages/wolf_3d_flutter/lib/audio/wolf_audio.dart similarity index 66% rename from packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart rename to packages/wolf_3d_flutter/lib/audio/wolf_audio.dart index 9232e29..96d32fa 100644 --- a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart +++ b/packages/wolf_3d_flutter/lib/audio/wolf_audio.dart @@ -2,29 +2,25 @@ 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'; +import 'package:wolf_3d_dart/wolf_3d_synth.dart'; +import 'package:wolf_3d_flutter/audio/debug_music_player.dart'; -class WolfAudio implements EngineAudio { +class WolfAudio implements EngineAudio, DebugMusicPlayer { @override Future 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"); + 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 _sfxPlayers = []; int _currentSfxIndex = 0; @@ -32,19 +28,15 @@ class WolfAudio implements EngineAudio { @override WolfensteinData? activeGame; - /// Initializes the audio engine and pre-allocates the SFX pool. @override Future 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); @@ -52,14 +44,13 @@ class WolfAudio implements EngineAudio { _isInitialized = true; log( - "[AUDIO] AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.", + '[AUDIO] AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.', ); } catch (e) { - log("[AUDIO] Failed to initialize AudioPlayers - $e"); + log('[AUDIO] Failed to initialize AudioPlayers - $e'); } } - /// Disposes of the audio engine and frees resources. @override void dispose() { stopAllAudio(); @@ -74,10 +65,7 @@ class WolfAudio implements EngineAudio { _isInitialized = false; } - // ========================================== - // MUSIC MANAGEMENT - // ========================================== - + @override Future playMusic(ImfMusic track, {bool looping = true}) async { if (!_isInitialized) return; await stopMusic(); @@ -91,7 +79,7 @@ class WolfAudio implements EngineAudio { ); await _musicPlayer.play(BytesSource(wavBytes)); } catch (e) { - log("[AUDIO] Error playing music track - $e"); + log('[AUDIO] Error playing music track - $e'); } } @@ -140,14 +128,10 @@ class WolfAudio implements EngineAudio { if (index < data.music.length) { await playMusic(data.music[index]); } else { - log("[AUDIO] Warning - Track index $index out of bounds."); + log('[AUDIO] Warning - Track index $index out of bounds.'); } } - // ========================================== - // SFX MANAGEMENT - // ========================================== - @override Future playSoundEffect(SoundEffect effect) async { final data = activeGame; @@ -159,9 +143,6 @@ class WolfAudio implements EngineAudio { 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; } @@ -171,11 +152,7 @@ class WolfAudio implements EngineAudio { @override Future 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. + log('[AUDIO] Playing sfx id $sfxId'); final data = activeGame; if (data == null) return; @@ -186,16 +163,11 @@ class WolfAudio implements EngineAudio { 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, @@ -204,11 +176,9 @@ class WolfAudio implements EngineAudio { 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"); + log('[AUDIO] SFX Error - $e'); } } } diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index 06f9840..44acdee 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -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 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; diff --git a/packages/wolf_3d_flutter/pubspec.yaml b/packages/wolf_3d_flutter/pubspec.yaml index 859f0d6..d5d3e6f 100644 --- a/packages/wolf_3d_flutter/pubspec.yaml +++ b/packages/wolf_3d_flutter/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: flutter: sdk: flutter wolf_3d_assets: any + audioplayers: ^6.6.0 dev_dependencies: flutter_test: