485 lines
14 KiB
Dart
485 lines
14 KiB
Dart
import 'dart:async' as async;
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart';
|
|
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart';
|
|
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
|
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
|
|
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
|
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
group('RuntimeAudioManager', () {
|
|
test('preloads required audio and exposes ready state', () async {
|
|
final package = await _createPackage(
|
|
'required_audio',
|
|
preload: GameResourcePreload.required,
|
|
);
|
|
final audio = RuntimeAudioManager();
|
|
|
|
await audio.mount(package);
|
|
|
|
expect(audio.audioState('dice'), GameResourceState.ready);
|
|
audio.dispose();
|
|
});
|
|
|
|
test('lazy audio plays from package bytes', () async {
|
|
final players = <_FakeRuntimeAudioPlayer>[];
|
|
final package = await _createPackage('lazy_audio');
|
|
final audio = RuntimeAudioManager(
|
|
playerFactory: () {
|
|
final player = _FakeRuntimeAudioPlayer();
|
|
players.add(player);
|
|
return player;
|
|
},
|
|
);
|
|
await audio.mount(package);
|
|
|
|
final playback = await audio.play('dice', volume: 0.5);
|
|
|
|
expect(playback, isNotNull);
|
|
expect(audio.audioState('dice'), GameResourceState.ready);
|
|
expect(players.single.startedBytes, _audioBytes);
|
|
expect(players.single.volume, 0.5);
|
|
|
|
players.single.complete();
|
|
await playback!.done;
|
|
audio.dispose();
|
|
});
|
|
|
|
test('lazy audio failure records state, error and diagnostics', () async {
|
|
final diagnostics = RuntimeDiagnostics();
|
|
final package = await _createPackage('missing_audio', writeAudio: false);
|
|
final audio = RuntimeAudioManager(diagnostics: diagnostics);
|
|
|
|
await audio.mount(package);
|
|
final playback = await audio.play('dice');
|
|
|
|
expect(playback, isNull);
|
|
expect(audio.audioState('dice'), GameResourceState.failed);
|
|
expect(audio.audioError('dice'), isNotNull);
|
|
expect(
|
|
diagnostics.entries.single.type,
|
|
RuntimeDiagnosticType.resourceLoadError,
|
|
);
|
|
audio.dispose();
|
|
});
|
|
|
|
test(
|
|
'exports audio debug json, evicts and retries failed records',
|
|
() async {
|
|
final root = await Directory.systemTemp.createTemp('audio_retry_');
|
|
Directory('${root.path}/assets').createSync(recursive: true);
|
|
addTearDown(() {
|
|
if (root.existsSync()) {
|
|
root.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
final package = _createPackageAt(
|
|
root,
|
|
preload: GameResourcePreload.lazy,
|
|
);
|
|
final audio = RuntimeAudioManager();
|
|
|
|
await audio.mount(package);
|
|
|
|
expect(audio.audioDebugJson(), {
|
|
'generation': 1,
|
|
'hasPackage': true,
|
|
'count': 1,
|
|
'activeLoads': 0,
|
|
'pendingLoads': 0,
|
|
'activePlayers': 0,
|
|
'pooledPlayers': 0,
|
|
'channels': <String>[],
|
|
'resources': [
|
|
{
|
|
'key': 'dice',
|
|
'path': endsWith('/assets/dice.wav'),
|
|
'type': GameResourceType.audio,
|
|
'declared': true,
|
|
'preload': GameResourcePreload.lazy,
|
|
'state': 'idle',
|
|
'loading': false,
|
|
'ready': false,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(await audio.retryAudio('dice'), isFalse);
|
|
expect(audio.audioState('dice'), GameResourceState.failed);
|
|
expect(audio.audioDebugJson().toString(), contains('error'));
|
|
|
|
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
|
|
expect(await audio.retryAudio('dice'), isTrue);
|
|
expect(audio.audioState('dice'), GameResourceState.ready);
|
|
|
|
expect(audio.evictAudio('dice'), isTrue);
|
|
expect(audio.audioState('dice'), GameResourceState.idle);
|
|
expect(audio.evictAudio('dice'), isFalse);
|
|
audio.dispose();
|
|
},
|
|
);
|
|
|
|
test('preloads and evicts audio resource groups', () async {
|
|
final package = await _createMultiAudioPackage('audio_group');
|
|
final audio = RuntimeAudioManager();
|
|
|
|
await audio.mount(package);
|
|
await audio.preloadGroup('scene');
|
|
|
|
expect(audio.audioState('dice'), GameResourceState.ready);
|
|
expect(audio.audioState('click'), GameResourceState.ready);
|
|
expect(audio.audioState('bgm'), GameResourceState.idle);
|
|
expect(audio.evictGroup('scene'), 2);
|
|
expect(audio.audioState('dice'), GameResourceState.idle);
|
|
expect(audio.audioState('click'), GameResourceState.idle);
|
|
audio.dispose();
|
|
});
|
|
|
|
test('limits concurrent audio reads during group preload', () async {
|
|
final package = _CountingAudioPackage(
|
|
await _createMultiAudioPackage('audio_concurrency'),
|
|
);
|
|
final audio = RuntimeAudioManager(maxConcurrentLoads: 1);
|
|
|
|
await audio.mount(package);
|
|
await audio.preloadGroup('scene');
|
|
|
|
expect(package.maxActiveReads, 1);
|
|
expect(audio.audioState('dice'), GameResourceState.ready);
|
|
expect(audio.audioState('click'), GameResourceState.ready);
|
|
audio.dispose();
|
|
});
|
|
|
|
test(
|
|
'drops stale audio load result after dispose without diagnostics',
|
|
() async {
|
|
final diagnostics = RuntimeDiagnostics();
|
|
final package = _BlockingAudioPackage(
|
|
await _createPackage('stale_audio'),
|
|
);
|
|
final audio = RuntimeAudioManager(diagnostics: diagnostics);
|
|
|
|
await audio.mount(package);
|
|
final playback = audio.play('dice');
|
|
|
|
audio.dispose();
|
|
package.releaseReads();
|
|
|
|
expect(await playback, isNull);
|
|
expect(diagnostics.entries, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('audio LRU evicts least recently used bytes', () async {
|
|
final package = await _createMultiAudioPackage('audio_lru');
|
|
final audio = RuntimeAudioManager(maxCacheEntries: 1);
|
|
|
|
await audio.mount(package);
|
|
expect(await audio.retryAudio('dice'), isTrue);
|
|
expect(audio.audioState('dice'), GameResourceState.ready);
|
|
|
|
expect(await audio.retryAudio('bgm'), isTrue);
|
|
|
|
expect(audio.audioState('dice'), GameResourceState.idle);
|
|
expect(audio.audioState('bgm'), GameResourceState.ready);
|
|
audio.dispose();
|
|
});
|
|
|
|
test('reuses pooled one-shot audio players', () async {
|
|
final players = <_FakeRuntimeAudioPlayer>[];
|
|
final package = await _createPackage('sfx_pool');
|
|
final audio = RuntimeAudioManager(
|
|
maxSfxPoolSize: 1,
|
|
playerFactory: () {
|
|
final player = _FakeRuntimeAudioPlayer();
|
|
players.add(player);
|
|
return player;
|
|
},
|
|
);
|
|
await audio.mount(package);
|
|
|
|
final first = await audio.play('dice');
|
|
players.single.complete();
|
|
await first!.done;
|
|
expect(audio.audioDebugJson()['pooledPlayers'], 1);
|
|
|
|
final second = await audio.play('dice');
|
|
players.single.complete();
|
|
await second!.done;
|
|
|
|
expect(players, hasLength(1));
|
|
expect(players.single.startCount, 2);
|
|
audio.dispose();
|
|
});
|
|
|
|
test('plays, pauses, resumes and stops bgm channel', () async {
|
|
final players = <_FakeRuntimeAudioPlayer>[];
|
|
final package = await _createPackage('bgm_audio');
|
|
final audio = RuntimeAudioManager(
|
|
playerFactory: () {
|
|
final player = _FakeRuntimeAudioPlayer();
|
|
players.add(player);
|
|
return player;
|
|
},
|
|
);
|
|
await audio.mount(package);
|
|
|
|
final playback = await audio.playBgm(
|
|
'dice',
|
|
channel: 'music',
|
|
volume: 0.3,
|
|
loop: true,
|
|
);
|
|
|
|
expect(playback, isNotNull);
|
|
expect(audio.hasBgm(channel: 'music'), isTrue);
|
|
expect(players.single.startedBytes, _audioBytes);
|
|
expect(players.single.volume, 0.3);
|
|
expect(players.single.loop, isTrue);
|
|
|
|
await audio.pauseBgm(channel: 'music');
|
|
await audio.resumeBgm(channel: 'music');
|
|
await audio.stopBgm(channel: 'music');
|
|
|
|
expect(players.single.paused, isTrue);
|
|
expect(players.single.resumed, isTrue);
|
|
expect(players.single.stopped, isTrue);
|
|
expect(audio.hasBgm(channel: 'music'), isFalse);
|
|
audio.dispose();
|
|
});
|
|
|
|
test('starting same bgm channel replaces previous playback', () async {
|
|
final players = <_FakeRuntimeAudioPlayer>[];
|
|
final package = await _createPackage('replace_bgm_audio');
|
|
final audio = RuntimeAudioManager(
|
|
playerFactory: () {
|
|
final player = _FakeRuntimeAudioPlayer();
|
|
players.add(player);
|
|
return player;
|
|
},
|
|
);
|
|
await audio.mount(package);
|
|
|
|
await audio.playBgm('dice', channel: 'music');
|
|
await audio.playBgm('dice', channel: 'music');
|
|
|
|
expect(players, hasLength(2));
|
|
expect(players.first.stopped, isTrue);
|
|
expect(audio.hasBgm(channel: 'music'), isTrue);
|
|
audio.dispose();
|
|
});
|
|
|
|
test('required audio preload failure fails mount', () async {
|
|
final package = await _createPackage(
|
|
'required_missing_audio',
|
|
preload: GameResourcePreload.required,
|
|
writeAudio: false,
|
|
);
|
|
final audio = RuntimeAudioManager();
|
|
|
|
await expectLater(
|
|
audio.mount(package),
|
|
throwsA(isA<ResourceLoadException>()),
|
|
);
|
|
expect(audio.audioState('dice'), GameResourceState.failed);
|
|
audio.dispose();
|
|
});
|
|
});
|
|
}
|
|
|
|
const _audioBytes = [1, 2, 3, 4, 5];
|
|
|
|
Future<GamePackage> _createPackage(
|
|
String name, {
|
|
String preload = GameResourcePreload.lazy,
|
|
bool writeAudio = true,
|
|
}) async {
|
|
final root = await Directory.systemTemp.createTemp('audio_${name}_');
|
|
Directory('${root.path}/assets').createSync(recursive: true);
|
|
if (writeAudio) {
|
|
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
|
|
}
|
|
|
|
addTearDown(() {
|
|
if (root.existsSync()) {
|
|
root.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
|
|
return _createPackageAt(root, preload: preload);
|
|
}
|
|
|
|
Future<GamePackage> _createMultiAudioPackage(String name) async {
|
|
final root = await Directory.systemTemp.createTemp('audio_${name}_');
|
|
Directory('${root.path}/assets').createSync(recursive: true);
|
|
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
|
|
File('${root.path}/assets/click.wav').writeAsBytesSync([6, 7, 8]);
|
|
File('${root.path}/assets/bgm.wav').writeAsBytesSync([9, 10, 11, 12]);
|
|
|
|
addTearDown(() {
|
|
if (root.existsSync()) {
|
|
root.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
|
|
return GamePackage.file(
|
|
rootPath: root.path,
|
|
manifest: GamePackageManifest(
|
|
gameId: 'test',
|
|
name: 'Test',
|
|
version: '0.1.0',
|
|
runtimeApiVersion: 1,
|
|
entry: 'scripts/main.lua',
|
|
assetsBase: 'assets',
|
|
resources: const {
|
|
'dice': GameResource(
|
|
type: GameResourceType.audio,
|
|
path: 'assets/dice.wav',
|
|
preload: GameResourcePreload.lazy,
|
|
group: 'scene',
|
|
),
|
|
'click': GameResource(
|
|
type: GameResourceType.audio,
|
|
path: 'assets/click.wav',
|
|
preload: GameResourcePreload.lazy,
|
|
group: 'scene',
|
|
),
|
|
'bgm': GameResource(
|
|
type: GameResourceType.audio,
|
|
path: 'assets/bgm.wav',
|
|
preload: GameResourcePreload.lazy,
|
|
group: 'music',
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
GamePackage _createPackageAt(
|
|
Directory root, {
|
|
String preload = GameResourcePreload.lazy,
|
|
}) {
|
|
return GamePackage.file(
|
|
rootPath: root.path,
|
|
manifest: GamePackageManifest(
|
|
gameId: 'test',
|
|
name: 'Test',
|
|
version: '0.1.0',
|
|
runtimeApiVersion: 1,
|
|
entry: 'scripts/main.lua',
|
|
assetsBase: 'assets',
|
|
resources: {
|
|
'dice': GameResource(
|
|
type: GameResourceType.audio,
|
|
path: 'assets/dice.wav',
|
|
preload: preload,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
class _CountingAudioPackage extends GamePackage {
|
|
_CountingAudioPackage(GamePackage package)
|
|
: super.file(rootPath: package.rootPath, manifest: package.manifest);
|
|
|
|
var activeReads = 0;
|
|
var maxActiveReads = 0;
|
|
|
|
@override
|
|
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
|
activeReads++;
|
|
if (activeReads > maxActiveReads) {
|
|
maxActiveReads = activeReads;
|
|
}
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
try {
|
|
return await super.readBytes(relativeOrAbsolutePath);
|
|
} finally {
|
|
activeReads--;
|
|
}
|
|
}
|
|
}
|
|
|
|
class _BlockingAudioPackage extends GamePackage {
|
|
_BlockingAudioPackage(GamePackage package)
|
|
: _releaseReads = async.Completer<void>(),
|
|
super.file(rootPath: package.rootPath, manifest: package.manifest);
|
|
|
|
final async.Completer<void> _releaseReads;
|
|
|
|
void releaseReads() {
|
|
if (!_releaseReads.isCompleted) {
|
|
_releaseReads.complete();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
|
await _releaseReads.future;
|
|
return super.readBytes(relativeOrAbsolutePath);
|
|
}
|
|
}
|
|
|
|
class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer {
|
|
async.Completer<void> _done = async.Completer<void>();
|
|
List<int>? startedBytes;
|
|
double? volume;
|
|
var loop = false;
|
|
var paused = false;
|
|
var resumed = false;
|
|
var stopped = false;
|
|
var disposed = false;
|
|
var startCount = 0;
|
|
|
|
@override
|
|
Future<void> get done => _done.future;
|
|
|
|
@override
|
|
Future<void> start(
|
|
Uint8List bytes, {
|
|
required double volume,
|
|
bool loop = false,
|
|
}) async {
|
|
if (_done.isCompleted) {
|
|
_done = async.Completer<void>();
|
|
}
|
|
startCount++;
|
|
startedBytes = bytes.toList(growable: false);
|
|
this.volume = volume;
|
|
this.loop = loop;
|
|
}
|
|
|
|
void complete() {
|
|
if (!_done.isCompleted) {
|
|
_done.complete();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() async {
|
|
paused = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> resume() async {
|
|
resumed = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
stopped = true;
|
|
complete();
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
disposed = true;
|
|
complete();
|
|
}
|
|
}
|