Sounds: synthesized sound set and a debug sound board

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 05:45:14 +03:00
1 parent 0c38528520
commit d8fd464e33
8 files changed
+648 -13

No files matched your search

@@ -173,18 +173,8 @@ class _SettingsTabState extends State<_SettingsTab> {
'Seeks and skips', 'Seeks and skips',
'Always in the activity log', 'Always in the activity log',
), ),
_switch(
'unfocused',
'Desktop notification when the app is in the background',
),
]),
_card('Sounds', [
_switch(
'sounds',
'Play sounds',
'Soft cues for ready, join and chat; seeks stay silent',
),
]), ]),
const _SoundBoard(),
]; ];
case 4: case 4:
return [ return [
@@ -0,0 +1,268 @@
part of 'shell_demo.dart';
// DEBUG ONLY: every app sound, playable, with where it plays. The sounds
// are synthesized (lib/src/sfx/sound_bank.dart); edit a spec, hot reload,
// press "Re-render" and play it again.
typedef _Sound = ({String key, String name, String where, SoundSpec spec, bool proposal});
List<(String, List<_Sound>)> _soundGroups() {
_Sound k(SfxKind kind, String name, String where) =>
(key: kind.name, name: name, where: where, spec: soundFor(kind), proposal: false);
_Sound x(ExtraSound s, String name, String where) =>
(key: 'x-${s.name}', name: name, where: where, spec: extraSound(s), proposal: true);
return [
('Ready', [
k(SfxKind.ready, 'Ready', 'Someone presses Space (you too). The second note climbs with each ready person: try the ladder below'),
k(SfxKind.unready, 'Not ready', 'Someone takes their ready back, or idle auto-unready'),
k(SfxKind.forceReady, 'Everyone ready', '"Mark everyone ready" from the member list or the player'),
k(SfxKind.forceUnready, 'Made not ready', 'Someone sets another member not ready'),
x(ExtraSound.start, 'Starting', 'Everyone is ready and playback starts together'),
]),
('People', [
k(SfxKind.join, 'Joined', 'Someone joins the server'),
k(SfxKind.leave, 'Left', 'Someone leaves (a little quieter than join)'),
k(SfxKind.stale, 'Lagging', 'Someone stops reporting for a few seconds (bad connection)'),
k(SfxKind.kick, 'Removed', 'Someone is kicked; also when it is you'),
]),
('Playback', [
k(SfxKind.swap, 'New video', 'Someone puts up a different video or episode'),
k(SfxKind.forcePlay, 'Forced play', 'Someone starts playback without waiting for everyone'),
k(SfxKind.forceHold, 'Held', 'Someone holds playback for everyone'),
k(SfxKind.pause, 'Paused', 'Playback pauses (someone unready, buffering)'),
k(SfxKind.resume, 'Resumed', 'Playback resumes after a pause'),
k(SfxKind.seek, 'Seek', 'Someone seeks or skips (off by default: seeks are frequent)'),
x(ExtraSound.finished, 'Finished', 'The video reaches its end'),
]),
('Chat & alerts', [
k(SfxKind.chat, 'Message', 'A chat message from someone else'),
x(ExtraSound.mention, 'Mention', 'A message with your name in it'),
x(ExtraSound.image, 'Image', 'Someone sends an image to chat'),
k(SfxKind.warn, 'Idle warning', '"Auto-unready in 2 min unless you press Space"'),
x(ExtraSound.lost, 'Connection lost', 'The server stops answering; reconnecting'),
x(ExtraSound.back, 'Reconnected', 'Back in the server after a drop'),
]),
];
}
class _SoundBoard extends StatefulWidget {
const _SoundBoard();
@override
State<_SoundBoard> createState() => _SoundBoardState();
}
class _SoundBoardState extends State<_SoundBoard> {
static final _sfx = SynthSfx();
final _off = <String>{'seek'};
String? _playing;
bool _sequence = false;
Future<void> _play(String key, SoundSpec spec) async {
setState(() => _playing = key);
await _sfx.playSpec(key, spec);
final ms = (spec.length * 1000).round();
await Future<void>.delayed(Duration(milliseconds: ms));
if (mounted && _playing == key) setState(() => _playing = null);
}
Future<void> _playAll() async {
setState(() => _sequence = true);
for (final (_, sounds) in _soundGroups()) {
for (final s in sounds) {
if (!mounted || !_sequence) return;
await _play(s.key, s.spec);
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
if (mounted) setState(() => _sequence = false);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
final pct = (_sfx.volume * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_panel(context, 'Sounds', [
Row(
children: [
Icon(_sfx.volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
color: scheme.onSurfaceVariant),
Expanded(
child: Slider(
value: _sfx.volume,
onChanged: (v) => setState(() => _sfx.volume = v),
onChangeEnd: (_) => _play('ready', soundFor(SfxKind.ready)),
),
),
SizedBox(width: 44, child: Text('$pct%', textAlign: TextAlign.end)),
],
),
Text('Soft and short on purpose; the slider previews the ready chime when you let go.', style: muted),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FilledButton.tonalIcon(
onPressed: _sequence ? () => setState(() => _sequence = false) : _playAll,
icon: Icon(_sequence ? Icons.stop : Icons.playlist_play),
label: Text(_sequence ? 'Stop' : 'Play all in order'),
),
OutlinedButton.icon(
onPressed: () {
_sfx.forget();
_Waveform._cache.clear();
setState(() {});
},
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Re-render (after hot reload)'),
),
const SizedBox(width: 8),
Text('Ready ladder:', style: muted),
for (var i = 1; i <= 6; i++)
ActionChip(
label: Text('$i'),
tooltip: '$i${i == 1 ? 'st' : i == 2 ? 'nd' : i == 3 ? 'rd' : 'th'} person ready',
onPressed: () => _play('ready-$i', readySound(i)),
),
],
),
]),
for (final (title, sounds) in _soundGroups())
_panel(context, title, [
for (final s in sounds) _row(context, s),
]),
],
);
}
Widget _panel(BuildContext context, String title, List<Widget> children) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
...children,
],
),
),
),
);
Widget _row(BuildContext context, _Sound s) {
final scheme = Theme.of(context).colorScheme;
final on = !_off.contains(s.key);
final playing = _playing == s.key;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
IconButton.filledTonal(
tooltip: 'Play',
onPressed: () => _play(s.key, s.spec),
icon: Icon(playing ? Icons.graphic_eq : Icons.play_arrow_rounded),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(s.name, style: const TextStyle(fontWeight: FontWeight.w600)),
if (s.proposal) ...[
const SizedBox(width: 8),
_Pill('new', scheme.tertiary),
],
],
),
Text(s.where, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
height: 32,
child: _Waveform(id: s.key, spec: s.spec, playing: playing, color: on ? scheme.primary : scheme.outlineVariant),
),
Switch(
value: on,
onChanged: (v) => setState(() => v ? _off.remove(s.key) : _off.add(s.key)),
),
],
),
);
}
}
/// The sound's envelope (peak per column), drawn once per spec.
class _Waveform extends StatelessWidget {
final String id;
final SoundSpec spec;
final bool playing;
final Color color;
const _Waveform({required this.id, required this.spec, required this.playing, required this.color});
static final _cache = <String, List<double>>{};
List<double> _peaks() => _cache[id] ??= () {
final samples = render(spec);
const cols = 48;
final per = (samples.length / cols).ceil();
final peaks = <double>[];
var max = 0.0;
for (var c = 0; c < cols; c++) {
var p = 0.0;
for (var i = c * per; i < (c + 1) * per && i < samples.length; i++) {
p = math.max(p, samples[i].abs());
}
peaks.add(p);
max = math.max(max, p);
}
return [for (final p in peaks) max == 0 ? 0.0 : math.sqrt(p / max)];
}();
@override
Widget build(BuildContext context) => AnimatedOpacity(
duration: _easeTime,
curve: _ease,
opacity: playing ? 1 : 0.55,
child: CustomPaint(painter: _WavePainter(_peaks(), color)),
);
}
class _WavePainter extends CustomPainter {
final List<double> peaks;
final Color color;
_WavePainter(this.peaks, this.color);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..strokeCap = StrokeCap.round;
final step = size.width / peaks.length;
final mid = size.height / 2;
for (var i = 0; i < peaks.length; i++) {
final h = math.max(1.0, peaks[i] * mid);
final x = step * (i + 0.5);
canvas.drawLine(Offset(x, mid - h), Offset(x, mid + h), paint);
}
}
@override
bool shouldRepaint(_WavePainter old) => old.peaks != peaks || old.color != color;
}
@@ -13,12 +13,17 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../../jellyfin/accounts.dart'; import '../../jellyfin/accounts.dart';
import '../../sfx/sfx.dart';
import '../../sfx/sound_bank.dart';
import '../../sfx/synth.dart';
import '../../sfx/synth_sfx.dart';
import '../../widgets/window_buttons.dart'; import '../../widgets/window_buttons.dart';
part 'demo_files.dart'; part 'demo_files.dart';
part 'demo_player.dart'; part 'demo_player.dart';
part 'demo_schedule.dart'; part 'demo_schedule.dart';
part 'demo_settings.dart'; part 'demo_settings.dart';
part 'demo_sounds.dart';
// DEBUG ONLY: a placeholder mock of the single-window layout (party strip, // DEBUG ONLY: a placeholder mock of the single-window layout (party strip,
// library with Jellyfin / Link / Files, click-to-select details, floating // library with Jellyfin / Link / Files, click-to-select details, floating
+130
View File
@@ -0,0 +1,130 @@
import 'sfx.dart';
import 'synth.dart';
// The app's sound set, all in D major pentatonic so any two that overlap
// still agree. Rising = someone arrives / gets ready, falling = leaves /
// unready; bells for people, soft sines for the player, plucks for ticks.
// Everything is quiet (-16 dBFS peaks or lower) and short.
// Pitches, in semitones from A4.
const _d4 = -7, _e4 = -5, _a4 = 0;
const _d5 = 5, _e5 = 7, _fs5 = 9, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _fs6 = 21, _a6 = 24;
double _p(int s) => semi(s);
/// The ready chime's second note climbs with how many are ready (1st, 2nd…).
const _readyLadder = [_fs5, _a5, _b5, _d6, _e6, _fs6];
SoundSpec readySound(int readyIndex) {
final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)];
return SoundSpec([
Note(0, _p(_d5), 0.35, gain: 0.8),
Note(0.075, _p(top), 0.55),
]);
}
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
SfxKind.ready => readySound(readyIndex),
SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.3, gain: 0.8),
Note(0.08, _p(_e5), 0.45, gain: 0.7),
], cutoff: 4200, peakDb: -19),
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d5), 0.35, gain: 0.7),
Note(0.06, _p(_fs5), 0.35, gain: 0.8),
Note(0.12, _p(_a5), 0.6),
]),
SfxKind.forceUnready => SoundSpec([
Note(0, _p(_a5), 0.3, gain: 0.8),
Note(0.06, _p(_fs5), 0.3, gain: 0.7),
Note(0.12, _p(_d5), 0.5, gain: 0.7),
], cutoff: 4200, peakDb: -19),
SfxKind.forcePlay => SoundSpec([
Note(0, _p(_d5), 0.7, gain: 0.6, voice: Voice.soft),
Note(0, _p(_a5), 0.7, gain: 0.5, voice: Voice.soft),
Note(0.12, _p(_d6), 0.8),
]),
SfxKind.forceHold => SoundSpec([
Note(0, _p(_a4), 0.35, voice: Voice.soft),
Note(0.1, _p(_d4), 0.55, voice: Voice.soft),
], cutoff: 3000, peakDb: -18),
SfxKind.kick => SoundSpec([
Note(0, _p(_e4), 0.4, voice: Voice.soft, to: _p(_d4 - 2)),
Note(0.14, _p(_d4 - 5), 0.6, voice: Voice.soft, gain: 0.8),
], cutoff: 1800, peakDb: -19),
SfxKind.join => SoundSpec([
Note(0, _p(_d5), 0.3, gain: 0.7),
Note(0.07, _p(_e5), 0.3, gain: 0.75),
Note(0.14, _p(_a5), 0.6),
]),
SfxKind.leave => SoundSpec([
Note(0, _p(_a5), 0.28, gain: 0.8),
Note(0.08, _p(_e5), 0.28, gain: 0.7),
Note(0.16, _p(_d5), 0.5, gain: 0.6),
], cutoff: 3800, peakDb: -20),
SfxKind.stale => SoundSpec([
Note(0, _p(_fs5), 0.6, voice: Voice.soft, wobble: 0.25),
], cutoff: 3500, peakDb: -21),
SfxKind.swap => SoundSpec([
Note(0, _p(_d5), 0.6, voice: Voice.soft, gain: 0.6),
Note(0.05, _p(_a5), 0.3, gain: 0.5),
Note(0.1, _p(_d6), 0.3, gain: 0.5),
Note(0.15, _p(_fs6), 0.5, gain: 0.45),
], cutoff: 6000, peakDb: -18),
SfxKind.seek => const SoundSpec([
Note(0, 987.77, 0.06, voice: Voice.pluck),
], cutoff: 3500, peakDb: -26, room: 0),
SfxKind.pause => SoundSpec([
Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_e4)),
], cutoff: 3000, peakDb: -21, room: 0.08),
SfxKind.resume => SoundSpec([
Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_d5)),
], cutoff: 3500, peakDb: -21, room: 0.08),
SfxKind.chat => SoundSpec([
Note(0, _p(_b5), 0.18, gain: 0.7),
Note(0.055, _p(_e6), 0.3),
], cutoff: 5500, peakDb: -19, room: 0.1),
SfxKind.warn => SoundSpec([
Note(0, _p(_fs5), 0.25, voice: Voice.soft),
Note(0.22, _p(_fs5), 0.25, voice: Voice.soft, gain: 0.85),
Note(0.44, _p(_d5), 0.45, voice: Voice.soft, gain: 0.8),
], cutoff: 3800, peakDb: -18),
};
/// Sounds the app has no event for yet (proposals, shown in the demo).
enum ExtraSound { start, finished, lost, back, image, mention }
SoundSpec extraSound(ExtraSound s) => switch (s) {
ExtraSound.start => SoundSpec([
Note(0, _p(_d5), 0.9, voice: Voice.soft, gain: 0.5),
Note(0.04, _p(_fs5), 0.9, voice: Voice.soft, gain: 0.45),
Note(0.08, _p(_a5), 0.9, voice: Voice.soft, gain: 0.45),
Note(0.12, _p(_d6), 0.9, gain: 0.6),
], cutoff: 5000, peakDb: -18, room: 0.2),
ExtraSound.finished => SoundSpec([
Note(0, _p(_a5), 0.5, gain: 0.8),
Note(0.16, _p(_fs5), 0.5, gain: 0.75),
Note(0.32, _p(_d5), 1.1, gain: 0.8),
Note(0.32, _p(_a4), 1.1, voice: Voice.soft, gain: 0.4),
], cutoff: 4500, peakDb: -18, room: 0.22),
ExtraSound.lost => SoundSpec([
Note(0, _p(_e4), 0.35, voice: Voice.soft),
Note(0.18, _p(_d4), 0.6, voice: Voice.soft),
], cutoff: 2500, peakDb: -18),
ExtraSound.back => SoundSpec([
Note(0, _p(_d4 + 12), 0.3, voice: Voice.soft),
Note(0.12, _p(_a4), 0.5, voice: Voice.soft),
Note(0.12, _p(_d5), 0.5, gain: 0.5),
], cutoff: 4000, peakDb: -18),
ExtraSound.image => SoundSpec([
Note(0, _p(_e6), 0.12, voice: Voice.pluck, gain: 0.6),
Note(0.05, _p(_b5), 0.18, gain: 0.7),
Note(0.1, _p(_a6), 0.35, gain: 0.6),
], cutoff: 6000, peakDb: -20, room: 0.1),
ExtraSound.mention => SoundSpec([
Note(0, _p(_d6), 0.25),
Note(0.09, _p(_a5), 0.25, gain: 0.8),
Note(0.18, _p(_d6), 0.45),
], cutoff: 6000, peakDb: -17),
};
+165
View File
@@ -0,0 +1,165 @@
import 'dart:math' as math;
import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds: notes with soft attacks and
// exponential decays, a low-pass to take the edge off and a short room
// tail. Everything renders to 16-bit mono WAV bytes. Pure Dart, no Flutter.
const sampleRate = 44100;
/// Timbre of a note.
enum Voice {
/// Sine with a touch of octave: round, flute-like.
soft,
/// Glass/marimba: a few partials, the upper ones die first.
bell,
/// Short and dry, for ticks.
pluck,
}
class Note {
/// Start (s) and pitch (Hz); [to] glides there over the note.
final double at;
final double hz;
final double? to;
/// Length (s) of the decay to about -40 dB.
final double len;
final double gain;
final Voice voice;
/// Vibrato depth in semitones (0 = none), ~5.5 Hz.
final double wobble;
const Note(this.at, this.hz, this.len,
{this.to, this.gain = 1, this.voice = Voice.bell, this.wobble = 0});
}
class SoundSpec {
final List<Note> notes;
/// One-pole low-pass cutoff (Hz): lower = softer, darker.
final double cutoff;
/// Peak level after normalising, in dBFS (quiet by design).
final double peakDb;
/// Room tail mix (0..1).
final double room;
const SoundSpec(this.notes, {this.cutoff = 5200, this.peakDb = -16, this.room = 0.16});
double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02);
}
/// Note frequency by name-free index: semitones from A4 (440 Hz).
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
Float64List render(SoundSpec spec) {
final total = (spec.length * sampleRate).ceil();
final out = Float64List(total);
for (final n in spec.notes) {
_addNote(out, n);
}
// Low-pass: one pole, run twice for a gentler 12 dB/oct slope.
final a = math.exp(-2 * math.pi * spec.cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < total; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
}
}
if (spec.room > 0) _room(out, spec.room);
// Fade the last 10 ms so nothing clicks, then normalise to the peak.
final fade = (0.01 * sampleRate).round();
for (var i = 0; i < fade && i < total; i++) {
out[total - 1 - i] *= i / fade;
}
var peak = 0.0;
for (final s in out) {
peak = math.max(peak, s.abs());
}
if (peak > 0) {
final g = math.pow(10, spec.peakDb / 20) / peak;
for (var i = 0; i < total; i++) {
out[i] *= g;
}
}
return out;
}
void _addNote(Float64List out, Note n) {
final start = (n.at * sampleRate).round();
final len = (n.len * sampleRate).round();
final attack = switch (n.voice) {
Voice.soft => 0.012,
Voice.bell => 0.004,
Voice.pluck => 0.002,
} * sampleRate;
// exp(-k * len) = 0.01 (-40 dB).
final k = math.log(100) / len;
final partials = switch (n.voice) {
Voice.soft => const [(1.0, 1.0, 1.0), (2.0, 0.12, 1.6)],
Voice.bell => const [(1.0, 1.0, 1.0), (2.0, 0.28, 1.8), (3.01, 0.08, 2.6), (4.2, 0.05, 3.5)],
Voice.pluck => const [(1.0, 1.0, 1.0), (2.0, 0.35, 2.0), (3.0, 0.15, 3.0)],
};
final phases = List<double>.filled(partials.length, 0);
for (var i = 0; i < len && start + i < out.length; i++) {
final t = i / len;
var hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
if (n.wobble > 0) {
hz *= math.pow(2, n.wobble / 12 * math.sin(2 * math.pi * 5.5 * i / sampleRate));
}
final env = (i < attack ? _smooth(i / attack) : 1.0) * math.exp(-k * i);
var s = 0.0;
for (var p = 0; p < partials.length; p++) {
final (mult, amp, decay) = partials[p];
phases[p] += 2 * math.pi * hz * mult / sampleRate;
// Upper partials fade faster: the attack has shimmer, the tail is round.
s += amp * math.sin(phases[p]) * math.exp(-k * (decay - 1) * i);
}
out[start + i] += n.gain * env * s;
}
}
double _smooth(double t) => t * t * (3 - 2 * t);
/// A few feedback delays (a small room): a soft tail instead of a dry stop.
void _room(Float64List out, double mix) {
const taps = [0.031, 0.047, 0.067];
final dry = Float64List.fromList(out);
for (final d in taps) {
final delay = (d * sampleRate).round();
final buf = Float64List(out.length);
for (var i = delay; i < out.length; i++) {
buf[i] = dry[i - delay] + 0.45 * buf[i - delay];
}
for (var i = 0; i < out.length; i++) {
out[i] += mix / taps.length * buf[i];
}
}
}
/// 16-bit PCM mono WAV.
Uint8List wav(Float64List samples) {
final data = samples.length * 2;
final b = ByteData(44 + data);
void str(int o, String s) {
for (var i = 0; i < s.length; i++) {
b.setUint8(o + i, s.codeUnitAt(i));
}
}
str(0, 'RIFF');
b.setUint32(4, 36 + data, Endian.little);
str(8, 'WAVE');
str(12, 'fmt ');
b.setUint32(16, 16, Endian.little);
b.setUint16(20, 1, Endian.little);
b.setUint16(22, 1, Endian.little);
b.setUint32(24, sampleRate, Endian.little);
b.setUint32(28, sampleRate * 2, Endian.little);
b.setUint16(32, 2, Endian.little);
b.setUint16(34, 16, Endian.little);
str(36, 'data');
b.setUint32(40, data, Endian.little);
for (var i = 0; i < samples.length; i++) {
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
}
return b.buffer.asUint8List();
}
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.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] is 0..1, applied on a square curve (ears are log).
class SynthSfx implements SfxService {
double volume;
SynthSfx({this.volume = 0.6});
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override
Future<void> play(SfxCue cue) =>
playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
/// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
if (volume <= 0) return;
try {
final path = await (_files[key] ??= _write(key, spec));
final player = await _player();
await player.setVolume(100 * volume * volume);
await player.open(Media(path));
} catch (e) {
debugPrint('sfx $key: $e');
}
}
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;
}
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];
}
/// Drops the cached renders (after editing a spec with hot reload).
void forget() => _files.clear();
Future<void> dispose() async {
for (final p in _pool) {
await p.dispose();
}
_pool.clear();
}
}
+2 -2
View File
@@ -723,14 +723,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "0.2.2" version: "1.0.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: "0.2.2" version: "1.0.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+7
View File
@@ -590,6 +590,13 @@ sideways until an end, then the page).
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
(what pops up; no switches), updates, shortcuts, about. (what pops up; no switches), updates, shortcuts, about.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` per feed event
through `SfxService` (the app still uses `NoopSfxService`). The set is
synthesized, not sampled: `synth.dart` renders notes (sine/bell/pluck
voices, soft attacks, low-pass, short room tail) to quiet WAVs,
`sound_bank.dart` defines every sound in D major pentatonic, and
`SynthSfx` plays them on a small pool of audio-only mpv players. The
debug layout demo's Notifications & sounds page plays each one.
- **Player** (`session_player.dart`): `SessionPlayer` holds the one - **Player** (`session_player.dart`): `SessionPlayer` holds the one
`Video` of the current player (a new `GlobalKey` per player generation). `Video` of the current player (a new `GlobalKey` per player generation).
The shell keeps it in one layer of the body at one place in the element The shell keeps it in one layer of the body at one place in the element