Sounds: three presets (Cosmic, Abyss, Wood & glass), chosen overall or per sound; nudge sound

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 20:21:43 +03:00
1 parent b038706dff
commit a4be24a2a5
10 files changed
+497 -74

No files matched your search

+1 -1
View File
@@ -164,7 +164,7 @@ password, recent servers with remove); Player (below); Jellyfin &
integrations (linked Jellyfin servers, below, the AnimeFlow connection: integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound 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). (the version).
Player settings live in the player: ⚙ at the right end of its controls Player settings live in the player: ⚙ at the right end of its controls
+2
View File
@@ -17,6 +17,8 @@ enum SfxKind {
/// The connection to the server dropped / came back. /// The connection to the server dropped / came back.
lost, lost,
back, back,
/// Someone pokes you for attention; plays only on your end.
nudge,
} }
class SfxCue { class SfxCue {
+204 -32
View File
@@ -1,83 +1,254 @@
import 'sfx.dart'; import 'sfx.dart';
import 'synth.dart'; import 'synth.dart';
// The sound of the house: koto for readiness, the wind chime for people // The app's sounds come in presets; each action can take its sound from any
// arriving and leaving, the shoji door for a new video, bamboo for chat, // of them. Every preset is quiet, short and on D, so sounds from different
// clappers for a kick, the singing bowl for the idle warning, a string // presets still agree when they overlap.
// going slack and re-tuned for the connection. All in the Yo scale on D // - Cosmic: star bells, stardust, bubbles, soft pads, a space tail.
// (D E G A B), so overlapping sounds agree; quiet and short. // - 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; enum SoundPreset { cosmic, abyss, woodAndGlass }
const _d5 = 5, _g5 = 10, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26; /// 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); double _p(int s) => semi(s);
/// The ready pluck's answer climbs with each person ready. /// The ready sound's answer climbs with each person ready.
const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6]; 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) { SoundSpec readySound(int readyIndex, {SoundPreset preset = defaultPreset}) =>
final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)]; soundFor(SfxKind.ready, readyIndex: readyIndex, preset: preset);
return SoundSpec([
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55), SoundSpec soundFor(SfxKind kind, {int readyIndex = 1, SoundPreset preset = defaultPreset}) {
Note(0.085, _p(top), 1.1, bright: 0.65), final i = (readyIndex - 1).clamp(0, 5);
], peakDb: -17); 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) { SoundSpec _cosmic(SfxKind kind, int i) => switch (kind) {
SfxKind.ready => readySound(readyIndex), 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. // The same strings, muted and stepping down.
SfxKind.unready => SoundSpec([ SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2), Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
Note(0.09, _p(_g5), 0.45, bright: 0.15), 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. // Just the bell: playback starting is the rest of the cue.
SfxKind.forceReady => SoundSpec([ SfxKind.forceReady => SoundSpec([
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime), Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
], cutoff: 7000, peakDb: -18, room: 0.18), ], cutoff: 7000, peakDb: -18, room: 0.18, tail: 0.5),
// Wind through the furin: three uneven tinkles. // Wind through glass: three uneven tinkles.
SfxKind.join => SoundSpec([ SfxKind.join => SoundSpec([
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8), 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.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), 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. // The wind settling: two softer, lower tinkles.
SfxKind.leave => SoundSpec([ SfxKind.leave => SoundSpec([
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6), 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), Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
], cutoff: 5000, peakDb: -22, room: 0.18), ], cutoff: 5000, peakDb: -22, room: 0.18, tail: 0.5),
// Hyoshigi: clack… clack, then a low muted string. // Two hardwood clacks, then a low muted string.
SfxKind.kick => SoundSpec([ SfxKind.kick => SoundSpec([
Note(0, 1900, 0.09, voice: Voice.clack), Note(0, 1900, 0.09, voice: Voice.clack),
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9), 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), 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. // The door slides open on something new: air, then a fifth.
SfxKind.swap => SoundSpec([ SfxKind.swap => SoundSpec([
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5), 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.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7), Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
], peakDb: -18), ], peakDb: -18, tail: 0.5),
// Bamboo: a hollow tok-tok, the second higher. // A hollow wooden tok-tok, the second higher.
SfxKind.chat => SoundSpec([ SfxKind.chat => SoundSpec([
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85), Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo), Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
], cutoff: 5500, peakDb: -19, room: 0.1), ], cutoff: 5500, peakDb: -19, room: 0.1, tail: 0.5),
// The singing bowl: one soft strike that hums. // A bowl: one soft strike that hums.
SfxKind.warn => SoundSpec([ SfxKind.warn => SoundSpec([
Note(0, _p(_d5), 2.6, voice: Voice.rin), 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. // A string going slack: plucked, its pitch sagging away.
SfxKind.lost => SoundSpec([ SfxKind.lost => SoundSpec([
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4), 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), 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. // Re-tuned: a quick bend up that lands, and a tinkle.
SfxKind.back => SoundSpec([ SfxKind.back => SoundSpec([
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5), 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.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), 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. /// 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.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'), SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'), SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
SfxKind.nudge: ('Nudge', 'Someone nudges you to get your attention'),
}; };
+170 -19
View File
@@ -1,10 +1,11 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds, voiced as a Japanese house: // A tiny offline synthesizer for UI sounds. The current palette is
// koto strings (Karplus-Strong), a glass wind chime (furin), a singing // spacious: FM star bells, bubble drops, slow pads, a sub that falls away,
// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a // stardust and a long dark space tail. Older voices (plucked string, glass
// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart. // and bowl bells, wood) stay available. Renders to 16-bit mono WAV bytes.
// Pure Dart.
const sampleRate = 44100; const sampleRate = 44100;
@@ -22,6 +23,19 @@ enum Voice {
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the /// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
/// sweep of its band. /// sweep of its band.
slide, 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 { class Note {
@@ -44,12 +58,15 @@ class SoundSpec {
final double cutoff; final double cutoff;
/// Peak level after normalising, dBFS (quiet by design). /// Peak level after normalising, dBFS (quiet by design).
final double peakDb; final double peakDb;
/// Room tail mix (0..1): the veranda, not a hall. /// Space tail mix (0..1).
final double room; 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 => double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02); notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? tail * 0.55 : 0.02);
} }
/// Semitones from A4 (440 Hz) to Hz. /// Semitones from A4 (440 Hz) to Hz.
@@ -69,13 +86,18 @@ Float64List render(SoundSpec spec, {int seed = 7}) {
Voice.bamboo => _bamboo(n, rnd), Voice.bamboo => _bamboo(n, rnd),
Voice.clack => _clack(n, rnd), Voice.clack => _clack(n, rnd),
Voice.slide => _slide(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++) { for (var i = 0; i < s.length && start + i < total; i++) {
out[start + i] += n.gain * s[i]; out[start + i] += n.gain * s[i];
} }
} }
_lowpass(out, spec.cutoff); _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. // 1 ms in, 10 ms out: no clicks at either end.
final fadeIn = (0.001 * sampleRate).round(); final fadeIn = (0.001 * sampleRate).round();
for (var i = 0; i < fadeIn && i < total; i++) { 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. /// Space: four damped feedback combs and two allpasses (a dark, smooth
void _room(Float64List out, double mix) { /// tail decaying to -60 dB in [tail] s) after a short pre-delay.
const taps = [0.029, 0.041, 0.059]; void _space(Float64List out, double mix, double tail) {
final dry = Float64List.fromList(out); const combs = [0.0297, 0.0371, 0.0411, 0.0437];
for (final d in taps) { 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 delay = _n(d);
final buf = Float64List(out.length); final g = math.pow(0.001, d / tail).toDouble();
for (var i = delay; i < out.length; i++) { final y = Float64List(len);
buf[i] = dry[i - delay] + 0.42 * buf[i - delay]; var lp = 0.0;
} for (var i = 0; i < len; i++) {
for (var i = 0; i < out.length; i++) { final back = i >= delay ? y[i - delay] : 0.0;
out[i] += mix / taps.length * buf[i]; 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. /// 16-bit PCM mono WAV.
+16 -5
View File
@@ -16,7 +16,11 @@ import 'synth.dart';
class SynthSfx implements SfxService { class SynthSfx implements SfxService {
final double Function() volume; final double Function() volume;
final bool Function(SfxKind kind) enabled; final bool Function(SfxKind kind) enabled;
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; static const _poolSize = 3;
final List<Player> _pool = []; final List<Player> _pool = [];
@@ -27,12 +31,19 @@ class SynthSfx implements SfxService {
@override @override
Future<void> play(SfxCue cue) async { Future<void> play(SfxCue cue) async {
if (!enabled(cue.kind)) return; 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). /// Plays [kind] even when it is switched off (Settings' preview), always
Future<void> preview(SfxKind kind, {int readyIndex = 1}) => /// freshly rendered: an edited spec plays after a hot reload.
playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex)); Future<void> 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). /// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async { Future<void> playSpec(String key, SoundSpec spec) async {
+39 -1
View File
@@ -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 { class SoundSettings extends StatelessWidget {
const SoundSettings({super.key}); const SoundSettings({super.key});
@@ -540,6 +541,8 @@ class SoundSettings extends StatelessWidget {
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final volume = s.soundVolume.value; final volume = s.soundVolume.value;
final off = s.soundsOff.value; final off = s.soundsOff.value;
final overall = s.soundPreset.value;
final per = s.soundPresetFor.value;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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. ' 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), 'Letting go of the slider plays the ready sound.', style: muted),
const SizedBox(height: 12),
Row(
children: [
const Expanded(child: Text('Sound set')),
DropdownButton<String>(
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), const SizedBox(height: 8),
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries) for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
Padding( Padding(
@@ -579,6 +602,21 @@ class SoundSettings extends StatelessWidget {
children: [Text(name), Text(help, style: muted)], children: [Text(name), Text(help, style: muted)],
), ),
), ),
DropdownButton<String?>(
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<String?>(value: null, child: Text('Same as set')),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
],
),
Switch( Switch(
key: ValueKey('sound-on-${kind.name}'), key: ValueKey('sound-on-${kind.name}'),
value: !off.contains(kind.name), value: !off.contains(kind.name),
@@ -10,6 +10,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
@@ -159,6 +160,7 @@ class SessionState {
final SfxService sfx = SynthSfx( final SfxService sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => AppSettings.instance.soundVolume.peek(),
enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name), enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor,
); );
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm(); final lastForm = ConnectForm();
@@ -399,3 +401,11 @@ class SessionState {
route.value = AppRoute.home; 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;
}
+29
View File
@@ -22,6 +22,8 @@ const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine'; const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume'; const _keySoundVolume = 'soundVolume';
const _keySoundsOff = 'soundsOff'; const _keySoundsOff = 'soundsOff';
const _keySoundPreset = 'soundPreset';
const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyUnreadyOnSwap = 'unreadyOnSwap';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -50,6 +52,11 @@ class AppSettings {
final soundVolume = signal(1.0); final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one. /// `SfxKind` names switched off one by one.
final soundsOff = signal<Set<String>>(const {}); final soundsOff = signal<Set<String>>(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<Map<String, String>>(const {});
/// Video shaders: one choice, used on join and changed both here and /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// 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); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff); final off = box.get(_keySoundsOff);
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>()); if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
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); final preset = box.get(_keyShaderPreset);
shaderPreset.value = shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -160,6 +176,19 @@ class AppSettings {
_put(_keySoundsOff, soundsOff.value.toList()); _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) { void setHideReReleases(bool on) {
hideReReleases.value = on; hideReReleases.value = on;
_put(_keyHideReReleases, on); _put(_keyHideReReleases, on);
+14 -8
View File
@@ -26,20 +26,26 @@ double _pitch(List<double> x, int from) {
void main() { void main() {
test('every sound is described, quiet, short and clean', () { test('every sound is described, quiet, short and clean', () {
for (final p in SoundPreset.values) {
expect(presetInfo[p], isNotNull, reason: p.name);
for (final k in SfxKind.values) { for (final k in SfxKind.values) {
expect(soundInfo[k], isNotNull, reason: k.name); final name = '${p.name}/${k.name}';
final r = render(soundFor(k)); expect(soundInfo[k], isNotNull, reason: name);
final r = render(soundFor(k, preset: p));
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs())); final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak'); expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '$name peak');
expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length'); expect(r.length / sampleRate, lessThan(3), reason: '$name length');
expect(r.every((v) => v.isFinite), isTrue, reason: k.name); expect(r.every((v) => v.isFinite), isTrue, reason: name);
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks'); expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '$name clicks');
}
} }
}); });
test('the ready ladder climbs and stays in range', () { test('the ready ladder climbs and stays in range', () {
expect(render(readySound(0)), render(readySound(1))); for (final p in SoundPreset.values) {
expect(render(readySound(99)), render(readySound(6))); 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', () { test('koto strings are in tune', () {
+12 -8
View File
@@ -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 unready, force-ready, join, leave, kick, swap and others' chat (feed
events), the idle warning, and connection lost / back (once per drop). events), the idle warning, and connection lost / back (once per drop).
Nothing for what the video already says (play, pause, seeks, the end). Nothing for what the video already says (play, pause, seeks, the end).
The set is synthesized, not sampled: `synth.dart` renders Japanese-house The sounds are synthesized, not sampled: `synth.dart` renders voices
voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo, (plucked strings with bends, glass and star bells, a singing bowl,
hyoshigi clappers, a shoji slide of filtered noise) through a low-pass hollow knocks, wooden clacks, filtered-noise air and stardust, a long
and a short room to quiet WAVs; `sound_bank.dart` voices each event in dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event
the Yo scale on D. `SynthSfx` renders a sound on first use into the temp in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one
dir and plays it on a pool of three audio-only mpv players, at 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 `AppSettings.soundVolume` (square curve) unless switched off in
`soundsOff`. Settings → Notifications & sounds has the volume and a `soundsOff`. Settings → Notifications & sounds has the volume, the
preview and switch per sound. 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 - **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