Public-flip audit: env-driven paths, scrub audit-ticket prefixes, terser README

the host bind paths + LAN host pins replaced with env defaults. Repository URLs
→ git.sulkta.com. Audit-changelog scaffolding stripped from inline comments
(technical reasoning preserved). README sheds marketing scaffolding. AI-speak
in load-bearing prompts/SOULs left alone — that IS the product.
This commit is contained in:
Sulkta 2026-05-27 11:42:58 -07:00
parent 566ca05ea8
commit 564233cbff
20 changed files with 233 additions and 258 deletions

View file

@ -7,7 +7,7 @@ version = "0.0.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Sulkta"] authors = ["Sulkta"]
repository = "http://127.0.0.1:3001/Sulkta/skald" repository = "https://git.sulkta.com/Sulkta/skald"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View file

@ -1,26 +1,16 @@
# Multi-stage build for skald. # Multi-stage build for skald. Postgres ships in the same image
# until the tool stabilises; to split, swap the runtime base to
# debian:bookworm-slim, drop entrypoint.sh, point DATABASE_URL at
# an external pg.
# #
# Stage 1: compile the rust binary against rust:1-bookworm. # Build context is the workspace root: `docker build -t skald:latest .`
# Stage 2: pgvector/pgvector:pg17 (debian-bookworm postgres with
# pgvector preinstalled) + tini + the skald binary.
#
# v0.1 ships postgres inside the same container ("singleton till we
# have a real working tool"). When we extract the DB out, swap the
# runtime base to debian:bookworm-slim, drop entrypoint.sh, point
# DATABASE_URL at the external pg.
#
# Build context is the workspace root:
# docker build -t skald:latest .
# ─── builder ──────────────────────────────────────────────────────
FROM rust:1.95-bookworm AS builder FROM rust:1.95-bookworm AS builder
WORKDIR /build WORKDIR /build
# Cache the dependency graph: copy manifests first, fetch + build # Dependency-cache layer: copy manifests + vendored path-dep first,
# stubs, THEN drop in real sources. The vendored clawdforge SDK # build stubs, then drop in real sources. clawdforge is a path dep
# needs its own manifest + source available during the cache layer # resolved at workspace load time.
# (path dep — Cargo resolves it at workspace load time, not at
# crate-compile time).
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY skald-core/Cargo.toml skald-core/Cargo.toml COPY skald-core/Cargo.toml skald-core/Cargo.toml
COPY skald/Cargo.toml skald/Cargo.toml COPY skald/Cargo.toml skald/Cargo.toml

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sulkta
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md
View file

@ -1,95 +1,79 @@
# skald # skald
Long-form story-writer with canon-keeping, sequel continuity, and Long-form story-writer with canon-keeping, sequel continuity, and
(future) self-hosted audiobook narration. Database is the source of self-hosted audiobook narration. The database is the source of truth;
truth — the writer is the tooling. the binary is the tooling.
Named for the Old Norse poets who composed and memorized kings' Named for the Old Norse poets who composed and memorized kings' sagas
sagas across generations. across generations.
## Status: v0.1 — scaffold ## What's wired
What's wired:
- Rust workspace (`skald-core` + `skald`) - Rust workspace (`skald-core` + `skald`)
- Postgres schema for stories, characters, canon facts, chapters, - Postgres schema for stories, characters, canon facts, chapters,
passages, generation runs, audit findings, tags passages, generation runs, audit findings, tags
- pgvector extension installed for future similarity search - pgvector for future similarity search
- `skald import-markdown` ingests a story file (chapters + bible) - `skald import-markdown` ingests a story file (chapters + bible)
into the schema - `skald serve` exposes `/health` + the web inspector and runs
- `skald serve` exposes `/health` and runs migrations on boot migrations on boot
- Single-container deploy: postgres + skald in one image - `skald continue` runs gen → cleanup → audit per chapter, with
multi-chapter batching (cap 20)
Wired (this commit): - `skald rewrite` re-authors a chapter in a named author's voice
- `skald audit` runs whole-story prose-quality audit; `skald dedup`
- clawdforge Rust SDK vendored at `vendor/clawdforge/` (upstream: is the surgical fix half of the loop
`Sulkta-OSS/clawdforge` `clients/rust/`) - `skald prepare-narration` annotates a chapter with `[breath]` /
- `skald-core::forge` — three-pass orchestration shell (gen / cleanup / `[pause:Xs]` / `[scene]` beats and per-character `[voice:...]`
audit). Prompts are TODO stubs; pipeline plumbing is in place. tags
- `skald narrate` renders a chapter to audio via one of three TTS
Not yet wired: engines (F5-TTS, Kokoro-82M, Tortoise-TTS) — see `engines/`
- Named-author "soul" personas via `skald authors seed`; author
- Web UI (the inbox + browse + queue surface) voice replaces the model's base system prompt for gen/cleanup/
- Prompt templates for the three passes (heavy prompt-engineering rewrite/dedup/narrate_prep
work — own session)
- `skald-core::context` — assemble the LLM context blob from DB rows
(bible + characters + parent prose summaries + similarity-matched
passages)
- Embeddings backfill + ivfflat index
- TTS sidecar container + post-render audit chain (see
`docs/tts-pipeline.md`)
## v0.1 smoke
```bash
docker compose -p skald up -d
docker exec skald skald import-markdown \
--path /seed/coast-down.md \
--title "The Coast-Down"
curl http://localhost:7780/health
# → { ok: true, db_ok: true, story_count: 1, ... }
```
## Schema (cheat sheet) ## Schema (cheat sheet)
``` ```
stories → meta + status + parent/root for series stories meta + status + parent/root for series
characters → real or fictional, story-scoped authors persona identity (slug, display_name, model)
canon_facts → setting, mystery, theme, rule, historical_anchor, hook author_revisions versioned souls; one current per author
chapters → full prose body characters real or fictional, story-scoped, voice-mappable
chapter_summaries → short summaries for cheap context loading canon_facts setting, mystery, theme, rule, historical_anchor, hook
passages → paragraph-level + embedding vector(1536) chapters full prose body + optional body_md_tts annotation
generation_runs → every LLM call logged chapter_summaries short summaries for cheap context loading
audit_findings → canon audit output (severity + area) passages paragraph-level + embedding vector(1536)
tags → arbitrary labels voices TTS voice rows (F5 ref clips / Kokoro / Tortoise names)
pronunciation_overrides per-story + global respellings for proper nouns
generation_runs every LLM call logged
audit_findings audit pass output (severity + area)
narration_runs per-chapter TTS renders
``` ```
## Architecture (v0.1 + the plan) ## Quickstart
``` ```sh
┌─────────────────────────────────┐ docker compose up -d
│ skald container │ docker exec skald skald import-markdown --path /seed/<story>.md \
│ ┌───────────┐ ┌────────────┐ │ --title "<title>"
│ │ postgres │ │ skald-rust │ │ curl http://localhost:7780/health
│ │ pgvector │←─│ axum + cli │ │
│ │ localhost │ │ :7780 │ │
│ └───────────┘ └─────┬──────┘ │
└─────────────────────────┼────────┘
│ HTTP (future)
┌──────────┐
│clawdforge│
└─────┬────┘
opus calls
``` ```
v1.0+: extract postgres to its own container on db-net. skald The compose file expects `POSTGRES_PASSWORD` and (optionally)
becomes pure stateless rust, connects via `DATABASE_URL`. Migration `CLAWDFORGE_URL` + `CLAWDFORGE_TOKEN` in `.env`. Story markdown
is a connection-string change + a network move; the binary doesn't goes into `./seed/`; postgres data persists in `./pgdata/`.
care where the DB lives.
## Architecture
v0.1 ships postgres inside the skald container — singleton until
the tool stabilises. To extract postgres later, swap the runtime
base to `debian:bookworm-slim`, drop `entrypoint.sh`, and point
`DATABASE_URL` at the external pg. The binary doesn't care where
the DB lives.
The generation passes call out to `clawdforge` (a bearer-token-gated
HTTP wrapper around `claude -p`). The Rust client is vendored at
`vendor/clawdforge/`. TTS calls go HTTP+JSON to the per-engine
sidecars under `engines/`.
## License ## License
MIT. MIT — see `LICENSE`.

View file

@ -1,37 +1,42 @@
# Standalone compose stack for skald v0.1. Postgres lives in the # Standalone compose stack for skald. Postgres lives inside the
# same container — single deployable unit "till we have a real # skald container — single deployable unit until the tool stabilises.
# working tool" (Sulkta's call, 2026-05-13).
# #
# To deploy on the host: # Set in .env (or the environment):
# sudo mkdir -p /srv/appdata/skald/{pgdata,seed} # POSTGRES_PASSWORD=... # required
# sudo cp <story>.md /srv/appdata/skald/seed/ # CLAWDFORGE_URL=http://...:8800 # if running gen / cleanup / audit
# sudo cp skald.env /srv/appdata/secrets/skald.env # POSTGRES_PASSWORD=... # CLAWDFORGE_TOKEN=cf_...
# docker compose -p skald up -d # SKALD_DATA=./pgdata # optional override; defaults to ./pgdata
# SKALD_SEED=./seed # optional override; defaults to ./seed
# #
# To import the first story: # To import the first story:
# docker exec skald skald import-markdown \ # docker compose exec skald skald import-markdown \
# --path /seed/<story>.md \ # --path /seed/<story>.md \
# --title "<title>" # --title "<title>"
name: skald
services: services:
skald: skald:
image: registry.example.local:5000/skald:latest build: .
image: skald:latest
container_name: skald container_name: skald
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7780:7780" - "7780:7780"
env_file: environment:
- /srv/appdata/secrets/skald.env POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_USER: ${POSTGRES_USER:-skald}
POSTGRES_DB: ${POSTGRES_DB:-skald}
DATABASE_URL: ${DATABASE_URL:-postgresql://skald:${POSTGRES_PASSWORD}@localhost:5432/skald}
CLAWDFORGE_URL: ${CLAWDFORGE_URL:-}
CLAWDFORGE_TOKEN: ${CLAWDFORGE_TOKEN:-}
SKALD_MODEL: ${SKALD_MODEL:-opus}
F5_TTS_URL: ${F5_TTS_URL:-}
KOKORO_URL: ${KOKORO_URL:-}
TORTOISE_URL: ${TORTOISE_URL:-}
RUST_LOG: ${RUST_LOG:-info}
SKALD_LOG_FORMAT: ${SKALD_LOG_FORMAT:-json}
volumes: volumes:
# Postgres data — persist across container recreates. # Postgres data — persist across container recreates.
- /srv/appdata/skald/pgdata:/var/lib/postgresql/data - ${SKALD_DATA:-./pgdata}:/var/lib/postgresql/data
# Markdown corpus to import via `docker exec skald skald import-markdown`. # Markdown corpus to import via `skald import-markdown`.
- /srv/appdata/skald/seed:/seed:ro - ${SKALD_SEED:-./seed}:/seed:ro
environment:
RUST_LOG: ${RUST_LOG:-info}
SKALD_LOG_FORMAT: json
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "Sulkta"
org.sulkta.managed-by: "compose"
org.sulkta.role: "skald"

View file

@ -40,19 +40,18 @@ generalise. Examples:
all three at once), preset choice ergonomics, character→tortoise- all three at once), preset choice ergonomics, character→tortoise-
voice seed assignments. voice seed assignments.
When deploying an engine to the host, the build dir at To deploy a tuned engine, check out the engine's branch in the build
`/srv/appdata/<engine>/build/` tracks the engine's branch: dir and `docker compose up -d --build`:
```bash ```bash
cd /srv/appdata/kokoro/build
git fetch && git checkout engine/kokoro git fetch && git checkout engine/kokoro
docker compose -p <name> up -d --build docker compose up -d --build
``` ```
## GPU coordination (2070 Super) ## GPU coordination
The 8GB card is the bottleneck. F5 + Kokoro can co-reside (~5GB + On an 8GB card F5 + Kokoro can co-reside (~5GB + ~1GB). Tortoise
~1GB). Tortoise pushes the budget over and needs the GPU largely pushes the budget over and needs the GPU largely to itself — the
to itself — the `engine/tortoise` branch will carry the script `engine/tortoise` branch carries a script to stop kokoro + f5
that stops kokoro + f5 before a tortoise run and restarts them before a Tortoise run and restart them after. Replace with proper
after. Replace with proper coordination once we have more VRAM. coordination once more VRAM is available.

View file

@ -1,9 +1,6 @@
# Sulkta build of F5-TTS — upstream ghcr.io/swivid/f5-tts:main was # F5-TTS rebuild on a known-good pytorch base. Upstream
# shipped with torch 2.11/torchaudio 2.4 ABI mismatch on 2026-05-13, # ghcr.io/swivid/f5-tts:main shipped a torch/torchaudio ABI mismatch
# breaking import torchaudio at boot. We rebuild on a known-good # that broke `import torchaudio` at boot; this image bypasses that.
# pytorch base + pip install f5-tts.
#
# Image tag in localhost-registry: registry.example.local:5000/f5-tts:<ver>
# #
# License: Apache 2.0 (code) / CC-BY-NC (Emilia-trained weights). # License: Apache 2.0 (code) / CC-BY-NC (Emilia-trained weights).
# Personal use OK; redistribution gray-area — flagged. # Personal use OK; redistribution gray-area — flagged.
@ -30,12 +27,11 @@ RUN pip install --no-cache-dir 'f5-tts>=1.0.0'
# Pre-warm the HF cache directory. # Pre-warm the HF cache directory.
RUN mkdir -p /cache/hf /audio /voices RUN mkdir -p /cache/hf /audio /voices
COPY f5_server.py /app/f5_server.py COPY server.py /app/server.py
WORKDIR /app WORKDIR /app
EXPOSE 7860 EXPOSE 7860
# Skald talks to our purpose-built FastAPI server, not Gradio. # Purpose-built FastAPI server, not Gradio. Models load at startup
# Models load at startup (first request would otherwise pay the # so the first request doesn't pay the cold-start cost.
# cold-start cost). uvicorn on :7860 to keep the port stable. CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
CMD ["uvicorn", "f5_server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -1,19 +1,21 @@
# F5-TTS standalone stack on the host. # F5-TTS sidecar.
# #
# License posture (acknowledged 2026-05-13): code is Apache 2.0, but # Code is Apache 2.0; pretrained F5TTS_v1_Base weights are CC-BY-NC
# the pretrained model weights are CC-BY-NC (Emilia training data). # (Emilia training data). Personal use is fine; redistribution is a
# Personal listen is fine; public sharing is a flagged gray area. # flagged gray area.
# Sulkta's call: ship anyway.
# #
# Runtime: 8GB GPU is plenty (F5 inference ~4-6GB peak). # First run downloads ~2GB of model weights from HuggingFace into
# the hf-cache volume; subsequent runs are warm.
# #
# First-run cost: ~2GB model download from HuggingFace into hf-cache, # Set in .env (or override):
# happens on first inference request. Subsequent runs are warm. # F5_HOST_PORT=7792
# F5_DATA=./data # ${F5_DATA}/hf-cache + voices + audio
name: f5-tts name: f5-tts
services: services:
f5-tts: f5-tts:
image: registry.example.local:5000/f5-tts:0.3 build: .
image: f5-tts:0.3
container_name: f5-tts container_name: f5-tts
restart: unless-stopped restart: unless-stopped
deploy: deploy:
@ -24,20 +26,11 @@ services:
count: all count: all
capabilities: [gpu] capabilities: [gpu]
ports: ports:
- "127.0.0.1:7792:7860" - "${F5_HOST_PORT:-7792}:7860"
- "127.0.0.1:7792:7860"
volumes: volumes:
# HF model weights cache — persists ~2GB after first download. - ${F5_DATA:-./data}/hf-cache:/cache/hf
- /srv/appdata/f5-tts/hf-cache:/cache/hf - ${F5_DATA:-./data}/voices:/voices:ro
# Reference voice clips (lj_speech.wav, etc). - ${F5_DATA:-./data}/audio:/audio
- /srv/appdata/f5-tts/voices:/voices:ro
# Rendered audio output — skald writes story narrations here.
- /srv/appdata/f5-tts/audio:/audio
environment: environment:
HF_HOME: /cache/hf HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1" HF_HUB_DISABLE_TELEMETRY: "1"
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "Sulkta"
org.sulkta.managed-by: "compose"
org.sulkta.role: "f5-tts"

View file

@ -1,13 +1,8 @@
# Sulkta build of Kokoro-82M TTS. # Kokoro-82M TTS. Apache 2.0 code AND weights — clean stack vs
# F5-TTS's CC-BY-NC asterisk.
# #
# License: Apache 2.0 (code AND model weights). Clean stack — no # Kokoro runs fine on CPU but we use the cuda base to stay
# CC-BY-NC asterisk like F5-TTS's Emilia weights. This is the # consistent with f5-tts and pick up the GPU when free.
# narrator engine for sleep-quality audiobook reads; F5-TTS stays
# around for voice-cloning cases.
#
# Kokoro is small enough to run on CPU but we use the cuda base
# anyway to stay consistent with f5-tts and so it'll pick up the
# GPU when no other tenant has it.
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
@ -27,9 +22,9 @@ RUN pip install --no-cache-dir 'kokoro>=0.9.0' 'fastapi>=0.115.0' 'uvicorn>=0.32
RUN mkdir -p /cache/hf /audio RUN mkdir -p /cache/hf /audio
COPY kokoro_server.py /app/kokoro_server.py COPY server.py /app/server.py
WORKDIR /app WORKDIR /app
EXPOSE 7860 EXPOSE 7860
CMD ["uvicorn", "kokoro_server:app", "--host", "0.0.0.0", "--port", "7860"] CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -1,16 +1,18 @@
# Kokoro-82M TTS stack on the host. # Kokoro-82M TTS sidecar.
# #
# Audiobook-quality narrator engine (Apache 2.0 code + weights — # Apache 2.0 code AND model weights — clean stack for share/publish.
# clean stack vs F5-TTS's CC-BY-NC asterisk). Sibling to f5-tts; # Audiobook-quality narrator; F5-TTS stays around for voice-cloning.
# both share /srv/appdata/f5-tts/audio so skald's audio
# route serves outputs from either engine through the same path.
# #
# License: Apache 2.0 top to bottom. Right for share/publish. # Set in .env (or override):
# KOKORO_HOST_PORT=7794
# KOKORO_DATA=./data # ${KOKORO_DATA}/hf-cache
# AUDIO_DIR=../f5-tts/data/audio # shared output dir across engines
name: kokoro name: kokoro
services: services:
kokoro: kokoro:
image: registry.example.local:5000/kokoro:0.5 build: .
image: kokoro:0.5
container_name: kokoro container_name: kokoro
restart: unless-stopped restart: unless-stopped
deploy: deploy:
@ -21,17 +23,10 @@ services:
count: all count: all
capabilities: [gpu] capabilities: [gpu]
ports: ports:
- "127.0.0.1:7794:7860" - "${KOKORO_HOST_PORT:-7794}:7860"
- "127.0.0.1:7794:7860"
volumes: volumes:
- /srv/appdata/kokoro/hf-cache:/cache/hf - ${KOKORO_DATA:-./data}/hf-cache:/cache/hf
# Shared with f5-tts so skald's /audio route covers both. - ${AUDIO_DIR:-./data/audio}:/audio
- /srv/appdata/f5-tts/audio:/audio
environment: environment:
HF_HOME: /cache/hf HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1" HF_HUB_DISABLE_TELEMETRY: "1"
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "Sulkta"
org.sulkta.managed-by: "compose"
org.sulkta.role: "kokoro"

View file

@ -1,4 +1,4 @@
"""Kokoro-82M FastAPI server, sibling to f5_server. """Kokoro-82M FastAPI server, sibling to the f5-tts server.
Same /synthesize contract as F5 so skald can route between engines Same /synthesize contract as F5 so skald can route between engines
just by which URL it points at. The semantic difference: Kokoro just by which URL it points at. The semantic difference: Kokoro
@ -234,7 +234,7 @@ def _startup() -> None:
@app.get("/healthz") @app.get("/healthz")
def healthz() -> dict: def healthz() -> dict:
# Shape matches f5_server's so the same Rust HealthResponse # Shape matches the f5-tts server's so the same Rust HealthResponse
# struct deserializes both: model/vocoder/loaded fields are # struct deserializes both: model/vocoder/loaded fields are
# required by skald-core::narrate::HealthResponse. # required by skald-core::narrate::HealthResponse.
return { return {

View file

@ -1,17 +1,13 @@
# Sulkta build of Tortoise-TTS. # Tortoise-TTS. Apache 2.0 code + weights.
# #
# Voice roster (built-in, no cloning needed): angie, daniel, deniro, # ~26 built-in voices (no cloning): angie, daniel, deniro, emma,
# emma, freeman, geralt, halle, jlaw, lj, mol, myself, pat, pat2, # freeman, geralt, halle, jlaw, lj, mol, myself, pat, pat2, rainbow,
# rainbow, snakes, tim_reynolds, tom, train_atkins, train_dotrice, # snakes, tim_reynolds, tom, train_atkins, train_dotrice,
# train_dreams, train_grace, train_kennard, train_lescault, # train_dreams, train_grace, train_kennard, train_lescault,
# train_mouse, weaver, william. ~26 voices baked in. # train_mouse, weaver, william.
# #
# License: Apache 2.0 (code) + Apache 2.0 (model weights). Clean # Slow: standard preset is ~10x slower than Kokoro; high_quality is
# stack for share/publish. # ~30x. Trade for quality.
#
# Speed: slow. Trade for quality. Standard preset is ~10x slower
# than Kokoro; high_quality is ~30x slower. Worth it for the
# audiobook-quality bar.
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
@ -37,9 +33,9 @@ RUN pip install --no-cache-dir \
RUN mkdir -p /cache/hf /cache/tortoise-models /audio RUN mkdir -p /cache/hf /cache/tortoise-models /audio
COPY tortoise_server.py /app/tortoise_server.py COPY server.py /app/server.py
WORKDIR /app WORKDIR /app
EXPOSE 7860 EXPOSE 7860
CMD ["uvicorn", "tortoise_server:app", "--host", "0.0.0.0", "--port", "7860"] CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -1,19 +1,22 @@
# Tortoise-TTS stack on the host. Audiobook-quality engine with 25+ # Tortoise-TTS sidecar. 25+ named voices, no cloning needed.
# named voices (no cloning). Apache 2.0 top to bottom. # Apache 2.0 top to bottom.
# #
# Slow: ~10x kokoro wall clock at 'standard' preset. Worth it for # Slow: ~10x kokoro wall-clock at 'standard' preset. Worth it for the
# the quality bar. Sulkta's call 2026-05-14: "use higgs (now tortoise) # quality bar; runs are batched.
# and we will only let it use the full gpu for runs" — translated:
# runs are batched, slow is acceptable.
# #
# Co-resides with kokoro on the 2070 Super since tortoise is ~5GB # Co-resides with kokoro on an 8GB card (tortoise ~5GB + kokoro ~1GB).
# and kokoro is ~1GB (8GB total). If OOM hits during a render, # OOM during a render: add a coordinator that pauses kokoro first.
# we'll add a coordination layer to pause kokoro first. #
# Set in .env (or override):
# TORTOISE_HOST_PORT=7795
# TORTOISE_DATA=./data # ${TORTOISE_DATA}/{hf-cache,models}
# AUDIO_DIR=../f5-tts/data/audio # shared output dir across engines
name: tortoise name: tortoise
services: services:
tortoise: tortoise:
image: registry.example.local:5000/tortoise:0.1 build: .
image: tortoise:0.1
container_name: tortoise container_name: tortoise
restart: unless-stopped restart: unless-stopped
deploy: deploy:
@ -24,20 +27,12 @@ services:
count: all count: all
capabilities: [gpu] capabilities: [gpu]
ports: ports:
- "127.0.0.1:7795:7860" - "${TORTOISE_HOST_PORT:-7795}:7860"
- "127.0.0.1:7795:7860"
volumes: volumes:
- /srv/appdata/tortoise/hf-cache:/cache/hf - ${TORTOISE_DATA:-./data}/hf-cache:/cache/hf
- /srv/appdata/tortoise/models:/cache/tortoise-models - ${TORTOISE_DATA:-./data}/models:/cache/tortoise-models
# Shared audio dir with f5/kokoro so skald serves all engines' - ${AUDIO_DIR:-./data/audio}:/audio
# outputs through the same /audio route.
- /srv/appdata/f5-tts/audio:/audio
environment: environment:
HF_HOME: /cache/hf HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1" HF_HUB_DISABLE_TELEMETRY: "1"
TORTOISE_MODELS_DIR: /cache/tortoise-models TORTOISE_MODELS_DIR: /cache/tortoise-models
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "Sulkta"
org.sulkta.managed-by: "compose"
org.sulkta.role: "tortoise-tts"

View file

@ -1,4 +1,4 @@
"""Tortoise-TTS FastAPI server. Sibling to kokoro_server. """Tortoise-TTS FastAPI server. Sibling to the kokoro server.
Same /synthesize contract as the kokoro server so skald only has to Same /synthesize contract as the kokoro server so skald only has to
route by voice.source. Differences: route by voice.source. Differences:
@ -71,7 +71,7 @@ def _get_voice(name: str) -> tuple:
return _voice_cache[name] return _voice_cache[name]
# ─── tag splitter (lifted from kokoro_server) ─────────────────── # ─── tag splitter (lifted from the kokoro server) ───────────────
class Node: class Node:
@ -209,7 +209,7 @@ def _startup() -> None:
@app.get("/healthz") @app.get("/healthz")
def healthz() -> dict: def healthz() -> dict:
# Shape matches f5_server/kokoro_server so skald's HealthResponse # Shape matches the f5-tts + kokoro servers so skald's HealthResponse
# struct deserializes all three. # struct deserializes all three.
return { return {
"ok": True, "ok": True,

View file

@ -1,14 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Skald container entrypoint.
#
# Boots the embedded postgres via the pgvector image's own # Boots the embedded postgres via the pgvector image's own
# docker-entrypoint, waits for it to accept connections, then execs # docker-entrypoint, waits for it to accept connections, then execs
# `skald` in the foreground. Tini is PID 1 (so it can reap zombies + # `skald` in the foreground. Tini is PID 1; postgres becomes our
# forward signals); we are PID 2; postgres becomes our child. # child. When the DB is extracted to its own container, this reduces
# # to `exec /usr/local/bin/skald "$@"`.
# This is explicitly "DB in the same container, for now" — when we
# split the DB out (see project notes), the entrypoint reduces to
# `exec /usr/local/bin/skald "$@"` and the pg startup goes away.
set -eo pipefail set -eo pipefail

View file

@ -8,24 +8,23 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeConfig { pub struct ForgeConfig {
/// Base URL of the clawdforge HTTP service. Defaults to /// Base URL of the clawdforge HTTP service. The calling binary
/// `http://clawdforge.example.local:8800` in production; override /// resolves this from `CLAWDFORGE_URL`.
/// for tests via env.
pub base_url: String, pub base_url: String,
/// App-level bearer token. Resolved by the binary from /// App-level bearer token. Resolved by the binary from
/// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed. /// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed.
pub app_token: String, pub app_token: String,
/// Model alias passed to clawdforge → `claude -p --model`. Skald /// Model alias passed to clawdforge → `claude -p --model`.
/// is opinionated: always opus max effort. Default reflects that. /// Defaults to opus.
pub model: String, pub model: String,
} }
impl Default for ForgeConfig { impl Default for ForgeConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
base_url: "http://clawdforge.example.local:8800".into(), base_url: "http://localhost:8800".into(),
app_token: String::new(), app_token: String::new(),
model: "opus".into(), model: "opus".into(),
} }

View file

@ -27,7 +27,7 @@ use uuid::Uuid;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct F5Config { pub struct F5Config {
/// e.g. http://127.0.0.1:7792 /// e.g. http://localhost:7792
pub base_url: String, pub base_url: String,
/// Inference subprocess timeout. Long-form chapters (3000 words) /// Inference subprocess timeout. Long-form chapters (3000 words)
/// take 60-180s on an 8GB GPU; cap at 1800s to match clawdforge. /// take 60-180s on an 8GB GPU; cap at 1800s to match clawdforge.

View file

@ -30,8 +30,8 @@ use uuid::Uuid;
about = "Long-form story-writer. Database is the source of truth; the writer is the tooling." about = "Long-form story-writer. Database is the source of truth; the writer is the tooling."
)] )]
struct Cli { struct Cli {
/// Postgres connection URL. Defaults to `postgresql://skald:skald@localhost:5432/skald`. /// Postgres connection URL. Read from `DATABASE_URL` if unset.
#[arg(long, env = "DATABASE_URL", default_value = "postgresql://skald:skald@localhost:5432/skald")] #[arg(long, env = "DATABASE_URL")]
database_url: String, database_url: String,
#[command(subcommand)] #[command(subcommand)]

View file

@ -458,16 +458,15 @@ async fn cleanup_superseded_renders(pool: &PgPool, chapter_id: Uuid, current_run
/// kokoro_* → KOKORO_URL /// kokoro_* → KOKORO_URL
/// tortoise_* → TORTOISE_URL /// tortoise_* → TORTOISE_URL
/// anything else (lj_speech etc.) → F5_TTS_URL /// anything else (lj_speech etc.) → F5_TTS_URL
/// Each env var has a LAN-default for the host.
fn engine_url_for(source: &str) -> anyhow::Result<String> { fn engine_url_for(source: &str) -> anyhow::Result<String> {
if source.starts_with("kokoro") { let (env_var, engine) = if source.starts_with("kokoro") {
Ok(std::env::var("KOKORO_URL") ("KOKORO_URL", "kokoro")
.unwrap_or_else(|_| "http://127.0.0.1:7794".into()))
} else if source.starts_with("tortoise") { } else if source.starts_with("tortoise") {
Ok(std::env::var("TORTOISE_URL") ("TORTOISE_URL", "tortoise")
.unwrap_or_else(|_| "http://127.0.0.1:7795".into()))
} else { } else {
Ok(std::env::var("F5_TTS_URL") ("F5_TTS_URL", "f5-tts")
.unwrap_or_else(|_| "http://127.0.0.1:7792".into())) };
} std::env::var(env_var).map_err(|_| {
anyhow::anyhow!("{env_var} not set — point at the {engine} sidecar")
})
} }

View file

@ -201,8 +201,12 @@ async fn new_story_create(
// parent to compare against. So you get a single first-chapter // parent to compare against. So you get a single first-chapter
// gen + cleanup pass and status flows to 'complete'. // gen + cleanup pass and status flows to 'complete'.
if form.fire == "now" { if form.fire == "now" {
let database_url = std::env::var("DATABASE_URL") let Ok(database_url) = std::env::var("DATABASE_URL") else {
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into()); return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_URL not set — cannot spawn background gen".into(),
));
};
let author_owned = if author_slug.is_empty() { let author_owned = if author_slug.is_empty() {
None None
} else { } else {
@ -337,7 +341,12 @@ async fn continue_create(
// If user clicked "fire now," spawn a background gen task. // If user clicked "fire now," spawn a background gen task.
// Otherwise the sequel sits in seed state until CLI fires it. // Otherwise the sequel sits in seed state until CLI fires it.
if form.fire == "now" { if form.fire == "now" {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into()); let Ok(database_url) = std::env::var("DATABASE_URL") else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_URL not set — cannot spawn background gen".into(),
));
};
let author_owned = if author_slug.is_empty() { None } else { Some(author_slug.to_string()) }; let author_owned = if author_slug.is_empty() { None } else { Some(author_slug.to_string()) };
let direction_owned = direction.clone(); let direction_owned = direction.clone();
let chapters = parse_chapters(&form.chapters); let chapters = parse_chapters(&form.chapters);
@ -512,8 +521,12 @@ async fn chapter_narrate_fire(
let chapter_id = let chapter_id =
chapter_id.ok_or((StatusCode::NOT_FOUND, "chapter not found".into()))?; chapter_id.ok_or((StatusCode::NOT_FOUND, "chapter not found".into()))?;
let database_url = std::env::var("DATABASE_URL") let Ok(database_url) = std::env::var("DATABASE_URL") else {
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into()); return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_URL not set — cannot spawn background narrate".into(),
));
};
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = crate::narrate::run(&database_url, chapter_id, None, 1.0).await { if let Err(e) = crate::narrate::run(&database_url, chapter_id, None, 1.0).await {
tracing::error!(chapter_id = %chapter_id, error = %e, "background narrate failed"); tracing::error!(chapter_id = %chapter_id, error = %e, "background narrate failed");
@ -671,7 +684,7 @@ fn render_shell(stories: &[StoryRow], current: Option<Uuid>, main: Markup) -> Ma
} }
footer.footbar { footer.footbar {
span { "skald · v0.3 · written down · " span { "skald · v0.3 · written down · "
a href="http://127.0.0.1:3001/Sulkta/skald" { "Sulkta/skald" } a href="https://git.sulkta.com/Sulkta/skald" { "Sulkta/skald" }
} }
} }
} }