--- name: sound-design description: Sound design and mixing for slopped.site's Web Audio - synthesis recipes (pads, bells, formant choir, noise beds for water and rain, whooshes, game SFX), routing (filters, reverb, delay, compressor), level and spectrum targets, browser rules (user gesture, tab visibility, node budget), and listen.mjs, which records a page's audio to WAV and measures loudness, peaks, brightness and stereo. Use when making or changing any sound on the sites (Scroll's ambient.js, SK8's SFX), when something sounds muddy, harsh, quiet, clicky or too loud, or before shipping audio. --- # Sound design for slopped.site All audio is synthesised with Web Audio: no files. Scroll's ambient music is `scroll/public/ambient.js`; SK8's sound effects are the `SFX` table in `games/public/sk8/index.html` (see game-feel for which verb gets which sound). Composition (notes, chords, form) is the music-composition skill; this one is about *how things sound* and *how they sit together*. ## You can't hear it: measure it ```sh node .claude/skills/sound-design/scripts/listen.mjs scroll spa 20 [out-dir] # one mood, 20 s node .claude/skills/sound-design/scripts/listen.mjs scroll choir 16 [out-dir] --scroll # while falling fast node .claude/skills/sound-design/scripts/listen.mjs scroll brown 16 # a noise on its own node .claude/skills/sound-design/scripts/listen.mjs scroll spa+brown 16 # music over a noise ``` It serves the site, starts the sound in headless Chrome (autoplay allowed), records the master output, writes a **WAV the user can play**, and prints: | Measure | Target for ambient beds | What it catches | | --- | --- | --- | | RMS loudness | about −24 dBFS, moods within ±2 dB of each other | too quiet, too loud, jumps between moods | | Peak / clipped samples | peak below −6 dBFS, 0 clipped | distortion | | Crest factor | 10–16 dB | squashed (low) or spiky (high) | | Per-2 s RMS | steady after the fade-in (±3 dB) | pumping, dropouts, runaway build-ups | | Spectral centroid | 300–1000 Hz warm ambient; rain/noise moods higher | dull (low) or harsh (high) | | Energy below 200 Hz | under ~55% | mud from drones, low noise and pad roots | | Energy above 4 kHz | a few % (more for rain) | no "air", or hiss | | L/R correlation | 0.1–0.7 | mono (1) or phase problems (below 0) | | Longest silence | under ~1 s after the fade-in | something not playing | The first pass of Scroll's moods measured 68–89% of energy below 200 Hz and nothing above 4 kHz: the drones were too loud and a shared lowpass was muffling the bells. **Always run it after changing a sound, and report the numbers plus the WAV path; never say a sound is good from code alone.** ## Recipes (all in ambient.js) - **Pad**: two sawtooths per note detuned ±6–7 cents (chorus), root as a triangle, slow attack (35% of the chord), through a dark lowpass (~1.5 kHz) and a highpass (~110 Hz) so it sits above the drone. Pan the notes across the field. - **Bell / chime**: sine fundamental plus inharmonic partials (×2.76 at 25%, ×5.4 at 6%), 10 ms attack, exponential decay ~3.5 s, into a feedback delay (~0.48 s, feedback 0.42, lowpassed) and the reverb. Keep bells out of the dark pad filter or they lose their shimmer. - **Choir "aah/ooh"**: sawtooth with ~5 Hz vibrato (0.6% depth) through three bandpass formants (ah 800/1150/2900 Hz, oo 350/800/2400 Hz, Q 9/7/5, levels 1/0.55/0.2), the formants gliding from one vowel to the other over the note. Several staggered voices make a choir. - **Ocean**: brown noise, band-limited 160–1300 Hz (surf, not rumble), amplitude swelling with a ~0.09 Hz LFO. - **Rain**: white noise band-limited 1.4–5.2 kHz, plus occasional droplets (a sine sweeping down from 2–3.4 kHz in 60 ms). - **Drone**: root and fifth very low (D2 + A2), quiet (0.012–0.016): felt more than heard. - **Noise layer** (`NOISES`, its own gain `noiseOut`, never reacting to scroll): **white** (lowpassed at 12 kHz so it isn't a hiss), **pink** (Paul Kellet's filter on white noise, −3 dB/octave), **brown** (leaky integrator; highpass 30 Hz), **waves** (two brown-noise swells on different LFOs, 0.075 and 0.11 Hz, panned apart, so the surf never lines up the same way), **rainfall** (white noise 0.9–6 kHz plus a brown "roof" rumble under 500 Hz). Steady-state targets: about −26 to −29 dBFS. Steady noise feels louder than music at the same RMS, so white sits lowest. - **Noise loops** are 4–8 s stereo buffers, different per ear, with the last 0.25 s crossfaded into the start: brown noise drifts, so a plain loop clicks at the seam. - **Whoosh** (speed): looped white noise through a bandpass (~900 Hz), level ∝ speed². - **Reverb**: a convolver with generated stereo noise decaying as (1 − t)³ over ~7 s. Send 0.55–0.75 for ambient. - **Master**: music (`out`) and noise (`noiseOut`) → `master` (the Volume slider, squared so it follows perceived loudness) → highpass 45 Hz (no sub rumble on laptop speakers) → compressor (−18 dB threshold, 3:1) → output. Each mood has a `trim` that level-matches it; `listen.mjs` measures after the master, at volume 100. - **Mud check for low moods**: dropping a whole pad an octave puts its root near 50 Hz (Night first measured 74% below 200 Hz). Keep the pad in place and darken it with a lower `padCut` instead; a high `trim` on a sparse mood (Glass) also boosts its drone, so cut the drone when you raise the trim. | Sound | Measured RMS (steady) | Below 200 Hz | | --- | --- | --- | | Spa / Choir / Rain | −24 to −25 dBFS | 37–44% | | Night / Glass | about −25.5 / −26.5 dBFS | 36% / 37% | | White / Pink / Brown / Waves / Rainfall | −29 / −27 / −26 / −28 (swelling) / −27 dBFS | — | ## Rules - **Fades, not switches.** Every gain change ramps (`linearRampToValueAtTime` / `setTargetAtTime`), and every ramp starts from an explicit `setValueAtTime` (a ramp with no earlier event can jump). Stop sources after their fade, never mid-sound: clicks. - **User gesture first.** Browsers start audio only from a click or key. A remembered "sound on" waits for the first pointerdown or keydown. Default is off. - **Tab hidden → `ctx.suspend()`**, visible → resume if on. - **Node budget.** Create short-lived nodes per note and let them stop; keep long-running beds few (≤ 6). Schedule ahead with a ~200 ms timer and a 0.5 s look-ahead rather than per frame. - **Mix by role.** Bed (noise, drone) lowest, pad under decorations, decorations (bells, voice lines) on top but sparse. If something must be heard, make it *brighter* or *sparser* before making it louder. - **SK8 effects** are short and quiet because they layer constantly. Pitch rises with achievement (multiplier ticks, bank arpeggios). Everything goes through `sfx()`, so `M` mutes all of it. ## See it (for reviewers who can't hear) `python3 .claude/skills/sound-design/scripts/see.py [--out dir]` turns WAVs (from listen.mjs, or a game's own `listen.mjs`) into `.see.png` plus a line of numbers each: - **The image:** loudness over time (RMS and peak, with clipping marked), a log-frequency spectrogram, and a piano roll of the strongest notes (C2–C7). - **The numbers:** peak/RMS dBFS, crest, clipped samples, low-end share under 200 Hz, centroid, a key estimate, a tempo estimate, and the loop seam (judged only when the file doesn't start or end in silence). It flags clipping, under 1 dB of headroom, more than 60% of the energy below 200 Hz (muddy on laptops, gone on phone speakers), and loop seams that click or jump in level. **Look at the PNG:** - a tune is a readable line in the piano roll; - an empty band between bass and melody is a hollow mix; - a smear under 300 Hz is mud; - vertical stripes are clicks. First run (2026-10-14, Always On's 12 songs): five had 62–75% of their energy under 200 Hz, and the battle theme's piano roll showed melody at C6–C7 and bass at C2–C3 with nothing in the middle octaves. Loudness numbers alone had passed all of them.