diff --git a/apps/desktop/lib/src/screens/demo/demo_settings.dart b/apps/desktop/lib/src/screens/demo/demo_settings.dart index 7736e68..e017e7c 100644 --- a/apps/desktop/lib/src/screens/demo/demo_settings.dart +++ b/apps/desktop/lib/src/screens/demo/demo_settings.dart @@ -173,18 +173,8 @@ class _SettingsTabState extends State<_SettingsTab> { 'Seeks and skips', '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: return [ diff --git a/apps/desktop/lib/src/screens/demo/demo_sounds.dart b/apps/desktop/lib/src/screens/demo/demo_sounds.dart new file mode 100644 index 0000000..19443eb --- /dev/null +++ b/apps/desktop/lib/src/screens/demo/demo_sounds.dart @@ -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 = {'seek'}; + String? _playing; + bool _sequence = false; + + Future _play(String key, SoundSpec spec) async { + setState(() => _playing = key); + await _sfx.playSpec(key, spec); + final ms = (spec.length * 1000).round(); + await Future.delayed(Duration(milliseconds: ms)); + if (mounted && _playing == key) setState(() => _playing = null); + } + + Future _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.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 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 = >{}; + + List _peaks() => _cache[id] ??= () { + final samples = render(spec); + const cols = 48; + final per = (samples.length / cols).ceil(); + final peaks = []; + 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 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; +} diff --git a/apps/desktop/lib/src/screens/demo/shell_demo.dart b/apps/desktop/lib/src/screens/demo/shell_demo.dart index 1520402..a8a5c11 100644 --- a/apps/desktop/lib/src/screens/demo/shell_demo.dart +++ b/apps/desktop/lib/src/screens/demo/shell_demo.dart @@ -13,12 +13,17 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:window_manager/window_manager.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'; part 'demo_files.dart'; part 'demo_player.dart'; part 'demo_schedule.dart'; part 'demo_settings.dart'; +part 'demo_sounds.dart'; // DEBUG ONLY: a placeholder mock of the single-window layout (party strip, // library with Jellyfin / Link / Files, click-to-select details, floating diff --git a/apps/desktop/lib/src/sfx/sound_bank.dart b/apps/desktop/lib/src/sfx/sound_bank.dart new file mode 100644 index 0000000..6d5a076 --- /dev/null +++ b/apps/desktop/lib/src/sfx/sound_bank.dart @@ -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), + }; diff --git a/apps/desktop/lib/src/sfx/synth.dart b/apps/desktop/lib/src/sfx/synth.dart new file mode 100644 index 0000000..dfcc3e1 --- /dev/null +++ b/apps/desktop/lib/src/sfx/synth.dart @@ -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 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(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.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(); +} diff --git a/apps/desktop/lib/src/sfx/synth_sfx.dart b/apps/desktop/lib/src/sfx/synth_sfx.dart new file mode 100644 index 0000000..9be2186 --- /dev/null +++ b/apps/desktop/lib/src/sfx/synth_sfx.dart @@ -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 _pool = []; + var _next = 0; + final Map> _files = {}; + Directory? _dir; + + @override + Future 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 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 _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() 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 dispose() async { + for (final p in _pool) { + await p.dispose(); + } + _pool.clear(); + } +} diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index 407c71d..5d1c420 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -723,14 +723,14 @@ packages: path: "../../packages/core" relative: true source: path - version: "0.2.2" + version: "1.0.0" sync_relay_server: dependency: "direct main" description: path: "../../packages/server" relative: true source: path - version: "0.2.2" + version: "1.0.0" synchronized: dependency: transitive description: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1bc4bf7..0314261 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -590,6 +590,13 @@ sideways until an end, then the page). Jellyfin & integrations (linked servers, AnimeFlow's `/me` via `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds (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 `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