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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- audio-processing

Installs to ~/.agents/skills/audio-processing/ for every project on this machine.

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.

~/.agents/skills/audio-processing/SKILL.md

--- name: audio-processing description: Edits and prepares audio with ffmpeg, SoX and Python - two-pass EBU R128 loudness normalisation to podcast, streaming or broadcast targets, trimming… license: MIT ---

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

DestinationIntegratedTrue peakNotes
Podcast (Apple, most hosts)-16 LUFS stereo, -19 LUFS mono-1.5 dBTPSpotify normalises to -14 anyway
Music streaming-14 LUFS-1 dBTPPlatforms turn louder masters down
Broadcast (EBU R128)-23 LUFS-1 dBTPUS ATSC A/85 uses -24 LKFS
YouTube / social video-14 LUFS-1 dBTP
Speech-to-text inputNot critical; avoid clippingFormat 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 + numpy for sample-level work (data, sr = sf.read("in.wav", always_2d=True)).
  • pyloudnorm measures LUFS in Python (pyln.Meter(sr).integrated_loudness(data)).
  • Avoid pydub on Python 3.13+: it depends on the removed audioop module and needs the audioop-lts backport. Calling ffmpeg via subprocess.run([...], check=True) is simpler.
  • librosa is 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 copy concat of identical streams.
  • Mono files played as “stereo” on one channel only: check channel_layout; upmix with -ac 2 or 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=20 removes it.
  • -c copy trims of AAC can leave a short glitch at the cut; re-encode when it matters.

Verification

  • ebur128=peak=true on 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: ffprobe shows 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.yamlgenerated from this page

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.py82 lines, 3.5 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem