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:
1 parent
b038706dff
commit
a4be24a2a5
10 files changed
+499
-76
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 k in SfxKind.values) {
|
for (final p in SoundPreset.values) {
|
||||||
expect(soundInfo[k], isNotNull, reason: k.name);
|
expect(presetInfo[p], isNotNull, reason: p.name);
|
||||||
final r = render(soundFor(k));
|
for (final k in SfxKind.values) {
|
||||||
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
|
final name = '${p.name}/${k.name}';
|
||||||
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak');
|
expect(soundInfo[k], isNotNull, reason: name);
|
||||||
expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length');
|
final r = render(soundFor(k, preset: p));
|
||||||
expect(r.every((v) => v.isFinite), isTrue, reason: k.name);
|
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
|
||||||
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks');
|
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', () {
|
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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user