Architecture#
What the pieces are and how a night flows through them. This is the map; design.md is the argument for why the map looks like this, and the ADRs record the turns that were taken.
The shape of it#
somnia renders public-domain books to audio itself, and that one decision pays for everything else. Because the renderer placed every sentence, it knows exactly which span of audio each sentence occupies — so the text/audio index is free, and a question at 2am can be answered with a timestamp rather than a guess.
Three things run. The two on the box share one sqlite file and the audio on disk; the third is on the phone and reaches both over HTTP:
the renderer (
somnia worker) — a supervisor that takes one book at a time off the queue and spawns a child to turn a Gutenberg id into per-chapter m4a files plus a semantic index, streaming chapter by chapterthe server (
somnia serve) — serves the page, the audio, the catalog, the queue and the agentthe page (the installed PWA) — plays the book, picks the next one, and carries the conversation
The renderer is a separate unit from the server on purpose, and ADR 5 has the argument: restarting the page’s process, which is what a deploy is, must not kill a render, and Kokoro must never compete with a seek for two cores.
flowchart LR
subgraph Phone["Phone — installed PWA"]
ms["Media Session<br>lock screen, Bluetooth"] --- page["app.js<br>one audio element,<br>one global-ms timeline"]
end
ts["tailscale serve<br>TLS, and the only way in"]
subgraph Box["nuc2 — nothing public"]
serve["somnia serve<br>Player fast lane, Conversation<br>agent lane, Queue lane"]
worker["somnia worker<br>supervisor, no torch"]
child["somnia worker --once<br>one book, under a lease"]
db[("somnia.db<br>where the units coordinate —<br>no lock file, no socket")]
files[/"library dir<br>.m4a per chapter"/]
joins[/"data dir<br>chapters joined,<br>one .m4a per version"/]
end
gut["Project Gutenberg /<br>PG Australia"]
api["Anthropic API"]
page <--> ts
ts --> serve
worker -->|"sees one waiting,<br>spawns"| child
gut -->|"book HTML"| child
serve --- db
worker --- db
child --- db
child --> files
serve --> files
files -->|"joined on the first ask,<br>-c copy"| joins
serve --> joins
serve -->|"the agent lane only"| api
Nothing leaves the box but the model calls and the book being fetched, and the player waits on neither: the sound comes off the disk beside it and where they have got to is a row in the file the two units share. There is no other server for a night to be held up by.
Asking for a book — from the panel or by voice — writes a queue row and nothing else, so the answer comes back at once and the render happens in the other unit entirely, minutes or hours later. That is how a book gets added at 2am without anything waiting for it — and how two books asked for a minute apart cannot both be rendering, because the worker claims one at a time and the claim is a single guarded UPDATE.
There is no login anywhere. Reachability is the authentication: the server
binds to localhost, and only tailscale serve can reach the port. The box it
runs on joins the tailnet tagged, and the ACL never lists that tag as a
source, so it can be reached by personal devices and can never initiate a
connection into the tailnet.
One clock#
Every timestamp in somnia — index hit, chapter mark, saved position, what the page shows — is global milliseconds from the start of the book. It is the render clock, counted in PCM samples before encoding, and never what a decoder reports: summing durations off the files drifts tens of milliseconds a chapter and is a second out by chapter forty.
The page converts between that clock and the clock of whatever the audio element
is actually holding, in four small functions, and that is the only place the
split exists. When the element holds the whole book joined into one file — the
ordinary case, and the reason a chapter boundary no longer takes the lock screen
down (ADR 7) — the
conversion is the identity, measured on the real forty-nine chapter book rather
than assumed. When it holds a single chapter, which is the fallback, the
conversion is (chapter index, seconds into this file). Either way “back a bit”
from the start of chapter five lands in chapter four, the way a listener means
it.
Making a book#
Asking for a book writes a row into queue and returns.
Two things ask. The agent, in a sentence, when somebody says a title out loud;
and the page’s own panel, which searches the catalog on disk, offers a voice,
and posts a gid. Both end in the same queue.submit and both get the same
sentence back, so the page and the voice cannot disagree about what just
happened.
The worker claims the oldest waiting row — while nothing else holds a live lease — and spawns a child that streams it: each chapter is written into the library folder and indexed the moment it finishes, so listening can start minutes after picking a book while the rest arrives overnight.
flowchart TD
Q[("queue row<br>claimed under a lease")] --> A["fetch_book — the HTML edition,<br>Gutenberg's or Australia's"]
A --> B["parse_book_html<br>chapters of paragraphs"]
B --> T["books.chapters_total written<br>the only honest denominator"]
T --> R{"any chapters rows<br>for this book already?"}
R -->|"yes"| S["resume at the first index with no row,<br>carrying the global clock on"]
R -->|"no"| C
S --> C
subgraph loop["for each chapter — every write below is per chapter"]
C["sentences — pysbd"] --> P{"beat: still ours,<br>and not cancelled?"}
P -->|"no"| X["stop here — this chapter<br>leaves no trace at all"]
P -->|"yes"| D["engine.render — Kokoro-82M<br>one sentence at a time"]
D --> E["ChapterAudio<br>+120ms between sentences,<br>+500ms between paragraphs,<br>clock in samples, so every<br>TimedSentence is exact"]
E --> F["ffmpeg → 'NNN - Title.m4a'<br>AAC 64k, faststart<br>— the chapter's first write of any kind"]
F --> H["windows — 3 sentences, stride 2<br>Embedder — e5-small-v2, 384-dim"]
H --> J[("chunks + vec_chunks")]
J --> K[("chapters row: idx, start_ms,<br>end_ms, audio_file<br>books.total_ms bumped")]
end
K --> N["listenable now — the page re-asks<br>for the manifest while status is 'rendering'"]
Rendering per sentence rather than per chunk or per paragraph is what makes
the timestamps exact by construction, and it is why the engine is swappable:
anything that can render one sentence satisfies the TTSEngine protocol. It is
also where a stop is allowed to happen, and nowhere else: a chapter is encoded
on the last line of rendering it, so a render stopped between sentences leaves
no m4a, no chunks and no chapters row for the chapter it was in — which is what
makes the window between indexing a chapter and writing its row unreachable,
and therefore what makes cancelling and resuming safe at all.
Never re-render a book with a different engine or voice. Durations change, and every timestamp — every index entry, every chapter mark, and the position they went to sleep at — is invalidated. Which voice a book gets is therefore settled once, on the queue row, at the moment somebody asks for it; the renderer prefers that, then the voice already on the book, then its own configuration, so neither a deploy nor a resume can change a narrator half way through.
Each chapter opens by saying what it is — Chapter 12. The Invisible Man — and that line is built from the heading rather than being the heading: roman numerals are converted, capitals are taken out, and a heading with no number in it is spoken without one, because somnia’s chapter index counts what the parser found and is not what the book calls the chapter. It is rendered before the first sentence’s clock is read, so it costs the timestamps nothing, and it is deliberately not indexed — it is not the book’s text.
What is stored#
One sqlite file holds all of it: the catalog for browsing, the books and their chapter timelines, the indexed text windows, and the vectors.
erDiagram
catalog {
text gid "FTS5, ~80k rows"
text title
text authors
}
catalog_urls {
int gid PK "only where the address is not computable"
text url "Project Gutenberg Australia"
}
books {
int gid PK
text title "the catalog's name, until somebody says otherwise"
text authors "same, and renamed with it"
text voice "which narrator read it; never changed mid-book"
text status "pending, rendering, done"
int total_ms "grows while rendering"
int chapters_total "how many it HAS; 0 = unknown"
int position_ms "nullable: never started. Also the spoiler guard's line"
int position_seq "agent moves only"
text position_at "last report taken, or opened; newest is last_gid"
text created_at "brought in; the Workshop's other sort"
text finished_at "nullable: the reader is done. Not status, which is the render's"
text renamed_at "nullable: a person has had an opinion, so ingest stops overwriting the name"
}
queue {
int id PK
int gid "one live row per book"
text state "queued, rendering, done, cancelled, failed"
int cancel "asked to stop"
text lease "uuid4 of the renderer, never a pid"
int pid "which process claimed it"
text beat_at "liveness, read at read time"
text chapter_at "a second clock: long chapter vs dead process"
text voice "the voice asked for, settled here; empty means the renderer's own"
int attempts "bounded at three"
text error "one plain sentence"
}
chapters {
int book_gid PK, FK
int idx PK
text title
int start_ms "global"
int end_ms "global"
text audio_file "never sent to the page"
}
chunks {
int id PK
int book_gid FK
int chapter_idx
int start_ms "global"
int end_ms "global"
text text "3-sentence window"
}
vec_chunks {
int rowid PK "= chunks.id"
blob embedding "float[384], sqlite-vec"
}
books ||--o{ chapters : "timeline"
books ||--o{ chunks : "index"
chunks ||--|| vec_chunks : "rowid"
books ||--o{ queue : "every time it was asked for"
That is what somnia reads and writes, which is not quite the same list as what
PRAGMA table_info would say. A database written before ADR 10 also carries
heard_to_ms, and nothing removes it: a column nobody selects costs a few bytes
a row, where DROP COLUMN costs a rewrite of the one table somnia cannot lose.
It is not drawn above because a database made today has no such column, and a
diagram that showed one would be wrong about every new install to be right about
the old ones.
queue has no foreign key to books, and that is not an oversight: a book is
asked for before it exists, and its books row is not written until the parse
finishes. A partial unique index on gid over the waiting and rendering states
is what keeps one live job per book — enforced by the database rather than by a
check that two presses a millisecond apart would both pass.
A book is a few thousand windows, so brute-force exact nearest-neighbour search is milliseconds. No database server, no shared infrastructure. The file is in WAL mode because several writers exist — an agent turn, the player, the queue panel, the worker, and a render running in another process entirely — and a reader must never block on any of them. That the render is in another process is also why the queue lives in this file rather than in a lock file or a socket: sqlite is the one thing every process in somnia already shares, so a render’s progress and the request to stop it need no channel of their own.
chunks earns a second job it was not designed for: because its rows are
overlapping windows taken every second sentence, a window start is always a
sentence start. That is how the page’s long rewind lands on the beginning of a
sentence rather than the middle of a clause.
A night#
Serving audio and answering questions are separate lanes. A model turn blocks
for tens of seconds on the API and on the embedder, and a seek must never queue
behind it — a dead player while a question is being answered is exactly the
moment the phone gets put down. So Player has its own sqlite connection and
its own lock, and shares nothing with a conversation but the file on disk.
The queue panel is a third lane in the same shape, and it is the same argument a second time: a submit button that sits there for twenty seconds because somebody happened to ask a question is exactly the dead control this arrangement exists to refuse.
sequenceDiagram
autonumber
participant P as Page
participant PL as Player
participant AG as Agent
participant DB as somnia.db
participant AN as Anthropic
Note over P: cold launch
P->>PL: GET /api/books
PL-->>P: last_gid
P->>PL: GET /api/book/{gid}
PL-->>P: timeline, position
P->>PL: GET /api/stream/{gid}/{n}, Range
Note over P,PL: one file for the whole book so far —<br>a chapter at a time is the fallback
loop every 15s, and at every jump and stop
P->>PL: position_ms, seq
PL->>DB: UPDATE ... WHERE position_seq = ?
PL-->>P: accepted, seq
end
Note over P,AN: "where does the horse die?"
P->>AG: POST /api/ask
AG->>AN: tool runner turn
AN->>AG: find_passage
AG->>DB: search, whole book
AN->>AG: offer_positions
AG->>DB: position_seq + 1, or a list to the page
AG-->>P: reply, and where to go
P->>P: jump there and play
alt that reply never arrives
P->>PL: next report, stale seq
PL-->>P: 200 refused — go here instead
P->>P: jump anyway
end
The asymmetry in step 7 is the whole protocol. position_seq counts agent
moves and nothing else; the page’s own reports leave it alone. So a report
carrying a stale count can only mean the agent moved the book, and the refusal
that comes back is also where to go instead. That is why a refusal is a 200
with a body rather than a 409: the last report of the night is a sendBeacon,
and a beacon can read nothing else.
Only the player and an agent move may write those four columns; what ingest may touch is in design.md.
How far a question may see#
The spoiler guard is bounded by where the book is, and by nothing else. One column, written by the page’s own reports and by an agent move, read by every search and by the one route that hands back book text.
flowchart TD
R["a report arrives: position_ms"] --> U["the line moves to it — forwards or back"]
U --> F["find_passage reads the whole book"]
U --> C["recall reads as far as the line + 60 seconds"]
F --> H{"is this hit past the line?"}
H -- yes --> T["the model gets a time and an id —<br>no words, no chapter"]
H -- no --> W["the model gets the passage whole"]
C --> A["answer from what they could have heard,<br>and offer nothing past it"]
It was a high-water mark until ADR 10, raised only over ground the sound had really covered — which every report had to prove by counting its own playback off the media clock, against a wall-clock ceiling and five seconds of slack. It could not be made to work: a report standing further past the mark than it had playback to show for could not be credited, and every report after a forward skip is such a report, so one press of +30 stopped the mark for the rest of the book. design.md argues what replaced it and what that gives up.
The line bounds what is said, not what may be found. The agent may answer a
question about a book out of what it already knows of it, as far as that line
and no further (ADR 6) —
and it is never handed the words of a passage past it, so there is nothing there
to be careless with (ADR 11).
The question tool, recall, is the one that stops at the line when it reads,
because prose has no press in front of it; it also marks the turn so that
offer_positions refuses — a question must not cost the listener their place.
What the page has to survive#
Three things nothing absorbs on the page’s behalf, and there is nothing else left to absorb them. All three end the same way if unhandled — silence, under a notification that says paused — and none can be seen without unlocking the phone, so each also says what is happening on the status line.
What happens |
What the page does |
|---|---|
The book grows while it is playing |
Re-asks for the manifest while |
The tailnet drops for a few seconds |
Reassigns |
The page is discarded |
The conversation is meant to die — it is keyed in |
Most of the transport is not on the page at all. With the screen off the book is driven from the lock screen, the notification shade and whatever is paired over Bluetooth, all arriving through the Media Session API. The scrubber published there is chapter-scale on purpose: a whole-book scrubber on a twelve-hour novel gives three minutes to the pixel, and one sleepy thumb would fling them past the spoiler guard into the ending.
The modules#
Module |
Job |
|---|---|
|
Both libraries’ published lists in one FTS5 table — browsing is offline, with no third-party API |
|
Project Gutenberg Australia’s plain-text index, and the offset ids that keep it out of Gutenberg’s way |
|
Fetch the HTML edition, parse it into chapters of paragraphs |
|
Sentences (pysbd), and the overlapping windows the index is built from |
|
The line a chapter opens with, built from the heading rather than being it |
|
The |
|
The six voices a book may be read in, kept out of |
|
Accumulate a chapter’s samples, track the clock, encode via ffmpeg |
|
e5-small-v2, with the asymmetric |
|
Store chunk embeddings; search one book, optionally bounded |
|
The streaming pipeline that joins all of the above — resumable, stoppable |
|
The ingest queue as pure functions: submit, claim, beat, stop, reconcile |
|
The supervisor and the one-book child that holds the lease |
|
The schema, the migrations, the one-off repairs, WAL, and loading sqlite-vec |
|
Everything the agent can do, as a plain library with no Anthropic import |
|
The system prompt and the tool-runner loop |
|
The fast lane: manifest, audio files, position reports |
|
The daytime verbs: take a book away for good, mark it finished, say what it is called |
|
The chapters joined into one file per version, so a boundary touches nothing |
|
Starlette routes, conversation storage, the mounted page |
|
Where everything is and what it is set to, from the environment |
|
The PWA: |
The HTTP surface#
Everything the page fetches sits under /api/, which is not cosmetic: it is
how the service worker knows what never to cache, and it keeps these routes
ahead of the static mount that would otherwise swallow them.
Which lane a route belongs to is the same split as everywhere else: /api/ask
and /api/forget are the agent’s, the manifest, the audio and the position
reports are the player’s, and the catalog, the voices and the queue are the
third’s. What each one answers is in the HTTP reference;
/ is the page itself, served straight off the installed package.
A chapter or a stream is named by a book and a number, never by a path. A
chapter’s file comes from its chapters row and is refused if it resolves
outside the library directory; a stream’s name is two integers under the data
directory, and it is built only from chapters that passed that same check. That
is the whole traversal defence, and it has to be, because the server has no auth
by design.