Sounds: Japanese-house sound set in the app, layout demo removed

Eleven sounds for events that carry news (ready, unready, everyone ready,
join, leave, kick, new video, others' chat, idle warning, connection
lost/back); none for what the video already says. New voices: koto
(Karplus-Strong, bendable), furin chime, rin bowl, bamboo, hyoshigi,
shoji slide, in the Yo scale on D. Played by SynthSfx at the Settings
volume with per-sound switches and previews. The debug layout mock is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 06:23:22 +03:00
1 parent d8fd464e33
commit fce392e425
19 files changed
+546 -6306

No files matched your search

+21 -44
View File
@@ -1,20 +1,22 @@
/// The app's sounds. Only events that carry news get one: no sound for
/// what the video itself makes obvious (start, pause, end, seeks).
enum SfxKind {
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
ready,
unready,
/// Someone marks everyone ready.
forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join,
leave,
stale,
kick,
/// A new video or episode is put up.
swap,
seek,
pause,
resume,
chat,
/// Idle warning: auto-unready soon.
warn,
/// The connection to the server dropped / came back.
lost,
back,
}
class SfxCue {
@@ -23,42 +25,17 @@ class SfxCue {
const SfxCue(this.kind, {this.readyIndex = 0});
}
SfxKind? sfxForFeedAction(String action) {
switch (action) {
case 'ready':
return SfxKind.ready;
case 'unready':
return SfxKind.unready;
case 'force-ready':
return SfxKind.forceReady;
case 'force-unready':
return SfxKind.forceUnready;
case 'force-play':
return SfxKind.forcePlay;
case 'force-hold':
return SfxKind.forceHold;
case 'kick':
return SfxKind.kick;
case 'join':
return SfxKind.join;
case 'leave':
return SfxKind.leave;
case 'stale':
return SfxKind.stale;
case 'swap':
return SfxKind.swap;
case 'seek':
return SfxKind.seek;
case 'pause':
return SfxKind.pause;
case 'resume':
return SfxKind.resume;
case 'chat':
return SfxKind.chat;
default:
return null;
}
}
SfxKind? sfxForFeedAction(String action) => switch (action) {
'ready' => SfxKind.ready,
'unready' => SfxKind.unready,
'force-ready' => SfxKind.forceReady,
'kick' => SfxKind.kick,
'join' => SfxKind.join,
'leave' => SfxKind.leave,
'swap' => SfxKind.swap,
'chat' => SfxKind.chat,
_ => null,
};
abstract class SfxService {
Future<void> play(SfxCue cue);
+73 -105
View File
@@ -1,130 +1,98 @@
import 'sfx.dart';
import 'synth.dart';
// The app's sound set, all in D major pentatonic so any two that overlap
// still agree. Rising = someone arrives / gets ready, falling = leaves /
// unready; bells for people, soft sines for the player, plucks for ticks.
// Everything is quiet (-16 dBFS peaks or lower) and short.
// 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.
// Pitches, in semitones from A4.
const _d4 = -7, _e4 = -5, _a4 = 0;
const _d5 = 5, _e5 = 7, _fs5 = 9, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _fs6 = 21, _a6 = 24;
const _d4 = -7, _a4 = 0, _b4 = 2;
const _d5 = 5, _e5 = 7, _g5 = 10, _a5 = 12, _b5 = 14;
const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26;
double _p(int s) => semi(s);
/// The ready chime's second note climbs with how many are ready (1st, 2nd…).
const _readyLadder = [_fs5, _a5, _b5, _d6, _e6, _fs6];
/// The ready pluck's answer climbs with each person ready.
const _readyLadder = [_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.35, gain: 0.8),
Note(0.075, _p(top), 0.55),
]);
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 soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
SfxKind.ready => readySound(readyIndex),
// The same strings, muted and stepping down.
SfxKind.unready => SoundSpec([
Note(0, _p(_a5), 0.3, gain: 0.8),
Note(0.08, _p(_e5), 0.45, gain: 0.7),
], cutoff: 4200, peakDb: -19),
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),
// A strum up the open strings, a chime on top.
SfxKind.forceReady => SoundSpec([
Note(0, _p(_d5), 0.35, gain: 0.7),
Note(0.06, _p(_fs5), 0.35, gain: 0.8),
Note(0.12, _p(_a5), 0.6),
]),
SfxKind.forceUnready => SoundSpec([
Note(0, _p(_a5), 0.3, gain: 0.8),
Note(0.06, _p(_fs5), 0.3, gain: 0.7),
Note(0.12, _p(_d5), 0.5, gain: 0.7),
], cutoff: 4200, peakDb: -19),
SfxKind.forcePlay => SoundSpec([
Note(0, _p(_d5), 0.7, gain: 0.6, voice: Voice.soft),
Note(0, _p(_a5), 0.7, gain: 0.5, voice: Voice.soft),
Note(0.12, _p(_d6), 0.8),
]),
SfxKind.forceHold => SoundSpec([
Note(0, _p(_a4), 0.35, voice: Voice.soft),
Note(0.1, _p(_d4), 0.55, voice: Voice.soft),
], cutoff: 3000, peakDb: -18),
SfxKind.kick => SoundSpec([
Note(0, _p(_e4), 0.4, voice: Voice.soft, to: _p(_d4 - 2)),
Note(0.14, _p(_d4 - 5), 0.6, voice: Voice.soft, gain: 0.8),
], cutoff: 1800, peakDb: -19),
for (final (i, s) in [_d5, _e5, _g5, _a5, _b5, _d6].indexed)
Note(i * 0.032, _p(s), 1.2, gain: 0.55 + i * 0.05, bright: 0.55),
Note(0.22, _p(_d6) * 2, 1.4, voice: Voice.chime, gain: 0.18),
], peakDb: -17),
// Wind through the furin: three uneven tinkles.
SfxKind.join => SoundSpec([
Note(0, _p(_d5), 0.3, gain: 0.7),
Note(0.07, _p(_e5), 0.3, gain: 0.75),
Note(0.14, _p(_a5), 0.6),
]),
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),
// The wind settling: two softer, lower tinkles.
SfxKind.leave => SoundSpec([
Note(0, _p(_a5), 0.28, gain: 0.8),
Note(0.08, _p(_e5), 0.28, gain: 0.7),
Note(0.16, _p(_d5), 0.5, gain: 0.6),
], cutoff: 3800, peakDb: -20),
SfxKind.stale => SoundSpec([
Note(0, _p(_fs5), 0.6, voice: Voice.soft, wobble: 0.25),
], cutoff: 3500, peakDb: -21),
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.
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),
// The door slides open on something new: air, then a fifth.
SfxKind.swap => SoundSpec([
Note(0, _p(_d5), 0.6, voice: Voice.soft, gain: 0.6),
Note(0.05, _p(_a5), 0.3, gain: 0.5),
Note(0.1, _p(_d6), 0.3, gain: 0.5),
Note(0.15, _p(_fs6), 0.5, gain: 0.45),
], cutoff: 6000, peakDb: -18),
SfxKind.seek => const SoundSpec([
Note(0, 987.77, 0.06, voice: Voice.pluck),
], cutoff: 3500, peakDb: -26, room: 0),
SfxKind.pause => SoundSpec([
Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_e4)),
], cutoff: 3000, peakDb: -21, room: 0.08),
SfxKind.resume => SoundSpec([
Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_d5)),
], cutoff: 3500, peakDb: -21, room: 0.08),
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.
SfxKind.chat => SoundSpec([
Note(0, _p(_b5), 0.18, gain: 0.7),
Note(0.055, _p(_e6), 0.3),
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.
SfxKind.warn => SoundSpec([
Note(0, _p(_fs5), 0.25, voice: Voice.soft),
Note(0.22, _p(_fs5), 0.25, voice: Voice.soft, gain: 0.85),
Note(0.44, _p(_d5), 0.45, voice: Voice.soft, gain: 0.8),
], cutoff: 3800, peakDb: -18),
Note(0, _p(_d5), 2.6, voice: Voice.rin),
], cutoff: 4500, peakDb: -20, room: 0.1),
// 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),
// 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),
};
/// Sounds the app has no event for yet (proposals, shown in the demo).
enum ExtraSound { start, finished, lost, back, image, mention }
SoundSpec extraSound(ExtraSound s) => switch (s) {
ExtraSound.start => SoundSpec([
Note(0, _p(_d5), 0.9, voice: Voice.soft, gain: 0.5),
Note(0.04, _p(_fs5), 0.9, voice: Voice.soft, gain: 0.45),
Note(0.08, _p(_a5), 0.9, voice: Voice.soft, gain: 0.45),
Note(0.12, _p(_d6), 0.9, gain: 0.6),
], cutoff: 5000, peakDb: -18, room: 0.2),
ExtraSound.finished => SoundSpec([
Note(0, _p(_a5), 0.5, gain: 0.8),
Note(0.16, _p(_fs5), 0.5, gain: 0.75),
Note(0.32, _p(_d5), 1.1, gain: 0.8),
Note(0.32, _p(_a4), 1.1, voice: Voice.soft, gain: 0.4),
], cutoff: 4500, peakDb: -18, room: 0.22),
ExtraSound.lost => SoundSpec([
Note(0, _p(_e4), 0.35, voice: Voice.soft),
Note(0.18, _p(_d4), 0.6, voice: Voice.soft),
], cutoff: 2500, peakDb: -18),
ExtraSound.back => SoundSpec([
Note(0, _p(_d4 + 12), 0.3, voice: Voice.soft),
Note(0.12, _p(_a4), 0.5, voice: Voice.soft),
Note(0.12, _p(_d5), 0.5, gain: 0.5),
], cutoff: 4000, peakDb: -18),
ExtraSound.image => SoundSpec([
Note(0, _p(_e6), 0.12, voice: Voice.pluck, gain: 0.6),
Note(0.05, _p(_b5), 0.18, gain: 0.7),
Note(0.1, _p(_a6), 0.35, gain: 0.6),
], cutoff: 6000, peakDb: -20, room: 0.1),
ExtraSound.mention => SoundSpec([
Note(0, _p(_d6), 0.25),
Note(0.09, _p(_a5), 0.25, gain: 0.8),
Note(0.18, _p(_d6), 0.45),
], cutoff: 6000, peakDb: -17),
};
/// Shown in Settings → Notifications & sounds: name and when it plays.
const soundInfo = {
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
SfxKind.join: ('Joined', 'Someone joins the server'),
SfxKind.leave: ('Left', 'Someone leaves'),
SfxKind.kick: ('Removed', 'Someone is kicked'),
SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'),
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'),
};
+224 -67
View File
@@ -1,78 +1,93 @@
import 'dart:math' as math;
import 'dart:typed_data';
// A tiny offline synthesizer for UI sounds: notes with soft attacks and
// exponential decays, a low-pass to take the edge off and a short room
// tail. Everything renders to 16-bit mono WAV bytes. Pure Dart, no Flutter.
// 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.
const sampleRate = 44100;
/// Timbre of a note.
enum Voice {
/// Sine with a touch of octave: round, flute-like.
soft,
/// Glass/marimba: a few partials, the upper ones die first.
bell,
/// Short and dry, for ticks.
pluck,
/// Plucked string; [Note.to] bends it (the delay line stretches).
koto,
/// Small glass bell: inharmonic partials, a slow shimmer.
chime,
/// Singing bowl: a soft strike and a long beating hum.
rin,
/// Hollow bamboo knock, pitched.
bamboo,
/// Two hardwood blocks struck together.
clack,
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
/// sweep of its band.
slide,
}
class Note {
/// Start (s) and pitch (Hz); [to] glides there over the note.
final double at;
final double hz;
final double? to;
/// Length (s) of the decay to about -40 dB.
/// Length (s) to about -40 dB.
final double len;
final double gain;
final Voice voice;
/// Vibrato depth in semitones (0 = none), ~5.5 Hz.
final double wobble;
/// Koto only: 0 = dark/muted pluck, 1 = bright.
final double bright;
const Note(this.at, this.hz, this.len,
{this.to, this.gain = 1, this.voice = Voice.bell, this.wobble = 0});
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
}
class SoundSpec {
final List<Note> notes;
/// One-pole low-pass cutoff (Hz): lower = softer, darker.
/// Gentle low-pass cutoff (Hz) over the mix.
final double cutoff;
/// Peak level after normalising, in dBFS (quiet by design).
/// Peak level after normalising, dBFS (quiet by design).
final double peakDb;
/// Room tail mix (0..1).
/// Room tail mix (0..1): the veranda, not a hall.
final double room;
const SoundSpec(this.notes, {this.cutoff = 5200, this.peakDb = -16, this.room = 0.16});
const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14});
double get length =>
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02);
}
/// Note frequency by name-free index: semitones from A4 (440 Hz).
/// Semitones from A4 (440 Hz) to Hz.
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
Float64List render(SoundSpec spec) {
Float64List render(SoundSpec spec, {int seed = 7}) {
final total = (spec.length * sampleRate).ceil();
final out = Float64List(total);
final rnd = math.Random(seed);
for (final n in spec.notes) {
_addNote(out, n);
}
// Low-pass: one pole, run twice for a gentler 12 dB/oct slope.
final a = math.exp(-2 * math.pi * spec.cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < total; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
final start = (n.at * sampleRate).round();
if (start >= total) continue;
final s = switch (n.voice) {
Voice.koto => _koto(n, rnd),
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
Voice.bamboo => _bamboo(n, rnd),
Voice.clack => _clack(n, rnd),
Voice.slide => _slide(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);
// Fade the last 10 ms so nothing clicks, then normalise to the peak.
// 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++) {
out[i] *= i / fadeIn;
}
final fade = (0.01 * sampleRate).round();
for (var i = 0; i < fade && i < total; i++) {
out[total - 1 - i] *= i / fade;
}
var peak = 0.0;
for (final s in out) {
peak = math.max(peak, s.abs());
for (final v in out) {
peak = math.max(peak, v.abs());
}
if (peak > 0) {
final g = math.pow(10, spec.peakDb / 20) / peak;
@@ -83,51 +98,193 @@ Float64List render(SoundSpec spec) {
return out;
}
void _addNote(Float64List out, Note n) {
final start = (n.at * sampleRate).round();
final len = (n.len * sampleRate).round();
final attack = switch (n.voice) {
Voice.soft => 0.012,
Voice.bell => 0.004,
Voice.pluck => 0.002,
} * sampleRate;
// exp(-k * len) = 0.01 (-40 dB).
final k = math.log(100) / len;
final partials = switch (n.voice) {
Voice.soft => const [(1.0, 1.0, 1.0), (2.0, 0.12, 1.6)],
Voice.bell => const [(1.0, 1.0, 1.0), (2.0, 0.28, 1.8), (3.01, 0.08, 2.6), (4.2, 0.05, 3.5)],
Voice.pluck => const [(1.0, 1.0, 1.0), (2.0, 0.35, 2.0), (3.0, 0.15, 3.0)],
};
final phases = List<double>.filled(partials.length, 0);
for (var i = 0; i < len && start + i < out.length; i++) {
int _n(double secs) => (secs * sampleRate).round();
double _smooth(double t) => t * t * (3 - 2 * t);
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
/// loop that loses a little of its highs each pass.
Float64List _koto(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
final buf = Float64List(maxPeriod.ceil() + 4);
final size = buf.length;
// Excitation: noise, smoothed more for a darker pluck.
final period0 = sampleRate / n.hz;
var prev = 0.0;
final smooth = 1 - (0.25 + 0.7 * n.bright);
for (var i = 0; i < period0.ceil(); i++) {
final x = rnd.nextDouble() * 2 - 1;
prev = smooth * prev + (1 - smooth) * x;
buf[i % size] = prev;
}
// Loop gain so the note reaches -40 dB at len.
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
var w = period0.ceil();
var last = 0.0;
for (var i = 0; i < len; i++) {
final t = i / len;
var hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
if (n.wobble > 0) {
hz *= math.pow(2, n.wobble / 12 * math.sin(2 * math.pi * 5.5 * i / sampleRate));
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
// The two-point average below delays half a sample: take it off.
final period = sampleRate / hz - 0.5;
final r = w - period;
final ri = r.floor();
final f = r - ri;
final a = buf[(ri % size + size) % size];
final b = buf[((ri + 1) % size + size) % size];
final y = a + (b - a) * f;
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
final v = loss * (0.5 * y + 0.5 * last);
last = y;
buf[w % size] = v;
w++;
// A 3 ms attack takes the click off the pluck.
out[i] = v * (i < 132 ? i / 132 : 1);
}
return out;
}
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
/// with a slightly detuned twin so the tail shimmers.
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
final attack = _n(attackSecs);
for (final (ratio, amp, decay) in partials) {
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
for (var i = 0; i < len; i++) {
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
final ph = 2 * math.pi * i / sampleRate;
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
}
final env = (i < attack ? _smooth(i / attack) : 1.0) * math.exp(-k * i);
var s = 0.0;
for (var p = 0; p < partials.length; p++) {
final (mult, amp, decay) = partials[p];
phases[p] += 2 * math.pi * hz * mult / sampleRate;
// Upper partials fade faster: the attack has shimmer, the tail is round.
s += amp * math.sin(phases[p]) * math.exp(-k * (decay - 1) * i);
}
return out;
}
/// A hollow tube: a pitched thump that drops into its note, an overtone
/// that dies fast, and a short click of air.
Float64List _bamboo(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final k = math.log(100) / len;
var ph = 0.0, ph2 = 0.0;
for (var i = 0; i < len; i++) {
final drop = math.exp(-i / _n(0.012));
final hz = n.hz * (1 + 0.5 * drop);
ph += 2 * math.pi * hz / sampleRate;
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
}
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
for (var i = 0; i < click.length && i < len; i++) {
out[i] += 0.5 * click[i] * (1 - i / click.length);
}
return out;
}
/// Hardwood: two resonances excited by a very short noise burst.
Float64List _clack(Note n, math.Random rnd) {
final len = _n(n.len);
final out = Float64List(len);
final burst = Float64List(len);
for (var i = 0; i < _n(0.002) && i < len; i++) {
burst[i] = rnd.nextDouble() * 2 - 1;
}
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
final r = _biquadBand(burst, f, q);
for (var i = 0; i < len; i++) {
out[i] += g * r[i];
}
}
final k = math.log(100) / len;
for (var i = 0; i < len; i++) {
out[i] *= math.exp(-k * i);
}
return out;
}
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
/// and out (a paper door sliding along its track).
Float64List _slide(Note n, math.Random rnd) {
final len = _n(n.len);
final noise = Float64List(len);
for (var i = 0; i < len; i++) {
noise[i] = rnd.nextDouble() * 2 - 1;
}
final out = Float64List(len);
// Sweep in short blocks (a fixed band per block is plenty).
const block = 256;
var z1 = 0.0, z2 = 0.0;
for (var b0 = 0; b0 < len; b0 += block) {
final t = b0 / len;
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
final c = _bandCoeffs(f.toDouble(), 2.5);
for (var i = b0; i < math.min(len, b0 + block); i++) {
final y = c.$1 * noise[i] + z1;
z1 = c.$2 * noise[i] - c.$4 * y + z2;
z2 = c.$3 * noise[i] - c.$5 * y;
final tt = i / len;
// Rises, holds, eases off: sin^2 swell with a tail.
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
// The track's grain: a slow flutter.
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
out[i] = y * env * grain;
}
}
return out;
}
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
final x = Float64List(len);
for (var i = 0; i < len; i++) {
x[i] = rnd.nextDouble() * 2 - 1;
}
return _biquadBand(x, f, q);
}
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
(double, double, double, double, double) _bandCoeffs(double f, double q) {
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
final alpha = math.sin(w) / (2 * q);
final a0 = 1 + alpha;
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
}
Float64List _biquadBand(Float64List x, double f, double q) {
final c = _bandCoeffs(f, q);
final y = Float64List(x.length);
var z1 = 0.0, z2 = 0.0;
for (var i = 0; i < x.length; i++) {
final v = c.$1 * x[i] + z1;
z1 = c.$2 * x[i] - c.$4 * v + z2;
z2 = c.$3 * x[i] - c.$5 * v;
y[i] = v;
}
return y;
}
/// One pole, twice: a soft 12 dB/oct roll-off.
void _lowpass(Float64List out, double cutoff) {
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
for (var pass = 0; pass < 2; pass++) {
var y = 0.0;
for (var i = 0; i < out.length; i++) {
y = (1 - a) * out[i] + a * y;
out[i] = y;
}
out[start + i] += n.gain * env * s;
}
}
double _smooth(double t) => t * t * (3 - 2 * t);
/// A few feedback delays (a small room): a soft tail instead of a dry stop.
/// A few feedback delays: wood and paper walls, a short tail.
void _room(Float64List out, double mix) {
const taps = [0.031, 0.047, 0.067];
const taps = [0.029, 0.041, 0.059];
final dry = Float64List.fromList(out);
for (final d in taps) {
final delay = (d * sampleRate).round();
final delay = _n(d);
final buf = Float64List(out.length);
for (var i = delay; i < out.length; i++) {
buf[i] = dry[i - delay] + 0.45 * buf[i - delay];
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];
+17 -10
View File
@@ -11,10 +11,12 @@ import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
/// played on a small pool of audio-only mpv players (so two sounds can
/// overlap). [volume] is 0..1, applied on a square curve (ears are log).
/// overlap). [volume] (0..1) is read per play and applied on a square
/// curve (ears are log); [enabled] filters cues.
class SynthSfx implements SfxService {
double volume;
SynthSfx({this.volume = 0.6});
final double Function() volume;
final bool Function(SfxKind kind) enabled;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true);
static const _poolSize = 3;
final List<Player> _pool = [];
@@ -23,18 +25,26 @@ class SynthSfx implements SfxService {
Directory? _dir;
@override
Future<void> play(SfxCue cue) =>
playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
Future<void> play(SfxCue cue) async {
if (!enabled(cue.kind)) return;
await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
}
/// 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 [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
if (volume <= 0) return;
final v = volume();
if (v <= 0) return;
try {
final path = await (_files[key] ??= _write(key, spec));
final player = await _player();
await player.setVolume(100 * volume * volume);
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
}
@@ -58,9 +68,6 @@ class SynthSfx implements SfxService {
return _pool[_next++ % _poolSize];
}
/// Drops the cached renders (after editing a spec with hot reload).
void forget() => _files.clear();
Future<void> dispose() async {
for (final p in _pool) {
await p.dispose();