Audio Processing skill for Codex
Measured loudness and clean speech, every episode. Codex measures integrated loudness and true peak before touching a file, cleans and trims in a fixed order, and normalises last with a bundled two-pass loudnorm script that verifies the output hits the target. It knows podcast, streaming and broadcast targets, and prepares 16 kHz mono files split at silences for speech-to-text with offsets recorded.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- audio-processing Installs to ~/.agents/skills/audio-processing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install audio-processing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project audio-processing Installs to .agents/skills/audio-processing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/audio-processing.zipunzip -o audio-processing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) audio-processing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $audio-processing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Edits and prepares audio with ffmpeg, SoX and Python - two-pass EBU R128 loudness normalisation to podcast, streaming or broadcast targets, trimming and silence removal, noise and hum cleanup, channel and sample-rate conversion, splitting long recordings at silences, and preparing 16 kHz mono files for speech-to-text. Includes a stdlib script for measured loudnorm. Use when asked to normalise or level podcast audio, make episodes the same volume, clean up a noisy recording, convert or batch audio formats, or chunk audio for Whisper or another transcription API.
Use it when
- Episodes or clips differ in volume and must match a loudness target
- A voice recording has rumble, hum, long pauses or background noise
- Audio must be converted, resampled or batch encoded
- Long recordings need preparing and chunking for transcription
Use something else when
- You need the transcript, speaker labels or timestamps themselves: use speech-to-text
- The work is on the video stream (cutting, encoding, subtitles): use ffmpeg-video
How to check the result
Run ebur128 on the output and confirm integrated loudness within 1 LU of target and true peak under the ceiling, check durations, then listen to the start, a loud passage and the end.
The mistake it prevents
Using single-pass loudnorm on whole episodes, which rides the gain up and down and makes speech pump instead of applying one constant level change.
Done well means: loudness is measured, not eyeballed, and lands within 1 LU of the target with true peaks under the ceiling; edits never clip; processing is applied once from the original, in a sensible order; and the output format matches where it is going (podcast host, video, speech model) rather than a habit.
Measure first
ffprobe -v error -show_entries stream=codec_name,sample_rate,channels,channel_layout,bit_rate:format=duration -of compact in.wav
ffmpeg -hide_banner -nostats -i in.wav -af ebur128=peak=true -f null - 2>&1 | tail -12
ffmpeg -hide_banner -nostats -i in.wav -af astats=measure_overall=Peak_level+RMS_level+DC_offset:measure_perchannel=0 -f null - 2>&1 | grep -E 'Peak|RMS|DC'
Record integrated loudness (LUFS), true peak (dBTP), loudness range (LU), sample rate and channel layout. A peak at 0 dBFS with flat tops means the source already clipped; no normalisation fixes that, so say so.
Targets
| Destination | Integrated | True peak | Notes |
|---|---|---|---|
| Podcast (Apple, most hosts) | -16 LUFS stereo, -19 LUFS mono | -1.5 dBTP | Spotify normalises to -14 anyway |
| Music streaming | -14 LUFS | -1 dBTP | Platforms turn louder masters down |
| Broadcast (EBU R128) | -23 LUFS | -1 dBTP | US ATSC A/85 uses -24 LKFS |
| YouTube / social video | -14 LUFS | -1 dBTP | |
| Speech-to-text input | Not critical; avoid clipping | Format matters more (below) |
Processing order
- [ ] 1. Convert to a working format: WAV/FLAC, original sample rate, float if editing heavily
- [ ] 2. Trim heads, tails and unwanted sections
- [ ] 3. Clean: high-pass rumble, remove hum, denoise lightly
- [ ] 4. Dynamics (optional): gentle compression for voice
- [ ] 5. Loudness normalise (two-pass) as the last level change
- [ ] 6. Encode to the delivery format once
- [ ] 7. Measure the output
Lossy-to-lossy round trips (MP3 to MP3) stack artefacts: keep intermediates lossless.
Loudness normalisation (bundled script)
python3 scripts/loudnorm_2pass.py episode.wav episode-norm.m4a --target -16 --tp -1.5
python3 scripts/loudnorm_2pass.py raw.wav out.wav --codec pcm_s16le --target -23
Pass 1 measures, pass 2 applies loudnorm with measured_* values and linear=true, so
gain is constant instead of pumping. The script copies video streams unchanged, prints
before and after, and exits 1 if the output misses the target by more than 1 LU (usually
a very dynamic source where ffmpeg falls back to dynamic mode; compress first, then rerun).
Requires ffmpeg on PATH; no network needed.
Single-pass -af loudnorm is acceptable for quick previews only.
Common operations
# Trim: keep 00:00:12.5 to 00:41:03 without re-encoding (MP3/AAC cut at frame boundaries)
ffmpeg -ss 12.5 -to 2463 -i in.m4a -c copy trimmed.m4a
# Fade in 2 s and out 3 s (needs re-encode; set st to duration minus 3)
ffmpeg -i in.wav -af "afade=t=in:d=2,afade=t=out:st=1797:d=3" faded.wav
# Voice cleanup: rumble filter, 50 Hz hum notches, light FFT denoise
ffmpeg -i in.wav -af "highpass=f=80,bandreject=f=50:width_type=h:w=4,bandreject=f=100:width_type=h:w=4,afftdn=nf=-25" clean.wav
# Gentle voice compression before normalising
ffmpeg -i clean.wav -af "acompressor=threshold=-21dB:ratio=3:attack=10:release=200:makeup=2" comp.wav
# Stereo to mono, resample to 48 kHz (add -af aresample=resampler=soxr if built with libsoxr)
ffmpeg -i in.wav -ac 1 -ar 48000 mono48k.wav
# Remove long pauses (keep up to 0.6 s of any silence below -40 dB)
ffmpeg -i in.wav -af "silenceremove=stop_periods=-1:stop_duration=0.6:stop_threshold=-40dB" tight.wav
# Detect silences (to choose split points or find dead air)
ffmpeg -hide_banner -nostats -i in.wav -af silencedetect=noise=-35dB:d=0.7 -f null - 2>&1 | grep silence_end
# Delivery encodes
ffmpeg -i final.wav -c:a libmp3lame -b:a 128k -ar 44100 episode.mp3 # podcast MP3 (mono: 64k-96k)
ffmpeg -i final.wav -c:a aac -b:a 192k episode.m4a
ffmpeg -i final.wav -c:a flac archive.flac
afftdn is a light touch; heavy denoising makes voices sound underwater. For badly noisy
recordings, recommend a dedicated tool (RNNoise via arnndn with a model file, or an editor
with spectral repair) and say what you could not fix. SoX equivalents when installed:
sox in.wav out.wav highpass 80 norm -1, sox in.wav -n stat.
Speech-to-text preparation
Most speech models resample internally to 16 kHz mono. Sending that format directly shrinks uploads about 6x versus 48 kHz stereo and avoids resampling surprises:
ffmpeg -i interview.m4a -vn -ac 1 -ar 16000 -c:a pcm_s16le interview-16k.wav # local Whisper / whisper.cpp
ffmpeg -i interview.m4a -vn -ac 1 -ar 16000 -c:a libopus -b:a 24k interview.ogg # small upload for APIs
Hosted APIs cap upload size (OpenAI’s transcription endpoint has used 25 MB; check the provider’s current limit). Split long files at silences, not at fixed times, so words are not cut:
# 1. find silence ends, 2. choose cut points near every 10 minutes, 3. cut
ffmpeg -hide_banner -nostats -i talk.wav -af silencedetect=noise=-35dB:d=0.5 -f null - 2>&1 \
| sed -n 's/.*silence_end: \([0-9.]*\).*/\1/p' > silences.txt
ffmpeg -i talk.wav -f segment -segment_times 598.4,1201.7,1799.2 -c copy part_%02d.wav
Keep a manifest of each chunk’s start offset so transcript timestamps can be shifted back
to the original timeline. Do not denoise heavily before transcription; modern models
handle noise better than they handle denoising artefacts. Transcription itself, diarisation
and timestamps are $speech-to-text.
Python
soundfile+numpyfor sample-level work (data, sr = sf.read("in.wav", always_2d=True)).pyloudnormmeasures LUFS in Python (pyln.Meter(sr).integrated_loudness(data)).- Avoid
pydubon Python 3.13+: it depends on the removedaudioopmodule and needs theaudioop-ltsbackport. Calling ffmpeg viasubprocess.run([...], check=True)is simpler. librosais for analysis (onsets, tempo, spectra), not for writing delivery files.
Batch
mkdir -p out
for f in raw/*.wav; do
b=$(basename "${f%.*}")
[ -e "out/$b.m4a" ] && continue
python3 scripts/loudnorm_2pass.py "$f" "out/$b.m4a" --target -16 || echo "$f" >> out/failed.txt
done
Write outputs to a new folder; never overwrite the raw recordings.
Gotchas
- Normalising each section separately then joining gives uneven results; join first, then normalise the whole programme.
- MP3 encoders add padding; gapless joins need WAV intermediates or
-c copyconcat of identical streams. - Mono files played as “stereo” on one channel only: check
channel_layout; upmix with-ac 2or pan (pan=stereo|c0=c0|c1=c0). - Sample-rate mismatch (44.1 vs 48 kHz) between music and voice tracks causes pitch or drift when mixed by naive tools; resample everything to one rate first.
- DC offset makes peaks asymmetric and wastes headroom;
highpass=f=20removes it. -c copytrims of AAC can leave a short glitch at the cut; re-encode when it matters.
Verification
ebur128=peak=trueon the output: integrated within 1 LU of target, true peak at or under the ceiling.- Duration equals the input minus intended trims (
ffprobe format=duration). - Listen to the first 30 seconds, a loud passage and the ending; check for pumping, clipping and denoise artefacts.
- For STT prep:
ffprobeshows 16000 Hz, 1 channel; chunk durations sum to the original.
Report
**Input:** ep42-raw.wav (48 kHz stereo, 52:10, -24.8 LUFS, TP -0.2 dBTP)
**Chain:** highpass 80 Hz, 50/100 Hz notch, acompressor 3:1, loudnorm two-pass linear
**Output:** ep42.mp3 (128k, 44.1 kHz), -16.1 LUFS, TP -1.6 dBTP, 52:10
**Notes:** 00:31:12 has a clipped laugh in the source; not repairable by levelling What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Audio Processing"
short_description: "Measured loudness and clean speech, every episode"
default_prompt: "$audio-processing Normalise all these podcast episodes to -16 LUFS so they sound equally loud" scripts/loudnorm_2pass.py
#!/usr/bin/env python3
"""Two-pass EBU R128 loudness normalisation with ffmpeg's loudnorm filter.
Pass 1 measures the input; pass 2 applies a linear gain using the measured values, which
avoids the pumping that single-pass (dynamic) loudnorm can cause. Prints before/after
integrated loudness and true peak, and exits non-zero if the result misses the target.
Usage:
python3 loudnorm_2pass.py IN OUT [--target -16] [--tp -1.5] [--lra 11]
[--codec aac|libmp3lame|pcm_s16le|flac] [--bitrate 192k]
Requires: ffmpeg on PATH. Video streams in IN are copied unchanged.
Exit codes: 0 = within 1 LU of target, 1 = outside tolerance, 2 = ffmpeg/usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
def run(cmd: list[str]) -> str:
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
sys.stderr.write(p.stderr[-2000:])
raise SystemExit(2)
return p.stderr
def measure(path: str, target: float, tp: float, lra: float) -> dict:
err = run(["ffmpeg", "-hide_banner", "-nostats", "-i", path, "-vn",
"-af", f"loudnorm=I={target}:TP={tp}:LRA={lra}:print_format=json",
"-f", "null", "-"])
blocks = re.findall(r"\{[^{}]*\"input_i\"[^{}]*\}", err, re.S)
if not blocks:
sys.stderr.write("could not find loudnorm JSON in ffmpeg output\n")
raise SystemExit(2)
return json.loads(blocks[-1])
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("src")
ap.add_argument("dst")
ap.add_argument("--target", type=float, default=-16.0, help="integrated LUFS")
ap.add_argument("--tp", type=float, default=-1.5, help="true peak ceiling, dBTP")
ap.add_argument("--lra", type=float, default=11.0, help="loudness range target, LU")
ap.add_argument("--codec", default="aac")
ap.add_argument("--bitrate", default="192k")
ap.add_argument("--sample-rate", default="48000")
args = ap.parse_args(argv)
if shutil.which("ffmpeg") is None:
sys.stderr.write("ffmpeg not found on PATH\n")
return 2
m = measure(args.src, args.target, args.tp, args.lra)
print(f"input: {float(m['input_i']):6.2f} LUFS TP {float(m['input_tp']):6.2f} dBTP "
f"LRA {float(m['input_lra']):5.2f} LU")
flt = (f"loudnorm=I={args.target}:TP={args.tp}:LRA={args.lra}"
f":measured_I={m['input_i']}:measured_TP={m['input_tp']}"
f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}"
f":offset={m['target_offset']}:linear=true:print_format=summary")
cmd = ["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", args.src, "-map", "0",
"-c:v", "copy", "-af", flt, "-ar", args.sample_rate, "-c:a", args.codec]
if not args.codec.startswith("pcm") and args.codec != "flac":
cmd += ["-b:a", args.bitrate]
run(cmd + [args.dst])
out = measure(args.dst, args.target, args.tp, args.lra)
got_i, got_tp = float(out["input_i"]), float(out["input_tp"])
print(f"output: {got_i:6.2f} LUFS TP {got_tp:6.2f} dBTP LRA {float(out['input_lra']):5.2f} LU")
ok = abs(got_i - args.target) <= 1.0 and got_tp <= args.tp + 0.5
if not ok:
print("outside tolerance: input may be too dynamic for linear mode "
"(loudnorm falls back to dynamic) or peaks limit the gain", file=sys.stderr)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.