# Design decisions This records the load-bearing decisions made while designing somnia, and why. It describes what actually got built; the scratch brief it grew out of has been dropped, because a document nothing was checked against had started to read like one that things were. ## The core insight **Generate the audio yourself and text/audio alignment is free.** Because we TTS the book, we know exactly which sentence produced which span of audio. The (text → timestamp) index falls out of the render loop — no forced alignment, no Whisper, no drift. Everything else follows from this. ## Rendering: per sentence, exact timestamps by construction The brief agonised over "render per chunk (audible seams) vs render per paragraph and interpolate (imprecise)". Both were rejected: we render **per sentence** and join with short configurable silences (120ms between sentences, 500ms between paragraphs). Sentence boundaries are natural pause points, TTS flatness suits sleep listening, and every sentence's start/end offset is known exactly because we placed it there. This also makes the TTS engine swappable — any engine that can render one sentence fits the `TTSEngine` protocol. ## Engine choice: Kokoro, benchmarked Benchmarks on the VPS somnia was first deployed to (2 vCPU AMD EPYC 9354P slice): | Engine | Speed | Verdict | |---|---|---| | Kokoro-82M (PyTorch) | ~1.1–1.26× realtime | chosen — preferred voice | | Kokoro-82M (ONNX int8) | ~0.73× realtime | slower than PyTorch; dead end | | Piper (en_GB-alan-medium) | ~18× realtime | fallback if speed ever matters more | Kokoro sounds much better and the owner preferred it decisively. At ~1.15× realtime on that 2 vCPU box the renderer still outruns 1× listening with a thin margin — and that margin is the sentence the ingest queue exists to defend, because two renders at once halve it and the whole of streaming ingest stops working. **One book renders at a time**, in its own systemd unit, which is [ADR 5](decisions/0005-render-one-book-at-a-time.md). If underruns bite in practice: more vCPUs, or a render worker on a faster home machine pushing chapters up — the lease a renderer holds is deliberately process-agnostic, so another machine drops into the same slot without a second queue. Measured again on 2026-08-07 with `scripts/somnia-bench.py` — the same sentence-at-a-time path a render takes: 3.87× on nuc2 and 1.06× on the VPS ([ADR 7](decisions/0007-cross-a-chapter-without-letting-go.md)). Both outrun 1× listening; only nuc2 has a cushion, which is why moving to a slower box reopens ADR 7. Never re-render a book with a different engine/voice: durations change and every timestamp — every index entry, every chapter mark, and the position they went to sleep at — would be invalidated. ## Streaming ingest: pick a book and go Asking for a book writes a row into a queue and answers at once. A separate unit — `somnia worker` — takes the oldest waiting row while nothing else holds a live lease, and spawns one child to render it. That is the whole of the serialisation, and it is a property of the database rather than a convention: the claim is a single guarded `UPDATE ... RETURNING`, so two books asked for a millisecond apart cannot both start. The pipeline emits **one m4a file per chapter** into the library folder as each chapter finishes. A book is many files on one global timeline, so: - listening can start when chapter one is rendered (minutes after picking) - the semantic index grows chapter by chapter, so a book can be asked about while it is still being read — but it is not what keeps the ending back. A book is indexed whole on the evening it renders and listened to over the fortnight after, so from the second night on, the only thing between a question and the last page is the spoiler guard below - per-chapter files are simultaneously the streaming unit, the re-render unit and — when there is no join to be had — the unit the phone fetches over HTTP (a single M4B would defeat all three) - a chapter is also the unit a render can be **stopped and taken up again** at. The render asks between sentences and encodes a chapter on the last line, so a stop leaves no audio, no chunks and no row for the chapter it was in, and starting again picks up at the first chapter that has no row and carries the global clock on from where that one ended. Re-indexing a chapter replaces its passages rather than adding a second copy of every one of them, which is what makes any of that safe All timestamps everywhere are **global milliseconds from book start**, on the render clock — `chapters` rows carry each chapter's global start, so index hits, the saved position and the player all speak it. [Architecture](architecture.md) has the reason that is not what a decoder reports. ## Semantic index - ~3-sentence overlapping windows (size 3, stride 2) — small enough to seek usefully, big enough to be a searchable semantic unit. - Embeddings: `intfloat/e5-small-v2` (384-dim). e5 is asymmetric — "query: "/"passage: " prefixes — which fits conversational 2am queries against narrative prose. - Store: **sqlite-vec in a single sqlite file** alongside FTS5 for the catalog. A book is a few thousand windows; brute-force exact NN is milliseconds. No database server, no shared infrastructure. Known limitation (accepted): concrete events ("the horse dies") search well; atmosphere ("the bit that felt strange") doesn't. Chapter-summary embeddings are a possible future hedge. ## Book discovery: local catalog, no API dependency Project Gutenberg has no official JSON API. Instead of depending on the community Gutendex instance, we import Gutenberg's **official catalog CSV dump** (~20MB, all ~75k books) into sqlite FTS5. Browsing is fully offline and deployment has no third-party API dependency. Refresh with `somnia catalog-update`. A second library sits in the same FTS5 table — Project Gutenberg Australia, which publishes no CSV and no API, only a text index meant for a person to read, and whose ids are offset clear of Gutenberg's so that one integer goes on meaning one book in the queue, the player and every saved position ([ADR 8](decisions/0008-a-second-library-under-the-same-ids.md)). ## Playback: the page is the player, and the position is somnia's own This reverses the original design, which made the Audiobookshelf Android app the player and a move a write to `PATCH /api/me/progress/:id`. It worked, and it ended every move with "now press play again", because ABS has no transport API and the app has no deep link. The argument, and the four ways of pressing that button remotely that we turned down, are in [ADR 3](decisions/0003-play-the-book-in-the-page.md); the losses are there too, offline downloads chief among them. **The page plays the book.** somnia rendered the audio, so serving it is a route resolved from the `chapters` rows and never from a path in the request; Range, `If-Range` and 416 — and therefore seeking — are Starlette's own. What holding one clock costs the page is in [Architecture](architecture.md). What the `