Sounds play on flutter_soloud instead of three mpv players
SoundEngine (platform/audio) opens the device once (2 s after start, or on the first sound), loads each synthesized sound into memory (no temp files), plays overlapping voices and is disposed on window close, before leaving the server. The SfxService interface and its callers are unchanged; loudness matches the old mpv path (v^6). WAV only, so no codec libraries are bundled and no build packages are needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd8064611a
commit
b949951ef9
13 files changed
+436
-77
No files matched your search
@@ -73,6 +73,8 @@ Future<void> main(List<String> args) async {
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
runApp(const WatchTogetherApp());
|
||||
// The sound device opens once the window is up, not on the first sound.
|
||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||
@@ -88,6 +90,11 @@ class _CloseGuard with WindowListener {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
await memory.save();
|
||||
try {
|
||||
await SessionState.instance.sfx.dispose().timeout(const Duration(milliseconds: 500));
|
||||
} catch (e) {
|
||||
debugPrint('sound shutdown on close failed: $e');
|
||||
}
|
||||
try {
|
||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
final _log = createLogger('sound');
|
||||
|
||||
/// What [SoundEngine] needs from an audio library: [SoLoudBackend] in the
|
||||
/// app, a fake in tests. A sound handle is whatever [load] returns.
|
||||
abstract class AudioBackend {
|
||||
Future<void> init();
|
||||
/// Decodes [wav] (a whole WAV file) into memory under [name].
|
||||
Future<Object> load(String name, Uint8List wav);
|
||||
/// Starts one voice of [sound] at [volume] (linear gain, 0..1); voices
|
||||
/// overlap.
|
||||
void play(Object sound, double volume);
|
||||
Future<void> unload(Object sound);
|
||||
Future<void> deinit();
|
||||
}
|
||||
|
||||
/// flutter_soloud (SoLoud mixing into miniaudio's output device).
|
||||
class SoLoudBackend implements AudioBackend {
|
||||
SoLoud get _s => SoLoud.instance;
|
||||
|
||||
@override
|
||||
Future<void> init() => _s.init();
|
||||
|
||||
@override
|
||||
Future<Object> load(String name, Uint8List wav) => _s.loadMem(name, wav);
|
||||
|
||||
@override
|
||||
void play(Object sound, double volume) => _s.play(sound as AudioSource, volume: volume);
|
||||
|
||||
@override
|
||||
Future<void> unload(Object sound) => _s.disposeSource(sound as AudioSource);
|
||||
|
||||
@override
|
||||
Future<void> deinit() => _s.deinitAsync();
|
||||
}
|
||||
|
||||
/// The app's short in-memory sounds. The audio device opens on [warmUp] or
|
||||
/// the first [play]; if it can't (no device, a broken driver) the engine
|
||||
/// logs once and every play is silently dropped. Each sound is loaded once
|
||||
/// per slot from the WAV bytes its renderer gives; a new key in a slot
|
||||
/// replaces (and frees) what the slot held.
|
||||
class SoundEngine {
|
||||
final AudioBackend _backend;
|
||||
SoundEngine({AudioBackend? backend}) : _backend = backend ?? SoLoudBackend();
|
||||
|
||||
Future<bool>? _ready;
|
||||
var _disposed = false;
|
||||
/// Per slot: the loaded key and its sound (null: failed to load).
|
||||
final Map<String, (String, Future<Object?>)> _slots = {};
|
||||
/// Load names are unique: SoLoud hands back the old sound for a name it
|
||||
/// already holds, which would defeat a re-render.
|
||||
var _loads = 0;
|
||||
|
||||
/// Opens the audio device now, so the first sound has no wait.
|
||||
Future<bool> warmUp() => _ready ??= _init();
|
||||
|
||||
Future<bool> _init() async {
|
||||
if (_disposed) return false;
|
||||
try {
|
||||
await _backend.init();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.warning('no sounds: audio init failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<void> play(String slot, String key, Future<Uint8List> Function() wav,
|
||||
{required double volume, bool fresh = false}) async {
|
||||
if (_disposed || volume <= 0) return;
|
||||
if (!await warmUp() || _disposed) return;
|
||||
var held = _slots[slot];
|
||||
if (held == null || held.$1 != key || fresh) {
|
||||
if (held != null) unawaited(_unload(held.$2));
|
||||
held = (key, _load(key, wav));
|
||||
_slots[slot] = held;
|
||||
}
|
||||
final sound = await held.$2;
|
||||
if (sound == null) {
|
||||
// Retry on the next play.
|
||||
if (identical(_slots[slot], held)) _slots.remove(slot);
|
||||
return;
|
||||
}
|
||||
if (_disposed || !identical(_slots[slot], held)) return;
|
||||
try {
|
||||
_backend.play(sound, volume.clamp(0.0, 1.0));
|
||||
} catch (e) {
|
||||
_log.warning('sound $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Object?> _load(String key, Future<Uint8List> Function() wav) async {
|
||||
try {
|
||||
return await _backend.load('$key#${_loads++}', await wav());
|
||||
} catch (e) {
|
||||
_log.warning('sound $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unload(Future<Object?> sound) async {
|
||||
try {
|
||||
final s = await sound;
|
||||
if (s != null && !_disposed) await _backend.unload(s);
|
||||
} catch (e) {
|
||||
_log.fine('unload: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees every sound and closes the audio device. Plays after it are
|
||||
/// dropped.
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_slots.clear();
|
||||
final ready = _ready;
|
||||
if (ready == null || !await ready) return;
|
||||
try {
|
||||
// Deinit frees the loaded sounds with the engine.
|
||||
await _backend.deinit();
|
||||
} catch (e) {
|
||||
_log.warning('audio deinit: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,60 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../platform/audio/sound_engine.dart';
|
||||
import 'sfx.dart';
|
||||
import 'sound_bank.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||
/// curve (ears are log); [enabled] filters cues.
|
||||
/// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
|
||||
/// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
|
||||
/// [volume] (0..1) is read per play and applied as v^6: the square curve
|
||||
/// (ears are log) the app always used, times mpv's own cubic volume it
|
||||
/// used to play through, so the slider sounds as it did; [enabled]
|
||||
/// filters cues.
|
||||
class SynthSfx implements SfxService {
|
||||
final double Function() volume;
|
||||
final bool Function(SfxKind kind) enabled;
|
||||
/// Which sound set each sound plays from.
|
||||
final SoundPreset Function(SfxKind kind) presetFor;
|
||||
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
|
||||
final SoundEngine engine;
|
||||
SynthSfx(
|
||||
{required this.volume,
|
||||
bool Function(SfxKind kind)? enabled,
|
||||
SoundPreset Function(SfxKind kind)? presetFor,
|
||||
SoundEngine? engine})
|
||||
: enabled = enabled ?? ((_) => true),
|
||||
presetFor = presetFor ?? ((_) => defaultPreset);
|
||||
|
||||
static const _poolSize = 3;
|
||||
final List<Player> _pool = [];
|
||||
var _next = 0;
|
||||
final Map<String, Future<String>> _files = {};
|
||||
Directory? _dir;
|
||||
presetFor = presetFor ?? ((_) => defaultPreset),
|
||||
engine = engine ?? SoundEngine();
|
||||
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async {
|
||||
if (!enabled(cue.kind)) return;
|
||||
final preset = presetFor(cue.kind);
|
||||
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
|
||||
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
|
||||
Future<void> play(SfxCue cue) {
|
||||
if (!enabled(cue.kind)) return Future.value();
|
||||
return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
|
||||
}
|
||||
|
||||
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||
/// freshly rendered: an edited spec plays after a hot reload.
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
||||
final p = preset ?? presetFor(kind);
|
||||
final key = '${p.name}-${kind.name}-$readyIndex';
|
||||
_files.remove(key);
|
||||
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
|
||||
}
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
|
||||
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
|
||||
|
||||
/// Plays [spec], cached under [key] (same key = same sound).
|
||||
Future<void> playSpec(String key, SoundSpec spec) async {
|
||||
/// One slot per sound and ready step: a new preset replaces it.
|
||||
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
|
||||
final v = volume();
|
||||
if (v <= 0) return;
|
||||
try {
|
||||
final path = await (_files[key] ??= _write(key, spec));
|
||||
final player = await _player();
|
||||
await player.setVolume(100 * v * v);
|
||||
await player.open(Media(path));
|
||||
} catch (e) {
|
||||
_files.remove(key);
|
||||
debugPrint('sfx $key: $e');
|
||||
}
|
||||
return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
|
||||
() => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
|
||||
volume: math.pow(v, 6).toDouble(), fresh: fresh);
|
||||
}
|
||||
|
||||
Future<String> _write(String key, SoundSpec spec) async {
|
||||
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||
final f = File('${_dir!.path}/$key.wav');
|
||||
await f.writeAsBytes(wav(render(spec)));
|
||||
return f.path;
|
||||
}
|
||||
/// Opens the audio device ahead of the first sound.
|
||||
Future<void> warmUp() => engine.warmUp();
|
||||
|
||||
Future<Player> _player() async {
|
||||
if (_pool.length < _poolSize) {
|
||||
final p = Player(
|
||||
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||
);
|
||||
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||
_pool.add(p);
|
||||
return p;
|
||||
}
|
||||
return _pool[_next++ % _poolSize];
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final p in _pool) {
|
||||
await p.dispose();
|
||||
}
|
||||
_pool.clear();
|
||||
}
|
||||
Future<void> dispose() => engine.dispose();
|
||||
}
|
||||
|
||||
/// [spec] as WAV bytes, rendered on a worker isolate.
|
||||
Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
|
||||
|
||||
Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
|
||||
@@ -18,7 +18,6 @@ import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
@@ -798,10 +797,7 @@ class NotificationsInfo extends StatelessWidget {
|
||||
class SoundSettings extends StatelessWidget {
|
||||
const SoundSettings({super.key});
|
||||
|
||||
void _preview(SfxKind kind) {
|
||||
final sfx = SessionState.instance.sfx;
|
||||
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||
}
|
||||
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -160,7 +160,8 @@ class SessionState {
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final SfxService sfx = SynthSfx(
|
||||
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
||||
final SynthSfx sfx = SynthSfx(
|
||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||
|
||||
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_soloud
|
||||
jni
|
||||
)
|
||||
|
||||
|
||||
@@ -153,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -227,6 +235,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_soloud:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_soloud
|
||||
sha256: "1c297e52182c5e8fd447ae8430bf0b598445dc586e72863ceeef0416242b2ae8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -246,6 +262,14 @@ packages:
|
||||
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||
source: git
|
||||
version: "0.1.9"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
hive_ce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -495,6 +519,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.5"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -623,6 +655,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -786,14 +826,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.2"
|
||||
version: "1.2.0"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.2"
|
||||
version: "1.2.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -36,6 +36,15 @@ dependencies:
|
||||
git:
|
||||
url: https://git.senkochan.moe/Foxyz/leaf_widgets.git
|
||||
ref: v0.1.0
|
||||
# App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from
|
||||
# source by its build hook (needs only a C++ toolchain).
|
||||
flutter_soloud: ^5.2.1
|
||||
# The sounds are WAVs made in memory: no Ogg/Opus/FLAC codecs, so the
|
||||
# build hook downloads and bundles no Xiph libraries.
|
||||
hooks:
|
||||
user_defines:
|
||||
flutter_soloud:
|
||||
no_xiph_libs: true
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/platform/audio/sound_engine.dart';
|
||||
import 'package:watch_together/src/sfx/sfx.dart';
|
||||
import 'package:watch_together/src/sfx/sound_bank.dart';
|
||||
import 'package:watch_together/src/sfx/synth_sfx.dart';
|
||||
|
||||
// Needs a real audio output: opens the device through flutter_soloud and
|
||||
// plays every sound of every preset once, quietly (WT_SFX_VOLUME, default
|
||||
// 0.1, on the square curve like the app).
|
||||
|
||||
void main() {
|
||||
// flutter_soloud's init asks path_provider for a temp dir.
|
||||
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'), (_) async => Directory.systemTemp.path);
|
||||
test('every sound loads and plays on the real device', timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final volume = double.tryParse(Platform.environment['WT_SFX_VOLUME'] ?? '') ?? 0.1;
|
||||
final engine = SoundEngine();
|
||||
expect(await engine.warmUp(), isTrue, reason: 'audio device opened');
|
||||
final sfx = SynthSfx(volume: () => volume, engine: engine);
|
||||
for (final p in SoundPreset.values) {
|
||||
for (final k in SfxKind.values) {
|
||||
await sfx.preview(k, readyIndex: 2, preset: p);
|
||||
expect(SoLoud.instance.getActiveVoiceCount(), greaterThan(0), reason: '${p.name}/${k.name}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
await sfx.dispose();
|
||||
expect(SoLoud.instance.isInitialized, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/platform/audio/sound_engine.dart';
|
||||
import 'package:watch_together/src/sfx/sfx.dart';
|
||||
import 'package:watch_together/src/sfx/sound_bank.dart';
|
||||
import 'package:watch_together/src/sfx/synth.dart';
|
||||
import 'package:watch_together/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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_soloud
|
||||
jni
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user