Long videos and podcast episodes have the same problem: nobody can find anything in them. A viewer wants the part about pricing in your 52-minute interview, sees one long progress bar, and leaves. The episode description says “Great chat with Budi about business!” and nothing else, so search engines and listeners have nothing to work with.

The fix is boring and well known: chapters (timestamps with titles) and show notes (a short summary, key points, things mentioned). YouTube shows chapters on the progress bar, and Spotify for Creators turns timestamps in an episode description into chapters too. Most small podcasters and YouTubers still skip it, because doing it by hand means listening to the whole episode again with a notepad. For a one-hour show that’s easily an hour or more of work.

In this guide you’ll build a Python tool that runs entirely on your own computer and:

  • transcribes the episode with faster-whisper (open-source Whisper, MIT licence)
  • saves the transcript as an editable TSV, so you can fix names once and re-run
  • asks a local LLM (Qwen3 8B via Ollama) where the topic changes, using JSON output
  • checks the chapters in Python against YouTube’s rules instead of trusting the model
  • writes show notes and drops any “mentioned resource” that doesn’t actually appear in the transcript
  • writes a description.txt you can paste into YouTube Studio or Spotify for Creators

Software cost: $0. Build time: about 45 minutes. Review time per episode: 15–25 minutes, and that review is what clients pay for.

What you need

ItemCostNotes
Python 3.10+$0Windows, macOS or Linux
faster-whisper$0pip install faster-whisper "av<19"
Ollama$0ollama.com/download
qwen3:8b model$0~5.2 GB download. Use qwen3:4b-instruct (~2.5 GB) on 8 GB RAM
ollama, pydantic Python packages$0pip install
A computer with 16 GB RAM$0 if you have oneA GPU or Apple Silicon makes it much faster, but isn’t required
Disk space~7–9 GBWhisper model + LLM

Optional paid alternative for the transcription step (prices as of October 2026, check before relying on them): Groq’s hosted whisper-large-v3-turbo costs $0.04 per hour of audio. If your laptop is slow, transcribe there, save the result in the same TSV format, and keep the rest of this pipeline local. We covered Groq in the voice notes guide.

YouTube’s chapter rules (the part people get wrong)

According to YouTube’s Help page on video chapters, chapters only appear if the description has:

  • a first timestamp at 00:00
  • at least three timestamps, in ascending order
  • every chapter at least 10 seconds long

Miss any of these and YouTube silently ignores your list. YouTube can also generate automatic chapters (a checkbox under Show more in the video’s details in Studio). Your own timestamps in the description replace them. That matters later in the Reality check: you’re competing with a free automatic feature, so your chapters need to be better than YouTube’s.

Step 1: Install everything

# Ollama: Linux (macOS and Windows: use the installer from ollama.com/download)
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen3:8b            # or qwen3:4b-instruct on 8 GB RAM

mkdir chapterer && cd chapterer
python3 -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install faster-whisper "av<19" ollama pydantic
mkdir in

Why av<19? With faster-whisper 1.2.1 and PyAV 19, transcription crashes with TypeError: open() got an unexpected keyword argument 'metadata_errors'. PyAV 18 works. The fix is already in faster-whisper’s GitHub code but not in a release yet (1.2.1 is the newest on PyPI as of October 2026), so keep the pin until a newer version ships. The captioning guide has the details.

Check that Ollama is running:

ollama run qwen3:8b "Say OK" --think=false

Put an episode in in/. Any format FFmpeg understands works: .mp3, .m4a, .wav, even an .mp4 video. faster-whisper decodes it with PyAV, so you don’t need to extract the audio first.

Step 2: How the tool avoids the usual LLM mistakes

The naive approach is to paste a transcript into a chatbot and say “make YouTube chapters”. You get timestamps that look right and aren’t: the model invents 12:30 because it’s a round number, puts chapters 40 seconds apart, or leaves out 00:00. Long transcripts make it worse.

This script never lets the model write a timestamp:

  1. The transcript is grouped into 45-second blocks, each with a number.
  2. The model only sees block numbers and a shortened version of each block’s text, and answers with block numbers + titles in a JSON schema.
  3. Python converts block numbers back to real timestamps, forces the first one to 00:00, drops chapters shorter than 90 seconds, and refuses to write output with fewer than three chapters.

Shortening each block to its first 45 words keeps a one-hour episode around 5,000 tokens, which fits comfortably in a 16K context on a normal laptop. You lose detail, but topic changes are still obvious. People usually announce them (“OK, let’s talk about pricing”).

Step 3: The script

Save this as chapters.py:

import argparse, csv, sys, time
from pathlib import Path

from faster_whisper import WhisperModel
from ollama import chat
from pydantic import BaseModel

LLM = "qwen3:8b"
BLOCK_SECONDS = 45      # transcript is grouped into blocks this long
WORDS_PER_BLOCK = 45    # words per block sent to the LLM
MIN_CHAPTER = 90        # seconds; YouTube's minimum is 10, but tiny chapters are useless
CTX = 16384             # Ollama's default context is too small for a long episode

class Chapter(BaseModel):
    block: int
    title: str

class Chapters(BaseModel):
    chapters: list[Chapter]

class Notes(BaseModel):
    summary: str
    takeaways: list[str]
    mentioned: list[str]

def ts(sec):
    sec = int(sec)
    h, m, s = sec // 3600, sec % 3600 // 60, sec % 60
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"

def transcribe(audio, size, lang, tsv):
    if tsv.exists():
        print(f"Using existing transcript {tsv.name}")
    else:
        model = WhisperModel(size, device="auto", compute_type="int8")
        segments, info = model.transcribe(str(audio), language=lang, vad_filter=True)
        print(f"Transcribing {info.duration / 60:.0f} min ({info.language}) with {size}")
        t0 = time.time()
        with tsv.open("w", newline="", encoding="utf-8") as f:
            w = csv.writer(f, delimiter="\t")
            for seg in segments:            # transcription happens while iterating
                w.writerow([f"{seg.start:.1f}", seg.text.strip()])
                print(f"\r  {ts(seg.end)} / {ts(info.duration)}", end="", flush=True)
        print(f"\n  done in {(time.time() - t0) / 60:.1f} min")
    with tsv.open(encoding="utf-8") as f:
        return [(float(r[0]), r[1]) for r in csv.reader(f, delimiter="\t") if len(r) == 2]

def make_blocks(segs):
    blocks = []
    for start, text in segs:
        if not blocks or start - blocks[-1]["start"] >= BLOCK_SECONDS:
            blocks.append({"id": len(blocks), "start": start, "text": []})
        blocks[-1]["text"].append(text)
    for b in blocks:
        b["short"] = " ".join(" ".join(b["text"]).split()[:WORDS_PER_BLOCK])
    return blocks

def ask(prompt, schema):
    t0 = time.time()
    resp = chat(
        model=LLM,
        messages=[{"role": "user", "content": prompt}],
        format=schema.model_json_schema(),
        think=False,
        options={"num_ctx": CTX, "temperature": 0.2},
    )
    print(f"  LLM: {resp.prompt_eval_count} in / {resp.eval_count} out tokens, "
          f"{time.time() - t0:.0f}s")
    return schema.model_validate_json(resp.message.content)

def clean_chapters(raw, blocks, end):
    starts = {b["id"]: b["start"] for b in blocks}
    out = [[0.0, "Intro"]]
    for c in sorted(raw.chapters, key=lambda c: c.block):
        title = " ".join(c.title.split()).strip(" .")[:60]
        if c.block not in starts or not title:
            continue                         # invented block number or empty title
        start = 0.0 if c.block == 0 else starts[c.block]
        if start == 0.0:
            out[0][1] = title
        elif start - out[-1][0] >= MIN_CHAPTER:
            out.append([start, title])
    if len(out) > 1 and end - out[-1][0] < MIN_CHAPTER:
        out.pop()                            # last chapter would be too short
    return out

def main():
    p = argparse.ArgumentParser()
    p.add_argument("audio", type=Path)
    p.add_argument("--whisper", default="small", help="small, medium, large-v3-turbo, large-v3")
    p.add_argument("--lang", default=None, help="e.g. en, id. Default: auto-detect")
    p.add_argument("--out-lang", default="English", help="language for titles and notes")
    a = p.parse_args()

    tsv = a.audio.with_suffix(".tsv")
    segs = transcribe(a.audio, a.whisper, a.lang, tsv)
    if not segs:
        sys.exit("Empty transcript. Is there speech in this file?")
    blocks = make_blocks(segs)
    end = segs[-1][0] + 10
    full = " ".join(t for _, t in segs)
    listing = "\n".join(f"[{b['id']}] ({ts(b['start'])}) {b['short']}" for b in blocks)
    target = max(3, min(15, int(end // 360)))          # roughly one chapter per 6 min

    print("Finding chapters...")
    raw = ask(f"""Below is a podcast/video transcript split into numbered blocks.
Each block shows only its first words. Split the episode into about {target} chapters
at points where the topic clearly changes. The first chapter starts at block 0.
For each chapter return the block number where it starts and a title of 2-7 words
in {a.out_lang}. Titles must say what is discussed ("Pricing your first client"),
never generic words like "Discussion", "Part 2" or "Conclusion".

{listing}""", Chapters)
    chapters = clean_chapters(raw, blocks, end)
    if len(chapters) < 3:
        sys.exit(f"Only {len(chapters)} usable chapters. YouTube needs 3. "
                 "Try a bigger model or write them by hand.")

    print("Writing show notes...")
    toc = "\n".join(f"{ts(s)} {t}" for s, t in chapters)
    notes = ask(f"""Write show notes in {a.out_lang} for this episode, using ONLY
facts in the transcript. Do not guess who said what.
- summary: 2-3 plain sentences, no hype words.
- takeaways: 3-6 concrete points a listener learns.
- mentioned: books, tools, websites, companies or people named in the episode,
  written exactly as in the transcript. Empty list if none.

Chapters:
{toc}

Transcript (shortened):
{listing}""", Notes)

    low = full.lower()
    kept = [m for m in notes.mentioned if m.strip() and m.lower() in low]
    dropped = [m for m in notes.mentioned if m not in kept]

    lines = [notes.summary.strip(), "", "In this episode:"]
    lines += [f"- {t.strip()}" for t in notes.takeaways[:6]]
    if kept:
        lines += ["", "Mentioned:"] + [f"- {m}  [ADD LINK]" for m in kept]
    lines += ["", "Chapters:"] + [f"{ts(s)} {t}" for s, t in chapters]

    out = a.audio.with_suffix(".description.txt")
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    a.audio.with_suffix(".transcript.txt").write_text(
        "\n".join(f"[{ts(s)}] {t}" for s, t in segs) + "\n", encoding="utf-8")
    print(f"Wrote {out.name} ({len(chapters)} chapters)")
    if dropped:
        print("Dropped (not found in transcript):", ", ".join(dropped))

if __name__ == "__main__":
    main()

Run it:

python chapters.py in/episode-41.mp3 --lang en
# Indonesian episode, Indonesian titles and notes:
python chapters.py in/episode-42.mp3 --lang id --out-lang Indonesian --whisper medium

You get three files next to the audio:

in/episode-41.tsv               # editable transcript (start seconds <TAB> text)
in/episode-41.transcript.txt    # readable transcript with timestamps
in/episode-41.description.txt   # paste into YouTube / Spotify

A description.txt looks like this:

Two warung owners compare how they handle supplier price increases, and why one
of them stopped offering credit to regular customers.

In this episode:
- Raising prices by small steps instead of one big jump
- Writing supplier prices in a notebook every week to spot increases early
- ...

Mentioned:
- BukuWarung  [ADD LINK]

Chapters:
00:00 Why margins got thinner this year
04:12 Tracking supplier prices weekly
11:47 Raising prices without losing regulars
...

Step 4: Fix the transcript, then re-run (the step that matters)

Whisper is good at ordinary speech and bad at names: guests, brands, local places, product names. “Solvinc” becomes “Solve Inc”, and “BukuWarung” becomes “Buku Warung” or something worse. Those errors end up in your titles and show notes.

Because the transcript is cached in the .tsv, fixing it is cheap:

  1. Open episode-41.tsv in any text editor (or LibreOffice Calc, choosing Tab as the separator).
  2. Search-and-replace the misheard names. Keep a per-client list of the usual suspects.
  3. Run the same command again.

The second run skips transcription (it sees the .tsv) and only redoes the two LLM calls, so it takes a minute or two instead of the full transcription time. Re-run as often as you want. Each run gives slightly different titles, so pick the best version.

Then do a human pass on the output before it goes anywhere:

  • Click each timestamp in the YouTube editor preview and check the chapter actually starts there. A chapter that starts mid-sentence is fine. One that starts in the previous topic isn’t.
  • Rewrite titles that are vague or wrong. Expect to rewrite some of them every time. Spotify recommends keeping chapter titles under 40 characters, and the script allows up to 60, so shorten long ones if the episode goes to Spotify.
  • Read the summary against what you know of the episode. Delete anything you can’t find in the transcript.
  • Replace every [ADD LINK] with the real URL, or delete the line.

Step 5: Batch a whole backlog

New clients often have 20–100 old episodes with no chapters. That’s where the tool earns its keep, because you can leave it running overnight:

for f in in/*.mp3 in/*.m4a; do
  [ -e "$f" ] || continue
  [ -e "${f%.*}.description.txt" ] && continue     # already done
  python chapters.py "$f" --lang en || echo "FAILED: $f" >> failed.txt
done

Then do the human review in the morning, in one sitting.

Choosing models for your hardware

Speed varies hugely between machines, so time one real episode before you quote a client a turnaround.

Whisper modelDownloadUse when
small~480 MBClear English, CPU only
medium~1.5 GBIndonesian or mixed Indonesian–English on CPU
large-v3-turbo~1.6 GBNVIDIA GPU or Apple Silicon. Best balance
large-v3~3 GBMaximum accuracy, slowest

For reference, faster-whisper’s README benchmark transcribes 13 minutes of audio with the small model in 1 min 42 s on an Intel Core i7-12700K (8 threads, int8). That’s a fast desktop CPU, so expect a typical laptop to be slower. Bigger models on older laptops can run slower than real time, which is fine overnight but not while a client waits.

For the LLM, qwen3:8b with a 16K context needs roughly 8–10 GB of free RAM or VRAM. With 8 GB RAM, set LLM = "qwen3:4b-instruct" and CTX = 8192. Use the -instruct tag on purpose: plain qwen3:4b on Ollama now points to the thinking-only 2507 version, which doesn’t fit this script’s think=False setup. The instruct model has no thinking mode, so if Ollama complains about thinking, delete the think=False line (see below). That handles episodes up to about an hour. On a CPU-only laptop, each LLM call takes a few minutes, mostly reading the prompt. On a recent GPU or an M-series Mac it’s usually well under a minute. The script prints token counts and seconds for every call, so you’ll know quickly.

If you switch LLM to a model without a thinking mode and Ollama returns an error about thinking, delete the think=False line.

Honest cost breakdown

ItemOne-offPer 1-hour episode
Software (faster-whisper, Ollama, Qwen3)$0$0
Your setup time~45 min—
Machine time (transcribe + 2 LLM calls)—~10–60 min, unattended, depending on hardware
Electricity—A few cents at most for a laptop
Your review and fixes—15–25 min
Optional: Groq transcription instead of local—$0.04

The real cost is your review time. Once the per-client name list is set up, plan on about 20 minutes per hour of audio, against an hour or more doing it by hand.

Turning it into money

None of these numbers are promises. They’re a way to think about pricing.

1. Chapters + show notes as a per-episode service. Your customers are podcasters and YouTubers who publish long-form content regularly: business interviews, kajian channels, church sermon uploads, coaching webinars, online class recordings. For each episode you deliver a ready-to-paste description, a transcript and chapters. Price it from your review time, not from the $0 software. If review takes 20 minutes and you want to earn Rp150,000 an hour, a one-hour episode works out to roughly Rp50,000–75,000 once you add revisions and messaging. Sell monthly bundles (4 or 8 episodes) so you aren’t chasing one-off payments.

2. Backlog clean-up. “I’ll add chapters and proper descriptions to your 40 most-watched videos” is a fixed-price project the client can see on their own channel. Overnight batching makes it practical. Agree a price per episode up front, and ask whether the client wants to give you a Studio role (Editor or Manager) or would rather paste the descriptions in themselves.

3. Add-on for existing editors. If you already edit podcasts or videos, add chapters and notes as an upsell. You already have the file, so the extra effort is the 20-minute review.

4. Freelance marketplaces. Search Fiverr and Upwork for “podcast show notes” and “YouTube timestamps”. Look at what sellers with reviews charge and how fast they deliver. This is a crowded category with cheap human-written competition, so compete on turnaround, language (Indonesian, bilingual episodes) and consistent quality. Don’t try to win on price.

How to get the first client: take one of their recent episodes, make the description, and send it: “I made chapters for your last episode, here’s the file, paste it in if you like it.” A finished sample of their own content works much better than a portfolio.

The privacy pitch is real. Everything runs locally. Unreleased episodes, paid course recordings and internal company webinars never go to a third-party AI service. For some clients that’s the deciding factor.

Reality check: what does NOT work

Shipping without review. The model sometimes puts a chapter one block (45 seconds) too early or late, writes a title that sounds right but describes the wrong segment, or summarises what it expected the episode to say. The Python checks catch invalid timestamps and invented names in the “mentioned” list. They can’t catch a wrong summary. Only your review can.

Ignoring YouTube’s free automatic chapters. YouTube already generates chapters for many videos at no cost. If its automatic chapters are good enough for a channel, the client doesn’t need you for chapters alone. Spotify for Creators can also generate automatic chapters, though Spotify says that is English-only for now and not available to every show. Your value is better titles, correct names, show notes, a transcript, and chapters for non-English episodes. Sell the package, not “timestamps”.

Asking who said what. Whisper doesn’t identify speakers. The transcript is one stream of text with no names. If the show notes say “Andi explains…”, the model guessed. The prompt tells it not to, but check anyway. If a client needs speaker labels, that’s a different tool (speaker diarization, for example pyannote) and a lot more work.

Very long episodes with default settings. A 3-hour livestream produces a shortened transcript bigger than a 16K context. When the prompt overflows, Ollama quietly drops the beginning, which is where your instructions are, and the output turns to nonsense. Raise CTX (if you have the RAM) or raise BLOCK_SECONDS to 90 for anything over two hours.

Music, intros and noisy recordings. Long music intros, background music under speech, or bad phone-call audio give Whisper trouble. It may hallucinate text over music or repeat one phrase over and over. vad_filter=True helps by skipping non-speech, but listen to anything that looks odd in the TSV.

Too many chapters. Twenty chapters in a 40-minute video is noise. The script aims for roughly one per six minutes and enforces a 90-second minimum. Some shows (Q&A episodes, listener questions) really do need more, so edit those by hand.

Using the tool on content you don’t have rights to. Making chapters for a client’s own episodes is fine. Downloading other people’s videos to transcribe and repost them is not, and it breaks YouTube’s terms.

Expecting this to be passive income. It’s a service. The software is free, but the money comes from a reliable weekly turnaround and careful review. Clients stay because their episodes go out on time with correct names, not because you use AI.

Final checklist

  • Ollama installed, qwen3:8b (or qwen3:4b-instruct) pulled, ollama run answers
  • faster-whisper with av<19 installed in a virtualenv
  • Timed one real episode on your machine end to end
  • Per-client list of names to fix in the .tsv
  • Chapters checked in YouTube’s preview: 00:00 first, 3+ chapters, every timestamp lands on the right topic
  • Every [ADD LINK] replaced or deleted
  • Summary and takeaways read against the transcript before delivery
  • Price based on your review time, sold as a monthly bundle