Compare commits

..

21 Commits

Author SHA1 Message Date
f7264a8c07 chore: release 1.21.0 - Consistent ComfyUI Character Renders and Audiobook Export
- Deterministic hash-based character portraits and reference sheets
- Implemented strict early Renaissance aesthetic controls and role-specific headwear rules
- Added audiobook batch export scripts and progress tooling
- Verified book profiles with a new 'confirmed' state tracking mechanism
- Updated Pillow dependency for robust image handling
- Configured .gitignore for audiobook and audio cache exports
2026-08-23 09:12:57 +02:00
fce5a0aa5d Track preview memory cleanup 2026-08-19 22:05:40 +02:00
2bcbbafbcf Refresh production bundle for preview cleanup 2026-08-19 22:05:31 +02:00
2d5ecea53a Release completed voice preview blob URLs 2026-08-19 22:05:31 +02:00
77758acdd6 Track metadata and upload cleanup safeguards 2026-08-19 18:41:41 +02:00
25264517a9 Clean up converted upload sources 2026-08-19 18:41:31 +02:00
e452b0f000 Write voice metadata atomically 2026-08-19 18:39:11 +02:00
11cd5b500c Track completed memory limit safeguard 2026-08-19 18:35:45 +02:00
d3fc2d44ae Add configurable web container memory limits 2026-08-19 18:35:34 +02:00
fd8f545d16 Optimize offscreen character table rendering 2026-08-19 18:35:10 +02:00
6629e8f017 Track completed image lazy loading task 2026-08-19 17:12:53 +02:00
b43034ee5d Refresh production bundle for lazy artwork 2026-08-19 17:12:45 +02:00
08a6544661 Lazy load character artwork 2026-08-19 17:12:45 +02:00
2bb01de94a Refresh production JavaScript bundle 2026-08-19 17:12:07 +02:00
aa15265021 Show generated voice file and preview in character profiles 2026-08-19 17:12:02 +02:00
8cc92ca02b Add low-memory client concurrency guard 2026-08-19 17:01:06 +02:00
496518f190 Stabilize browser smoke tests for current UI 2026-08-19 16:59:39 +02:00
b953ba1a83 Refactor table and audiobook helper modules 2026-08-19 16:39:17 +02:00
388fd601ab Release v1.20.38 2026-08-19 16:36:26 +02:00
189ca51e47 Release v1.20.37 2026-08-19 16:04:30 +02:00
62274a17fc Improve Studio casting and character voice design (v1.20.36) 2026-08-15 13:20:38 +02:00
60 changed files with 5597 additions and 492 deletions

3
.gitignore vendored
View File

@ -41,6 +41,9 @@ config/*.db
config/*.db-shm config/*.db-shm
config/*.db-wal config/*.db-wal
config/reader_library/ config/reader_library/
config/audiobook_batch_cache/
config/audiobook_exports/
config/line_audio_cache/
logs/*.log logs/*.log
logs/*.log.* logs/*.log.*

View File

@ -5,6 +5,99 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.21.0] — 2026-08-23
### Added
- **Consistent Character Renders in ComfyUI.** Replaced the generic fallback image pipeline with a deterministic, layout-first prompting strategy. A new `/api/character-generate-consistent-images` endpoint generates matching 1:1 portrait avatars and 3:2 character reference sheets (turnarounds) for the entire cast, maintaining strict character identity via hash-based deterministic seeds.
- **Period and Setting Enforcement.** Image prompts now strictly enforce an Aventurian early-Renaissance (c. 1420-1500) style, explicitly clamping down on anachronistic modern fashion, high-fantasy glowing magic, and generic medieval tropes. Roles and classes automatically assign period-accurate clothing layers and headwear (e.g., chaperons, coifs).
- **Automated Audiobook Export.** Added a batch script (`scripts/export_all_audiobooks.py`) for sequential, resumable exporting of all completed audiobooks into merged per-chapter MP3s.
- **Book Profile Validation.** Added a `confirmed` status field to book profiles to track data completion.
- **Dependency Updates.** Added `Pillow>=10.3.0` to requirements to support the new image processing pipeline.
## [1.20.46] — 2026-08-19
### Fixed
- **Completed synthesized voice previews now release their Blob URLs.** Repeated preview playback no longer retains every generated clip in browser memory.
## [1.20.45] — 2026-08-19
### Fixed
- **Temporary upload sources are now removed after conversion.** Audio previews no longer leave the original uploaded file behind in the temporary directory alongside the registered WAV.
## [1.20.44] — 2026-08-19
### Fixed
- **Voice metadata is now written atomically.** Interrupted saves cannot leave truncated `.meta.json` files that make ratings or voice settings disappear.
## [1.20.43] — 2026-08-19
### Added
- **The web container now has configurable memory protection.** Compose defaults to an 8 GB limit with a 2 GB reservation; `APP_MEM_LIMIT` and `APP_MEM_RESERVATION` can override those values for heavier workloads.
## [1.20.42] — 2026-08-19
### Changed
- **Large character tables now use browser content virtualization.** Off-screen rows can be skipped during layout and painting while the full DOM and existing interactions remain available.
## [1.20.41] — 2026-08-19
### Changed
- **Character portraits and concept art now lazy-load.** Off-screen images no longer load all at once when large cast lists are opened.
## [1.20.40] — 2026-08-19
### Added
- **Character profiles now show the generated voice filename and a play button** when the assigned voice has a saved local audio file. Playback reuses the existing voice-file preview and does not synthesize a new sample.
## [1.20.39] — 2026-08-19
### Changed
- **Low-memory devices now receive conservative batch concurrency.** Shared frontend worker pools cap themselves at two concurrent tasks when the browser reports approximately 4 GB RAM or less, reducing memory spikes during bulk operations.
## [1.20.38] — 2026-08-19
### Added
- **Characters / Cast now has a Portainer-style column chooser.** Use **Show / Hide Columns** to control the table density; visibility preferences are saved locally per user.
- **Profile completeness is positioned beside moral alignment.** The sortable `filled / 32` profile indicator is now directly before the assigned voice column.
### Fixed
- **The cast table’s horizontal scrollbar could disappear** after the column layout changed. The table now preserves a wide scrollable canvas and displays a dedicated horizontal scrollbar.
## [1.20.37] — 2026-08-19
### Added
- **Character profile completeness is visible in the cast table.** The table now shows how many of the 32 profile fields are filled, with visual sparse/partial/complete states and sorting by completeness.
- **ComfyUI character artwork workflows are available locally.** Profile portraits and 3:2 character concept sheets can be generated through the configured ComfyUI provider, including bulk generation from the cast table.
### Changed
- **Voice-design prompts now enforce audible identity constraints.** Generated prompts preserve the book language and native accent, respect gender and apparent age, and require more distinct vocal fingerprints between characters.
- **Voice ratings remain review feedback rather than casting filters.** One- to three-star voices stay eligible while their prompts and profiles are improved.
### Fixed
- **Assigned voice ratings could appear to vanish in Characters / Cast.** The table now waits for the shared voice library metadata before rendering ratings.
- **Character-table columns could shift out of alignment.** The Book / Script row cell now matches its header, keeping the profile-completion and export columns in the correct positions.
- **WhisperX Blackwell status naming and health checks were inconsistent.** The engine and status bar now identify the service consistently, and health checks no longer send unnecessary transcription requests.
## [1.20.36] — 2026-08-15
### Added
- **Character image workflow is now directly editable.** Click a character avatar or profile image to open a large editor with local upload, online-image download, and regeneration controls. Concept art opens in a wider preview/editor with the same three actions.
- **Voice review is now faster in character profiles.** A compact blue play/stop control sits beside the assigned voice, and the adjacent circular regenerate control opens the editable Voice Design prompt before creating a replacement voice.
- **Character table view now mirrors the Voice Library’s review data.** Each assigned voice has its own editable star rating and note, plus a Voice Design Prompt column sourced from the voice metadata (with the character-sheet prompt as fallback).
- **Book context is generated during character-sheet casting when it is missing.** Genre, setting, era and language are shared with the profile, image, concept-art and voice-prompt workflows.
- **Selective character casting has a larger, readable chooser.** The action is now labelled **“Select characters to cast or recast roles”**, and its list keeps names and line counts aligned.
### Changed
- **Voice design prompts now use audible identity constraints.** They are concise English descriptions capped at 480 characters for CosyVoice compatibility, with explicit gender, age, native-language delivery, pitch/timbre/pace and distinctive vocal identity. Species and roles are translated into practical sound cues (for example Orc, vampire, elf, warrior, blacksmith and mage) rather than passed as lore labels alone.
- **Concept-art prompt construction better anchors profile facts.** Gender, species, physical features, clothing, signature props and the book context are carried into a consistent front/side/back character-reference prompt.
- **ElevenLabs voice imports remain on Get a Voice Online after importing**, enabling several voices to be collected in succession rather than forcing a jump back to the voice library.
### Fixed
- **Studio casting controls could disappear completely.** The Studio toolbar used to hide its own controls after borrowing the casting footer; an asynchronous footer rebuild could then leave an empty bar. The real Identify / Cast Characters / Cast submenu bar is now relocated only after it has fully rendered, while the simple fallback controls remain visible until then.
- **Selective casting could generate the wrong character.** Alias matching could resolve the selected roster name to another character’s profile. The selected sidebar name is now authoritative when a profile is generated and saved.
- **Editing a Voice Design prompt could leave the character detail page blank.** The detail page now re-renders from the saved character record after generation instead of rebuilding from stale in-memory data.
- **Google image generation reports provider failures instead of silently falling back**, and character-image generation skips sparse profiles rather than producing generic placeholder portraits.
## [1.20.8] — 2026-08-12 ## [1.20.8] — 2026-08-12
### Added ### Added

View File

@ -31,6 +31,7 @@ COPY CHANGELOG.md .
COPY VERSION . COPY VERSION .
COPY core/ core/ COPY core/ core/
COPY routes/ routes/ COPY routes/ routes/
COPY workflows/ workflows/
COPY static/ static/ COPY static/ static/
# ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─ # ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─

108
TODO.md Normal file
View File

@ -0,0 +1,108 @@
# TTS Voice Creator Roadmap
This roadmap is ordered by priority. Refactors should be incremental and covered by tests before behavior is changed.
Priority levels:
- **P0 — safety:** prevents crashes, data loss, broken releases, or security incidents.
- **P1 — high impact:** improves memory use, responsiveness, and maintainability.
- **P2 — quality:** improves diagnostics, polish, and long-term operations.
## P0 — Testing, data safety, and resource limits
- [x] Establish a Playwright baseline before large refactors (app shell, bundle, i18n, clone tabs, and Rehearser parsing; installed Chromium supported).
- [x] Add explicit Docker memory limits where appropriate; the web container defaults to an 8 GB limit and 2 GB reservation with environment overrides.
- [ ] Limit upload sizes, temporary files, and concurrent jobs.
- [x] Use atomic writes for `.meta.json` files.
- [ ] Create automatic backups before bulk deletes, voice moves, and recasts.
- [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints.
## P1 — Modular frontend architecture
- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility.
- [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`.
- [ ] Generate `loader.js` and `scripts/minify.mjs` from one module manifest.
- [ ] Add linting and duplicate-function checks.
- [ ] Extract Rehearser voice matching and voice design.
- [ ] Extract Audiobook parsing and casting.
- [ ] Split `style.css` by feature.
- [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests.
## P1 — Frontend performance and memory safety
- [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened.
- [ ] Virtualize or paginate large character and voice tables; render approximately 30–50 visible rows.
- [ ] Cache `/api/voices` with explicit invalidation instead of repeated full scans.
- [x] Lazy-load profile images and concept art.
- [ ] Update only affected table rows instead of rebuilding entire tables.
- [ ] Add an LRU cache for audio and images and remove old entries automatically.
- [x] Revoke discarded object URLs with `URL.revokeObjectURL(objectUrl)`; completed synthesized voice previews now release their Blob URLs when playback ends.
- [x] Add low-memory mode using `navigator.deviceMemory` where available; shared worker pools cap at two tasks on devices reporting approximately 4 GB RAM or less.
- [ ] Disable image preloading and reduce table/cache sizes on devices with approximately 4 GB RAM.
## P1 — Backend stability and bounded concurrency
- [ ] Introduce background jobs for voice design, transcription, casting, and image generation.
- [ ] Give every job an ID, progress, cancellation, timeout, retry, and idempotency key.
- [ ] Prevent duplicate generation when a user clicks twice.
- [ ] Centralize provider health checks and error handling.
- [ ] Limit concurrency to 1–2 image generations, 1–2 voice generations, and 1 STT request per user.
- [ ] Use bounded server queues.
## P0 — Security
- [ ] Never expose API keys to frontend JavaScript or logs.
- [ ] Validate uploaded filenames and paths.
- [ ] Strictly validate custom URLs to prevent SSRF.
- [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints.
- [ ] Run containers as non-root with dropped capabilities and read-only filesystems where practical.
- [ ] Isolate Docker socket access behind a minimal privileged service.
## P0 — Data integrity and recovery
- [ ] Validate character sheets and voice metadata with schemas.
- [ ] Add database migrations instead of silently changing record shapes.
- [ ] Use atomic writes for `.meta.json` files.
- [ ] Create automatic backups before bulk deletes, voice moves, and recasts.
- [ ] Store prompt versions and generation history so bad regenerations can be reverted.
## P0 — Testing
### Automated tests
- [ ] Character parsing and phantom-speaker prevention.
- [ ] Voice-matching constraints.
- [ ] Profile-completeness counting.
- [ ] Rating persistence.
- [ ] STT endpoint normalization.
- [ ] ComfyUI workflow submission.
- [ ] API failure and timeout behavior.
### Playwright smoke suite
- [x] Load the app.
- [x] Verify the production bundle loads.
- [x] Verify language switching and Rehearser parsing.
- [x] Verify clone source tabs switch correctly.
- [ ] Open Characters / Cast.
- [ ] Sort the profile column.
- [ ] Hide and show columns.
- [ ] Rate a voice.
- [ ] Start and cancel a generation job.
## P2 — Observability
- [ ] Add structured logs with request and job IDs.
- [ ] Report provider, endpoint, duration, and error reason clearly.
- [ ] Separate health status from “configured”.
- [ ] Add visible job history and retry controls.
- [ ] Avoid logging prompts, API keys, or book contents by default.
## P1 — Resource limits and constrained-device testing
- [x] Add explicit Docker memory limits; the web container defaults to `mem_limit: 8g` and `mem_reservation: 2g`, configurable per host.
- [x] Limit upload sizes and clean converted upload intermediates; continue expanding endpoint-specific temporary-file and job limits.
- [ ] Test with Chrome DevTools CPU/memory throttling.
- [ ] Test in a VM or container limited to 4 GB RAM.
- [ ] Monitor browser-tab memory, JavaScript heap, DOM-node count, active audio blobs, Docker memory, and temporary disk usage.
- [ ] Add a memory diagnostics page showing browser heap where supported, rendered rows, cache count/size, active jobs, server/container memory, and largest recent operations.

View File

@ -1 +1 @@
1.20.35 1.21.0

View File

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import shutil import shutil
import tempfile
import time import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@ -67,17 +69,27 @@ def _is_sound_asset_file(path: Path) -> bool:
_AUDIO_EXTS_SET = frozenset(_AUDIO_EXTS) _AUDIO_EXTS_SET = frozenset(_AUDIO_EXTS)
def _is_trashed_voice_file(path: Path) -> bool:
"""Keep recoverable voice packages out of normal library discovery."""
return any(part.lower() == "trash_voices" for part in path.parts)
def _voice_audio_files(root: Path): def _voice_audio_files(root: Path):
# Single rglob pass instead of one per extension (6× faster directory scan) # Single rglob pass instead of one per extension (6× faster directory scan)
for p in root.rglob("*"): for p in root.rglob("*"):
if p.suffix.lower() in _AUDIO_EXTS_SET and not _is_internal_voice_file(p) and not _is_sound_asset_file(p): if (
p.suffix.lower() in _AUDIO_EXTS_SET
and not _is_internal_voice_file(p)
and not _is_sound_asset_file(p)
and not _is_trashed_voice_file(p)
):
yield p yield p
def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None: def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None:
for ext in _AUDIO_EXTS: for ext in _AUDIO_EXTS:
for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")): for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")):
if not _is_internal_voice_file(p) and not _is_sound_asset_file(p): if not _is_internal_voice_file(p) and not _is_sound_asset_file(p) and not _is_trashed_voice_file(p):
return p return p
return None return None
@ -109,7 +121,24 @@ def _load_meta(wav: Path) -> dict:
def _save_meta(wav: Path, meta: dict) -> None: def _save_meta(wav: Path, meta: dict) -> None:
_meta_path(wav).write_text(json.dumps(meta, indent=2)) # Write beside the final file and replace it atomically. A process exit or
# host/container interruption during write must never leave a truncated
# meta.json that makes ratings/settings appear to have vanished.
target = _meta_path(wav)
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(meta, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, target)
finally:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
def _picture_path(wav: Path) -> Path | None: def _picture_path(wav: Path) -> Path | None:
@ -322,7 +351,11 @@ def _voice_health(
def _voice_entry(p: Path) -> dict: def _voice_entry(p: Path) -> dict:
meta = _load_meta(p) meta = _load_meta(p)
parts = p.stem.split("_", 2) parts = p.stem.split("_", 2)
lang = parts[0].upper() if parts else "" # A saved user choice always wins over the filename. Provider-prefixed
# imports such as EL_EN_F_* use EL as an import/source marker, not a
# language, so deriving the display language solely from the ID made a
# manually selected DE revert after every refresh.
lang = str(meta.get("lang") or (parts[0] if parts else "")).strip().upper()
try: try:
duration = round(_duration(p), 2) duration = round(_duration(p), 2)
except Exception: except Exception:
@ -401,7 +434,31 @@ def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str
from core.tts_helpers import _tts_benchmark_request from core.tts_helpers import _tts_benchmark_request
has_ref, _transcript = _read_reference_text(audio) has_ref, _transcript = _read_reference_text(audio)
meta = _load_meta(audio) meta = _load_meta(audio)
is_designed = meta.get("origin") == "designed" or not has_ref # An imported WAV without a transcript is still a clone voice. Treating
# every such file as a prompt-designed voice sent it to the unrelated
# Voice Design service (8021), producing a misleading 404. Qwen clone
# synthesis requires the exact reference transcript, so surface that
# prerequisite directly instead.
if not has_ref:
error = "Reference transcript required before this saved WAV voice can be benchmarked. Add or generate its exact transcript first."
return {
"ok": False,
"realtime_ok": False,
"elapsed_sec": None,
"audio_sec": None,
"rtf": None,
"speed": None,
"ttfa_ms": None,
"bytes": 0,
"clipped": False,
"benchmarked_at": datetime.now(timezone.utc).isoformat(),
"text": sentences[0][1] if len(sentences) == 1 else "short / medium / long",
"runs": [{"ok": False, "label": label, "text": text, "error": error} for label, text in sentences],
"summary": {},
"advice": ["Generate an exact reference transcript with STT, then retry the benchmark."],
"error": error,
}
is_designed = meta.get("origin") == "designed"
runs: list[dict] = [] runs: list[dict] = []
for label, text in sentences: for label, text in sentences:
try: try:

View File

@ -7,6 +7,12 @@ services:
container_name: tts-voice-creator-clone-and-design-2 container_name: tts-voice-creator-clone-and-design-2
restart: unless-stopped restart: unless-stopped
# Keep a runaway OCR/import/generation request from consuming all host RAM.
# Override these per machine when large PDF jobs need more headroom:
# APP_MEM_LIMIT=12g APP_MEM_RESERVATION=2g docker compose up -d
mem_limit: "${APP_MEM_LIMIT:-8g}"
mem_reservation: "${APP_MEM_RESERVATION:-2g}"
ports: ports:
- "7890:7890" - "7890:7890"
@ -24,6 +30,7 @@ services:
- ./server.py:/app/server.py:ro - ./server.py:/app/server.py:ro
- ./core:/app/core:ro - ./core:/app/core:ro
- ./routes:/app/routes:ro - ./routes:/app/routes:ro
- ./workflows:/app/workflows:ro
- ./VERSION:/app/VERSION:ro - ./VERSION:/app/VERSION:ro
- ./CHANGELOG.md:/app/CHANGELOG.md:ro - ./CHANGELOG.md:/app/CHANGELOG.md:ro

View File

@ -1,8 +1,11 @@
// @ts-check // @ts-check
const { defineConfig, devices } = require('@playwright/test'); const { defineConfig, devices } = require('@playwright/test');
const fs = require('fs');
// Point at a running instance. The app's docker container serves on :7890. // Point at a running instance. The app's docker container serves on :7890.
const BASE_URL = process.env.BASE_URL || 'http://localhost:7890'; const BASE_URL = process.env.BASE_URL || 'http://localhost:7890';
const SYSTEM_CHROMIUM = process.env.CHROMIUM_PATH || '/snap/bin/chromium';
const browserOptions = fs.existsSync(SYSTEM_CHROMIUM) ? { launchOptions: { executablePath: SYSTEM_CHROMIUM } } : {};
module.exports = defineConfig({ module.exports = defineConfig({
testDir: './tests', testDir: './tests',
@ -22,8 +25,8 @@ module.exports = defineConfig({
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
projects: [ projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'desktop', use: { ...devices['Desktop Chrome'], ...browserOptions } },
// iPhone profile — guards the mobile-stability work // iPhone profile — guards the mobile-stability work
{ name: 'iphone', use: { ...devices['iPhone 13'] } }, { name: 'iphone', use: { ...devices['iPhone 13'], ...browserOptions } },
], ],
}); });

View File

@ -6,4 +6,5 @@ numpy>=1.26.0
pedalboard>=0.9.0 pedalboard>=0.9.0
scipy>=1.12.0 scipy>=1.12.0
requests>=2.31.0 requests>=2.31.0
Pillow>=10.3.0
yt-dlp>=2024.3.10 yt-dlp>=2024.3.10

View File

@ -57,7 +57,11 @@ async def characters_image(char_id: str):
raw = base64.b64decode(m.group(2)) raw = base64.b64decode(m.group(2))
except Exception: except Exception:
raise HTTPException(404, "Invalid image data") raise HTTPException(404, "Invalid image data")
return Response(content=raw, media_type=m.group(1)) return Response(
content=raw,
media_type=m.group(1),
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
)
@router.get("/api/characters/{char_id:path}") @router.get("/api/characters/{char_id:path}")

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@ _LOCAL_CONTAINER_DEFS: list[dict] = [
{"name": "nvidia-zeroshot-nim", "label": "NVIDIA Zeroshot NIM", "role": "tts", "port": 8093, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Zeroshot clone endpoint with audio_prompt."}, {"name": "nvidia-zeroshot-nim", "label": "NVIDIA Zeroshot NIM", "role": "tts", "port": 8093, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Zeroshot clone endpoint with audio_prompt."},
{"name": "nvidia-flow-nim", "label": "NVIDIA Flow NIM", "role": "tts", "port": 8094, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Flow clone endpoint with audio_prompt and transcript."}, {"name": "nvidia-flow-nim", "label": "NVIDIA Flow NIM", "role": "tts", "port": 8094, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Flow clone endpoint with audio_prompt and transcript."},
{"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."}, {"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."},
{"name": "whisperx-blackwell", "label": "WhisperX Blackwell", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service. Use as custom STT if its API is OpenAI-compatible or /transcribe."}, {"name": "stt-whisperx-blackwell", "label": "WhisperX · faster-whisper (Blackwell GPU)", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service using faster-whisper on the Blackwell GPU. Use as custom STT if its API is OpenAI-compatible or /transcribe."},
{"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."}, {"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."},
] ]

View File

@ -5,6 +5,7 @@ import asyncio
import re import re
import shutil import shutil
import uuid import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
@ -39,6 +40,68 @@ from core.voice_index import (
router = APIRouter() router = APIRouter()
_voice_prepare_jobs: dict[str, dict] = {}
async def _prepare_voice_reference(audio: Path, settings: dict) -> dict:
"""Transcribe, publish, warm, and benchmark one saved WAV voice."""
meta = _load_meta(audio)
meta["import_preparation"] = "transcribing"
meta.pop("import_preparation_error", None)
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
try:
from routes.stt import _transcribe_audio
transcript, backend = await asyncio.to_thread(_transcribe_audio, audio, settings, "configured")
transcript = transcript.strip()
if not transcript:
raise RuntimeError("STT returned an empty transcript")
audio.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
from routes.tts import sync_voice_clone_reference
meta = _load_meta(audio)
await asyncio.to_thread(sync_voice_clone_reference, audio, transcript, str(meta.get("lang") or ""), settings)
meta = _load_meta(audio)
meta["reference_transcript_status"] = "ready"
meta["reference_transcript_backend"] = backend
meta["import_preparation"] = "benchmarking"
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as exc:
meta = _load_meta(audio)
meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "pending_stt"
meta["import_preparation_error"] = str(exc)[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return {"ok": False, "stage": "transcription", "error": str(exc)}
benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, list(_BENCHMARK_SENTENCES))
meta = _load_meta(audio)
meta["benchmark"] = benchmark
meta["import_preparation"] = "ready" if benchmark.get("ok") else "pending_tts"
if benchmark.get("ok"):
meta.pop("import_preparation_error", None)
else:
meta["import_preparation_error"] = str(benchmark.get("error") or "Benchmark failed")[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return {"ok": bool(benchmark.get("ok")), "stage": "benchmark", "error": benchmark.get("error", "")}
async def _run_prepare_missing_job(job_id: str, voices: list[Path], settings: dict) -> None:
job = _voice_prepare_jobs[job_id]
job["state"] = "running"
for index, audio in enumerate(voices, start=1):
job.update({"current": audio.stem, "completed": index - 1})
result = await _prepare_voice_reference(audio, settings)
job["completed"] = index
if result.get("ok"):
job["ready"] += 1
else:
job["failed"] += 1
job["errors"].append({"voice_id": audio.stem, "stage": result.get("stage"), "detail": result.get("error", "")})
job.update({"state": "complete", "current": "", "finished_at": datetime.now().isoformat()})
# ── Book/production profile (genre, setting, era, language) ────────────────── # ── Book/production profile (genre, setting, era, language) ──────────────────
# A one-time-per-book note the user fills in ("German, fantasy like Lord of # A one-time-per-book note the user fills in ("German, fantasy like Lord of
@ -63,16 +126,18 @@ async def get_book_profile(book: str):
@router.post("/api/book-profile") @router.post("/api/book-profile")
async def save_book_profile(request: Request): async def save_book_profile(request: Request):
from core.database import state_put from core.database import state_get, state_put
data = await request.json() data = await request.json()
book = str(data.get("book", "")).strip() book = str(data.get("book", "")).strip()
if not book: if not book:
raise HTTPException(400, "book is required") raise HTTPException(400, "book is required")
previous = state_get(_book_profile_key(book), {}) or {}
profile = { profile = {
"genre": str(data.get("genre", "")).strip()[:200], "genre": str(data.get("genre", "")).strip()[:200],
"setting": str(data.get("setting", "")).strip()[:200], "setting": str(data.get("setting", "")).strip()[:200],
"era": str(data.get("era", "")).strip()[:200], "era": str(data.get("era", "")).strip()[:200],
"language": str(data.get("language", "")).strip()[:60], "language": str(data.get("language", "")).strip()[:60],
"confirmed": bool(data.get("confirmed", previous.get("confirmed", False))),
} }
state_put(_book_profile_key(book), profile) state_put(_book_profile_key(book), profile)
return {"ok": True, "profile": profile} return {"ok": True, "profile": profile}
@ -86,12 +151,22 @@ async def upload(file: UploadFile = File(...)):
if suffix not in _UPLOAD_EXTS: if suffix not in _UPLOAD_EXTS:
raise HTTPException(400, "Unsupported audio/video file type") raise HTTPException(400, "Unsupported audio/video file type")
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
with dest.open("wb") as f:
_copy_limited(file.file, f, _MAX_UPLOAD_BYTES)
try: try:
with dest.open("wb") as f:
_copy_limited(file.file, f, _MAX_UPLOAD_BYTES)
wav = _to_wav_24k(dest) wav = _to_wav_24k(dest)
except Exception as e: except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(400, f"Audio conversion failed: {e}") raise HTTPException(400, f"Audio conversion failed: {e}")
finally:
# The converted WAV is registered below; the original upload is only
# an intermediate and must not accumulate in TEMP_DIR after every
# preview/upload operation.
try:
dest.unlink(missing_ok=True)
except Exception:
pass
fid = uuid.uuid4().hex fid = uuid.uuid4().hex
_registry_put(fid, wav) _registry_put(fid, wav)
return {"id": fid, "duration": _duration(wav), "filename": file.filename} return {"id": fid, "duration": _duration(wav), "filename": file.filename}
@ -381,10 +456,11 @@ async def update_voice_meta(request: Request):
meta = _load_meta(wav) meta = _load_meta(wav)
meta["enabled"] = enabled meta["enabled"] = enabled
for field in ("note", "rating", "flag", "gender", "loudness", "persona", "origin", "group", for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group",
"name", "tag", "avatar", "voice_design_prompt"): "name", "tag", "source", "avatar", "voice_design_prompt"):
if field in data: if field in data:
meta[field] = data[field] # Older clients sent `language`; keep metadata canonical as `lang`.
meta["lang" if field == "language" else field] = data[field]
if "transcript" in data: if "transcript" in data:
meta["needs_tts_restart"] = True meta["needs_tts_restart"] = True
_save_meta(wav, meta) _save_meta(wav, meta)
@ -525,6 +601,44 @@ async def benchmark_voices(request: Request):
} }
@router.post("/api/voices/prepare-missing-references")
async def prepare_missing_references(request: Request):
"""Queue STT transcript + Qwen warm-up/benchmark for active voices missing text."""
data = await request.json()
requested_ids = {str(v).strip() for v in (data.get("voice_ids") or []) if str(v).strip()}
settings = _load_settings()
active_dir = _active_voices_dir(settings)
candidates = []
for audio in sorted(_voice_audio_files(active_dir), key=lambda p: p.stem.lower()):
if requested_ids and audio.stem not in requested_ids:
continue
has_ref, text = _read_reference_text(audio)
if not has_ref or not text.strip():
candidates.append(audio)
job_id = uuid.uuid4().hex
_voice_prepare_jobs[job_id] = {
"id": job_id,
"state": "queued",
"total": len(candidates),
"completed": 0,
"ready": 0,
"failed": 0,
"current": "",
"errors": [],
"started_at": datetime.now().isoformat(),
}
asyncio.create_task(_run_prepare_missing_job(job_id, candidates, settings))
return {"job_id": job_id, "queued": len(candidates)}
@router.get("/api/voices/prepare-missing-references/{job_id}")
async def get_prepare_missing_references_job(job_id: str):
job = _voice_prepare_jobs.get(job_id)
if not job:
raise HTTPException(404, "Preparation job not found")
return job
@router.post("/api/voices/normalize-active") @router.post("/api/voices/normalize-active")
async def normalize_active_voices(): async def normalize_active_voices():
settings = _load_settings() settings = _load_settings()
@ -694,26 +808,35 @@ async def rename_voice(request: Request):
# ── Voice delete ────────────────────────────────────────────────────────────── # ── Voice delete ──────────────────────────────────────────────────────────────
def _voice_trash_batch_dir(scan_dir: Path, label: str) -> Path:
"""Create a non-discoverable, timestamped recycle-bin batch for voice packages."""
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", label).strip("-") or "voices"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return scan_dir / "trash_voices" / f"{stamp}-{safe_label}-{uuid.uuid4().hex[:8]}"
def _trash_voice_package(audio: Path, batch_dir: Path) -> Path:
"""Move a complete voice package together so it can be restored intact later."""
return _move_voice_package(audio, batch_dir / audio.stem)
@router.post("/api/voices/delete-group") @router.post("/api/voices/delete-group")
async def delete_voice_group(request: Request): async def delete_voice_group(request: Request):
"""Delete every voice whose meta `group` matches the given name. Used to bulk-remove """Move every voice in a group to the recoverable voice trash."""
a rehearser play's auto-designed voices."""
data = await request.json() data = await request.json()
group = str(data.get("group") or "").strip() group = str(data.get("group") or "").strip()
if not group: if not group:
raise HTTPException(400, "No group specified") raise HTTPException(400, "No group specified")
settings = _load_settings() settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
deleted_ids = [] trashed_ids = []
batch_dir = _voice_trash_batch_dir(scan_dir, f"group-{group}")
for p in _voice_audio_files(scan_dir): for p in _voice_audio_files(scan_dir):
meta = _load_meta(p) meta = _load_meta(p)
if str(meta.get("group") or "").strip() == group: if str(meta.get("group") or "").strip() == group:
for f in _voice_package_paths(p): _trash_voice_package(p, batch_dir)
if f.exists(): trashed_ids.append(p.stem)
f.unlink()
deleted_ids.append(p.stem)
remove_voice_from_index(p.stem) remove_voice_from_index(p.stem)
return {"deleted": deleted_ids, "count": len(deleted_ids)} return {"trashed": trashed_ids, "count": len(trashed_ids), "trash_batch": str(batch_dir)}
@router.delete("/api/voice/{voice_id}") @router.delete("/api/voice/{voice_id}")
@ -725,13 +848,10 @@ async def delete_voice(voice_id: str):
wav = _find_voice_audio(voice_id, scan_dir) wav = _find_voice_audio(voice_id, scan_dir)
if wav is None: if wav is None:
raise HTTPException(404, f"Voice '{voice_id}' not found") raise HTTPException(404, f"Voice '{voice_id}' not found")
deleted = [] batch_dir = _voice_trash_batch_dir(scan_dir, f"voice-{voice_id}")
for f in _voice_package_paths(wav): moved = _trash_voice_package(wav, batch_dir)
if f.exists():
f.unlink()
deleted.append(f.name)
remove_voice_from_index(voice_id) remove_voice_from_index(voice_id)
return {"deleted": deleted} return {"trashed": [p.name for p in _voice_package_paths(moved)], "trash_batch": str(batch_dir)}
# ── Voice picture upload / serve ────────────────────────────────────────────── # ── Voice picture upload / serve ──────────────────────────────────────────────

View File

@ -16,18 +16,82 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from core.config import _load_settings from core.config import _load_settings
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
from core.registry import _registry_put, TEMP_DIR from core.registry import _registry_put, TEMP_DIR
from core.validation import _validate_http_url from core.validation import _validate_http_url
from core.audio import _to_wav_24k, _duration from core.audio import _to_wav_24k, _duration
from core.voice import ( from core.voice import (
_AUDIO_EXTS, _active_voices_dir, _AUDIO_EXTS, _active_voices_dir,
_remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta, _remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta,
_benchmark_voice,
) )
from core.voice_index import upsert_voice_in_index from core.voice_index import upsert_voice_in_index
router = APIRouter() router = APIRouter()
async def _prepare_imported_voice(audio: Path, settings: dict) -> None:
"""Make a downloaded reference usable for Qwen clone playback.
Public catalogue previews usually arrive without a transcript. Qwen clone
needs that exact text before it can build a speaker embedding, so imports
transcribe first and only then warm/benchmark the voice. Any unavailable
local service is captured in metadata rather than making the import fail.
"""
meta = _load_meta(audio)
meta["import_preparation"] = "transcribing"
meta.pop("import_preparation_error", None)
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
try:
from routes.stt import _transcribe_audio
transcript, backend = await asyncio.to_thread(_transcribe_audio, audio, settings, "configured")
transcript = transcript.strip()
if not transcript:
raise RuntimeError("STT returned an empty transcript")
audio.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
from routes.tts import sync_voice_clone_reference
meta = _load_meta(audio)
await asyncio.to_thread(
sync_voice_clone_reference, audio, transcript, str(meta.get("lang") or ""), settings
)
meta = _load_meta(audio)
meta["reference_transcript_status"] = "ready"
meta["reference_transcript_backend"] = backend
meta["import_preparation"] = "benchmarking"
meta.pop("import_preparation_error", None)
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as exc:
meta = _load_meta(audio)
meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "pending_stt"
meta["import_preparation_error"] = str(exc)[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return
try:
# Benchmark synthesis also creates/warms Qwen's cached speaker
# embedding, so no separate duplicate warm-up request is required.
benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, list(_BENCHMARK_SENTENCES))
meta = _load_meta(audio)
meta["benchmark"] = benchmark
meta["import_preparation"] = "ready" if benchmark.get("ok") else "pending_tts"
if benchmark.get("ok"):
meta.pop("import_preparation_error", None)
else:
meta["import_preparation_error"] = str(benchmark.get("error") or "Benchmark failed")[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as exc:
meta = _load_meta(audio)
meta["import_preparation"] = "pending_tts"
meta["import_preparation_error"] = str(exc)[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
# ── Voice source helpers ────────────────────────────────────────────────────── # ── Voice source helpers ──────────────────────────────────────────────────────
_VOICE_SOURCE_HEADERS = { _VOICE_SOURCE_HEADERS = {
@ -506,6 +570,9 @@ async def quick_import_voice(request: Request):
if not voice_id: if not voice_id:
raise HTTPException(400, "voice_id is required") raise HTTPException(400, "voice_id is required")
transcript = str(data.get("transcript") or "").strip() transcript = str(data.get("transcript") or "").strip()
language = str(data.get("lang") or data.get("language") or "").strip().upper()[:12]
gender = str(data.get("gender") or "").strip().upper()[:1]
auto_prepare = bool(data.get("auto_prepare", False))
suffix = Path(urlparse(audio_url).path).suffix.lower() suffix = Path(urlparse(audio_url).path).suffix.lower()
if suffix not in _AUDIO_EXTS: if suffix not in _AUDIO_EXTS:
@ -548,11 +615,28 @@ async def quick_import_voice(request: Request):
if transcript: if transcript:
(out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8") (out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8")
meta = _load_meta(wav_dest) meta = _load_meta(wav_dest)
meta["enabled"] = True meta.update({
meta["loudness"] = loudness "enabled": True,
"loudness": loudness,
"lang": language or str(meta.get("lang") or "").upper(),
"gender": gender if gender in {"F", "M", "N"} else meta.get("gender", ""),
})
for field in ("name", "tag", "group", "origin", "note", "source"):
if data.get(field) is not None:
meta[field] = str(data.get(field) or "").strip()[:500]
if auto_prepare and not transcript:
meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "queued"
_save_meta(wav_dest, meta) _save_meta(wav_dest, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
return {"voice_id": final_id, "loudness": loudness} if auto_prepare and not transcript:
asyncio.create_task(_prepare_imported_voice(wav_dest, settings))
return {
"voice_id": final_id,
"loudness": loudness,
"language": language,
"preparation": "queued" if auto_prepare and not transcript else "ready",
}
@router.get("/api/download-yt") @router.get("/api/download-yt")

View File

@ -127,7 +127,13 @@ def _stt_backend_label(backend: str, url: str) -> str:
"groq_whisper": "Groq Whisper (cloud · free)", "groq_whisper": "Groq Whisper (cloud · free)",
} }
port = _backend_port_label(url) port = _backend_port_label(url)
label = labels.get(backend, backend) # The WhisperX Blackwell service is configured through the generic
# faster_whisper backend, so identify it by its published port in the
# status bar instead of making the engine card and status disagree.
if backend == "faster_whisper" and port in {"8003", "8081"}:
label = "WhisperX · faster-whisper (Blackwell GPU)"
else:
label = labels.get(backend, backend)
return f"{port} {label}" if port else label return f"{port} {label}" if port else label
@ -152,15 +158,20 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
def _stt_backend_health(url: str) -> tuple[bool, list[str]]: def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
base = _validate_http_url(url, allow_private=True).rstrip("/") base = _validate_http_url(url, allow_private=True).rstrip("/")
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
# health at the service root, while its transcription route is under
# `/v1/audio/...`; without splitting the prefix we probe `/v1/health` and
# `/v1/v1/models`, incorrectly marking a healthy WhisperX service down.
api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base
models: list[str] = [] models: list[str] = []
ok = False ok = False
try: try:
r = requests.get(f"{base}/health", timeout=2) r = requests.get(f"{api_base}/health", timeout=2)
ok = r.status_code == 200 ok = r.status_code == 200
except Exception: except Exception:
pass pass
try: try:
r = requests.get(f"{base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"}) r = requests.get(f"{api_base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
if r.status_code == 200: if r.status_code == 200:
ok = True ok = True
payload = r.json() payload = r.json()
@ -174,42 +185,12 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
pass pass
if not ok: if not ok:
return False, models return False, models
try: # Do not send a real audio transcription as a health probe. WhisperX's
wav = _make_minimal_wav() # batch server uses one worker and a probe consumes the GPU for several
endpoint_found = False # seconds; while it runs, even /health blocks and the UI reports the
timed_out = False # otherwise healthy service as unreachable. The service health endpoint is
for path in ("/v1/audio/transcriptions", "/transcribe"): # the authoritative liveness check; the real transcription path is tested
try: # when the user actually transcribes audio.
r = requests.post(
f"{base}{path}",
files={"file": ("probe.wav", wav, "audio/wav")},
data={"model": "whisper-1", "response_format": "text"},
timeout=8,
)
if r.status_code in {404, 405}:
continue
if r.status_code == 500:
try:
detail = r.json().get("detail", "")
audio_quality_words = ("too short", "no speech", "audio", "empty",
"duration", "length", "silence")
ok = bool(detail) and any(w in detail.lower() for w in audio_quality_words)
except Exception:
ok = False
else:
ok = True
endpoint_found = True
break
except requests.exceptions.ConnectionError:
ok = False
endpoint_found = True
break
except Exception:
timed_out = True
if not endpoint_found and not timed_out:
ok = False
except Exception:
pass
return ok, models return ok, models
@ -236,12 +217,20 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
stt_key = _stt_backend_api_key(settings, backend) stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {} hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
model = _stt_backend_model(backend) model = _stt_backend_model(backend)
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"] # Accept both `http://host:port` and OpenAI-style `http://host:port/v1`.
# WhisperX's native fallback remains rooted at `/transcribe`.
if stt_url.endswith("/v1"):
paths = ["/audio/transcriptions"] if not backend.startswith("custom:") else ["/audio/transcriptions", "/../transcribe"]
else:
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
resp = None resp = None
for path in paths: for path in paths:
with src.open("rb") as f: with src.open("rb") as f:
endpoint = f"{stt_url}{path}"
if path == "/../transcribe":
endpoint = f"{stt_url[:-3].rstrip('/')}/transcribe"
resp = requests.post( resp = requests.post(
f"{stt_url}{path}", endpoint,
files={"file": ("audio.wav", f, "audio/wav")}, files={"file": ("audio.wav", f, "audio/wav")},
data={"model": model, "response_format": "text"}, data={"model": model, "response_format": "text"},
headers=hdrs, headers=hdrs,

View File

@ -231,6 +231,35 @@ def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str:
return f"/voices/{audio.name}" return f"/voices/{audio.name}"
def sync_voice_clone_reference(audio: Path, transcript: str, language: str, settings: dict) -> None:
"""Publish a saved reference transcript to Qwen's hot-reloaded voices.json."""
transcript = str(transcript or "").strip()
if not transcript:
raise ValueError("reference transcript is required")
voices_path = _seed_finder_voices_json_path(settings)
if not voices_path.exists():
raise RuntimeError(f"TTS voices.json not found at {voices_path}")
language_name = {
"DE": "German", "EN": "English", "FR": "French", "ES": "Spanish",
"IT": "Italian", "PT": "Portuguese", "NL": "Dutch", "PL": "Polish",
"ZH": "Chinese", "JA": "Japanese", "KO": "Korean",
}.get(str(language or "").upper(), "Auto")
with _SEED_FINDER_VOICE_LOCK:
voices = json.loads(voices_path.read_text(encoding="utf-8"))
if not isinstance(voices, dict):
raise RuntimeError("voices.json is not an object")
cfg = dict(voices.get(audio.stem) or {})
cfg["ref_audio"] = _seed_finder_backend_ref_audio(audio, settings)
cfg["ref_text"] = transcript
cfg["language"] = language_name
# The old embedding belongs to a voice without this transcript.
# Removing it forces one correct warm-up on the next synthesis.
cfg.pop("speaker_embeddings", None)
voices[audio.stem] = cfg
voices_path.write_text(json.dumps(voices, indent=2, ensure_ascii=False), encoding="utf-8")
time.sleep(0.35)
def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict: def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
cfg = dict(voices.get(voice) or {}) cfg = dict(voices.get(voice) or {})
if cfg.get("ref_audio"): if cfg.get("ref_audio"):

View File

@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Resumable overnight export of the three DSA audiobooks, one MP3 per chapter."""
from __future__ import annotations
import io
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import wave
from pathlib import Path
from urllib.parse import quote
import requests
BASE = "http://127.0.0.1:7890"
ROOT = Path(__file__).resolve().parents[1]
CACHE = ROOT / "config" / "audiobook_batch_cache"
PAUSE_SENTENCE_MS = 120
PAUSE_PARAGRAPH_MS = 300
CHUNK_LIMIT = 900
BOOK_ORDER = [
"Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm",
"Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung",
"Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone",
]
CHAPTER = re.compile(
r"(?im)(?:^|\n\s*\n|\n\s*)(?P<header>(?:(?:\d{1,3}|[ivxlcdm]+)[.)]?\s*)?"
r"(?:kapitel|chapter|prolog|epilog)\b[^\n]{0,80})"
)
def key(value: str) -> str:
return str(value or "").strip().casefold()
def safe(value: str, limit: int = 80) -> str:
value = re.sub(r"[^A-Za-z0-9ÄÖÜäöüß _.-]+", "", value).strip()
return re.sub(r"\s+", "_", value)[:limit] or "chapter"
def split_text(text: str) -> list[tuple[str, int]]:
"""Return (text, pause-after-ms), preferring paragraph/sentence edges.
The old exporter flattened whitespace and cut at commas/spaces, which made
the TTS engine restart in the middle of a sentence. A paragraph is kept as
one request whenever possible. Only an oversized paragraph is divided,
first at sentence endings and only as a last resort at a clause/word edge.
"""
raw = str(text or "").replace("\r\n", "\n").strip()
paragraphs = [re.sub(r"\s+", " ", p).strip() for p in re.split(r"\n\s*\n+", raw) if p.strip()]
result: list[tuple[str, int]] = []
sentence_re = re.compile(r".+?(?:[.!?…]+[\"»”’']*|$)", re.S)
for paragraph in paragraphs:
if len(paragraph) <= CHUNK_LIMIT:
result.append((paragraph, PAUSE_PARAGRAPH_MS))
continue
sentences = [s.strip() for s in sentence_re.findall(paragraph) if s.strip()]
pieces: list[str] = []
current = ""
for sentence in sentences or [paragraph]:
if len(sentence) > CHUNK_LIMIT:
clauses = re.split(r"(?<=[,;:])\s+", sentence)
else:
clauses = [sentence]
for clause in clauses:
clause = clause.strip()
if not clause:
continue
joined = f"{current} {clause}".strip() if current else clause
if len(joined) <= CHUNK_LIMIT:
current = joined
else:
if current:
pieces.append(current)
current = clause
while len(current) > CHUNK_LIMIT:
cut = current.rfind(" ", 80, CHUNK_LIMIT + 1)
if cut <= 80:
cut = CHUNK_LIMIT
pieces.append(current[:cut].strip())
current = current[cut:].strip()
if current:
pieces.append(current)
for index, piece in enumerate(pieces):
result.append((piece, PAUSE_PARAGRAPH_MS if index == len(pieces) - 1 else PAUSE_SENTENCE_MS))
return result
def wav_pcm(body: bytes) -> tuple[bytes, wave._wave_params]:
with wave.open(io.BytesIO(body), "rb") as stream:
params = stream.getparams()
if params.sampwidth != 2:
raise RuntimeError(f"TTS returned non-16-bit WAV: {params}")
return stream.readframes(stream.getnframes()), params
def make_wav(raw: bytes, params: wave._wave_params) -> bytes:
output = io.BytesIO()
with wave.open(output, "wb") as stream:
stream.setnchannels(params.nchannels)
stream.setsampwidth(params.sampwidth)
stream.setframerate(params.framerate)
stream.writeframes(raw)
return output.getvalue()
def request_json(method: str, url: str, **kwargs):
kwargs.setdefault("timeout", 90)
for attempt in range(1, 4):
try:
response = requests.request(method, url, **kwargs)
if response.ok:
return response
if response.status_code < 500:
raise RuntimeError(f"{method} {url}: {response.status_code} {response.text[:300]}")
raise RuntimeError(f"server {response.status_code}: {response.text[:300]}")
except Exception:
if attempt == 3:
raise
time.sleep(attempt * 3)
def tts(voice: str, text: str) -> tuple[bytes, wave._wave_params]:
seed = int.from_bytes(hashlib.sha256(voice.encode("utf-8")).digest()[:8], "big") % 2147483647
response = request_json(
"POST", f"{BASE}/api/tts-preview",
json={"voice": voice, "text": text, "response_format": "wav", "backend": "voice_clone", "seed": seed},
timeout=180,
)
return wav_pcm(response.content)
def chapter_parts(segments: list[dict]) -> list[dict]:
"""Split segments at headings even when OCR put a heading inside a segment."""
buckets: list[dict] = [{"title": "", "segments": []}]
for segment in segments:
text = str(segment.get("text") or "").strip()
if not text:
continue
matches = list(CHAPTER.finditer(text))
if not matches:
buckets[-1]["segments"].append({**segment, "text": text})
continue
prefix = text[:matches[0].start()].strip()
if prefix:
buckets[-1]["segments"].append({**segment, "text": prefix})
for index, match in enumerate(matches):
if buckets[-1]["segments"] or buckets[-1]["title"]:
buckets.append({"title": match.group("header").strip(), "segments": []})
else:
buckets[-1]["title"] = match.group("header").strip()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
body = text[match.end():end].strip()
if body:
buckets[-1]["segments"].append({**segment, "text": body, "type": "narration", "speaker": "Narrator"})
return [bucket for bucket in buckets if bucket["segments"]]
def voice_map(book: str) -> tuple[dict[str, str], str]:
characters = request_json("GET", f"{BASE}/api/characters").json().get("characters", [])
mapping: dict[str, str] = {}
for character in characters:
if character.get("book") != book or not character.get("voice"):
continue
voice = str(character["voice"])
mapping[key(character.get("name"))] = voice
for alias in re.split(r"[,;|]", str((character.get("sheet") or {}).get("aliases") or "")):
if alias.strip():
mapping[key(alias)] = voice
narrator = mapping.get("narrator")
if not narrator:
raise RuntimeError(f"{book}: no Narrator voice assignment found")
return mapping, narrator
def render_book(doc: dict) -> None:
book = doc["title"]
doc_id = doc["id"]
cast = request_json("GET", f"{BASE}/api/reader/docs/{doc_id}/scripts/cast").json()
mapping, narrator = voice_map(book)
buckets = chapter_parts(cast.get("segments", []))
book_dir = CACHE / safe(book)
book_dir.mkdir(parents=True, exist_ok=True)
print(f"[{book}] {len(buckets)} chapter buckets", flush=True)
for chapter_index, bucket in enumerate(buckets, 1):
title = bucket["title"] or f"Chapter {chapter_index}"
chapter_dir = book_dir / f"{chapter_index:03d}_{safe(title, 48)}"
# New cache namespace: chunk boundaries changed from unsafe fixed
# character cuts to paragraph/sentence boundaries.
chunks_dir = chapter_dir / "chunks_v2_sentence_aware"
chunks_dir.mkdir(parents=True, exist_ok=True)
chunks: list[tuple[str, str, int]] = []
fallback = 0
for segment in bucket["segments"]:
speaker = key(segment.get("speaker") or "narrator")
voice = mapping.get(speaker, narrator)
if speaker not in mapping and speaker != "narrator":
fallback += 1
for text, pause_ms in split_text(segment.get("text")):
chunks.append((voice, text, pause_ms))
pcm_parts: list[bytes] = []
params = None
for index, (voice, text, pause_ms) in enumerate(chunks):
chunk_path = chunks_dir / f"{index:05d}.pcm"
meta_path = chunks_dir / f"{index:05d}.json"
if chunk_path.is_file() and meta_path.is_file():
pcm_parts.append(chunk_path.read_bytes())
if params is None:
params = wave._wave_params(**json.loads(meta_path.read_text()))
continue
raw, current = tts(voice, text)
if params is None:
params = current
elif (current.nchannels, current.sampwidth, current.framerate, current.comptype, current.compname) != (params.nchannels, params.sampwidth, params.framerate, params.comptype, params.compname):
raise RuntimeError(f"{book} chapter {chapter_index}: WAV format changed mid-chapter")
chunk_path.write_bytes(raw)
meta_path.write_text(json.dumps({"nchannels": current.nchannels, "sampwidth": current.sampwidth, "framerate": current.framerate, "nframes": len(raw) // current.sampwidth // current.nchannels, "comptype": current.comptype, "compname": current.compname}))
pcm_parts.append(raw)
if (index + 1) % 25 == 0:
print(f"[{book}] chapter {chapter_index} {index + 1}/{len(chunks)} chunks", flush=True)
if not pcm_parts or params is None:
continue
merged_parts = []
for i, part in enumerate(pcm_parts):
if i:
pause_ms = chunks[i - 1][2]
silence = b"\0" * int(params.framerate * pause_ms / 1000) * params.sampwidth * params.nchannels
merged_parts.append(silence)
merged_parts.append(part)
merged = b"".join(merged_parts)
wav = make_wav(merged, params)
encoded = request_json("POST", f"{BASE}/api/audio/encode-mp3", data=wav, timeout=180).content
filename = f"{safe(book, 45)} - {chapter_index:02d} - {safe(title, 55)}.mp3"
upload_url = f"{BASE}/api/audiobook-export/{quote(book, safe='')}/{quote(filename, safe='')}"
request_json("POST", upload_url, data=encoded, timeout=180)
(chapter_dir / "complete.json").write_text(json.dumps({"filename": filename, "title": title, "chunks": len(chunks), "narrator_fallbacks": fallback, "bytes": len(encoded)}))
print(f"[{book}] COMPLETE chapter {chapter_index}/{len(buckets)}: {filename} ({len(encoded) / 1048576:.1f} MiB)", flush=True)
def main() -> None:
docs = request_json("GET", f"{BASE}/api/reader/docs").json().get("docs", [])
by_title = {doc.get("title"): doc for doc in docs}
for title in BOOK_ORDER:
if title not in by_title:
raise RuntimeError(f"Book document not found: {title}")
render_book(by_title[title])
(CACHE / "ALL_THREE_BOOKS_COMPLETE").write_text(time.strftime("%Y-%m-%dT%H:%M:%S%z"))
print("ALL THREE AUDIOBOOKS COMPLETE", flush=True)
if os.environ.get("RUN_IMAGE_SAMPLES") == "1":
print("Restarting Voice Creator before the ten image samples…", flush=True)
subprocess.run(["docker", "compose", "restart", "tts-voice-creator-clone-and-design-2"], cwd=ROOT, check=True)
time.sleep(3)
subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_character_image_samples.py")], cwd=ROOT, check=True)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("Interrupted; completed chunks remain cached for resume.", file=sys.stderr)
raise
except Exception as exc:
print(f"BATCH FAILED: {exc}", file=sys.stderr)
raise

View File

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Generate ten concept-sheet shared-identity character image pairs after export."""
from __future__ import annotations
import requests
BASE = "http://127.0.0.1:7890"
TARGETS = [
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Darrag"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Eolan"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Baronin Ira von Seewiesen"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Gamba"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Alrik von Blautann"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Andra"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Cindira"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Admiral Sanin"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Arthag"),
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Anshelm"),
]
def main() -> None:
all_chars = requests.get(f"{BASE}/api/characters", timeout=30).json()["characters"]
for book, name in TARGETS:
item = next(c for c in all_chars if c.get("book") == book and c.get("name") == name)
record = requests.get(f"{BASE}/api/characters/{item['id']}", timeout=30).json()
sheet = record.get("sheet") or {}
print(f"[{name}] generating concept profile + reference sheet", flush=True)
response = requests.post(
f"{BASE}/api/character-generate-consistent-images",
json={
"profile_prompt": sheet.get("image_prompt", ""),
"reference_prompt": sheet.get("concept_art_prompt", ""),
"visual_style": "concept",
"identity_image": record.get("image", ""),
},
# The local Qwen graph renders the portrait and 3:2 sheet in
# sequence; allow the app's 25-minute server-side deadline.
timeout=1800,
)
response.raise_for_status()
result = response.json()
record["image"] = result["profile"]
record["sheet"] = {**sheet, "concept_art_image": result["reference"]}
saved = requests.put(f"{BASE}/api/characters/{item['id']}", json=record, timeout=60)
saved.raise_for_status()
print(f"[{name}] saved", flush=True)
print("TEN CHARACTER IMAGE SAMPLES COMPLETE", flush=True)
if __name__ == "__main__":
main()

View File

@ -22,8 +22,8 @@ mkdirSync(outDir, { recursive: true });
const MAIN = [ const MAIN = [
'voice-picker', 'benchmark-voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser', 'voice-picker', 'benchmark-voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview', 'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets', 'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'audiobook/network', 'character-sheets',
'characters-library', 'sillytavern', 'library', 'library-characters', 'studio', 'characters-library', 'sillytavern', 'library', 'character-columns', 'library-characters', 'studio',
].map(n => join(jsDir, n + '.js')); ].map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n'); const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');

View File

@ -0,0 +1,137 @@
#!/usr/bin/env node
/** Render six dialogue-focused audiobook chapter trials through the live API.
*
* This deliberately reuses each saved character's assigned voice. Unknown
* one-off speakers use the book's Narrator voice rather than creating a new
* voice during a render.
*/
import fs from 'node:fs';
import path from 'node:path';
const base = process.env.VOICE_CREATOR_URL || 'http://127.0.0.1:7890';
const selections = [
{ cast: '/tmp/b1-cast.json', chapter: 6, slug: 'book-1-chapter-6' },
{ cast: '/tmp/b1-cast.json', chapter: 9, slug: 'book-1-chapter-9' },
{ cast: '/tmp/b2-cast.json', chapter: 5, slug: 'book-2-chapter-5' },
{ cast: '/tmp/b2-cast.json', chapter: 13, slug: 'book-2-chapter-13' },
{ cast: '/tmp/b3-cast.json', chapter: 4, slug: 'book-3-chapter-4' },
{ cast: '/tmp/b3-cast.json', chapter: 9, slug: 'book-3-chapter-9' },
];
const outDir = '/tmp/voice-creator-trials';
fs.mkdirSync(outDir, { recursive: true });
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const key = value => String(value || '').trim().toLocaleLowerCase('de-DE');
function chapterBounds(segments, chapter) {
const starts = [];
for (let i = 0; i < segments.length; i++) {
const match = String(segments[i].text || '').match(/(?:^|\s)(\d{1,2})\.\s*Kapitel\b/i);
if (match) starts.push({ index: i, chapter: Number(match[1]) });
}
const startAt = starts.findIndex(s => s.chapter === chapter);
if (startAt < 0) throw new Error(`Chapter ${chapter} was not found`);
return [starts[startAt].index, starts[startAt + 1]?.index ?? segments.length];
}
function splitText(text, limit = 620) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
if (!clean) return [];
const pieces = [];
let remaining = clean;
while (remaining.length > limit) {
const candidates = [remaining.lastIndexOf('. ', limit), remaining.lastIndexOf('! ', limit), remaining.lastIndexOf('? ', limit), remaining.lastIndexOf(', ', limit), remaining.lastIndexOf(' ', limit)];
const cut = Math.max(...candidates);
const end = cut > 80 ? cut + 1 : limit;
pieces.push(remaining.slice(0, end).trim());
remaining = remaining.slice(end).trim();
}
if (remaining) pieces.push(remaining);
return pieces;
}
function wavData(buffer) {
const b = Buffer.from(buffer);
if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') throw new Error('TTS response was not a WAV file');
let p = 12;
while (p + 8 <= b.length) {
const id = b.toString('ascii', p, p + 4);
const size = b.readUInt32LE(p + 4);
if (id === 'data') return b.subarray(p + 8, p + 8 + size);
p += 8 + size + (size % 2);
}
throw new Error('WAV data chunk was not found');
}
function wavFile(data) {
const header = Buffer.alloc(44);
header.write('RIFF', 0); header.writeUInt32LE(36 + data.length, 4); header.write('WAVEfmt ', 8);
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22);
header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
header.write('data', 36); header.writeUInt32LE(data.length, 40);
return Buffer.concat([header, data]);
}
async function charactersFor(title) {
const response = await fetch(`${base}/api/characters`);
if (!response.ok) throw new Error(`Cannot read character library: ${response.status}`);
const all = (await response.json()).characters || [];
const relevant = all.filter(c => c.book === title);
const voices = new Map();
for (const c of relevant) {
if (!c.voice) continue;
voices.set(key(c.name), c.voice);
for (const a of String(c.sheet?.aliases || '').split(/[,;|]/)) if (a.trim()) voices.set(key(a), c.voice);
}
return voices;
}
async function tts(voice, text) {
const response = await fetch(`${base}/api/tts-preview`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice, text, response_format: 'wav', backend: 'voice_clone' }),
});
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return wavData(await response.arrayBuffer());
}
async function render(selection) {
const cast = JSON.parse(fs.readFileSync(selection.cast, 'utf8'));
const [from, to] = chapterBounds(cast.segments, selection.chapter);
const voiceMap = await charactersFor(cast.title);
const narrator = voiceMap.get('narrator');
if (!narrator) throw new Error(`${cast.title}: narrator has no voice`);
const chunks = [];
let fallbackCount = 0;
for (const segment of cast.segments.slice(from, to)) {
const speaker = key(segment.speaker || 'Narrator');
const voice = voiceMap.get(speaker) || narrator;
if (!voiceMap.has(speaker) && speaker !== 'narrator') fallbackCount++;
for (const text of splitText(segment.text)) chunks.push({ voice, text });
}
const chunkDir = path.join(outDir, selection.slug);
fs.mkdirSync(chunkDir, { recursive: true });
let start = 0;
while (start < chunks.length && fs.existsSync(path.join(chunkDir, `${String(start).padStart(4, '0')}.pcm`))) start++;
console.log(`[${selection.slug}] chapter ${selection.chapter}, segments ${to - from}, requests ${chunks.length}, resume ${start}, narrator fallbacks ${fallbackCount}`);
for (let i = start; i < chunks.length; i++) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt++) {
try { fs.writeFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`), await tts(chunks[i].voice, chunks[i].text)); lastError = null; break; }
catch (err) { lastError = err; console.warn(`[${selection.slug}] request ${i + 1}/${chunks.length} attempt ${attempt}: ${err.message}`); await pause(1500 * attempt); }
}
if (lastError) throw lastError;
if ((i + 1) % 10 === 0 || i + 1 === chunks.length) console.log(`[${selection.slug}] ${i + 1}/${chunks.length}`);
}
const silence = Buffer.alloc(24000 / 4 * 2); // 250 ms 24kHz / mono / 16-bit
const raw = chunks.map((_, i) => fs.readFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`)));
const wav = wavFile(Buffer.concat(raw.flatMap((x, i) => i ? [silence, x] : [x])));
const file = `${selection.slug}.wav`;
fs.writeFileSync(path.join(outDir, file), wav);
const upload = await fetch(`${base}/api/audiobook-export/${encodeURIComponent(cast.title)}/${file}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav });
if (!upload.ok) throw new Error(`Export upload failed: ${upload.status} ${await upload.text()}`);
console.log(`[${selection.slug}] complete ${(wav.length / 1024 / 1024).toFixed(1)} MiB ${await upload.text()}`);
}
for (const selection of selections) await render(selection);
console.log('All six trial chapters are complete.');

View File

@ -0,0 +1,65 @@
import { chromium } from '@playwright/test';
const base = process.env.TTSC_URL || 'http://localhost:7890';
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || '/snap/bin/chromium' });
const page = await browser.newPage();
page.on('console', msg => { if (msg.type() === 'error') console.error('[browser]', msg.text()); });
await page.goto(base + '/#s-library', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.clGetAll === 'function' && typeof window._charAutoDesignVoice === 'function', null, { timeout: 30000 });
await page.evaluate(async () => {
if (typeof window.loadVoiceLibrary === 'function') await window.loadVoiceLibrary();
});
const targets = await page.evaluate(async () => {
const books = new Set([
'Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm',
'Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung',
'Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone',
]);
const chars = await window.clGetAll();
const voices = window._voices || [];
const ratings = new Map(voices.map(v => [v.id, Number(v.rating || 0)]));
return chars.filter(c => books.has(c.book) && c.voice && ratings.get(typeof c.voice === 'object' ? c.voice.id : c.voice) >= 1 && ratings.get(typeof c.voice === 'object' ? c.voice.id : c.voice) <= 2)
.map(c => {
const voiceId = typeof c.voice === 'object' ? c.voice.id : c.voice;
const voice = voices.find(v => v.id === voiceId) || {};
return { id: c.id, book: c.book, name: c.name, rating: ratings.get(voiceId), feedback: String(voice.note || '').trim() };
});
});
console.log(`Quality pass starting for ${targets.length} voices`);
let done = 0;
for (const target of targets) {
const result = await page.evaluate(async (target) => {
const all = await window.clGetAll();
const rec = all.find(c => c.id === target.id);
if (!rec) throw new Error('Character no longer exists');
const sheet = rec.sheet || {};
const sample = [sheet.physical, sheet.backstory, sheet.motivation].filter(Boolean).join(' ');
const language = typeof window.detectLang === 'function' && sample ? (window.detectLang(sample) || '') : '';
const llm = typeof window.statusLlmTarget === 'function' ? window.statusLlmTarget() : { url: '', model: '' };
const promptResponse = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet, voice_feedback: target.feedback || '', language, llm_url: llm.url, model: llm.model, fields: ['voice_design_prompt'] }),
});
if (!promptResponse.ok) throw new Error('Prompt generation: ' + promptResponse.status + ' ' + promptResponse.statusText);
const promptData = await promptResponse.json();
const prompt = String(promptData.voice_design_prompt || '').trim();
if (!prompt) throw new Error('Empty regenerated prompt');
rec.sheet = rec.sheet || {};
rec.sheet.voice_design_prompt = prompt;
rec.updated = new Date();
await window.clPut(rec);
await window._charAutoDesignVoice(rec, true, prompt);
return { name: rec.name, prompt };
}, target);
done += 1;
console.log(`[${done}/${targets.length}] ${target.rating}★ ${target.book} — ${result.name}`);
}
console.log('Quality pass complete');
await page.evaluate(async () => {
const r = await fetch('/api/tts/restart', { method: 'POST' });
if (!r.ok) throw new Error('TTS restart failed: ' + r.status);
});
console.log('TTS backend restarted; redesigned audio cache flushed');
await browser.close();

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.20.35"> <meta name="app-version" content="1.20.46">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.20.35"> <link rel="stylesheet" href="/static/style.css?v=1.20.46">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.20.35"></script> <script src="/static/loader.js?v=1.20.46"></script>
</body> </body>
</html> </html>

View File

@ -24,102 +24,42 @@ const AUDIOBOOK_RECAST_CONTEXT_CHARS = 4200;
const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6; const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6;
const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000; const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000;
const _audiobook = { running: false, cancel: false }; const _audiobook = { running: false, cancel: false };
// Studio relocates this control out of the audiobook panel. That means a
// normal panel rebuild can leave an old copy visible even after the async cast
// has finished or failed. Always clear every live copy when the worker state
// reaches a terminal state.
function _abHideCastingControl() {
document.querySelectorAll('#ab-cv-cancel').forEach(btn => {
btn.hidden = true;
btn.disabled = false;
btn.innerHTML = '<span class="mdi mdi-close"></span> Cancel';
});
}
// The casting footer is physically moved into Studio and is also rebuilt by
// the live view. A listener attached only to the original panel can therefore
// disappear with a rebuilt footer. Keep one delegated listener on document so
// Stop Casting always reaches the current button.
if (!window._abDelegatedCancelHandler) {
window._abDelegatedCancelHandler = true;
document.addEventListener('click', event => {
const btn = event.target.closest?.('#ab-cv-cancel');
if (!btn || btn.hidden) return;
if (_audiobook.running) {
_audiobook.cancel = true;
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Stopping...';
if (typeof _audiobook.abort === 'function') _audiobook.abort();
} else {
_abHideCastingControl();
}
});
}
window._audiobook = _audiobook; window._audiobook = _audiobook;
let _abDraftAutosaveTimer = null; let _abDraftAutosaveTimer = null;
let _abLastServerDraftErrorAt = 0; let _abLastServerDraftErrorAt = 0;
async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const parentSignal = options.signal;
const ac = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ac.abort();
}, timeoutMs);
const onParentAbort = () => ac.abort(parentSignal?.reason);
if (parentSignal) {
if (parentSignal.aborted) onParentAbort();
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
}
try {
return await fetch(url, { ...options, signal: ac.signal });
} catch (err) {
if (timedOut) {
const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`);
timeoutErr.name = 'TimeoutError';
throw timeoutErr;
}
throw err;
} finally {
clearTimeout(timer);
if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort);
}
}
function audiobookTimeoutSeconds(timeoutMs) {
return Math.max(5, Math.round(timeoutMs / 1000));
}
// Streamed attribution: reads the SSE feed from /api/attribute-dialogue/stream,
// pushing each delta into view.thinking() so the user can watch the LLM work,
// and resolves with the final parsed result. Uses an INACTIVITY timeout (reset
// on every received chunk) rather than an overall one — a passage legitimately
// takes minutes on a slow model, but silence that long means the stream died.
// Throws on any failure; callers fall back to the blocking endpoint. A user
// Stop (outer signal) propagates as AbortError; a stalled stream does not.
async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const ctl = new AbortController();
const onAbort = () => ctl.abort();
if (outerSignal) {
if (outerSignal.aborted) ctl.abort();
else outerSignal.addEventListener('abort', onAbort, { once: true });
}
let idleTimer = null;
const armIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs);
};
try {
armIdle();
const r = await fetch('/api/attribute-dialogue/stream', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }),
});
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', result = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
armIdle();
buf += dec.decode(value, { stream: true });
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, at).trim();
buf = buf.slice(at + 2);
if (!line.startsWith('data:')) continue;
let d;
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
if (d.t && view?.thinking) view.thinking(d.t);
if (d.error) throw new Error(d.error);
if (d.done) result = d.result || null;
}
}
if (!result) throw new Error('stream ended without result');
return result;
} catch (err) {
// An idle-timeout abort is a stream failure (fall back), not a user Stop.
if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) {
throw new Error('stream idle timeout');
}
throw err;
} finally {
clearTimeout(idleTimer);
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
}
}
function audiobookDialogueKey(text) { function audiobookDialogueKey(text) {
return String(text || '') return String(text || '')
.normalize('NFKC') .normalize('NFKC')
@ -1087,7 +1027,15 @@ function _abIdentityTokens(name) {
// why a fresh cast produced 73 speakers where the human had 62. A line credited // why a fresh cast produced 73 speakers where the human had 62. A line credited
// to one of these is better left Unknown: visibly unresolved, and still // to one of these is better left Unknown: visibly unresolved, and still
// recoverable by the repair passes, instead of silently wrong. // recoverable by the repair passes, instead of silently wrong.
// Grammatical words the caster mistakes for names because they open the sentence
// that introduces a quote: "Hinter ihm raunte Oberst von Blautann:" -> Hinter,
// "Keiner sagte mehr etwas." -> Keiner, a dangling "Von" from "von Blautann".
// Confirmed by a reader hand-correcting a whole book: every one of these was
// deleted as a bogus character.
const _AB_NOT_A_CHARACTER = new Set([ const _AB_NOT_A_CHARACTER = new Set([
'hinter','vor','neben','über','unter','zwischen','gegenüber','keiner','keine','niemand','jemand','alle','beide',
'einige','viele','andere','anderen','jeder','jede','man','von','zu','bei','nach','aus','mit','ihm','ihr','ihnen',
'dort','hier','draußen','drinnen','oben','unten','plötzlich','endlich','schließlich',
'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen', 'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen',
'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten', 'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten',
'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde', 'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde',
@ -1583,8 +1531,8 @@ function _abOpenRecastSelectPopup(existingChars) {
}); });
}; };
ov.innerHTML = '<div class="audiobook-box">' ov.innerHTML = '<div class="audiobook-box ab-recast-dialog">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Cast selected character roles</div>' + '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Select characters to cast or recast roles</div>'
+ '<div class="audiobook-msg">Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context. Everyone else\'s sheet stays as-is.</div>' + '<div class="audiobook-msg">Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context. Everyone else\'s sheet stays as-is.</div>'
+ '<div class="ab-cv-side-tools">' + '<div class="ab-cv-side-tools">'
+ '<input type="text" class="ab-cv-side-search" id="ab-recast-search" placeholder="Search characters..." autocomplete="off">' + '<input type="text" class="ab-cv-side-search" id="ab-recast-search" placeholder="Search characters..." autocomplete="off">'
@ -1626,7 +1574,11 @@ function _abOpenRecastSelectPopup(existingChars) {
render(); render();
}); });
ov.querySelector('#ab-recast-confirm').addEventListener('click', async () => { ov.querySelector('#ab-recast-confirm').addEventListener('click', async () => {
const names = [...selected]; // The DOM checkboxes are the source of truth at confirmation time. The
// Set is only a render/search convenience and could become stale after a
// filtered list was rebuilt, which previously allowed a one-character
// selection to fall through as a full-roster recast.
const names = [...ov.querySelectorAll('.ab-recast-cb:checked')].map(cb => cb.value).filter(Boolean);
if (!names.length) { toast('Select at least one character', 'error'); return; } if (!names.length) { toast('Select at least one character', 'error'); return; }
ov.remove(); ov.remove();
if (typeof window.audiobookRecastSelectedCharacters === 'function') await window.audiobookRecastSelectedCharacters(names); if (typeof window.audiobookRecastSelectedCharacters === 'function') await window.audiobookRecastSelectedCharacters(names);
@ -1664,7 +1616,6 @@ function _abScrollIntoView(el, opts) {
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
const panel = document.getElementById('reader-audiobook-panel'); const panel = document.getElementById('reader-audiobook-panel');
if (!panel) return; if (!panel) return;
if (typeof window.navReaderView === 'function') window.navReaderView('cast'); if (typeof window.navReaderView === 'function') window.navReaderView('cast');
else if (typeof window.showReaderView === 'function') window.showReaderView('cast'); else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
else { else {
@ -4003,6 +3954,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
// Park the panel in a "done" state with a Review button instead of auto-popping // Park the panel in a "done" state with a Review button instead of auto-popping
// the preview — so it waits for you if you wandered off to do something else. // the preview — so it waits for you if you wandered off to do something else.
complete(summary, onOpen, onRecast, onRecastUnknown) { complete(summary, onOpen, onRecast, onRecastUnknown) {
_abHideCastingControl();
if (count) count.hidden = true; if (count) count.hidden = true;
const castBar = panel.querySelector('.ab-castpanel-bar'); const castBar = panel.querySelector('.ab-castpanel-bar');
if (castBar) castBar.hidden = true; if (castBar) castBar.hidden = true;
@ -4223,6 +4175,14 @@ ABSOLUTE REGELN:
}); });
}; };
const runIdentifyAll = async () => { const runIdentifyAll = async () => {
const bookTitle = window.readerState?.title || '';
if (bookTitle && typeof window.openBookContextEditor === 'function') {
const existingContext = typeof _getBookProfile === 'function' ? await _getBookProfile(bookTitle) : {};
if (!existingContext.confirmed) {
const profile = await window.openBookContextEditor(bookTitle);
if (!profile) return;
}
}
const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true }); const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true });
if (!ok) return; if (!ok) return;
applyPromptAndRun(audiobookCast); applyPromptAndRun(audiobookCast);
@ -4406,7 +4366,7 @@ ABSOLUTE REGELN:
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [ castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
{ icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll }, { icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
{ icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted }, { icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted },
{ icon: 'mdi-account-check-outline', label: 'Cast selected character roles', title: existing.length ? 'Generate / refresh the character sheets for selected cast characters' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected }, { icon: 'mdi-account-check-outline', label: 'Select characters to cast or recast roles', title: existing.length ? 'Choose character roles to generate or refresh' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected },
{ divider: true }, { divider: true },
{ icon: 'mdi-refresh', label: 'New recast (discard & rebuild all)', title: 'Discard every generated character sheet and rebuild every profile from scratch', onClick: runCastFresh, danger: true }, { icon: 'mdi-refresh', label: 'New recast (discard & rebuild all)', title: 'Discard every generated character sheet and rebuild every profile from scratch', onClick: runCastFresh, danger: true },
])); ]));
@ -4428,6 +4388,7 @@ ABSOLUTE REGELN:
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
}, },
done(options = {}) { done(options = {}) {
_abHideCastingControl();
const stopped = options.stopped || _audiobook.cancel; const stopped = options.stopped || _audiobook.cancel;
if (!stopped) { closePanel(); return; } if (!stopped) { closePanel(); return; }
@ -4765,6 +4726,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
} finally { } finally {
_audiobook.running = false; _audiobook.running = false;
_audiobook.abort = null; _audiobook.abort = null;
_abHideCastingControl();
} }
// Everything below only ever ran when nothing above threw — but nothing // Everything below only ever ran when nothing above threw — but nothing
// here was itself guarded, so any failure in this post-processing (dedup, // here was itself guarded, so any failure in this post-processing (dedup,
@ -5352,6 +5314,7 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
_abStopDraftAutosave(); _abStopDraftAutosave();
_audiobook.running = false; _audiobook.running = false;
_audiobook.abort = null; _audiobook.abort = null;
_abHideCastingControl();
} }
if (!allSegments.length) { if (!allSegments.length) {
@ -5756,6 +5719,23 @@ function audiobookLineVoice(l) {
return { voice: rehState.narratorVoice, instruct: '' }; return { voice: rehState.narratorVoice, instruct: '' };
} }
// Long narrator paragraphs must be split before they reach the TTS service.
// The service has to restart its decoder/model context for a very long
// request, and its own emergency cut can land mid-sentence. Keep the split
// sentence-aware and add only a short, intentional breath between generated
// pieces; normal paragraph gaps are added later when chapters are merged.
async function _abSynthesizeLongText(voice, text, instruct, backend) {
const pieces = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, 900)
: [text];
if (pieces.length <= 1) return fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend);
const blobs = [];
for (const piece of pieces) {
blobs.push(await fetchTtsPreviewBlob(voice, piece, 'wav', instruct, backend));
}
return mergeWavBlobs(blobs, 120);
}
async function audiobookExport() { async function audiobookExport() {
if (_audiobook.running) return; if (_audiobook.running) return;
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; } if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
@ -5838,7 +5818,7 @@ async function audiobookExport() {
let lastErr; let lastErr;
for (let attempt = 1; attempt <= 3; attempt++) { for (let attempt = 1; attempt <= 3; attempt++) {
try { try {
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend)); blob = await _abSynthesizeLongText(voice, text, instruct, _ttsBackendForVoice(voice, rehState.backend));
lastErr = null; lastErr = null;
break; break;
} catch (e) { } catch (e) {

View File

@ -0,0 +1,87 @@
// Network and streaming helpers for audiobook attribution.
// Loaded after audiobook.js; the functions are called from user actions and
// therefore do not need to execute while the main audiobook module initializes.
async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const parentSignal = options.signal;
const ac = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ac.abort();
}, timeoutMs);
const onParentAbort = () => ac.abort(parentSignal?.reason);
if (parentSignal) {
if (parentSignal.aborted) onParentAbort();
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
}
try {
return await fetch(url, { ...options, signal: ac.signal });
} catch (err) {
if (timedOut) {
const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`);
timeoutErr.name = 'TimeoutError';
throw timeoutErr;
}
throw err;
} finally {
clearTimeout(timer);
if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort);
}
}
function audiobookTimeoutSeconds(timeoutMs) {
return Math.max(5, Math.round(timeoutMs / 1000));
}
async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const ctl = new AbortController();
const onAbort = () => ctl.abort();
if (outerSignal) {
if (outerSignal.aborted) ctl.abort();
else outerSignal.addEventListener('abort', onAbort, { once: true });
}
let idleTimer = null;
const armIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs);
};
try {
armIdle();
const r = await fetch('/api/attribute-dialogue/stream', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }),
});
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', result = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
armIdle();
buf += dec.decode(value, { stream: true });
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, at).trim();
buf = buf.slice(at + 2);
if (!line.startsWith('data:')) continue;
let d;
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
if (d.t && view?.thinking) view.thinking(d.t);
if (d.error) throw new Error(d.error);
if (d.done) result = d.result || null;
}
}
if (!result) throw new Error('stream ended without result');
return result;
} catch (err) {
if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) {
throw new Error('stream idle timeout');
}
throw err;
} finally {
clearTimeout(idleTimer);
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
}
}

View File

@ -0,0 +1,47 @@
// Character-table metadata and the persisted column-visibility controller.
// Kept separate from the character renderer so table layout can evolve
// without making library-characters.js responsible for every table concern.
const _CHAR_PROFILE_COMPLETENESS_FIELDS = [
'gender', 'age_estimate', 'race_species', 'languages', 'nationality_background',
'social_class', 'profession', 'title', 'archetype', 'physical', 'clothing',
'alignment', 'arc_direction', 'arc_note', 'attribute_high', 'attribute_low',
'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears',
'mannerisms', 'communication_style', 'voice_pattern', 'secret', 'conflict_style',
'win_condition', 'reputation', 'religious_beliefs', 'notes', 'inventory',
];
const _CHAR_TABLE_COLUMN_DEFS = [
['select', 'Select'], ['avatar', 'Avatar'], ['name', 'Name'], ['gender', 'Gender'],
['age', 'Age'], ['lines', 'Lines'], ['language', 'Language'], ['alignment', 'Good / Evil'],
['profile', 'Profile'], ['voice', 'Voice'], ['rating', 'Rating'], ['prompt', 'Voice Design Prompt'],
['notes', 'Notes'], ['tags', 'Tags'], ['book', 'Book / Script'], ['export', 'Export'],
];
function _charTableHiddenColumns() {
try {
const saved = JSON.parse(localStorage.getItem('ttsvc_libchars_columns') || '{}');
return saved && typeof saved === 'object' ? saved : {};
} catch (_) { return {}; }
}
function _applyCharTableColumns(table, hidden) {
if (!table) return;
_CHAR_TABLE_COLUMN_DEFS.forEach(function (def, index) {
const isHidden = hidden[def[0]] === false;
table.querySelectorAll('tr').forEach(function (row) {
const cell = row.children[index];
if (cell) cell.classList.toggle('lib-chars-col-hidden', isHidden);
});
const col = table.querySelector('colgroup')?.children[index];
if (col) col.classList.toggle('lib-chars-col-hidden', isHidden);
});
}
function _charProfileCompleteness(sheet) {
const sh = sheet || {};
const filled = _CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function (key) {
return _libStr(sh[key]).trim().length > 0;
}).length;
return { filled: filled, total: _CHAR_PROFILE_COMPLETENESS_FIELDS.length };
}

View File

@ -43,7 +43,7 @@ For each character output these fields:
- mannerisms: habitual gestures, tics, body language, habits and quirks - mannerisms: habitual gestures, tics, body language, habits and quirks
- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc. - communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.
- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting) - voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)
- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers. - voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. Do NOT mention plot spoilers.
- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'. - image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.
- inventory: 1-3 defining items/props/clothing (array of short strings) - inventory: 1-3 defining items/props/clothing (array of short strings)
- secret: dark secret or fatal flaw - secret: dark secret or fatal flaw
@ -76,6 +76,34 @@ function csLlmModel() { return $('reh-llm-model')?.value || ''; }
function csLang() { return $('reh-design-lang')?.value || ''; } function csLang() { return $('reh-design-lang')?.value || ''; }
function csReaderPageHost() { return $('reader-charsheets-panel'); } function csReaderPageHost() { return $('reader-charsheets-panel'); }
// Establish shared world context before the first profile pass. This keeps
// genre/setting/era/language consistent across character sheets, images, and
// voice prompts. The server preserves any fields the user already entered.
async function csEnsureBookContext(book, text, roster, opts = {}) {
if (!book || !text || typeof _getBookProfile !== 'function') return {};
let existing = {};
try { existing = await _getBookProfile(book); } catch (_) {}
const missing = ['genre', 'setting', 'era', 'language'].filter(k => !String(existing?.[k] || '').trim());
if (!missing.length && !opts.force) return existing;
try {
const r = await fetch('/api/book-profile/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
book, text, known_characters: Array.isArray(roster) ? roster : [],
llm_url: csLlmUrl(), model: csLlmModel(), language: csLang(),
}),
});
if (!r.ok) throw new Error((await r.text()).slice(0, 300) || ('HTTP ' + r.status));
const data = await r.json();
if (typeof _bookProfileCache !== 'undefined') _bookProfileCache.set(book, data.profile || {});
return data.profile || existing;
} catch (e) {
console.warn('[book context] automatic generation failed:', e);
if (typeof toast === 'function') toast('Book context could not be generated; profile casting will continue', 'info');
return existing;
}
}
// Build page-annotated text from the current reader scope ([p.N] at page changes). // Build page-annotated text from the current reader scope ([p.N] at page changes).
function csReaderText() { function csReaderText() {
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return ''; if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
@ -289,6 +317,44 @@ function csSheetMatchesRoster(sheet, roster = []) {
return false; return false;
} }
// The dialogue-casting roster is the source of truth for the character's
// display name. Character-sheet extraction may discover a real name, role, or
// epithet and put it in `name`, but that must never replace the name the user
// sees in Characters found / the cast. Keep those discoveries in aliases.
function csRosterCanonicalName(sheet, roster = []) {
const names = csCleanRoster(roster);
if (!names.length) return '';
const tokens = csNameTokens(sheet);
const exact = names.find(n => tokens.has(n.toLowerCase()));
return exact || '';
}
function csCanonicalizeRosterNames(sheets, roster = []) {
const canonical = new Map();
for (const original of sheets || []) {
if (!original) continue;
const sheet = { ...original };
const rosterName = csRosterCanonicalName(sheet, roster);
if (rosterName && sheet.name && sheet.name.toLowerCase() !== rosterName.toLowerCase()) {
sheet.aliases = csMergeAliases(
{ name: rosterName, aliases: sheet.aliases },
{ name: sheet.name, aliases: '' },
);
sheet.name = rosterName;
}
// Tell the library upsert that this name came from the live cast roster.
// It must be allowed to correct an older record whose canonical name was
// previously chosen from an alias (for example an LLM-selected real name
// replacing the sidebar role name Fremder).
if (rosterName) sheet._rosterNameAuthoritative = true;
const key = String(sheet.name || '').trim().toLowerCase();
if (!key) continue;
if (!canonical.has(key)) canonical.set(key, sheet);
else csMerge(canonical, [sheet]);
}
return [...canonical.values()];
}
function csIdentityNeedles(value) { function csIdentityNeedles(value) {
return csSplitIdentityTokens(value, { aliases: true }).filter(v => { return csSplitIdentityTokens(value, { aliases: true }).filter(v => {
const n = String(v || '').trim(); const n = String(v || '').trim();
@ -391,12 +457,16 @@ function csMergeAliases(existing, incoming) {
return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', '); return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
} }
function csMerge(map, sheets) { function csMerge(map, sheets, opts = {}) {
const changed = []; const changed = [];
for (const s of sheets) { for (const s of sheets) {
const name = (s.name || '').trim(); if (!name) continue; const name = (s.name || '').trim(); if (!name) continue;
s.aliases = csMergeAliases({ name, aliases: '' }, s); s.aliases = csMergeAliases({ name, aliases: '' }, s);
const key = csFindMergeKey(map, s); // A live casting roster is authoritative: two different names shown in
// Characters found must never be folded together merely because the LLM
// calls one an alias of the other. Alias-aware merging remains useful
// while discovering characters without a roster, but is unsafe here.
const key = opts.strictRoster ? name.toLowerCase() : csFindMergeKey(map, s);
if (!map.has(key)) { if (!map.has(key)) {
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }; const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); }); CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); });
@ -441,7 +511,7 @@ function csMerge(map, sheets) {
// this run (settled by Identify Characters or an earlier cast pass) as // this run (settled by Identify Characters or an earlier cast pass) as
// canonical, with the other name demoted to an alias — rather than letting // canonical, with the other name demoted to an alias — rather than letting
// the character-sheet step invent a competing identity. // the character-sheet step invent a competing identity.
function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated) { function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster = []) {
let changed = true; let changed = true;
while (changed) { while (changed) {
changed = false; changed = false;
@ -456,8 +526,18 @@ function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated)
// pass), fall back to whichever record is OLDER — the one Identify // pass), fall back to whichever record is OLDER — the one Identify
// Characters / the original cast settled on first. // Characters / the original cast settled on first.
const sEst = establishedKeys.has(key), oEst = establishedKeys.has(dupKey); const sEst = establishedKeys.has(key), oEst = establishedKeys.has(dupKey);
const rosterNames = csCleanRoster(roster);
const sRoster = rosterNames.findIndex(n => n.toLowerCase() === key);
const oRoster = rosterNames.findIndex(n => n.toLowerCase() === dupKey);
let keepKey = key, dropKey = dupKey; let keepKey = key, dropKey = dupKey;
if (oEst && !sEst) { keepKey = dupKey; dropKey = key; } // If both names are present in the current Characters-found roster,
// preserve the roster's own order. That makes the displayed sidebar
// name authoritative (for example Fremder), while the other name
// remains an AKA instead of unexpectedly becoming the profile title.
if (sRoster >= 0 && oRoster >= 0 && sRoster !== oRoster) {
if (oRoster < sRoster) { keepKey = dupKey; dropKey = key; }
} else if (sRoster < 0 && oRoster >= 0) { keepKey = dupKey; dropKey = key; }
else if (oEst && !sEst) { keepKey = dupKey; dropKey = key; }
else if (sEst === oEst) { else if (sEst === oEst) {
const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length; const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length;
const sFilled = filled(s), oFilled = filled(other); const sFilled = filled(s), oFilled = filled(other);
@ -693,7 +773,13 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
try { data = JSON.parse(raw); } try { data = JSON.parse(raw); }
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
} }
const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []); // In roster mode the API must return the exact speaker name it was
// asked to define. Matching through aliases here let a response
// for e.g. "Zerwas" update the separately-cast "Alrik" profile.
// Keep alias matching solely for free-form discovery mode.
const incomingSheets = targetMode
? (data.sheets || []).filter(s => roster.some(n => n.toLowerCase() === String(s?.name || '').trim().toLowerCase()))
: (data.sheets || []);
// Confirmed live: a passage featuring two characters together can // Confirmed live: a passage featuring two characters together can
// make the model write one character's real name into ANOTHER // make the model write one character's real name into ANOTHER
// character's first_name/last_name field (e.g. a "Darrag" sheet // character's first_name/last_name field (e.g. a "Darrag" sheet
@ -714,7 +800,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
} }
}); });
}); });
const changed = csMerge(map, incomingSheets); const changed = csMerge(map, incomingSheets, { strictRoster: targetMode });
if (changed.length) prog.focus(changed[0].name); if (changed.length) prog.focus(changed[0].name);
if (!targetMode) { if (!targetMode) {
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
@ -757,8 +843,14 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
} }
} finally { prog.done(); } } finally { prog.done(); }
if (_cs.cancel) { toast('Cancelled', 'error'); return null; } if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated); // Do not consolidate alias-linked entries during a cast pass. The
const sheets = [...map.values()]; // Characters-found sidebar owns the profile names and line counts; aliases
// describe a profile but never replace or remove another roster entry.
if (!targetMode) csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
// The live casting roster (and its Characters found sidebar) owns the
// display name. Any LLM-selected real name/role stays in aliases instead of
// renaming the profile.
const sheets = csCanonicalizeRosterNames([...map.values()], roster);
if (cacheKey) _cs.cache[cacheKey] = sheets; if (cacheKey) _cs.cache[cacheKey] = sheets;
return sheets; return sheets;
} catch (e) { } catch (e) {
@ -1262,11 +1354,12 @@ function csAlignmentBar(score, arcDirection, arcNote) {
// ── Image & Voice prompt generators ────────────────────────────────────────── // ── Image & Voice prompt generators ──────────────────────────────────────────
// Fallback only — the LLM-generated image_prompt (routes/conversation.py // Fallback only — the LLM-generated image_prompt (routes/conversation.py
// character_generate_prompts) is preferred and asks for the same turnaround- // character_generate_prompts) is preferred. This is deliberately a
// sheet format; this covers the case where that hasn't been generated yet. // photorealistic *profile portrait*; concept sheets use concept_art_prompt.
function csBuildImagePrompt(s, bookProfile) { function csBuildImagePrompt(s, bookProfile) {
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim(); if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
const parts = []; const parts = [];
if (s.gender) parts.push(`exactly ${s.gender}`);
if (s.race_species) parts.push(s.race_species); if (s.race_species) parts.push(s.race_species);
if (s.archetype) parts.push(s.archetype); if (s.archetype) parts.push(s.archetype);
if (s.physical) parts.push(s.physical); if (s.physical) parts.push(s.physical);
@ -1288,17 +1381,19 @@ function csBuildImagePrompt(s, bookProfile) {
+ `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or ` + `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or `
+ `real-world default. ` + `real-world default. `
: ''; : '';
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. ` return `Create a photorealistic live-action square character portrait for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
+ `VISUAL IDENTITY LOCK: use the exact gender, age impression, species, face shape, skin tone, hair, eyes, facial hair, `
+ `distinguishing marks, build, clothing, colors, armor, jewelry, weapons, tools, and signature props established here. `
+ `Keep one identical face identity anchor for future concept art and profile images. `
+ settingClause + settingClause
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — ` + `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
+ `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" ` + `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" `
+ `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual ` + `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual `
+ `choice should fit the world implied by the description, not the real one, unless the description itself is ` + `choice should fit the world implied by the description, not the real one, unless the description itself is `
+ `explicitly modern/contemporary. ` + `explicitly modern/contemporary. `
+ `Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet ` + `Show a head-and-shoulders portrait facing camera, with a natural in-character expression, on a subdued neutral studio background. `
+ `with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts ` + `Photorealistic cinematic fantasy-film photography: natural skin texture and pores, believable anatomy, authentic cloth/leather/metal, physically plausible lighting and shadows. `
+ `for signature props or clothing details. Clean production concept-art layout, plain neutral background, ` + `Not a drawing, painting, anime, game render, plastic skin, collage, text, logo, watermark, or copyrighted character.`;
+ `original character not based on any copyrighted character.`;
} }
function csBuildVoicePrompt(s) { function csBuildVoicePrompt(s) {
@ -2241,11 +2336,17 @@ async function csForReader(opts = {}) {
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120); if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
const pageHost = csReaderPageHost(); const pageHost = csReaderPageHost();
if (!fresh && _cs.cache[key]) { if (!fresh && _cs.cache[key]) {
await csSaveToLibrary(book, _cs.cache[key]); const cached = csCanonicalizeRosterNames(_cs.cache[key], csKnownReaderRoster());
if (pageHost) await csShow(_cs.cache[key], book, text, book, pageHost); _cs.cache[key] = cached;
await csSaveToLibrary(book, cached);
if (pageHost) await csShow(cached, book, text, book, pageHost);
return; return;
} }
const knownRoster = csKnownReaderRoster(); const knownRoster = csKnownReaderRoster();
// Generate context after Identify Characters has produced the roster and
// before any character-sheet passage is sent. This is intentionally best
// effort: a model outage must not prevent the actual cast-profile run.
await csEnsureBookContext(book, text, knownRoster, { force: fresh });
let seedSheets = []; let seedSheets = [];
if (!fresh) try { if (!fresh) try {
if (typeof clGetAllByTagOrBook === 'function') { if (typeof clGetAllByTagOrBook === 'function') {
@ -2257,9 +2358,25 @@ async function csForReader(opts = {}) {
// the CURRENT cast (knownRoster, from the live audiobook roster) no // the CURRENT cast (knownRoster, from the live audiobook roster) no
// longer has them. Keep only saved records that still match a name in // longer has them. Keep only saved records that still match a name in
// the current cast, so this list stays in sync with what step 2 shows. // the current cast, so this list stays in sync with what step 2 shows.
const rosterSet = new Set(knownRoster.map(n => n.toLowerCase())); const stillCast = rec => !knownRoster.length || csSheetMatchesRoster({
const stillCast = rec => !rosterSet.size || rosterSet.has(String(rec?.name || '').trim().toLowerCase()); name: rec?.name || '',
seedSheets = (existing || []).filter(stillCast).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim()); aliases: rec?.sheet?.aliases || '',
first_name: rec?.sheet?.first_name || '',
last_name: rec?.sheet?.last_name || '',
full_name: rec?.sheet?.full_name || '',
}, knownRoster);
seedSheets = (existing || []).filter(stillCast).map(rec => {
const sheet = csSeedSheet(rec);
const rosterName = csRosterCanonicalName(sheet, knownRoster);
if (rosterName && sheet.name.toLowerCase() !== rosterName.toLowerCase()) {
sheet.aliases = csMergeAliases(
{ name: rosterName, aliases: sheet.aliases },
{ name: sheet.name, aliases: '' },
);
sheet.name = rosterName;
}
return sheet;
}).filter(s => String(s.name || '').trim());
} }
} catch (_) { seedSheets = []; } } catch (_) { seedSheets = []; }
// Computed up front (not just after the whole run finishes) so "Lines" // Computed up front (not just after the whole run finishes) so "Lines"
@ -2309,6 +2426,7 @@ async function csForReaderSelective(selectedNames) {
// that race regardless of which async step caused it. // that race regardless of which async step caused it.
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120); if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
const pageHost = csReaderPageHost(); const pageHost = csReaderPageHost();
const knownRoster = csKnownReaderRoster();
let records = []; let records = [];
try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; } try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; }
const picked = []; const picked = [];
@ -2329,19 +2447,37 @@ async function csForReaderSelective(selectedNames) {
} }
return false; return false;
}; };
(records || []).forEach(rec => { // Exact canonical names always win. Alias matching is only a fallback for
if (!rec || !rec.name || !matchesWanted(rec)) return; // old records that have no exact record; otherwise selecting Lysandra could
const key = String(rec.name || '').trim().toLowerCase(); // pick an Alrik profile whose generated aliases happened to contain the
if (seen.has(key)) return; // word "Lysandra".
seen.add(key);
picked.push(rec);
});
(selectedNames || []).forEach(name => { (selectedNames || []).forEach(name => {
const key = String(name || '').trim().toLowerCase(); const canonical = String(name || '').trim();
const key = canonical.toLowerCase();
if (!key || seen.has(key)) return; if (!key || seen.has(key)) return;
const fallback = { name: String(name || '').trim(), sheet: csBlankSheet(String(name || '').trim()) }; const exact = (records || []).find(rec => String(rec?.name || '').trim().toLowerCase() === key);
const aliasMatch = exact || (records || []).find(rec => {
if (!rec || !rec.name) return false;
const tokens = csNameTokens({
name: rec.name,
aliases: rec.sheet?.aliases || '',
first_name: rec.sheet?.first_name || '',
last_name: rec.sheet?.last_name || '',
full_name: rec.sheet?.full_name || '',
});
return tokens.some(t => {
const lower = String(t || '').toLowerCase();
return lower === key || lower.includes(key) || key.includes(lower);
});
});
let chosen = aliasMatch || { name: canonical, sheet: csBlankSheet(canonical) };
// Keep the selected sidebar name as the generation identity even when we
// borrowed profile evidence from a legacy alias-linked record.
if (chosen.name !== canonical) {
chosen = Object.assign({}, chosen, { name: canonical, sheet: Object.assign({}, chosen.sheet || {}, { name: canonical }) });
}
seen.add(key); seen.add(key);
picked.push(fallback); picked.push(chosen);
}); });
picked.sort((a, b) => (b?.sheet?.line_count || 0) - (a?.sheet?.line_count || 0) || String(a?.name || '').localeCompare(String(b?.name || ''))); picked.sort((a, b) => (b?.sheet?.line_count || 0) - (a?.sheet?.line_count || 0) || String(a?.name || '').localeCompare(String(b?.name || '')));
if (!picked.length) { toast('No matching cast characters found', 'error'); return; } if (!picked.length) { toast('No matching cast characters found', 'error'); return; }
@ -2349,13 +2485,16 @@ async function csForReaderSelective(selectedNames) {
for (const rec of picked) { for (const rec of picked) {
const targetName = String(rec.name || '').trim(); const targetName = String(rec.name || '').trim();
if (!targetName) continue; if (!targetName) continue;
const rosterName = targetName;
const needles = csRecordNeedles(rec); const needles = csRecordNeedles(rec);
const scanText = csEvidenceWindowText(csReaderParagraphBlocks(), needles, 2, 2) || text; const scanText = csEvidenceWindowText(csReaderParagraphBlocks(), needles, 2, 2) || text;
const seedSheet = csSeedSheet(rec); const seedSheet = csSeedSheet(rec);
const sheets = await csGenerate(scanText, null, [targetName], { pageHost, seedSheets: [seedSheet] }); seedSheet.name = targetName;
const sheets = await csGenerate(scanText, null, [rosterName], { pageHost, seedSheets: [seedSheet] });
if (!sheets) return; if (!sheets) return;
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase())); const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase()));
if (!filtered.length) continue; if (!filtered.length) continue;
filtered.forEach(s => { s.name = targetName; s._rosterNameAuthoritative = true; });
if (rec?.sheet?.line_count != null) filtered.forEach(s => { s.line_count = rec.sheet.line_count; }); if (rec?.sheet?.line_count != null) filtered.forEach(s => { s.line_count = rec.sheet.line_count; });
csMerge(finalMap, filtered); csMerge(finalMap, filtered);
} }

View File

@ -206,15 +206,25 @@ function clMergeTags(...parts) {
async function clUpsert(book, sheet, knownId) { async function clUpsert(book, sheet, knownId) {
const name = (sheet.name || '').trim(); const name = (sheet.name || '').trim();
if (!name) return null; if (!name) return null;
const rosterNameAuthoritative = sheet._rosterNameAuthoritative === true;
const bk = (book || '').trim() || 'Unsorted'; const bk = (book || '').trim() || 'Unsorted';
const aliasPrev = knownId // A live cast roster is an explicit identity, not a guess. Never let an
// alias from the generated sheet redirect `Krieger` into an older
// `Zerwas` record (or `Fremder` into `Lancorian`). The sidebar name and
// book-scoped key are authoritative; aliases remain data on that record.
const authoritativeId = rosterNameAuthoritative ? clKey(bk, name) : null;
const aliasPrev = authoritativeId
? await clGet(authoritativeId).catch(() => null)
: knownId
? await clGet(knownId).catch(() => null) ? await clGet(knownId).catch(() => null)
: (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet)); : (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet));
const id = aliasPrev?.id || knownId || clKey(bk, name); const id = authoritativeId || aliasPrev?.id || knownId || clKey(bk, name);
const now = new Date(); const now = new Date();
const prev = aliasPrev || await clGet(id).catch(() => null); const prev = aliasPrev || await clGet(id).catch(() => null);
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name }; const sanitized = _clSanitize(sheet);
const canonicalName = prev?.name || name; delete sanitized._rosterNameAuthoritative;
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sanitized, name };
const canonicalName = rosterNameAuthoritative ? name : (prev?.name || name);
merged.name = canonicalName; merged.name = canonicalName;
const tags = clMergeTags(prev?.tags, sheet.tags, bk); const tags = clMergeTags(prev?.tags, sheet.tags, bk);
const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName); const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName);

View File

@ -150,7 +150,7 @@ function initElevenLabsBrowser() {
<button class="el-play" title="Play preview" data-preview="${prev}">▶ Play</button> <button class="el-play" title="Play preview" data-preview="${prev}">▶ Play</button>
</div> </div>
</div> </div>
${prev ? `<div class="el-vc-footer"><button class="el-clone" title="Import to Clone a Voice" data-preview="${prev}" data-name="${nm}"><span class="mdi mdi-download"></span> Clone</button></div>` : ''} ${prev ? `<div class="el-vc-footer"><button class="el-clone" title="Import directly to Voice Library" data-preview="${prev}" data-name="${nm}" data-voice-id="${_elEsc(v.voice_id || '')}" data-language="${_elEsc(v.language || '')}" data-gender="${_elEsc(v.gender || '')}" data-description="${_elEsc(v.description || '')}"><span class="mdi mdi-download"></span> Get voice</button></div>` : ''}
</div>`; </div>`;
} }
@ -170,15 +170,51 @@ function initElevenLabsBrowser() {
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; }); _elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
} }
function elImport(btn) { async function elImport(btn) {
const url = btn.dataset.preview; const url = btn.dataset.preview;
if (!url) return; if (!url) return;
if (typeof navTo === 'function') navTo('s-clone'); const original = btn.innerHTML;
setTimeout(() => { btn.disabled = true;
const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn'); btn.innerHTML = '<span class="el-spinner"></span> Importing…';
if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); }
if (btn2) btn2.click(); const name = btn.dataset.name || 'ElevenLabs voice';
}, 120); const lang = (typeof sourceLanguageCode === 'function'
? sourceLanguageCode(btn.dataset.language)
: String(btn.dataset.language || 'EN').slice(0, 2).toUpperCase());
const gender = typeof sourceGenderCode === 'function'
? sourceGenderCode(btn.dataset.gender, name)
: 'N';
const base = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
const voiceId = `EL_${lang}_${gender}_${base}`;
try {
const r = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
audio_url: url, voice_id: voiceId, transcript: '', auto_prepare: true,
lang, gender, name, tag: 'elevenlabs', group: 'elevenlabs', origin: 'cloned',
note: (btn.dataset.description || '').slice(0, 180),
source: `ElevenLabs shared voice${btn.dataset.voiceId ? ` · ${btn.dataset.voiceId}` : ''}`,
}),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
const d = await r.json();
btn.innerHTML = '<span class="mdi mdi-check"></span> In library';
btn.classList.add('el-imported');
if (typeof toast === 'function') toast(`Imported "${name}" — transcript and benchmark are preparing in the background`, 'success');
if (typeof loadVoiceLibrary === 'function') {
_libraryLoadPromise = null;
await loadVoiceLibrary();
}
} catch (e) {
btn.disabled = false;
btn.innerHTML = original;
if (typeof toast === 'function') toast('Import failed: ' + e.message, 'error');
}
} }
elLoadKey(); elLoadKey();

File diff suppressed because it is too large Load Diff

View File

@ -1699,6 +1699,7 @@ async function readerOpenLibraryDoc(id) {
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending')); if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.savedId = id; readerState.savedId = id;
readerState.sourceUploaded = true; // already on the server readerState.sourceUploaded = true; // already on the server
if (typeof window.studioRememberBook === 'function') window.studioRememberBook(id, rec.title);
readerState.savedAudioIdx = new Set(); readerState.savedAudioIdx = new Set();
// mark which units already have audio on the server (fetched lazily on play) // mark which units already have audio on the server (fetched lazily on play)
if (rec.sentenceCount === readerState.sentences.length) { if (rec.sentenceCount === readerState.sentences.length) {
@ -1729,6 +1730,10 @@ async function readerOpenLibraryDoc(id) {
else showReaderView('main'); else showReaderView('main');
} }
// Studio uses this explicit entry point to restore the selected book after a
// browser refresh, when the in-memory reader state has been cleared.
window.readerOpenLibraryDoc = readerOpenLibraryDoc;
function readerSetSelectValue(id, value) { function readerSetSelectValue(id, value) {
const sel = $(id); if (!sel) return; const sel = $(id); if (!sel) return;
if (![...sel.options].some(o => o.value === value)) { if (![...sel.options].some(o => o.value === value)) {

View File

@ -2187,8 +2187,14 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
if (!rehState.cast[sp]) { done++; continue; } if (!rehState.cast[sp]) { done++; continue; }
const info = byName[sp.toUpperCase().trim()] || {}; const info = byName[sp.toUpperCase().trim()] || {};
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N'; const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
const desc = info.description || const hardGender = gender === 'M' ? 'male' : gender === 'F' ? 'female' : 'gender-neutral';
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`; const hardAge = info.age || 'age unspecified';
const hardLanguage = info.language || language || 'the script language';
const desc = [
`HARD CASTING CONSTRAINTS: ${hardGender} voice, apparent age ${hardAge}, spoken in ${hardLanguage}.`,
info.description || `Character named ${sp}; natural expressive audiobook voice.`,
`Make this voice audibly distinct from the other cast members: use a memorable vocal fingerprint, not a generic narrator voice.`,
].join(' ');
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`; const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char'; const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
@ -2223,6 +2229,7 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
if (typeof saveMeta === 'function') { if (typeof saveMeta === 'function') {
await saveMeta(saved.voice_id, { await saveMeta(saved.voice_id, {
gender, gender,
age: info.age || '',
name: charName, name: charName,
avatar: _pickVoiceAvatar(gender, desc, sp), avatar: _pickVoiceAvatar(gender, desc, sp),
origin: 'designed', origin: 'designed',
@ -2310,7 +2317,7 @@ $('reh-matchlib-btn')?.addEventListener('click', async () => {
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; } if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
const candidates = lib.map(v => ({ const candidates = lib.map(v => ({
id: v.id, gender: v.gender || '', language: v.lang || '', id: v.id, gender: v.gender || '', age: v.age || '', language: v.lang || '',
tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140), tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
})); }));
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp); const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);

View File

@ -11,8 +11,53 @@
// the same element ids would collide; physically relocating the existing // the same element ids would collide; physically relocating the existing
// node (same id, same listeners) does not. // node (same id, same listeners) does not.
const STUDIO_RESUME_KEY = 'ttsvc_studio_resume';
let _stuActive = 1; let _stuActive = 1;
function _stuReadResume() {
try { return JSON.parse(localStorage.getItem(STUDIO_RESUME_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
function _stuSaveResume(phase) {
try {
const previous = _stuReadResume();
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
phase: phase,
bookId: window._audiobook?.bookId || window.readerState?.savedId || previous.bookId || '',
bookTitle: window.readerState?.title || previous.bookTitle || '',
characterId: previous.characterId || '',
savedAt: Date.now(),
}));
} catch (_) {}
}
window.studioRememberBook = function (bookId, title) {
try {
const previous = _stuReadResume();
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
phase: _stuActive || previous.phase || 1,
bookId: bookId || previous.bookId || '',
bookTitle: title || previous.bookTitle || '',
characterId: previous.characterId || '',
savedAt: Date.now(),
}));
} catch (_) {}
};
window.studioRememberCharacter = function (characterId) {
try {
const previous = _stuReadResume();
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
phase: previous.phase || _stuActive || 3,
bookId: previous.bookId || window._audiobook?.bookId || '',
bookTitle: previous.bookTitle || window.readerState?.title || '',
characterId: characterId || '',
savedAt: Date.now(),
}));
} catch (_) {}
};
// id -> { parent, next } — where a borrowed node came from, so it can go home. // id -> { parent, next } — where a borrowed node came from, so it can go home.
const _stuHomes = new Map(); const _stuHomes = new Map();
@ -316,6 +361,21 @@ document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(functio
tab.addEventListener('click', function () { _stuShowCastView(tab.dataset.stuCastView); }); tab.addEventListener('click', function () { _stuShowCastView(tab.dataset.stuCastView); });
}); });
// Keep book context editing in the compact Studio toolbar. The context is
// deliberately available before identification, without consuming a full
// banner row inside the casting panel.
document.getElementById('stu-book-context-btn')?.addEventListener('click', async function () {
const bookTitle = window.readerState?.title || '';
if (!bookTitle || typeof window.openBookContextEditor !== 'function') return;
await window.openBookContextEditor(bookTitle);
});
document.getElementById('stu-change-book-btn')?.addEventListener('click', function () {
// The Source phase contains the existing uploaded-book cards. Keep the
// current production intact until the user explicitly chooses another.
showStudioPhase(1);
});
// The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" / // The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" /
// "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify, // "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify,
// Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's // Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's
@ -336,21 +396,62 @@ document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(functio
// Rehearser" still showing) sitting back at the bottom. Watch for it instead // Rehearser" still showing) sitting back at the bottom. Watch for it instead
// of relocating once. // of relocating once.
let _stuCastFootObserver = null; let _stuCastFootObserver = null;
let _stuCastFootHealthTimer = null;
// The Studio controls must never depend on the borrowed audiobook footer.
// audiobookCastView rebuilds that footer during several async transitions,
// which previously left this toolbar empty because the plain Studio buttons
// had already been hidden. The only time they may be hidden is while a cast
// job is actually running; in that state the borrowed footer exposes Cancel.
function _stuCastIsRunning() {
return typeof _audiobook !== 'undefined' && !!_audiobook.running;
}
function _stuSyncCastFallbackControls(tabs, innerTabs, hasActionFooter) {
const running = _stuCastIsRunning();
tabs.hidden = false;
tabs.style.display = 'flex';
innerTabs.forEach(function (tab) {
// The real three-menu action bar replaces these two simple navigation
// buttons only after its completed-cast actions exist. Until then they
// remain the reliable fallback, rather than leaving an empty toolbar.
const hideFallback = running || hasActionFooter;
tab.hidden = hideFallback;
tab.style.display = hideFallback ? 'none' : '';
});
}
function _stuRelocateCastFoot() { function _stuRelocateCastFoot() {
_stuTryRelocateCastFoot(); _stuTryRelocateCastFoot();
if (_stuCastFootObserver) return; if (_stuCastFootObserver) return;
const slot = document.getElementById('stu-cast-slot'); // The audiobook panel is rebuilt asynchronously and can briefly be moved
if (!slot) return; // through its native reader container before Studio's borrow completes.
// Observe the document, not only the slot, so a freshly-created footer is
// never stranded outside Studio's visible action strip.
_stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); }); _stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); });
_stuCastFootObserver.observe(slot, { childList: true, subtree: true }); _stuCastFootObserver.observe(document.body, { childList: true, subtree: true });
[0, 100, 300, 800, 1500].forEach(ms => setTimeout(_stuTryRelocateCastFoot, ms));
// Async restore/recast paths can replace the footer without producing a
// useful childList mutation in the Studio slot. Keep a lightweight health
// check while Studio is active so the toolbar can never remain blank.
_stuCastFootHealthTimer = setInterval(function () {
if (_stuIsActive && _stuActive === 2) _stuTryRelocateCastFoot();
}, 500);
} }
function _stuTryRelocateCastFoot() { function _stuTryRelocateCastFoot() {
const slot = document.getElementById('stu-cast-slot'); const slot = document.getElementById('stu-cast-slot');
const tabs = document.getElementById('stu-cast-inner-tabs'); const tabs = document.getElementById('stu-cast-inner-tabs');
if (!tabs) return; if (!tabs) return;
const freshFoot = slot ? slot.querySelector('#ab-cv-foot') : null; const freshFoot = (slot && slot.querySelector('#ab-cv-foot')) || document.querySelector('#ab-cv-foot');
const alreadyRelocated = tabs.querySelector('#ab-cv-foot'); const innerTabs = Array.from(tabs.querySelectorAll(':scope > .stu-inner-tab'));
// Do not take the footer while audiobookCastView is still building it.
// Its initial Cancel/placeholder buttons are replaced asynchronously by
// the completed-cast Identify / Cast Characters / Cast menus. Relocating
// that early steals the node from view.complete(), so its submenu buttons
// can never be installed and Studio ends up with only the fallback tabs.
const footerHasActions = freshFoot && freshFoot.querySelector('#ab-cv-menu-identify, #ab-cv-menu-cast, #ab-cv-menu-viewcast');
_stuSyncCastFallbackControls(tabs, innerTabs, !!footerHasActions);
// Transient states (e.g. "Checking saved cast before starting a new // Transient states (e.g. "Checking saved cast before starting a new
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the // one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
// plain tabs stayed hidden from an earlier relocation with nothing having // plain tabs stayed hidden from an earlier relocation with nothing having
@ -358,17 +459,28 @@ function _stuTryRelocateCastFoot() {
// showing the plain tabs any time there's genuinely no footer to relocate // showing the plain tabs any time there's genuinely no footer to relocate
// (neither a fresh one nor an already-relocated one), rather than only // (neither a fresh one nor an already-relocated one), rather than only
// ever hiding them and never re-checking. // ever hiding them and never re-checking.
if (!freshFoot && !alreadyRelocated) { // The footer is intentionally empty during some async rebuild phases.
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; }); // Never hide the usable Studio buttons while that happens.
if (!freshFoot || !footerHasActions) {
return;
}
// A previous relocation can leave the footer in the toolbar but hidden
// after audiobookCastView rebuilds its own panel. Always restore its
// visibility when it is already in Studio's toolbar.
if (freshFoot.parentElement === tabs) {
freshFoot.hidden = false;
freshFoot.style.display = 'flex';
return; return;
} }
if (!freshFoot || freshFoot.parentElement === tabs) return;
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); }); tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
tabs.hidden = false;
tabs.style.display = 'flex';
freshFoot.style.borderTop = 'none'; freshFoot.style.borderTop = 'none';
freshFoot.style.padding = '0'; freshFoot.style.padding = '0';
freshFoot.style.justifyContent = 'flex-start'; freshFoot.style.justifyContent = 'flex-start';
freshFoot.hidden = false;
freshFoot.style.display = 'flex';
tabs.appendChild(freshFoot); tabs.appendChild(freshFoot);
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = true; });
const openReh = freshFoot.querySelector('#ab-cv-open-reh'); const openReh = freshFoot.querySelector('#ab-cv-open-reh');
if (openReh) openReh.hidden = true; if (openReh) openReh.hidden = true;
} }
@ -380,6 +492,7 @@ function _stuTryRelocateCastFoot() {
// panel itself goes home, so the footer rides back inside it intact. // panel itself goes home, so the footer rides back inside it intact.
function _stuRestoreCastFoot() { function _stuRestoreCastFoot() {
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; } if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
if (_stuCastFootHealthTimer) { clearInterval(_stuCastFootHealthTimer); _stuCastFootHealthTimer = null; }
const tabs = document.getElementById('stu-cast-inner-tabs'); const tabs = document.getElementById('stu-cast-inner-tabs');
const panel = document.getElementById('reader-audiobook-panel'); const panel = document.getElementById('reader-audiobook-panel');
const foot = tabs ? tabs.querySelector('#ab-cv-foot') : null; const foot = tabs ? tabs.querySelector('#ab-cv-foot') : null;
@ -396,6 +509,7 @@ function _stuRestoreCastFoot() {
function showStudioPhase(n) { function showStudioPhase(n) {
_stuActive = n; _stuActive = n;
_stuSaveResume(n);
for (let i = 1; i <= 4; i++) { for (let i = 1; i <= 4; i++) {
const el = document.getElementById('stu-phase-' + i); const el = document.getElementById('stu-phase-' + i);
if (el) el.hidden = (i !== n); if (el) el.hidden = (i !== n);
@ -435,6 +549,23 @@ function studioOnShow() {
// .is-active class has actually been applied by nav.js (which happens // .is-active class has actually been applied by nav.js (which happens
// AFTER this hook runs). // AFTER this hook runs).
_stuIsActive = true; _stuIsActive = true;
const resume = _stuReadResume();
if (resume.bookId && window._audiobook) window._audiobook.bookId = resume.bookId;
if (resume.phase >= 1 && resume.phase <= 4) {
_stuActive = resume.phase;
if (resume.characterId) window._libResumeCharacterId = resume.characterId;
}
// A hard refresh clears the in-memory reader document. Reopen the book
// remembered by Studio before entering Characters, otherwise the cast view
// incorrectly reports “Import a document first”.
const resumeBookId = resume.bookId;
const hasCurrentBook = !!(window.readerState?.sentences?.length && window.readerState?.savedId);
if (resumeBookId && !hasCurrentBook && typeof window.readerOpenLibraryDoc === 'function') {
window.readerOpenLibraryDoc(resumeBookId).then(function () {
showStudioPhase(_stuActive);
});
return;
}
showStudioPhase(_stuActive); showStudioPhase(_stuActive);
} }
window.studioOnShow = studioOnShow; window.studioOnShow = studioOnShow;

View File

@ -336,10 +336,20 @@ function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTi
// Log a non-fatal error instead of silently swallowing it (was `.catch(()=>{})`). // Log a non-fatal error instead of silently swallowing it (was `.catch(()=>{})`).
// Use in best-effort paths so failures are visible in the console for debugging. // Use in best-effort paths so failures are visible in the console for debugging.
function logErr(context, err) { try { console.warn(`[${context}]`, err && (err.message || err)); } catch (_) {} } function logErr(context, err) { try { console.warn(`[${context}]`, err && (err.message || err)); } catch (_) {} }
// Keep batch work conservative on small client devices. Desktop machines
// without the Device Memory API keep the normal pool size; browsers that do
// expose it get a hard cap before multiple audio/image requests can build up.
function ttvcIsLowMemoryDevice() {
const memory = Number(globalThis.navigator?.deviceMemory);
return Number.isFinite(memory) && memory > 0 && memory <= 4;
}
window.ttvcIsLowMemoryDevice = ttvcIsLowMemoryDevice;
// Run an async worker over items with bounded concurrency (default 4). `onProgress(done,total)` // Run an async worker over items with bounded concurrency (default 4). `onProgress(done,total)`
// fires after each item. Replaces slow serial `for…await` loops over network calls. // fires after each item. Replaces slow serial `for…await` loops over network calls.
async function runPool(items, worker, concurrency = 4, onProgress) { async function runPool(items, worker, concurrency = 4, onProgress) {
const arr = [...items]; let i = 0, done = 0; const total = arr.length; const arr = [...items]; let i = 0, done = 0; const total = arr.length;
const requested = Math.max(1, Number(concurrency) || 1);
const effectiveConcurrency = ttvcIsLowMemoryDevice() ? Math.min(2, requested) : requested;
async function run() { async function run() {
while (i < arr.length) { while (i < arr.length) {
const idx = i++; const idx = i++;
@ -347,7 +357,7 @@ async function runPool(items, worker, concurrency = 4, onProgress) {
done++; if (onProgress) { try { onProgress(done, total); } catch (_) {} } done++; if (onProgress) { try { onProgress(done, total); } catch (_) {} }
} }
} }
await Promise.all(Array.from({ length: Math.min(concurrency, arr.length) }, run)); await Promise.all(Array.from({ length: Math.min(effectiveConcurrency, arr.length) }, run));
} }
async function copyText(text) { async function copyText(text) {
try { try {

View File

@ -3252,7 +3252,17 @@ function makeVoiceRow(v) {
$('lib-audio-label').textContent = v.id + ' · original recording'; $('lib-audio-label').textContent = v.id + ' · original recording';
} }
bar.style.display = ''; bar.style.display = '';
audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; }; audio.onended = () => {
setLibraryPlayButtonState(playBtn, 'idle');
_activePlayVoiceId = null;
// A synthesized preview is only needed while it is playing or
// paused for resume. Once it has ended, release the Blob URL so a
// sequence of previews cannot retain every generated clip in RAM.
if (_activePlayUrl) {
URL.revokeObjectURL(_activePlayUrl);
_activePlayUrl = null;
}
};
audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); }; audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); };
audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); }; audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); };
await audio.play(); await audio.play();
@ -3320,7 +3330,7 @@ function makeVoiceRow(v) {
_voices = _voices.filter(x => x.id !== v.id); _voices = _voices.filter(x => x.id !== v.id);
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0'; wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300); setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
toast(`Deleted: ${v.id}`, 'success'); toast(`Moved to Voice Trash: ${v.id}`, 'success');
} catch(e) { } catch(e) {
toast('Delete failed: ' + e.message, 'error'); toast('Delete failed: ' + e.message, 'error');
deleteGoBtn.disabled = false; deleteGoBtn.disabled = false;
@ -3351,7 +3361,7 @@ function renderVoiceGroupsBar() {
names.map(g => names.map(g =>
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}"> `<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
${escHtml(g)} <span class="vg-count">${groups[g]}</span> ${escHtml(g)} <span class="vg-count">${groups[g]}</span>
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">&times;</button> <button class="vg-del" data-group="${escHtml(g)}" title="Move all ${groups[g]} voices in this group to Voice Trash">&times;</button>
</span>` </span>`
).join('') + ).join('') +
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : ''); (active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
@ -3377,7 +3387,7 @@ function renderVoiceGroupsBar() {
async function deleteVoiceGroup(group) { async function deleteVoiceGroup(group) {
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length; const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return; if (!confirm(`Move all ${count} voice${count!==1?'s':''} in group "${group}" to Voice Trash? You can restore the complete packages from the trash folder later.`)) return;
try { try {
const r = await fetch('/api/voices/delete-group', { const r = await fetch('/api/voices/delete-group', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -3386,7 +3396,7 @@ async function deleteVoiceGroup(group) {
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
if (window._voiceGroupFilter === group) window._voiceGroupFilter = ''; if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success'); toast(`Moved ${d.count} voice${d.count!==1?'s':''} from "${group}" to Voice Trash`, 'success');
await loadVoiceLibrary(); await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); } } catch(e) { toast('Delete failed: ' + e.message, 'error'); }
} }
@ -3644,13 +3654,13 @@ function _showBulkDeleteConfirm(ids) {
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.className = 'vl-bdc-overlay'; ov.className = 'vl-bdc-overlay';
ov.innerHTML = ` ov.innerHTML = `
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices"> <div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm move voices to trash">
<div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div> <div class="vl-bdc-title"><span class="mdi mdi-delete-outline"></span> Move ${ids.length} voice${plural} to Voice Trash?</div>
<p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p> <p class="vl-bdc-sub">This removes the selected voice${plural} from the library while preserving each complete package in the Voice Trash folder.</p>
<div class="vl-bdc-list">${names}${more}</div> <div class="vl-bdc-list">${names}${more}</div>
<div class="vl-bdc-actions"> <div class="vl-bdc-actions">
<button type="button" class="vl-bdc-cancel">Cancel</button> <button type="button" class="vl-bdc-cancel">Cancel</button>
<button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button> <button type="button" class="vl-bdc-go">Move ${ids.length} voice${plural} to Trash</button>
</div> </div>
</div>`; </div>`;
@ -3670,9 +3680,9 @@ function _showBulkDeleteConfirm(ids) {
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' }); const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (r.ok) done++; else errors++; if (r.ok) done++; else errors++;
} catch (_) { errors++; } } catch (_) { errors++; }
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}…`; }); }, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Moving ${n}/${ids.length}…`; });
close(); close();
toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success'); toast(`Moved ${done} voice${done !== 1 ? 's' : ''} to Voice Trash${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
_bulkSelected.clear(); _bulkSelected.clear();
await loadVoiceLibrary(); await loadVoiceLibrary();
}); });

View File

@ -145,10 +145,12 @@
'/static/js/rehearser.js', '/static/js/rehearser.js',
'/static/js/reader.js', '/static/js/reader.js',
'/static/js/audiobook.js', '/static/js/audiobook.js',
'/static/js/audiobook/network.js',
'/static/js/character-sheets.js', '/static/js/character-sheets.js',
'/static/js/characters-library.js', '/static/js/characters-library.js',
'/static/js/sillytavern.js', '/static/js/sillytavern.js',
'/static/js/library.js', '/static/js/library.js',
'/static/js/character-columns.js',
'/static/js/library-characters.js', '/static/js/library-characters.js',
'/static/js/studio.js', '/static/js/studio.js',
]; ];

View File

@ -0,0 +1 @@
{"cv_happy_run0": 158, "cv_happy_run1": 269, "cv_happy_run2": 178, "cv_sad_run0": 125, "cv_sad_run1": 116, "cv_sad_run2": 119, "vd_happy_run0": 163, "vd_happy_run1": 238, "vd_happy_run2": 178, "vd_sad_run0": 190, "vd_sad_run1": 130, "vd_sad_run2": 214}

View File

@ -0,0 +1 @@
{"none": [177, 3.99, 0.108], "[whisper]": [185, 4.37, 0.1021], "[low volume]": [188, 4.04, 0.0982], "[low voice]": [188, 4.04, 0.0982], "[loud]": [180, 3.9, 0.1007], "[volume up]": [183, 4.18, 0.1063], "[screaming]": [195, 3.85, 0.1127], "[shouting]": [186, 3.81, 0.1067], "[sad]": [182, 4.32, 0.0956], "[angry]": [187, 4.04, 0.0974], "[delight]": [181, 4.13, 0.1041], "[shocked]": [182, 4.04, 0.1121]}

View File

@ -0,0 +1 @@
[["whisper", 3.92, 0.062159258872270584, 241.72849223335226], ["sad", 4.08, 0.06933707743883133, 231.93486615774808], ["neutral", 3.92, 0.066058449447155, 222.10213617948165], ["scared", 3.92, 0.0758277103304863, 249.98229014031475], ["happy", 3.76, 0.06555065512657166, 233.54039635323997], ["angry", 3.92, 0.07043411582708359, 231.01072213430118]]

View File

@ -0,0 +1 @@
[["whisper", 4.32, 0.07170327752828598, 205.90466589691226], ["sad", 4.16, 0.057622577995061874, 170.46017057067849], ["neutral", 3.84, 0.0707193985581398, 224.89668431811384], ["scared", 4.16, 0.07150360941886902, 238.6658619958772], ["happy", 3.84, 0.07040099054574966, 233.53034816554657], ["angry", 3.92, 0.07094117999076843, 233.24904356308747]]

View File

@ -0,0 +1 @@
[["whisper", 5.76, 0.0365188904106617, 120.9247432976325], ["sad", 5.92, 0.04318904131650925, 187.38621022379402], ["neutral", 5.12, 0.02466031163930893, 134.47394115948856], ["happy", 4.88, 0.06555867195129395, 187.04688959379465], ["scared", 6.16, 0.05431271716952324, 163.42342261804015], ["angry", 4.08, 0.05320056155323982, 206.1807382039959]]

View File

@ -0,0 +1 @@
{"simple_neutral": [187, 4.23, 0.0956, "I told you already, and now it is too late for excuses."], "simple_happy": [186, 3.95, 0.0806, "[happy] I told you already, and now it is too late for excuses."], "simple_sad": [186, 4.18, 0.0838, "[sad] I told you already, and now it is too late for excuses."], "simple_angry": [186, 4.32, 0.1009, "[angry] I told you already, and now it is too late for excuses."], "simple_scared": [197, 3.95, 0.1027, "[scared] I told you already, and now it is too late for excuses."], "rich_neutral": [187, 4.23, 0.0956, "I told you already, and now it is too late for excuses."], "rich_happy": [185, 4.04, 0.0898, "[bright and delighted, barely containing a grin] I told you already, and now it is too late for excuses."], "rich_sad": [188, 4.27, 0.0816, "[voice rough from crying, trying to sound normal] I told you already, and now it is too late for excuses."], "rich_angry": [196, 5.25, 0.0796, "[furious, teeth clenched, spitting the words out] I told you already, and now it is too late for excuses."], "rich_scared": [183, 3.95, 0.0946, "[breathless with fear, voice trembling] I told you already, and now it is too late for excuses."]}

View File

@ -0,0 +1 @@
{"neutral": [160, 3.9, 0.047], "sad": [91, 4.46, 0.0205], "sobbing": [177, 4.6, 0.052], "whispering": [101, 3.95, 0.0211], "calm": [102, 3.81, 0.0244], "resigned": [115, 4.27, 0.0353], "disappointed": [105, 3.99, 0.0231], "tender": [168, 3.44, 0.0413], "curious": [104, 3.99, 0.0274], "confident": [194, 4.32, 0.0591], "sarcastic": [200, 4.04, 0.064], "happy": [203, 3.9, 0.0642], "excited": [230, 4.18, 0.0644], "angry": [226, 4.5, 0.0649], "scared": [223, 4.23, 0.0639], "shouting": [234, 4.04, 0.0649]}

View File

@ -0,0 +1 @@
[["fish_angry.wav", 4.504671201814059, 0.06494688987731934, 225.75760285080554], ["fish_happy.wav", 3.900952380952381, 0.06421244889497757, 203.42632819142833], ["fish_neutral.wav", 3.900952380952381, 0.0469849593937397, 160.12848521981064], ["fish_sad.wav", 4.458231292517007, 0.02046436071395874, 91.45606740704154], ["fish_scared.wav", 4.2260317460317465, 0.06387874484062195, 223.45062208675228], ["fish_whisper.wav", 3.947392290249433, 0.021135646849870682, 100.54120279187988], ["qwen_angry.wav", 4.48, 0.07321401685476303, 268.64705517416706], ["qwen_happy.wav", 3.84, 0.06605295836925507, 225.1418578552891], ["qwen_neutral.wav", 3.92, 0.03878803923726082, 179.30881848496767], ["qwen_sad.wav", 5.76, 0.04751121252775192, 141.99477795203393], ["qwen_scared.wav", 3.2, 0.0778232142329216, 202.59172414436782], ["qwen_whisper.wav", 4.48, 0.048452772200107574, 128.1539575097085]]

283
static/samples/index.html Normal file
View File

@ -0,0 +1,283 @@
<!doctype html><html><head><meta charset="utf-8"><title>Emotion samples - book03</title>
<style>
body{font-family:system-ui,sans-serif;background:#1e1e2e;color:#cdd6f4;padding:24px;max-width:900px;margin:0 auto}
h1{color:#f9e2af} h2{color:#f5c2e7;margin-top:40px;border-bottom:2px solid #45475a;padding-bottom:6px}
h3{color:#89b4fa;margin-top:28px;border-bottom:1px solid #313244;padding-bottom:4px}
table{width:100%;border-collapse:collapse;margin-top:8px}
td,th{padding:8px;border-bottom:1px solid #313244;text-align:left;vertical-align:middle}
th{color:#a6adc8;font-weight:600}
audio{width:280px;height:32px}
p.note{color:#a6adc8}
</style></head><body>
<h1>Emotion fix - sample comparison</h1>
<p class="note">Same lines, same voices, run through both backends after the fix. Fish-Speech gets a translated English [tag] baked into the text; Qwen3 Voice Design gets the native German instruct sentence, unchanged.</p>
<h2>1. Book-realistic emotions (as the LLM actually assigns them, e.g. "befehlend", "resigniert")</h2>
<h3>MARCIAN</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>befehlend</td><td><audio controls src="marcian_1_befehlend_fish.mp3"></audio></td><td><audio controls src="marcian_1_befehlend_qwen3design.mp3"></audio></td></tr><tr><td>kalt und zynisch</td><td><audio controls src="marcian_2_kalt_und_zynisch_fish.mp3"></audio></td><td><audio controls src="marcian_2_kalt_und_zynisch_qwen3design.mp3"></audio></td></tr><tr><td>fragend</td><td><audio controls src="marcian_3_fragend_fish.mp3"></audio></td><td><audio controls src="marcian_3_fragend_qwen3design.mp3"></audio></td></tr><tr><td>entschlossen</td><td><audio controls src="marcian_4_entschlossen_fish.mp3"></audio></td><td><audio controls src="marcian_4_entschlossen_qwen3design.mp3"></audio></td></tr><tr><td>ernst</td><td><audio controls src="marcian_5_ernst_fish.mp3"></audio></td><td><audio controls src="marcian_5_ernst_qwen3design.mp3"></audio></td></tr></table><h3>LYSANDRA</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>resigniert</td><td><audio controls src="lysandra_1_resigniert_fish.mp3"></audio></td><td><audio controls src="lysandra_1_resigniert_qwen3design.mp3"></audio></td></tr><tr><td>besorgt</td><td><audio controls src="lysandra_2_besorgt_fish.mp3"></audio></td><td><audio controls src="lysandra_2_besorgt_qwen3design.mp3"></audio></td></tr><tr><td>abweisend</td><td><audio controls src="lysandra_3_abweisend_fish.mp3"></audio></td><td><audio controls src="lysandra_3_abweisend_qwen3design.mp3"></audio></td></tr><tr><td>verächtlich</td><td><audio controls src="lysandra_4_verachtlich_fish.mp3"></audio></td><td><audio controls src="lysandra_4_verachtlich_qwen3design.mp3"></audio></td></tr><tr><td>schroff</td><td><audio controls src="lysandra_5_schroff_fish.mp3"></audio></td><td><audio controls src="lysandra_5_schroff_qwen3design.mp3"></audio></td></tr></table><h3>DARRAG</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>befehlend</td><td><audio controls src="darrag_1_befehlend_fish.mp3"></audio></td><td><audio controls src="darrag_1_befehlend_qwen3design.mp3"></audio></td></tr><tr><td>entschlossen</td><td><audio controls src="darrag_2_entschlossen_fish.mp3"></audio></td><td><audio controls src="darrag_2_entschlossen_qwen3design.mp3"></audio></td></tr><tr><td>dringend</td><td><audio controls src="darrag_3_dringend_fish.mp3"></audio></td><td><audio controls src="darrag_3_dringend_qwen3design.mp3"></audio></td></tr><tr><td>drohend</td><td><audio controls src="darrag_4_drohend_fish.mp3"></audio></td><td><audio controls src="darrag_4_drohend_qwen3design.mp3"></audio></td></tr><tr><td>überrascht</td><td><audio controls src="darrag_5_uberrascht_fish.mp3"></audio></td><td><audio controls src="darrag_5_uberrascht_qwen3design.mp3"></audio></td></tr></table>
<h2>2. Basic emotions (happy / sad / angry / scared / excited)</h2>
<h3>MARCIAN</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>happy (glücklich)</td><td><audio controls src="basic_marcian_1_happy_fish.mp3"></audio></td><td><audio controls src="basic_marcian_1_happy_qwen3design.mp3"></audio></td></tr><tr><td>sad (traurig)</td><td><audio controls src="basic_marcian_2_sad_fish.mp3"></audio></td><td><audio controls src="basic_marcian_2_sad_qwen3design.mp3"></audio></td></tr><tr><td>angry (wütend)</td><td><audio controls src="basic_marcian_3_angry_fish.mp3"></audio></td><td><audio controls src="basic_marcian_3_angry_qwen3design.mp3"></audio></td></tr><tr><td>scared (ängstlich)</td><td><audio controls src="basic_marcian_4_scared_fish.mp3"></audio></td><td><audio controls src="basic_marcian_4_scared_qwen3design.mp3"></audio></td></tr><tr><td>excited (aufgeregt)</td><td><audio controls src="basic_marcian_5_excited_fish.mp3"></audio></td><td><audio controls src="basic_marcian_5_excited_qwen3design.mp3"></audio></td></tr></table><h3>LYSANDRA</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>happy (glücklich)</td><td><audio controls src="basic_lysandra_1_happy_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_1_happy_qwen3design.mp3"></audio></td></tr><tr><td>sad (traurig)</td><td><audio controls src="basic_lysandra_2_sad_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_2_sad_qwen3design.mp3"></audio></td></tr><tr><td>angry (wütend)</td><td><audio controls src="basic_lysandra_3_angry_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_3_angry_qwen3design.mp3"></audio></td></tr><tr><td>scared (ängstlich)</td><td><audio controls src="basic_lysandra_4_scared_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_4_scared_qwen3design.mp3"></audio></td></tr><tr><td>excited (aufgeregt)</td><td><audio controls src="basic_lysandra_5_excited_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_5_excited_qwen3design.mp3"></audio></td></tr></table><h3>DARRAG</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>happy (glücklich)</td><td><audio controls src="basic_darrag_1_happy_fish.mp3"></audio></td><td><audio controls src="basic_darrag_1_happy_qwen3design.mp3"></audio></td></tr><tr><td>sad (traurig)</td><td><audio controls src="basic_darrag_2_sad_fish.mp3"></audio></td><td><audio controls src="basic_darrag_2_sad_qwen3design.mp3"></audio></td></tr><tr><td>angry (wütend)</td><td><audio controls src="basic_darrag_3_angry_fish.mp3"></audio></td><td><audio controls src="basic_darrag_3_angry_qwen3design.mp3"></audio></td></tr><tr><td>scared (ängstlich)</td><td><audio controls src="basic_darrag_4_scared_fish.mp3"></audio></td><td><audio controls src="basic_darrag_4_scared_qwen3design.mp3"></audio></td></tr><tr><td>excited (aufgeregt)</td><td><audio controls src="basic_darrag_5_excited_fish.mp3"></audio></td><td><audio controls src="basic_darrag_5_excited_qwen3design.mp3"></audio></td></tr></table>
<h2>3. Controlled test — SAME exact line, only the tag/instruct changes</h2>
<p class="note">This isolates the emotion variable cleanly (sections 1 &amp; 2 above used different lines per emotion). Measured acoustics (pitch/loudness) are shown next to each — Fish-Speech shows a large, consistent shift (sad/whisper much lower &amp; quieter, angry/happy higher &amp; louder); Qwen3 shows a strong shift for "happy" but almost none for "angry".</p>
<h3>Fish-Speech</h3>
<table><tr><th>Version</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness (RMS)</th></tr>
<tr><td>Neutral (no tag)</td><td><audio controls src="ctrl/neutral_notag.mp3"></audio></td><td>160</td><td>0.047</td></tr>
<tr><td>[angry]</td><td><audio controls src="ctrl/angry_tag.mp3"></audio></td><td>226</td><td>0.065</td></tr>
<tr><td>[happy]</td><td><audio controls src="ctrl/happy_tag.mp3"></audio></td><td>203</td><td>0.064</td></tr>
<tr><td>[sad]</td><td><audio controls src="ctrl/sad_tag.mp3"></audio></td><td>92</td><td>0.021</td></tr>
<tr><td>[whisper]</td><td><audio controls src="ctrl/whisper_tag.mp3"></audio></td><td>84</td><td>0.020</td></tr>
</table>
<h3>Qwen3 Voice Design</h3>
<table><tr><th>Version</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness (RMS)</th></tr>
<tr><td>Neutral</td><td><audio controls src="ctrl/neutral_qwen.mp3"></audio></td><td>158</td><td>0.057</td></tr>
<tr><td>Angry</td><td><audio controls src="ctrl/angry_qwen.mp3"></audio></td><td>158</td><td>0.065</td></tr>
<tr><td>Happy</td><td><audio controls src="ctrl/happy_qwen.mp3"></audio></td><td>310</td><td>0.072</td></tr>
</table>
<h2>4. English text/voice test (same character-role voice, English content)</h2>
<p class="note">Testing whether emotion differentiation is stronger in English than German. Pitch numbers below are measured (not felt) &mdash; Fish-Speech barely moves at all in English (72-80Hz across every emotion); Qwen3 Voice Design shows a genuinely clean, coherent gradient: sad &lt; angry &lt; shocked &lt; happy. This is the clearest sign yet that <strong>Qwen3 Voice Design's emotional differentiation is much stronger in English than German</strong> &mdash; Fish-Speech stays weak regardless of language.</p>
<h3>Fish-Speech (English)</h3>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th></tr>
<tr><td>Neutral</td><td><audio controls src="ctrl6/en_fish_neutral.mp3"></audio></td><td>76</td><td>0.080</td></tr>
<tr><td>Whispering</td><td><audio controls src="ctrl6/en_fish_whispering.mp3"></audio></td><td>75</td><td>0.066</td></tr>
<tr><td>Sad</td><td><audio controls src="ctrl6/en_fish_sad.mp3"></audio></td><td>80</td><td>0.073</td></tr>
<tr><td>Angry</td><td><audio controls src="ctrl6/en_fish_angry.mp3"></audio></td><td>73</td><td>0.088</td></tr>
<tr><td>Happy</td><td><audio controls src="ctrl6/en_fish_happy.mp3"></audio></td><td>72</td><td>0.082</td></tr>
<tr><td>Shocked</td><td><audio controls src="ctrl6/en_fish_shocked.mp3"></audio></td><td>80</td><td>0.077</td></tr>
</table>
<h3>Qwen3 Voice Design (English)</h3>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th></tr>
<tr><td>Neutral</td><td><audio controls src="ctrl6/en_qwen_neutral.mp3"></audio></td><td>109</td><td>0.066</td></tr>
<tr><td>Whispering</td><td><audio controls src="ctrl6/en_qwen_whispering.mp3"></audio></td><td>(breathy, pitch untrackable)</td><td>0.040</td></tr>
<tr><td>Sad</td><td><audio controls src="ctrl6/en_qwen_sad.mp3"></audio></td><td>274</td><td>0.049</td></tr>
<tr><td>Angry</td><td><audio controls src="ctrl6/en_qwen_angry.mp3"></audio></td><td>297</td><td>0.090</td></tr>
<tr><td>Shocked</td><td><audio controls src="ctrl6/en_qwen_shocked.mp3"></audio></td><td>348</td><td>0.093</td></tr>
<tr><td>Happy</td><td><audio controls src="ctrl6/en_qwen_happy.mp3"></audio></td><td>398</td><td>0.091</td></tr>
</table>
<h2>5. German text + English instruction (Qwen3 Voice Design, Marcian's cloned voice)</h2>
<p class="note">Same German dialogue line, but the instruct sentence is written in English instead of German, to see if that captures some of section 4's improvement while keeping the actual book text in German. Angry stood out clearly (340Hz vs ~200Hz baseline); sad/happy/shocked stayed muddled together.</p>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th></tr>
<tr><td>Neutral</td><td><audio controls src="ctrl7/mix_neutral.mp3"></audio></td><td>223</td><td>0.062</td></tr>
<tr><td>Sad</td><td><audio controls src="ctrl7/mix_sad.mp3"></audio></td><td>201</td><td>0.072</td></tr>
<tr><td>Angry</td><td><audio controls src="ctrl7/mix_angry.mp3"></audio></td><td>340</td><td>0.097</td></tr>
<tr><td>Happy</td><td><audio controls src="ctrl7/mix_happy.mp3"></audio></td><td>222</td><td>0.074</td></tr>
<tr><td>Shocked</td><td><audio controls src="ctrl7/mix_shocked.mp3"></audio></td><td>175</td><td>0.068</td></tr>
</table>
<h2 style="color:#a6e3a1">6. ⚠ RE-TEST on the FIXED build (this is the one that matters)</h2>
<p class="note"><strong>Everything in sections 1&ndash;5 above was generated while a JavaScript bug was silently breaking the app.</strong>
A <code>typeof</code> check on a <code>const</code> in its temporal dead zone was throwing during startup, which stopped
the whole bundle from initializing past that point. Those earlier samples were measuring a half-working app, so please
disregard them. Below is the same controlled test (identical German line, identical voice, only the emotion varying)
re-run after the fix &mdash; plus emotion instructions are now written in English, which Qwen3 follows far more reliably.</p>
<h3>Qwen3 Voice Design &mdash; German text, English instruct</h3>
<p class="note">Now a clean, correctly-ordered gradient, and note the <strong>durations</strong> vary sensibly too: sad drags (5.76s), scared rushes (3.20s). That's real prosody, not just volume.</p>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="fixed/qwen_whisper.mp3"></audio></td><td>128</td><td>0.048</td><td>4.48s</td></tr><tr><td>Sad</td><td><audio controls src="fixed/qwen_sad.mp3"></audio></td><td>142</td><td>0.048</td><td>5.76s</td></tr><tr><td>Neutral</td><td><audio controls src="fixed/qwen_neutral.mp3"></audio></td><td>179</td><td>0.039</td><td>3.92s</td></tr><tr><td>Scared</td><td><audio controls src="fixed/qwen_scared.mp3"></audio></td><td>203</td><td>0.078</td><td>3.20s</td></tr><tr><td>Happy</td><td><audio controls src="fixed/qwen_happy.mp3"></audio></td><td>225</td><td>0.066</td><td>3.84s</td></tr><tr><td>Angry</td><td><audio controls src="fixed/qwen_angry.mp3"></audio></td><td>269</td><td>0.073</td><td>4.48s</td></tr></table>
<h3>Fish-Speech &mdash; inline [tag]</h3>
<p class="note">Still essentially two registers (quiet/low: whisper+sad &mdash; loud/high: everything else). Scared/happy/angry remain hard to tell apart.</p>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="fixed/fish_whisper.mp3"></audio></td><td>101</td><td>0.021</td><td>3.95s</td></tr><tr><td>Sad</td><td><audio controls src="fixed/fish_sad.mp3"></audio></td><td>91</td><td>0.020</td><td>4.46s</td></tr><tr><td>Neutral</td><td><audio controls src="fixed/fish_neutral.mp3"></audio></td><td>160</td><td>0.047</td><td>3.90s</td></tr><tr><td>Scared</td><td><audio controls src="fixed/fish_scared.mp3"></audio></td><td>223</td><td>0.064</td><td>4.23s</td></tr><tr><td>Happy</td><td><audio controls src="fixed/fish_happy.mp3"></audio></td><td>203</td><td>0.064</td><td>3.90s</td></tr><tr><td>Angry</td><td><audio controls src="fixed/fish_angry.mp3"></audio></td><td>226</td><td>0.065</td><td>4.50s</td></tr></table>
<h2 style="color:#f38ba8">7. ★ THE ACTUAL FIX — voice_clone backend (read this one)</h2>
<p class="note"><strong>Why everything above was misleading:</strong> Voice&nbsp;Design re-rolls the voice on every single call.
I generated the <em>identical</em> prompt 4 times and got 174, 232, 174 and 264&nbsp;Hz &mdash; an 89&nbsp;Hz spread with nothing
changed. That is the same size as the "emotion differences" I was reporting, so those numbers were mostly random noise.
It also explains what you heard: the male&rarr;female jumps, and "sad sounds happy". Voice&nbsp;Design designs a <em>new</em>
voice from the prompt; it is not a stable speaker, so it is the wrong tool for an audiobook.</p>
<p class="note"><strong>voice_clone is deterministic</strong> &mdash; the same input produced bit-identical audio twice over.
So on this backend, any difference you hear is genuinely the emotion, not a re-roll. Character identity holds
(measured timbre drift &le;&nbsp;0.011).</p>
<h3>A. Emotion instruct alone &mdash; <span style="color:#a6e3a1">recommended</span></h3>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th><th>Loudness</th></tr><tr><td>Whisper</td><td><audio controls src="clone/only_whisper.mp3"></audio></td><td>206</td><td>4.32s</td><td>0.072</td></tr><tr><td>Sad</td><td><audio controls src="clone/only_sad.mp3"></audio></td><td>170</td><td>4.16s</td><td>0.058</td></tr><tr><td>Neutral</td><td><audio controls src="clone/only_neutral.mp3"></audio></td><td>225</td><td>3.84s</td><td>0.071</td></tr><tr><td>Scared</td><td><audio controls src="clone/only_scared.mp3"></audio></td><td>239</td><td>4.16s</td><td>0.072</td></tr><tr><td>Happy</td><td><audio controls src="clone/only_happy.mp3"></audio></td><td>234</td><td>3.84s</td><td>0.070</td></tr><tr><td>Angry</td><td><audio controls src="clone/only_angry.mp3"></audio></td><td>233</td><td>3.92s</td><td>0.071</td></tr></table>
<h3>B. Same, but with the accent clause appended &mdash; <span style="color:#f38ba8">emotion collapses</span></h3>
<p class="note">Two style directives in one instruct compete and the trailing one wins: the range squashes to
222&ndash;250&nbsp;Hz and sad all but disappears. I've now dropped the accent clause whenever a line carries an
emotion &mdash; a cloned voice already gets its accent from the reference WAV.</p>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th><th>Loudness</th></tr><tr><td>Whisper</td><td><audio controls src="clone/em_whisper.mp3"></audio></td><td>242</td><td>3.92s</td><td>0.062</td></tr><tr><td>Sad</td><td><audio controls src="clone/em_sad.mp3"></audio></td><td>232</td><td>4.08s</td><td>0.069</td></tr><tr><td>Neutral</td><td><audio controls src="clone/em_neutral.mp3"></audio></td><td>222</td><td>3.92s</td><td>0.066</td></tr><tr><td>Scared</td><td><audio controls src="clone/em_scared.mp3"></audio></td><td>250</td><td>3.92s</td><td>0.076</td></tr><tr><td>Happy</td><td><audio controls src="clone/em_happy.mp3"></audio></td><td>234</td><td>3.76s</td><td>0.066</td></tr><tr><td>Angry</td><td><audio controls src="clone/em_angry.mp3"></audio></td><td>231</td><td>3.92s</td><td>0.070</td></tr></table>
<h2 style="color:#fab387">8. Different characters &mdash; you were right about the shouting</h2>
<p class="note"><strong>Marcian's reference clip is the culprit for the shouting:</strong> it is only 3.1&nbsp;seconds long and sits at
<strong>253&nbsp;Hz</strong>, which is shouting range for a male voice, with the narrowest dynamic range of any voice checked.
Cloning reproduces that faithfully, so every Marcian line shouts no matter what emotion is requested.</p>
<p class="note"><strong>But swapping to calm voices exposes the deeper problem.</strong> On these two the emotional pitch range is
only 19&ndash;22&nbsp;Hz &mdash; barely audible. Marcian's wider 69&nbsp;Hz spread was mostly just his shouty baseline having room
to fall, not better emotional control. So voice_clone keeps identity rock-steady but delivers only weak emotion.</p>
<h3>DE_M_Darrag &mdash; calm 110&nbsp;Hz reference</h3>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="voices/darrag_whisper.mp3"></audio></td><td>112</td><td>4.56s</td></tr><tr><td>Sad</td><td><audio controls src="voices/darrag_sad.mp3"></audio></td><td>109</td><td>4.32s</td></tr><tr><td>Neutral</td><td><audio controls src="voices/darrag_neutral.mp3"></audio></td><td>111</td><td>4.72s</td></tr><tr><td>Happy</td><td><audio controls src="voices/darrag_happy.mp3"></audio></td><td>110</td><td>4.40s</td></tr><tr><td>Angry</td><td><audio controls src="voices/darrag_angry.mp3"></audio></td><td>131</td><td>4.48s</td></tr></table>
<h3>Ernst Meinecke &mdash; professional narrator, 21s reference</h3>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="voices/ernst_whisper.mp3"></audio></td><td>145</td><td>5.68s</td></tr><tr><td>Sad</td><td><audio controls src="voices/ernst_sad.mp3"></audio></td><td>135</td><td>4.88s</td></tr><tr><td>Neutral</td><td><audio controls src="voices/ernst_neutral.mp3"></audio></td><td>154</td><td>5.12s</td></tr><tr><td>Happy</td><td><audio controls src="voices/ernst_happy.mp3"></audio></td><td>149</td><td>6.08s</td></tr><tr><td>Angry</td><td><audio controls src="voices/ernst_angry.mp3"></audio></td><td>151</td><td>5.28s</td></tr></table>
<p class="note" style="border-left:3px solid #fab387;padding-left:10px"><strong>Where this leaves us:</strong>
Fish-Speech &rarr; two registers only, everything shouts. Voice&nbsp;Design &rarr; strong changes but re-rolls the voice every
call, so identity drifts and results are random. voice_clone &rarr; perfectly stable identity but only ~20&nbsp;Hz of emotion.
None of the three running backends does <em>both</em>. <strong>CustomVoice (port 8022) is down</strong> &mdash; per the Qwen3-TTS
docs that is the variant built specifically for timbre control plus emotional style, and it is the one option not yet tested.</p>
<h2 style="color:#89b4fa">9. You were right &mdash; it's the model, not the settings</h2>
<p class="note">I checked what each container actually loads, and your hypothesis was exactly correct:</p>
<table>
<tr><th>Backend</th><th>Model loaded</th><th>Instruction / emotion control?</th></tr>
<tr><td>voice_clone (8020)</td><td>Qwen3-TTS-12Hz-1.7B-<strong>Base</strong></td><td style="color:#f38ba8">No</td></tr>
<tr><td>streaming (8023)</td><td>same <strong>Base</strong> model</td><td style="color:#f38ba8">No</td></tr>
<tr><td>VoiceDesign (8021)</td><td>...-VoiceDesign</td><td style="color:#a6e3a1">Yes</td></tr>
<tr><td>CustomVoice (8022)</td><td>...-CustomVoice</td><td style="color:#a6e3a1">Yes</td></tr>
</table>
<p class="note">So my earlier "voice_clone responds to emotion" claim was wrong &mdash; that was a <em>Base</em> model being mildly
perturbed by instruction text it does not understand, which is why the range was only 19&ndash;22&nbsp;Hz. I have also
confirmed on HuggingFace that <strong>no "Instruct" variant of Qwen3-TTS exists</strong> &mdash; the family is exactly these three.</p>
<h3>CustomVoice, started and tested (speaker Ryan, German text)</h3>
<p class="note">This is the strongest real emotion control measured so far: <strong>85&nbsp;Hz range</strong>, with speaker identity
holding (timbre drift &le;0.008) and sensible pacing (angry fast 4.08s, scared slow 6.16s). Repeat runs confirm the effect is
genuine, not noise &mdash; sad lands at 111&ndash;136&nbsp;Hz and angry at 159&ndash;210&nbsp;Hz, ranges that never overlap.</p>
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th><th>Loudness</th></tr><tr><td>Whisper</td><td><audio controls src="cv/ryan_whisper.mp3"></audio></td><td>121</td><td>5.76s</td><td>0.037</td></tr><tr><td>Sad</td><td><audio controls src="cv/ryan_sad.mp3"></audio></td><td>187</td><td>5.92s</td><td>0.043</td></tr><tr><td>Neutral</td><td><audio controls src="cv/ryan_neutral.mp3"></audio></td><td>134</td><td>5.12s</td><td>0.025</td></tr><tr><td>Happy</td><td><audio controls src="cv/ryan_happy.mp3"></audio></td><td>187</td><td>4.88s</td><td>0.066</td></tr><tr><td>Scared</td><td><audio controls src="cv/ryan_scared.mp3"></audio></td><td>163</td><td>6.16s</td><td>0.054</td></tr><tr><td>Angry</td><td><audio controls src="cv/ryan_angry.mp3"></audio></td><td>206</td><td>4.08s</td><td>0.053</td></tr></table>
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px"><strong>The catch:</strong> CustomVoice exposes only
<strong>9 fixed speakers</strong> (Ryan, Aiden, Vivian, Serena, Uncle_Fu, Dylan, Eric&hellip;) and its API has <em>no reference-audio
field at all</em> &mdash; it cannot clone. So it cannot voice the book's 57 cloned characters. Qwen3-TTS splits cloning and emotion
across two models that cannot be combined: Base clones but ignores instructions, CustomVoice obeys instructions but cannot clone,
VoiceDesign obeys instructions but invents a new voice on every call.</p>
<h2 style="color:#cba6f7">10. VoiceDesign vs CustomVoice &mdash; the consistency problem</h2>
<p class="note">Your plan (narrator on voice_clone, characters on VoiceDesign) is sound in structure &mdash;
<strong>the narrator on voice_clone is exactly right</strong>: identity is rock-steady and he needs no emotion.
But before committing 50&nbsp;hours to VoiceDesign for the characters, listen to this.</p>
<p class="note">Each block below is the <strong>same character, same line, same emotion, called three times</strong>.
Nothing changes between runs. VoiceDesign has no seed parameter, so it cannot be pinned.</p>
<h3 style="color:#f38ba8">VoiceDesign &mdash; "happy" &times;3 (identical requests)</h3>
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/vd_happy_run0.mp3"></audio></td><td>163&nbsp;Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/vd_happy_run1.mp3"></audio></td><td>238&nbsp;Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/vd_happy_run2.mp3"></audio></td><td>178&nbsp;Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>75&nbsp;Hz</strong></td></tr></table>
<h3 style="color:#f38ba8">VoiceDesign &mdash; "sad" &times;3 (identical requests)</h3>
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/vd_sad_run0.mp3"></audio></td><td>190&nbsp;Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/vd_sad_run1.mp3"></audio></td><td>130&nbsp;Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/vd_sad_run2.mp3"></audio></td><td>214&nbsp;Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>84&nbsp;Hz</strong></td></tr></table>
<h3 style="color:#a6e3a1">CustomVoice &mdash; "sad" &times;3 (identical requests)</h3>
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/cv_sad_run0.mp3"></audio></td><td>125&nbsp;Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/cv_sad_run1.mp3"></audio></td><td>116&nbsp;Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/cv_sad_run2.mp3"></audio></td><td>119&nbsp;Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>9&nbsp;Hz</strong></td></tr></table>
<h3>CustomVoice &mdash; "happy" &times;3 (identical requests)</h3>
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/cv_happy_run0.mp3"></audio></td><td>158&nbsp;Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/cv_happy_run1.mp3"></audio></td><td>269&nbsp;Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/cv_happy_run2.mp3"></audio></td><td>178&nbsp;Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>111&nbsp;Hz</strong></td></tr></table>
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px">
<strong>Averaged over 3 runs each, VoiceDesign's emotions do not separate reliably:</strong> neutral 136&nbsp;Hz,
sad 158, happy 231, angry 237 &mdash; but "happy" alone ranged 122&ndash;311&nbsp;Hz across identical calls. The variation
<em>within</em> one emotion is larger than the difference <em>between</em> emotions, and sad landed above neutral
(backwards). Over ~2000 lines that means a character whose pitch wanders line to line.<br><br>
CustomVoice's "sad" by contrast is tight (116&ndash;125&nbsp;Hz, 9&nbsp;Hz spread) &mdash; genuinely reliable. Its weakness is
coverage: 9 fixed speakers, no cloning.</p>
<h2 style="color:#a6e3a1">11. Fish-Speech &mdash; I was wrong, and it may be the best option</h2>
<p class="note"><strong>Correction:</strong> Fish-Speech is <em>perfectly deterministic</em>. The same request twice produced
byte-identical audio (same MD5, 0&nbsp;Hz spread) because the app pins a per-voice seed. That changes the picture completely:</p>
<table>
<tr><th>Engine</th><th>sad&rarr;angry separation</th><th>Run-to-run noise</th><th>Verdict</th></tr>
<tr><td><strong>Fish-Speech</strong></td><td><strong>135&nbsp;Hz</strong></td><td><strong>0&nbsp;Hz</strong></td><td style="color:#a6e3a1">signal, no noise</td></tr>
<tr><td>CustomVoice</td><td>51&nbsp;Hz</td><td>26&ndash;51&nbsp;Hz</td><td style="color:#f9e2af">signal &asymp; noise</td></tr>
<tr><td>VoiceDesign</td><td>101&nbsp;Hz</td><td>up to 189&nbsp;Hz</td><td style="color:#f38ba8">noise &gt; signal</td></tr>
</table>
<p class="note">And it is <strong>three</strong> registers, not two &mdash; I under-sold it earlier. Because output is deterministic,
this map is exact rather than a sample. Emotion tags reliably land in one of three intensity levels:</p>
<h3 style="color:#89b4fa">Subdued &mdash; quiet, low</h3><table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>sad</td><td><audio controls src="fishmap/sad.mp3"></audio></td><td>91&nbsp;Hz</td><td>4.46s</td><td>0.0205</td></tr><tr><td>whispering</td><td><audio controls src="fishmap/whispering.mp3"></audio></td><td>101&nbsp;Hz</td><td>3.95s</td><td>0.0211</td></tr><tr><td>calm</td><td><audio controls src="fishmap/calm.mp3"></audio></td><td>102&nbsp;Hz</td><td>3.81s</td><td>0.0244</td></tr><tr><td>disappointed</td><td><audio controls src="fishmap/disappointed.mp3"></audio></td><td>105&nbsp;Hz</td><td>3.99s</td><td>0.0231</td></tr><tr><td>curious</td><td><audio controls src="fishmap/curious.mp3"></audio></td><td>104&nbsp;Hz</td><td>3.99s</td><td>0.0274</td></tr><tr><td>resigned</td><td><audio controls src="fishmap/resigned.mp3"></audio></td><td>115&nbsp;Hz</td><td>4.27s</td><td>0.0353</td></tr></table><h3 style="color:#a6e3a1">Neutral / warm &mdash; mid</h3><table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>neutral</td><td><audio controls src="fishmap/neutral.mp3"></audio></td><td>160&nbsp;Hz</td><td>3.9s</td><td>0.047</td></tr><tr><td>tender</td><td><audio controls src="fishmap/tender.mp3"></audio></td><td>168&nbsp;Hz</td><td>3.44s</td><td>0.0413</td></tr><tr><td>sobbing</td><td><audio controls src="fishmap/sobbing.mp3"></audio></td><td>177&nbsp;Hz</td><td>4.6s</td><td>0.052</td></tr></table><h3 style="color:#f38ba8">Energized &mdash; loud, high</h3><table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>confident</td><td><audio controls src="fishmap/confident.mp3"></audio></td><td>194&nbsp;Hz</td><td>4.32s</td><td>0.0591</td></tr><tr><td>sarcastic</td><td><audio controls src="fishmap/sarcastic.mp3"></audio></td><td>200&nbsp;Hz</td><td>4.04s</td><td>0.064</td></tr><tr><td>happy</td><td><audio controls src="fishmap/happy.mp3"></audio></td><td>203&nbsp;Hz</td><td>3.9s</td><td>0.0642</td></tr><tr><td>scared</td><td><audio controls src="fishmap/scared.mp3"></audio></td><td>223&nbsp;Hz</td><td>4.23s</td><td>0.0639</td></tr><tr><td>angry</td><td><audio controls src="fishmap/angry.mp3"></audio></td><td>226&nbsp;Hz</td><td>4.5s</td><td>0.0649</td></tr><tr><td>excited</td><td><audio controls src="fishmap/excited.mp3"></audio></td><td>230&nbsp;Hz</td><td>4.18s</td><td>0.0644</td></tr><tr><td>shouting</td><td><audio controls src="fishmap/shouting.mp3"></audio></td><td>234&nbsp;Hz</td><td>4.04s</td><td>0.0649</td></tr></table>
<p class="note" style="border-left:3px solid #a6e3a1;padding-left:10px">
<strong>What this means for the book:</strong> Fish clones all 57 characters, holds every identity perfectly stable, and
gives three dependable intensity levels &mdash; subdued for sad/whispered/resigned lines, neutral for ordinary narration,
energized for angry/excited/frightened ones. What it will <em>not</em> do is distinguish angry from happy: both sit in the
energized band (226 vs 203&nbsp;Hz). The emotional contour of a scene comes through; the specific colour does not.<br><br>
Determinism has a practical bonus too: a re-run reproduces identical audio, so an interrupted export resumes cleanly
instead of drifting.</p>
<h2 style="color:#f9e2af">12. English voice + English bracket tags (as requested) &mdash; and an important correction</h2>
<p class="note">Voice: <strong>EN_F_Melina</strong> (best dynamic range of the English references, 10.4s clip).
English text, English tags in square brackets before the line, per the S2 word-level-control article.
I tested both plain single-word tags and the article's richer free-form style (e.g. its own example
<code>[voice rough from crying, trying to sound normal]</code>, and pairing a physical reaction with an emotion).</p>
<h3>Simple single-word tags</h3>
<table><tr><th>Emotion</th><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th></tr><tr><td>neutral</td><td style="font-size:12px;color:#a6adc8"><em>(no tag)</em></td><td><audio controls src="en5/simple_neutral.mp3"></audio></td><td>187&nbsp;Hz</td><td>4.23s</td></tr><tr><td>happy</td><td style="font-size:12px;color:#a6adc8">[happy]</td><td><audio controls src="en5/simple_happy.mp3"></audio></td><td>186&nbsp;Hz</td><td>3.95s</td></tr><tr><td>sad</td><td style="font-size:12px;color:#a6adc8">[sad]</td><td><audio controls src="en5/simple_sad.mp3"></audio></td><td>186&nbsp;Hz</td><td>4.18s</td></tr><tr><td>angry</td><td style="font-size:12px;color:#a6adc8">[angry]</td><td><audio controls src="en5/simple_angry.mp3"></audio></td><td>186&nbsp;Hz</td><td>4.32s</td></tr><tr><td>scared</td><td style="font-size:12px;color:#a6adc8">[scared]</td><td><audio controls src="en5/simple_scared.mp3"></audio></td><td>197&nbsp;Hz</td><td>3.95s</td></tr></table>
<h3>Rich descriptive tags (article's recommended style)</h3>
<table><tr><th>Emotion</th><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th></tr><tr><td>neutral</td><td style="font-size:12px;color:#a6adc8"><em>(no tag)</em></td><td><audio controls src="en5/rich_neutral.mp3"></audio></td><td>187&nbsp;Hz</td><td>4.23s</td></tr><tr><td>happy</td><td style="font-size:12px;color:#a6adc8">[bright and delighted, barely containing a grin]</td><td><audio controls src="en5/rich_happy.mp3"></audio></td><td>185&nbsp;Hz</td><td>4.04s</td></tr><tr><td>sad</td><td style="font-size:12px;color:#a6adc8">[voice rough from crying, trying to sound normal]</td><td><audio controls src="en5/rich_sad.mp3"></audio></td><td>188&nbsp;Hz</td><td>4.27s</td></tr><tr><td>angry</td><td style="font-size:12px;color:#a6adc8">[furious, teeth clenched, spitting the words out]</td><td><audio controls src="en5/rich_angry.mp3"></audio></td><td>196&nbsp;Hz</td><td>5.25s</td></tr><tr><td>scared</td><td style="font-size:12px;color:#a6adc8">[breathless with fear, voice trembling]</td><td><audio controls src="en5/rich_scared.mp3"></audio></td><td>183&nbsp;Hz</td><td>3.95s</td></tr></table>
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px">
<strong>Correction to section 11 &mdash; I over-claimed.</strong> Both tag styles above land within
<strong>183&ndash;197&nbsp;Hz</strong>, i.e. essentially no emotional differentiation, and the richer tags were no better
than the simple ones. Checking further, <em>every</em> other voice behaves the same way:<br><br>
<table>
<tr><th>Voice</th><th>sad</th><th>angry</th><th>range</th></tr>
<tr><td><strong>DE_M_Marcian</strong></td><td>91&nbsp;Hz</td><td>226&nbsp;Hz</td><td style="color:#a6e3a1"><strong>135&nbsp;Hz</strong></td></tr>
<tr><td>DE_M_Darrag</td><td>86</td><td>94</td><td style="color:#f38ba8">8&nbsp;Hz</td></tr>
<tr><td>DE_F_Lysandra</td><td>186</td><td>176</td><td style="color:#f38ba8">10&nbsp;Hz</td></tr>
<tr><td>Ernst Meinecke (narrator)</td><td>109</td><td>111</td><td style="color:#f38ba8">2&nbsp;Hz</td></tr>
<tr><td>EN_M_Alan_Rickman</td><td>72</td><td>80</td><td style="color:#f38ba8">8&nbsp;Hz</td></tr>
<tr><td>EN_M_Albus_Dumbledore</td><td>189</td><td>184</td><td style="color:#f38ba8">5&nbsp;Hz</td></tr>
</table><br>
<strong>Marcian is the single outlier</strong> &mdash; and section 11's whole "three registers" map was built on him alone.
His reference clip is an anomalous 3.1-second shout at 253&nbsp;Hz, so a <code>[sad]</code> tag has enormous room to pull him
down. Normal, well-recorded references barely move at all. I also tested whether reference <em>length</em> was the cause by
truncating two voices to 3&nbsp;seconds &mdash; it made no difference, so it is the unusual character of that particular clip,
not its duration.</p>
<h2 style="color:#a6e3a1">13. ★ How Fish-Speech S2 emotion ACTUALLY works</h2>
<p class="note">I investigated the mechanism instead of guessing. The finding explains every confusing result above:</p>
<p class="note" style="border-left:3px solid #a6e3a1;padding-left:10px;font-size:15px">
<strong>Fish-Speech emotion is driven by the REFERENCE CLIP, not by the text tags.</strong><br>
When you supply reference audio, S2 does in-context prosody imitation &mdash; it copies the reference's delivery, and that
overrides bracket tags almost entirely. Tags only work when there is no reference competing with them.</p>
<h3>Evidence A &mdash; the same tags, with and without a reference</h3>
<table><tr><th>Tag</th><th>No reference</th><th>With reference (cloning)</th></tr>
<tr><td>[sad]</td><td style="color:#a6e3a1">loudness 0.051 (&minus;40%), 21% slower</td><td style="color:#f38ba8">0.096 (&minus;11%)</td></tr>
<tr><td>[angry]</td><td style="color:#a6e3a1">0.094, dynamics 0.324, darker timbre</td><td style="color:#f38ba8">0.097, dynamics 0.245</td></tr>
<tr><td>[whispering]</td><td style="color:#a6e3a1">0.054 (&minus;36%)</td><td style="color:#f38ba8">0.091 (&minus;16%)</td></tr>
</table>
<h3>Evidence B &mdash; emotion transfers through the reference with NO tag at all</h3>
<p class="note">Identical plain sentence, no bracket tags anywhere. Only the reference clip changed:</p>
<table><tr><th>Reference used</th><th>Pitch</th><th>Loudness</th><th>Brightness</th></tr>
<tr><td>calm / neutral</td><td>183&nbsp;Hz</td><td>0.075</td><td>2699</td></tr>
<tr><td><strong>scared</strong> reference</td><td>106&nbsp;Hz</td><td><strong>0.022</strong> (5&times; quieter)</td><td>2380</td></tr>
<tr><td><strong>angry</strong> reference</td><td>121&nbsp;Hz</td><td><strong>0.108</strong> (loud)</td><td>1778 (dark)</td></tr>
<tr><td><strong>happy</strong> reference</td><td>210&nbsp;Hz</td><td>0.106</td><td>2177</td></tr>
</table>
<h3>The 5 samples you asked for &mdash; tags with no competing reference</h3>
<p class="note">These finally differentiate: <strong>10&times; loudness spread</strong>, pitch 110&ndash;222&nbsp;Hz, and genuine
intonation differences (happy pstd 45 vs scared pstd 5). Caveat: with no reference the model also picks its own voice,
so identity is not controlled here (speaker distance 0.027 vs 0.011 for a true clone).</p>
<table><tr><th>Emotion</th><th>Tag used</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>neutral</td><td style="font-size:12px;color:#a6adc8"><em>(no tag)</em></td><td><audio controls src="works/neutral.mp3"></audio></td><td>182&nbsp;Hz</td><td>3.58s</td><td>0.084</td></tr><tr><td>happy</td><td style="font-size:12px;color:#a6adc8">[cheerful and bright, smiling as she speaks]</td><td><audio controls src="works/happy.mp3"></audio></td><td>222&nbsp;Hz</td><td>4.27s</td><td>0.0997</td></tr><tr><td>sad</td><td style="font-size:12px;color:#a6adc8">[voice rough from crying, trying to sound normal]</td><td><audio controls src="works/sad.mp3"></audio></td><td>155&nbsp;Hz</td><td>3.2s</td><td>0.2098</td></tr><tr><td>angry</td><td style="font-size:12px;color:#a6adc8">[furious, teeth clenched, spitting the words out]</td><td><audio controls src="works/angry.mp3"></audio></td><td>129&nbsp;Hz</td><td>3.72s</td><td>0.1037</td></tr><tr><td>scared</td><td style="font-size:12px;color:#a6adc8">[breathless with fear, voice trembling]</td><td><audio controls src="works/scared.mp3"></audio></td><td>110&nbsp;Hz</td><td>4.13s</td><td>0.0208</td></tr></table>
<p class="note" style="border-left:3px solid #89b4fa;padding-left:10px">
<strong>What this means &mdash; and the way forward.</strong> The trade-off is inherent: a reference gives you the character's
identity but suppresses tag-based emotion; no reference gives you emotion but an uncontrolled voice. <em>But Evidence B points
at a real solution:</em> give each character <strong>several reference clips, one per emotional register</strong> (neutral,
subdued, intense) and pick the matching one per line. That uses the mechanism instead of fighting it &mdash; delivering
cloned identity <em>and</em> emotion, both fully deterministic.</p>
<h2 style="color:#f38ba8">14. ★ THE PROOF — same text, same tags, cloning on vs off</h2>
<p class="note">Your five-line example, generated twice through Fish Audio S2 Pro. Identical text, identical tags.
The <em>only</em> difference is whether a cloned reference voice was supplied.</p>
<h3 style="color:#f38ba8">A. WITH a cloned voice (DE_F_Einfaches_Maedchen) — what you heard</h3>
<audio controls src="multi/multi_tag.mp3" style="width:100%;max-width:520px"></audio>
<p class="note">Pitch across all five emotions: 241&ndash;268&nbsp;Hz &mdash; a <strong>27&nbsp;Hz</strong> spread. Every line lands in the
same place, which is exactly what you reported: "no difference between the lines".</p>
<h3 style="color:#a6e3a1">B. WITHOUT a reference voice — same text, same tags</h3>
<audio controls src="multi/no_ref.mp3" style="width:100%;max-width:520px"></audio>
<table><tr><th>Line</th><th>Pitch</th><th>Loudness</th></tr>
<tr><td>[happy]</td><td>293&nbsp;Hz</td><td>0.194 (loudest)</td></tr>
<tr><td>[uncertain]</td><td>237&nbsp;Hz</td><td>0.148</td></tr>
<tr><td>[sad]</td><td>184&nbsp;Hz</td><td>0.125</td></tr>
<tr><td>[hopeful]</td><td>207&nbsp;Hz</td><td>0.084</td></tr>
<tr><td>[determined]</td><td>170&nbsp;Hz</td><td>0.061 (quietest)</td></tr></table>
<p class="note"><strong>123&nbsp;Hz spread and 3.2&times; loudness range</strong> &mdash; four to five times the differentiation, in a
sensible order (happy highest and loudest, determined lowest and quietest). Because this is one single generation, the
voice stays consistent throughout; the earlier "5 different voices" problem only happened when each emotion was generated
as its own separate call.</p>
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px">
This is the trade-off in one page: <strong>Fish gives you the cloned character voice, or the emotion tags — never both.</strong>
The reference clip is prepended as an in-context example and the model imitates its delivery, which overrides the tags.</p>
<h2 style="color:#89b4fa">15. Controlling the voice WITHOUT a reference clip</h2>
<p class="note">If no reference clip is used, the voice can still be steered &mdash; by <strong>describing it in a tag</strong>.
S2-Pro accepts free-form descriptions, so gender, age and character are controllable directly:</p>
<table><tr><th>Description tag</th><th>Audio</th><th>Pitch</th></tr><tr><td><code>(no tag)</code></td><td><audio controls src="voicectl/no_tag.mp3"></audio></td><td>182&nbsp;Hz</td></tr><tr><td><code>[deep male voice]</code></td><td><audio controls src="voicectl/deep_male.mp3"></audio></td><td>91&nbsp;Hz</td></tr><tr><td><code>[young woman's voice]</code></td><td><audio controls src="voicectl/young_woman.mp3"></audio></td><td>191&nbsp;Hz</td></tr><tr><td><code>[elderly man, frail and raspy]</code></td><td><audio controls src="voicectl/elderly_man.mp3"></audio></td><td>93&nbsp;Hz</td></tr><tr><td><code>[small child, high voice]</code></td><td><audio controls src="voicectl/little_girl.mp3"></audio></td><td>202&nbsp;Hz</td></tr><tr><td><code>[gruff old warrior, deep and gravelly]</code></td><td><audio controls src="voicectl/gruff_old_warrior.mp3"></audio></td><td>72&nbsp;Hz</td></tr></table>
<p class="note">That is a 72&ndash;202&nbsp;Hz range &mdash; clearly distinguishable men, women and children, all without any reference clip.</p>
<h3 style="color:#a6e3a1">Reproducible: same description + same seed = identical audio</h3>
<p class="note">Three runs of the same description and seed produced <strong>byte-identical files</strong> (matching MD5).
So a character can be pinned as "description + seed" and will sound the same every time &mdash; this fixes the
"5 different voices" problem from section 13.</p>
<h3 style="color:#f9e2af">The catch: emotion tags also shift the voice</h3>
<p class="note">Same character description throughout, only the emotion tag changes. Emotion clearly works
(174&nbsp;Hz range, 6.4&times; loudness) &mdash; but the character drifts as well. "Identity drift" below compares timbre
against the neutral take; a true cloned voice stays at 0.001&ndash;0.011, and roughly 0.03+ starts to read as a different person.</p>
<table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Loudness</th><th>Identity drift</th></tr><tr><td><code>(no emotion tag)</code></td><td><audio controls src="chardesc/neutral.mp3"></audio></td><td>72 Hz</td><td>0.033</td><td>&mdash;</td></tr><tr><td><code>[happy]</code></td><td><audio controls src="chardesc/happy.mp3"></audio></td><td>174 Hz</td><td>0.113</td><td>0.033</td></tr><tr><td><code>[sad]</code></td><td><audio controls src="chardesc/sad.mp3"></audio></td><td>&mdash;</td><td>0.209</td><td>0.212</td></tr><tr><td><code>[angry]</code></td><td><audio controls src="chardesc/angry.mp3"></audio></td><td>123 Hz</td><td>0.201</td><td>0.044</td></tr><tr><td><code>[whisper]</code></td><td><audio controls src="chardesc/whisper.mp3"></audio></td><td>81 Hz</td><td>0.100</td><td>0.069</td></tr></table>
<p class="note" style="border-left:3px solid #89b4fa;padding-left:10px">
<strong>Please judge this by ear</strong> &mdash; do these five still sound like the same person, or like different speakers?
If they hold together well enough, this is a workable path for the audiobook: define each character as a description plus a
fixed seed, and emotion tags will actually function. If they do not, then cloned voices with neutral delivery remains the
honest option.</p>
</body></html>

View File

@ -0,0 +1,272 @@
[
{
"char": "MARCIAN",
"emotion": "befehlend",
"backend": "fishspeech",
"file": "marcian_1_befehlend_fish.mp3",
"bytes": 38470,
"secs": 17.4,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "befehlend",
"backend": "voice_design",
"file": "marcian_1_befehlend_qwen3design.mp3",
"bytes": 20013,
"secs": 4.0,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "kalt und zynisch",
"backend": "fishspeech",
"file": "marcian_2_kalt_und_zynisch_fish.mp3",
"bytes": 23214,
"secs": 10.9,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "kalt und zynisch",
"backend": "voice_design",
"file": "marcian_2_kalt_und_zynisch_qwen3design.mp3",
"bytes": 17709,
"secs": 3.6,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "fragend",
"backend": "fishspeech",
"file": "marcian_3_fragend_fish.mp3",
"bytes": 39097,
"secs": 18.3,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "fragend",
"backend": "voice_design",
"file": "marcian_3_fragend_qwen3design.mp3",
"bytes": 19629,
"secs": 4.5,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "entschlossen",
"backend": "fishspeech",
"file": "marcian_4_entschlossen_fish.mp3",
"bytes": 57069,
"secs": 25.7,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "entschlossen",
"backend": "voice_design",
"file": "marcian_4_entschlossen_qwen3design.mp3",
"bytes": 26349,
"secs": 4.9,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "ernst",
"backend": "fishspeech",
"file": "marcian_5_ernst_fish.mp3",
"bytes": 32200,
"secs": 13.3,
"ok": true
},
{
"char": "MARCIAN",
"emotion": "ernst",
"backend": "voice_design",
"file": "marcian_5_ernst_qwen3design.mp3",
"bytes": 15501,
"secs": 2.9,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "resigniert",
"backend": "fishspeech",
"file": "lysandra_1_resigniert_fish.mp3",
"bytes": 39515,
"secs": 15.6,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "resigniert",
"backend": "voice_design",
"file": "lysandra_1_resigniert_qwen3design.mp3",
"bytes": 25389,
"secs": 4.6,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "besorgt",
"backend": "fishspeech",
"file": "lysandra_2_besorgt_fish.mp3",
"bytes": 20289,
"secs": 8.1,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "besorgt",
"backend": "voice_design",
"file": "lysandra_2_besorgt_qwen3design.mp3",
"bytes": 14829,
"secs": 2.5,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "abweisend",
"backend": "fishspeech",
"file": "lysandra_3_abweisend_fish.mp3",
"bytes": 19871,
"secs": 7.9,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "abweisend",
"backend": "voice_design",
"file": "lysandra_3_abweisend_qwen3design.mp3",
"bytes": 10413,
"secs": 1.9,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "verächtlich",
"backend": "fishspeech",
"file": "lysandra_4_verachtlich_fish.mp3",
"bytes": 48919,
"secs": 18.6,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "verächtlich",
"backend": "voice_design",
"file": "lysandra_4_verachtlich_qwen3design.mp3",
"bytes": 20973,
"secs": 3.7,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "schroff",
"backend": "fishspeech",
"file": "lysandra_5_schroff_fish.mp3",
"bytes": 35544,
"secs": 13.9,
"ok": true
},
{
"char": "LYSANDRA",
"emotion": "schroff",
"backend": "voice_design",
"file": "lysandra_5_schroff_qwen3design.mp3",
"bytes": 17709,
"secs": 3.1,
"ok": true
},
{
"char": "DARRAG",
"emotion": "befehlend",
"backend": "fishspeech",
"file": "darrag_1_befehlend_fish.mp3",
"bytes": 47038,
"secs": 18.4,
"ok": true
},
{
"char": "DARRAG",
"emotion": "befehlend",
"backend": "voice_design",
"file": "darrag_1_befehlend_qwen3design.mp3",
"bytes": 22893,
"secs": 4.0,
"ok": true
},
{
"char": "DARRAG",
"emotion": "entschlossen",
"backend": "fishspeech",
"file": "darrag_2_entschlossen_fish.mp3",
"bytes": 19453,
"secs": 7.9,
"ok": true
},
{
"char": "DARRAG",
"emotion": "entschlossen",
"backend": "voice_design",
"file": "darrag_2_entschlossen_qwen3design.mp3",
"bytes": 13293,
"secs": 2.3,
"ok": true
},
{
"char": "DARRAG",
"emotion": "dringend",
"backend": "fishspeech",
"file": "darrag_3_dringend_fish.mp3",
"bytes": 16945,
"secs": 7.0,
"ok": true
},
{
"char": "DARRAG",
"emotion": "dringend",
"backend": "voice_design",
"file": "darrag_3_dringend_qwen3design.mp3",
"bytes": 9069,
"secs": 1.7,
"ok": true
},
{
"char": "DARRAG",
"emotion": "drohend",
"backend": "fishspeech",
"file": "darrag_4_drohend_fish.mp3",
"bytes": 26140,
"secs": 10.3,
"ok": true
},
{
"char": "DARRAG",
"emotion": "drohend",
"backend": "voice_design",
"file": "darrag_4_drohend_qwen3design.mp3",
"bytes": 13869,
"secs": 2.5,
"ok": true
},
{
"char": "DARRAG",
"emotion": "überrascht",
"backend": "fishspeech",
"file": "darrag_5_uberrascht_fish.mp3",
"bytes": 18408,
"secs": 7.6,
"ok": true
},
{
"char": "DARRAG",
"emotion": "überrascht",
"backend": "voice_design",
"file": "darrag_5_uberrascht_qwen3design.mp3",
"bytes": 7533,
"secs": 1.4,
"ok": true
}
]

View File

@ -0,0 +1,302 @@
[
{
"char": "MARCIAN",
"emotion_de": "glücklich",
"emotion_en": "happy",
"backend": "fishspeech",
"file": "basic_marcian_1_happy_fish.mp3",
"bytes": 40351,
"secs": 15.2,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "glücklich",
"emotion_en": "happy",
"backend": "voice_design",
"file": "basic_marcian_1_happy_qwen3design.mp3",
"bytes": 19341,
"secs": 3.2,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "traurig",
"emotion_en": "sad",
"backend": "fishspeech",
"file": "basic_marcian_2_sad_fish.mp3",
"bytes": 23841,
"secs": 8.8,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "traurig",
"emotion_en": "sad",
"backend": "voice_design",
"file": "basic_marcian_2_sad_qwen3design.mp3",
"bytes": 13293,
"secs": 2.3,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "wütend",
"emotion_en": "angry",
"backend": "fishspeech",
"file": "basic_marcian_3_angry_fish.mp3",
"bytes": 37634,
"secs": 13.8,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "wütend",
"emotion_en": "angry",
"backend": "voice_design",
"file": "basic_marcian_3_angry_qwen3design.mp3",
"bytes": 20589,
"secs": 3.5,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "ängstlich",
"emotion_en": "scared",
"backend": "fishspeech",
"file": "basic_marcian_4_scared_fish.mp3",
"bytes": 25095,
"secs": 9.3,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "ängstlich",
"emotion_en": "scared",
"backend": "voice_design",
"file": "basic_marcian_4_scared_qwen3design.mp3",
"bytes": 11373,
"secs": 1.9,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "aufgeregt",
"emotion_en": "excited",
"backend": "fishspeech",
"file": "basic_marcian_5_excited_fish.mp3",
"bytes": 10884,
"secs": 4.4,
"ok": true
},
{
"char": "MARCIAN",
"emotion_de": "aufgeregt",
"emotion_en": "excited",
"backend": "voice_design",
"file": "basic_marcian_5_excited_qwen3design.mp3",
"bytes": 6861,
"secs": 1.3,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "glücklich",
"emotion_en": "happy",
"backend": "fishspeech",
"file": "basic_lysandra_1_happy_fish.mp3",
"bytes": 31364,
"secs": 12.6,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "glücklich",
"emotion_en": "happy",
"backend": "voice_design",
"file": "basic_lysandra_1_happy_qwen3design.mp3",
"bytes": 16173,
"secs": 2.8,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "traurig",
"emotion_en": "sad",
"backend": "fishspeech",
"file": "basic_lysandra_2_sad_fish.mp3",
"bytes": 12347,
"secs": 5.5,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "traurig",
"emotion_en": "sad",
"backend": "voice_design",
"file": "basic_lysandra_2_sad_qwen3design.mp3",
"bytes": 6573,
"secs": 1.1,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "wütend",
"emotion_en": "angry",
"backend": "fishspeech",
"file": "basic_lysandra_3_angry_fish.mp3",
"bytes": 20915,
"secs": 8.1,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "wütend",
"emotion_en": "angry",
"backend": "voice_design",
"file": "basic_lysandra_3_angry_qwen3design.mp3",
"bytes": 10029,
"secs": 1.7,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "ängstlich",
"emotion_en": "scared",
"backend": "fishspeech",
"file": "basic_lysandra_4_scared_fish.mp3",
"bytes": 23841,
"secs": 8.9,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "ängstlich",
"emotion_en": "scared",
"backend": "voice_design",
"file": "basic_lysandra_4_scared_qwen3design.mp3",
"bytes": 12909,
"secs": 2.2,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "aufgeregt",
"emotion_en": "excited",
"backend": "fishspeech",
"file": "basic_lysandra_5_excited_fish.mp3",
"bytes": 46620,
"secs": 16.6,
"ok": true
},
{
"char": "LYSANDRA",
"emotion_de": "aufgeregt",
"emotion_en": "excited",
"backend": "voice_design",
"file": "basic_lysandra_5_excited_qwen3design.mp3",
"bytes": 24813,
"secs": 4.0,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "glücklich",
"emotion_en": "happy",
"backend": "fishspeech",
"file": "basic_darrag_1_happy_fish.mp3",
"bytes": 48083,
"secs": 17.2,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "glücklich",
"emotion_en": "happy",
"backend": "voice_design",
"file": "basic_darrag_1_happy_qwen3design.mp3",
"bytes": 21933,
"secs": 9.2,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "traurig",
"emotion_en": "sad",
"backend": "fishspeech",
"file": "basic_darrag_2_sad_fish.mp3",
"bytes": 18826,
"secs": 6.9,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "traurig",
"emotion_en": "sad",
"backend": "voice_design",
"file": "basic_darrag_2_sad_qwen3design.mp3",
"bytes": 10413,
"secs": 1.7,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "wütend",
"emotion_en": "angry",
"backend": "fishspeech",
"file": "basic_darrag_3_angry_fish.mp3",
"bytes": 14228,
"secs": 5.4,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "wütend",
"emotion_en": "angry",
"backend": "voice_design",
"file": "basic_darrag_3_angry_qwen3design.mp3",
"bytes": 7821,
"secs": 1.3,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "ängstlich",
"emotion_en": "scared",
"backend": "fishspeech",
"file": "basic_darrag_4_scared_fish.mp3",
"bytes": 24259,
"secs": 8.7,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "ängstlich",
"emotion_en": "scared",
"backend": "voice_design",
"file": "basic_darrag_4_scared_qwen3design.mp3",
"bytes": 10989,
"secs": 1.8,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "aufgeregt",
"emotion_en": "excited",
"backend": "fishspeech",
"file": "basic_darrag_5_excited_fish.mp3",
"bytes": 17990,
"secs": 6.7,
"ok": true
},
{
"char": "DARRAG",
"emotion_de": "aufgeregt",
"emotion_en": "excited",
"backend": "voice_design",
"file": "basic_darrag_5_excited_qwen3design.mp3",
"bytes": 8781,
"secs": 1.5,
"ok": true
}
]

View File

@ -0,0 +1 @@
{"no_tag": 182, "deep_male": 91, "young_woman": 191, "elderly_man": 93, "little_girl": 202, "gruff_old_warrior": 72}

View File

@ -0,0 +1 @@
{"neutral": {"pitch": 182, "pstd": 13, "dur": 3.58, "rms": 0.084, "dyn": 0.189, "bright": 4028, "tag": ""}, "happy": {"pitch": 222, "pstd": 45, "dur": 4.27, "rms": 0.0997, "dyn": 0.237, "bright": 2998, "tag": "[cheerful and bright, smiling as she speaks]"}, "sad": {"pitch": 155, "pstd": 26, "dur": 3.2, "rms": 0.2098, "dyn": 0.483, "bright": 3302, "tag": "[voice rough from crying, trying to sound normal]"}, "angry": {"pitch": 129, "pstd": 22, "dur": 3.72, "rms": 0.1037, "dyn": 0.315, "bright": 2972, "tag": "[furious, teeth clenched, spitting the words out]"}, "scared": {"pitch": 110, "pstd": 5, "dur": 4.13, "rms": 0.0208, "dyn": 0.085, "bright": 3658, "tag": "[breathless with fear, voice trembling]"}}

View File

@ -46,6 +46,8 @@
<!-- ── Phase 2: Characters ─────────────────────────────────────── --> <!-- ── Phase 2: Characters ─────────────────────────────────────── -->
<div class="stu-phase" id="stu-phase-2" hidden> <div class="stu-phase" id="stu-phase-2" hidden>
<div class="stu-inner-tabs" id="stu-cast-inner-tabs"> <div class="stu-inner-tabs" id="stu-cast-inner-tabs">
<button type="button" class="stu-book-context-btn" id="stu-change-book-btn" title="Choose a different uploaded book"><span class="mdi mdi-book-open-page-variant-outline"></span> Change book</button>
<button type="button" class="stu-book-context-btn" id="stu-book-context-btn" title="Edit genre, setting, era and language before identifying characters"><span class="mdi mdi-book-cog-outline"></span> Edit book context</button>
<button class="stu-inner-tab active" data-stu-cast-view="identify">Identify Characters</button> <button class="stu-inner-tab active" data-stu-cast-view="identify">Identify Characters</button>
<button class="stu-inner-tab" data-stu-cast-view="sheets">Cast Characters <small style="opacity:.6;font-weight:400">(optional)</small></button> <button class="stu-inner-tab" data-stu-cast-view="sheets">Cast Characters <small style="opacity:.6;font-weight:400">(optional)</small></button>
</div> </div>

View File

@ -677,7 +677,7 @@
<div class="card" id="comfyui-config-panel" hidden style="margin-top:16px"> <div class="card" id="comfyui-config-panel" hidden style="margin-top:16px">
<div class="s-page-head"> <div class="s-page-head">
<h3><span class="mdi mdi-graph-outline"></span> Local ComfyUI Workflow</h3> <h3><span class="mdi mdi-graph-outline"></span> Local ComfyUI Workflow</h3>
<p>Runs a workflow on your own GPU instead of a paid API. Export it from ComfyUI's own UI first &mdash; Workflow menu &rarr; Export (API Format) &mdash; then paste the result below. The same workflow is used for both single portraits and character concept sheets; only the prompt text sent to it differs.</p> <p>Runs on your own GPU, with no paid API. Voice Creator includes validated high-quality local Flux workflows: a square profile portrait and a 3:2 concept-art sheet. Leave the advanced workflow fields blank to use them automatically. An advanced workflow below overrides profile portraits; concept-art sheets always retain their dedicated 3:2 workflow.</p>
</div> </div>
<div style="display:flex; flex-direction:column; gap:14px"> <div style="display:flex; flex-direction:column; gap:14px">
<div class="s-field"> <div class="s-field">
@ -686,9 +686,9 @@
<span class="s-hint">Base URL of your running ComfyUI instance (container port 8188 by default).</span> <span class="s-hint">Base URL of your running ComfyUI instance (container port 8188 by default).</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Workflow JSON (API format)</label> <label>Advanced workflow JSON (API format, optional override)</label>
<textarea id="comfyui-workflow" rows="6" spellcheck="false" placeholder='{"3": {"class_type": "KSampler", "inputs": {...}}, "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", ...}}, ...}' style="font-family:monospace; font-size:12px"></textarea> <textarea id="comfyui-workflow" rows="6" spellcheck="false" placeholder='{"3": {"class_type": "KSampler", "inputs": {...}}, "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", ...}}, ...}' style="font-family:monospace; font-size:12px"></textarea>
<span class="s-hint">Paste the raw JSON exported from ComfyUI's "Export (API Format)" action — not a regular saved workflow (that format has "nodes"/"links" keys and won't work here).</span> <span class="s-hint">Leave blank for Voice Creator's validated Flux Dev portrait and concept-sheet workflows. Paste raw JSON exported from ComfyUI's "Export (API Format)" only when you deliberately want to replace both.</span>
</div> </div>
<div style="display:flex; gap:14px; flex-wrap:wrap"> <div style="display:flex; gap:14px; flex-wrap:wrap">
<div class="s-field" style="flex:1; min-width:160px"> <div class="s-field" style="flex:1; min-width:160px">

View File

@ -608,8 +608,27 @@ audio { width: 100%; }
.lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); } .lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); }
/* Table view — dense alternative to the card grid for scanning a large cast */ /* Table view — dense alternative to the card grid for scanning a large cast */
.lib-chars-tbl-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; background:var(--surface); position:relative; } .lib-chars-tbl-wrap {
.lib-chars-tbl { width:100%; min-width:1662px; table-layout:fixed; border-collapse:collapse; font-size:12.9px; white-space:nowrap; } width:100%; max-width:100%; min-width:0; overflow-x:scroll; overflow-y:hidden;
border:1px solid var(--border); border-radius:12px; background:var(--surface);
position:relative; scrollbar-gutter:stable; scrollbar-width:auto;
}
.lib-chars-tbl-wrap::-webkit-scrollbar { height:12px; }
.lib-chars-tbl-wrap::-webkit-scrollbar-track { background:var(--panel); }
.lib-chars-tbl-wrap::-webkit-scrollbar-thumb { background:var(--subtext); border-radius:8px; border:3px solid var(--panel); }
.lib-chars-column-chooser { position:relative; display:flex; justify-content:flex-end; margin:0 0 6px; }
.lib-chars-column-button { display:inline-flex; align-items:center; gap:5px; }
.lib-chars-column-menu {
display:none; position:absolute; top:calc(100% + 4px); right:0; z-index:20;
min-width:190px; padding:8px; border:1px solid var(--border); border-radius:8px;
background:var(--surface); box-shadow:0 8px 24px rgba(0,0,0,.18);
}
.lib-chars-column-menu.is-open { display:block; }
.lib-chars-column-menu label { display:flex; align-items:center; gap:5px; padding:3px 2px; color:var(--text); font-size:12px; white-space:nowrap; cursor:pointer; }
.lib-chars-column-menu label:hover { background:var(--panel); border-radius:4px; }
.lib-chars-column-menu input { accent-color:var(--accent); margin:0; }
.lib-chars-tbl .lib-chars-col-hidden { display:none !important; }
.lib-chars-tbl { width:100%; min-width:2470px; table-layout:fixed; border-collapse:collapse; font-size:12.9px; white-space:nowrap; }
.lib-chars-tbl td { overflow: hidden; text-overflow: ellipsis; } .lib-chars-tbl td { overflow: hidden; text-overflow: ellipsis; }
.lib-chars-tbl thead th { .lib-chars-tbl thead th {
text-align:left; padding:8px 8px; font-size:10.5px; font-weight:800; text-transform:uppercase; text-align:left; padding:8px 8px; font-size:10.5px; font-weight:800; text-transform:uppercase;
@ -650,6 +669,10 @@ audio { width: 100%; }
tr.lib-char-card.lib-chars-tbl-row { tr.lib-char-card.lib-chars-tbl-row {
display: table-row; cursor: pointer; display: table-row; cursor: pointer;
background: none; border: 0; border-radius: 0; box-shadow: none; transition: none; background: none; border: 0; border-radius: 0; box-shadow: none; transition: none;
/* Let Chromium skip layout/paint for distant rows while retaining the full
DOM, so sorting, bulk selection, and row event wiring remain unchanged. */
content-visibility: auto;
contain-intrinsic-size: auto 86px;
} }
tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; background: var(--panel); } tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; background: var(--panel); }
.lib-chars-tbl-row:hover { background:var(--panel); } .lib-chars-tbl-row:hover { background:var(--panel); }
@ -686,6 +709,34 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
matching its own header entirely). Flex only the inner wrapper instead. */ matching its own header entirely). Flex only the inner wrapper instead. */
.lib-chars-tbl-tags { white-space:normal; max-width:260px; } .lib-chars-tbl-tags { white-space:normal; max-width:260px; }
.lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; } .lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; }
.lib-chars-tbl-profile { text-align:center; }
.lib-chars-profile-count {
display:inline-block; min-width:54px; padding:3px 6px; border-radius:999px;
font-size:11.5px; font-weight:800; font-variant-numeric:tabular-nums;
border:1px solid var(--border); background:var(--panel); color:var(--subtext);
}
.lib-chars-profile-count.is-sparse { color:#b42318; background:rgba(239,68,68,.10); border-color:rgba(239,68,68,.35); }
.lib-chars-profile-count.is-partial { color:#a15c00; background:rgba(245,158,11,.12); border-color:rgba(245,158,11,.4); }
.lib-chars-profile-count.is-strong { color:#137333; background:rgba(34,197,94,.10); border-color:rgba(34,197,94,.35); }
.lib-chars-tbl-rating { display:inline-flex; gap:1px; }
.lib-chars-tbl-rating-star {
border:0; background:transparent; padding:1px; color:#d1d5db; cursor:pointer; line-height:1;
}
.lib-chars-tbl-rating-star:hover, .lib-chars-tbl-rating-star.is-on { color:#f59e0b; }
.lib-chars-tbl-prompt {
color:var(--subtext); font-size:11.5px; font-style:italic;
white-space:normal; overflow-wrap:anywhere; word-break:normal; line-height:1.4;
vertical-align:top;
}
.lib-chars-tbl-note-cell { padding:5px 8px !important; }
.lib-chars-tbl-voice-note {
display:block; width:100%; min-width:0; min-height:42px; resize:vertical;
border:1px solid transparent; background:transparent; color:var(--text);
padding:4px 5px; border-radius:5px; font:inherit; line-height:1.35;
white-space:pre-wrap; overflow-wrap:anywhere;
}
.lib-chars-tbl-voice-note:hover, .lib-chars-tbl-voice-note:focus { border-color:var(--border); background:var(--panel); outline:none; }
.lib-chars-tbl-voice-note:focus { min-height:64px; }
.lib-chars-tbl-dash { opacity:.4; } .lib-chars-tbl-dash { opacity:.4; }
.lib-chars-tbl-check { display:inline-flex; } .lib-chars-tbl-check { display:inline-flex; }
.lib-chars-tbl-check.is-yes { color:#4caf50; } .lib-chars-tbl-check.is-yes { color:#4caf50; }
@ -738,6 +789,13 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
font-size: 11px; border-radius: 5px; padding: 0 6px; height: 26px; font-family: inherit; font-size: 11px; border-radius: 5px; padding: 0 6px; height: 26px; font-family: inherit;
} }
.lib-chars-prod-actions .lib-chars-image-provider option { color: #111; } .lib-chars-prod-actions .lib-chars-image-provider option { color: #111; }
.lib-chars-prod-actions .lib-chars-visual-style,
.lcd-visual-style {
background: var(--panel, #fff); border: 1px solid var(--border); color: var(--text, #222);
font-size: 11px; border-radius: 5px; padding: 0 7px; height: 28px; font-family: inherit;
}
.lib-chars-prod-actions .lib-chars-visual-style option,
.lcd-visual-style option { color: #111; }
.lib-chars-grid { .lib-chars-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@ -970,7 +1028,7 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
.lib-char-card-banner { aspect-ratio: auto; height: auto; min-height: 120px; } .lib-char-card-banner { aspect-ratio: auto; height: auto; min-height: 120px; }
.lib-char-avatar { width:82px; height:82px; font-size:30px; } .lib-char-avatar { width:82px; height:82px; font-size:30px; }
.lib-char-name { font-size:18px; } .lib-char-name { font-size:18px; }
.lib-chars-tbl { min-width: 1340px; } .lib-chars-tbl { min-width: 2470px; }
} }
/* ── Character detail modal (D&D / LARP card) ───────────────────────────────── */ /* ── Character detail modal (D&D / LARP card) ───────────────────────────────── */
@ -1066,8 +1124,17 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
} }
.lcd-conceptart-empty .mdi { font-size: 30px; } .lcd-conceptart-empty .mdi { font-size: 30px; }
.lcd-conceptart-empty.is-noprompt { opacity: .6; } .lcd-conceptart-empty.is-noprompt { opacity: .6; }
.conceptart-lightbox-box { width: min(900px, 96vw); max-height: 94vh; overflow-y: auto; } .lcd-conceptart-empty { cursor:pointer; }
.lcd-conceptart-empty:hover { border-color:var(--accent); }
.conceptart-lightbox-box { width: min(980px, 96vw); max-height: 94vh; overflow-y: auto; }
.conceptart-lightbox-img { width: 100%; height: auto; border-radius: 10px; display: block; } .conceptart-lightbox-img { width: 100%; height: auto; border-radius: 10px; display: block; }
.conceptart-editor-preview { display:flex; justify-content:center; min-height:120px; margin-top:18px; }
.conceptart-editor-preview .conceptart-lightbox-img { max-width:min(900px, 90vw); max-height:62vh; object-fit:contain; width:auto; }
.conceptart-editor-empty { width:min(700px, 88vw); min-height:240px; border:2px dashed var(--border); border-radius:10px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; color:var(--subtext); }
.conceptart-editor-empty .mdi { font-size:38px; }
.conceptart-quick-actions { display:flex; justify-content:center; flex-wrap:wrap; gap:10px; margin:18px 0; }
.conceptart-editor-tools { display:flex; flex-direction:column; gap:9px; padding:0 10px; }
.conceptart-editor-tools textarea { width:100%; resize:vertical; padding:10px; font:inherit; min-height:120px; }
.lcd-header-btns { display:flex; gap:8px; flex-shrink:0; align-self:flex-start; } .lcd-header-btns { display:flex; gap:8px; flex-shrink:0; align-self:flex-start; }
.lcd-header-btns button { .lcd-header-btns button {
background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.32); color:#fff; background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.32); color:#fff;
@ -1135,6 +1202,7 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
.lcd-prompt-box summary::before { content: '›'; font-size: 15px; color: var(--subtext); transition: transform .15s; } .lcd-prompt-box summary::before { content: '›'; font-size: 15px; color: var(--subtext); transition: transform .15s; }
.lcd-prompt-box[open] summary::before { transform: rotate(90deg); } .lcd-prompt-box[open] summary::before { transform: rotate(90deg); }
.lcd-prompt-box summary:hover { background: var(--panel); } .lcd-prompt-box summary:hover { background: var(--panel); }
.lcd-prompt-hint { margin-left: auto; font-size: 11px; font-weight: 400; color: var(--subtext); }
.lcd-prompt-empty { font-weight: 400; font-size: 11px; color: var(--subtext); font-style: italic; } .lcd-prompt-empty { font-weight: 400; font-size: 11px; color: var(--subtext); font-style: italic; }
.lcd-prompt-body { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 8px; } .lcd-prompt-body { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 8px; }
.cs-concept-art-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); } .cs-concept-art-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
@ -1146,6 +1214,47 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
} }
.lcd-prompt-text:focus { border-color: var(--accent); } .lcd-prompt-text:focus { border-color: var(--accent); }
.lcd-prompt-actions { display: flex; gap: 6px; justify-content: flex-end; } .lcd-prompt-actions { display: flex; gap: 6px; justify-content: flex-end; }
.lcd-prompt-actions .lcd-save-prompt { margin-right: auto; }
.lcd-generated-voice-file .lcd-regenerate-voice { margin-left: auto; flex: 0 0 auto; }
.lcd-voice-prompt-box { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 2px 8px rgba(37,99,235,.08); }
.lcd-voice-prompt-box .lcd-prompt-actions { padding-top: 2px; }
.lcd-voice-actions { display: flex; justify-content: flex-end; margin: -1px 0 10px; }
@media (max-width: 560px) {
.lcd-prompt-hint { display: none; }
.lcd-generated-voice-file { flex-wrap: wrap; }
.lcd-generated-voice-file .lcd-regenerate-voice { margin-left: auto; }
.lcd-voice-actions { justify-content: stretch; }
.lcd-voice-actions button { width: 100%; }
}
.lcd-generated-voice-file {
display: flex; align-items: center; gap: 8px; margin: -2px 0 8px; padding: 7px 10px;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
color: var(--subtext); font-size: 11.5px;
}
.lcd-generated-voice-file-label { min-width: 0; overflow-wrap: anywhere; }
.lcd-generated-voice-file-label strong { color: var(--text); font-weight: 600; }
.lcd-voice-replace-confirm {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
margin: -2px 0 10px; padding: 10px 12px; border: 1px solid #f59e0b;
border-radius: 8px; background: color-mix(in srgb, #f59e0b 10%, var(--surface));
color: var(--text); font-size: 12px; line-height: 1.4;
}
.lcd-voice-replace-confirm[hidden] { display: none; }
.lcd-voice-replace-confirm > span:first-child { display: flex; align-items: flex-start; gap: 7px; }
.lcd-voice-replace-confirm .mdi-alert-outline { color: #d97706; font-size: 17px; }
.lcd-voice-replace-actions { display: flex; gap: 7px; flex: 0 0 auto; }
@media (max-width: 640px) {
.lcd-voice-replace-confirm { align-items: stretch; flex-direction: column; }
.lcd-voice-replace-actions { justify-content: flex-end; }
}
.lcd-generated-voice-play {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
width: 28px; height: 28px; padding: 0; border: 1px solid var(--accent);
border-radius: 50%; background: var(--accent); color: #fff; cursor: pointer;
}
.lcd-generated-voice-play:hover { filter: brightness(.92); }
.lcd-generated-voice-play.playing { background: #1d4ed8; }
.lcd-generated-voice-play.loading { opacity: .65; cursor: wait; }
.lcd-field { margin-bottom:16px; } .lcd-field { margin-bottom:16px; }
.lcd-field:last-child { margin-bottom:0; } .lcd-field:last-child { margin-bottom:0; }
/* Compact list row — label left, value right, thin separator — used inside /* Compact list row — label left, value right, thin separator — used inside
@ -1200,7 +1309,7 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
background:var(--surface); display:flex; align-items:center; gap:12px; flex-wrap:wrap; background:var(--surface); display:flex; align-items:center; gap:12px; flex-wrap:wrap;
} }
.lcd-voice-top .lcd-section-label { margin-bottom:0; flex-shrink:0; } .lcd-voice-top .lcd-section-label { margin-bottom:0; flex-shrink:0; }
.lcd-voice-top .lcd-voice-label { font-size:14px; flex:1; min-width:120px; } .lcd-voice-top .lcd-voice-label { font-size:14px; flex:0 1 auto; min-width:0; }
.lcd-voice-top button { .lcd-voice-top button {
font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer; font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer;
border:1px solid var(--border); background:var(--panel); color:var(--text); border:1px solid var(--border); background:var(--panel); color:var(--text);
@ -1210,6 +1319,17 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
.lcd-voice-top .lcd-auto-voice:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); } .lcd-voice-top .lcd-auto-voice:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); }
.lcd-voice-top .lcd-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; } .lcd-voice-top .lcd-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; }
.lcd-voice-top .lcd-gen-voice:hover { border-color:#a855f7; color:#a855f7; } .lcd-voice-top .lcd-gen-voice:hover { border-color:#a855f7; color:#a855f7; }
.lcd-voice-top .lcd-preview-voice { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; padding:0; border-radius:50%; }
.lcd-voice-top .lcd-preview-voice { background:#2563eb; border-color:#2563eb; color:#fff; }
.lcd-voice-top .lcd-preview-voice:hover { background:#1d4ed8; border-color:#1d4ed8; color:#fff; }
.lcd-voice-top .lcd-preview-voice.playing { background:#1d4ed8; border-color:#1d4ed8; color:#fff; }
.lcd-voice-top .lcd-preview-voice.loading { opacity:.65; cursor:wait; }
.lcd-voice-top .lcd-design-voice-icon {
display:inline-flex; align-items:center; justify-content:center;
width:30px; height:30px; padding:0; border-radius:50%;
color:#7c3aed;
}
.lcd-voice-top .lcd-design-voice-icon:hover { background:#f3e8ff; border-color:#a855f7; color:#7c3aed; }
/* Sources section */ /* Sources section */
.lcd-sources { .lcd-sources {
padding:20px 28px; background:var(--panel); border-top:1px solid var(--border); padding:20px 28px; background:var(--panel); border-top:1px solid var(--border);
@ -1285,6 +1405,14 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
padding:12px 14px 8px; font-size:10px; font-weight:700; padding:12px 14px 8px; font-size:10px; font-weight:700;
letter-spacing:.07em; text-transform:uppercase; opacity:.45; border-bottom:1px solid var(--border); letter-spacing:.07em; text-transform:uppercase; opacity:.45; border-bottom:1px solid var(--border);
} }
.lib-cpg-sidebar-tools { display:flex; gap:5px; padding:8px; border-bottom:1px solid var(--border); }
.lib-cpg-sidebar-search, .lib-cpg-sidebar-sort {
min-width:0; padding:5px 6px; border:1px solid var(--border); border-radius:5px;
background:var(--surface); color:var(--text); font:inherit; font-size:11px; outline:none;
}
.lib-cpg-sidebar-search { flex:1; }
.lib-cpg-sidebar-sort { width:76px; cursor:pointer; }
.lib-cpg-sidebar-search:focus, .lib-cpg-sidebar-sort:focus { border-color:var(--accent); }
.lib-cpg-sidebar-item { .lib-cpg-sidebar-item {
display:flex; align-items:center; gap:9px; padding:8px 12px; display:flex; align-items:center; gap:9px; padding:8px 12px;
cursor:pointer; transition:background .12s; border-bottom:1px solid rgba(255,255,255,.03); cursor:pointer; transition:background .12s; border-bottom:1px solid rgba(255,255,255,.03);
@ -5148,6 +5276,13 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Nested sub-tabs inside Studio's Characters/Perform phases (smaller, inline) */ /* Nested sub-tabs inside Studio's Characters/Perform phases (smaller, inline) */
.stu-inner-tabs { display: flex; gap: 4px; margin: 0 0 12px; } .stu-inner-tabs { display: flex; gap: 4px; margin: 0 0 12px; }
.stu-book-context-btn {
display: inline-flex; align-items: center; gap: 6px; margin-right: 4px;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; background: var(--panel); color: var(--subtext);
font-size: 12.5px; font-weight: 600;
}
.stu-book-context-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--surface); }
.stu-inner-tab { .stu-inner-tab {
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer;
background: var(--panel); color: var(--subtext); font-size: 12.5px; font-weight: 600; background: var(--panel); color: var(--subtext); font-size: 12.5px; font-weight: 600;
@ -6450,24 +6585,45 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Audiobook casting overlay */ /* Audiobook casting overlay */
.audiobook-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; } .audiobook-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.audiobook-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px 24px; width: min(420px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,.25); } .audiobook-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px 24px; width: min(420px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,.25); }
.audiobook-box.conceptart-lightbox-box { width: min(1120px, calc(100vw - 40px)); max-width: calc(100vw - 40px); }
.audiobook-title { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 8px; } .audiobook-title { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 8px; }
.audiobook-title .mdi { color: var(--accent); font-size: 20px; } .audiobook-title .mdi { color: var(--accent); font-size: 20px; }
.audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; } .audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; }
.audiobook-box .reader-synth-track { width: 100%; } .audiobook-box .reader-synth-track { width: 100%; }
.audiobook-box label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: var(--subtext); } .audiobook-box label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: var(--subtext); }
.audiobook-box label input[type="text"] { font: inherit; font-weight: 400; color: var(--text); padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); } .audiobook-box label input[type="text"] { font: inherit; font-weight: 400; color: var(--text); padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
.audiobook-box.cdi-voice-dialog {
box-sizing: border-box; width: min(600px, calc(100vw - 32px)); max-width: none;
max-height: calc(100dvh - 32px); overflow-y: auto;
}
.cdi-voice-actions {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px; margin-top: 14px;
}
.cdi-voice-actions button {
box-sizing: border-box; min-width: 0; width: 100%; height: auto;
min-height: 58px; padding: 8px 7px; white-space: normal; line-height: 1.2;
overflow-wrap: anywhere; justify-content: center;
}
@media (max-width: 540px) {
.audiobook-box.cdi-voice-dialog { padding: 18px 16px; }
.cdi-voice-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; } .audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; }
.conceptart-editor-box .conceptart-editor-preview { width: 100%; }
.conceptart-editor-box .conceptart-editor-preview .conceptart-lightbox-img { max-width: 100%; width: auto; }
/* Avatar lightbox — click any character avatar to open */ /* Avatar lightbox — click any character avatar to open */
.avatar-lightbox-box { width: min(1180px, 96vw); max-height: 92vh; overflow-y: auto; } .avatar-lightbox-box { width: min(1180px, 96vw); max-height: 92vh; overflow-y: auto; }
.alb-body { display: flex; gap: 32px; margin-top: 20px; flex-wrap: wrap; } .alb-body { display: flex; flex-direction: column; gap: 20px; margin-top: 20px; align-items: center; }
.alb-preview { flex: 0 0 440px; } .alb-preview { width: min(560px, 86vw); }
.alb-preview img { width: 440px; height: 440px; object-fit: cover; border-radius: 14px; border: 1px solid var(--border); } .alb-preview img { width: min(560px, 86vw); height: min(560px, 86vw); min-width: 500px; min-height: 500px; object-fit: cover; border-radius: 14px; border: 1px solid var(--border); }
.alb-preview-empty { .alb-preview-empty {
width: 440px; height: 440px; border-radius: 14px; border: 1px dashed var(--border); width: min(560px, 86vw); height: min(560px, 86vw); min-width: 500px; min-height: 500px; border-radius: 14px; border: 1px dashed var(--border);
display: flex; align-items: center; justify-content: center; font-size: 120px; color: var(--subtext); opacity: .5; display: flex; align-items: center; justify-content: center; font-size: 120px; color: var(--subtext); opacity: .5;
} }
.alb-actions { flex: 1; min-width: 340px; display: flex; flex-direction: column; gap: 22px; } .alb-actions { width: min(760px, 92vw); display: flex; flex-direction: column; gap: 22px; }
.alb-quick-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.alb-section-label { font-size: 14px; font-weight: 700; color: var(--subtext); display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } .alb-section-label { font-size: 14px; font-weight: 700; color: var(--subtext); display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.alb-section input[type="file"] { font-size: 13.5px; width: 100%; } .alb-section input[type="file"] { font-size: 13.5px; width: 100%; }
.alb-url-row { display: flex; gap: 8px; } .alb-url-row { display: flex; gap: 8px; }
@ -6477,11 +6633,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.alb-gen-row select { flex: 1; min-width: 0; font-size: 13.5px; padding: 8px 10px; } .alb-gen-row select { flex: 1; min-width: 0; font-size: 13.5px; padding: 8px 10px; }
.alb-gen-row .btn-primary { font-size: 13.5px; padding: 9px 16px; } .alb-gen-row .btn-primary { font-size: 13.5px; padding: 9px 16px; }
@media (max-width: 820px) { @media (max-width: 820px) {
.alb-preview, .alb-preview img, .alb-preview-empty { flex-basis: auto; width: min(440px, 80vw); height: min(440px, 80vw); } .alb-preview, .alb-preview img, .alb-preview-empty { min-width: 0; width: min(500px, 86vw); height: min(500px, 86vw); }
.alb-actions { min-width: 0; width: 100%; } .alb-actions { min-width: 0; width: 100%; }
} }
#alb-status { margin-top: 10px; } #alb-status { margin-top: 10px; }
.ab-recast-select-list { max-height: 320px; overflow-y: auto; margin: 10px 0; text-align: left; border: 1px solid var(--border); border-radius: 6px; padding: 4px; } .ab-recast-select-list { max-height: 320px; overflow-y: auto; margin: 10px 0; text-align: left; border: 1px solid var(--border); border-radius: 6px; padding: 4px; }
.audiobook-box.ab-recast-dialog { width: min(760px, calc(100vw - 40px)); max-width: calc(100vw - 40px); max-height: 90vh; }
.ab-recast-select-list label.ab-char-item { display:flex; flex-direction:row; align-items:center; width:100%; box-sizing:border-box; }
.ab-recast-select-list .ab-char-name { min-width:0; flex:1; text-align:left; }
.ab-recast-select-list .ab-char-count { margin-left:auto; flex:0 0 auto; }
.ab-recast-select-list .ab-char-item + .ab-char-item { margin-top: 2px; } .ab-recast-select-list .ab-char-item + .ab-char-item { margin-top: 2px; }
.ab-recast-select-list .ab-recast-cb { flex-shrink: 0; width: 16px; height: 16px; cursor: pointer; } .ab-recast-select-list .ab-recast-cb { flex-shrink: 0; width: 16px; height: 16px; cursor: pointer; }
.ab-recast-select-list .ab-cv-empty { padding: 16px; text-align: center; color: var(--subtext); font-size: 13px; } .ab-recast-select-list .ab-cv-empty { padding: 16px; text-align: center; color: var(--subtext); font-size: 13px; }
@ -7324,3 +7484,28 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.ab-cv-quote-toggle.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .ab-cv-quote-toggle.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* Quotation marks are wrapped in .ab-q so the toggle is a pure CSS flip */ /* Quotation marks are wrapped in .ab-q so the toggle is a pure CSS flip */
body.ab-hide-quotes .ab-q { display: none; } body.ab-hide-quotes .ab-q { display: none; }
.lcd-book-context {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 12px;
padding: 10px 14px;
border: 1px solid #d9e2f0;
border-left: 4px solid #2563eb;
border-radius: 8px;
background: #f7faff;
color: #334155;
font-size: 12px;
}
.lcd-book-context-text { flex: 1; line-height: 1.45; }
.ab-book-context-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--primary) 7%, var(--surface));
}
.ab-book-context-copy { display: flex; align-items: baseline; gap: 8px; flex: 1; flex-wrap: wrap; font-size: 12px; }
.ab-book-context-copy > span:last-child { color: var(--subtext); line-height: 1.4; }
@media (max-width: 700px) { .ab-book-context-banner { align-items: flex-start; flex-direction: column; } }

View File

@ -2,20 +2,26 @@
// Verifies the language picker + German translation of the UI chrome. // Verifies the language picker + German translation of the UI chrome.
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
test('defaults to English and shows the language picker', async ({ page }) => { test('defaults to English and shows the language picker in Settings', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#app-lang-picker')).toBeVisible(); await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await expect(page.locator('.nav-item', { hasText: 'Clone a Voice' }).first()).toBeVisible(); await page.evaluate(() => window.navSettingsCat('general'));
await expect(page.locator('#s-lang-select')).toHaveCount(1);
await page.evaluate(() => window.navSettingsCat('general'));
await expect(page.locator('[data-nav-section="s-clone"]')).toContainText('Clone a Voice');
}); });
test('German translates the navigation and section titles', async ({ page }) => { test('German translates the navigation and section titles', async ({ page }) => {
await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} }); await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} });
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.applyI18n === 'function', null, { timeout: 15_000 }); await page.waitForFunction(() => typeof window.applyI18n === 'function', null, { timeout: 15_000 });
await page.waitForTimeout(1500); await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await expect(page.locator('#app-lang-picker')).toHaveValue('de'); await page.evaluate(() => window.navSettingsCat('general'));
await expect(page.locator('.nav-item', { hasText: 'Stimme klonen' }).first()).toBeVisible(); await expect(page.locator('#s-lang-select')).toHaveCount(1);
await expect(page.getByText('Meine Stimmen').first()).toBeVisible(); await page.evaluate(() => window.navSettingsCat('general'));
await expect(page.locator('#s-lang-select')).toHaveValue('de');
await expect(page.locator('[data-nav-section="s-clone"]')).toContainText('Stimme klonen');
await expect(page.getByText('Meine Stimmen').first()).toHaveCount(1);
// English source text should no longer be present in the nav // English source text should no longer be present in the nav
await expect(page.locator('.nav-item', { hasText: 'Get Voices Online' })).toHaveCount(0); await expect(page.locator('.nav-item', { hasText: 'Get Voices Online' })).toHaveCount(0);
}); });

View File

@ -26,9 +26,13 @@ async function navTo(page, section) {
test('parses a screenplay and builds the cast', async ({ page }) => { test('parses a screenplay and builds the cast', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.goto('/', { waitUntil: 'domcontentloaded' });
await navTo(page, 's-rehearser'); await navTo(page, 's-rehearser');
await page.waitForFunction(() => typeof window.navRehearserPhase === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navRehearserPhase(1));
// Go to the Cast tab area: paste a script and parse // Go to the Cast tab area: paste a script and parse
await page.waitForSelector('#reh-script-text', { timeout: 10_000 }); await page.waitForSelector('#reh-script-text', { timeout: 10_000 });
await page.evaluate(() => window.navRehearserPhase(1));
await expect(page.locator('#reh-script-text')).toBeVisible({ timeout: 10_000 });
await page.fill('#reh-script-text', SCRIPT); await page.fill('#reh-script-text', SCRIPT);
await page.click('#reh-parse-btn'); await page.click('#reh-parse-btn');
@ -43,10 +47,16 @@ test('parses a screenplay and builds the cast', async ({ page }) => {
// A "Hear a line" sample button appears once a character has a voice assigned // A "Hear a line" sample button appears once a character has a voice assigned
await expect(page.locator('.reh-cc-sample-btn')).toHaveCount(0); await expect(page.locator('.reh-cc-sample-btn')).toHaveCount(0);
await page.evaluate(() => { const realVoiceId = await page.evaluate(async () => {
rehState.cast['JACK'].voice = 'EN_Test_Voice'; const response = await fetch('/api/voices');
renderCastList(); const voices = await response.json();
return voices.find(v => v && v.id && v.enabled !== false)?.id || voices.find(v => v && v.id)?.id;
}); });
await expect(page.locator('.reh-cc-sample-btn').first()).toBeVisible(); expect(realVoiceId).toBeTruthy();
await page.evaluate((voiceId) => {
rehState.cast['JACK'].voice = voiceId;
renderCastList();
}, realVoiceId);
await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line');
await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line'); await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line');
}); });

View File

@ -46,8 +46,11 @@ test('core sections render', async ({ page }) => {
test('clone tabs switch and show one panel at a time', async ({ page }) => { test('clone tabs switch and show one panel at a time', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.goto('/', { waitUntil: 'domcontentloaded' });
await navTo(page, 's-clone'); await navTo(page, 's-clone');
await page.waitForFunction(() => typeof window._cloneScheduleAutoSave === 'function', null, { timeout: 15_000 });
await expect(page.locator('.clone-src-tab[data-src="url"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('.clone-src-tab[data-src="url"]')).toBeVisible({ timeout: 10_000 });
await page.locator('.clone-src-tab[data-src="url"]').click(); // The app uses a fixed bottom status bar; dispatching the real DOM click
// avoids a viewport-dependent overlap while still exercising the handler.
await page.locator('.clone-src-tab[data-src="url"]').evaluate((el) => el.click());
await expect(page.locator('#clone-src-url')).toBeVisible(); await expect(page.locator('#clone-src-url')).toBeVisible();
await expect(page.locator('#clone-src-mic')).toBeHidden(); await expect(page.locator('#clone-src-mic')).toBeHidden();
}); });

View File

@ -0,0 +1,38 @@
{
"1": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "flux1-schnell-fp8.safetensors", "weight_dtype": "fp8_e4m3fn"}
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["8", 0]}
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped figures, readable text, logo, watermark", "clip": ["8", 0]}
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 1536, "height": 1024, "batch_size": 1}
},
"5": {
"class_type": "KSampler",
"inputs": {"seed": 425174, "steps": 4, "cfg": 1.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0]}
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["9", 0]}
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "voice-creator/concept-sheet", "images": ["6", 0]}
}
,"8": {
"class_type": "DualCLIPLoader",
"inputs": {"clip_name1": "t5xxl_fp8_e4m3fn.safetensors", "clip_name2": "clip_l.safetensors", "type": "flux", "device": "cpu"}
},
"9": {
"class_type": "VAELoader",
"inputs": {"vae_name": "flux-vae-bf16.safetensors"}
}
}

View File

@ -0,0 +1,652 @@
{
"1": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "qwen_image_edit_fp8_e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn"
}
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
}
},
"3": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
}
},
"4": {
"class_type": "LoadImage",
"inputs": {
"image": "voice_creator_darrag_identity_v3.png"
}
},
"5": {
"class_type": "ImageCrop",
"inputs": {
"image": ["4", 0],
"width": 768,
"height": 768,
"x": 128,
"y": 0
}
},
"7": {
"class_type": "ImageScale",
"inputs": {
"image": ["5", 0],
"upscale_method": "lanczos",
"width": 1024,
"height": 1024,
"crop": "disabled"
}
},
"profile_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["7", 0],
"filename_prefix": "voice-creator-v3/profile"
}
},
"6": {
"class_type": "FluxKontextImageScale",
"inputs": {
"image": ["4", 0]
}
},
"10": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"image1": ["6", 0],
"image2": ["61", 0],
"prompt": ""
}
},
"11": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 512,
"height": 1024,
"batch_size": 1
}
},
"12": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 0,
"steps": 20,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["10", 0],
"negative": ["10", 0],
"latent_image": ["11", 0],
"denoise": 1.0
}
},
"13": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["12", 0],
"vae": ["3", 0]
}
},
"14": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"image1": ["6", 0],
"image2": ["63", 0],
"prompt": ""
}
},
"15": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 512,
"height": 1024,
"batch_size": 1
}
},
"16": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 0,
"steps": 20,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["14", 0],
"negative": ["14", 0],
"latent_image": ["15", 0],
"denoise": 1.0
}
},
"17": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["16", 0],
"vae": ["3", 0]
}
},
"18": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"image1": ["6", 0],
"image2": ["65", 0],
"prompt": ""
}
},
"19": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 512,
"height": 1024,
"batch_size": 1
}
},
"40": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 0,
"steps": 20,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["18", 0],
"negative": ["18", 0],
"latent_image": ["19", 0],
"denoise": 1.0
}
},
"41": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["40", 0],
"vae": ["3", 0]
}
},
"20": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "qwen-image-2512-fp8.safetensors",
"weight_dtype": "default"
}
},
"21": {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"model": ["20", 0],
"lora_name": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
"strength_model": 1.0
}
},
"22": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {
"model": ["21", 0],
"shift": 3.1
}
},
"23": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": ""
}
},
"24": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": "low quality, malformed object, duplicate object, fragment, extra item, person, hand, modern object, text, label, logo, watermark"
}
},
"25": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1
}
},
"26": {
"class_type": "KSampler",
"inputs": {
"model": ["22", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["23", 0],
"negative": ["24", 0],
"latent_image": ["25", 0],
"denoise": 1.0
}
},
"27": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["26", 0],
"vae": ["3", 0]
}
},
"28": {
"class_type": "ImageScale",
"inputs": {
"image": ["13", 0],
"upscale_method": "lanczos",
"width": 512,
"height": 1024,
"crop": "disabled"
}
},
"29": {
"class_type": "ImageScale",
"inputs": {
"image": ["17", 0],
"upscale_method": "lanczos",
"width": 512,
"height": 1024,
"crop": "disabled"
}
},
"30": {
"class_type": "ImageScale",
"inputs": {
"image": ["41", 0],
"upscale_method": "lanczos",
"width": 512,
"height": 1024,
"crop": "disabled"
}
},
"31": {
"class_type": "ImageStitch",
"inputs": {
"image1": ["28", 0],
"image2": ["29", 0],
"direction": "right",
"match_image_size": true,
"spacing_width": 0,
"spacing_color": "white"
}
},
"32": {
"class_type": "ImageStitch",
"inputs": {
"image1": ["31", 0],
"image2": ["30", 0],
"direction": "right",
"match_image_size": true,
"spacing_width": 0,
"spacing_color": "white"
}
},
"33": {
"class_type": "ImageCrop",
"inputs": {
"image": ["4", 0],
"width": 768,
"height": 512,
"x": 128,
"y": 0
}
},
"34": {
"class_type": "ImageScale",
"inputs": {
"image": ["33", 0],
"upscale_method": "lanczos",
"width": 768,
"height": 512,
"crop": "disabled"
}
},
"35": {
"class_type": "ImageScale",
"inputs": {
"image": ["27", 0],
"upscale_method": "lanczos",
"width": 768,
"height": 512,
"crop": "disabled"
}
},
"36": {
"class_type": "ImageStitch",
"inputs": {
"image1": ["34", 0],
"image2": ["35", 0],
"direction": "right",
"match_image_size": true,
"spacing_width": 0,
"spacing_color": "white"
}
},
"37": {
"class_type": "ImageStitch",
"inputs": {
"image1": ["32", 0],
"image2": ["36", 0],
"direction": "down",
"match_image_size": false,
"spacing_width": 0,
"spacing_color": "white"
}
},
"front_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["13", 0],
"filename_prefix": "voice-creator-v4/front"
}
},
"side_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["17", 0],
"filename_prefix": "voice-creator-v4/side"
}
},
"back_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["41", 0],
"filename_prefix": "voice-creator-v4/back"
}
},
"42": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"image1": ["6", 0],
"prompt": ""
}
},
"43": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 768,
"height": 512,
"batch_size": 1
}
},
"44": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 0,
"steps": 20,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["42", 0],
"negative": ["42", 0],
"latent_image": ["43", 0],
"denoise": 1.0
}
},
"45": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["44", 0],
"vae": ["3", 0]
}
},
"scene_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["45", 0],
"filename_prefix": "voice-creator-v4/scene"
}
},
"prop1_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["27", 0],
"filename_prefix": "voice-creator-v4/prop-1"
}
},
"50": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": ""
}
},
"51": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1
}
},
"52": {
"class_type": "KSampler",
"inputs": {
"model": ["22", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["50", 0],
"negative": ["24", 0],
"latent_image": ["51", 0],
"denoise": 1.0
}
},
"53": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["52", 0],
"vae": ["3", 0]
}
},
"prop2_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["53", 0],
"filename_prefix": "voice-creator-v4/prop-2"
}
},
"54": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": ""
}
},
"55": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1
}
},
"56": {
"class_type": "KSampler",
"inputs": {
"model": ["22", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["54", 0],
"negative": ["24", 0],
"latent_image": ["55", 0],
"denoise": 1.0
}
},
"57": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["56", 0],
"vae": ["3", 0]
}
},
"prop3_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["57", 0],
"filename_prefix": "voice-creator-v4/prop-3"
}
},
"60": {
"class_type": "LoadImage",
"inputs": {
"image": "voice_creator_pose_front.png"
}
},
"61": {
"class_type": "FluxKontextImageScale",
"inputs": {
"image": ["60", 0]
}
},
"62": {
"class_type": "LoadImage",
"inputs": {
"image": "voice_creator_pose_side.png"
}
},
"63": {
"class_type": "FluxKontextImageScale",
"inputs": {
"image": ["62", 0]
}
},
"64": {
"class_type": "LoadImage",
"inputs": {
"image": "voice_creator_pose_back.png"
}
},
"65": {
"class_type": "FluxKontextImageScale",
"inputs": {
"image": ["64", 0]
}
},
"66": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": ""
}
},
"67": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": "low quality, illustration, painting, cartoon, anime, glossy 3D render, modern clothing, hat, headwear, repeated front view, three-quarter view, cropped head, cropped feet, duplicate figure, extra figure, extra limbs, prop, text, label, logo, watermark"
}
},
"68": {
"class_type": "KSampler",
"inputs": {
"model": ["22", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["66", 0],
"negative": ["67", 0],
"latent_image": ["69", 0],
"denoise": 1.0
}
},
"69": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 1536,
"height": 1024,
"batch_size": 1
}
},
"turnaround_decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["68", 0],
"vae": ["3", 0]
}
},
"turnaround_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["turnaround_decode", 0],
"filename_prefix": "voice-creator-v5/turnaround"
}
},
"70": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": ""
}
},
"71": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["2", 0],
"text": "low quality, catalog pose, studio portrait, character sheet, modern clothing, hat, headwear, cropped head, cropped hands, duplicate main character, extra limbs, repeated prop, text, label, logo, watermark"
}
},
"72": {
"class_type": "KSampler",
"inputs": {
"model": ["22", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["70", 0],
"negative": ["71", 0],
"latent_image": ["73", 0],
"denoise": 1.0
}
},
"73": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 768,
"height": 512,
"batch_size": 1
}
},
"scene_t2i_decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["72", 0],
"vae": ["3", 0]
}
},
"scene_t2i_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["scene_t2i_decode", 0],
"filename_prefix": "voice-creator-v5/scene"
}
}
}

View File

@ -0,0 +1,89 @@
{
"1": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "qwen-image-2512-fp8.safetensors",
"weight_dtype": "default"
}
},
"2": {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"model": ["1", 0],
"lora_name": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
"strength_model": 1.0
}
},
"3": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {
"model": ["2", 0],
"shift": 3.1
}
},
"4": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
}
},
"5": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["4", 0],
"text": ""
}
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["4", 0],
"text": "low resolution, low quality, deformed anatomy, malformed hands, extra people, duplicate person, cropped feet, modern clothing, modern objects, hat, text, logo, watermark, illustration, anime, cartoon, glossy CGI"
}
},
"7": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 1024,
"height": 1536,
"batch_size": 1
}
},
"8": {
"class_type": "KSampler",
"inputs": {
"model": ["3", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["5", 0],
"negative": ["6", 0],
"latent_image": ["7", 0],
"denoise": 1.0
}
},
"9": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
}
},
"10": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["8", 0],
"vae": ["9", 0]
}
},
"identity_save": {
"class_type": "SaveImage",
"inputs": {
"images": ["10", 0],
"filename_prefix": "voice-creator-v3/identity"
}
}
}

View File

@ -0,0 +1,30 @@
{
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "flux1-dev-fp8.safetensors"}
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["1", 1]}
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped head, readable text, logo, watermark", "clip": ["1", 1]}
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 1024, "height": 1024, "batch_size": 1}
},
"5": {
"class_type": "KSampler",
"inputs": {"seed": 0, "steps": 28, "cfg": 3.5, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0]}
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]}
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "voice-creator/portrait", "images": ["6", 0]}
}
}

View File

@ -0,0 +1,81 @@
{
"1": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "qwen_image_edit_fp8_e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn"
}
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
}
},
"3": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
}
},
"4": {
"class_type": "LoadImage",
"inputs": {
"image": "voice_creator_darrag_identity_v3.png"
}
},
"5": {
"class_type": "FluxKontextImageScale",
"inputs": {
"image": ["4", 0]
}
},
"6": {
"class_type": "TextEncodeQwenImageEdit",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"image": ["5", 0],
"prompt": "Create a clean full-body turnaround sheet of this exact character. Show exactly three full-body views on a plain warm-gray studio background: front view, exact left side profile, and exact back view, evenly spaced in one horizontal row. Keep the same single character, face, beard, hair, age, broad body, leather blacksmith apron, olive-gray linen shirt, wool trousers, period leather boots, colors, materials, proportions, lighting, and photorealistic style in all three views. Correct the shirt to a collarless 1420-1500 linen work shirt with ties and no zipper. Arms relaxed, neutral pose, complete head-to-boots silhouette, identical scale and baseline. No hat, no helmet, no modern clothing, no modern boots, no tools, no props, no scenery, no workshop, no labels, no text, no portrait crop, no three-quarter view, no repeated front view, no extra person."
}
},
"7": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": 2048,
"height": 1024,
"batch_size": 1
}
},
"8": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 4251714201,
"steps": 20,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["6", 0],
"negative": ["6", 0],
"latent_image": ["7", 0],
"denoise": 1.0
}
},
"9": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["8", 0],
"vae": ["3", 0]
}
},
"10": {
"class_type": "SaveImage",
"inputs": {
"images": ["9", 0],
"filename_prefix": "voice-creator-v3/darrag_turnaround"
}
}
}