Sounds: synthesized sound set and a debug sound board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user