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:
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:wolf_3d_cli/cli_game_loop.dart';
|
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.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_engine.dart';
|
||||||
@@ -46,13 +47,15 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CliGameLoop? gameLoop;
|
CliGameLoop? gameLoop;
|
||||||
|
late final WolfEngine engine;
|
||||||
|
|
||||||
void stopAndExit(int code) {
|
void stopAndExit(int code) {
|
||||||
gameLoop?.stop();
|
gameLoop?.stop();
|
||||||
|
engine.audio.dispose();
|
||||||
exitCleanly(code);
|
exitCleanly(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
final engine = WolfEngine(
|
engine = WolfEngine(
|
||||||
availableGames: availableGames.values.toList(growable: false),
|
availableGames: availableGames.values.toList(growable: false),
|
||||||
startingEpisode: 0,
|
startingEpisode: 0,
|
||||||
frameBuffer: FrameBuffer(
|
frameBuffer: FrameBuffer(
|
||||||
@@ -60,11 +63,13 @@ void main() async {
|
|||||||
stdout.terminalLines,
|
stdout.terminalLines,
|
||||||
),
|
),
|
||||||
input: CliInput(),
|
input: CliInput(),
|
||||||
|
engineAudio: CliSubprocessAudio(),
|
||||||
onGameWon: () => stopAndExit(0),
|
onGameWon: () => stopAndExit(0),
|
||||||
onQuit: () => stopAndExit(0),
|
onQuit: () => stopAndExit(0),
|
||||||
saveGamePersistence: DefaultSaveGamePersistence(),
|
saveGamePersistence: DefaultSaveGamePersistence(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await engine.audio.init();
|
||||||
engine.init();
|
engine.init();
|
||||||
|
|
||||||
final persistence = DefaultRendererSettingsPersistence();
|
final persistence = DefaultRendererSettingsPersistence();
|
||||||
|
|||||||
@@ -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<Process> _sfxProcesses = <Process>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<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 _playMusicTrack(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 _playMusicTrack(data.music[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playMusicTrack(ImfMusic track) async {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pcmSamples = ImfRenderer.render(track);
|
||||||
|
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
|
||||||
|
|
||||||
|
await _startLoopingMusic(wavBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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<Process> _startFilePlaybackProcess(
|
||||||
|
String executable,
|
||||||
|
List<String> 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, <String>[
|
||||||
|
...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<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 {
|
||||||
|
await _cleanupTempWav(path);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _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<void> _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 }
|
||||||
@@ -5,7 +5,6 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_flutter/wolf_3d_flutter.dart';
|
||||||
import 'package:wolf_3d_gui/screens/gallery_game_selector.dart';
|
import 'package:wolf_3d_gui/screens/gallery_game_selector.dart';
|
||||||
|
|
||||||
@@ -142,15 +141,16 @@ class _AudioGalleryState extends State<AudioGallery> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final engineAudio = widget.wolf3d.audio;
|
final engineAudio = widget.wolf3d.audio;
|
||||||
if (engineAudio is! WolfAudio) {
|
if (engineAudio is! DebugMusicPlayer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final debugAudio = engineAudio as DebugMusicPlayer;
|
||||||
|
|
||||||
if (trackIndex < 0 || trackIndex >= _selectedGame.music.length) {
|
if (trackIndex < 0 || trackIndex >= _selectedGame.music.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await engineAudio.playMusic(_selectedGame.music[trackIndex]);
|
await debugAudio.playMusic(_selectedGame.music[trackIndex]);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/// Entry point for the sound synthesis module of Wolf3D.
|
/// Entry point for the sound synthesis module of Wolf3D.
|
||||||
///
|
///
|
||||||
/// This library provides access to audio functionalities, primarily by exporting
|
/// This library provides access to audio synthesis and WAV encoding helpers.
|
||||||
/// the [WolfAudio] class.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/synth/wolf_3d_audio.dart' show WolfAudio;
|
export 'src/synth/imf_renderer.dart' show ImfRenderer;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ environment:
|
|||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
arcane_helper_utils: ^1.4.7
|
arcane_helper_utils: ^1.4.7
|
||||||
audioplayers: ^6.6.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
}
|
||||||
+11
-41
@@ -2,29 +2,25 @@ import 'dart:developer';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.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
|
@override
|
||||||
Future<void> debugSoundTest() async {
|
Future<void> debugSoundTest() async {
|
||||||
// Play the first 50 sounds with a 2-second gap to identify them
|
|
||||||
for (int i = 0; i < 50; i++) {
|
for (int i = 0; i < 50; i++) {
|
||||||
Future.delayed(Duration(seconds: i * 2), () {
|
Future.delayed(Duration(seconds: i * 2), () {
|
||||||
log("[AUDIO] Testing Sound ID: $i");
|
log('[AUDIO] Testing Sound ID: $i');
|
||||||
playSoundEffectId(i);
|
playSoundEffectId(i);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
|
||||||
// --- Music State ---
|
|
||||||
final AudioPlayer _musicPlayer = AudioPlayer();
|
final AudioPlayer _musicPlayer = AudioPlayer();
|
||||||
|
|
||||||
// --- SFX State ---
|
|
||||||
// A pool of players to allow overlapping sound effects.
|
|
||||||
static const int _maxSfxChannels = 8;
|
static const int _maxSfxChannels = 8;
|
||||||
final List<AudioPlayer> _sfxPlayers = [];
|
final List<AudioPlayer> _sfxPlayers = [];
|
||||||
int _currentSfxIndex = 0;
|
int _currentSfxIndex = 0;
|
||||||
@@ -32,19 +28,15 @@ class WolfAudio implements EngineAudio {
|
|||||||
@override
|
@override
|
||||||
WolfensteinData? activeGame;
|
WolfensteinData? activeGame;
|
||||||
|
|
||||||
/// Initializes the audio engine and pre-allocates the SFX pool.
|
|
||||||
@override
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set music player mode
|
|
||||||
await _musicPlayer.setPlayerMode(PlayerMode.mediaPlayer);
|
await _musicPlayer.setPlayerMode(PlayerMode.mediaPlayer);
|
||||||
|
|
||||||
// Initialize the SFX pool
|
|
||||||
for (int i = 0; i < _maxSfxChannels; i++) {
|
for (int i = 0; i < _maxSfxChannels; i++) {
|
||||||
final player = AudioPlayer();
|
final player = AudioPlayer();
|
||||||
// lowLatency mode is highly recommended for short game sounds
|
|
||||||
await player.setPlayerMode(PlayerMode.lowLatency);
|
await player.setPlayerMode(PlayerMode.lowLatency);
|
||||||
await player.setReleaseMode(ReleaseMode.stop);
|
await player.setReleaseMode(ReleaseMode.stop);
|
||||||
_sfxPlayers.add(player);
|
_sfxPlayers.add(player);
|
||||||
@@ -52,14 +44,13 @@ class WolfAudio implements EngineAudio {
|
|||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
log(
|
log(
|
||||||
"[AUDIO] AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.",
|
'[AUDIO] AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
stopAllAudio();
|
stopAllAudio();
|
||||||
@@ -74,10 +65,7 @@ class WolfAudio implements EngineAudio {
|
|||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
@override
|
||||||
// MUSIC MANAGEMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
|
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
|
||||||
if (!_isInitialized) return;
|
if (!_isInitialized) return;
|
||||||
await stopMusic();
|
await stopMusic();
|
||||||
@@ -91,7 +79,7 @@ class WolfAudio implements EngineAudio {
|
|||||||
);
|
);
|
||||||
await _musicPlayer.play(BytesSource(wavBytes));
|
await _musicPlayer.play(BytesSource(wavBytes));
|
||||||
} catch (e) {
|
} 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) {
|
if (index < data.music.length) {
|
||||||
await playMusic(data.music[index]);
|
await playMusic(data.music[index]);
|
||||||
} else {
|
} else {
|
||||||
log("[AUDIO] Warning - Track index $index out of bounds.");
|
log('[AUDIO] Warning - Track index $index out of bounds.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SFX MANAGEMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> playSoundEffect(SoundEffect effect) async {
|
Future<void> playSoundEffect(SoundEffect effect) async {
|
||||||
final data = activeGame;
|
final data = activeGame;
|
||||||
@@ -159,9 +143,6 @@ class WolfAudio implements EngineAudio {
|
|||||||
return;
|
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) {
|
if (data.version == GameVersion.spearOfDestinyDemo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,11 +152,7 @@ class WolfAudio implements EngineAudio {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> playSoundEffectId(int sfxId) async {
|
Future<void> playSoundEffectId(int sfxId) async {
|
||||||
log("[AUDIO] Playing sfx id $sfxId");
|
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;
|
final data = activeGame;
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
@@ -186,16 +163,11 @@ class WolfAudio implements EngineAudio {
|
|||||||
final raw8bitBytes = soundsList[sfxId].bytes;
|
final raw8bitBytes = soundsList[sfxId].bytes;
|
||||||
if (raw8bitBytes.isEmpty) return;
|
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);
|
final Int16List converted16bit = Int16List(raw8bitBytes.length);
|
||||||
for (int i = 0; i < raw8bitBytes.length; i++) {
|
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;
|
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Wrap in a WAV header (Wolf3D digitized sounds are 7000Hz Mono)
|
|
||||||
final wavBytes = ImfRenderer.createWavFile(
|
final wavBytes = ImfRenderer.createWavFile(
|
||||||
converted16bit,
|
converted16bit,
|
||||||
sampleRate: 7000,
|
sampleRate: 7000,
|
||||||
@@ -204,11 +176,9 @@ class WolfAudio implements EngineAudio {
|
|||||||
try {
|
try {
|
||||||
final player = _sfxPlayers[_currentSfxIndex];
|
final player = _sfxPlayers[_currentSfxIndex];
|
||||||
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
|
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
|
||||||
|
|
||||||
// Note: We use BytesSource because createWavFile returns Uint8List (the file bytes)
|
|
||||||
await player.play(BytesSource(wavBytes));
|
await player.play(BytesSource(wavBytes));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("[AUDIO] SFX Error - $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.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_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';
|
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.
|
/// Coordinates asset discovery, audio initialization, and input reuse for apps.
|
||||||
class Wolf3d {
|
class Wolf3d {
|
||||||
/// Creates an empty facade that must be initialized with [init].
|
/// 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.
|
/// All successfully discovered or bundled game data sets.
|
||||||
final List<WolfensteinData> availableGames = [];
|
final List<WolfensteinData> availableGames = [];
|
||||||
WolfensteinData? _activeGame;
|
WolfensteinData? _activeGame;
|
||||||
|
|
||||||
/// Shared engine audio backend used by menus and gameplay sessions.
|
/// 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.
|
/// Engine menu background color as 24-bit RGB.
|
||||||
int menuBackgroundRgb = 0x890000;
|
int menuBackgroundRgb = 0x890000;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
wolf_3d_assets: any
|
wolf_3d_assets: any
|
||||||
|
audioplayers: ^6.6.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user