---
name: "audio_cleanup"
description: "Repair recorded speech (dialogue, voiceover, podcast): measure noise floor and loudness first, reduce steady background noise, tame sibilance, gate pauses, normalize to a target LUFS, then verify after. ffmpeg-only, every command executed and measured. Triggers on: clean up noisy audio, remove hiss or room tone, de-ess a recording, gate breaths or pauses, normalize podcast loudness, fix a bad recording of speech."
metadata: { "includeInPrompt": true }
---

# Audio Cleanup

## Purpose

Fix a speech recording that is usable but dirty: steady background noise, harsh sibilance, audible room tone in pauses, wrong loudness. This is repair, not mixing. No music bed, no creative EQ, no compression artistry. The order is fixed: measure, denoise, de-ess, gate, normalize, verify. You measure twice, before and after, so every change is a number, not a feeling.

All commands below ran on ffmpeg 8.1.2 against a synthetic 10-second fixture (voice-like harmonics in three phrases over a -30 dBFS white-noise floor). The measured numbers are quoted so you can sanity-check your own runs. sox was not installed in the test environment, so this skill is ffmpeg-only; no sox command is claimed to work.

## Workflow

### 0. Build a test fixture (recommended before touching the real file)

Save this as `make_fixture.py` and run `python3 make_fixture.py`. It writes `white.wav`: 10 s mono 48 kHz, voice-like harmonics in three phrases (1.2-3.0, 4.2-6.0, 7.2-8.8 s), a white-noise floor, and extra 6-8 kHz hiss riding phrase two.

```python
import numpy as np, wave
sr = 48000; dur = 10.0; n = int(sr * dur); t = np.arange(n) / sr
rng = np.random.default_rng(3)
white = rng.standard_normal(n); white = white / np.sqrt(np.mean(white**2)) * 10**(-40/20)
f0 = 120 + 25*np.sin(2*np.pi*0.6*t); phase = 2*np.pi*np.cumsum(f0)/sr
voice = np.zeros(n)
for k, a in [(1,1),(2,.5),(3,.3),(4,.2),(5,.1)]:
    voice += a*np.sin(k*phase)
voice *= 0.3
env = np.zeros(n)
for a, b in [(1.2,3.0),(4.2,6.0),(7.2,8.8)]:
    m = (t >= a) & (t < b)
    env[m] = np.clip((t[m]-a)/0.05, 0, 1) * (0.6 + 0.4*np.abs(np.sin(2*np.pi*4*(t[m]-a))))
voice = voice * env
voice = voice / np.sqrt(np.mean(voice[voice != 0]**2)) * 10**(-18/20)
mix = white + voice
mix = mix / max(abs(mix)) * 0.9
with wave.open('white.wav','wb') as w:
    w.setnchannels(1); w.setsampwidth(2); w.setframerate(sr)
    w.writeframes((mix*32767).astype(np.int16).tobytes())
print('wrote white.wav')
```

### 1. Measure first

Noise floor on a speech-free stretch (put `-ss` before `-i`; output seeking, `-ss` after `-i`, mis-seeks on some wavs and will silently measure the wrong segment):

```bash
ffmpeg -hide_banner -ss 0 -t 1 -i in.wav -af astats -f null - 2>&1 | grep "RMS level dB"
```

Speech level:

```bash
ffmpeg -hide_banner -ss 1.2 -t 1.8 -i in.wav -af astats -f null - 2>&1 | grep -E "Peak level dB|RMS level dB"
```

Loudness, first pass (JSON goes to stderr):

```bash
ffmpeg -hide_banner -i in.wav -af loudnorm=print_format=json -f null - 2>&1
```

Write down: noise RMS, speech peak/RMS, and the loudnorm `input_i`, `input_tp`, `input_lra`, `input_thresh`, `target_offset`. On the fixture: noise -30.28 dBFS, speech peak -1.02 / RMS -8.21 dBFS, loudnorm input_i -10.21 LUFS, input_tp -0.87 dBTP, input_lra 0.50 LU.

### 2. Reduce steady noise

```bash
ffmpeg -hide_banner -y -i in.wav -af "afftdn=nr=12:nf=-32" denoised.wav
```

Set `nf` within a few dB of the noise RMS you measured in step 1. This matters: the default `nf=-50` anchors the tracker's noise estimate below typical room tone, and on the fixture the filter did nothing measurable until `nf` was raised near the true floor. `nr=12` is a moderate cut; when the noise is heavy, `nr=20` is the next step, not `nr=40` in one pass.

Measured on the fixture: noise floor -30.28 to -38.52 dBFS (8.2 dB down); speech peak -1.02 to -1.62, RMS -8.21 to -8.30 (voice intact).

When speech and noise sit at nearly the same level, afftdn cannot separate them. Back off `nr` or accept the residual and let the gate do the rest.

### 3. De-ess

Measure the sibilant stretch in the 6-8 kHz band:

```bash
ffmpeg -hide_banner -ss 4.9 -t 1 -i denoised.wav -af "highpass=f=6000,lowpass=f=8000,astats" -f null - 2>&1 | grep "RMS level dB"
```

Then a targeted dip:

```bash
ffmpeg -hide_banner -y -i denoised.wav -af "equalizer=f=7000:t=q:w=2:g=-8" deessed.wav
```

Measured on the fixture: 6-8 kHz band -47.60 to -50.58 dBFS in the sibilant stretch; full-band speech RMS unchanged (-8.30 to -8.30). If the voice turns lispy, the dip is too deep or too wide; halve `g` first, then narrow `w`.

### 4. Gate the pauses

```bash
ffmpeg -hide_banner -y -i deessed.wav -af "agate=threshold=0.06:range=0.003:ratio=20:attack=2:release=80:knee=2" gated.wav
```

Set the threshold above the residual noise *peaks*, not its RMS. On the fixture the post-denoise noise RMS was -38.5 dBFS but its peaks hit about -27, so threshold 0.06 (about -24.4 dB) closed the gate fully while speech at -8.3 dBFS RMS passed untouched. Measured: pause gap -39.33 to -89.48 dBFS; speech peak/RMS identical within 0.001 dB.

If quiet word endings clip, lower the threshold or lengthen release. If the gate chatters on breaths, the threshold is sitting inside the breath level; raise it slightly.

### 5. Normalize loudness (dual pass)

Pass 1 already ran in step 1 and gave you the measured values. Pass 2, podcast target -16 LUFS / -1.5 dBTP / 11 LU:

```bash
ffmpeg -hide_banner -y -i gated.wav -af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-10.30:measured_TP=-1.61:measured_LRA=0.60:measured_thresh=-20.70:offset=-0.32:linear=true:print_format=json" final.wav
```

Substitute YOUR pass-1 numbers for the ones above; the fixture's are shown only as an example. Measured on the fixture: pass 2 reported output_i -15.96, output_tp -7.31, linear mode; an independent re-measure read -16.00 LUFS exactly.

### 6. Verify after

Run the step-1 measurements on `final.wav` and compare:

- LUFS within 0.5 of target (fixture: -16.00)
- true peak under the ceiling (fixture: -7.31 dBTP against a -1.5 ceiling)
- noise floor in a gated pause (fixture: -91.92 dBFS)
- speech peak with headroom left (fixture: -7.31 dBFS)

If a check fails, re-tune the step that owns it: loudness off means redo step 5 with the correct measured values; noise still high means revisit step 2's `nf` or accept it and re-gate.

## Output Contract

Given an input speech file, deliver the cleaned file plus a short before/after table with the six numbers from steps 1 and 6: noise RMS, speech peak, speech RMS, LUFS, true peak, and the gated-pause floor. No cleaned file ships without the after numbers.

## Operating Rules

1. Never skip step 1 or step 6. Cleanup without measurements is guesswork.
2. Change one thing per pass. Denoise, measure, then de-ess. Stacked filters hide which one caused damage.
3. Thresholds come from the file in front of you, never from memory. `nf` tracks the measured noise floor; the gate threshold clears the measured noise peaks.
4. When a filter does nothing measurable, say so and move on. Do not crank its strength to force an effect. On the fixture, afftdn with default `nf`, `adeclick` on a synthetic pop, and `silenceremove` (start trim never engaged; stop trim removed the whole file) all failed to behave, so they are left out, not forced.
5. ffmpeg only. No sox command in this skill is claimed to work.
6. Keep the original. Every step writes a new file; nothing overwrites the input.
