From a4be24a2a548ea4e690c851bf0a9436f02e24283 Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 20:21:43 +0300 Subject: [PATCH] Sounds: three presets (Cosmic, Abyss, Wood & glass), chosen overall or per sound; nudge sound Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/desktop/lib/src/sfx/sfx.dart | 2 + apps/desktop/lib/src/sfx/sound_bank.dart | 236 +++++++++++++++--- apps/desktop/lib/src/sfx/synth.dart | 189 ++++++++++++-- apps/desktop/lib/src/sfx/synth_sfx.dart | 21 +- apps/desktop/lib/src/shell/settings_tab.dart | 40 ++- apps/desktop/lib/src/state/session_state.dart | 10 + apps/desktop/lib/src/state/settings.dart | 29 +++ apps/desktop/test/sfx_test.dart | 26 +- docs/ARCHITECTURE.md | 20 +- 10 files changed, 499 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 3b64983..cf64461 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ password, recent servers with remove); Player (below); Jellyfin & integrations (linked Jellyfin servers, below, the AnimeFlow connection: "connected as " with what you may do, or why not, and playing the next episode automatically); Notifications & sounds (what pops up; the sound -volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About +volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About (the version). Player settings live in the player: ⚙ at the right end of its controls diff --git a/apps/desktop/lib/src/sfx/sfx.dart b/apps/desktop/lib/src/sfx/sfx.dart index ff406c3..d7bdac3 100644 --- a/apps/desktop/lib/src/sfx/sfx.dart +++ b/apps/desktop/lib/src/sfx/sfx.dart @@ -17,6 +17,8 @@ enum SfxKind { /// The connection to the server dropped / came back. lost, back, + /// Someone pokes you for attention; plays only on your end. + nudge, } class SfxCue { diff --git a/apps/desktop/lib/src/sfx/sound_bank.dart b/apps/desktop/lib/src/sfx/sound_bank.dart index bd9e4de..12bc23e 100644 --- a/apps/desktop/lib/src/sfx/sound_bank.dart +++ b/apps/desktop/lib/src/sfx/sound_bank.dart @@ -1,83 +1,254 @@ import 'sfx.dart'; import 'synth.dart'; -// The sound of the house: koto for readiness, the wind chime for people -// arriving and leaving, the shoji door for a new video, bamboo for chat, -// clappers for a kick, the singing bowl for the idle warning, a string -// going slack and re-tuned for the connection. All in the Yo scale on D -// (D E G A B), so overlapping sounds agree; quiet and short. +// The app's sounds come in presets; each action can take its sound from any +// of them. Every preset is quiet, short and on D, so sounds from different +// presets still agree when they overlap. +// - Cosmic: star bells, stardust, bubbles, soft pads, a space tail. +// - Abyss: deep space and its dread. Far beacons, nebula washes, slow +// beating clusters and tritone sags, on D phrygian, with a vast tail. +// - Wood & glass: plucked strings, glass bells, wood knocks, a short room. -const _d4 = -7, _a4 = 0, _b4 = 2; -const _d5 = 5, _g5 = 10, _a5 = 12, _b5 = 14; -const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26; +enum SoundPreset { cosmic, abyss, woodAndGlass } + +/// Shown in Settings: name and feel. +const presetInfo = { + SoundPreset.cosmic: ('Cosmic', 'Star bells, stardust and a soft space tail'), + SoundPreset.abyss: ('Abyss', 'Deep space: far beacons, nebula, a vast dark tail'), + SoundPreset.woodAndGlass: ('Wood & glass', 'Plucked strings, glass bells and wood, close and dry'), +}; + +/// The preset used when nothing else is chosen. +const defaultPreset = SoundPreset.abyss; + +const _d3 = -19, _ab3 = -13; +const _d4 = -7, _eb4 = -6, _ab4 = -1, _a4 = 0, _b4 = 2; +const _d5 = 5, _eb5 = 6, _e5 = 7, _f5 = 8, _fs5 = 9, _g5 = 10, _a5 = 12, _bb5 = 13, _b5 = 14; +const _c6 = 15, _cs6 = 16, _d6 = 17, _e6 = 19, _f6 = 20, _fs6 = 21, _g6 = 22, _a6 = 24, _b6 = 26; double _p(int s) => semi(s); -/// The ready pluck's answer climbs with each person ready. -const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6]; +/// The ready sound's answer climbs with each person ready. +const _cosmicLadder = [_a5, _b5, _d6, _e6, _fs6, _a6]; +const _abyssLadder = [_f5, _g5, _a5, _c6, _d6, _f6]; +const _woodLadder = [_g5, _a5, _b5, _d6, _e6, _g6]; -SoundSpec readySound(int readyIndex) { - final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)]; - return SoundSpec([ - Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55), - Note(0.085, _p(top), 1.1, bright: 0.65), - ], peakDb: -17); +SoundSpec readySound(int readyIndex, {SoundPreset preset = defaultPreset}) => + soundFor(SfxKind.ready, readyIndex: readyIndex, preset: preset); + +SoundSpec soundFor(SfxKind kind, {int readyIndex = 1, SoundPreset preset = defaultPreset}) { + final i = (readyIndex - 1).clamp(0, 5); + return switch (preset) { + SoundPreset.cosmic => _cosmic(kind, i), + SoundPreset.abyss => _abyss(kind, i), + SoundPreset.woodAndGlass => _wood(kind, i), + }; } -SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) { - SfxKind.ready => readySound(readyIndex), +SoundSpec _cosmic(SfxKind kind, int i) => switch (kind) { + SfxKind.ready => SoundSpec([ + Note(0, _p(_d4), 1.0, voice: Voice.pad, gain: 0.3), + Note(0, _p(_d5), 1.2, voice: Voice.star, gain: 0.8, bright: 0.4), + Note(0.09, _p(_cosmicLadder[i]), 1.5, voice: Voice.star, bright: 0.5), + ], peakDb: -17, room: 0.3), + // The same bells, dimmed and stepping down. + SfxKind.unready => SoundSpec([ + Note(0, _p(_fs5), 0.6, voice: Voice.star, gain: 0.8, bright: 0.15), + Note(0.1, _p(_d5), 0.8, voice: Voice.star, bright: 0.1), + ], cutoff: 3500, peakDb: -20, room: 0.25), + // Just the bell, left hanging in space: playback is the rest of the cue. + SfxKind.forceReady => SoundSpec([ + Note(0, _p(_d6), 1.8, voice: Voice.star, bright: 0.7), + ], cutoff: 7000, peakDb: -18, room: 0.35, tail: 1.6), + // A star arriving: dust rising into a bell. + SfxKind.join => SoundSpec([ + Note(0, _p(_d6), 1.0, voice: Voice.dust, to: _p(_a6), gain: 0.5), + Note(0.15, _p(_a5), 1.4, voice: Voice.star, bright: 0.35, gain: 0.6), + ], cutoff: 7000, peakDb: -19, room: 0.3), + // Dust drifting down, a low bell going out. + SfxKind.leave => SoundSpec([ + Note(0, _p(_a6), 0.9, voice: Voice.dust, to: _p(_d6), gain: 0.5), + Note(0.1, _p(_d5), 1.3, voice: Voice.star, bright: 0.2, gain: 0.5), + ], cutoff: 5000, peakDb: -22, room: 0.3), + // Falling into the dark: a deep tone sinking an octave under a sagging pad. + SfxKind.kick => SoundSpec([ + Note(0, _p(_d4), 1.0, voice: Voice.sub, to: _p(_d3)), + Note(0, _p(_a4), 0.9, voice: Voice.pad, to: _p(_d4), gain: 0.4), + ], cutoff: 3000, peakDb: -19, room: 0.25), + // Something new opens up: a rush of air, then a wide chord blooms. + SfxKind.swap => SoundSpec([ + Note(0, 300, 0.6, voice: Voice.slide, to: 2400, gain: 0.35), + Note(0.35, _p(_d4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3), + Note(0.35, _p(_a4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3), + Note(0.35, _p(_e5), 1.4, voice: Voice.pad, gain: 0.4, bright: 0.3), + Note(0.38, _p(_fs6), 1.2, voice: Voice.star, bright: 0.3, gain: 0.3), + ], peakDb: -18, room: 0.3), + // Two bubbles rising: bloop-bloop, the second higher. + SfxKind.chat => SoundSpec([ + Note(0, _p(_a5), 0.18, voice: Voice.drop, gain: 0.8), + Note(0.08, _p(_e6), 0.22, voice: Voice.drop), + ], peakDb: -19, room: 0.15, tail: 1.0), + // Something huge drifting past: a slow, deep swell. + SfxKind.warn => SoundSpec([ + Note(0, _p(_d4), 2.0, voice: Voice.pad), + Note(0, _p(_a4), 2.0, voice: Voice.pad, gain: 0.5), + Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.4), + ], cutoff: 3000, peakDb: -20, room: 0.2, tail: 1.0), + // The signal fading: a chord sagging down and dimming. + SfxKind.lost => SoundSpec([ + Note(0, _p(_a4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.7), + Note(0, _p(_e5), 1.2, voice: Voice.pad, to: _p(_a4), gain: 0.4), + ], cutoff: 2500, peakDb: -19, room: 0.3, tail: 1.2), + // Re-acquired: a bell gliding up into tune, landing with dust. + SfxKind.back => SoundSpec([ + Note(0, _p(_a4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.3, gain: 0.5), + Note(0.22, _p(_a5), 1.2, voice: Voice.star, bright: 0.5), + Note(0.25, _p(_d6), 0.6, voice: Voice.dust, to: _p(_a6), gain: 0.3), + ], peakDb: -18, room: 0.25), + // A poke: two quick bright bubbles flicking up, a glint on the second. + // High and dry so it cuts through a video and stays a patter when spammed. + SfxKind.nudge => SoundSpec([ + Note(0, _p(_d6), 0.12, voice: Voice.drop, to: _p(_fs6), gain: 0.8), + Note(0.09, _p(_a6), 0.16, voice: Voice.drop), + Note(0.09, _p(_cs6) * 2, 0.3, voice: Voice.star, bright: 0.6, gain: 0.2), + ], cutoff: 7000, peakDb: -18, room: 0.05, tail: 0.6), + }; + +SoundSpec _abyss(SfxKind kind, int i) => switch (kind) { + // A beacon pinging far out, the void humming under it. + SfxKind.ready => SoundSpec([ + Note(0, _p(_d3), 1.2, voice: Voice.pad, gain: 0.35), + Note(0, _p(_d5), 1.4, voice: Voice.star, gain: 0.8, bright: 0.25), + Note(0.12, _p(_abyssLadder[i]), 1.6, voice: Voice.star, bright: 0.3), + ], cutoff: 5000, peakDb: -18, room: 0.4, tail: 1.8), + // The beacon answering itself a tritone down, dimmer. + SfxKind.unready => SoundSpec([ + Note(0, _p(_a4), 0.7, voice: Voice.star, gain: 0.8, bright: 0.1), + Note(0.12, _p(_eb4), 0.9, voice: Voice.star, bright: 0.1), + ], cutoff: 2500, peakDb: -21, room: 0.35, tail: 1.6), + // One bell, huge and very far away. + SfxKind.forceReady => SoundSpec([ + Note(0, _p(_d5), 1.7, voice: Voice.star, bright: 0.5), + ], cutoff: 5000, peakDb: -18, room: 0.45, tail: 1.8), + // Something emerging from the nebula: a dark wash brightening into a bell. + SfxKind.join => SoundSpec([ + Note(0, 150, 1.2, voice: Voice.slide, to: 900, gain: 0.4), + Note(0.05, _p(_d4), 1.2, voice: Voice.pad, gain: 0.3), + Note(0.3, _p(_a5), 1.3, voice: Voice.star, bright: 0.3, gain: 0.5), + ], cutoff: 4500, peakDb: -19, room: 0.4, tail: 1.6), + // Swallowed back into it: the wash darkening over a fading bell. + SfxKind.leave => SoundSpec([ + Note(0, 900, 1.1, voice: Voice.slide, to: 150, gain: 0.4), + Note(0.1, _p(_d5), 1.2, voice: Voice.star, bright: 0.15, gain: 0.4), + ], cutoff: 3500, peakDb: -22, room: 0.4, tail: 1.6), + // Pulled under: a deep fall with a tritone sagging over it. + SfxKind.kick => SoundSpec([ + Note(0, _p(_d4), 1.3, voice: Voice.sub, to: _p(_d3)), + Note(0, _p(_ab4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.35), + ], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.6), + // A door into the dark: low air, then an unresolved chord opens. + SfxKind.swap => SoundSpec([ + Note(0, 120, 0.8, voice: Voice.slide, to: 1200, gain: 0.35), + Note(0.45, _p(_d4), 1.3, voice: Voice.pad, gain: 0.5, bright: 0.2), + Note(0.45, _p(_a4), 1.3, voice: Voice.pad, gain: 0.45, bright: 0.2), + Note(0.45, _p(_eb5), 1.3, voice: Voice.pad, gain: 0.3, bright: 0.2), + Note(0.5, _p(_bb5), 1.1, voice: Voice.star, bright: 0.2, gain: 0.25), + ], cutoff: 4000, peakDb: -19, room: 0.35, tail: 1.4), + // Bubbles rising from deep water. + SfxKind.chat => SoundSpec([ + Note(0, _p(_d5), 0.25, voice: Voice.drop, gain: 0.8), + Note(0.1, _p(_a5), 0.3, voice: Voice.drop), + ], cutoff: 4500, peakDb: -19, room: 0.3, tail: 1.4), + // Something vast drawing near: a slow swell of two notes rubbing. + SfxKind.warn => SoundSpec([ + Note(0, _p(_d4), 2.0, voice: Voice.pad), + Note(0, _p(_eb4), 2.0, voice: Voice.pad, gain: 0.35), + Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.45), + ], cutoff: 2200, peakDb: -20, room: 0.25, tail: 1.0), + // The signal drowning: a chord sagging by a tritone into static. + SfxKind.lost => SoundSpec([ + Note(0, _p(_a4), 1.3, voice: Voice.pad, to: _p(_eb4), gain: 0.7), + Note(0, _p(_d4), 1.3, voice: Voice.pad, to: _p(_ab3), gain: 0.5), + Note(0.2, 600, 1.0, voice: Voice.slide, to: 200, gain: 0.2), + ], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.4), + // Found again: a bell climbing out of the tritone into tune. + SfxKind.back => SoundSpec([ + Note(0, _p(_ab4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.25, gain: 0.5), + Note(0.25, _p(_a5), 1.3, voice: Voice.star, bright: 0.4), + Note(0.28, _p(_d5), 0.6, voice: Voice.dust, to: _p(_a5), gain: 0.25), + ], cutoff: 5000, peakDb: -18, room: 0.35, tail: 1.6), + // A poke from the dark: two quick bubbles flicking up, a pale glint. + // Still high and fairly dry so it cuts through a video when spammed. + SfxKind.nudge => SoundSpec([ + Note(0, _p(_a5), 0.13, voice: Voice.drop, to: _p(_c6), gain: 0.8), + Note(0.1, _p(_d6), 0.18, voice: Voice.drop), + Note(0.1, _p(_a6), 0.35, voice: Voice.star, bright: 0.5, gain: 0.2), + ], cutoff: 6000, peakDb: -18, room: 0.1, tail: 1.0), + }; + +SoundSpec _wood(SfxKind kind, int i) => switch (kind) { + SfxKind.ready => SoundSpec([ + Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55), + Note(0.085, _p(_woodLadder[i]), 1.1, bright: 0.65), + ], peakDb: -17, room: 0.14, tail: 0.5), // The same strings, muted and stepping down. SfxKind.unready => SoundSpec([ Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2), Note(0.09, _p(_g5), 0.45, bright: 0.15), - ], cutoff: 3800, peakDb: -20, room: 0.1), + ], cutoff: 3800, peakDb: -20, room: 0.1, tail: 0.5), // Just the bell: playback starting is the rest of the cue. SfxKind.forceReady => SoundSpec([ Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime), - ], cutoff: 7000, peakDb: -18, room: 0.18), - // Wind through the furin: three uneven tinkles. + ], cutoff: 7000, peakDb: -18, room: 0.18, tail: 0.5), + // Wind through glass: three uneven tinkles. SfxKind.join => SoundSpec([ Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8), Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55), Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45), - ], cutoff: 7000, peakDb: -19, room: 0.18), + ], cutoff: 7000, peakDb: -19, room: 0.18, tail: 0.5), // The wind settling: two softer, lower tinkles. SfxKind.leave => SoundSpec([ Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6), Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5), - ], cutoff: 5000, peakDb: -22, room: 0.18), - // Hyoshigi: clack… clack, then a low muted string. + ], cutoff: 5000, peakDb: -22, room: 0.18, tail: 0.5), + // Two hardwood clacks, then a low muted string. SfxKind.kick => SoundSpec([ Note(0, 1900, 0.09, voice: Voice.clack), Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9), Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7), - ], cutoff: 5000, peakDb: -19, room: 0.12), + ], cutoff: 5000, peakDb: -19, room: 0.12, tail: 0.5), // The door slides open on something new: air, then a fifth. SfxKind.swap => SoundSpec([ Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5), Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8), Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7), - ], peakDb: -18), - // Bamboo: a hollow tok-tok, the second higher. + ], peakDb: -18, tail: 0.5), + // A hollow wooden tok-tok, the second higher. SfxKind.chat => SoundSpec([ Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85), Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo), - ], cutoff: 5500, peakDb: -19, room: 0.1), - // The singing bowl: one soft strike that hums. + ], cutoff: 5500, peakDb: -19, room: 0.1, tail: 0.5), + // A bowl: one soft strike that hums. SfxKind.warn => SoundSpec([ Note(0, _p(_d5), 2.6, voice: Voice.rin), - ], cutoff: 4500, peakDb: -20, room: 0.1), + ], cutoff: 4500, peakDb: -20, room: 0.1, tail: 0.5), // A string going slack: plucked, its pitch sagging away. SfxKind.lost => SoundSpec([ Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4), Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6), - ], cutoff: 3500, peakDb: -19), + ], cutoff: 3500, peakDb: -19, tail: 0.5), // Re-tuned: a quick bend up that lands, and a tinkle. SfxKind.back => SoundSpec([ Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5), Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7), Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2), - ], peakDb: -18), + ], peakDb: -18, tail: 0.5), + // Two quick wooden taps, the second flicking up, with a glint. + SfxKind.nudge => SoundSpec([ + Note(0, _p(_a5), 0.12, voice: Voice.bamboo, gain: 0.8), + Note(0.09, _p(_e6), 0.16, voice: Voice.bamboo), + Note(0.09, _p(_b6), 0.35, voice: Voice.chime, gain: 0.3), + ], cutoff: 6500, peakDb: -18, room: 0.04, tail: 0.5), }; /// Shown in Settings → Notifications & sounds: name and when it plays. @@ -93,4 +264,5 @@ const soundInfo = { SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'), SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'), SfxKind.back: ('Reconnected', 'Back in the server after a drop'), + SfxKind.nudge: ('Nudge', 'Someone nudges you to get your attention'), }; diff --git a/apps/desktop/lib/src/sfx/synth.dart b/apps/desktop/lib/src/sfx/synth.dart index 19c773b..74d1c09 100644 --- a/apps/desktop/lib/src/sfx/synth.dart +++ b/apps/desktop/lib/src/sfx/synth.dart @@ -1,10 +1,11 @@ import 'dart:math' as math; import 'dart:typed_data'; -// A tiny offline synthesizer for UI sounds, voiced as a Japanese house: -// koto strings (Karplus-Strong), a glass wind chime (furin), a singing -// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a -// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart. +// A tiny offline synthesizer for UI sounds. The current palette is +// spacious: FM star bells, bubble drops, slow pads, a sub that falls away, +// stardust and a long dark space tail. Older voices (plucked string, glass +// and bowl bells, wood) stay available. Renders to 16-bit mono WAV bytes. +// Pure Dart. const sampleRate = 44100; @@ -22,6 +23,19 @@ enum Voice { /// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the /// sweep of its band. slide, + /// FM bell: strikes glassy, settles to a pure hanging tone; [Note.bright] + /// scales the strike, [Note.to] glides it. + star, + /// A bubble: the pitch rises into the note fast; [Note.to] flicks it on. + drop, + /// Slow detuned swell (in over a third of its length, then out); + /// [Note.bright] adds overtones, [Note.to] glides it. + pad, + /// Deep round tone that falls from [Note.hz] to [Note.to]. + sub, + /// A scatter of tiny pings on the scale above [Note.hz], drifting + /// towards [Note.to]. + dust, } class Note { @@ -44,12 +58,15 @@ class SoundSpec { final double cutoff; /// Peak level after normalising, dBFS (quiet by design). final double peakDb; - /// Room tail mix (0..1): the veranda, not a hall. + /// Space tail mix (0..1). final double room; - const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14}); + /// Space tail decay to -60 dB (s); the sound keeps about half of it. + final double tail; + const SoundSpec(this.notes, + {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14, this.tail = 1.4}); double get length => - notes.fold(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02); + notes.fold(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? tail * 0.55 : 0.02); } /// Semitones from A4 (440 Hz) to Hz. @@ -69,13 +86,18 @@ Float64List render(SoundSpec spec, {int seed = 7}) { Voice.bamboo => _bamboo(n, rnd), Voice.clack => _clack(n, rnd), Voice.slide => _slide(n, rnd), + Voice.star => _star(n), + Voice.drop => _drop(n), + Voice.pad => _pad(n, rnd), + Voice.sub => _sub(n), + Voice.dust => _dust(n, rnd), }; for (var i = 0; i < s.length && start + i < total; i++) { out[start + i] += n.gain * s[i]; } } _lowpass(out, spec.cutoff); - if (spec.room > 0) _room(out, spec.room); + if (spec.room > 0) _space(out, spec.room, spec.tail); // 1 ms in, 10 ms out: no clicks at either end. final fadeIn = (0.001 * sampleRate).round(); for (var i = 0; i < fadeIn && i < total; i++) { @@ -276,20 +298,149 @@ void _lowpass(Float64List out, double cutoff) { } } -/// A few feedback delays: wood and paper walls, a short tail. -void _room(Float64List out, double mix) { - const taps = [0.029, 0.041, 0.059]; - final dry = Float64List.fromList(out); - for (final d in taps) { +/// Space: four damped feedback combs and two allpasses (a dark, smooth +/// tail decaying to -60 dB in [tail] s) after a short pre-delay. +void _space(Float64List out, double mix, double tail) { + const combs = [0.0297, 0.0371, 0.0411, 0.0437]; + const damp = 0.35; + final len = out.length; + final pre = _n(0.018); + final dry = Float64List(len); + for (var i = pre; i < len; i++) { + dry[i] = out[i - pre]; + } + final wet = Float64List(len); + for (final d in combs) { final delay = _n(d); - final buf = Float64List(out.length); - for (var i = delay; i < out.length; i++) { - buf[i] = dry[i - delay] + 0.42 * buf[i - delay]; - } - for (var i = 0; i < out.length; i++) { - out[i] += mix / taps.length * buf[i]; + final g = math.pow(0.001, d / tail).toDouble(); + final y = Float64List(len); + var lp = 0.0; + for (var i = 0; i < len; i++) { + final back = i >= delay ? y[i - delay] : 0.0; + lp = back * (1 - damp) + lp * damp; + y[i] = dry[i] + g * lp; + wet[i] += y[i] / combs.length; } } + for (final d in const [0.005, 0.0017]) { + final delay = _n(d); + final x = Float64List.fromList(wet); + for (var i = 0; i < len; i++) { + final xd = i >= delay ? x[i - delay] : 0.0; + final yd = i >= delay ? wet[i - delay] : 0.0; + wet[i] = -0.5 * x[i] + xd + 0.5 * yd; + } + } + for (var i = 0; i < len; i++) { + out[i] += mix * wet[i]; + } +} + +/// [Note.hz] gliding to [Note.to] over the first ~60% of the note. +double _glide(Note n, double t) => + n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6))).toDouble(); + +Float64List _star(Note n) { + final len = _n(n.len); + final out = Float64List(len); + final k = math.log(100) / len; + final attack = _n(0.004); + final index0 = 0.3 + 3.0 * n.bright; + final idxTau = _n(0.09 + 0.12 * n.bright); + const ratio = 3.5; + var c1 = 0.0, c2 = 0.0, m1 = 0.0, m2 = 0.0; + for (var i = 0; i < len; i++) { + final hz = _glide(n, i / len); + final w = 2 * math.pi * hz / sampleRate; + c1 += w; + c2 += w * 1.0017; + m1 += w * ratio; + m2 += w * ratio * 1.0017; + final idx = index0 * math.exp(-i / idxTau); + final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1); + out[i] = env * 0.5 * (math.sin(c1 + idx * math.sin(m1)) + math.sin(c2 + idx * math.sin(m2))); + } + return out; +} + +Float64List _drop(Note n) { + final len = _n(n.len); + final out = Float64List(len); + final k = math.log(100) / len; + final attack = _n(0.002); + final riseTau = _n(0.016); + var ph = 0.0; + for (var i = 0; i < len; i++) { + final t = i / len; + final base = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 2.5))); + final hz = base * (0.62 + 0.38 * (1 - math.exp(-i / riseTau))); + ph += 2 * math.pi * hz / sampleRate; + final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1); + out[i] = env * (math.sin(ph) + 0.15 * math.sin(2 * ph)); + } + return out; +} + +Float64List _pad(Note n, math.Random rnd) { + final len = _n(n.len); + final out = Float64List(len); + const detune = [-9.0, -3.0, 4.0, 10.0]; + final ph = [for (var j = 0; j < detune.length + 1; j++) rnd.nextDouble() * 2 * math.pi]; + final ratios = [for (final c in detune) math.pow(2, c / 1200).toDouble(), 0.5]; + final amps = [for (final _ in detune) 1.0, 0.45]; + const a = 0.33; + for (var i = 0; i < len; i++) { + final t = i / len; + final env = t < a ? _smooth(t / a) : math.pow(1 - _smooth((t - a) / (1 - a)), 2).toDouble(); + final vib = 1 + 0.002 * math.sin(2 * math.pi * 0.3 * i / sampleRate); + final hz = _glide(n, t) * vib; + var v = 0.0; + for (var j = 0; j < ratios.length; j++) { + ph[j] += 2 * math.pi * hz * ratios[j] / sampleRate; + v += amps[j] * (math.sin(ph[j]) + n.bright * (0.25 * math.sin(2 * ph[j]) + 0.1 * math.sin(3 * ph[j]))); + } + out[i] = env * v / ratios.length; + } + return out; +} + +Float64List _sub(Note n) { + final len = _n(n.len); + final out = Float64List(len); + final k = math.log(100) / len; + final attack = _n(0.025); + var ph = 0.0; + for (var i = 0; i < len; i++) { + final t = i / len; + final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t)); + ph += 2 * math.pi * hz / sampleRate; + final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1); + // Overtones so it still reads on small speakers. + out[i] = env * (math.sin(ph) + 0.35 * math.sin(2 * ph) + 0.12 * math.sin(3 * ph)); + } + return out; +} + +Float64List _dust(Note n, math.Random rnd) { + final len = _n(n.len); + final out = Float64List(len); + const steps = [0, 2, 4, 7, 9, 12, 14]; + final count = math.max(4, (n.len * 14).round()); + for (var g = 0; g < count; g++) { + final tg = (g + rnd.nextDouble() * 0.6) / count * 0.75; + final start = _n(tg * n.len); + final centre = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, tg); + final f = centre * math.pow(2, steps[rnd.nextInt(steps.length)] / 12); + final glen = math.min(_n(0.1 + 0.15 * rnd.nextDouble()), len - start); + if (glen <= 0) continue; + final amp = (1 - tg) * (0.5 + 0.5 * rnd.nextDouble()); + final k = math.log(100) / glen; + final w = 2 * math.pi * f / sampleRate; + for (var i = 0; i < glen; i++) { + out[start + i] += amp * math.exp(-k * i) * (i < 88 ? i / 88 : 1) * math.sin(w * i); + } + } + return out; } /// 16-bit PCM mono WAV. diff --git a/apps/desktop/lib/src/sfx/synth_sfx.dart b/apps/desktop/lib/src/sfx/synth_sfx.dart index 0747db1..dfab9d8 100644 --- a/apps/desktop/lib/src/sfx/synth_sfx.dart +++ b/apps/desktop/lib/src/sfx/synth_sfx.dart @@ -16,7 +16,11 @@ import 'synth.dart'; class SynthSfx implements SfxService { final double Function() volume; final bool Function(SfxKind kind) enabled; - SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true); + /// Which sound set each sound plays from. + final SoundPreset Function(SfxKind kind) presetFor; + SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor}) + : enabled = enabled ?? ((_) => true), + presetFor = presetFor ?? ((_) => defaultPreset); static const _poolSize = 3; final List _pool = []; @@ -27,12 +31,19 @@ class SynthSfx implements SfxService { @override Future play(SfxCue cue) async { if (!enabled(cue.kind)) return; - await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex)); + final preset = 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). - Future preview(SfxKind kind, {int readyIndex = 1}) => - playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex)); + /// Plays [kind] even when it is switched off (Settings' preview), always + /// freshly rendered: an edited spec plays after a hot reload. + Future preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) { + final p = preset ?? presetFor(kind); + final key = '${p.name}-${kind.name}-$readyIndex'; + _files.remove(key); + return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p)); + } /// Plays [spec], cached under [key] (same key = same sound). Future playSpec(String key, SoundSpec spec) async { diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index a4bdac2..a144561 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -523,7 +523,8 @@ class NotificationsInfo extends StatelessWidget { } } -/// Volume, then every sound with a preview button and its own switch. +/// Volume and the sound set, then every sound with a preview button, its +/// own set (or the overall one) and its own switch. class SoundSettings extends StatelessWidget { const SoundSettings({super.key}); @@ -540,6 +541,8 @@ class SoundSettings extends StatelessWidget { return SignalBuilder(builder: (context) { final volume = s.soundVolume.value; final off = s.soundsOff.value; + final overall = s.soundPreset.value; + final per = s.soundPresetFor.value; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -560,6 +563,26 @@ class SoundSettings extends StatelessWidget { ), Text('Soft cues for people and readiness; the video itself covers play, pause and the end. ' 'Letting go of the slider plays the ready sound.', style: muted), + const SizedBox(height: 12), + Row( + children: [ + const Expanded(child: Text('Sound set')), + DropdownButton( + key: const ValueKey('sound-preset'), + value: overall, + onChanged: (v) { + if (v == null) return; + s.setSoundPreset(v); + _preview(SfxKind.ready); + }, + items: [ + for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) + DropdownMenuItem(value: p.name, child: Text(name)), + ], + ), + ], + ), + Text(presetInfo.entries.where((e) => e.key.name == overall).firstOrNull?.value.$2 ?? '', style: muted), const SizedBox(height: 8), for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries) Padding( @@ -579,6 +602,21 @@ class SoundSettings extends StatelessWidget { children: [Text(name), Text(help, style: muted)], ), ), + DropdownButton( + key: ValueKey('sound-preset-${kind.name}'), + value: per[kind.name], + underline: const SizedBox.shrink(), + style: muted, + onChanged: (v) { + s.setSoundPresetFor(kind.name, v); + if (volume > 0) _preview(kind); + }, + items: [ + const DropdownMenuItem(value: null, child: Text('Same as set')), + for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) + DropdownMenuItem(value: p.name, child: Text(name)), + ], + ), Switch( key: ValueKey('sound-on-${kind.name}'), value: !off.contains(kind.name), diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index b3b5fe9..03f16b5 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -10,6 +10,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/session_media.dart'; import '../playback/yt_dlp_app.dart'; import '../sfx/sfx.dart'; +import '../sfx/sound_bank.dart'; import '../sfx/synth_sfx.dart'; import '../session/health.dart'; import '../session/session_coordinator.dart'; @@ -159,6 +160,7 @@ class SessionState { final SfxService sfx = SynthSfx( volume: () => AppSettings.instance.soundVolume.peek(), enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name), + presetFor: soundPresetFor, ); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); final lastForm = ConnectForm(); @@ -399,3 +401,11 @@ class SessionState { route.value = AppRoute.home; } } + +/// The sound set [kind] plays from: its own pick in Settings, else the +/// overall one. +SoundPreset soundPresetFor(SfxKind kind) { + final s = AppSettings.instance; + final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek(); + return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset; +} diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index fbb6699..23d8a14 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -22,6 +22,8 @@ const _keyHideReReleases = 'hideReReleases'; const _keyProgressLine = 'progressLine'; const _keySoundVolume = 'soundVolume'; const _keySoundsOff = 'soundsOff'; +const _keySoundPreset = 'soundPreset'; +const _keySoundPresetFor = 'soundPresetFor'; const _keyUnreadyOnSwap = 'unreadyOnSwap'; enum HwdecMode { auto, off, custom } @@ -50,6 +52,11 @@ class AppSettings { final soundVolume = signal(1.0); /// `SfxKind` names switched off one by one. final soundsOff = signal>(const {}); + /// The sound set (`SoundPreset` name) every sound uses unless + /// [soundPresetFor] picks another one for it. + final soundPreset = signal('cosmic'); + /// `SfxKind` name -> `SoundPreset` name, per-sound overrides. + final soundPresetFor = signal>(const {}); /// Video shaders: one choice, used on join and changed both here and /// in the session (Ctrl+0..6, the shader button). @@ -98,6 +105,15 @@ class AppSettings { if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); final off = box.get(_keySoundsOff); if (off is List) soundsOff.value = Set.unmodifiable(off.whereType()); + final soundSet = box.get(_keySoundPreset); + if (soundSet is String) soundPreset.value = soundSet; + final per = box.get(_keySoundPresetFor); + if (per is Map) { + soundPresetFor.value = Map.unmodifiable({ + for (final e in per.entries) + if (e.key is String && e.value is String) e.key as String: e.value as String, + }); + } final preset = box.get(_keyShaderPreset); shaderPreset.value = ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? @@ -160,6 +176,19 @@ class AppSettings { _put(_keySoundsOff, soundsOff.value.toList()); } + void setSoundPreset(String preset) { + soundPreset.value = preset; + _put(_keySoundPreset, preset); + } + + /// [preset] null: the sound follows [soundPreset] again. + void setSoundPresetFor(String kind, String? preset) { + final m = {...soundPresetFor.value}; + preset == null ? m.remove(kind) : m[kind] = preset; + soundPresetFor.value = Map.unmodifiable(m); + _put(_keySoundPresetFor, m); + } + void setHideReReleases(bool on) { hideReReleases.value = on; _put(_keyHideReReleases, on); diff --git a/apps/desktop/test/sfx_test.dart b/apps/desktop/test/sfx_test.dart index 3fd60c4..d52a645 100644 --- a/apps/desktop/test/sfx_test.dart +++ b/apps/desktop/test/sfx_test.dart @@ -26,20 +26,26 @@ double _pitch(List x, int from) { void main() { test('every sound is described, quiet, short and clean', () { - for (final k in SfxKind.values) { - expect(soundInfo[k], isNotNull, reason: k.name); - final r = render(soundFor(k)); - final peak = r.fold(0, (m, v) => math.max(m, v.abs())); - expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak'); - expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length'); - expect(r.every((v) => v.isFinite), isTrue, reason: k.name); - expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks'); + for (final p in SoundPreset.values) { + expect(presetInfo[p], isNotNull, reason: p.name); + for (final k in SfxKind.values) { + final name = '${p.name}/${k.name}'; + expect(soundInfo[k], isNotNull, reason: name); + final r = render(soundFor(k, preset: p)); + final peak = r.fold(0, (m, v) => math.max(m, v.abs())); + expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '$name peak'); + expect(r.length / sampleRate, lessThan(3), reason: '$name length'); + expect(r.every((v) => v.isFinite), isTrue, reason: name); + expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '$name clicks'); + } } }); test('the ready ladder climbs and stays in range', () { - expect(render(readySound(0)), render(readySound(1))); - expect(render(readySound(99)), render(readySound(6))); + for (final p in SoundPreset.values) { + expect(render(readySound(0, preset: p)), render(readySound(1, preset: p))); + expect(render(readySound(99, preset: p)), render(readySound(6, preset: p))); + } }); test('koto strings are in tune', () { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 670e187..690d2ac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -714,15 +714,19 @@ always scrolls the page). Space never presses a focused button unready, force-ready, join, leave, kick, swap and others' chat (feed events), the idle warning, and connection lost / back (once per drop). Nothing for what the video already says (play, pause, seeks, the end). - The set is synthesized, not sampled: `synth.dart` renders Japanese-house - voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo, - hyoshigi clappers, a shoji slide of filtered noise) through a low-pass - and a short room to quiet WAVs; `sound_bank.dart` voices each event in - the Yo scale on D. `SynthSfx` renders a sound on first use into the temp - dir and plays it on a pool of three audio-only mpv players, at + The sounds are synthesized, not sampled: `synth.dart` renders voices + (plucked strings with bends, glass and star bells, a singing bowl, + hollow knocks, wooden clacks, filtered-noise air and stardust, a long + dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event + in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one + 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 + plays it on a pool of three audio-only mpv players, at `AppSettings.soundVolume` (square curve) unless switched off in - `soundsOff`. Settings → Notifications & sounds has the volume and a - preview and switch per sound. + `soundsOff`. Settings → Notifications & sounds has the volume, the + overall sound set (`soundPreset`), and per sound a preview, its own set + (`soundPresetFor`, else the overall one) and a switch; a preview always + re-renders, so an edited spec plays after a hot reload. - **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