# Local Narration Captions

> Produce narrated, captioned vertical video entirely offline - Kokoro TTS for the voice, faster-whisper for real word timings, ASS subtitles burned in by ffmpeg, and two-pass loudness normalisation. Use when adding voiceover or burned-in captions to generated video without any cloud service.

- Skill: `bizpers11991-code/local-narration-captions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bizpers11991-code/local-narration-captions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bizpers11991-code/local-narration-captions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: bizpers11991-code (https://skillmd.com/u/bizpers11991-code)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/bizpers11991-code/local-narration-captions

---


# Local narration and burned-in captions

A full offline voice + caption pipeline. Verified on Arch Linux, Python 3.14,
CPU only (no CUDA). Nothing leaves the machine.

## Why burned-in captions are not optional

Most short-form video is watched muted. For a large share of viewers the
captions *are* the narration, not an accessibility extra. Generate them as
subtitles rather than rendering text in your 3D scene: typography should not
cost a re-render, and libass kerns and outlines far better than anything you
would build on a renderer.

## Install

```sh
python -m venv --system-site-packages .venv
.venv/bin/pip install kokoro-onnx soundfile faster-whisper
```

`kokoro-onnx` pulls `onnxruntime`. Both it and `faster-whisper` ship wheels that
work on Python 3.14 (`piper-tts` also ships `cp39-abi3` wheels if you prefer it).

Models (~350 MB):

```sh
curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx
curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin
```

**Watch out:** on many distros `/usr/bin/piper` is the *Logitech mouse
configuration tool*, not the TTS. Check `piper --help` before assuming.

## Synthesis

```python
from kokoro_onnx import Kokoro
k = Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")
samples, rate = k.create(text, voice="bm_george", speed=1.0, lang="en-gb")
```

Measured ~4x realtime on CPU; model load ~0.4 s. Voices are `af_*`/`am_*`
(American f/m) and `bf_*`/`bm_*` (British). Use `lang="en-gb"` with the `b*`
voices.

**Synthesise sentence by sentence**, not in one pass. A sentence is the unit a
narrator actually phrases, the gaps between them are where a viewer catches up,
and you get real sentence boundaries as timestamps for free. Assemble with
explicit silence: ~0.35 s lead-in, ~0.26 s between sentences, ~0.45 s tail.

### Fitting a script to a fixed slot

Speed up the read rather than cutting the picture short. Up to about **1.12x**
is imperceptible; past that it sounds hurried and the real fix is a shorter
script:

```python
room = target_seconds - 0.5
n = synthesise(script, speed=1.0)
if n.duration > room:
    n = synthesise(script, speed=min(1.22, n.duration / room * 1.02))
```

Check this *before* rendering — a script that overruns cannot be fixed
downstream. Synthesising all scripts up front and reporting the read speed each
would need takes seconds and catches the problem while it is still editable.

Budget roughly **2.4 words/second** including sentence gaps. 2.6 is too tight.

## Word timings

Kokoro will not tell you when it said each word, and estimating from character
counts drifts badly across a sentence. Transcribe the audio you just produced:

```python
from faster_whisper import WhisperModel
model = WhisperModel("small", device="cpu", compute_type="int8")
segments, _ = model.transcribe(wav, word_timestamps=True, vad_filter=False, beam_size=1)
```

~3.6 s for 8 s of audio on CPU.

**Use the recogniser only as a clock.** The words come from your script, in
order; the recogniser supplies *when* each landed. It will write "ninety" where
your script says "90", and its comma guesses are not better than the writer's.
Walk the script words forward through the recognised list matching on a
normalised key (lowercase, strip non-alphanumerics), and when a word does not
match, hang it off the previous word's end rather than dropping it.

## Caption cards

Group words into cards of **at most 3 words / ~26 characters**. Break a card
whenever the gap exceeds ~0.45 s or the previous word ended a sentence, so
cards never straddle a sentence boundary. Then extend each card towards the
next when the gap is only a breath, so captions do not flicker off between
words.

## ASS subtitles

ASS gives real outlines, shadows and fades, all of which survive compression
better than thin text. Colours are `&HAABBGGRR` — **alpha first, then BGR**.

```
[Script Info]
ScriptType: v4.00+
PlayResX: 1080
PlayResY: 1920
WrapStyle: 0
ScaledBorderAndShadow: yes

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Caption,DejaVu Sans,92,&H00FFFFFF,&H00FFFFFF,&H00121216,&HA0000000,-1,0,0,0,100,100,1,0,1,7,4,2,70,70,330,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.35,0:00:01.20,Caption,,0,0,0,,{\fad(70,70)}Why does a
```

- `WrapStyle: 0` (smart wrap). **`WrapStyle: 2` means no wrapping at all** — a
  long title then runs off both sides of the frame.
- Alignment `2` = bottom centre, `8` = top centre. `MarginV` is measured from
  that edge.
- Timestamps are `H:MM:SS.cc` — centiseconds, one digit of hours, no padding.
- Escape `{` and `}` in text; they are override-block delimiters.

### 9:16 safe areas (1080x1920)

- Top ~140-300 px: title kicker
- Middle: the subject
- Bottom: captions at `MarginV` ~330, clear of the platform's own UI

## Loudness

Do loudness as **two passes**, on the audio alone. Measure first:

```sh
ffmpeg -i vo.wav -af loudnorm=I=-14.5:TP=-1.5:LRA=11:print_format=json -f null -
```

Parse the JSON from stderr (`{...\"input_i\"...}`), then apply with the measured
values and `linear=true`. Single-pass loudnorm is an estimate and pumps.

Target **-14 LUFS, -1.5 dBTP** — what YouTube normalises to anyway. Normalise
the voice to about -14.5 first, then mix the bed under it, then limit.

## Music bed

A track pulled off the internet is a copyright claim waiting to happen on a
channel meant to run unattended. Generate one: a few detuned sine partials
(1x, 2x, 3x, 4x, 6x the root), each with its own slow amplitude LFO so it never
settles into an obvious loop, through a one-pole lowpass at ~900 Hz, with a
squared fade in and out. At about **-34 dBFS RMS** it registers as room tone,
not music.

## Final encode: one pass

Burn the subtitles in the same pass that encodes the frames. A second pass over
finished H.264 re-compresses everything just to add text.

```sh
ffmpeg -y -framerate 30 -start_number 1 -i frames/%04d.png -i mix.wav \
  -vf "subtitles=captions.ass:fontsdir=/usr/share/fonts,format=yuv420p" \
  -c:v libx264 -preset medium -crf 18 -profile:v high -level 4.2 \
  -x264-params keyint=60:min-keyint=30:scenecut=0 \
  -c:a aac -b:a 192k -ar 48000 -af "alimiter=limit=0.8414" \
  -movflags +faststart -t 25.000 out.mp4
```

- **Detect the frame-number padding** — Blender pads to 4 digits, not 5. Read
  the first PNG's stem length rather than hardcoding `%05d`.
- Pass explicit `-t` and pad the audio to length. `-shortest` will silently
  truncate the video to the narration.
- `format=yuv420p` after the subtitle filter, or some players get a pix_fmt
  they will not decode.

