Skip to content

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

  1. process the file as above and drop it in assets/sounds/bells/ or assets/sounds/music/ using lowercase snake_case names.
  2. update builtInBellDefinitions (data/database/built_in_bells.dart) or builtInSoundscapeDefinitions (data/database/built_in_soundscapes.dart) — the seed and the legacy*Paths/legacy*SyncIds maps drive in-place replacement on upgrade so existing meditation references keep working.
  3. bump schemaVersion and add an if (from < N) migration step calling BuiltInSeeders.ensureBuiltInBells() / BuiltInSeeders.ensureBuiltInSoundscapes().
  4. update the ATTRIBUTIONS.md next to the assets and CHANGELOG.md.