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 }