Speech to Text skill for Codex

Transcripts with speakers, timestamps and a WER. Codex picks the transcription engine from what you need: hosted accuracy, speaker labels, word timestamps, or audio that never leaves the machine. It normalizes audio with ffmpeg, chunks long recordings on silence, biases spelling of names with a short prompt, converts segments to readable captions with a bundled script, and reports WER on a hand-checked sample.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- speech-to-text

Installs to ~/.agents/skills/speech-to-text/ 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 $speech-to-text, 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

Builds transcription pipelines with OpenAI speech-to-text (gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize, whisper-1) and local Whisper via faster-whisper or whisper.cpp - ffmpeg audio prep, chunking long files under the 25 MB limit, word and segment timestamps, speaker diarization, vocabulary prompts, SRT/VTT subtitles, and WER measurement. Use when asked to transcribe audio or video, add subtitles or captions, label who said what, transcribe offline or on-device, or improve transcription accuracy on names and jargon.

Use it when

  • Audio or video needs a transcript, captions or subtitles
  • Speakers must be identified in meetings, interviews or calls
  • Transcription must run offline or on-device for privacy
  • Names and jargon keep coming out misspelled

Use something else when

How to check the result

WER on a hand-corrected sample of representative audio is reported per condition, and chunk boundaries show no dropped or duplicated words.

The mistake it prevents

Cutting long audio at fixed intervals instead of on silence, which drops or doubles words at every seam.

~/.agents/skills/speech-to-text/SKILL.md

--- name: speech-to-text description: Builds transcription pipelines with OpenAI speech-to-text (gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize, whisper-1) and local… license: MIT ---

Done well means: audio is normalized before transcription, the model is chosen for the need (accuracy, timestamps, speakers, privacy, cost), long files are chunked on silence with no lost or duplicated words at boundaries, and accuracy is measured with WER on a sample against a human reference.

Choose the engine

NeedDefault
Best hosted accuracy, plain transcriptgpt-4o-transcribe (cheaper: gpt-4o-mini-transcribe)
Speaker labels from the APIgpt-4o-transcribe-diarize with response_format="diarized_json"
Word-level timestamps from the APIwhisper-1 with verbose_json + timestamp_granularities
Audio must not leave the machine, or no networkfaster-whisper (GPU/CPU) or whisper.cpp (CPU, Apple Silicon)
Word timestamps + diarization locallyWhisperX (alignment) + pyannote diarization
Live captions while someone speaksRealtime transcription ($openai-realtime-voice)

Audio of people is personal data. Confirm the user may process it, and with a third-party API, that participants consented and the data policy allows it. Hosted APIs need network; in a Codex sandbox ask before uploading anything.

Prepare audio

ffprobe -hide_banner input.mp4                                  # duration, codecs, channels
ffmpeg -i input.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le clean.wav          # local models
ffmpeg -i input.mp4 -vn -ac 1 -ar 16000 -c:a libopus -b:a 32k clean.ogg  # small upload
  • Mono 16 kHz is what Whisper-family models consume; higher rates only add size.
  • Opus at 24-32 kbps keeps a 2-hour meeting well under 25 MB.
  • Stereo call recordings with one speaker per channel: transcribe each channel separately (-af "pan=mono|c0=c0") for free, perfect diarization.
  • Loudness normalize very quiet recordings (-af loudnorm); do not apply aggressive noise reduction, which often hurts accuracy.

OpenAI API

from openai import OpenAI
client = OpenAI()

with open("clean.ogg", "rb") as f:
    tx = client.audio.transcriptions.create(
        model="gpt-4o-transcribe",
        file=f,
        language="en",                                         # skip auto-detect errors
        prompt="Speakers discuss Kubernetes, Istio, and the Acme Payments API.",
    )
print(tx.text)
  • prompt biases spelling of names and jargon; keep it short and factual. Not supported by the diarize model.
  • File limit is 25 MB per request. Longer audio: split on silence (below) and pass the previous chunk’s last sentence as prompt for continuity.
  • response_format: json/text for gpt-4o models; srt, vtt, verbose_json for whisper-1.

Word timestamps (whisper-1):

tx = client.audio.transcriptions.create(
    model="whisper-1", file=open("clean.ogg", "rb"),
    response_format="verbose_json", timestamp_granularities=["word", "segment"],
)
words = [(w.word, w.start, w.end) for w in tx.words]

Diarization:

tx = client.audio.transcriptions.create(
    model="gpt-4o-transcribe-diarize", file=open("meeting.ogg", "rb"),
    response_format="diarized_json", chunking_strategy="auto",
)
for seg in tx.segments:
    print(seg.speaker, seg.start, seg.end, seg.text)

Set chunking_strategy for anything over about 30 seconds. To map speakers to names, pass up to four 2-10 second reference clips as known_speaker_names and known_speaker_references (via extra_body if the SDK version lacks the parameters).

Local Whisper

from faster_whisper import WhisperModel

model = WhisperModel("large-v3", device="cuda", compute_type="float16")
# CPU: device="cpu", compute_type="int8"; faster option: "large-v3-turbo" or "distil-large-v3"
segments, info = model.transcribe("clean.wav", language="en", beam_size=5,
                                  vad_filter=True, word_timestamps=True,
                                  initial_prompt="Kubernetes, Istio, Acme Payments")
for s in segments:                      # generator: iterate to actually run
    print(f"[{s.start:.2f} -> {s.end:.2f}] {s.text}")

whisper.cpp for CPU/Metal: build, fetch a ggml model, then ./build/bin/whisper-cli -m models/ggml-large-v3-turbo.bin -f clean.wav -l en -osrt -ovtt.

Model downloads need network once; then cache them (HF_HOME) for offline runs.

Chunking long audio

  1. Detect silences: ffmpeg -i clean.wav -af silencedetect=noise=-35dB:d=0.6 -f null - 2>&1 | grep silence_end.
  2. Cut at the silence nearest each target length (about 10 minutes, under 25 MB).
  3. Transcribe chunks (parallel is fine), then offset each chunk’s timestamps by its start time.
  4. Never cut mid-word on fixed boundaries; that drops or doubles words at every seam.

Subtitles

Turn segments or words into SRT/VTT with readable line lengths (bundled, stdlib only):

python3 scripts/segments_to_subtitles.py segments.json --format srt --max-chars 42 --max-duration 6 > out.srt
python3 scripts/segments_to_subtitles.py segments.json --format vtt > out.vtt

Input is a JSON list of {"start", "end", "text"} (or an object with segments or words, as returned by the APIs above). Captions are split at 42 characters per line, 2 lines, and 6 seconds maximum; it exits non-zero on overlaps or invalid times.

Measuring accuracy

  • Hand-correct 10-15 minutes of representative audio as the reference.
  • WER = (substitutions + deletions + insertions) / reference words, after normalizing case, punctuation and numbers (jiwer with a normalizing transform).
  • Report WER per condition (clean vs phone audio, accents) and the error on key terms (product names), which matters more than the overall rate.

Gotchas

  • Whisper hallucinates text on silence or music (“Thanks for watching”). Use VAD (vad_filter=True) and drop segments with very low average log-probability.
  • Auto language detection on the first 30 seconds fails on code-switched or late-starting audio; set language when known.
  • Speaker labels are arbitrary (A, SPEAKER_00) and not stable across chunks unless known-speaker references are used; reconcile before showing names.
  • Timestamps drift after concatenation if chunk offsets use planned instead of actual cut times.
  • Do not keep raw recordings longer than the user’s retention policy; delete only after the user confirms.

Report template

**Input:** board-meeting.mp4 (1h52m, stereo 48 kHz) -> mono 16 kHz opus, 11 chunks on silence
**Engine:** gpt-4o-transcribe-diarize (consent confirmed by user) | language en
**Output:** transcript.md (speaker-labelled), board.srt (1,804 cues)
**Accuracy:** WER 6.8% on a 12-minute hand-checked sample; product names 2 errors / 41
**Review needed:** 03:12-03:40 overlapping speech, speakers B/C uncertain

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: "Speech to Text"
  short_description: "Transcripts with speakers, timestamps and a WER"
  default_prompt: "$speech-to-text Transcribe this two-hour meeting recording and label who is speaking"
scripts/segments_to_subtitles.py126 lines, 4.9 KB
#!/usr/bin/env python3
"""Convert transcription segments or words (JSON) into SRT or WebVTT captions.

Usage:
    python3 segments_to_subtitles.py segments.json [--format srt|vtt] [--max-chars 42]
                                     [--max-lines 2] [--max-duration 6]

Input: a JSON list of {"start": float, "end": float, "text"|"word": str}, or an object
with a "segments" or "words" list (OpenAI verbose_json/diarized_json, faster-whisper dumps).
Words are regrouped into captions; long segments are split by character budget with times
interpolated by character position. Writes to stdout. Exits 1 on invalid or overlapping times.
"""
import argparse
import json
import sys
import textwrap


def load_items(path):
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
    if isinstance(data, dict):
        data = data.get("words") or data.get("segments") or []
    items = []
    for i, d in enumerate(data):
        text = (d.get("text") if d.get("text") is not None else d.get("word", "")).strip()
        start, end = float(d["start"]), float(d["end"])
        speaker = d.get("speaker")
        items.append({"start": start, "end": end, "text": text, "speaker": speaker, "i": i})
    return items


def split_long(item, budget, max_duration):
    """Split one segment into words with times interpolated by character position, then regroup."""
    words = item["text"].split()
    if not words:
        return []
    total = sum(len(w) + 1 for w in words)
    dur = item["end"] - item["start"]
    synthetic, pos = [], 0
    for w in words:
        start = item["start"] + dur * pos / total
        pos += len(w) + 1
        synthetic.append({"start": start, "end": item["start"] + dur * pos / total,
                          "text": w, "speaker": item["speaker"]})
    return group_words(synthetic, budget, max_duration)


def group_words(items, budget, max_duration):
    cues, cur = [], []
    for w in items:
        text = " ".join(x["text"] for x in cur + [w])
        too_long = len(text) > budget
        too_slow = cur and w["end"] - cur[0]["start"] > max_duration
        speaker_change = cur and w["speaker"] != cur[-1]["speaker"]
        sentence_end = cur and cur[-1]["text"].endswith((".", "?", "!")) and len(text) > budget // 2
        if cur and (too_long or too_slow or speaker_change or sentence_end):
            cues.append(cur)
            cur = []
        cur.append(w)
    if cur:
        cues.append(cur)
    return [{"start": c[0]["start"], "end": c[-1]["end"], "text": " ".join(x["text"] for x in c),
             "speaker": c[0]["speaker"]} for c in cues]


def ts(seconds, sep):
    ms = int(round(seconds * 1000))
    h, ms = divmod(ms, 3_600_000)
    m, ms = divmod(ms, 60_000)
    s, ms = divmod(ms, 1000)
    return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path")
    ap.add_argument("--format", choices=["srt", "vtt"], default="srt")
    ap.add_argument("--max-chars", type=int, default=42, help="characters per line")
    ap.add_argument("--max-lines", type=int, default=2)
    ap.add_argument("--max-duration", type=float, default=6.0, help="seconds per cue")
    args = ap.parse_args()

    try:
        items = load_items(args.path)
    except (OSError, ValueError, KeyError, TypeError) as exc:
        print(f"error: cannot read input: {exc}", file=sys.stderr)
        return 1
    errors = [f"item {x['i']}: end before start" for x in items if x["end"] < x["start"]]
    ordered = sorted(items, key=lambda x: x["start"])
    for a, b in zip(ordered, ordered[1:]):
        if b["start"] < a["end"] - 0.05 and a["speaker"] == b["speaker"]:
            errors.append(f"items {a['i']} and {b['i']} overlap ({a['end']:.2f} > {b['start']:.2f})")
    if errors:
        for e in errors[:20]:
            print(f"error: {e}", file=sys.stderr)
        return 1

    budget = args.max_chars * args.max_lines
    is_words = all(len(x["text"].split()) <= 1 for x in ordered)
    if is_words:
        cues = group_words([x for x in ordered if x["text"]], budget, args.max_duration)
    else:
        cues = [c for x in ordered for c in split_long(x, budget, args.max_duration)]

    out = ["WEBVTT", ""] if args.format == "vtt" else []
    sep = "." if args.format == "vtt" else ","
    for n, c in enumerate(cues, start=1):
        text = c["text"]
        if c["speaker"] and args.format == "vtt":
            text = f"<v {c['speaker']}>{text}"
        elif c["speaker"]:
            text = f"[{c['speaker']}] {text}"
        lines = textwrap.wrap(text, width=args.max_chars) or [""]
        if args.format == "srt":
            out.append(str(n))
        out.append(f"{ts(c['start'], sep)} --> {ts(c['end'], sep)}")
        out.extend(lines)
        out.append("")
    sys.stdout.write("\n".join(out))
    print(f"{len(cues)} cues written", file=sys.stderr)
    return 0


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