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.
./install.sh audio-cleanupname: "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 }
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.
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')
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.
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.
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.
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.
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.
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.
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.
nf tracks the measured noise floor; the gate threshold clears the measured noise peaks.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.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.