Sounds: synthesized sound set and a debug sound board

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 05:45:14 +03:00
1 parent 0c38528520
commit d8fd464e33
8 files changed
+648 -13

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
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.
// 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;
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];
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),
]);
}
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
SfxKind.ready => readySound(readyIndex),
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),
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),
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),
]),
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),
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),
SfxKind.chat => SoundSpec([
Note(0, _p(_b5), 0.18, gain: 0.7),
Note(0.055, _p(_e6), 0.3),
], cutoff: 5500, peakDb: -19, room: 0.1),
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),
};
/// 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),
};
+165
View File
@@ -0,0 +1,165 @@
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.
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,
}
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.
final double len;
final double gain;
final Voice voice;
/// Vibrato depth in semitones (0 = none), ~5.5 Hz.
final double wobble;
const Note(this.at, this.hz, this.len,
{this.to, this.gain = 1, this.voice = Voice.bell, this.wobble = 0});
}
class SoundSpec {
final List<Note> notes;
/// One-pole low-pass cutoff (Hz): lower = softer, darker.
final double cutoff;
/// Peak level after normalising, in dBFS (quiet by design).
final double peakDb;
/// Room tail mix (0..1).
final double room;
const SoundSpec(this.notes, {this.cutoff = 5200, this.peakDb = -16, this.room = 0.16});
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).
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
Float64List render(SoundSpec spec) {
final total = (spec.length * sampleRate).ceil();
final out = Float64List(total);
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;
}
}
if (spec.room > 0) _room(out, spec.room);
// Fade the last 10 ms so nothing clicks, then normalise to the peak.
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());
}
if (peak > 0) {
final g = math.pow(10, spec.peakDb / 20) / peak;
for (var i = 0; i < total; i++) {
out[i] *= g;
}
}
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++) {
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 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);
}
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.
void _room(Float64List out, double mix) {
const taps = [0.031, 0.047, 0.067];
final dry = Float64List.fromList(out);
for (final d in taps) {
final delay = (d * sampleRate).round();
final buf = Float64List(out.length);
for (var i = delay; i < out.length; i++) {
buf[i] = dry[i - delay] + 0.45 * buf[i - delay];
}
for (var i = 0; i < out.length; i++) {
out[i] += mix / taps.length * buf[i];
}
}
}
/// 16-bit PCM mono WAV.
Uint8List wav(Float64List samples) {
final data = samples.length * 2;
final b = ByteData(44 + data);
void str(int o, String s) {
for (var i = 0; i < s.length; i++) {
b.setUint8(o + i, s.codeUnitAt(i));
}
}
str(0, 'RIFF');
b.setUint32(4, 36 + data, Endian.little);
str(8, 'WAVE');
str(12, 'fmt ');
b.setUint32(16, 16, Endian.little);
b.setUint16(20, 1, Endian.little);
b.setUint16(22, 1, Endian.little);
b.setUint32(24, sampleRate, Endian.little);
b.setUint32(28, sampleRate * 2, Endian.little);
b.setUint16(32, 2, Endian.little);
b.setUint16(34, 16, Endian.little);
str(36, 'data');
b.setUint32(40, data, Endian.little);
for (var i = 0; i < samples.length; i++) {
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
}
return b.buffer.asUint8List();
}
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import 'sfx.dart';
import 'sound_bank.dart';
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).
class SynthSfx implements SfxService {
double volume;
SynthSfx({this.volume = 0.6});
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override
Future<void> play(SfxCue cue) =>
playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
/// Plays [spec], cached under [key] (same key = same sound).
Future<void> playSpec(String key, SoundSpec spec) async {
if (volume <= 0) return;
try {
final path = await (_files[key] ??= _write(key, spec));
final player = await _player();
await player.setVolume(100 * volume * volume);
await player.open(Media(path));
} catch (e) {
debugPrint('sfx $key: $e');
}
}
Future<String> _write(String key, SoundSpec spec) async {
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec)));
return f.path;
}
Future<Player> _player() async {
if (_pool.length < _poolSize) {
final p = Player(
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
);
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
_pool.add(p);
return p;
}
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();
}
_pool.clear();
}
}