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
+497
-74
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:
|
||||
"connected as <name>" 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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<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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Player> _pool = [];
|
||||
@@ -27,12 +31,19 @@ class SynthSfx implements SfxService {
|
||||
@override
|
||||
Future<void> 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<void> 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<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).
|
||||
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 {
|
||||
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<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),
|
||||
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<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(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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
|
||||
/// 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<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);
|
||||
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);
|
||||
|
||||
@@ -26,20 +26,26 @@ double _pitch(List<double> x, int from) {
|
||||
|
||||
void main() {
|
||||
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) {
|
||||
expect(soundInfo[k], isNotNull, reason: k.name);
|
||||
final r = render(soundFor(k));
|
||||
final name = '${p.name}/${k.name}';
|
||||
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()));
|
||||
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');
|
||||
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', () {
|
||||
|
||||
+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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user