diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf2548..fae44b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi - **LibriVox search returned 502 "unavailable" after 20-30 s.** Cause: the LibriVox API is erratic (10-40 s for any request, measured from host and container; egress/DNS/TLS fine) and the old call asked for `extended=1` (all chapters of 15 books) with a 30 s timeout, mapping every failure to one generic message. Now the list is light (no `extended`), the first chapter is fetched per book on demand (`GET /api/voice-sources/librivox/book/{id}`, UI "Load preview" / on import), timeout 25 s with one retry and backoff, failures cached 30 s, distinct messages (timeout / unreachable / HTTP status / unreadable) and a privacy-safe log line (exception class and query length only). Host/redirect rules unchanged. - **`export_provenance_default` missing from settings defaults.** It is now in the defaults (`true`), so `GET /api/settings` exposes it. ### Added +- **Voice archetype gallery (own, German-first).** "Voice gallery" card in Design a Voice: 40 hand-authored named voices (mostly German, a few English) in 7 categories, each a free-text Qwen3 VoiceDesign description with facets (gender, age, pitch, accent, tone) from the design-chip taxonomy. Search, facet chips, category list, favourites (browser storage), 24 cards per page rendered only after opening, labelled Preview (aria-live status) and "Use voice" (then "Open in Design" prefills the editor). `GET /api/archetypes`, `POST /api/archetypes/{id}/preview` (cached by sha256 of instruct, language, engine and seed 42; timeouts, blank-audio guard) and `POST /api/archetypes/{id}/use` (saves voice `archetype_`, origin designed, provenance basis `unknown`, source `archetype/`, note "synthetic designed voice"; idempotent). Own catalog, nothing copied from other projects; no network. Optional `scripts/render_archetype_previews.py` pre-renders all previews. Tests: `tests/test_voice_archetypes.py`, `tests/archetype-gallery.spec.js`. See `architecture/voice-archetypes.md`. - **Audiobook emotion tests.** Playwright `tests/audiobook-emotion.spec.js` (mocked routes) covers span-cast emotion -> `instruct` in `/api/tts-preview` (dialogue only, English tag, narration empty) and the line cache key; `tests/test_tts_instruct_payload.py` covers the instruct passthrough. - **LibriVox and Wikimedia Commons voice sources.** New tab in Get Voices Online searches the official LibriVox feed and the Commons MediaWiki API through `GET /api/voice-sources/{librivox|wikimedia}?q=&lang=` (fixed hosts, query cap 80, 10 results, 30 s timeout, 10 min cache, 1 s throttle, descriptive User-Agent, result URLs host-checked). Results show licence and a preview; "Import clip" trims (start/length) and stores provenance (LibriVox: public domain + check-the-reader note; Wikimedia: licence + attribution). `archive.org` and `upload.wikimedia.org` added to the audio proxy allowlist. Tests: `tests/test_voice_sources_new.py`, `tests/voice-sources-new.spec.js`. See `architecture/voice-sources.md`. - **Provenance tags on exported audio.** Magazine MP3 downloads, `audio.zip` and the MP3s inside the EPUB get ID3 tags (`synthetic_speech=true`, `generator`, `generator_version`, `generated_date`, comment; voice name only on opt-in) via one ffmpeg stream copy of a temp copy; stored audio is untouched. Per-export checkboxes on the magazine export row ("Mark as synthetic speech", "include voice name"), default from new setting `export_provenance_default` (on). `POST /api/export/provenance` and `python -m core.export_provenance file.mp3` read the tags back. Inaudible AudioSeal watermarking is researched (MIT code and weights, torch needed, weak message recovery after MP3/AAC) but only a stub `watermark(path)` that reports unavailable. Spoken disclosure is not implemented yet. Tests: `tests/test_watermark_tags.py`, `tests/magazine.spec.js`. See `architecture/watermarking.md`. diff --git a/Manual.md b/Manual.md index fd39689..f7067e7 100644 --- a/Manual.md +++ b/Manual.md @@ -19,6 +19,15 @@ Purpose: pauses, spelled-out acronyms and slower/faster passages inside one text Known control tags such as `[laugh]` are removed, never read; `[1]` and `[sic]` stay. Rendered (pauses, spell, speed) in: Try It Out/generate, Read Aloud and audiobook line previews (both call `/api/tts-preview`), the OpenAI-compatible `/v1/audio/speech` (non-streaming), and `/speak` and the MCP speak tool. Tags are only removed, never spoken, in: streaming playback and streaming `/v1/audio/speech`, spoken chat replies, and magazine MP3 (so recorded sentence timings stay exact). Speed uses ffmpeg, so no engine support is needed. Errors: a failing engine reports the usual TTS error. Mobile/accessibility: the switch is a labelled checkbox with `role=switch` (keyboard Space, 44 px row); German and English labels. Privacy: text stays local. +## Voice gallery (Design a Voice) + +**Purpose:** start from a ready-made voice instead of describing one from scratch. 40 named voices, German first (a few English), in 7 categories (narration, conversational, characters, social media, TV, advertisement, educational). +**Prerequisites:** the voice-design engine must be running for previews and "Use voice". Browsing works without it. +**Steps:** open Design a Voice -> "Voice gallery" (collapsed until opened). Search by name, narrow by gender, age, pitch, accent, language or category, or tick "Favourites only". Press "Preview" to hear the voice (first time about half a minute, then instant because it is cached). Press "Use voice" to save it to your voice library as `archetype_`; "Open in Design" then copies its description into the editor so you can tweak it. The star marks a favourite (kept in this browser only). +**Errors:** a failed preview or save shows the reason in the status line under the filters; try again. +**Mobile/accessibility:** 44 px buttons, labelled controls (each button names its voice), status announced politely, no motion with reduced-motion. Names follow the UI language (German or English). +**Offline/privacy:** everything runs on your own server; no network request leaves it. Saved voices are marked "synthetic designed voice" in Source & consent (basis "unknown", since no real person is involved). + ## Design a Voice: attribute chips **Purpose:** build a voice description quickly. **Use:** open Design a Voice, press chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion). Each chip adds its phrase to the description; pressing it again removes only that phrase. Gender, age, pitch and pace allow one choice each. **Free text:** type anything in the box; chips never overwrite your text and are not a limit on what you can describe. **Keyboard/touch:** chips are buttons (Tab, Space/Enter, `aria-pressed`, 44 px targets). **Languages:** chip labels follow the UI language (German/English); the inserted phrase is English. **Offline/privacy:** purely local, nothing is sent until you generate. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 11be4df..c690c4a 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -56,6 +56,7 @@ Two self-hosted Docker instances, both port 7890: | Area | Server | Frontend | State | |---|---|---|---| | Voice library / clone / design | `routes/library.py`, `core/voice.py` | `library.js` | mature | +| Voice archetype gallery (40 own German-first design voices, cached previews) | `core/voice_archetypes.py`, `routes/archetypes.py` | `voice-archetypes.js`, `s-design.html` | **new** โ€” see [architecture/voice-archetypes.md](architecture/voice-archetypes.md) | | Read Aloud (books, PDFs) | `routes/reader.py` | `reader.js` | mature | | Audiobook casting (span mode: deterministic quote spans, LLM names speakers only; verify/recast) | `routes/conversation.py`, `core/attribution_spans.py` | `audiobook*.js`, `audiobook-spans.js`, `audiobook-cast-spans.js` | **new 2026-09-19** โ€” see [architecture/audiobook-attribution.md](architecture/audiobook-attribution.md) | | Characters (avatar/concept identity, appearance forms) | `routes/characters.py`, `routes/conversation.py` | `library-characters.js`, `character-sheets.js` | **reworked 2026-09-19** โ€” see [architecture/character-visual-identity.md](architecture/character-visual-identity.md) | diff --git a/architecture/voice-archetypes.md b/architecture/voice-archetypes.md new file mode 100644 index 0000000..dd833de --- /dev/null +++ b/architecture/voice-archetypes.md @@ -0,0 +1,18 @@ +# Voice archetype gallery + +Code: `core/voice_archetypes.py` (catalog + filter), `routes/archetypes.py`, UI `static/js/voice-archetypes.js` (core module) + card in `static/sections/s-design.html`. + +**Catalog:** 40 entries authored here (no third-party data or code). Fields: `id`, `name_de`, `name_en`, `instruct` (free-text Qwen3 VoiceDesign description; German prompts transliterated to ASCII like the existing design presets), `category` (7 fixed), `language` (German/English), `facets` {gender, age, pitch, accent, tone} from the `DESIGN_CHIPS` vocabulary, `sample_script_de/en` (per category). Validated at import: unknown facet, category, language or duplicate/odd id raises ValueError (unit-tested). + +**API** +- `GET /api/archetypes?gender&age&pitch&accent&language&category&q&offset&limit` -> `{total, items, categories, cached}`; limit capped at 100, q at 100 chars. +- `POST /api/archetypes/{id}/preview` -> WAV. Cache `/archetype_previews/.wav`; engine is the configured voice-design model, seed fixed to 42 (injected as extra param for this call only). Rendering goes through `_voice_design_request_audio` inside the shared bounded TTS queue, 240 s timeout, per-key lock, size cap 8 MB, blank/too-short audio rejected (502), atomic write. Cache is naturally bounded by the catalog size. +- `POST /api/archetypes/{id}/use` -> saves voice `archetype_` (normalized WAV from the cached preview, reference text = sample script, meta `origin: designed`, `voice_design_prompt`). Idempotent: an existing voice returns `created: false`. + +**Provenance choice:** `core/provenance.BASES` has no "synthetic" value, so basis is `unknown` (closest; nothing to consent to), `source` `archetype/` (a `:` would be parsed as a URL scheme and rejected), `note` "synthetic designed voice". + +**UI:** the card is a collapsed `
`; nothing is fetched or rendered until opened (DOM budget). 24 cards per page ("Load more"), favourites in localStorage (guarded), DE translations in `i18n.js`. + +**Pre-render:** `scripts/render_archetype_previews.py` calls the preview endpoint for every entry (sequential, idempotent). No API key needed: the API-key gate accepts same-origin requests, so the script sends `Origin: `; only local/LAN bases accepted. + +Not done: per-archetype audio shipped in the repo, user-defined archetypes, community import. diff --git a/scripts/render_archetype_previews.py b/scripts/render_archetype_previews.py new file mode 100644 index 0000000..9b4b67e --- /dev/null +++ b/scripts/render_archetype_previews.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Pre-render all voice archetype previews into the server-side cache (idempotent; cached ones are skipped). + +Usage: python scripts/render_archetype_previews.py [--base http://localhost:7890] [--only ID ...] +Auth: none needed. The app's API-key gate lets same-origin requests through, so the script sends +the Origin header of the base URL, like the browser UI. No key is read or printed. Run it on the Spark. +Each render takes tens of seconds (one at a time); the server enforces timeouts and a blank-audio guard. +""" +import argparse +import json +import sys +import urllib.request +from urllib.parse import urlsplit + + +def call(base, path, method="GET"): + req = urllib.request.Request(base + path, method=method, data=b"" if method == "POST" else None, + headers={"Origin": base}) + with urllib.request.urlopen(req, timeout=300) as r: + return r.read() if method == "POST" else json.load(r) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base", default="http://localhost:7890") + ap.add_argument("--only", nargs="*", default=[]) + a = ap.parse_args() + host = urlsplit(a.base).hostname or "" + if urlsplit(a.base).scheme != "http" or not (host in ("localhost", "127.0.0.1") or host.startswith(("192.168.", "10."))): + sys.exit("refusing non-local base URL") + items, off = [], 0 + while True: + d = call(a.base, f"/api/archetypes?limit=100&offset={off}") + items += d["items"]; off += 100 + if off >= d["total"]: + break + fail = 0 + for it in items: + if a.only and it["id"] not in a.only: + continue + try: + n = len(call(a.base, f"/api/archetypes/{it['id']}/preview", "POST")) + print(f"ok {it['id']} ({n} bytes)") + except Exception as e: + fail += 1; print(f"FAIL {it['id']}: {e}") + sys.exit(1 if fail else 0) + + +if __name__ == "__main__": + main()