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>
194 lines
6.4 KiB
Dart
194 lines
6.4 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:starsync/src/platform/audio/sound_engine.dart';
|
|
import 'package:starsync/src/sfx/sfx.dart';
|
|
import 'package:starsync/src/sfx/sound_bank.dart';
|
|
import 'package:starsync/src/sfx/synth.dart';
|
|
import 'package:starsync/src/sfx/synth_sfx.dart';
|
|
|
|
class _FakeBackend implements AudioBackend {
|
|
final bool failInit;
|
|
_FakeBackend({this.failInit = false});
|
|
|
|
var inits = 0, deinits = 0;
|
|
final loads = <String, Uint8List>{};
|
|
final unloaded = <Object>[];
|
|
final plays = <(String, double)>[];
|
|
|
|
@override
|
|
Future<void> init() async {
|
|
inits++;
|
|
if (failInit) throw StateError('no audio device');
|
|
}
|
|
|
|
@override
|
|
Future<Object> load(String name, Uint8List wav) async {
|
|
loads[name] = wav;
|
|
return name;
|
|
}
|
|
|
|
@override
|
|
void play(Object sound, double volume) => plays.add((sound as String, volume));
|
|
|
|
@override
|
|
Future<void> unload(Object sound) async => unloaded.add(sound);
|
|
|
|
@override
|
|
Future<void> deinit() async => deinits++;
|
|
}
|
|
|
|
Future<Uint8List> _bytes() async => Uint8List.fromList([1, 2, 3]);
|
|
|
|
void main() {
|
|
test('loads a sound once per key and lets plays overlap', () async {
|
|
final b = _FakeBackend();
|
|
final e = SoundEngine(backend: b);
|
|
await Future.wait([
|
|
e.play('chat', 'abyss-chat', _bytes, volume: 0.5),
|
|
e.play('chat', 'abyss-chat', _bytes, volume: 0.5),
|
|
]);
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 0.25);
|
|
expect(b.inits, 1);
|
|
expect(b.loads.length, 1);
|
|
expect(b.plays.map((p) => p.$2), [0.5, 0.5, 0.25]);
|
|
});
|
|
|
|
test('a new key or fresh replaces and frees the slot', () async {
|
|
final b = _FakeBackend();
|
|
final e = SoundEngine(backend: b);
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
|
await e.play('chat', 'cosmic-chat', _bytes, volume: 1);
|
|
await e.play('chat', 'cosmic-chat', _bytes, volume: 1, fresh: true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(b.loads.length, 3);
|
|
expect(b.loads.keys.toSet().length, 3, reason: 'load names are unique');
|
|
expect(b.unloaded.length, 2);
|
|
expect(b.plays.map((p) => p.$1).toSet().length, 3);
|
|
});
|
|
|
|
test('silence and zero volume load nothing', () async {
|
|
final b = _FakeBackend();
|
|
final e = SoundEngine(backend: b);
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 0);
|
|
expect(b.inits, 0);
|
|
expect(b.loads, isEmpty);
|
|
});
|
|
|
|
test('a failed init means no sounds, no throw, one try', () async {
|
|
final b = _FakeBackend(failInit: true);
|
|
final e = SoundEngine(backend: b);
|
|
expect(await e.warmUp(), isFalse);
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
|
await e.play('join', 'abyss-join', _bytes, volume: 1);
|
|
expect(b.inits, 1);
|
|
expect(b.loads, isEmpty);
|
|
expect(b.plays, isEmpty);
|
|
await e.dispose();
|
|
expect(b.deinits, 0);
|
|
});
|
|
|
|
test('a failed render is retried on the next play', () async {
|
|
final b = _FakeBackend();
|
|
final e = SoundEngine(backend: b);
|
|
await e.play('chat', 'abyss-chat', () async => throw StateError('render'), volume: 1);
|
|
expect(b.plays, isEmpty);
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
|
expect(b.plays.length, 1);
|
|
});
|
|
|
|
test('dispose deinits once and drops later plays', () async {
|
|
final b = _FakeBackend();
|
|
final e = SoundEngine(backend: b);
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
|
await e.dispose();
|
|
await e.dispose();
|
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
|
expect(b.deinits, 1);
|
|
expect(b.plays.length, 1);
|
|
});
|
|
|
|
test('dispose without a sound ever played opens nothing', () async {
|
|
final b = _FakeBackend();
|
|
await SoundEngine(backend: b).dispose();
|
|
expect(b.inits + b.deinits, 0);
|
|
});
|
|
|
|
group('SynthSfx', () {
|
|
test('renders a WAV per sound, volume as before (v^6), switches and preview', () async {
|
|
final b = _FakeBackend();
|
|
var volume = 0.5;
|
|
final off = <SfxKind>{SfxKind.join};
|
|
final sfx = SynthSfx(
|
|
volume: () => volume,
|
|
enabled: (k) => !off.contains(k),
|
|
presetFor: (_) => SoundPreset.woodAndGlass,
|
|
engine: SoundEngine(backend: b),
|
|
);
|
|
for (final k in SfxKind.values) {
|
|
await sfx.play(SfxCue(k, readyIndex: 2));
|
|
}
|
|
expect(b.plays.length, SfxKind.values.length - 1);
|
|
expect(b.plays.every((p) => p.$2 == 0.015625), isTrue); // 0.5^6
|
|
for (final MapEntry(:key, :value) in b.loads.entries) {
|
|
expect(String.fromCharCodes(value.sublist(0, 4)), 'RIFF', reason: key);
|
|
expect(key, startsWith('woodAndGlass-'));
|
|
}
|
|
final chat = b.loads.entries.firstWhere((e) => e.key.startsWith('woodAndGlass-chat-2'));
|
|
expect(chat.value, wav(render(soundFor(SfxKind.chat, readyIndex: 2, preset: SoundPreset.woodAndGlass))));
|
|
|
|
await sfx.preview(SfxKind.join, readyIndex: 2);
|
|
expect(b.plays.last.$1, startsWith('woodAndGlass-join-2'));
|
|
|
|
volume = 0;
|
|
await sfx.play(const SfxCue(SfxKind.chat));
|
|
expect(b.plays.length, SfxKind.values.length);
|
|
await sfx.dispose();
|
|
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();
|
|
}
|
|
}
|