Sounds: survive an audio device that crashes the app on open

A Windows friend's 1.3.0 dies about a second in on every start, right
around the 2 s audio warm-up (no Dart error, no close lines). Opening the
device is now logged and leaves audio-init.pending until it returns; a
start that finds it skips sounds instead of crashing again, and a sound
preview in Settings tries once more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-17 00:50:26 +03:00
1 parent 22ce40a488
commit c91d57633f
5 files changed
+92 -3

No files matched your search

+2
View File
@@ -49,6 +49,8 @@ Future<void> main(List<String> args) async {
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
// The composition root: every system, made once. // The composition root: every system, made once.
final graph = AppGraph.build(); 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; final ports = graph.ports;
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others // Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", your server shuts down and no player outlives the engine. // see "left", your server shuts down and no player outlives the engine.
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_soloud/flutter_soloud.dart'; import 'package:flutter_soloud/flutter_soloud.dart';
@@ -50,6 +51,13 @@ class SoundEngine {
Future<bool>? _ready; Future<bool>? _ready;
var _disposed = false; 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). /// Per slot: the loaded key and its sound (null: failed to load).
final Map<String, (String, Future<Object?>)> _slots = {}; final Map<String, (String, Future<Object?>)> _slots = {};
/// Load names are unique: SoLoud hands back the old sound for a name it /// Load names are unique: SoLoud hands back the old sound for a name it
@@ -61,15 +69,43 @@ class SoundEngine {
Future<bool> _init() async { Future<bool> _init() async {
if (_disposed) return false; 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 { try {
await _backend.init(); await _backend.init();
_log.info('audio device open');
return true; return true;
} catch (e) { } catch (e) {
_log.warning('no sounds: audio init failed: $e'); _log.warning('no sounds: audio init failed: $e');
return false; 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 /// Plays the sound of [slot] at [volume] (linear gain). [key] names what
/// [wav] renders: the same key reuses the loaded sound; another key, or /// [wav] renders: the same key reuses the loaded sound; another key, or
/// [fresh], renders and loads it again. /// [fresh], renders and loads it again.
+5 -2
View File
@@ -37,8 +37,11 @@ class SynthSfx implements SfxService {
/// Plays [kind] even when it is switched off (Settings' preview), always /// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload. /// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) => Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true); // 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. /// One slot per sound and ready step: a new preset replaces it.
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) { Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
+43
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -147,4 +148,46 @@ void main() {
expect(b.deinits, 1); 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<void> init() async {
onInit();
await super.init();
}
} }
+6 -1
View File
@@ -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 switched off in `soundsOff`. The device opens 2 s after startup
(`warmUp`), so neither startup nor the first sound waits on it; if it (`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 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 `<support>/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 before leaving the server. Settings → Notifications & sounds has the volume, the
overall sound set (`soundPreset`), and per sound a preview, its own set overall sound set (`soundPreset`), and per sound a preview, its own set
(`soundPresetFor`, else the overall one) and a switch; a preview always (`soundPresetFor`, else the overall one) and a switch; a preview always