Audit a rehearsal recording against the slide deck it was delivered with, then track whether the next run actually got better.
Give it a video of yourself practising and your deck. It transcribes locally, measures how you delivered it, cross-checks every number you said against every number on your slides, and hands an agent a pack of measured facts to review.
Nothing is uploaded. Transcription runs on your machine, and the tool never calls a model itself.
From a real rehearsal: the speaker said "180 ms" while the slide behind him read "120.0 ms". Nothing in the audio reveals that — but the room sees and hears both at once.
16:05 spoke '180 ms' conf=0.52
nearest: 120 ms on slides 33, 41, 42, 44
It survives into the transcript only because the recogniser is deliberately
never primed with numbers. Prime it with the correct figure and it "hears"
the correct figure — the same audio transcribed as 120 at confidence 0.49
instead of 180 at 0.91, silently deleting the finding.
Ask a language model to "review this transcript" and it produces a confident, well-formatted review containing invented numbers. A widely used tool, given a real 28:59 rehearsal, reported:
| It said | Reality |
|---|---|
| Runtime 19:52 | 28:59 |
| A 12.0 s pause | Longest real gap 4.50 s |
| 16 slides | 45 slides |
Its judgements were reasonable. Its measurements were fiction — and the runtime error alone inverted the headline finding, turning "five minutes under budget" into what was really four minutes over.
So the work is split, and the split is enforced:
Python measures. The agent judges.
Everything countable is computed from timestamps in Python. The reviewer prompt forbids the agent from recomputing any of it.
As a Claude Code plugin — recommended, and the way it is designed to be used:
/plugin marketplace add robbiegal/plugin-marketplace
/plugin install presentation-coach@robbiegal-tools
/presentation-coach:coach-doctor
coach-doctor builds a Python environment in the plugin's data directory,
which survives plugin updates. Nothing installs at plugin-install time — the
transcription extra is hundreds of megabytes before a ~3 GB model, and no
session should block on that.
As a plain CLI:
git clone https://github.com/robbiegal/presentation-coach
cd presentation-coach
pip install -e ".[transcribe,prosody]"Needs Python 3.10+ and ffmpeg. Core install is stdlib-only and takes
seconds; the heavy pieces are optional extras (transcribe, prosody, align,
pdf, ocr).
mkdir my-talk && cd my-talk
presentation-coach init # talk.yaml + ledger.md
presentation-coach audit \
--video "dry-run-1.mp4" \
--deck "deck.pptx" \
--label "first run"
presentation-coach prompt review # give this + evidence.json to an agent
presentation-coach validate --run run-01 # refuses a malformed findings file
presentation-coach trend # once you have two runsThe audit writes audit/runs/run-01/:
| File | What it is |
|---|---|
evidence.json |
Every measured number. The reviewer's only source of facts |
transcript.timestamped.md |
Verbatim, uncertain words marked [?] |
transcript.txt · .srt · .vtt |
Reading copy and subtitles |
transcript.words.json |
Every word with timestamp and confidence |
The agent writes findings.json back. Validate it — a malformed findings
file that got silently skipped would make the next run report every finding as
resolved. It is refused loudly instead. See examples/ for the exact shape.
| Dimension | Notes | |
|---|---|---|
| measured | Timing vs budget | Talk is timed first word to last; setup and teardown silence are not delivery |
| Pace | Speaking-only wpm. Wall-clock is depressed by embedded video and belongs to timing | |
| Filler discipline | um/uh scored against a 2% cap. so/right/basically counted, never scored |
|
| Slide/speech consistency | Every spoken figure against every figure on the deck | |
| Pitch variation | How much of the talk went flat, in semitones | |
| judged | Technical accuracy · claim discipline · structure · audience fit · demo narration · Q&A exposure · disclosure | The agent's, from the evidence |
Composite is a weighted mean on 0–5, not a total, so it stays comparable
between runs that scored different dimensions. Readiness is a separate gate:
NOT READY while any BLOCKER is open, however good the average.
A check that could not run is reported, never scored as zero. Deck-dependent
checks degrade to nothing on decks without the right properties, and
evidence.capabilities says which and why — a silent zero reads as a clean
result.
Decks change between rehearsals, so decks are versioned separately from runs:
presentation-coach audit --video dry-run-2.mp4 --deck deck.pptx # same deck
presentation-coach audit --video dry-run-3.mp4 --deck deck-v2.pptx # revised
presentation-coach status
presentation-coach deck-diff deck-01 deck-02run deck duration wpm fillers fig? label
run-01 deck-01 28:59 125.5 131 4 first run
run-02 deck-01 26:10 131.0 88 2 second run
run-03 deck-02 25:05 134.2 71 1 after deck revision
The trap it exists to avoid: you can "fix" a wrong-number finding by editing
the slide to match what you said. The metric improves and the talk gets worse.
So every resolved finding is labelled fixed-by-speaker, fixed-by-deck or
removed, and only the first is progress.
Where a delta would be meaningless the tool refuses it and says why — a changed deck, a changed alignment method, a moved pitch bracket, or two runs covering very different spans of speech.
talk.yaml — all optional; defaults describe a generic 25-minute talk.
talk:
budget_minutes: 25
delivery:
wpm_target: [130, 160] # compared against speaking-only wpm
filler_rate_max: 0.02 # tier 1 (um/uh) onlyledger.md holds domain truths your deck cannot express — the claims a reviewer
should check hardest. Optional, but it is what lifts a review from "you said um
a lot" to "that contradicts your own result."
How delivery is measured — and why two of its numbers are not scored
Pitch dispersion is reported as a p10–p90 range in semitones, not as Hincks' pitch variation quotient, and that choice was forced by measurement rather than taste.
PVQ is F0 standard deviation over F0 mean, validated against listener ratings of liveliness at r = 0.83 — better pedigree than anything else here. But standard deviation is not robust. On one real recording PVQ read 0.146, 0.194 or 0.491 depending only on where Praat's pitch ceiling sat, because a handful of octave-up frames dominate a variance. The semitone range over those same three settings moved 5.89 → 6.04 → 7.47. Semitones are also the unit the published comparison is in: English TED speakers show ~7.05 st of 80-percentile range.
So PVQ is reported and never compared against its own published threshold — that threshold belongs to pitch settings the paper does not record.
| Measured | Scored | Why |
|---|---|---|
| Pitch range (semitones) | yes | Robust to the settings that swing PVQ 3.4× |
| Flat windows | yes | Share of 10 s windows spanning under 4 st. Calibrated on four consecutive rehearsals: 16 / 9 / 7 / 7 % |
| Pace variation | no | Seven talks all fell between 0.148 and 0.265 — any threshold sits inside the whole observed spread |
| Sentence endings | no | The rising fraction moved 38–50% on one recording from the tail length alone |
| Level dynamics | no | Spread ran 5–24 dB across four talks on recording chain alone |
The useful output is not the score but the located stretch:
Flattest stretches: 23:00 for 130s (3.4 st, slides 38-43), 25:20 for 80s (3.6 st)
"Pitch variation 3/5" is not something anyone can rehearse. "You were inside a major third for 130 seconds across the mitigation slides" is.
Slide timing, and matching video frames against the deck
Each slide is mapped to the stretch of time it was on screen, so section timing is measured rather than guessed. The default needs only ffmpeg.
Two signals combine: the transcript and the deck are two orderings of the same material, so aligning them says which slide is up; ffmpeg scene detection says when the picture changed.
00:04 06:36 Background
06:40 05:30 Acquisition & Operation
12:10 02:25 Demo
14:35 10:23 Measurement
24:57 03:56 Detection / Mitigations
Sections come from the deck's own divider slides, so they survive insertions and reordering — defining them by slide ranges breaks the moment you add a slide.
--refine renders the deck with LibreOffice and matches sampled video frames
against it, an independent signal that does not care whether the speaker read
the slide, paraphrased it, or talked past it.
The hard part is not matching, it is cropping. A recording composites the deck with a camera tile, event branding and letterbox, and hashing the whole frame compares furniture. So the slide rectangle is located first, by searching for the crop that best correlates with the rendered deck. Measured across four real talks, boundary error is 0.02–0.06 of the frame, including two broadcast layouts where inferring the region from motion alone failed.
| Method | Slides matched | Mean error | Needs |
|---|---|---|---|
| Content (default) | 28 / 45 | ~64 s | ffmpeg only |
--refine |
41 / 45 | ~23 s | LibreOffice, .pptx, a few extra minutes |
--refine is .pptx only and falls back to content alignment, with a warning,
if the toolchain is missing. Slides nobody spoke to get no span at all — and are
reported as unmatched, not as skipped, because at a low match score those are
as likely to be alignment misses.
Deck formats, and decks that are only images
| Format | Text | Rendering for --refine |
|---|---|---|
.pptx |
stdlib (OOXML) | LibreOffice |
.html |
stdlib | not supported |
.pdf |
[pdf] extra |
built in — no LibreOffice needed |
Hidden slides are skipped and the remainder renumbered, so slide numbers match what the room saw.
Image-only PDFs — a deck exported as page images — are common and carry no extractable text at all. The tool says so rather than reporting an empty deck as a deck with nothing on it:
NOTE: talk.pdf contains no extractable text (81 slides scanned).
It is almost certainly a deck exported as page images.
Without --ocr there is no vocabulary, no figure cross-check and no
section detection. Slide/time alignment still works with --refine.
--ocr recovers it (~3 s/slide, cached on the deck's bytes). On a real 81-slide
conference deck it recovered text on 77 slides and produced a usable domain
vocabulary from a deck that had looked completely empty.
Keep the deck format consistent across runs. Deck versions are identified by
content, and the .pptx and .html exports of the same deck differ slightly.
Switching format registers as a slide revision that never happened.
Reading the numbers
- Two wpm figures. Speaking-only is the delivery number; wall-clock is depressed by embedded video and silence and belongs to timing.
- Fillers are two tiers.
um/uhare scored.so/right/basicallyare ordinary English — counted for tic detection, never scored. Blending them gives a number nobody can act on. [?]means the recogniser was unsure, not that you misspoke. Check the timestamp before believing it.- Overrun is a content-volume problem. The tool quotes a word count to cut, never "speak faster" — pace is close to a personal constant and asking someone to add 30 wpm under stage pressure fails on the day.
- Speaker notes are ignored by design — the audience cannot see them.
Updating the plugin
/plugin update presentation-coach@robbiegal-tools
The scoped id is required; the bare plugin name reports "not found". A plugin
update does not rebuild the Python environment — the environment lives in a
data directory that deliberately outlives every plugin version. The SessionStart
check says when the two have drifted, and coach-doctor refreshes it.
Everything described above works: transcription, deck parsing (.pptx / .html
/ .pdf), vocabulary derivation, figure cross-check, delivery and prosody
measurement, slide↔time alignment including render-based refinement, section
timing, deck versioning and diffing, findings lifecycle with resolution
attribution, trend reporting, and the Claude Code plugin.
See CLAUDE.md for the design invariants before changing anything.
MIT.