ABChapterize documentationv1.0.0Download

Adding a language to ABChapterize#

So your audiobooks are in a language ABChapterize does not speak yet, and you would like to fix that. Good news: it is a self-contained job. You will touch two or three files, all of them in src\Language\, and you need to know nothing whatsoever about how the rest of the tool works — no audio, no Whisper, no ffmpeg, no threading. If you have written code in any curly-brace language, you can do this.

This guide assumes you know what a regular expression is, roughly. Everything else is spelled out.

Contents

  1. What a "language" consists of
  2. Step 1 — the language class
  3. Step 2 — register it
  4. Step 3 — the number parser
  5. Step 4 — tests
  6. Step 5 — documentation
  7. Building and running
  8. Checklist

1. What a "language" consists of#

ABChapterize finds chapters by listening for the narrator saying something like "Chapter seventeen". To do that in your language it needs two things from you:

  1. Words. What does a chapter announcement sound like? What should the marks be called? Half a dozen short strings. This is 90 % of the value and maybe fifteen minutes of work.
  2. Numbers. How is "seventeen" spelled out in your language? This is the larger job, and it is optional in the sense that the tool still works without it — announcements with digits ("Kapitel 17") and Roman numerals ("Kapitel XVII") are understood in every language, always. Only spelled-out numbers need a parser.

You can ship step 1 alone and add step 2 later. A pull request with just the words is genuinely useful.

Everything lives here:

src\Language\
    ILanguage.cs              <- the interface you implement (read this first)
    LanguageRegistry.cs       <- the one list of all languages (add one line)
    Languages\
        EnglishLanguage.cs    <- copy one of these
        GermanLanguage.cs
        ...
    Parsers\
        INumberWordParser.cs  <- the number interface
        EnglishNumberParser.cs
        ...

Nothing outside src\Language\ knows how many languages exist or what they are called. That is deliberate, and it is why this is a small job.

Which alphabets this works for#

All thirteen languages shipped so far are written in the Latin alphabet, but nothing in the machinery requires that, and it was checked rather than assumed (2026-08-22, build 383). Matching is case-insensitive across the whole of Unicode, the word boundaries the number pattern uses are the Unicode ones, and everything written to a file — chapter titles included — is UTF-8. So a language written in Cyrillic, Greek, Hebrew, Arabic or Hangul is an ordinary addition: write the class, write the parser, add the line, and nothing else is different. Two things to know before you do:

Language codes are not restricted to two letters either. Whisper's own list is not purely ISO 639-1 — Hawaiian is haw and Cantonese is yue — so --lang, the [xx] tag and ILanguage.Code all take two or three letters.


2. Step 1 — the language class#

Say you are adding Romanian (ro). Copy an existing file that is structurally close to your language — GermanLanguage.cs is the simplest one — to src\Language\Languages\RomanianLanguage.cs, and edit it:

// ABChapterize - mark chapter starts in audiobooks using Whisper speech recognition
// Copyright (c) 2026 Jan O. Gretza. Written with Claude (Anthropic).
// MIT license - see the LICENSE file in the repository root.

using ABChapterize.Language.Parsers;

namespace ABChapterize.Language.Languages;

/// <summary>Romanian.</summary>
public sealed class RomanianLanguage : ILanguage
{
    /// <inheritdoc/>
    public string Code => "ro";

    /// <inheritdoc/>
    public string ChapterPhrase => "/(?:^capitol ()|^() capitol|^capitol)/";

    /// <inheritdoc/>
    public string ChapterTitle => "Capitolul";

    /// <inheritdoc/>
    public string PartTitle => "Partea";

    /// <inheritdoc/>
    public string IntroTitle => "Introducere";

    /// <inheritdoc/>
    public string ProloguePhrase => "/prolog/";

    /// <inheritdoc/>
    public string PrologueTitle => "Prolog";

    /// <inheritdoc/>
    public string EpiloguePhrase => "/epilog/";

    /// <inheritdoc/>
    public string EpilogueTitle => "Epilog";

    /// <inheritdoc/>
    public INumberWordParser NumberParser { get; } = new RomanianNumberParser();
}

That is the whole class. A few notes on filling it in.

The three phrases#

ChapterPhrase, ProloguePhrase and EpiloguePhrase are exactly what a user could type after --chapter-phrase on the command line: either a plain word, or a regular expression wrapped in slashes. Use the /regexp/ form — every built-in language does, for consistency.

The chapter phrase has one more thing in it. Every built-in language spells it /(?:^WORD ()|^() WORD|^WORD)/: three alternatives of one phrase, where () stands for a chapter number in your language's own notation and captures it. The first alternative takes the number that follows the word directly ("Capitolul 12"), the second the number that precedes it ("Primul capitol"), and the third is the bare word, where the number is read off whatever stands around it. Copy the shape and change the word; the parts that vary by language are the word itself and the number grammar in step 3.

Keep all three, in that order. The order matters where two of them read the same words differently: on "Der erste Kapitel 5" the second alternative claims "erste Kapitel" — it starts earlier, and the leftmost match wins — and would make that chapter 1. Three transcripts in the reference corpus read exactly like that, none because the narrator said it, all because a short probe window opened with a word Whisper invented. What saves them is that a reading the chapter sequence cannot accept is put aside and the next one tried, so "Kapitel 5" gets its turn; the ordering is what decides which reading is preferred when both would fit.

The ^ on all three says the announcement must be set off from what precedes it — by a pause, or by the recognizer writing it as a transcript segment of its own. Keep it. It is what stops your chapter word becoming a mark every time it turns up in ordinary narration, and on the second alternative it is also what lets an ordinal-first announcement be recognized by its own segment start.

If your word needs a (?:...) for a stem change, as Swedish and Danish do with kapit(?:el|let), write it and think no further: the alternation is multiplied out for you, so those three alternatives become six, one per spelling. That is deliberate — each alternative has to be a single expression with no choice left in it, because the one that finds an announcement is also the one its position is confirmed against later.

Two rules about how they are matched, both of which save you work:

What does need a pattern:

Situation Example Pattern
Whisper drops an accent "capitulo" for "capítulo" cap[íi]tulo
Whisper drops a diacritic "bolum" for "bölüm" b[öo]l[üu]m
The stem itself changes Swedish "kapitel" / "kapitlet" kapit(?:el\|let)
Two accepted spellings "prolog" / "prologue" just prolog — substring

Write any alternation of your own as (?:a|b), not (a|b). A plain (...) is a capturing group, and ABChapterize reads one in a phrase as "the chapter number is here" — which is exactly what the () above is, and what a second one would collide with. A unit test (EveryRegisteredLanguage_HasUsableDefaultPhrases) will fail if you slip.

Do not try to cover a different word your language might use for a chapter ("part", "book", "section"). Being greedy here costs accuracy: a phrase that matches ordinary prose puts marks in the middle of scenes. Pick the one word a narrator actually says, and let users reach for --chapter-phrase for the rest.

The exception, should your language have one: if two different words are genuinely interchangeable as the thing narrators announce — not "one common one and one a publisher occasionally uses", but two you would be equally unsurprised to hear — then the default has to cover both, or half the audiobooks in that language get nothing. Write it as one alternation, /(?:^(?:woord|kapittel) ()|^() (?:woord|kapittel)|^(?:woord|kapittel))/, and the earlier note applies: (?:...), never (...). Test it against prose before you commit to it. The bar is high on purpose, and no built-in language has cleared it so far: all thirteen get by on a single word, and the only alternations among them (Swedish and Danish kapit(?:el|let), Norwegian kapit(?:tel|let)) are two endings of one word rather than two words. Czech bends the shape a different way and still stays within the rule: its bare alternative stops at the stem kapitol where the two number-bearing ones spell the nominative kapitola, which is one word written to two lengths, not two words.

For the prologue and the epilogue, prefer your language's Latin-derived form ("Prolog", "Prólogo", …) over a native near-synonym. Words like German "Vorwort" or Turkish "Önsöz" mean foreword — front matter about the book — whereas a prologue is part of the story, and that is what gets announced.

The four titles#

ChapterTitle is the word marks are named after: with Capitolul, chapters come out as "Capitolul 1", "Capitolul 2", …. Write it the way it should appear in a player — properly capitalized, with all its accents.

PartTitle is only used for a book whose chapters count from one again in every part, where the marks come out as "Partea 2 - Capitolul 1". Pick the word for a structural division of a book, not a synonym of the chapter word — Turkish uses "Bölüm" for a chapter and "Kısım" here, and reusing one for both would produce "Bölüm 2 - Bölüm 1". Two rules follow from how it is read back off a written title: it must be a word the language does not routinely use at the start of a chapter heading, and if it happens to be the beginning of a longer word (Swedish "Del" is also the start of "Delen"), that is fine — a prefix only counts when a non-letter follows it.

IntroTitle names the mark covering whatever comes before the first chapter (a publisher's announcement, a title read aloud). "Intro" is fine if your language borrows it; otherwise use the natural word.

PrologueTitle and EpilogueTitle name those two marks. They are usually the phrase with a capital letter.

NumberParser#

Point it at your parser from step 3. If you are not writing one yet, borrow English's tables through a stub that still declares your code — a test (EveryRegisteredLanguage_CarriesItsOwnNumberParser in NumberWordParserTests.cs) checks that no language is reachable under another language's parser, so handing over new EnglishNumberParser() directly turns the suite red:

public sealed class RomanianNumberParser : INumberWordParser
{
    private static readonly EnglishNumberParser Borrowed = new();

    /// <inheritdoc/>
    public string LanguageCode => "ro";

    /// <inheritdoc/>
    public string DigitOrdinalSuffixPattern => Borrowed.DigitOrdinalSuffixPattern;

    /// <inheritdoc/>
    public string NumberWordPattern => Borrowed.NumberWordPattern;

    /// <inheritdoc/>
    public bool TryParse(IReadOnlyList<string> tokens, out int number, out int consumed)
        => Borrowed.TryParse(tokens, out number, out consumed);
}
public INumberWordParser NumberParser { get; } = new RomanianNumberParser();

Digits and Roman numerals still work that way; spelled-out Romanian numbers will not, until step 3 replaces the borrowed tables.

One thing you do not have to worry about: your language's number words are tried before the Roman-numeral reading, so a word that happens to be spelled like one is safe. French "dix" is ten, even though DIX is also a perfectly good Roman 509.

(Do please come back and replace it. Many audiobooks spell their numbers out.)


3. Step 2 — register it#

Open src\Language\LanguageRegistry.cs and add one line to the All array:

    private static readonly ILanguage[] All =
    [
        new EnglishLanguage(),
        new GermanLanguage(),
        ...
        new DanishLanguage(),
        new RomanianLanguage(),   // <- yours
    ];

That is the entire wiring. The --lang help text, the supported-language list, the number-parser lookup and the localized defaults all read from this array; none of them needs touching.


4. Step 3 — the number parser#

This is the part that takes an evening. Your job: turn the words Whisper wrote ("sedmnáct", "dvacet jedna") into the integers 17 and 21, for every number from 0 to 999, as cardinals ("chapter twenty-one") and as ordinals ("the twenty-first chapter") — audiobooks use both.

Ordinals are allowed to stop short of 999 if your language spells them as words of their own rather than deriving them from the cardinals. Spanish ("vigésimo" has nothing to do with "veinte") stops at 199 and Danish at 100 for exactly that reason: the words above are ones no narrator would say. Go as far as the language stays regular, say where you stopped in the class doc comment, and let the digit ordinals cover the rest.

Create src\Language\Parsers\RomanianNumberParser.cs implementing INumberWordParser. Read INumberWordParser.cs first; it is short and states the contract precisely. Then pick the existing parser whose language works most like yours and adapt it:

Your language builds numbers like… Start from
separate words, "twenty one" EnglishNumberParser.cs
one compound word, units first, "einundzwanzig" GermanNumberParser.cs, DutchNumberParser.cs
one compound word, tens first, "tjugoett" SwedishNumberParser.cs
separate words with a connector, "vinte e um" PortugueseNumberParser.cs
separate words, heavily inflected ordinals PolishNumberParser.cs

Three things to get right, all of which the existing parsers demonstrate:

There is a fourth member, NumberWordPattern: a regex fragment matching one spoken number of your language, which is what the () of a phrase expands to. Build it from the very tables you parse with, using the helpers in NumberWordPatterns.cs — TokenRun for a language that writes a number as separate words, Alt and AnyOf for one that compounds it — so that adding a spelling to a table adds it to the pattern in the same edit. It is allowed, and meant, to be a superset of what your parser accepts: whatever it captures is handed straight back to TryParse, which is the authority on the value. Being generous costs nothing; missing a spelling costs a chapter. NumberWordPatternTests checks the direction that matters, replaying every spelling the reference spellers produce for 0-999 through it.


5. Step 4 — tests#

Run the suite from the repository root:

dotnet test tests\ABChapterize.Tests

Some tests will already cover your work the moment you register the language: they walk LanguageRegistry.Languages and check that every language's phrases compile, that the chapter phrase carries its number group where it should while the prologue and epilogue phrases carry none, and that the titles are non-empty.

Two you should extend by hand:

If you wrote a number parser, add it to the exhaustive round-trip tests as well: ExhaustiveNumberWordTests.cs for cardinals, ExhaustiveOrdinalTests.cs for ordinals, and NumberWordPatternTests.cs for the () expansion. They spell out every number 0–999 with an independent reference speller in Spellers.cs and feed it back through your parser — the grammar is deliberately written twice, so a misunderstanding of it cannot agree with itself. Copy the reference speller closest to your language, then add your language to those three files.

Finally, try it on a real book:

abchapterize --lang ro --dry-run carte.m4b

--dry-run detects everything and writes nothing, so it is safe to run repeatedly. Add --verbose to watch each announcement being recognized.


6. Step 5 — documentation#

Three tables in doc\manual.md, section 7 ("Languages and number recognition"):

README.md's feature list also names the supported languages and counts them ("Thirteen languages of number recognition out of the box …"), with one spelled-out example number each: bump the count, add your language, add a number word. It is the first thing anyone reads, and nothing generates it.

If your language is the first to need some trick — a script issue, a transcription quirk worth warning about — say so in a sentence there.

Add a line to CHANGELOG.md under the unreleased version, written for someone using the tool:

- **Romanian is now understood**: chapter announcements, spoken numbers 0–999 and
  the localized `--lang ro` defaults.

7. Building and running#

You need the .NET SDK (version 10 or newer). From the repository root:

dotnet build                          # compile
dotnet test tests\ABChapterize.Tests  # run the tests
dotnet publish -c Release             # build the actual executable

The publish output lands in bin\publish\win-x64\ (or linux-x64\ with -r linux-x64). You do not need to edit any project file: every .cs under src\ is compiled automatically, so a new file in the right folder is picked up on its own.

Keep the build at zero warnings. Documentation comments are compiler-checked here, so a <see cref="..."/> pointing at something that does not exist is a warning, not a silent typo.


8. Checklist#

Thank you — every language added here is one more shelf of audiobooks that stops being a wall of "Chapter 1" and starts being navigable.