← Kevin Yoder
Personal, Family & Home

StoryForge + TTS Studio

Two small coupled home-server apps that do one thing together: generate a story with an LLM, then read it aloud paragraph-by-paragraph in a real neural voice β€” prefetching three audio chunks ahead so playback stays smooth and highlighting the paragraph being spoken.

Running on the home server Built Mar–Apr 2026 Two coupled services 29 Jest tests De-identified runnable clone

βš™ How it works πŸ“Š Results πŸ–Ό Screenshots

01 Overview

Two no-build, vanilla-JS + Express + better-sqlite3 apps that share a single story: write a story with a language model, then hear it read back in a real neural voice. StoryForge (port 8234) is a family-shared story generator with no login and one shared workspace β€” a shared character pool, per-session context, 8 genres, exactly 5 AI plot hooks, and stories streamed token-by-token over SSE, with a read-aloud engine that plays a finished story paragraph-by-paragraph. TTS Studio (port 8221) is the shared voice engine: a dual-backend browser over roughly 54 local Kokoro voices plus about 30 styled "Gemini" voices. The two are deliberately coupled β€” StoryForge does no text-to-speech itself; it proxies its voice and audio calls to TTS Studio over the container network. It is a home tool for one household, not a product.

the story app

StoryForge

Shared character pool, per-session context points, 8 genres and 5 regenerable plot hooks, and SSE-streamed stories on a 300–5,000-word slider. No login β€” one shared family workspace.

the voice engine

TTS Studio

A dual-backend voice browser: ~54 local Kokoro voices grouped by language with flag emojis, plus ~30 styled Gemini voices, drag-drop text input, a live status dot, and a generation history.

the coupling

Read-aloud

StoryForge proxies voices and audio to TTS Studio and reads a finished story aloud, prefetching three chunks ahead with retry/backoff, highlighting the active paragraph, and resuming from a failed chunk.

02 Why I built it

These were built as family-facing consumers of the home server's existing LLM and TTS stack β€” a way for anyone in the house to make up a story and then sit and listen to it. The design note for StoryForge is plain about the scope: a shared story-generation app where any family member can create a session and nothing is gated behind a login. TTS Studio started earlier as a standalone browser for the studio's Kokoro and Gemini voices; StoryForge's read-aloud feature is what turned it into the shared voice backend. Being honest, the shape of the read-aloud engine is the residue of a real failure: after a report that the voices "all sounded the same" on a phone browser β€” that was the browser's built-in Web-Speech, not a real neural voice β€” the TTS path was reworked onto server-side neural TTS, and then onto a three-ahead prefetch pipeline to hide the per-chunk latency.

03 What I built & how it works

One SSE story stream, one proxied voice engine, two neural-TTS backends, and a prefetch pipeline that keeps read-aloud smooth.

Browser β€” StoryForge SPA (vanilla JS) reads over SSE · taps a paragraph to hear it StoryForge Node 20 · Express · :8234 story + character CRUD · SSE gen · TTS proxy proxies /api/voices, /api/tts TTS Studio Node 20 · Express · :8221 dual-backend voices · PCM16 → 44-byte WAV LiteLLM · opt-in :4000 · OpenAI-compatible 'paid-chat' (SSE) · 'gemini-tts' Kokoro TTS · key-free OpenAI-compatible /v1/audio ~54 neural voices · CPU/GPU SQLite (WAL) stories.db · 6 tables (FK cascade) tts-studio.db · generations story SSE 'paid-chat' Kokoro voice Gemini → gemini-tts persist

Fig. 1 β€” StoryForge streams a story from LiteLLM over SSE, then proxies /api/voices and /api/tts to TTS Studio, which synthesizes each chunk on Kokoro (key-free) or on Gemini via LiteLLM and streams the audio back for paragraph-by-paragraph read-aloud. Each app persists to its own WAL SQLite database (StoryForge β†’ stories.db, 6 tables; TTS Studio β†’ tts-studio.db). LiteLLM is opt-in.

  1. Write a story β€” POST /api/sessions/:id/stories streams tokens over SSE from LiteLLM (model paid-chat). The server side is hardened for a flaky LAN: it excludes text/event-stream from compression, aborts on res close, sets keepalive / no-delay / no socket timeout, and sends a 15-second heartbeat so nothing drops during first-token latency. Stopping discards the partial.
  2. Ask for plot hooks β€” a prompt asks for exactly 5 hooks (25 words or fewer, as a JSON array); the reply is fence-stripped and parsed, with a newline-split fallback if the JSON doesn't parse.
  3. Chunk for speech β€” a finished story is split into per-voice chunk sizes (Kokoro 4000, Gemini 600, browser 220 characters) while recording each paragraph's first chunk, so the active paragraph can be highlighted.
  4. Prefetch three ahead β€” a LOOKAHEAD = 3 queue keeps up to three /api/tts fetches in flight ahead of the chunk playing, each wrapped in a retry (3 attempts, linear backoff) under a single AbortController.
  5. Play, highlight, resume β€” each chunk plays through an Audio object URL (revoked on end/error) while the active paragraph is highlighted and auto-scrolled; a failed chunk is recorded and offered as a Resume, and every paragraph has a play button to start from there.
  6. Synthesize β€” TTS Studio picks the backend by voice-name shape: Kokoro voices go to /v1/audio/speech; Gemini voices go to LiteLLM (gemini-tts, modalities:['audio']), whose PCM16 is converted to a hand-built 44-byte WAV. Every generation is written to SQLite.

04 πŸ›  Skills & tech used

Languages
Node 20 / JavaScript (CommonJS)vanilla JS SPA (no build)SQL / SQLite
ML / AI
LiteLLM (OpenAI-compatible)SSE token streaming from an LLMstrict-JSON plot prompt (fence-strip + fallback)PG-13 prompt constraint
Audio / TTS
OpenAI-compatible Kokoro /v1/audio/speechGemini TTS via modalities:['audio']hand-built PCM16 β†’ WAV RIFF headerper-voice chunk sizing3-ahead prefetch pipeline
Frontend
~1,000-line SPA, framework-freeAbortController + retry/backoffobject-URL lifecycleparagraph highlight + auto-scrollWeb-Speech fallback (voiceschanged race fix)drag-drop FileReader inputadaptive status polling
Data
better-sqlite3 (WAL, foreign_keys ON)6-table FK-cascade schemajoin-based session assemblyINSERT OR REPLACE upserts
Infra / Ops
Docker (node:20 alpine/slim)512m mem_limit + healthcheckcontainer-DNS service mesh (TTSTUDIO_URL)Jest + Supertest (mocked LLM, :memory: DB)brainstorm β†’ spec β†’ plan β†’ build

05 Notable challenges & decisions

Small apps, but the interesting parts are at the seams β€” a fragile streaming connection, a product pivot driven by real feedback, and a couple of trade-offs stated honestly.

Streaming

SSE over a home LAN is fragile

Getting token streaming to hold up on the house network took a block of six specific fixes: aborting on res close rather than req, excluding text/event-stream from compression, setting keepalive / no-delay / no socket timeout, disabling proxy buffering, and β€” the clearest lesson β€” sending a 15-second heartbeat comment so a NAT or proxy doesn't drop the connection during the latency before the first token arrives.

Product

A pivot from real feedback

The read-aloud engine's shape is the residue of a real failure. A report that the voices "all sounded the same" on a phone browser β€” that was the browser's built-in Web-Speech β€” drove the move to server-side neural TTS, and the audible gaps between chunks then drove the three-ahead prefetch with retry and resume-from-failure. The feature grew out of a complaint, not a spec.

Convention

Backend chosen by voice-name shape

One dropdown drives two very different backends. TTS Studio decides which one by the shape of the voice name β€” a leading xx_ means a Kokoro voice, anything else is treated as Gemini. It keeps the UI simple, at the cost of coupling correctness to a naming convention rather than real validation, which is a fair thing to be honest about.

Resilience

Degrade instead of break

If the Kokoro server is unreachable, /api/voices reports it offline and the UI shows an offline banner rather than failing; the Gemini voices (through LiteLLM) keep working, and StoryForge's proxy degrades the same way. The status dot polls adaptively β€” slower when the backend is up, faster when it's down.

Honest limits. Both apps are unauthenticated and bound to all interfaces on the LAN β€” not behind any reverse proxy or tunnel β€” so they are home tools, not something to expose as-is. Story generation and the Gemini voices need a language-model endpoint; without one, those paths fail with a clear message while the key-free Kokoro read-aloud still works. A legacy per-story audio-cache route is left wired but effectively dead (zero rows in the live database), kept honestly for reference. And the source uses absolute paths, so it wants its own port or subdomain rather than a subpath proxy.

06 Results

29
Jest tests β€” offline, LLM mocked, :memory: DB
3
TTS chunks prefetched ahead of playback (LOOKAHEAD)
8
story genres Β· exactly 5 AI plot hooks
~84
neural voices β€” β‰ˆ54 Kokoro (live) + ~30 Gemini (catalog)
6
SQLite tables in stories.db (FK cascade)
2
coupled services β€” StoryForge + TTS Studio

Sources: the project dossier (read 2026-08-02), with the test count verified by reading each test file (health 1 + characters 6 + sessions 10 + stories 7 + audio 5 = 29) and both containers confirmed up and healthy on the home server that day. The Kokoro count (~54) is carried from an earlier survey β€” the GPU voice server was offline at the time of this one, so it was not re-counted. Source size is roughly 1,760 lines for StoryForge and 600 for TTS Studio. The live database is real family data and is deliberately never shown or shipped.

07 Screenshots

Captured from the de-identified clone, never the live instance β€” the live database holds real family names and stories, so nothing here is drawn from it; the clone boots with fully fictional characters instead.

The StoryForge interface: a sidebar of story sessions (The Comet's Secret / Sci-Fi, The Whispering Woods / Fairy Tale), a Character Pool link, and a welcome pane inviting a new session.
StoryForge from the verified clone β€” the sidebar holds the seeded fictional sessions; the Character Pool and new-session flow lead into the generate-and-read-aloud loop.
[ TTS Studio ]
flag-grouped Kokoro voice list + styled Gemini voices, a live status dot, and inline play
The dual-backend voice browser β€” Kokoro voices grouped by language with flag emojis, Gemini voices with personality descriptors.
[ docker logs story-generator ]
SSE heartbeats and token flow during a live story stream
The streaming path from the other side: heartbeat comments and tokens as a story generates over SSE.

08 Honest status

This is a pair of modest home tools, and that framing is deliberate. Both containers were built in spring 2026 (TTS Studio on 2026-03-10, StoryForge across 2026-03-31 to 04-02) and have run quietly and continuously on the home server since, verified up and healthy as recently as 2026-08-02. They are unauthenticated and LAN-only by design β€” bound to all interfaces, not behind any reverse proxy or tunnel β€” so they are not something to expose as-is. The story core depends on a language-model endpoint; in the runnable clone that is off by default and fails with a clear message, while the key-free Kokoro read-aloud still works, so the demo stands without any keys. StoryForge is the only service in the wider home studio with an HTTP test suite (29 Jest tests that run offline against a mocked LLM). The one firm rule: the live database is family data β€” real names and stories about real people β€” and is never shipped or shown; the clone ships a fresh, empty database seeded with entirely fictional characters and two original sample stories, so read-aloud is demonstrable with no family exposure.

StoryForge + TTS Studio β€” Build Recipe

Take a bare machine to a running copy of StoryForge + TTS Studio β€” two coupled self-hosted services that together do one distinctive thing: generate a story with an LLM, then read it aloud paragraph-by-paragraph in a real neural voice, with a 3-ahead audio prefetch pipeline, retry/backoff, and the active paragraph highlighted and auto-scrolling. StoryForge (:8234) is a family-style story generator β€” shared character pool, per-session context, 8 genres, a 300–5,000-word slider, exactly 5 AI plot hooks, SSE-streamed stories. TTS Studio (:8221) is a dual-backend voice browser (~54 Kokoro voices + 30 styled Gemini voices). StoryForge does no TTS itself β€” it proxies /api/voices and /api/tts to TTS Studio, which is the whole point of cloning them together.

Status: βœ… Verified 2026-08-02 β€” built from this clone on a Linux Docker host (docker compose up -d --build). All three services came up healthy (story-generator :8234, tts-studio :8221, bundled kokoro-fastapi-cpu); the DB seeded 6 fictional characters (Captain Nova, Pip the Fox, Luna Quill, Bramble, Mira Stoneweaver, Old Salt) + two sample stories, and the key-free Kokoro voice list loaded (/api/voices β†’ 200) with no keys and no GPU. A browser pass showed the StoryForge sessions and the coupling to TTS Studio. All three tiers covered; the prebuilt tarball (docker save storyforge-tts-story-generator storyforge-tts-tts-studio + the pulled kokoro image) is recorded in ../PREBUILT_IMAGES.md.

Sensitive data: none. The live DBs (real family character names + TTS history) are never shipped β€” a synthetic fictional-character seed replaces them; home-LAN voice-service coupling was replaced with the bundled key-free Kokoro service.

Sensitive data β€” read this. StoryForge's real database is family data: 15 real first names and 5 stories written about those people. None of it is in this clone. The shipped stories.db is created empty on first boot and seeded with fully fictional characters (Captain Nova, Pip the Fox, Mira Stoneweaver, Bramble, Old Salt, Luna Quill) and two short original sample stories written for the demo. No TTS history ships (empty on first run). The one rule when reproducing: never copy a running stories.db or tts-studio.db β€” ship empty volumes, which this recipe does.


What it is

Three containers (the first two run by default; the LLM proxy is opt-in):

Service Stack Role Port
kokoro remsky/kokoro-fastapi-cpu (upstream image) Key-free neural TTS backend β€” ~54 voices, CPU-only, no keys, no GPU. OpenAI-compatible /v1/audio/{voices,speech} 8880 (internal)
tts-studio Node 20 / Express + better-sqlite3 Voice browser + TTS API; proxies Kokoro voices, converts Gemini PCM16β†’WAV 8221
story-generator Node 20 / Express + better-sqlite3 StoryForge web app + SSE story generation; proxies voices/TTS to tts-studio; DB seeded with fictional stories on first boot 8234
litellm (opt-in, --profile llm) ghcr.io/berriai/litellm OpenAI-compatible proxy that unlocks live story generation + Gemini voices 4000

Both frontends are hand-built vanilla-JS SPAs (no build step). The read-aloud engine (3-ahead prefetch, retry/backoff, resume-from-failed-chunk, per-paragraph play + highlight) lives in story-generator/public/app.js.

Prerequisites

New machine? See ../SETUP.md. Then, per tier:

Tier You need
1 β€” prebuilt containers (recommended) Docker (see ../SETUP.md) + this bundle's clone/ folder (for the compose file).
2 β€” build from source Same as Tier 1.
3 β€” bare-metal Node 20+ for both apps, and a running Kokoro server (or any OpenAI-compatible TTS endpoint) for read-aloud.

Configuration β€” nothing required

docker compose up needs no .env and no keys. What works at each level:

Setting Unlocks Without it
(nothing) Read-aloud on real Kokoro-CPU voices + browsing the seeded fictional characters/stories β€”
--profile llm + LITELLM_URL/key Live story + plot-hook generation and Gemini voices β€” any OpenAI-compatible endpoint; local Ollama works key-free for story generation Story generation fails with a clear "LLM not configured" message; Gemini voices return a clear "pick a Kokoro voice" message. Everything else works.
LITELLM_MODEL / GEMINI_TTS_MODEL / LITELLM_TTS_MODEL Point the app's model aliases (paid-chat / gemini-tts / best-tts) at whatever your endpoint offers Defaults assume the bundled LiteLLM proxy's aliases
STORY_PORT / TTS_PORT Remap the host ports 8234 / 8221

All documented one line at a time in clone/.env.example.


Tier 1 β€” Run the prebuilt containers (recommended)

Availability: the prebuilt images are available on request β€” they are not published or linked anywhere. Ask Kevin for them, or build from source via Tier 2. (The kokoro image is pulled from its public upstream either way.)

docker load -i storyforge-tts-clone-image.tar   # loads the two first-party images
cd clone
docker compose up -d                            # β†’ http://localhost:8234  (StoryForge)
                                                #   http://localhost:8221  (TTS Studio)

To produce the tarball on a machine that has built the images (Tier 2): docker save clone-story-generator clone-tts-studio -o storyforge-tts-clone-image.tar. The kokoro image is large (several GB β€” voice models baked in) and is pulled from its public registry, not shipped in the tarball.

Tier 2 β€” Build from source

cd clone
docker compose up -d --build                    # β†’ http://localhost:8234 and :8221
curl -s localhost:8234/health                   # β†’ {"status":"ok"}
curl -s localhost:8221/health                   # β†’ {"status":"ok"}

First boot: the kokoro image is pulled and loads its voice models (this is the slow part β€” several GB, a few minutes). Once TTS Studio's status dot goes KOKORO LIVE, open StoryForge β†’ a seeded session (e.g. "The Comet's Secret") β†’ Stories tab β†’ expand a story β†’ πŸ”Š Listen and it reads aloud in a real neural voice, highlighting each paragraph. StoryForge boots with the fictional seed already present; TTS Studio history starts empty.

Tier 3 β€” Bare-metal (two Node apps + a Kokoro server)

cd clone
# TTS Studio (Node 20+) β€” point it at any OpenAI-compatible TTS server
cd tts-studio && npm install --omit=dev
DB_PATH=./data/tts.db AUDIO_DIR=./public/audio \
  KOKORO_API_URL=http://localhost:8880 node server.js        # :8221

# StoryForge (Node 20+, separate shell, from clone/)
cd story-generator && npm install
DB_PATH=./data/stories.db AUDIO_DIR=./data/audio \
  TTSTUDIO_URL=http://localhost:8221 node server.js          # :8234 (seeds on first run)

You still need a Kokoro server for read-aloud β€” the easiest is docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2. On Windows, set the same variables with $env: before node server.js.

Optional extras (all off by default)

  • Live story generation + Gemini voices β€” docker compose --profile llm up -d --build. Put a provider key in .env (OPENAI_API_KEY for paid-chat, GEMINI_API_KEY for gemini-tts) and set LITELLM_URL=http://litellm:4000 + LITELLM_KEY=<any master key>. Then StoryForge's Generate Plot Suggestions and story streaming work, and TTS Studio's Gemini voices synthesize.
  • Local key-free story generation (Ollama) β€” no proxy needed:

bash # in clone/.env LITELLM_URL=http://host.docker.internal:11434 # the app appends /v1/chat/completions LITELLM_KEY=ollama LITELLM_MODEL=llama3.2 # override the 'paid-chat' alias (Ollama covers story generation; Gemini voices still need a real Gemini key. Kokoro read-aloud is unaffected β€” always key-free.)

Verify

curl -s localhost:8234/health                       # StoryForge β†’ {"status":"ok"}
curl -s localhost:8221/health                       # TTS Studio β†’ {"status":"ok"}
curl -s localhost:8221/api/voices | head -c 300     # Kokoro voice list (once kokoro is warm) + 30 Gemini
curl -s localhost:8234/api/sessions                 # the two seeded fictional sessions
# key-free read-aloud (once Kokoro is live) — one chunk through the StoryForge→TTS-Studio proxy:
curl -s -X POST localhost:8234/api/tts -H 'Content-Type: application/json' \
  -d '{"text":"Hello from StoryForge.","voice":"af_bella"}' --output chunk.mp3 && ls -l chunk.mp3

Run StoryForge's tests (offline, zero keys β€” the LLM is mocked, :memory: DB):

cd clone/story-generator && npm install && npm test        # Jest + Supertest β€” 29 tests

What was stripped from the personal version (de-identification + minimalism)

  • Family data β†’ a synthetic seed. The real stories.db (15 real first names, 5 stories about real people) and the tts-studio.db history (which stores every synthesized input) are never shipped. Fresh empty volumes; StoryForge seeds fully fictional characters + two original sample stories on first boot (story-generator/seed.js), so read-aloud works immediately with no family exposure.
  • Home-network coupling (tts-studio/server.js lines 10–14). Hardcoded env defaults pointed at a local (GPU PC) Kokoro server, a home-LAN Asset-Harvester, a LAN-IP public base URL, and a home LiteLLM gateway. All four were replaced with localhost / service-DNS / blank, and wired in docker-compose.yml to the bundled kokoro service.
  • The Asset-Harvester hand-off. That button pushed audio to a companion home-LAN service; it is dropped from the UI and its route is off unless you set ASSET_HARVESTER_URL.
  • The hard LiteLLM coupling. Story generation and Gemini voices now activate only when LITELLM_URL is set (any OpenAI-compatible endpoint; Ollama key-free for stories). The model aliases paid-chat / gemini-tts / best-tts are env-overridable so no personal gateway is assumed.
  • The multi-service home compose (compose/apps.yml, the homeserver network, /mnt/... mounts, the shared LiteLLM gateway + injected keys) β†’ a standalone docker-compose.yml with named volumes, a bridge network, and an opt-in --profile llm.
  • Dev artifacts β€” the .superpowers/ brainstorm droppings and docs/ design specs were not copied (not needed to run).

Known limitations (stated honestly)

  • Personal-LAN tools: no authentication. Both apps are unauthenticated and bind all interfaces. Put them behind a reverse proxy with auth before exposing anything.
  • Kokoro-CPU is real but slow, and the kokoro image is large (several GB β€” voice models baked in), so the first up is a long pull. GPU is not required; if you have one you can point KOKORO_API_URL at a GPU Kokoro instead.
  • Story generation needs an LLM. By design it is off by default and fails with a clear message; the seeded stories exist precisely so read-aloud is demonstrable without one.
  • Gemini voices need a real Gemini key (via LiteLLM). Without it they list in the UI but return a clear "pick a Kokoro voice" error β€” the key-free voice path is Kokoro (and browser Web-Speech).
  • Legacy audio route. StoryForge keeps a per-story /api/stories/:id/audio/:voice cache route (best-tts via /audio/speech) that was already effectively dead upstream (0 rows in the live DB); it is left as honest legacy and is LLM-gated like the rest.
  • Backend is chosen by voice-name shape (xx_name = Kokoro, else Gemini) β€” a deliberate convention, not validation.
  • The containers run as root (as the originals did); hardening is left to the deployer.

Provenance β€” data / model (no training, no GPU required)

Neither app trains anything and no model weights ship in the first-party images. The only bundled model is the upstream remsky/kokoro-fastapi-cpu image (Kokoro-82M voices, CPU), pulled from its public registry. The seeded characters and the two sample stories are original fiction written for this clone β€” no real people, no data from any running instance. Measured facts (from the dossier): StoryForge = 6-table SQLite, 8 genres, 300–5,000-word slider, exactly 5 plot hooks, SSE streaming, 29 Jest tests; read-aloud LOOKAHEAD=3, retry 3Γ— linear backoff, chunk sizes 4000/600/220 (Kokoro/ Gemini/browser). TTS Studio = ~54 Kokoro + 30 Gemini voices, 5,000-char cap, PCM16β†’WAV via a hand-built 44-byte RIFF header.