audio asset pipeline
how the bundled audio assets under assets/sounds/ are produced. every file
is MP3 (44.1 kHz) encoded with lame VBR -q:a 2 — flutter_soloud builds
without the Xiph decoders (hooks.user_defines.flutter_soloud.no_xiph_libs:
true in pubspec.yaml), so Opus/Ogg/Vorbis/FLAC sources are not supported. VBR spends bits on the strike transients (where mp3's
pre-echo artifact lives) and almost nothing on simple decay tails, so the
bell library stays around 1.8 MB total.
bells
bells are one-shot recordings; they are peak-normalized, not loudness-normalized. loudness normalization on a short strike with a long decay inflates quiet bells and pumps; a common peak ceiling keeps every bell equally present while preserving each instrument's natural decay and relative character.
target: −3 dBTP true peak.
# 1. measure the true peak
ffmpeg -i in.mp3 -af ebur128=peak=true -f null -
# -> read "Peak:" under "True peak:" in the Summary block
# 2. apply the static gain needed to reach -3 dBTP and re-encode
ffmpeg -i in.mp3 -af "volume=<gain>dB" -ar 44100 -c:a libmp3lame -q:a 2 out.mp3
# e.g. a file measured at -10.4 dBTP gets volume=7.4dB
the current library was normalized with gains between −2.6 dB and +8.0 dB (chukpi −0.4 dBTP → −2.6 dB, large crystal bowl −11.0 dBTP → +8.0 dB).
soundscapes
soundscapes loop continuously under the whole session, so they need two steps: build a seamless loop, then normalize to a common loudness.
seamless loop
the loop is constructed so the wrap point is sample-continuous: the file starts with a crossfade of the tail region into the head, followed by the untouched body. when the player wraps, the end of the body flows straight into the start of the crossfade — no click, no level step.
output = acrossfade(source[E-X : E] -> source[0 : X]) + source[X : E-X]
ffmpeg -i source.mp3 -filter_complex \
"[0:a]atrim=start=<E-X>:end=<E>,asetpts=PTS-STARTPTS[tail];
[0:a]atrim=start=0:end=<X>,asetpts=PTS-STARTPTS[head];
[0:a]atrim=start=<X>:end=<E-X>,asetpts=PTS-STARTPTS[body];
[tail][head]acrossfade=d=<X>:c1=tri:c2=tri[x];
[x][body]concat=n=2:v=0:a=1[out]" \
-map "[out]" loop.wav
choose E so the kept region is steady — trim off fade-ins/fade-outs in the
source first (measure per-second RMS with ffmpeg -ss <t> -t 1 -i src -af
volumedetect -f null -). X of 3–5 s works well for nature textures; the
crossfade hides small level differences between the two regions.
values used for the current tracks:
| file | E | X | result |
|---|---|---|---|
| rain_in_a_tent.mp3 | 31 s | 4 s | 27 s loop (source fades out after ~31 s) |
| lake_at_night.mp3 | 46 s | 4 s | 42 s loop (tail best matches head level) |
loudness normalization
use a static gain plus a limiter, not loudnorm. loudnorm falls back
to dynamic mode when linear gain would exceed the true-peak ceiling, and its
time-varying gain can undo the seamless wrap; a static gain cannot break a
seam by construction.
target: ≈ −27 LUFS integrated (between the two original placeholders)
with a −2 dBFS ceiling (alimiter limit=0.794).
# 1. measure the loop's integrated loudness
ffmpeg -i loop.wav -af loudnorm=print_format=summary -f null -
# 2. apply static gain toward the target, with the limiter catching
# transient overshoot (e.g. raindrop strikes)
ffmpeg -i loop.wav \
-af "volume=<gain>dB,alimiter=limit=0.794:level=false:attack=5:release=80" \
-ar 44100 -c:a libmp3lame -q:a 2 out.mp3
gains used: rain +24 dB (droplet transients are limited, which is fine for an ambient bed), lake +13 dB (no limiting needed). results: −29.5 and −27.3 LUFS integrated — close enough that switching between them feels even.
generated noise
noise soundscapes are not assets at all — they are synthesized at playback
time. NoiseType (lib/data/models/noise_type.dart) enumerates the colors,
and generateNoiseWav (lib/data/services/noise_generator.dart) renders a
seeded, deterministic stereo buffer for each one:
- white — uniform random samples.
- pink — Paul Kellet's 3-pole approximation of a −3 dB/octave filter.
- brown — a leaky integrator over white noise (−6 dB/octave).
- green — white noise through two cascaded RBJ-cookbook band-pass biquads at 500 Hz, Q 0.4 (an effective pass band of roughly 250 Hz–1 kHz).
each buffer is a 20 s loop made seamless by a 1 s equal-power crossfade at the wrap point and peak-normalized to 0.85 so the colors sit at comparable loudness.
NoiseType is append-only: soundName, soundId (negative, derived
from the enum index), and syncId all derive from the value's name and
index and are persisted in meditation_soundscapes and easy-mode settings,
so a new color must be appended at the end — never inserted or reordered.
soundscape pickers list the noise options sorted by name inside
SoundsRepository.watchAllSoundscapeOptions, independent of enum order.
adding or replacing a bundled sound
- process the file as above and drop it in
assets/sounds/bells/orassets/sounds/music/using lowercase snake_case names. - update
builtInBellDefinitions(data/database/built_in_bells.dart) orbuiltInSoundscapeDefinitions(data/database/built_in_soundscapes.dart) — the seed and thelegacy*Paths/legacy*SyncIdsmaps drive in-place replacement on upgrade so existing meditation references keep working. - bump
schemaVersionand add anif (from < N)migration step callingBuiltInSeeders.ensureBuiltInBells()/BuiltInSeeders.ensureBuiltInSoundscapes(). - update the
ATTRIBUTIONS.mdnext to the assets andCHANGELOG.md.