Sounds: synthesized sound set and a debug sound board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c38528520
commit
d8fd464e33
8 files changed
+648
-13
No files matched your search
@@ -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),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user