Skip to content

Architecture

Kenkui uses a src layout and a functional-core/imperative-shell design. Public source/pipeline/result/event values are immutable. EPUB parsing, normalization, selection, and semantic planning are kept separate from filesystem, process, provider, cache, FFmpeg, callback, and publication effects. Internal plans and binding factories are deliberately not public APIs.

Deterministic source and planning core

EPUB spine order is authoritative. Visible text extraction excludes active and hidden content, inserts semantic block boundaries, then applies versioned nfc-space-newline-v1 normalization: CR/CRLF become LF, designated Unicode spaces become ordinary spaces, Unicode is NFC-normalized, horizontal runs are collapsed per line, surrounding space is removed, and runs above two newlines are reduced to two. Case and punctuation are preserved. Character counts refer to this exact normalized string.

The pure planner consumes an immutable inspection, exact source-bytes SHA-256, resolved voice metadata, model revision, and ordered intent. It builds one flat canonical grid from paragraph, line, sentence, phrase, quote, and emphasis boundaries, then derives immutable structural ranges and gap reasons from those leaves. Planning, attribution, selection, and tuning consume that shared grid; they do not rescan quotes, blocks, or lines.

The grid-v1 packer transforms spoken-form regions before measuring them and packs the largest fitting paragraph, line, sentence, then phrase ranges without crossing speaker, scoped-pronunciation, explicit-silence, or enabled-gap cuts. Every synthesis segment contains at most 1,000 spoken characters. Only one over-budget phrase enters the emergency splitter, which prefers punctuation or hyphens, then whitespace, and finally a hard token cut. Emergency provenance is retained for tests and quality metrics. Whitespace-only ranges are never sent to the engine; bounded redistribution preserves adjacent speech, silence, and canonical coverage wherever synthesizable output can represent them. The planner emits schema versions, content hashes, stable segment identities, resolved metadata, and a canonical semantic fingerprint. Canonical JSON key ordering and bounded UTF-8 hashing make equivalent semantic inputs produce the same plan regardless of output path, callback, worker count, cache location, or run ID. The source file and resolved voice/model inputs—not any cached row—remain the authority for every run.

Coordinator and spawned isolation

The coordinator is the only owner of planning, ordering, event callbacks, assembly, and atomic publication. It does not construct or call a synthesis engine. Rendering uses a bounded pool of children created from Python's spawn multiprocessing context, even where fork exists. Each worker receives one frozen pickle-safe engine specification and a bounded static task batch, constructs one engine, and reuses it serially for that batch. The parent never constructs an engine.

Segments are worker/cache units, but public progress and output metadata remain semantic chapter units. As each chapter completes, its ordered segment PCM is spilled to a private part file in the run workspace and leaves memory, so a run holds at most one chapter of samples rather than a whole book. Only metadata -- identities, frame counts, durations -- travels on to assembly, which concatenates the parts in one linear pass and aggregates exact frames into one M4B marker per selected chapter. Parts are untrusted like any other worker output: each must be a regular file whose size matches its chapter's metadata exactly.

workers="auto" reserves two CPUs for the rest of the system and is bounded by chapter count and a hard cap of sixteen; the ceiling is memory, because each worker copies a private model snapshot and holds its own model instance. The scheduler bounds combined live and completed-but-not-emitted work, validates per-segment, per-chapter, and whole-run PCM budgets, accepts completion out of order, and emits results/events strictly in plan order.

Worker audio never crosses a multiprocessing pipe. A worker writes one versioned, bounded header and raw PCM to a private result path using sibling-temp plus atomic rename, then waits for the parent to remove that file as an acknowledgement before advancing. The parent validates containment, type, no-follow/link/identity state, size, primitive-only metadata, task identity, and audio invariants before bounded reads and immediate removal, while the reusable worker may remain alive. Final results are withheld until workers have exited and cleaned their engines. Startup, timeout, malformed result, provider, callback, and cancellation paths use bounded terminate grace, kill escalation, and final join; no unbounded join is allowed.

Private cache semantics and ownership

Segment PCM caching is a private execution optimization, never a public pipeline parameter or source of semantic truth. Only internal ExecutionBindings can own a cache directory. Public callers cannot choose its location, issue SQL, select a schema, or trust it instead of parsing/planning current input. No cache object or SQLite connection is retained or sent to a child.

The owner-only root contains cache.sqlite3 and content-addressed PCM sidecars under payloads/. Root, payload directory, database, locks, temporary files, and sidecars are checked without following links. Unsafe owner, type, mode, identity, or hardlink state disables/bypasses caching without touching an external target. Payload operations are descriptor-relative and private modes are enforced only through validated descriptors.

Keys contain synthesis semantics only: cache/PCM contract versions, segment identity and text digest, voice content and rights identities, model/engine PCM configuration, sample rate, and channels. Worker count, output/cache paths, callbacks, and run IDs are excluded. Unknown schemas are never treated as compatible. Hits are fully hashed/validated before use and merged with misses in plan order. Payloads are bounded, hashed while written, fsynced, atomically published, then transactionally referenced.

grid-v1 deliberately replaced both tts-chunks-v4 and tts-chunks-v5 in segment identity, so legacy PCM entries are clean misses. The migration neither deletes nor rewrites those rows or payloads. Cache pruning remains an explicit operator/publication choice; check free space before a large library rerender.

Caching is offline and fail-open for rendering: missing/corrupt/truncated or hash-mismatched payloads, malformed metadata, unknown schema, lock timeout, unwritable storage, or SQLite failure become misses. Such failures cannot turn an otherwise correct uncached render into failure. This does not mean unsafe data is accepted; it means the cache is bypassed.

Retention is per-book and explicit. A successful publication clears the published book's rows, runs, book, and unreferenced payloads, so a finished book stops holding its rendered audio on disk; write(..., keep_audio_cache= True) opts out. Run rows keep a finite bound of 1,024. Each maintenance call scans at most 64 orphan names. Payload reclamation takes the digest lock and rechecks references under BEGIN IMMEDIATE immediately before unlinking. Cleanup removes only validated old regular temporaries and unreferenced regular, single-link sidecars; poisoned/unsafe nodes are left untouched. Book/voice/run metadata is pruned in bounded batches.

FFmpeg shell and publication

The native shell discovers and capability-checks ffmpeg/ffprobe, invokes them with argv (not a shell), bounds diagnostics, builds AAC MP4/M4B metadata/chapters and optional source cover, probes semantic output, and performs a full decode. Only a validated candidate is atomically published. Public errors are stable and sanitized; local paths and subprocess/provider diagnostics stay private.

Every callback exception before publication commit becomes callback_failed, terminates active work through coordinator unwinding, removes the workspace, and publishes nothing. Publication StageStarted and StageProgress are fatal pre-commit callbacks followed by cancellation checks. The final private snapshot is then validated, cancellation is checked, and no callback runs between that point and atomic commit. A successful commit is followed by best-effort publication StageCompleted and terminal Completed; observer errors are only generic-log records and cannot revoke durable success. Publish failure emits no publication completion. Logs contain only generic stage/cache categories and terminal stable codes: cache keys/ordinals, source/output paths, text, voice/model paths, and provider diagnostics are excluded.

Attribution as a resolved input

Character and series records live in _characters.models, independently of SQLite. _characters.series handles identity matching and merging; _characters.continuity derives series pins, prior voice usage, and diagnostics from supplied records. These functions perform no I/O and return frozen values. The imperative functions in _resolution own reading records, resolving resources, reporting decisions, and persisting the resulting series through _characters.store. The fluent Pipeline delegates to that implementation; its methods record intent and provide convenient entry points into the work.

Character discovery is separately materializable through resolve(until="characters"). It retains a CharacterRoster and the exact source snapshot without binding voices or attributing quotes. Pure validation in _characters.review canonicalizes replacements supplied through with_characters(). Adding attribution or casting preserves that roster; changing the source selection invalidates it.

Attribution accepts a supplied roster and skips discovery. Its cache identity includes the supplied roster and review status, keeping edited input separate from automatically derived records. Reviewed known genders survive later dialogue inference; unspecified genders remain inferable. Roster aliases are included in prompts for supplied rosters. The automatic path retains its existing prompts and cache identity.

Character inference and dialogue attribution call a language model, which the pure planner must not do. They are resolved in the shell and handed to the planner as finished values, exactly as voice metadata already is: the planner receives a roster, speaker spans, and a cast, and reaches neither a model nor a store.

resolve() and write() share one resolution implementation. Resolution hashes and parses the same private source snapshot using _source, which also supplies rendering's bounded snapshot function. The returned pipeline retains frozen source and casting inspection values alongside private rendering bindings. inspect() exposes the values; bindings stay internal.

Synthesis, metadata, pronunciation, and pauses preserve a resolved checkpoint because attribution uses canonical text. Other intent changes discard it. Before rendering, the coordinator compares its private source snapshot with the resolved source hash and rejects mismatches as source_changed. An explicit resolve() refreshes a cast checkpoint whose source bytes have changed. When a roster checkpoint is present, refresh discovery with resolve(until="characters") and review the new input first. This keeps both the reviewable checkpoint and the render tied to exact input.

All model traffic happens in the parent process, beside the network voice provisioning already performs. The render path is unchanged: spawned workers install a socket-denying audit hook and set the offline environment variables, so no credential and no request can reach them.

A book narrated in the first person names its narrator during roster inference, and attribution marks them in the roster it sends. Their id is an ordinary character id, so their spoken lines and their narration differ only in which voice casting gives them. Character casting prefers voices distinct from narration, but shares the available voices when no distinct voice exists. A single loaded voice can therefore narrate every part of the book.

Attribution also handles a speaker the text identifies by role but never names, such as a guard, innkeeper, or first man. The model returns a short role word; resolution scopes that identity to the chapter and turns it into an ordinary character profile for casting. Two role speakers in one scene therefore receive different voices, while a guard in a later chapter may reuse one. A role that cannot be identified remains unattributed rather than becoming a guessed character.

Roster identity is deliberately conservative. Clear aliases fold into one character, but ambiguous short names and conflicting honorifics do not. A duplicate voice is locally audible; assigning two distinct people one voice is a more damaging error.

Grid-folded segmentation

Attribution produces speaker spans in canonical grid coordinates. Effective speaker changes become mandatory packer cuts; whitespace-only attribution spans are assigned to adjacent effective speech because they cannot produce valid engine input. Structural gaps remain pure grid reasons until pause policy turns them into durations after packing.

A chapter with no attributed dialogue is one narration span. Speaker and voice enter segment identity for attributed speech, while every segment contains the single grid-v1 chunking input. Pause tier names and structure schema do not enter segment identity; silence never reaches synthesis, and a duration-only retune reuses unchanged PCM.

Canonical text and the spoken form

Normalized chapter text stays the single authority for billing, inspection, chapter identity, and attribution offsets. When a caller asks for it, a separate versioned spoken-form-v1 stage derives the string the engine actually speaks, and nothing else consumes that string. This is what lets normalized_speech_characters keep describing the book the caller supplied while synthesized_characters follows the expansion.

Segment compilation establishes canonical semantic regions, speaks each region independently, retains exact canonical-to-spoken replacement maps, and then packs by spoken length. The mappings keep selection and source provenance in canonical coordinates even when a number or pronunciation expands or contracts text. The spoken-form schema, number tier, and lexicon identity enter segment identity whenever that stage is active. The same discipline governs the plan fingerprint: an optional key is absent, never null, when its feature is not in play.

Silence as a gap between segments

Pauses are generated silence, not prosody hints. Between any two adjacent segments there is exactly one gap, and a gap may have several reasons; its duration is the maximum of them, never the sum, so a chapter boundary meeting a chapter title's leading pause cannot compound. Modelling gaps rather than per-segment durations makes that impossible by construction.

Each gap folds into the preceding segment's trailing pad, so the inter-chapter gap is counted in the earlier chapter and skipping forward lands on speech. The coordinator pads SegmentAudio only after the raw worker output has been validated; because byte_count is derived from frame_count, that single adjustment keeps the assembler's part-size check, the chapter markers, and the reported duration in agreement without changing the FFmpeg shell at all. Silence never reaches a worker or the cache, so pause durations stay out of segment identity and retuning them costs no re-synthesis.