diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 1e8fc55..7958e6c 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -49,6 +49,8 @@ Future main(List args) async { await AppSettings.instance.ensureLoaded(); // The composition root: every system, made once. final graph = AppGraph.build(); + // Sounds stay off after an audio-device crash (see SoundEngine.crashMark). + graph.sfx.engine.crashMark = '${dirs.support}${Platform.pathSeparator}audio-init.pending'; final ports = graph.ports; // Close button / Alt+F4: the orderly close ([shutDownApp]), so others // see "left", your server shuts down and no player outlives the engine. diff --git a/apps/desktop/lib/src/platform/audio/sound_engine.dart b/apps/desktop/lib/src/platform/audio/sound_engine.dart index b0cd922..d987e24 100644 --- a/apps/desktop/lib/src/platform/audio/sound_engine.dart +++ b/apps/desktop/lib/src/platform/audio/sound_engine.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_soloud/flutter_soloud.dart'; @@ -50,6 +51,13 @@ class SoundEngine { Future? _ready; var _disposed = false; + + /// A file that exists only while the audio device is opening (set by the + /// app). Found at the next start, the last open took the app down (a + /// native crash in the driver or miniaudio, seen on Windows): sounds stay + /// off rather than crash every start, until [retry] (Settings' preview). + String? crashMark; + var _skipped = false; /// Per slot: the loaded key and its sound (null: failed to load). final Map)> _slots = {}; /// Load names are unique: SoLoud hands back the old sound for a name it @@ -61,15 +69,43 @@ class SoundEngine { Future _init() async { if (_disposed) return false; + final mark = crashMark == null ? null : File(crashMark!); + try { + if (mark != null && mark.existsSync()) { + _skipped = true; + _log.warning('no sounds: opening the audio device crashed the app last time ' + '(${mark.path}); a sound preview in Settings tries again'); + return false; + } + mark?.writeAsStringSync('opening the audio device'); + } catch (_) {} + _log.info('opening the audio device'); try { await _backend.init(); + _log.info('audio device open'); return true; } catch (e) { _log.warning('no sounds: audio init failed: $e'); return false; + } finally { + try { + if (mark != null && mark.existsSync()) mark.deleteSync(); + } catch (_) {} } } + /// Opens the device again after a crash mark kept sounds off (the user + /// asked for a sound). A real crash leaves the mark again. + void retry() { + if (!_skipped) return; + _skipped = false; + try { + final m = crashMark; + if (m != null) File(m).deleteSync(); + } catch (_) {} + _ready = null; + } + /// Plays the sound of [slot] at [volume] (linear gain). [key] names what /// [wav] renders: the same key reuses the loaded sound; another key, or /// [fresh], renders and loads it again. diff --git a/apps/desktop/lib/src/sfx/synth_sfx.dart b/apps/desktop/lib/src/sfx/synth_sfx.dart index b7146f3..90cffc3 100644 --- a/apps/desktop/lib/src/sfx/synth_sfx.dart +++ b/apps/desktop/lib/src/sfx/synth_sfx.dart @@ -37,8 +37,11 @@ class SynthSfx implements SfxService { /// Plays [kind] even when it is switched off (Settings' preview), always /// freshly rendered: an edited spec plays after a hot reload. - Future preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) => - _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true); + Future preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) { + // Asked for: try the device again even after a crash kept it closed. + engine.retry(); + return _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true); + } /// One slot per sound and ready step: a new preset replaces it. Future _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) { diff --git a/apps/desktop/test/sound_engine_test.dart b/apps/desktop/test/sound_engine_test.dart index 90c1130..a678683 100644 --- a/apps/desktop/test/sound_engine_test.dart +++ b/apps/desktop/test/sound_engine_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; @@ -147,4 +148,46 @@ void main() { expect(b.deinits, 1); }); }); + + group('crash mark', () { + late Directory dir; + setUp(() => dir = Directory.systemTemp.createTempSync('sound_mark')); + tearDown(() => dir.deleteSync(recursive: true)); + + test('exists only while the device opens', () async { + final mark = '${dir.path}/audio-init.pending'; + var seen = false; + final b = _MarkBackend(() => seen = File(mark).existsSync()); + final e = SoundEngine(backend: b)..crashMark = mark; + expect(await e.warmUp(), isTrue); + expect(seen, isTrue, reason: 'there while opening'); + expect(File(mark).existsSync(), isFalse, reason: 'gone once open'); + }); + + test('left by a crash: sounds stay off, a preview tries again', () async { + final mark = '${dir.path}/audio-init.pending'; + File(mark).writeAsStringSync('opening the audio device'); + final b = _FakeBackend(); + final e = SoundEngine(backend: b)..crashMark = mark; + await e.play('chat', 'k', _bytes, volume: 1); + expect(b.inits, 0, reason: 'never opened after a crash'); + expect(b.plays, isEmpty); + e.retry(); + await e.play('chat', 'k', _bytes, volume: 1); + expect(b.inits, 1); + expect(b.plays, hasLength(1)); + expect(File(mark).existsSync(), isFalse); + }); + }); +} + +class _MarkBackend extends _FakeBackend { + final void Function() onInit; + _MarkBackend(this.onInit); + + @override + Future init() async { + onInit(); + await super.init(); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 59746bf..bbf3515 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1946,7 +1946,12 @@ panel) and chat messages (Copy message, and a link in it). switched off in `soundsOff`. The device opens 2 s after startup (`warmUp`), so neither startup nor the first sound waits on it; if it can't open, the engine logs `no sounds: audio init failed` once and drops - every play. App close disposes the engine (deinit, 500 ms bound) + every play. Opening it is logged (`opening the audio device`, + `audio device open`) and leaves `/audio-init.pending` until it + returns: a native crash there (a driver, miniaudio) leaves the file, and + the next start skips sounds (`no sounds: opening the audio device + crashed the app last time`) instead of crashing again; a sound preview + in Settings deletes it and tries once more. App close disposes the engine (deinit, 500 ms bound) before leaving the server. Settings → Notifications & sounds has the volume, the overall sound set (`soundPreset`), and per sound a preview, its own set (`soundPresetFor`, else the overall one) and a switch; a preview always