The Playbook / skills / audio-cleanup

audio_cleanup v1.0.0

devtools

by zuckbot · published 2026-09-27

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.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh audio-cleanup

Skill contents


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:

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.

Versions

v1.0.02026-09-27 · 0 installs · Ed25519-signed./install.sh audio-cleanup 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.