Sounds play on flutter_soloud instead of three mpv players
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 10m15s

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:
foxyzandClaude Opus 5.5 committed 2026-10-15 17:18:39 +03:00
1 parent fd8064611a
commit b949951ef9
13 files changed
+435 -76

No files matched your search

+7
View File
@@ -73,6 +73,8 @@ Future<void> main(List<String> args) async {
// Linux: the launcher entry and icon Wayland needs (release installs). // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry()); unawaited(installDesktopEntry());
runApp(const WatchTogetherApp()); 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 /// Close button / Alt+F4: leave gracefully first (others see "left", a
@@ -88,6 +90,11 @@ class _CloseGuard with WindowListener {
if (_closing) return; if (_closing) return;
_closing = true; _closing = true;
await memory.save(); await memory.save();
try {
await SessionState.instance.sfx.dispose().timeout(const Duration(milliseconds: 500));
} catch (e) {
debugPrint('sound shutdown on close failed: $e');
}
try { try {
await SessionState.instance.leave().timeout(const Duration(seconds: 2)); await SessionState.instance.leave().timeout(const Duration(seconds: 2));
} catch (e) { } 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');
}
}
}
+33 -61
View File
@@ -1,88 +1,60 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; 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 'sfx.dart';
import 'sound_bank.dart'; import 'sound_bank.dart';
import 'synth.dart'; import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and /// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
/// played on a small pool of audio-only mpv players (so two sounds can /// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
/// overlap). [volume] (0..1) is read per play and applied on a square /// [volume] (0..1) is read per play and applied as v^6: the square curve
/// curve (ears are log); [enabled] filters cues. /// (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 { class SynthSfx implements SfxService {
final double Function() volume; final double Function() volume;
final bool Function(SfxKind kind) enabled; final bool Function(SfxKind kind) enabled;
/// Which sound set each sound plays from. /// Which sound set each sound plays from.
final SoundPreset Function(SfxKind kind) presetFor; 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), : enabled = enabled ?? ((_) => true),
presetFor = presetFor ?? ((_) => defaultPreset); presetFor = presetFor ?? ((_) => defaultPreset),
engine = engine ?? SoundEngine();
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override @override
Future<void> play(SfxCue cue) async { Future<void> play(SfxCue cue) {
if (!enabled(cue.kind)) return; if (!enabled(cue.kind)) return Future.value();
final preset = presetFor(cue.kind); return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
} }
/// 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}) =>
final p = preset ?? presetFor(kind); _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
final key = '${p.name}-${kind.name}-$readyIndex';
_files.remove(key);
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
}
/// Plays [spec], cached under [key] (same key = same sound). /// One slot per sound and ready step: a new preset replaces it.
Future<void> playSpec(String key, SoundSpec spec) async { Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
final v = volume(); final v = volume();
if (v <= 0) return; return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
try { () => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
final path = await (_files[key] ??= _write(key, spec)); volume: math.pow(v, 6).toDouble(), fresh: fresh);
final player = await _player();
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
} }
Future<String> _write(String key, SoundSpec spec) async { /// Opens the audio device ahead of the first sound.
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true); Future<void> warmUp() => engine.warmUp();
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec))); Future<void> dispose() => engine.dispose();
return f.path;
} }
Future<Player> _player() async { /// [spec] as WAV bytes, rendered on a worker isolate.
if (_pool.length < _poolSize) { Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
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 { Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
for (final p in _pool) {
await p.dispose();
}
_pool.clear();
}
}
+1 -5
View File
@@ -18,7 +18,6 @@ import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
@@ -798,10 +797,7 @@ class NotificationsInfo extends StatelessWidget {
class SoundSettings extends StatelessWidget { class SoundSettings extends StatelessWidget {
const SoundSettings({super.key}); const SoundSettings({super.key});
void _preview(SfxKind kind) { void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -160,7 +160,8 @@ class SessionState {
final route = signal(AppRoute.home); final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); 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(), volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume). // 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), 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 list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_soloud
jni jni
) )
+42 -2
View File
@@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.2" version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker: file_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -227,6 +235,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -246,6 +262,14 @@ packages:
url: "https://git.senkochan.moe/Foxyz/fox_utils.git" url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git source: git
version: "0.1.9" version: "0.1.9"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hive_ce: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -495,6 +519,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" 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: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -623,6 +655,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -786,14 +826,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.1.2" version: "1.2.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.1.2" version: "1.2.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+9
View File
@@ -36,6 +36,15 @@ dependencies:
git: git:
url: https://git.senkochan.moe/Foxyz/leaf_widgets.git url: https://git.senkochan.moe/Foxyz/leaf_widgets.git
ref: v0.1.0 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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);
});
}
+150
View File
@@ -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 list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_soloud
jni jni
) )
+12 -5
View File
@@ -335,7 +335,7 @@ menu and Settings say so). Otherwise the session player logs at `warn`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one on media_kit's handle): media_kit drains mpv's log on the UI thread, one
message at a time, so a chatty level, or ffmpeg logging every retry of a message at a time, so a chatty level, or ffmpeg logging every retry of a
refused request, could starve the UI. Shader and yt-dlp hook errors are refused request, could starve the UI. Shader and yt-dlp hook errors are
`error` lines and still arrive. The thumbnail and sound players keep `error` lines and still arrive. The thumbnail players keep
media_kit's default (`error`) and nothing listens to their log. The hardware path is media_kit's default (`error`) and nothing listens to their log. The hardware path is
fragile: media_kit borrows whatever EGL context GDK left current on the main fragile: media_kit borrows whatever EGL context GDK left current on the main
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
@@ -1261,10 +1261,17 @@ panel) and chat messages (Copy message, and a link in it).
dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event
in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one
shared key, so mixed presets still agree. `SynthSfx` renders a sound on shared key, so mixed presets still agree. `SynthSfx` renders a sound on
first use into the temp dir (cache key: preset, sound, ready index) and first use (on a worker isolate, to WAV bytes in memory) and hands it to
plays it on a pool of three audio-only mpv players, at `SoundEngine` (`platform/audio/sound_engine.dart`), which loads it into
`AppSettings.soundVolume` (square curve) unless switched off in flutter_soloud (SoLoud mixing into a miniaudio output device; voices
`soundsOff`. Settings → Notifications & sounds has the volume, the overlap) once per slot (sound and ready index); a different preset in a
slot replaces and frees the old sound. Each play is at
`AppSettings.soundVolume` (as v^6, SoLoud's linear gain: the square curve times mpv's cubic volume the sounds used to play through, so the slider sounds as before) unless
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. The close path 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 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
re-renders, so an edited spec plays after a hot reload. re-renders, so an edited spec plays after a hot reload.
+9 -2
View File
@@ -6,7 +6,11 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev). build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
mpv libraries come from `media_kit_libs_video`. `fox_utils` (drag & drop, mpv libraries come from `media_kit_libs_video`. `flutter_soloud` (the app's
sounds) compiles SoLoud and miniaudio from source in its build hook with
that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio
loads ALSA / PulseAudio / JACK at run time) and, with `no_xiph_libs` set in
the desktop pubspec, downloads nothing. `fox_utils` (drag & drop,
clipboard files and images; native Linux/Windows plugin) is the owner's clipboard files and images; native Linux/Windows plugin) is the owner's
shared package, a git dependency pinned to a tag of shared package, a git dependency pinned to a tag of
`git.senkochan.moe/Foxyz/fox_utils`: bump it by tagging there and changing `git.senkochan.moe/Foxyz/fox_utils`: bump it by tagging there and changing
@@ -67,6 +71,9 @@ and `e2e_check` (simulated viewers against a live server).
media_kit "S/W rendering" because they have no display; that says nothing media_kit "S/W rendering" because they have no display; that says nothing
about the app. Run only tests related to about the app. Run only tests related to
what you changed. what you changed.
- `sound_device_e2e` opens the real audio device (flutter_soloud) and plays
every sound of every preset quietly (`WT_SFX_VOLUME`, default 0.1);
`sound_engine_test` covers the engine with a fake backend.
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus - CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
`dart compile exe` of the dedicated server (fails if Flutter creeps into `dart compile exe` of the dedicated server (fails if Flutter creeps into
`packages/server`). Desktop tests are not in CI. `packages/server`). Desktop tests are not in CI.
@@ -184,7 +191,7 @@ slow to come up shows mpv's error with "no HTTP error logged".
- media_kit has no public `Player.setProperty`; the code reaches mpv through a - media_kit has no public `Player.setProperty`; the code reaches mpv through a
dynamic call on `player.platform`. Cache size goes through dynamic call on `player.platform`. Cache size goes through
`PlayerConfiguration(bufferSize:)`. `PlayerConfiguration(bufferSize:)`.
- Hot restart with any media_kit `Player` alive (session, thumbnails, sfx) - Hot restart with any media_kit `Player` alive (session, thumbnails)
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
is a `NativeCallable` of the old isolate, freed by the restart, and media_kit is a `NativeCallable` of the old isolate, freed by the restart, and media_kit
then sends the leftover handles `quit`, which fires it. `fox_utils`' Linux then sends the leftover handles `quit`, which fires it. `fox_utils`' Linux