Studio Notebook redesign: warm cream theme, sidebar, multi-file static structure

- New light UI: fixed 220px sidebar, single scrolling page, 8 named sections
- Static files split by concern: style.css, app.js, loader.js, nav.js
- Each page section is its own partial in static/sections/s-*.html
- loader.js fetches all section partials in parallel, then loads app.js and nav.js
- All original functionality, element IDs, and API endpoints preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-25 19:53:01 +02:00
commit 631ca16290
25 changed files with 12681 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
__pycache__/
*.pyc
*.pyo
.env
.env.*
*.log
.DS_Store
.claude/
.vscode/
*.code-workspace
# Local runtime data and private voice assets
voices/
active_voices/
hidden_voices/
*.wav
*.mp3
*.ogg
*.flac
*.m4a
*.mp4
*.mkv
*.webm
*.aac
*.reference.txt
*.meta.json
# Local settings and generated scratch data
settings.json
voice_design_presets.json

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# ─────────────────────────────────────────────────────────────────────────────
# TTS Voice Creator - Clone and Design — Docker image (ARM64 / aarch64 compatible)
#
# Web app (FastAPI + WaveSurfer.js) — no VNC, no Qt, no X11.
# Accessible on port 7860 via any browser.
# ─────────────────────────────────────────────────────────────────────────────
FROM python:3.11-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
# ── System packages ───────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ── Python dependencies ───────────────────────────────────────────────────────
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
# ── Application ───────────────────────────────────────────────────────────────
WORKDIR /app
COPY server.py .
COPY static/ static/
# ── Runtime user ──────────────────────────────────────────────────────────────
RUN mkdir -p /voices/active_voices /voices/hidden_voices /home/app/.config/tts-voice-creator && \
useradd -m -s /bin/bash app && \
chown -R app:app /app /voices /home/app
USER app
EXPOSE 7860
CMD ["python3", "server.py"]

563
README.md Normal file
View File

@ -0,0 +1,563 @@
# TTS Voice Creator - Clone and Design
A browser-based app for managing Qwen3 TTS voice clones and Voice Design prompts. It runs in Docker, opens in any browser, and gives you one place to curate the voice library, crop/reference audio, normalize loudness, write reusable voice design prompts, and copy integration snippets for apps such as SillyTavern, Open WebUI, and Home Assistant.
---
## What it does
```
[ Drop file / YouTube URL / Record mic ] or [ Describe voice in text ]
↓ ↓
[ Interactive waveform trimmer ] [ Qwen3-TTS VoiceDesign API ]
↓ ↓
[ 24 kHz · mono · 16-bit PCM WAV + reference.txt ]
[ Named voice saved to disk ]
[ TTS preview / Library management ]
```
Two workflows in one tool:
- **Voice cloning** — record or import a real voice, trim a clean segment, auto-transcribe, normalize loudness, and save it as a TTS reference voice.
- **Voice design** — describe a voice in plain language and let the Qwen3-TTS VoiceDesign model synthesize one from scratch. Designed voices can be exported to the clone library or used live as virtual voices through this app's `/v1` proxy.
---
## Requirements
| Requirement | Notes |
|---|---|
| Docker Engine + Docker Compose v2 | [docs.docker.com/get-docker](https://docs.docker.com/get-docker/) |
| A Docker network shared with your TTS/STT containers | `docker network create tts_net` |
| A Whisper-compatible transcription server | See [Recommended backends](#recommended-backends) |
| A TTS server | See [Recommended backends](#recommended-backends) |
| ARM64 (aarch64) or x86-64 | Image is based on `python:3.11-slim-bookworm` — works on both |
| Internet access during build | pip packages; WaveSurfer.js is loaded from CDN at runtime |
---
## Installation & Setup
### 1 — Clone the repository
```bash
git clone https://github.com/mARTin-B78/TTS-Voice-Creator.git tts-voice-creator-clone-and-design
cd tts-voice-creator-clone-and-design
```
### 2 — Create a Docker network (if you don't have one already)
All containers that need to talk to each other (TTS Voice Creator - Clone and Design, your TTS server, your Whisper server) should share a network:
```bash
docker network create tts_net
```
If you already have a shared network, note its name — you will use it in the next step.
### 3 — Configure volume paths
Edit `docker-compose.yml` (or `portainer-stack.yml` for Portainer) and set the host path where your voices are stored:
```yaml
volumes:
- /your/voices/directory:/voices:rw
```
This directory is shared with the TTS server — voices saved here become immediately available after the TTS server is restarted.
Also update the network name if yours differs from the default:
```yaml
networks:
tts_net:
external: true
```
### 4 — Build the image
```bash
docker build -t tts-voice-creator:latest .
```
This step is required before the first run, and whenever you pull updates.
### 5a — Run with Docker Compose
```bash
docker compose up -d
```
### 5b — Deploy via Portainer
> Use **`portainer-stack.yml`**, not `docker-compose.yml`.
> The Portainer file references the pre-built image and has no `build:` section, which avoids the "no such file: Dockerfile" error that occurs because Portainer stores stacks in its own internal directory.
1. Build the image on the host (step 4 above)
2. Portainer → **Stacks** → **Add stack**
3. Enter a stack name (e.g. `tts-voice-creator`)
4. Select **Web editor**
5. Paste the contents of [`portainer-stack.yml`](portainer-stack.yml)
6. Click **Deploy the stack**
### 6 — Open the UI
```
http://<host-ip>:7860
```
### 7 — Configure API endpoints
Click the **⚙ gear icon** (top right) and fill in:
| Field | Example | Notes |
|---|---|---|
| Whisper API URL | `http://whisper:8000` | Use container name if on the same network |
| Whisper API key | *(optional)* | Only needed for cloud APIs |
| TTS API URL | `http://tts-server:8000` | |
| TTS API key | *(optional)* | |
| TTS backend | `Qwen3-TTS / OpenAI` | Select to match your server |
| Voice Design URL | `http://host.docker.internal:8021` | Optional; required for prompt-based Voice Design. For Qwen-style dialogue/timbre reuse, point this at the 1.7B VoiceDesign backend. |
| Voice Design API key | *(leave empty)* | Falls back to TTS key if blank |
| Voice scan directory | `/voices` | Where the library tab looks for voices |
| Active voices directory | `/voices/active_voices` | Where newly saved and TTS-visible voices land |
Settings are persisted to a named Docker volume and survive container restarts.
---
## Recommended Backends
### Speech-to-Text (transcription)
The app sends audio to a Whisper-compatible REST endpoint (`POST /v1/audio/transcriptions`).
| Option | Type | Recommendation |
|---|---|---|
| **[faster-whisper-server](https://github.com/fedirz/faster-whisper-server)** | Local, free | Best local option. Runs `large-v3` efficiently on GPU or CPU. OpenAI-compatible API. |
| **[Whisper.cpp server](https://github.com/ggerganov/whisper.cpp)** | Local, free | Lightweight, works on any hardware with no Python. Good for CPU-only setups. |
| **[OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text)** | Cloud, paid | Highest accuracy, no local GPU needed. Set URL to `https://api.openai.com` and add your API key. |
| **[Groq Whisper API](https://console.groq.com/docs/speech-text)** | Cloud, free tier | Very fast inference, generous free tier. OpenAI-compatible endpoint. |
**Recommended for local use:** `faster-whisper-server` with `large-v3` on a GPU. Example Docker run:
```bash
docker run -d --gpus all --network tts_net --name whisper \
-p 8000:8000 \
fedirz/faster-whisper-server:latest-cuda
```
Then set the Whisper URL in settings to `http://whisper:8000`.
---
### Text-to-Speech
The app supports any server that exposes a compatible REST endpoint.
| Option | Type | Recommendation |
|---|---|---|
| **[Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS)** | Local, free | State-of-the-art multilingual TTS with voice cloning and voice design. Primary recommendation. |
| **[faster-qwen3-tts](https://github.com/mARTin-B78/TTS-Voice-Creator)** | Local wrapper | Docker wrapper for Qwen3-TTS with an OpenAI-compatible API and automatic voice loading from the configured active voices directory. |
| **[LocalAI](https://github.com/mudler/LocalAI)** | Local, free | Supports many TTS backends (Piper, Bark, XTTS, etc.) behind a unified API. Select "LocalAI" in settings. |
| **[Pocket-TTS](https://github.com/ai-joe-git/pocket-tts-server)** | Local, free | Lightweight XTTS-based server. Select "Pocket-TTS" in settings. |
| **[OpenAI TTS API](https://platform.openai.com/docs/guides/text-to-speech)** | Cloud, paid | High quality, no local hardware needed. Set URL to `https://api.openai.com` and add your API key. |
| **NVIDIA Magpie + Parakeet** | Local, free | OpenAI-compatible NVIDIA speech stack. Use Magpie Multilingual on `http://host.docker.internal:8091` for fixed-speaker TTS and Parakeet on `http://host.docker.internal:8092` for STT, or route both through `8090`. NVIDIA Magpie Zeroshot/Flow clone NIMs can be configured separately, usually on `http://host.docker.internal:8093`. |
| **[ElevenLabs](https://elevenlabs.io/docs/api-reference)** | Cloud, freemium | Best-in-class voice quality and cloning. OpenAI-compatible endpoint available. |
**Recommended for local use:** Qwen3-TTS is the best open-source multilingual TTS model as of 2025. It produces very natural speech, supports 16+ languages, and has built-in zero-shot voice cloning and voice design.
---
### Voice Cloning vs Voice Design
This tool supports two fundamentally different ways to create a new TTS voice:
#### Option A — Voice Cloning
You provide a real recording of a voice. The TTS model uses it as a reference to reproduce that speaker's characteristics.
**What makes a good reference recording:**
- **Duration:** 520 seconds (1015 s is ideal)
- **Content:** Natural, expressive speech — not just counting or reciting the alphabet
- **Audio quality:** No background music, reverb, noise, or multiple speakers
- **Emotion:** Should match the emotion you want the TTS to reproduce (calm for a narrator, energetic for an announcer, etc.)
- **Format:** The app converts anything to 24 kHz mono 16-bit PCM WAV automatically
- **Transcript:** The reference text (what was spoken) is critical — Qwen3-TTS uses it for alignment
**Good sources for reference audio:**
- Record yourself or a voice actor directly in the app (mic tab)
- Extract a clean segment from a podcast, audiobook, or interview using the YouTube/URL downloader
- Any audio or video file: WAV, MP3, OGG, FLAC, M4A, MP4, MKV, WEBM
#### Option B — Voice Design
Describe the voice you want in plain language. The Qwen3-TTS VoiceDesign model synthesises a new voice from your description — no recording required.
**Requires:** A server running the `Qwen3-TTS-12Hz-1.7B-VoiceDesign` model or a compatible alias (typically a separate model from the main TTS model). The container environment variable `VOICE_DESIGN_MODEL` controls the model name sent to the VoiceDesign backend.
**How to write good voice descriptions:**
Describe: age, gender, pitch, pace, accent, emotion, and speaking style. Be specific.
```
# Good descriptions:
"A middle-aged British woman with a calm, authoritative tone and clear diction.
Slightly formal, measured speech rate."
"Young male voice, mid-20s, enthusiastic and upbeat. American accent, slightly
fast pace, warm and friendly."
"Elderly male narrator with a deep, resonant voice. Slow and deliberate,
with gravitas. No discernible accent."
# Bad (too vague):
"A nice voice"
"Female voice"
```
---
## Application Tabs
### 1 Voice Clone
The first tab is the main voice library. It contains the old Source & Trim and
Name & Save workflow inside an `Add new voice` panel, so you no longer need to
move back and forth between separate tabs.
| Feature | Details |
|---|---|
| Add new voice | Opens drag and drop, YouTube/audio URL, microphone, waveform trim, transcript, and save controls |
| Library editing | Rename by editing the name directly; the app renames the WAV and sidecar files |
| Language | Formerly shown as Flag; language and region are stored in metadata |
| Type / Length / dB | Shows audio type, duration, and stored loudness values |
| Pencil optimizer | Opens waveform crop, transcript, dB controls, save crop, save volume, and undo crop |
| Active toggle | Moves complete voice packages between `active_voices` and `hidden_voices` |
| Copy active voices | Copies active voice IDs as a comma-separated list |
Cropping is reversible: the app keeps an original copy before replacing the
active voice file.
### 2 Voice Design
Voice Design is for prompt-based voices. Samples are listed in a collapsible
table. `Preview` generates audio from a sample, and `Use` fills the description,
sample text, and language fields.
| Feature | Details |
|---|---|
| Voice Design samples | Example prompts for acoustic attribute control, age control, gradual control, human-likeness, background information, and timbre reuse |
| Reference transcript | Shared transcript field used by samples and presets |
| Generate voice | Calls the VoiceDesign backend and plays the generated audio |
| Export to Voice Clone Library | Saves the generated WAV, transcript, name, language, and gender into the clone library |
| Prompt library | Stores reusable Voice Design presets |
### 3 Routing
The Routing tab lets this app act as an OpenAI-compatible TTS router. External
apps can send a simple voice name such as `default` to this app's `/v1` proxy,
and routing rules can map it to a real Qwen3-TTS voice by app and detected
language.
Example:
| App | Input voice | Language | Output voice |
|---|---|---|---|
| Open WebUI | `default` | `EN` | `EN_F_Anna` |
| Open WebUI | `default` | `DE` | `DE_M_Max` |
### 4 Use In Apps
This tab shows ready-to-copy examples for SillyTavern, Open WebUI, Home
Assistant, and OpenAI-compatible clients.
Exported clone voices should usually point external apps directly at the Qwen3
TTS server. Virtual VoiceDesign voices should point external apps at this app's
`/v1` proxy.
---
## Command Line Usage
Everything needed for headless operation is available through `ttsvc_cli.py`.
It talks to the running app over HTTP, so it works locally or against a remote
creator instance.
```bash
./ttsvc_cli.py --help
./ttsvc_cli.py settings show
./ttsvc_cli.py settings set --tts-url http://host.docker.internal:8020 --tts-backend openai
./ttsvc_cli.py voices list --all
./ttsvc_cli.py voices enable EN_F_Anna
./ttsvc_cli.py routes openwebui-defaults --en EN_F_Anna --de DE_M_Max
./ttsvc_cli.py routes list
./ttsvc_cli.py tts voices
./ttsvc_cli.py tts speak --voice default --text "Hallo, das ist ein Test." --out test.wav
./ttsvc_cli.py create-voice --file sample.wav --voice-id EN_F_NewVoice --transcript "Exact words spoken."
```
Use `--base-url http://HOST:7860` when the app is not running on localhost.
Each command and subcommand has its own `--help` output.
---
## Guided Installer
For non-command-line users, start the graphical installer:
```bash
python3 guided_installer.py
```
Or double-click one of these launchers:
- Linux: `start-guided-installer.sh`
- macOS: `start-guided-installer.command`
- Windows: `start-guided-installer.bat`
The installer checks Docker, Docker Compose, optional Portainer, this app, and
the Qwen3-TTS clone/design/custom containers. It explains what each component is
for, lets the user choose ports and model locations, generates a Docker Compose
file, and can run the selected Docker actions with visible logs.
Additional publishing assets:
- [Forum playbook](docs/forum-playbook.md)
- [Tutorial video script](docs/tutorial-video-script.md)
---
## API compatibility
### TTS endpoints
| Backend | Setting | Endpoint | Request body |
|---|---|---|---|
| Qwen3-TTS / OpenAI | `openai` | `POST /v1/audio/speech` | `{"model":"tts-1","input":"...","voice":"EN_F_Anna","response_format":"wav"}` |
| LocalAI | `localai` | `POST /tts` | `{"input":"...","model":"EN_F_Anna","response_format":"wav"}` |
| Pocket-TTS | `pocket` | `POST /v1/audio/speech` | `{"input":"...","voice":"EN_F_Anna","response_format":"wav"}` |
| NVIDIA Magpie Multilingual | `nvidia_magpie` | `POST /v1/audio/speech` | `{"model":"tts-1","input":"...","voice":"sofia","response_format":"wav"}` |
| NVIDIA Magpie Zeroshot | `nvidia_zeroshot` | `POST /v1/audio/synthesize` | Multipart form with `language`, `text`, and selected library WAV as `audio_prompt` |
| NVIDIA Magpie Flow | `nvidia_flow` | `POST /v1/audio/synthesize` | Multipart form with `language`, `text`, selected library WAV as `audio_prompt`, and saved reference transcript as `audio_prompt_transcript` |
### Voice Design endpoint
```
POST /v1/audio/speech
{
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
"input": "sample text to speak",
"instruct": "describe the voice here",
"language": "Auto",
"response_format": "wav"
}
```
For Qwen-style multi-speaker VoiceDesign, send the complete dialogue in `input`
and the speaker timbre map in `instruct`, for example:
```json
{
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
"input": "Lucas:Hi there.\nMia:Hello back.",
"instruct": "\"Lucas\": \"Male, 17 years old, tenor range\"\n\"Mia\": \"Female, 16 years old, mezzo-soprano range\"",
"language": "English",
"response_format": "wav"
}
```
### Whisper endpoint
```
POST /v1/audio/transcriptions
Content-Type: multipart/form-data
file=<audio.wav>
model=large-v3
response_format=text
```
For NVIDIA Parakeet, set the Whisper/STT URL to `http://host.docker.internal:8092` for the direct ASR container, or `http://host.docker.internal:8090` for the speech router. For NVIDIA voice cloning, set the NVIDIA Zeroshot/Flow NIM URL to the deployed Speech NIM endpoint, commonly `http://host.docker.internal:8093`; the app sends saved library WAVs as `audio_prompt`, and Flow also sends the saved `.reference.txt` transcript.
All endpoints accept an optional `Authorization: Bearer <key>` header, configurable in Settings.
---
## Timbre Control Support
There are two different modes:
- `faster-qwen3-tts` uses saved voice/reference audio IDs. It supports timbre reuse through cloned or exported voices, but it does not normally use prompt fields such as gender, age, pitch, texture, or emotion.
- `faster-qwen3-tts-voicedesign` is the prompt-based path. It can use acoustic descriptions and timbre-style prompts, including multi-speaker/timbre reuse style descriptions, but those voices are generated live from the prompt unless you export the result.
So: timbre descriptions belong in the Voice Design container. Stable reusable
voice IDs belong in the normal TTS container after export.
---
## Use In Apps
### Exported Clone Voices
For SillyTavern, Open WebUI, Home Assistant, or any OpenAI-compatible client:
```text
Base URL: http://YOUR_TTS_HOST:8020/v1
Model: qwen
Voice: one of the active voice IDs
```
After adding or changing voices, restart the TTS container so it rereads the
active voice folder.
### Virtual VoiceDesign Voices Without Export
This app exposes an OpenAI-compatible proxy:
```text
Base URL: http://YOUR_APP_HOST:7860/v1
Model: qwen3-tts-voice-creator
Voice: vd_<PresetName>
```
Virtual voices call the Voice Design backend live for each generation. This is
useful when you want to keep editing prompts without exporting WAV files yet. It
is slower than using exported clone voices and requires the VoiceDesign backend
URL to be configured.
Example request:
```bash
curl http://localhost:7860/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3-tts-voice-creator",
"voice": "vd_WarmNarrator",
"input": "This line is generated through a saved Voice Design preset."
}' \
--output speech.wav
```
---
## Voice file format
Qwen3-TTS (and most modern neural TTS systems) expect reference audio in a specific format:
| Property | Value |
|---|---|
| Format | WAV (PCM, uncompressed) |
| Sample rate | **24 000 Hz** |
| Channels | **Mono** |
| Bit depth | **16-bit** |
| Duration | **520 seconds** |
| Companion file | `<voice_id>.reference.txt` — verbatim transcript of the spoken audio |
The app converts all source audio to this specification automatically using `pydub` + `ffmpeg`.
### Voice metadata sidecar files
Each saved voice can have optional sidecar files stored alongside the WAV:
| File | Contents |
|---|---|
| `<id>.reference.txt` | Transcript of the reference audio (required for voice cloning) |
| `<id>.meta.json` | Note, star rating, flag, gender, enabled state |
| `<id>.jpg` / `.png` / `.webp` | Profile picture shown in the library |
---
## Naming convention
Voice IDs have no forced format. The suggested convention is:
```
{LANG}_{GENDER}_{Name}.wav
```
Examples: `EN_F_Anna.wav`, `DE_M_Christoph.wav`, `ZH_F_Mei.wav`
Language codes: `EN DE ZH FR ES JA KO IT PT RU AR PL NL SV TR HI`
Gender codes: `F` (female) · `M` (male) · `N` (neutral / non-binary)
Custom IDs like `narrator_calm` or `my-voice-v2` are equally valid — the naming helper in the UI is optional.
---
## Volume mounts
| Host path | Container path | Purpose |
|---|---|---|
| `${VOICE_HOST_DIR:-./voices}` | `/voices` | Shared voice collection (read-write) |
| Named volume `tts_voice_creator_config` | `/home/app/.config/tts-voice-creator` | Persisted settings and Voice Design presets |
The TTS server should mount and scan the `active_voices` subfolder. The `hidden_voices` subfolder is for voices kept in the library but removed from Qwen3-TTS model discovery.
---
## Reloading voices into your TTS server
Most local TTS servers load voices on startup. After saving a new voice, restart the TTS container:
```bash
docker restart <your-tts-container-name>
```
The default public Compose file does not mount the Docker socket. Restart the
TTS container from Docker, Portainer, or your host scripts.
---
## Public Repository Safety
The repository ignores private runtime data:
- `.env` files and local settings.
- `.claude/`, editor workspaces, logs, and caches.
- Voice folders and audio/video files.
- Per-voice transcript and metadata sidecars.
Do not commit real API keys, private voice samples, speaker folders, or
host-specific paths.
---
## Architecture
```
Browser (any device on your local network)
└── port 7860 ──► FastAPI + uvicorn (server.py)
├── pydub + ffmpeg audio conversion & trimming
├── yt-dlp YouTube / URL audio extraction
├── Whisper API ──────► transcription
├── TTS API ──────► preview & voice list
└── VoiceDesign API ───► AI voice generation
Frontend (single HTML file, no build step)
├── WaveSurfer.js 7 interactive waveform + region trimmer
└── MediaRecorder API browser microphone recording
```
---
## Troubleshooting
**WaveSurfer / UI doesn't load** — the container needs internet access to fetch WaveSurfer.js from `unpkg.com`. If your network is air-gapped, download the two JS files and serve them from `static/`.
**"Cannot connect to Whisper/TTS API"** — check that:
1. The API server container is running
2. Both containers are on the same Docker network
3. You're using the container name (not `localhost`) as the URL inside Docker — e.g. `http://whisper:8000`, not `http://localhost:8000`
**Microphone not working** — browsers block `getUserMedia` on non-`localhost` origins without HTTPS. Set up a reverse proxy with a TLS certificate (e.g. Nginx + Let's Encrypt or Caddy) if accessing from another device.
**Generated voice sounds wrong** — for cloning, check the reference audio quality: no background noise, single speaker, natural speech, and a correct transcript. For voice design, make the description more specific — add age, pitch, pace, and accent details.
**Voice not appearing in TTS server after save** — restart the TTS container. Most servers only scan the voices directory at startup.
**Transcription produces garbled text** — try a larger Whisper model (`large-v3` recommended), or manually type the transcript before saving.

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
tts-voice-creator-clone-and-design:
build:
context: .
dockerfile: Dockerfile
image: tts-voice-creator-clone-and-design:latest
container_name: tts-voice-creator-clone-and-design
restart: unless-stopped
ports:
- "7860:7860"
volumes:
- ${VOICE_HOST_DIR:-./voices}:/voices:rw
- tts-voice-creator-clone-and-design:/home/app/.config/tts-voice-creator
environment:
- PYTHONUNBUFFERED=1
- VOICES_DIR=/voices
- OUTPUT_DIR=/voices/active_voices
- TTS_CONTAINER_NAME=faster-qwen3-tts
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- tts_net
volumes:
tts-voice-creator-clone-and-design:
networks:
tts_net:
external: true

398
guided_installer.py Executable file
View File

@ -0,0 +1,398 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import platform
import queue
import shutil
import subprocess
import threading
import webbrowser
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
ROOT = Path(__file__).resolve().parent
GENERATED_DIR = ROOT / "installer" / "generated"
COMPOSE_PATH = GENERATED_DIR / "docker-compose.guided.yml"
DOCKER_DOCS = "https://docs.docker.com/engine/install/"
OPEN_WEBUI_DOCS = "https://docs.openwebui.com/features/audio/"
MODEL_EXPLANATIONS = {
"clone": (
"Voice Clone",
"Uses recorded reference voices from the Voice Library. This is the everyday server for exported voices and Open WebUI.",
"faster-qwen3-tts",
"8020",
"/models/Qwen3-TTS",
),
"design": (
"Voice Design",
"Generates a voice from a written description. Great for prototyping characters without a recording.",
"faster-qwen3-tts-voicedesign",
"8021",
"/models/Qwen3-TTS-VoiceDesign",
),
"custom": (
"Custom Voice",
"Uses Qwen3 CustomVoice speaker IDs and optional instructions. Useful when you already maintain named model speakers.",
"faster-qwen3-tts-customvoice",
"8022",
"/models/Qwen3-TTS-CustomVoice",
),
}
class GuidedInstaller(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("TTS Voice Creator Guided Installer")
self.geometry("1120x780")
self.minsize(920, 680)
self.log_queue: queue.Queue[str] = queue.Queue()
self._build_vars()
self._build_ui()
self.after(120, self._drain_log)
self.check_environment()
def _build_vars(self) -> None:
home = Path.home()
self.voice_dir = tk.StringVar(value=str(home / "TTS_Voices"))
self.model_dir = tk.StringVar(value=str(home / "TTS_Models"))
self.creator_port = tk.StringVar(value="7860")
self.network_name = tk.StringVar(value="tts_net")
self.tts_image = tk.StringVar(value="faster-qwen3-tts-dgx-spark:v4")
self.install_portainer = tk.BooleanVar(value=False)
self.model_enabled = {key: tk.BooleanVar(value=(key == "clone")) for key in MODEL_EXPLANATIONS}
self.model_ports = {key: tk.StringVar(value=meta[3]) for key, meta in MODEL_EXPLANATIONS.items()}
self.model_paths = {key: tk.StringVar(value=str(Path(self.model_dir.get()) / Path(meta[4]).name)) for key, meta in MODEL_EXPLANATIONS.items()}
self.extra_tts_name = tk.StringVar(value="")
self.extra_tts_url = tk.StringVar(value="")
def _build_ui(self) -> None:
self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1)
left = ttk.Frame(self, padding=14)
left.grid(row=0, column=0, sticky="ns")
right = ttk.Frame(self, padding=14)
right.grid(row=0, column=1, sticky="nsew")
right.columnconfigure(0, weight=1)
right.rowconfigure(1, weight=1)
ttk.Label(left, text="Install Checks", font=("", 15, "bold")).pack(anchor="w")
self.status = tk.Text(left, width=38, height=15, wrap="word")
self.status.pack(fill="x", pady=(8, 12))
ttk.Button(left, text="Re-check this computer", command=self.check_environment).pack(fill="x", pady=3)
ttk.Button(left, text="Open Docker install guide", command=lambda: webbrowser.open(DOCKER_DOCS)).pack(fill="x", pady=3)
ttk.Button(left, text="Install Docker on Linux", command=self.install_docker_linux).pack(fill="x", pady=3)
ttk.Button(left, text="Install Portainer", command=self.install_portainer_now).pack(fill="x", pady=3)
ttk.Button(left, text="Generate compose file", command=self.generate_compose).pack(fill="x", pady=(14, 3))
ttk.Button(left, text="Install / update selected stack", command=self.install_stack).pack(fill="x", pady=3)
ttk.Button(left, text="Open Open WebUI audio guide", command=lambda: webbrowser.open(OPEN_WEBUI_DOCS)).pack(fill="x", pady=(14, 3))
nb = ttk.Notebook(right)
nb.grid(row=0, column=0, sticky="nsew")
right.rowconfigure(0, weight=1)
self._setup_tab(nb)
self._models_tab(nb)
self._openwebui_tab(nb)
self._log_tab(nb)
def _setup_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.columnconfigure(1, weight=1)
nb.add(tab, text="1. Basics")
rows = [
("Voice library folder", self.voice_dir, True),
("Model cache / LLM folder", self.model_dir, True),
("Creator app port", self.creator_port, False),
("Docker network", self.network_name, False),
("Qwen TTS Docker image", self.tts_image, False),
]
for row, (label, var, browse) in enumerate(rows):
ttk.Label(tab, text=label).grid(row=row, column=0, sticky="w", pady=6)
ttk.Entry(tab, textvariable=var).grid(row=row, column=1, sticky="ew", pady=6, padx=8)
if browse:
ttk.Button(tab, text="Browse", command=lambda v=var: self.pick_dir(v)).grid(row=row, column=2, pady=6)
ttk.Checkbutton(tab, text="Also install Portainer web UI", variable=self.install_portainer).grid(row=len(rows), column=1, sticky="w", pady=8)
text = (
"What these pieces do:\n\n"
"Docker runs the app and TTS servers in containers.\n"
"Portainer is optional. It gives non-command-line users a web dashboard for containers.\n"
"The voice folder stores cloned voices and metadata.\n"
"The model folder stores large Qwen model files, ideally on a fast disk with enough space."
)
ttk.Label(tab, text=text, wraplength=680, justify="left").grid(row=len(rows)+1, column=0, columnspan=3, sticky="ew", pady=16)
def _models_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.columnconfigure(2, weight=1)
nb.add(tab, text="2. Qwen Models")
ttk.Label(tab, text="Install", font=("", 10, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(tab, text="Model", font=("", 10, "bold")).grid(row=0, column=1, sticky="w")
ttk.Label(tab, text="Model location", font=("", 10, "bold")).grid(row=0, column=2, sticky="w")
ttk.Label(tab, text="Port", font=("", 10, "bold")).grid(row=0, column=3, sticky="w")
for i, (key, meta) in enumerate(MODEL_EXPLANATIONS.items(), start=1):
title, desc, _container, _port, _path = meta
ttk.Checkbutton(tab, variable=self.model_enabled[key]).grid(row=i, column=0, sticky="nw", pady=8)
ttk.Label(tab, text=f"{title}\n{desc}", wraplength=260, justify="left").grid(row=i, column=1, sticky="w", pady=8, padx=8)
ttk.Entry(tab, textvariable=self.model_paths[key]).grid(row=i, column=2, sticky="ew", pady=8, padx=8)
ttk.Entry(tab, textvariable=self.model_ports[key], width=8).grid(row=i, column=3, sticky="w", pady=8)
ttk.Label(tab, text="Optional external TTS endpoint", font=("", 11, "bold")).grid(row=5, column=0, columnspan=4, sticky="w", pady=(22, 6))
ttk.Label(tab, text="Name").grid(row=6, column=0, sticky="w")
ttk.Entry(tab, textvariable=self.extra_tts_name).grid(row=6, column=1, sticky="ew", padx=8)
ttk.Label(tab, text="Base URL").grid(row=6, column=2, sticky="e")
ttk.Entry(tab, textvariable=self.extra_tts_url).grid(row=6, column=3, sticky="ew")
ttk.Label(tab, text="Use this if you already have another OpenAI-compatible TTS server. The creator can route to it later by setting the TTS API URL in Settings.", wraplength=760).grid(row=7, column=0, columnspan=4, sticky="w", pady=10)
def _openwebui_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.columnconfigure(0, weight=1)
nb.add(tab, text="3. Open WebUI")
self.openwebui_text = tk.Text(tab, height=24, wrap="word")
self.openwebui_text.grid(row=0, column=0, sticky="nsew")
ttk.Button(tab, text="Refresh instructions", command=self.refresh_openwebui_text).grid(row=1, column=0, sticky="e", pady=8)
self.refresh_openwebui_text()
def _log_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.rowconfigure(0, weight=1)
tab.columnconfigure(0, weight=1)
nb.add(tab, text="Log")
self.log = tk.Text(tab, wrap="word")
self.log.grid(row=0, column=0, sticky="nsew")
def pick_dir(self, var: tk.StringVar) -> None:
chosen = filedialog.askdirectory(initialdir=var.get() or str(Path.home()))
if chosen:
var.set(chosen)
def log_line(self, msg: str) -> None:
self.log_queue.put(msg.rstrip() + "\n")
def _drain_log(self) -> None:
while True:
try:
msg = self.log_queue.get_nowait()
except queue.Empty:
break
self.log.insert("end", msg)
self.log.see("end")
self.after(120, self._drain_log)
def run_threaded(self, title: str, commands: list[list[str]]) -> None:
def worker():
self.log_line(f"\n== {title} ==")
for cmd in commands:
self.log_line("$ " + " ".join(cmd))
try:
proc = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True)
if proc.stdout:
self.log_line(proc.stdout)
if proc.stderr:
self.log_line(proc.stderr)
if proc.returncode:
self.log_line(f"Command exited with {proc.returncode}")
break
except Exception as exc:
self.log_line(f"Failed: {exc}")
break
threading.Thread(target=worker, daemon=True).start()
def command_ok(self, cmd: list[str]) -> bool:
try:
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=6).returncode == 0
except Exception:
return False
def docker_names(self) -> set[str]:
try:
out = subprocess.check_output(["docker", "ps", "-a", "--format", "{{.Names}}"], text=True, timeout=8)
return {line.strip() for line in out.splitlines() if line.strip()}
except Exception:
return set()
def check_environment(self) -> None:
docker_bin = shutil.which("docker")
compose_ok = self.command_ok(["docker", "compose", "version"]) if docker_bin else False
daemon_ok = self.command_ok(["docker", "info"]) if docker_bin else False
names = self.docker_names() if daemon_ok else set()
lines = [
f"System: {platform.system()} {platform.release()}",
f"Docker command: {'found' if docker_bin else 'missing'}",
f"Docker daemon: {'running' if daemon_ok else 'not reachable'}",
f"Docker Compose plugin: {'found' if compose_ok else 'missing'}",
f"Portainer: {'installed' if any('portainer' in n for n in names) else 'not found'}",
f"Creator app: {'installed' if 'tts-voice-creator-clone-and-design' in names or 'tts-voice-creator' in names or 'voice-clone-factory' in names else 'not found'}",
f"Qwen clone TTS: {'installed' if 'faster-qwen3-tts' in names else 'not found'}",
f"Qwen Voice Design: {'installed' if 'faster-qwen3-tts-voicedesign' in names else 'not found'}",
f"Qwen Custom Voice: {'installed' if 'faster-qwen3-tts-customvoice' in names else 'not found'}",
]
self.status.delete("1.0", "end")
self.status.insert("end", "\n".join(lines))
def install_portainer_now(self) -> None:
if not messagebox.askyesno("Install Portainer", "Install or update Portainer CE with Docker?"):
return
self.run_threaded("Install Portainer", [
["docker", "volume", "create", "portainer_data"],
[
"docker", "run", "-d", "--name", "portainer", "--restart=always",
"-p", "9000:9000", "-p", "9443:9443",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", "portainer_data:/data",
"portainer/portainer-ce:latest",
],
])
def install_docker_linux(self) -> None:
if platform.system().lower() != "linux":
messagebox.showinfo("Docker installer", "This one-click installer is only for Linux. Use the Docker install guide for this operating system.")
return
if not messagebox.askyesno(
"Install Docker",
"Run Docker's official Linux convenience installer?\n\nThis may ask for sudo privileges and needs internet access.",
):
return
self.run_threaded("Install Docker", [
["curl", "-fsSL", "https://get.docker.com", "-o", "/tmp/get-docker.sh"],
["sh", "/tmp/get-docker.sh"],
["docker", "version"],
])
def generate_compose(self) -> None:
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
content = self.compose_text()
COMPOSE_PATH.write_text(content)
self.log_line(f"Generated {COMPOSE_PATH}")
messagebox.showinfo("Compose generated", f"Generated:\n{COMPOSE_PATH}")
def install_stack(self) -> None:
self.generate_compose()
if self.install_portainer.get():
self.install_portainer_now()
if not messagebox.askyesno("Install stack", "Run docker compose up -d for the generated stack?"):
return
self.run_threaded("Install selected stack", [
["docker", "network", "create", self.network_name.get()],
["docker", "compose", "-f", str(COMPOSE_PATH), "up", "-d", "--build"],
])
def compose_text(self) -> str:
network = self.network_name.get().strip() or "tts_net"
voice_dir = self.voice_dir.get().strip() or "./voices"
model_dir = self.model_dir.get().strip() or "./models"
image = self.tts_image.get().strip() or "faster-qwen3-tts-dgx-spark:v4"
creator_port = self.creator_port.get().strip() or "7860"
services = [
"services:",
" tts-voice-creator-clone-and-design:",
" build:",
" context: ../..",
" dockerfile: Dockerfile",
" image: tts-voice-creator-clone-and-design:latest",
" container_name: tts-voice-creator-clone-and-design",
" restart: unless-stopped",
" ports:",
f" - \"{creator_port}:7860\"",
" volumes:",
f" - {voice_dir}:/voices:rw",
" - tts-voice-creator-clone-and-design:/home/app/.config/tts-voice-creator",
" environment:",
" - PYTHONUNBUFFERED=1",
" - VOICES_DIR=/voices",
" - OUTPUT_DIR=/voices/active_voices",
" - TTS_CONTAINER_NAME=faster-qwen3-tts",
" extra_hosts:",
" - \"host.docker.internal:host-gateway\"",
" networks:",
f" - {network}",
"",
]
for key, meta in MODEL_EXPLANATIONS.items():
if not self.model_enabled[key].get():
continue
_title, _desc, container, _default_port, _default_model = meta
host_port = self.model_ports[key].get().strip() or _default_port
model_path = self.model_paths[key].get().strip() or str(Path(model_dir) / Path(_default_model).name)
services.extend(self.tts_service_yaml(key, container, image, host_port, model_path, network))
services.extend([
"volumes:",
" tts-voice-creator-clone-and-design:",
"",
"networks:",
f" {network}:",
" external: true",
"",
])
return "\n".join(services)
def tts_service_yaml(self, key: str, container: str, image: str, host_port: str, model_path: str, network: str) -> list[str]:
lines = [
f" {container}:",
f" image: {image}",
f" container_name: {container}",
" restart: unless-stopped",
" ports:",
f" - \"{host_port}:8000\"",
" volumes:",
f" - {model_path}:/models/{Path(model_path).name}:ro",
f" - {ROOT / 'support' / 'faster-qwen3-tts-config'}:/config:ro",
" environment:",
" - PYTHONUNBUFFERED=1",
" networks:",
f" - {network}",
]
if key == "design":
lines.extend([
" command: python /config/run_voicedesign_server.py --host 0.0.0.0 --port 8000 --model /models/" + Path(model_path).name,
])
elif key == "custom":
lines.extend([
" command: python /config/run_customvoice_server.py --host 0.0.0.0 --port 8000 --model /models/" + Path(model_path).name,
])
lines.append("")
return lines
def refresh_openwebui_text(self) -> None:
creator = f"http://YOUR_HOST:{self.creator_port.get() or '7860'}/v1"
text = f"""Open WebUI setup
Without routing:
1. Open Open WebUI Admin Settings -> Audio.
2. Choose OpenAI-compatible TTS.
3. Base URL: http://YOUR_HOST:{self.model_ports['clone'].get() or '8020'}/v1
4. API key: dummy
5. Model: qwen3-tts or tts-1
6. Voice: one real active voice, such as EN_F_Anna.
With routing:
1. Open Open WebUI Admin Settings -> Audio.
2. Choose OpenAI-compatible TTS.
3. Base URL: {creator}
4. API key: dummy
5. Model: tts-1
6. Voice: default
7. In TTS Voice Creator -> Routing, map:
Open WebUI + default + EN -> EN_F_YourEnglishVoice
Open WebUI + default + DE -> DE_M_YourGermanVoice
What to tell users:
- Voice Clone is the normal production path for recorded voices.
- Voice Design is for creating voices from descriptions.
- Custom Voice is for named Qwen speakers/custom voice model workflows.
- Portainer is optional. Use it when you want a browser dashboard for containers.
"""
self.openwebui_text.delete("1.0", "end")
self.openwebui_text.insert("end", text)
if __name__ == "__main__":
GuidedInstaller().mainloop()

49
portainer-stack.yml Normal file
View File

@ -0,0 +1,49 @@
# ─────────────────────────────────────────────────────────────────────────────
# TTS Voice Creator - Clone and Design — Portainer Stack
#
# IMPORTANT: build the image on the host before deploying this stack:
#
# docker build -t tts-voice-creator-clone-and-design:latest /home/sparky/Docker/tts-voice-creator-clone-and-design/
#
# Then paste this file into Portainer → Stacks → Add stack → Web editor.
# ─────────────────────────────────────────────────────────────────────────────
services:
tts-voice-creator-clone-and-design:
image: tts-voice-creator-clone-and-design:latest
container_name: tts-voice-creator-clone-and-design
restart: unless-stopped
ports:
- "7860:7860"
volumes:
- /home/sparky/Projekte/TTS_Voices:/voices:rw
- tts-voice-creator-clone-and-design:/home/app/.config/voice-clone-factory
- /home/sparky/Docker/tts-voice-creator-clone-and-design/static:/app/static:ro
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "988"
environment:
- PYTHONUNBUFFERED=1
- VOICES_DIR=/voices
- OUTPUT_DIR=/voices/active_voices
- TTS_CONTAINER_NAME=faster-qwen3-tts-voiceclone
- TTS_STREAM_URL=http://host.docker.internal:8023
- VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- dgx_net
volumes:
tts-voice-creator-clone-and-design:
networks:
dgx_net:
external: true

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
python-multipart>=0.0.9
pydub>=0.25.1
numpy>=1.26.0
scipy>=1.12.0
requests>=2.31.0
yt-dlp>=2024.3.10

3942
server.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
@echo off
cd /d "%~dp0"
py -3 guided_installer.py
if errorlevel 1 python guided_installer.py
pause

3
start-guided-installer.command Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env sh
cd "$(dirname "$0")" || exit 1
python3 guided_installer.py

3
start-guided-installer.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env sh
cd "$(dirname "$0")" || exit 1
python3 guided_installer.py

5222
static/app.js Normal file

File diff suppressed because it is too large Load Diff

94
static/index.html Normal file
View File

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TTS Voice Creator</title>
<!-- WaveSurfer must load synchronously before app.js initialises audio -->
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Hidden tabs nav — required for JS backend availability detection -->
<nav class="tabs" style="display:none" aria-hidden="true">
<div class="tab active" data-tab="library" data-backend-required="voice_clone" tabindex="-1"><span class="tab-title">Voice Clone</span></div>
<div class="tab" data-tab="design" data-backend-required="voice_design" tabindex="-1"><span class="tab-title">Voice Design</span></div>
<div class="tab" data-tab="custom" data-backend-required="customvoice" tabindex="-1"><span class="tab-title">Voice Custom</span></div>
<div class="tab" data-tab="generation" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">TTS Generation</span></div>
<div class="tab" data-tab="stt-tts" data-backend-required="any_tts" tabindex="-1"><span class="tab-title">STT-TTS</span></div>
<div class="tab" data-tab="routing" tabindex="-1"><span class="tab-title">Routing</span></div>
<div class="tab" data-tab="integrations" tabindex="-1"><span class="tab-title">How to</span></div>
<div class="tab" data-tab="getvoices" tabindex="-1"><span class="tab-title">Get Voices</span></div>
<div class="tab" data-tab="settings" tabindex="-1"><span class="tab-title">Settings</span></div>
</nav>
<!-- App shell -->
<div id="app-shell">
<!-- Sidebar -->
<aside id="sidebar">
<div class="sidebar-brand">
<h1>&#127908; Voice Creator</h1>
<p>Clone &middot; Design &middot; Deploy</p>
</div>
<nav class="sidebar-nav">
<div class="nav-group-label">Voices</div>
<div class="nav-item active" data-nav-section="s-voices" onclick="navTo('s-voices')">
<span class="nav-icon">&#128266;</span> My Voices
</div>
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')">
<span class="nav-icon">&#127908;</span> Clone a Voice
</div>
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')">
<span class="nav-icon">&#10024;</span> Design a Voice
</div>
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')">
<span class="nav-icon">&#127760;</span> Get Voices Online
</div>
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')">
<span class="nav-icon">&#9654;</span> Try It Out
</div>
<div class="nav-group-label" style="margin-top:6px">Setup</div>
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')">
<span class="nav-icon">&#8652;</span> App Routing
</div>
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')">
<span class="nav-icon">&#128279;</span> Connect Apps
</div>
<div class="nav-item" data-nav-section="s-settings" onclick="navTo('s-settings')">
<span class="nav-icon">&#9881;</span> Settings
</div>
</nav>
<div class="sidebar-footer">
<button id="theme-btn" title="Switch theme" style="display:none">&#9728;</button>
<button id="settings-btn" onclick="navTo('s-settings')" title="Open settings">&#9881; Settings</button>
</div>
</aside>
<!-- Main scrollable content — sections filled by loader.js -->
<main id="main-content">
<section class="page-section" id="s-voices"></section>
<section class="page-section" id="s-clone"></section>
<section class="page-section" id="s-design"></section>
<section class="page-section" id="s-studio"></section>
<section class="page-section" id="s-tryout"></section>
<section class="page-section" id="s-routing"></section>
<section class="page-section" id="s-connect"></section>
<section class="page-section" id="s-settings"></section>
</main>
</div><!-- /app-shell -->
<div id="toast"></div>
<div id="status-bar">Loading…</div>
<!-- loader.js: fetches sections → loads app.js → loads nav.js -->
<script src="/static/loader.js"></script>
</body>
</html>

36
static/loader.js Normal file
View File

@ -0,0 +1,36 @@
(async function () {
'use strict';
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings'];
function loadScript(src) {
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = function () { reject(new Error('Failed to load ' + src)); };
document.body.appendChild(s);
});
}
// 1. Fetch all section partials in parallel and inject into their shells
await Promise.all(SECTIONS.map(async function (id) {
try {
var res = await fetch('/static/sections/' + id + '.html');
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
var html = await res.text();
var el = document.getElementById(id);
if (el) el.innerHTML = html;
} catch (e) {
console.error('[loader] section', id, 'failed:', e.message);
var el = document.getElementById(id);
if (el) el.innerHTML = '<p style="color:var(--red);padding:24px">Failed to load section <b>' + id + '</b>: ' + e.message + '</p>';
}
}));
// 2. Load main application logic (runs init immediately on parse — sections must exist first)
await loadScript('/static/app.js');
// 3. Apply scroll-based navigation overrides (must run after app.js defines switchTab etc.)
await loadScript('/static/nav.js');
})();

67
static/nav.js Normal file
View File

@ -0,0 +1,67 @@
(function () {
'use strict';
const TAB_SECTION_MAP = {
library: 's-voices',
source: 's-clone',
save: 's-clone',
design: 's-design',
custom: 's-design',
getvoices: 's-studio',
generation: 's-tryout',
'stt-tts': 's-tryout',
routing: 's-routing',
integrations: 's-connect',
howto: 's-connect',
settings: 's-settings'
};
function updateNavActive(sectionId) {
document.querySelectorAll('[data-nav-section]').forEach(function (item) {
item.classList.toggle('active', item.dataset.navSection === sectionId);
});
}
window.navTo = function (sectionId) {
var el = document.getElementById(sectionId);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
updateNavActive(sectionId);
// Fire lazy-loader side-effects
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
return TAB_SECTION_MAP[k] === sectionId;
});
if (tabName) window.switchTab(tabName);
};
// Override switchTab — redirect tab switches to scroll-based navigation
window.switchTab = function (name) {
if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary();
if ((name === 'integrations' || name === 'howto') && typeof renderIntegrationSnippets === 'function') {
if (typeof loadVoiceLibrary === 'function' && !(window._voices && window._voices.length)) loadVoiceLibrary();
renderIntegrationSnippets();
}
if (name === 'routing' && typeof loadRoutingTab === 'function') loadRoutingTab();
if (name === 'getvoices' && typeof loadGetVoices === 'function') loadGetVoices();
var sectionId = TAB_SECTION_MAP[name];
if (sectionId) {
var el = document.getElementById(sectionId);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
updateNavActive(sectionId);
}
return true;
};
// IntersectionObserver: highlight sidebar item for the section in view
var sections = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings'];
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) updateNavActive(entry.target.id);
});
}, { threshold: 0.15 });
sections.forEach(function (id) {
var el = document.getElementById(id);
if (el) io.observe(el);
});
})();

View File

@ -0,0 +1,112 @@
<div class="section-head">
<span class="section-icon">&#127908;</span>
<div class="section-title">
<h2>Clone a Voice</h2>
<p>Upload or record 3-20 seconds of audio, trim it, then save it as a reusable voice clone.</p>
</div>
</div>
<!-- Step 1: Load audio -->
<div class="tab-content" id="tab-source">
<div class="card">
<h2>Step 1 &mdash; Load audio</h2>
<p class="note">Provide the audio you want to clone. Drop a file, paste a YouTube link, or record your microphone.</p>
<div id="drop-zone">
<strong>Drop an audio / video file here</strong>
<span>WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; WEBM</span>
<span style="margin-top:10px;font-size:12px;color:var(--subtext)">or click to browse</span>
<input type="file" id="file-input" accept="audio/*,video/*">
</div>
</div>
<div class="card">
<h2>YouTube / URL</h2>
<div class="url-row">
<input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
<button class="btn-primary" id="yt-btn">Download</button>
</div>
<div id="yt-progress"></div>
</div>
<div class="card">
<h2>Microphone</h2>
<div class="btn-row">
<button class="btn-red" id="rec-start-btn">&#9679; Record</button>
<button class="btn-secondary" id="rec-stop-btn" disabled>&#9632; Stop</button>
<div id="rec-indicator"><div id="rec-dot"></div><span id="rec-time">0:00</span></div>
</div>
</div>
<div class="card" id="waveform-card" style="display:none">
<h2>Step 2 &mdash; Trim selection <span class="note" style="font-weight:400;text-transform:none;letter-spacing:0">(3-20 seconds is ideal)</span></h2>
<div id="waveform-wrap"><div id="waveform"></div></div>
<div class="time-row">
<label>Start (s) <input type="number" id="trim-start" value="0" min="0" step="0.1"></label>
<label>End (s) &nbsp;<input type="number" id="trim-end" value="0" min="0" step="0.1"></label>
<span id="trim-duration" class="dur-ok">0.0 s</span>
</div>
<div class="btn-row">
<button class="btn-primary" id="trim-btn">Confirm trim &rarr; Name &amp; Save</button>
<button class="btn-secondary" id="auto-trim-btn">Auto trim</button>
<button class="btn-secondary" id="play-btn">&#9654; Play</button>
<button class="btn-secondary" id="play-selection-btn">&#9654; Play selection</button>
</div>
</div>
</div><!-- /tab-source -->
<!-- Step 3: Name and save -->
<div class="tab-content" id="tab-save">
<div class="card">
<h2>Step 3 &mdash; Name the voice</h2>
<p class="note">Give the voice a unique ID in the format <code>LANG_GENDER_Name</code>, for example <code>EN_F_Anna</code>.</p>
<div class="voice-id-row">
<input type="text" id="voice-id-input" placeholder="e.g. EN_F_Anna or MyCustomVoice" spellcheck="false">
</div>
<div id="voice-id-hint" class="note" style="min-height:1.2em"></div>
<details class="helper">
<summary>Build from parts &nbsp;(LANG &middot; GENDER &middot; Name helper)</summary>
<div class="helper-body">
<div class="field">
<label>Language</label>
<select id="lang-select">
<option>EN</option><option>DE</option><option>ZH</option><option>FR</option>
<option>ES</option><option>JA</option><option>KO</option><option>IT</option>
<option>PT</option><option>RU</option><option>AR</option><option>PL</option>
<option>NL</option><option>SV</option><option>TR</option><option>HI</option>
</select>
</div>
<div class="field">
<label>Gender</label>
<select id="gender-select">
<option value="F">F &mdash; Female</option>
<option value="M">M &mdash; Male</option>
<option value="N">N &mdash; Neutral</option>
</select>
</div>
<div class="field" style="flex:1;min-width:120px">
<label>Name (no spaces)</label>
<input type="text" id="name-input" placeholder="Anna">
</div>
<button class="btn-secondary" id="helper-apply-btn" style="align-self:flex-end">Apply &rarr;</button>
</div>
</details>
</div>
<div class="card">
<h2>Transcript (reference text)</h2>
<p class="note">Type what was spoken in the audio, or click Auto-transcribe to fill it automatically.</p>
<div class="btn-row">
<button class="btn-secondary" id="transcribe-btn">&#128172; Auto-transcribe</button>
<span id="transcribe-status" style="font-size:13px;color:var(--subtext)"></span>
</div>
<textarea id="transcript-area" placeholder="Type or auto-transcribe the spoken text&hellip;"></textarea>
</div>
<div class="card">
<h2>Audio preview</h2>
<audio id="trim-audio" controls style="display:none"></audio>
<p id="no-audio-hint" class="note">No audio loaded yet. Load a file above (Step 1) or use Voice Design below.</p>
</div>
<div class="card">
<h2>Step 4 &mdash; Save to library</h2>
<div class="btn-row">
<button class="btn-green" id="save-btn">&#128190; Save to Voice Library</button>
</div>
<div id="save-result" class="note" style="display:none"></div>
</div>
</div><!-- /tab-save -->

View File

@ -0,0 +1,63 @@
<div class="section-head">
<span class="section-icon">&#128279;</span>
<div class="section-title">
<h2>Connect Your Apps</h2>
<p>Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, and more.</p>
</div>
</div>
<div class="tab-content" id="tab-integrations">
<div class="card">
<h2>Use voices in other apps</h2>
<p class="note">The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.</p>
<div class="integration-toolbar">
<button class="btn-primary" id="show-api-btn" type="button">show api</button>
<button class="btn-secondary" id="integration-refresh-btn">Refresh examples</button>
<button class="btn-secondary" id="copy-active-voices-btn-integrations">Copy active voices</button>
<span id="integration-url-label" class="note"></span>
</div>
</div>
<div class="integration-grid">
<div class="integration-card">
<h3>SillyTavern</h3>
<p>Use an OpenAI-compatible TTS provider. Paste one active voice into the voice field, or paste the comma-separated list where SillyTavern accepts custom voices.</p>
<pre><code id="snippet-sillytavern"></code></pre>
<button class="btn-secondary copy-snippet" data-snippet="snippet-sillytavern">Copy SillyTavern sample</button>
</div>
<div class="integration-card">
<h3>Open WebUI</h3>
<p>Configure TTS as OpenAI-compatible audio. Use the creator proxy if you want Routing rules such as incoming voice <code>default</code> mapped by language.</p>
<pre><code id="snippet-open-webui"></code></pre>
<button class="btn-secondary copy-snippet" data-snippet="snippet-open-webui">Copy Open WebUI sample</button>
</div>
<div class="integration-card">
<h3>Home Assistant</h3>
<p>Use this as a REST example for automations or scripts that call the TTS backend. Save the returned audio somewhere Home Assistant can play from.</p>
<pre><code id="snippet-home-assistant"></code></pre>
<button class="btn-secondary copy-snippet" data-snippet="snippet-home-assistant">Copy Home Assistant sample</button>
</div>
<div class="integration-card">
<h3>Generic curl test</h3>
<p>Quick terminal test for the voice list and speech endpoint after restarting the TTS container.</p>
<pre><code id="snippet-curl"></code></pre>
<button class="btn-secondary copy-snippet" data-snippet="snippet-curl">Copy curl sample</button>
</div>
<div class="integration-card">
<h3>VoiceDesign virtual voices</h3>
<p>Use saved Voice Design prompt presets without exporting WAVs. Point the external app at this creator app as an OpenAI-compatible TTS proxy and select a <code>vd_...</code> voice.</p>
<pre><code id="snippet-voice-design-proxy"></code></pre>
<button class="btn-secondary copy-snippet" data-snippet="snippet-voice-design-proxy">Copy virtual voice sample</button>
</div>
<div class="integration-card">
<h3>Streaming TTS</h3>
<p>Use this when the target app can play audio progressively. For routed streaming, keep response format WAV and avoid before/after route sounds, otherwise the proxy must buffer before playback.</p>
<pre><code id="snippet-streaming-howto"></code></pre>
<button class="btn-secondary copy-snippet" data-snippet="snippet-streaming-howto">Copy streaming how-to</button>
</div>
</div>
<div class="card">
<h2>Important after voice changes</h2>
<p class="note">After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated <code>active_voices</code> folder. Then refresh the model or voice list in the target app.</p>
<p class="note">Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the <code>faster-qwen3-tts-voicedesign</code> container reachable from Settings.</p>
</div>
</div><!-- /tab-integrations -->

View File

@ -0,0 +1,160 @@
<div class="section-head">
<span class="section-icon">&#10024;</span>
<div class="section-title">
<h2>Design a Voice</h2>
<p>Describe a voice in words and let the AI create it. No recording needed.</p>
</div>
</div>
<div class="tab-content" id="tab-design">
<div class="card">
<details class="design-samples-details" open>
<summary>
<span>Voice Design samples</span>
<span class="note">Preview a sample or use it to fill the editor below.</span>
</summary>
<div class="design-sample-grid design-sample-header">
<div>Name</div><div>Sex</div><div>Language</div>
<div>Description</div><div>Sample text</div><div>Actions</div>
</div>
<div id="qwen-sample-list" style="display:flex;flex-direction:column;gap:4px"></div>
</details>
</div>
<div class="card">
<h2>Describe the voice</h2>
<p class="note">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Gender</label>
<select class="lang-select" id="design-gender">
<option value="N">Neutral</option>
<option value="F">Female</option>
<option value="M">Male</option>
</select>
</div>
<div class="field">
<label>Language</label>
<select class="lang-select" id="design-language">
<option value="Auto">Auto-detect</option>
<option value="English">English</option><option value="Chinese">Chinese</option>
<option value="Japanese">Japanese</option><option value="Korean">Korean</option>
<option value="German">German</option><option value="French">French</option>
<option value="Spanish">Spanish</option><option value="Italian">Italian</option>
<option value="Portuguese">Portuguese</option><option value="Russian">Russian</option>
</select>
</div>
<div class="field">
<label>Name</label>
<input type="text" id="design-preset-name" placeholder="Narrator calm">
</div>
<button class="btn-secondary" id="design-preset-save">Save Voice Design Preset</button>
</div>
<textarea id="design-instruct" placeholder="e.g. A calm, deep male voice with a slight British accent and authoritative tone.&#10;&#10;Or: Young female, high-pitched, enthusiastic and slightly breathless.&#10;&#10;Or: Speak in an incredulous tone, with a hint of panic creeping into your voice."></textarea>
<table class="design-hints">
<thead><tr><th>Dimension</th><th>Examples</th></tr></thead>
<tbody>
<tr><td>Gender</td><td>Male, female, neutral</td></tr>
<tr><td>Age</td><td>Child (5-12), teenager (13-18), young adult (19-35), middle-aged (36-55), elderly (55+)</td></tr>
<tr><td>Pitch</td><td>High, mid, low, slightly high, slightly low</td></tr>
<tr><td>Speaking rate</td><td>Fast, moderate, slow, slightly fast, slightly slow</td></tr>
<tr><td>Emotion</td><td>Cheerful, calm, gentle, serious, lively, composed, soothing</td></tr>
<tr><td>Characteristics</td><td>Magnetic, crisp, husky, smooth, sweet, rich, powerful</td></tr>
<tr><td>Use case</td><td>News broadcasting, advertisement voice-over, audiobook, animated character, voice assistant, documentary narration</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Reference transcript &amp; generate</h2>
<p class="note">This shared reference transcript is used by samples, prompt presets, generation, preview, download, and export to the Voice Clone Library.</p>
<textarea id="design-sample-text" placeholder="The exact text the generated voice should speak...">Hello! This is a voice design sample. I hope you enjoy listening to me.</textarea>
<div class="btn-row">
<button class="btn-teal" id="design-generate-btn" style="align-self:flex-end">&#10024; Generate voice</button>
<span id="design-status" style="font-size:13px;color:var(--subtext);align-self:flex-end"></span>
</div>
</div>
<div class="card" id="design-result" style="display:none">
<h2>Generated voice</h2>
<audio id="design-audio" controls></audio>
<hr>
<h2>Export to Voice Clone Library</h2>
<div class="d-naming-row">
<div class="field">
<label>Language</label>
<select id="d-lang">
<option value="EN">EN</option><option value="DE">DE</option><option value="ZH">ZH</option>
<option value="FR">FR</option><option value="ES">ES</option><option value="JA">JA</option>
<option value="KO">KO</option><option value="IT">IT</option><option value="PT">PT</option>
<option value="RU">RU</option><option value="AR">AR</option><option value="PL">PL</option>
<option value="NL">NL</option><option value="SV">SV</option><option value="TR">TR</option>
<option value="HI">HI</option>
</select>
</div>
<div class="field">
<label>Gender</label>
<select id="d-gender">
<option value="F">F &mdash; Female</option>
<option value="M">M &mdash; Male</option>
<option value="N">N &mdash; Neutral</option>
</select>
</div>
<div class="field">
<label>Short name</label>
<input type="text" id="d-name" placeholder="Anna" style="width:120px">
</div>
<div class="field flex1">
<label>Voice ID (editable)</label>
<input type="text" id="d-voice-id" placeholder="EN_F_Anna" spellcheck="false">
</div>
</div>
<div class="field" style="margin-top:2px">
<label>Reference transcript</label>
<textarea id="d-transcript" placeholder="The spoken text from the sample (auto-filled)&hellip;"></textarea>
</div>
<div class="btn-row">
<button class="btn-secondary" id="design-retry-btn">&#8635; Regenerate</button>
<button class="btn-secondary" id="design-download-btn">&#8595; Download WAV</button>
<button class="btn-green" id="design-save-btn">&#128190; Export to Voice Clone Library</button>
</div>
<div id="design-save-result" style="display:none">
<hr>
<p style="color:var(--green);font-size:14px;margin-bottom:6px">Voice exported to the Voice Clone Library.</p>
<p class="note" style="margin-bottom:6px">To reload in Qwen3-TTS, restart the TTS container so it scans <code>active_voices</code>.</p>
</div>
</div>
<div class="card">
<h2>Voice Design prompt library</h2>
<div class="preset-row">
<div class="field">
<label>Preset</label>
<select id="design-preset-select"><option value="">— preset —</option></select>
</div>
<button class="btn-secondary" id="design-preset-load">Load</button>
<button class="btn-secondary" id="design-preset-delete">Delete</button>
</div>
<div class="design-preset-library" id="design-preset-library"></div>
</div>
</div><!-- /tab-design -->
<!-- CustomVoice subsection -->
<div class="tab-content" id="tab-custom">
<div class="card" style="border-color: rgba(37,99,235,.25);">
<h2>Custom Voice (Qwen style control)</h2>
<p class="note">CustomVoice uses Qwen's configured premium/custom speakers. It is the best place to test style instructions when you can use one of the CustomVoice timbres.</p>
<div class="backend-help">
<strong>Best for style control over configured target timbres.</strong>
<div class="backend-help-tags">
<span class="backend-tag warn">model voice</span>
<span class="backend-tag good">style-aware</span>
<span class="backend-tag">stream-capable model</span>
</div>
<div>It does not automatically reuse arbitrary WAV voices from the Voice Clone library. For your own recurring character, create/fine-tune/configure that voice here, or design/export a WAV and then clone it.</div>
</div>
<div class="btn-row">
<button class="btn-primary backend-jump" data-backend="customvoice">Open CustomVoice in Try It Out</button>
</div>
</div>
</div><!-- /tab-custom -->

View File

@ -0,0 +1,87 @@
<div class="section-head">
<span class="section-icon">&#8652;</span>
<div class="section-title">
<h2>App Routing</h2>
<p>Map incoming requests from apps like Open WebUI or SillyTavern to specific voices and backends.</p>
</div>
</div>
<div class="tab-content" id="tab-routing">
<div class="card">
<h2>TTS voice routing</h2>
<div class="routing-help">
Recommended OpenAI-compatible TTS base URL: <code id="routing-proxy-url"></code>. If an app asks for the full speech endpoint instead of a base URL, use <code>/v1/audio/speech</code>.
Rules can turn an incoming app voice such as <code>default</code> into a real cloned voice before the request is sent to Qwen3-TTS.
The app name is read from request JSON fields <code>app</code>/<code>client</code>, headers such as <code>X-TTS-App</code>, or guessed from <code>User-Agent</code>/<code>Origin</code>. If a client cannot send that, use app <code>*</code> or give each app a unique incoming voice name.
For Open WebUI, set Audio &rarr; Text-to-Speech &rarr; Additional Parameters to <code>{"app":"Open WebUI"}</code> so these routes match explicitly. For Home Assistant, set the TTS agent base URL to this Creator proxy, not the direct Qwen backend, and use extra payload <code>{"app":"Home Assistant"}</code>. Use Response splitting <code>Punctuation</code> for lower perceived latency.
Backend chooses whether this route uses normal Voice Clone, low-latency Streaming, or Voice Design presets such as <code>vd_...</code>. Streaming routes cannot apply before/after sounds without buffering.
Language rules use lightweight text detection for EN, DE, FR, ES, IT, PT, NL, and PL.
Optional before/after sounds are audio files inside the configured voices folder, for example <code>sounds/start.wav</code>.
<div class="routing-url-warning" id="routing-url-warning">
<strong>Do not use <code>0.0.0.0</code> in Open WebUI.</strong>
Use this machine's LAN IP, hostname, or Docker service name instead.
</div>
</div>
<div class="routing-toolbar">
<button class="btn-secondary" id="routing-refresh-btn">Refresh</button>
<button class="btn-primary" id="routing-add-btn">Add route</button>
<button class="btn-green" id="routing-save-btn">Save routes</button>
<button class="btn-secondary" id="routing-add-openwebui-btn">Add Open WebUI default examples</button>
<span class="note" id="routing-status">No routes loaded.</span>
</div>
<div class="routing-test-row">
<input id="routing-test-app" value="Open WebUI" spellcheck="false" placeholder="App or *" title="Examples: Open WebUI, SillyTavern, Home Assistant">
<input id="routing-test-voice" value="default" spellcheck="false" placeholder="Incoming voice">
<textarea id="routing-test-text" placeholder="Type sample text to test language routing">Das ist ein kurzer deutscher Routing Test.</textarea>
<button class="btn-secondary" id="routing-test-btn">Test route</button>
</div>
<div class="routing-test-result" id="routing-test-result"></div>
</div>
<div class="card">
<div class="routing-grid routing-header">
<div>On</div><div>App</div><div>Input voice</div><div>Language</div>
<div>Backend</div><div>Output voice</div><div>Before sound</div><div>After sound</div><div></div>
</div>
<datalist id="routing-voice-options"></datalist>
<datalist id="routing-sound-options"></datalist>
<div id="routing-list" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<div class="card" id="routing-sound-browser" hidden>
<div class="routing-sound-browser-head">
<div>
<h2>Sound browser</h2>
<p class="note" id="routing-sound-browser-note">Preview uploaded route sounds, then apply one as a before or after sound.</p>
</div>
<div class="routing-sound-browser-actions">
<button class="btn-secondary" id="routing-sound-refresh-btn" type="button">Refresh sounds</button>
<button class="btn-secondary" id="routing-sound-close-btn" type="button">Close</button>
</div>
</div>
<input id="routing-sound-search" class="routing-sound-search" type="search" placeholder="Search sounds, e.g. beep, alert, computer..." autocomplete="off">
<div class="routing-sound-list" id="routing-sound-list"></div>
<audio class="routing-sound-preview" id="routing-sound-preview" controls hidden></audio>
</div>
<div class="card">
<div class="routing-log-head">
<div>
<h2>Routing log</h2>
<p class="note">Recent route tests and proxy requests. This log is kept in memory and resets when the server restarts.</p>
</div>
<div class="routing-log-actions">
<select id="routing-log-filter" class="routing-log-filter" title="Filter routing log entries">
<option value="all">All entries</option>
<option value="attention">Errors + no match</option>
<option value="error">Errors only</option>
<option value="no_match">No match only</option>
</select>
<button class="btn-secondary" id="routing-log-refresh-btn" type="button">Refresh log</button>
<button class="btn-secondary" id="routing-log-clear-btn" type="button">Clear log</button>
</div>
</div>
<div class="routing-log-scroll">
<div class="routing-log-list" id="routing-log-list">
<div class="routing-log-empty">No routing log entries yet.</div>
</div>
</div>
</div>
</div><!-- /tab-routing -->

View File

@ -0,0 +1,245 @@
<div class="section-head">
<span class="section-icon">&#9881;</span>
<div class="section-title">
<h2>Settings</h2>
<p>Configure backend URLs, API keys, voice folders, and playback preferences.</p>
</div>
</div>
<div class="tab-content" id="tab-settings">
<div class="card" id="settings-box">
<div class="settings-title-row">
<div>
<h2>Settings</h2>
<p class="note">Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.</p>
</div>
<div class="settings-status-pill">Local stack</div>
</div>
<div class="setup-intro settings-checklist" id="settings-first-run-note">
<strong>Quick setup</strong>
<span>1. Check core TTS URLs.</span>
<span>2. Point STT at Whisper, Parakeet, or the NVIDIA router.</span>
<span>3. Save settings.</span>
</div>
<section class="settings-section settings-section-primary">
<div class="settings-section-head">
<div>
<h3>Core connections</h3>
<p>These are the endpoints you change most often. Qwen3 TTS, NVIDIA TTS, and STT are grouped separately.</p>
</div>
</div>
<div class="settings-cluster-grid">
<div class="settings-cluster">
<div class="settings-cluster-head">
<strong>TTS Text to Speech</strong>
<span>Qwen3 engines: clone, design, custom, streaming</span>
</div>
<div class="settings-grid compact">
<div class="s-field">
<label>Voice Clone/Base URL <span style="font-weight:400">(WAV voices)</span></label>
<input type="text" id="s-tts-url" placeholder="http://host.docker.internal:8020">
<span class="s-hint">Uploaded/cloned WAV voices. Expected: <code>POST /v1/audio/speech</code>.</span>
</div>
<div class="s-field">
<label>Voice Design URL <span style="font-weight:400">(instruction voices)</span></label>
<input type="text" id="s-voice-design-url" placeholder="http://host.docker.internal:8021">
<span class="s-hint">Prompt-designed voices and <code>vd_...</code> virtual voices.</span>
</div>
<div class="s-field">
<label>CustomVoice URL</label>
<input type="text" id="s-customvoice-url" placeholder="http://host.docker.internal:8022">
<span class="s-hint">Style over configured speakers such as Ryan, Vivian, Serena.</span>
</div>
<div class="s-field">
<label>Streaming URL</label>
<input type="text" id="s-tts-stream-url" placeholder="http://host.docker.internal:8023">
<span class="s-hint">Progressive low-latency WAV playback.</span>
</div>
</div>
</div>
<div class="settings-cluster">
<div class="settings-cluster-head">
<strong>NVIDIA speech stack</strong>
<span>router, Magpie TTS, Parakeet ASR, clone NIM</span>
</div>
<div class="settings-grid compact">
<div class="s-field">
<label>NVIDIA router URL <span style="font-weight:400">(TTS + STT)</span></label>
<input type="text" id="s-nvidia-router-url" placeholder="http://host.docker.internal:8090">
<span class="s-hint">OpenAI-compatible base URL for the NVIDIA speech router.</span>
</div>
<div class="s-field">
<label>NVIDIA Magpie TTS URL</label>
<input type="text" id="s-nvidia-tts-url" placeholder="http://host.docker.internal:8091">
<span class="s-hint">Direct Magpie endpoint. Fixed speakers, not WAV cloning.</span>
</div>
<div class="s-field">
<label>NVIDIA Parakeet ASR URL</label>
<input type="text" id="s-nvidia-asr-url" placeholder="http://host.docker.internal:8092">
<span class="s-hint">Direct Parakeet endpoint for transcription.</span>
</div>
<div class="s-field">
<label>NVIDIA Zeroshot NIM URL</label>
<input type="text" id="s-nvidia-zeroshot-url" placeholder="http://host.docker.internal:8093">
<span class="s-hint">Magpie Zeroshot clone endpoint with <code>audio_prompt</code>.</span>
</div>
<div class="s-field">
<label>NVIDIA Flow NIM URL</label>
<input type="text" id="s-nvidia-flow-url" placeholder="http://host.docker.internal:8094">
<span class="s-hint">Magpie Flow clone endpoint with <code>audio_prompt</code> and transcript.</span>
</div>
</div>
</div>
<div class="settings-cluster settings-cluster-stt">
<div class="settings-cluster-head">
<strong>STT Speech to Text</strong>
<span>Whisper, Parakeet, or NVIDIA router</span>
</div>
<div class="settings-grid compact stt-settings-grid">
<div class="s-field stt-url-field">
<label>Whisper/STT URL</label>
<input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010">
<span class="s-hint">Reference text recognition. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<div class="btn-row settings-mini-actions">
<button class="btn-secondary" id="s-use-parakeet-asr" type="button">Use Parakeet</button>
<button class="btn-secondary" id="s-use-nvidia-router" type="button">Use router</button>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="settings-section">
<div class="settings-section-head">
<div>
<h3>Playback behavior</h3>
<p>Small behavior switches for previews and OpenAI-compatible TTS calls.</p>
</div>
</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>TTS preview playback</label>
<select id="s-tts-stream-mode">
<option value="auto">Stream when available, then fall back</option>
<option value="streaming">Streaming only</option>
<option value="buffered">Buffered WAV only</option>
</select>
<span class="s-hint">Buffered keeps Save WAV available. Streaming starts sooner.</span>
</div>
<div class="s-field">
<label>OpenAI-compatible request style</label>
<select id="s-tts-backend">
<option value="openai">Qwen3-TTS / OpenAI</option>
<option value="localai">LocalAI (/tts)</option>
<option value="pocket">Pocket-TTS</option>
<option value="nvidia_magpie">NVIDIA Magpie / OpenAI</option>
</select>
<span class="s-hint">Controls payload shape for the normal TTS API URL.</span>
</div>
</div>
</section>
<details class="settings-details">
<summary>
<span>Advanced request payloads</span>
<small>JSON extras sent to each backend</small>
</summary>
<div class="settings-grid three settings-param-grid">
<div class="s-field">
<label>Voice Clone/Base params</label>
<textarea id="s-tts-extra-voice-clone" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea>
<span class="s-hint">Extra fields for the 8020 WAV voice clone/base model.</span>
</div>
<div class="s-field">
<label>Streaming params</label>
<textarea id="s-tts-extra-streaming" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea>
<span class="s-hint">Extra fields for the 8023 streaming model.</span>
</div>
<div class="s-field">
<label>CustomVoice params</label>
<textarea id="s-tts-extra-customvoice" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea>
<span class="s-hint">Extra fields for the CustomVoice backend.</span>
</div>
<div class="s-field">
<label>Voice Design params</label>
<textarea id="s-tts-extra-voice-design" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea>
<span class="s-hint">Extra fields for Voice Design and virtual <code>vd_...</code> voices.</span>
</div>
<div class="s-field">
<label>NVIDIA Magpie params</label>
<textarea id="s-tts-extra-nvidia-magpie" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Usually empty. Magpie accepts fixed speaker voices such as sofia, aria, jason, leo, and john.</span>
</div>
<div class="s-field">
<label>NVIDIA Zeroshot params</label>
<textarea id="s-tts-extra-nvidia-zeroshot" spellcheck="false" placeholder='{"zero_shot_quality":20}'></textarea>
<span class="s-hint">Optional multipart fields. The app supplies <code>text</code>, <code>language</code>, and <code>audio_prompt</code>.</span>
</div>
<div class="s-field">
<label>NVIDIA Flow params</label>
<textarea id="s-tts-extra-nvidia-flow" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Optional multipart fields. The app also sends the saved reference transcript.</span>
</div>
</div>
</details>
<details class="settings-details">
<summary>
<span>Voice folders</span>
<small>container paths and Portainer volume mounts</small>
</summary>
<div class="settings-grid">
<div class="s-field">
<label>Voice scan directory</label>
<input type="text" id="s-voices-scan-dir" placeholder="/voices">
<span class="s-hint">Contains <code>active_voices</code>, <code>hidden_voices</code>, sounds, and metadata.</span>
</div>
<div class="s-field">
<label>Active voices directory</label>
<input type="text" id="s-output-dir" placeholder="/voices/active_voices">
<span class="s-hint">New cloned/exported voices are saved here.</span>
</div>
</div>
</details>
<details class="settings-details">
<summary>
<span>API keys</span>
<small>usually empty for local containers</small>
</summary>
<div class="settings-grid three">
<div class="s-field">
<label>TTS API key <span style="font-weight:400">(optional)</span></label>
<div class="s-key-row">
<input type="password" id="s-tts-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-tts-key">&#128065;</button>
</div>
</div>
<div class="s-field">
<label>Voice Design API key <span style="font-weight:400">(optional)</span></label>
<div class="s-key-row">
<input type="password" id="s-vd-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-vd-key">&#128065;</button>
</div>
</div>
<div class="s-field">
<label>Whisper API key <span style="font-weight:400">(optional)</span></label>
<div class="s-key-row">
<input type="password" id="s-whisper-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-whisper-key">&#128065;</button>
</div>
</div>
</div>
</details>
<div class="btn-row settings-actions">
<button class="btn-primary" id="s-save-btn">Save settings</button>
<button class="btn-secondary" id="s-close-btn">Reload settings</button>
</div>
</div>
</div><!-- /tab-settings -->

View File

@ -0,0 +1,36 @@
<div class="section-head">
<span class="section-icon">&#127760;</span>
<div class="section-title">
<h2>Get Voices Online</h2>
<p>Browse public voice clip sources, preview direct audio files, and import voices from the web.</p>
</div>
</div>
<div class="tab-content" id="tab-getvoices">
<div class="card">
<h2>Get voices</h2>
<div class="getvoices-source-editor">
<textarea id="getvoices-sources" spellcheck="false" aria-label="Voice source URLs"></textarea>
<div class="getvoices-source-buttons">
<button class="btn-primary" id="getvoices-refresh-btn">Scrape sources</button>
<button class="btn-secondary" id="getvoices-reset-sources-btn">Reset list</button>
<span id="getvoices-status" class="note">Not scraped yet.</span>
</div>
</div>
<div class="getvoices-toolbar">
<input type="search" id="getvoices-search" placeholder="Search voices, languages, datasets..." autocomplete="off">
<select id="getvoices-source-filter"><option value="all">Source: all</option></select>
<select id="getvoices-language-filter"><option value="all">Language: all</option></select>
<select id="getvoices-gender-filter"><option value="all">Sex: all</option></select>
<select id="getvoices-filetype-filter"><option value="all">Filetype: all</option></select>
<label style="font-size:14px;color:var(--subtext);display:flex;align-items:center;gap:7px;cursor:pointer;white-space:nowrap">
<input type="checkbox" id="getvoices-direct-only"> Direct audio only
</label>
</div>
<p class="note">Edit the source list one URL per line. External sources may block scraping; source errors are shown without hiding successful results. Check each source page for license, consent, and usage rights before importing or publishing a voice.</p>
</div>
<div class="getvoices-summary" id="getvoices-summary"></div>
<div class="getvoices-grid" id="getvoices-list">
<div class="card"><p class="note">Click <strong>Scrape sources</strong> to fetch Aiartes VoiceAI clips, yaph/tts-samples MP3 files, and the jim-schwoebel voice dataset index.</p></div>
</div>
</div><!-- /tab-getvoices -->

View File

@ -0,0 +1,157 @@
<div class="section-head">
<span class="section-icon">&#9654;</span>
<div class="section-title">
<h2>Try It Out</h2>
<p>Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.</p>
</div>
</div>
<!-- TTS Generation Playground -->
<div class="tab-content" id="tab-generation">
<div class="card">
<h2>TTS generation playground</h2>
<p class="note">Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.</p>
</div>
<div class="card">
<h2>Generate speech</h2>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="tts-backend-select"><option value="">Checking backends...</option></select>
</div>
<div class="backend-help" id="tts-backend-help" aria-live="polite">
<strong>Checking available TTS backends...</strong>
</div>
<div class="field">
<label>Backend voice</label>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="fetch-tts-voices-btn">Fetch voices</button>
<select id="tts-voice-select"><option value="">— select after fetch —</option></select>
</div>
</div>
<div class="field">
<label>Playback</label>
<select id="preview-playback-mode">
<option value="settings">Settings default</option>
<option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option>
<option value="buffered">Buffered WAV</option>
</select>
</div>
</div>
<p class="note" style="margin-top:-4px;margin-bottom:10px">After changing active voices, restart the TTS container so the engine reads the updated voice folder.</p>
<div class="preview-match-panel" id="preview-match-panel" hidden>
<div class="preview-match-meta">
<div class="preview-match-title" id="preview-match-title">Reference voice</div>
<div class="preview-match-detail" id="preview-match-detail"></div>
<div class="preview-match-warning" id="preview-match-warning"></div>
<div class="preview-match-actions">
<button class="btn-secondary" id="preview-ref-play" type="button">Play reference WAV</button>
<button class="btn-secondary" id="preview-ref-use-text" type="button">Use reference text</button>
<button class="btn-primary" id="preview-ref-synth" type="button">Synthesize reference text</button>
</div>
</div>
<div class="preview-match-meta">
<div class="preview-match-transcript" id="preview-match-transcript">No reference text saved for this voice.</div>
<audio id="preview-ref-audio" controls></audio>
</div>
</div>
<div class="field">
<label>Target Text (text to synthesize)</label>
<textarea id="preview-text-area" placeholder="Enter the text you want to synthesize&hellip;">Hello! This is a voice preview from TTS Voice Creator - Clone and Design.</textarea>
</div>
<div class="field">
<label>Style Instruction <span style="font-weight:400">(optional)</span></label>
<input type="text" id="preview-style-instruction" placeholder="Optional style/emotion instruction, e.g. speak slowly and calmly, excited tone">
<span class="note">This is sent as <code>instruct</code>. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware.</span>
</div>
<div class="btn-row">
<button class="btn-primary" id="preview-btn">&#9654; Generate &amp; play</button>
<button class="btn-secondary" id="save-preview-mp3-btn" disabled>&#8595; Save MP3</button>
<button class="btn-secondary" id="save-preview-btn" disabled>&#8595; Save WAV</button>
</div>
<audio id="preview-audio" controls style="display:none"></audio>
</div>
</div><!-- /tab-generation -->
<!-- STT -> TTS -->
<div class="tab-content" id="tab-stt-tts">
<div class="card">
<h2>STT &#8594; TTS workspace</h2>
<p class="note">Upload speech audio, transcribe it with the configured STT endpoint, then synthesize the resulting text with any available TTS backend.</p>
</div>
<div class="card">
<h2>Source speech</h2>
<div class="settings-grid">
<div class="s-field">
<label>Speech recognition</label>
<div style="display:flex;gap:8px">
<select id="stt-tts-stt-backend"><option value="configured">Configured Whisper/STT</option></select>
<button class="btn-secondary" id="stt-tts-refresh-stt-btn" type="button">Refresh</button>
</div>
<span class="s-hint" id="stt-tts-stt-help">Uses Settings &rarr; Whisper/STT URL by default.</span>
</div>
<div class="s-field">
<label>Speech audio</label>
<div class="btn-row" style="gap:8px;align-items:center">
<input type="file" id="stt-tts-file" accept="audio/*,video/*">
<button class="btn-secondary" id="stt-tts-rec-start" type="button" title="Record from microphone">&#127908; Record</button>
<button class="btn-secondary" id="stt-tts-rec-stop" type="button" disabled>Stop</button>
<span class="mic-timer" id="stt-tts-rec-time">0:00</span>
</div>
<span class="s-hint">Record or upload speech audio, then transcribe it with the selected recognition engine.</span>
</div>
<div class="s-field">
<label>Source preview</label>
<audio id="stt-tts-source-audio" controls style="display:none;width:100%"></audio>
<span class="s-hint" id="stt-tts-source-status">No source audio loaded.</span>
</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="stt-tts-transcribe-btn" disabled>Transcribe</button>
<button class="btn-secondary" id="stt-tts-copy-preview-btn" disabled>Copy to TTS playground</button>
</div>
<div class="field">
<label>Transcribed text</label>
<textarea id="stt-tts-text" placeholder="Transcribed text appears here. Edit before synthesis if needed."></textarea>
</div>
</div>
<div class="card">
<h2>Synthesize transcription</h2>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="stt-tts-backend-select"><option value="">Checking backends...</option></select>
</div>
<div class="backend-help" id="stt-tts-backend-help" aria-live="polite">
<strong>Checking available TTS backends...</strong>
</div>
<div class="field">
<label>Backend voice</label>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="stt-tts-fetch-voices-btn">Fetch voices</button>
<select id="stt-tts-voice-select"><option value="">-- select after fetch --</option></select>
</div>
</div>
<div class="field">
<label>Playback</label>
<select id="stt-tts-playback-mode">
<option value="settings">Settings default</option>
<option value="streaming">Streaming only</option>
<option value="auto">Stream with fallback</option>
<option value="buffered">Buffered WAV</option>
</select>
</div>
</div>
<div class="field">
<label>Style Instruction <span style="font-weight:400">(optional)</span></label>
<input type="text" id="stt-tts-style-instruction" placeholder="Optional style/emotion instruction">
</div>
<div class="btn-row">
<button class="btn-primary" id="stt-tts-generate-btn">Generate &amp; play</button>
<button class="btn-secondary" id="stt-tts-save-mp3-btn" disabled>Save MP3</button>
<button class="btn-secondary" id="stt-tts-save-wav-btn" disabled>Save WAV</button>
</div>
<audio id="stt-tts-output-audio" controls style="display:none"></audio>
</div>
</div><!-- /tab-stt-tts -->

View File

@ -0,0 +1,250 @@
<div class="section-head">
<span class="section-icon">&#128266;</span>
<div class="section-title">
<h2>My Voice Library</h2>
<p>All your cloned and designed voices in one place. Play, manage, benchmark, and export them.</p>
</div>
</div>
<div class="tab-content" id="tab-library">
<div class="card" style="padding-bottom:8px">
<div class="lib-toolbar">
<h2 style="margin:0">Voice library</h2>
<span id="voice-count" class="note"></span>
<div class="spacer"></div>
</div>
<div class="library-benchmark-panel">
<div class="field">
<label>Sample sentence for generated playback and benchmarks</label>
<textarea id="benchmark-sample-text" placeholder="Sentence every voice should speak for preview and benchmark"></textarea>
</div>
<div class="field play-mode-field">
<label>Select TTS Engine</label>
<select id="library-tts-backend-select"><option value="">Checking backends...</option></select>
</div>
<div class="benchmark-actions volume-actions">
<label class="target-db-field" title="Target volume for volume normalization">
<span>Target dBFS</span>
<input type="number" id="library-target-db" value="-20" min="-60" max="-1" step="0.5" aria-label="Target dBFS">
</label>
<button class="btn-secondary" id="normalize-volume-btn" title="Normalize visible WAV voices to the target volume">Normalize volume</button>
<button class="btn-secondary" id="benchmark-reset-sample-btn">Reset sample</button>
<button class="btn-secondary" id="benchmark-use-preview-btn">Use preview text</button>
</div>
</div>
<div class="benchmark-progress" id="benchmark-progress" hidden aria-live="polite">
<div class="benchmark-progress-head">
<span id="benchmark-progress-label">Benchmarking voices...</span>
<span id="benchmark-progress-count">0 / 0</span>
</div>
<div class="benchmark-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="benchmark-progress-bar"></div>
</div>
<div class="benchmark-live-stats" id="benchmark-live-stats">
<span>Elapsed -</span><span>Avg -</span><span>ETA -</span><span>OK 0</span><span>Slow 0</span><span>Errors 0</span>
</div>
<div class="benchmark-live-last" id="benchmark-live-last"></div>
</div>
<div class="lib-add-panel" id="lib-add-panel">
<div class="lib-add-stack">
<div class="lib-add-section">
<h2 style="margin-bottom:10px">1 Load or record source</h2>
<div class="lib-add-input-grid">
<div class="lib-add-import-box">
<input type="file" id="lib-add-file" accept="audio/*,video/*" style="display:none">
<div class="lib-add-drop" id="lib-add-drop">
<strong>Drop an audio / video file here</strong>
<span>WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; WEBM</span>
<span>or click to browse</span>
</div>
<div>
<label class="note" for="lib-add-url">Audio or video URL</label>
<div class="url-row">
<input type="text" id="lib-add-url" placeholder="Paste a direct MP3/WAV, YouTube, or Aiartes cloned-voice URL">
<button class="btn-primary" id="lib-add-url-btn">Download URL</button>
</div>
</div>
</div>
<div class="lib-add-source-box lib-add-record-box">
<div class="record-head">
<span class="note">Record a fresh sample</span>
<div class="record-head-actions">
<button class="btn-secondary" id="lib-add-mic-help-btn">How to unlock mic</button>
</div>
</div>
<div class="lib-add-recorder-tools">
<div class="lib-add-mic-row">
<div class="mic-buttons">
<button class="btn-secondary" id="lib-add-monitor-btn">Check level</button>
<button class="btn-secondary" id="lib-add-monitor-stop" disabled>Stop monitor</button>
<button class="btn-red" id="lib-add-rec-start">&#9679; Record</button>
<button class="btn-secondary" id="lib-add-rec-stop" disabled>&#9632; Stop</button>
<span class="mic-timer" id="lib-add-rec-time">0:00</span>
</div>
<div class="mic-monitor-box">
<div class="mic-monitor-head">
<span>Input level</span>
<span class="meter-readout" id="lib-add-db-readout">-&#8734; dB</span>
</div>
<div class="mic-meter" id="lib-add-mic-meter" aria-hidden="true"></div>
<div class="mic-gain-row">
<label for="lib-add-mic-gain">Mic gain</label>
<input id="lib-add-mic-gain" type="range" min="0" max="2" step="0.05" value="1">
<span class="mic-gain-value" id="lib-add-mic-gain-value">1.00x</span>
</div>
<div class="note" style="margin-top:5px">Best peaks: -18 to -9 dB, never red.</div>
</div>
</div>
<div class="sample-read-box">
<div class="sample-head">
<label for="lib-add-sample-lang">Read sample</label>
<select id="lib-add-sample-lang">
<option value="EN">English</option>
<option value="DE">Deutsch</option>
<option value="IT">Italiano</option>
<option value="ES">Espa&ntilde;ol</option>
<option value="FR">Fran&ccedil;ais</option>
<option value="PT">Portugu&ecirc;s</option>
<option value="NL">Nederlands</option>
<option value="PL">Polski</option>
</select>
</div>
<textarea class="sample-sentence" id="lib-add-sample-text" spellcheck="true"></textarea>
<div class="sample-actions">
<button class="btn-secondary" id="lib-add-use-sample">Use as transcript</button>
<button class="btn-secondary" id="lib-add-reset-sample">Reset sentence</button>
<span class="note">Use this as the spoken script if you record.</span>
</div>
<div class="recording-tips">
<span class="recording-tip">Quiet room</span>
<span class="recording-tip">20 cm from mic</span>
<span class="recording-tip">No clipping</span>
<span class="recording-tip">Natural pace</span>
</div>
</div>
</div>
<div class="mic-help-panel" id="lib-add-mic-help">
<strong>If the microphone is blocked:</strong>
<ul>
<li><strong>Chrome, Brave, Edge:</strong> click the lock/tune icon in the address bar, set Microphone to Allow, then reload.</li>
<li><strong>Firefox:</strong> click the microphone or lock icon in the address bar, remove Blocked or choose Allow, then reload.</li>
<li><strong>Safari:</strong> open Safari Settings, Websites, Microphone, then allow this site.</li>
<li><strong>Requested device not found:</strong> choose or enable a microphone in your OS input settings, then reload.</li>
<li>Browsers require localhost or HTTPS for microphone access.</li>
</ul>
</div>
<div class="opt-status" id="lib-add-status">Load a file, paste a URL, or record a sample. Then trim, name, and save the voice.</div>
</div>
</div>
<div class="lib-add-audio-row">
<audio id="lib-add-audio" controls style="display:none"></audio>
<div class="btn-row">
<button class="btn-secondary" id="lib-add-auto-trim">Auto trim</button>
<button class="btn-primary" id="lib-add-save-crop">Crop selection</button>
<button class="btn-secondary" id="lib-add-play">Play crop</button>
</div>
</div>
<canvas class="opt-wave" id="lib-add-wave" style="display:none;margin-top:10px"></canvas>
<div class="opt-controls">
<div class="opt-field"><label>Start</label><input id="lib-add-start" type="number" step="0.01" value="0"></div>
<div class="opt-field"><label>End</label><input id="lib-add-end" type="number" step="0.01" value="0"></div>
<button class="btn-primary" id="lib-add-save-crop-bottom">Crop selection</button>
<span class="crop-duration-hint" id="lib-add-crop-hint">Select 3-20 seconds for best cloning.</span>
</div>
</div>
<div class="lib-add-section">
<h2 style="margin-bottom:10px">2 Name and save voice</h2>
<div class="lib-add-name-grid">
<div class="opt-field"><label>Language</label><select id="lib-add-lang">
<option value="EN">EN</option><option value="DE">DE</option><option value="IT">IT</option>
<option value="ES">ES</option><option value="FR">FR</option><option value="PT">PT</option>
<option value="NL">NL</option><option value="PL">PL</option>
</select></div>
<div class="opt-field"><label>Gender</label><input id="lib-add-gender" value="N"></div>
<div class="opt-field"><label>Voice ID</label><input id="lib-add-voice-id" placeholder="EN_N_NewVoice"></div>
</div>
<textarea id="lib-add-transcript" placeholder="Reference transcript: paste or recognise the exact words spoken in the source" style="margin-top:10px"></textarea>
<div class="btn-row" style="margin-top:10px">
<button class="btn-secondary" id="lib-add-recognize">Recognise text</button>
<button class="btn-green" id="lib-add-save">Save voice</button>
</div>
</div>
</div>
</div>
<div class="library-control-panel" id="library-filter-panel">
<div class="field">
<label>Filter voices</label>
<input type="search" id="library-filter-text" placeholder="Search name, reference, note..." autocomplete="off">
</div>
<div class="field">
<label>Language</label>
<select id="library-filter-lang"><option value="">All languages</option></select>
</div>
<div class="field">
<label>Sex</label>
<select id="library-filter-sex"><option value="">All</option></select>
</div>
<div class="field">
<label>Filetype</label>
<select id="library-filter-type"><option value="">All</option></select>
</div>
<div class="field">
<label>Rating</label>
<select id="library-filter-rating"><option value="">Any</option><option value="5">5 stars</option><option value="4">4+ stars</option><option value="3">3+ stars</option><option value="1">Rated</option><option value="0">Unrated</option></select>
</div>
<button class="btn-secondary" id="library-clear-filters" type="button">Clear filters</button>
</div>
<div class="library-list-title"><strong>Voices</strong></div>
<div class="library-action-row" aria-label="Voice library actions">
<div class="voice-action-group action-add">
<button class="btn-primary" id="add-new-voice-btn">Add new voice</button>
</div>
<div class="voice-action-group action-refresh">
<button class="btn-secondary" id="refresh-voices-btn">&#8635; Refresh</button>
<button class="btn-secondary" id="sync-voice-folders-btn">Sync folders</button>
</div>
<div class="voice-action-group action-db">
<button class="btn-secondary" id="calculate-db-btn" title="Calculate dBFS for each voice">Calc dB</button>
</div>
<div class="voice-action-group action-benchmark">
<button class="btn-secondary" id="benchmark-voices-btn" title="Measure TTS synthesis speed for active voices">Benchmark</button>
</div>
<div class="voice-action-group action-copy">
<button class="btn-secondary" id="copy-active-voices-btn" title="Copy active voices as comma-separated names">Copy active voices</button>
</div>
<label class="show-disabled-control action-show-disabled">
<input type="checkbox" id="show-disabled-cb"> Show disabled
</label>
</div>
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
<strong id="benchmark-confirm-title">Benchmark active voices?</strong>
<span id="benchmark-confirm-text"></span>
<button class="btn-secondary" id="benchmark-confirm-cancel" type="button">Cancel</button>
<button class="benchmark-confirm-start" id="benchmark-confirm-start" type="button">Start benchmark</button>
</div>
<div class="disabled-info" id="disabled-info" style="display:none">
<strong>About disabled voices:</strong> The Active toggle moves the complete voice package between
<code>active_voices</code> and <code>hidden_voices</code>. Qwen3-TTS should scan only <code>active_voices</code>.
To permanently remove a voice, delete its <code>.wav</code> and <code>.reference.txt</code> files directly.
</div>
<div class="vl-grid vl-header">
<div data-sort="has_picture" title="Sort by photo">Image</div>
<div data-sort="id" title="Sort by language, sex, and name" class="sort-asc">Language / Sex / Name</div>
<div data-sort="file_type" title="Sort by file type">Filetype</div>
<div data-sort="duration" title="Sort by audio length">Length</div>
<div data-sort="dbfs" title="Sort by loudness">dB</div>
<div data-sort="benchmark" title="Sort by synthesis benchmark">Benchmark</div>
<div data-sort="rating" title="Sort by rating">Rating</div>
<div class="no-sort" title="Original and synthesized playback">Play / Pause</div>
<div class="no-sort" title="Edit audio">Edit</div>
</div>
<div id="voice-list" style="display:flex;flex-direction:column;gap:4px;flex:1;min-height:260px;overflow-y:auto;padding-right:2px"></div>
<div class="library-insights" id="library-insights" aria-live="polite"></div>
<div id="lib-audio-bar" style="display:none">
<div class="lib-audio-label" id="lib-audio-label">-</div>
<audio id="lib-audio" controls></audio>
</div>
</div>
</div><!-- /tab-library -->

704
static/style.css Normal file
View File

@ -0,0 +1,704 @@
<style>
/* ── Theme tokens (warm cream light theme) ──────────────────────────────── */
:root {
--bg: #F5F3EE;
--surface: #FFFFFF;
--panel: #F3F4F6;
--border: #E5E7EB;
--text: #111827;
--subtext: #6B7280;
--accent: #2563EB;
--green: #16A34A;
--red: #DC2626;
--yellow: #D97706;
--mauve: #7C3AED;
--teal: #0D9488;
--on-accent:#FFFFFF;
--radius: 8px;
--font: 'Segoe UI', system-ui, sans-serif;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--sidebar-width: 220px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text); font-family: var(--font);
font-size: 16px; height: 100vh; display: flex; flex-direction: column;
overflow: hidden;
}
/* ── App shell ───────────────────────────────────────────────────────────── */
#app-shell {
display: flex; flex: 1; min-height: 0; overflow: hidden;
}
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
#sidebar {
width: var(--sidebar-width); flex-shrink: 0;
background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden;
box-shadow: 2px 0 12px rgba(0,0,0,0.06);
}
.sidebar-brand {
padding: 18px 16px 14px;
border-bottom: 1px solid var(--border);
}
.sidebar-brand h1 {
font-size: 15px; font-weight: 800; color: var(--accent);
letter-spacing: -0.01em; line-height: 1.2;
}
.sidebar-brand p {
font-size: 11px; color: var(--subtext); margin-top: 3px;
}
.sidebar-nav { flex: 1; padding: 8px 0; }
.nav-group-label {
padding: 10px 16px 4px;
font-size: 10px; font-weight: 800; color: var(--subtext);
text-transform: uppercase; letter-spacing: 0.09em;
}
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 16px; cursor: pointer; color: var(--subtext);
font-size: 13.5px; font-weight: 500;
transition: background 0.12s, color 0.12s;
border-right: 3px solid transparent;
user-select: none;
}
.nav-item:hover { background: var(--panel); color: var(--text); }
.nav-item.active {
background: rgba(37,99,235,0.08);
color: var(--accent); font-weight: 700;
border-right-color: var(--accent);
}
.nav-icon { font-size: 15px; width: 18px; text-align: center; flex-shrink: 0; }
.sidebar-footer {
padding: 12px 16px; border-top: 1px solid var(--border);
display: flex; gap: 8px; align-items: center;
}
.sidebar-footer button {
background: var(--panel); border: 1px solid var(--border); color: var(--subtext);
border-radius: var(--radius); padding: 6px 10px; font-size: 13px; cursor: pointer;
flex: 1; transition: background 0.12s, color 0.12s;
}
.sidebar-footer button:hover { background: var(--border); color: var(--text); }
/* ── Main content ────────────────────────────────────────────────────────── */
#main-content {
flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 0 28px 80px; min-width: 0;
}
/* ── Page sections ───────────────────────────────────────────────────────── */
.page-section { scroll-margin-top: 12px; padding-top: 28px; }
.section-head {
display: flex; align-items: center; gap: 14px;
padding-bottom: 14px; border-bottom: 2px solid var(--border);
margin-bottom: 20px;
}
.section-icon { font-size: 26px; line-height: 1; }
.section-title h2 {
font-size: 20px; font-weight: 800; color: var(--text);
letter-spacing: -0.02em; margin: 0;
}
.section-title p {
font-size: 13px; color: var(--subtext); margin: 3px 0 0; line-height: 1.4;
}
/* ── Force all tab-content visible ──────────────────────────────────────── */
.tab-content {
display: flex !important; flex-direction: column; gap: 18px; padding: 0;
}
#tab-library { min-height: 0; }
/* ── Cards ──────────────────────────────────────────────────────────────── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px;
display: flex; flex-direction: column; gap: 14px;
box-shadow: var(--shadow); min-width: 0; max-width: 100%;
}
.card h2 {
font-size: 12px; font-weight: 700; color: var(--subtext);
text-transform: uppercase; letter-spacing: .09em;
}
/* ── Drop zone ──────────────────────────────────────────────────────────── */
#drop-zone {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 44px 20px; text-align: center; cursor: pointer; color: var(--subtext);
transition: border-color .2s, background .2s; font-size: 15px;
}
#drop-zone.drag-over { border-color: var(--accent); background: rgba(37,99,235,.05); color: var(--accent); }
#drop-zone span { display: block; font-size: 13px; margin-top: 9px; }
#drop-zone input[type=file] { display: none; }
/* ── Buttons ────────────────────────────────────────────────────────────── */
button {
cursor: pointer; border: none; border-radius: var(--radius);
padding: 9px 18px; font-size: 14px; font-family: inherit;
transition: opacity .15s, filter .15s;
}
button:disabled { opacity: .4; cursor: default; }
button:not(:disabled):hover { filter: brightness(0.95); }
.btn-primary { background: var(--accent); color: var(--on-accent); font-weight: 600; }
.btn-secondary { background: var(--panel); color: var(--text); border: 1px solid var(--border); }
.btn-green { background: var(--green); color: var(--on-accent); font-weight: 600; }
.btn-red { background: var(--red); color: var(--on-accent); font-weight: 600; }
.btn-teal { background: var(--teal); color: var(--on-accent); font-weight: 600; }
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
/* ── Waveform ───────────────────────────────────────────────────────────── */
#waveform-wrap { background: var(--panel); border-radius: var(--radius); overflow: hidden; }
#waveform { min-height: 90px; }
.time-row { display: flex; gap: 18px; align-items: center; flex-wrap: wrap; }
.time-row label { color: var(--subtext); font-size: 13px; display: flex; align-items: center; gap: 7px; }
.time-row input[type=number] {
background: var(--panel); border: 1px solid var(--border); color: var(--text);
border-radius: 4px; padding: 6px 10px; width: 100px; font-size: 14px;
}
#trim-duration { font-size: 14px; padding: 6px 14px; border-radius: 4px; font-weight: 600; }
.dur-ok { background: rgba(22,163,74,.12); color: var(--green); }
.dur-warn { background: rgba(217,119,6,.12); color: var(--yellow); }
.dur-bad { background: rgba(220,38,38,.12); color: var(--red); }
/* ── URL row ────────────────────────────────────────────────────────────── */
.url-row { display: flex; gap: 8px; }
.url-row input[type=text] {
flex: 1; background: var(--panel); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius); padding: 9px 14px; font-size: 14px;
}
input::placeholder, textarea::placeholder { color: var(--border); }
#yt-progress { font-size: 13px; color: var(--subtext); min-height: 1.4em; font-family: monospace; }
/* ── Recording ──────────────────────────────────────────────────────────── */
#rec-indicator { display: none; align-items: center; gap: 8px; color: var(--red); font-size: 14px; }
#rec-indicator.active { display: flex; }
#rec-dot { width: 10px; height: 10px; background: var(--red); border-radius: 50%; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
/* ── Voice ID field ─────────────────────────────────────────────────────── */
.voice-id-row input[type=text] {
flex: 1; background: var(--panel); border: 1px solid var(--border); color: var(--text);
border-radius: var(--radius); padding: 10px 14px; font-size: 15px; font-family: monospace;
font-weight: 600; width: 100%;
}
.voice-id-row { display: flex; gap: 8px; }
input.id-valid { border-color: var(--green) !important; }
input.id-invalid { border-color: var(--red) !important; }
/* ── Helper details ─────────────────────────────────────────────────────── */
details.helper { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); }
details.helper summary { padding: 10px 14px; cursor: pointer; font-size: 13px; color: var(--subtext); user-select: none; list-style: none; }
details.helper summary::before { content: '\25B6 '; font-size: 11px; }
details.helper[open] summary::before { content: '\25BC '; }
details.helper .helper-body { padding: 12px 14px 14px; display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 12px; color: var(--subtext); font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
.field select, .field input[type=text] {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: var(--radius); padding: 8px 12px; font-size: 14px;
}
/* ── Textareas ──────────────────────────────────────────────────────────── */
#transcript-area, #preview-text-area, #design-instruct, #design-sample-text {
width: 100%; background: var(--panel); border: 1px solid var(--border); color: var(--text);
border-radius: var(--radius); padding: 10px 14px; font-size: 14px; font-family: inherit; resize: vertical;
}
#transcript-area { min-height: 90px; }
#preview-text-area { min-height: 72px; }
#design-instruct { min-height: 110px; }
#design-sample-text { min-height: 56px; }
audio { width: 100%; }
/* ── Voice Design ───────────────────────────────────────────────────────── */
.design-hints { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12px; color: var(--subtext); }
.design-hints th, .design-hints td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border); }
.design-hints th { color: var(--text); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.design-samples-details summary { cursor: pointer; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--subtext); user-select: none; list-style: none; }
.design-samples-details summary::-webkit-details-marker { display: none; }
.design-samples-details summary::before { content: '\25B6'; color: var(--accent); font-size: 11px; }
.design-samples-details[open] summary::before { content: '\25BC'; }
.design-samples-details summary span:first-child { font-size: 12px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .09em; }
.design-samples-details .design-sample-header { margin-top: 12px; }
.preset-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-top: 10px; }
.preset-row input, .preset-row select { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; }
.design-sample-grid { display: grid; grid-template-columns: minmax(150px,1fr) 52px 86px minmax(220px,1.4fr) minmax(220px,1.4fr) 176px; gap: 8px; align-items: center; }
.design-sample-header { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); }
.design-sample-header div { white-space: nowrap; overflow: hidden; font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; }
.qwen-sample { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 8px 10px; transition: border-color .15s; }
.qwen-sample:hover { border-color: var(--accent); }
.qwen-sample strong { color: var(--accent); font-size: 13px; }
.qwen-sample .sample-sex, .qwen-sample .sample-language { color: var(--subtext); font-size: 12px; font-family: monospace; }
.qwen-sample .sample-desc, .qwen-sample .sample-text { color: var(--subtext); font-size: 12px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.qwen-sample audio { display: none; grid-column: 1 / -1; margin-top: 6px; }
.qwen-sample-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; justify-content: flex-end; }
.lang-select { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; }
.d-naming-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.d-naming-row .field { flex: 0 0 auto; }
.d-naming-row .field.flex1 { flex: 1 1 140px; }
#d-voice-id { font-family: monospace; font-weight: 600; font-size: 15px; color: var(--accent); width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; }
#d-transcript { min-height: 70px; width: 100%; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 10px 14px; font-size: 14px; font-family: inherit; resize: vertical; }
#d-lang, #d-gender, #d-name { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; }
#design-save-result { display: flex; flex-direction: column; gap: 8px; }
#tts-voice-select { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; min-width: 200px; }
.preview-match-panel { margin-top: 12px; border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.04); padding: 12px; display: grid; grid-template-columns: minmax(220px,.75fr) minmax(260px,1fr); gap: 12px; }
.preview-match-panel[hidden] { display: none; }
.preview-match-meta { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.preview-match-title { font-weight: 700; color: var(--text); }
.preview-match-detail { color: var(--subtext); font-size: 12px; line-height: 1.45; }
.preview-match-warning { color: var(--yellow); font-size: 12px; line-height: 1.45; }
.preview-match-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.preview-match-panel audio { width: 100%; min-width: 0; }
.preview-match-transcript { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--subtext); font-family: monospace; font-size: 12px; line-height: 1.4; max-height: 88px; overflow: auto; white-space: pre-wrap; }
@media (max-width: 900px) { .preview-match-panel { grid-template-columns: 1fr; } }
/* ── Settings ───────────────────────────────────────────────────────────── */
#settings-box { display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: none; box-sizing: border-box; }
#settings-box h2 { font-size: 17px; font-weight: 700; }
.settings-section { border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.03); padding: 14px; display: flex; flex-direction: column; gap: 12px; }
.settings-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.settings-section-head h3 { font-size: 13px; color: var(--text); margin: 0; text-transform: uppercase; letter-spacing: .07em; }
.settings-section-head p { font-size: 13px; color: var(--subtext); line-height: 1.45; margin: 4px 0 0; max-width: 980px; }
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; }
.settings-grid.three { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.settings-actions { padding-top: 12px; margin-top: 2px; border-top: 1px solid var(--border); }
.setup-intro { background: rgba(37,99,235,.06); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; color: var(--subtext); font-size: 13px; line-height: 1.55; }
.setup-intro strong { color: var(--text); }
.s-group { font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .09em; padding-top: 6px; border-top: 1px solid var(--border); }
.s-field { display: flex; flex-direction: column; gap: 5px; }
.s-field label { font-size: 13px; color: var(--subtext); font-weight: 500; }
.s-field input, .s-field select, .s-field textarea { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 9px 14px; font-size: 14px; }
.s-field textarea { min-height: 76px; resize: vertical; font-family: monospace; }
.s-hint { font-size: 12px; color: var(--subtext); }
.s-key-row { display: flex; gap: 6px; }
.s-key-row input { flex: 1; }
.s-eye-btn { background: var(--panel); border: 1px solid var(--border); color: var(--subtext); border-radius: var(--radius); padding: 0 13px; font-size: 15px; cursor: pointer; flex-shrink: 0; }
.s-eye-btn:hover { color: var(--text); }
.settings-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
.settings-title-row h2 { margin:0; }
.settings-title-row .note { margin-top:4px; max-width:760px; }
.settings-status-pill { border:1px solid var(--border); background:rgba(22,163,74,.10); color:var(--green); border-radius:999px; padding:5px 10px; font-size:12px; font-weight:700; white-space:nowrap; }
.settings-checklist { display:flex; flex-wrap:wrap; align-items:center; gap:8px 14px; padding:10px 12px; }
.settings-checklist strong { margin-right:2px; }
.settings-checklist span { display:inline-flex; align-items:center; min-height:24px; padding:2px 9px; border:1px solid var(--border); border-radius:999px; background:var(--panel); color:var(--subtext); }
.settings-section { padding:12px; gap:10px; }
.settings-section-primary { background:rgba(37,99,235,.04); }
.settings-section-head h3 { font-size:12px; }
.settings-cluster-grid { display:grid; grid-template-columns: repeat(2, minmax(320px,1fr)); gap:12px; }
.settings-cluster { border:1px solid var(--border); border-radius:7px; background:var(--surface); padding:12px; display:flex; flex-direction:column; gap:10px; min-width:0; }
.settings-cluster-head { display:flex; justify-content:space-between; gap:10px; align-items:baseline; border-bottom:1px solid var(--border); padding-bottom:8px; }
.settings-cluster-head strong { color:var(--text); font-size:13px; text-transform:uppercase; letter-spacing:.07em; }
.settings-cluster-head span { color:var(--subtext); font-size:12px; text-align:right; }
.settings-grid.compact { grid-template-columns: repeat(2, minmax(220px,1fr)); gap:10px; }
.settings-behavior-grid { grid-template-columns: minmax(220px,.7fr) minmax(220px,.7fr) minmax(340px,1.1fr); align-items:start; }
.settings-mini-actions { margin-top:2px; gap:8px; }
.settings-mini-actions button { min-height:34px; padding:7px 12px; }
.settings-details { border:1px solid var(--border); border-radius:7px; background:rgba(37,99,235,.025); overflow:hidden; }
.settings-details summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--text); font-weight:800; text-transform:uppercase; letter-spacing:.07em; font-size:12px; }
.settings-details summary::-webkit-details-marker { display:none; }
.settings-details summary::before { content:'+'; width:20px; height:20px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--border); color:var(--accent); margin-right:2px; flex:0 0 auto; }
.settings-details[open] summary::before { content:'-'; }
.settings-details summary span { display:flex; align-items:center; gap:8px; margin-right:auto; }
.settings-details summary small { color:var(--subtext); font-weight:500; text-transform:none; letter-spacing:0; font-size:12px; }
.settings-details > .settings-grid { padding:0 14px 14px; }
.settings-param-grid textarea { min-height:58px; }
.settings-actions { display:flex; gap:10px; align-items:center; padding:12px 0 0; }
.settings-actions button { min-width:128px; }
.settings-cluster-stt { grid-column: 1 / -1; }
.stt-settings-grid { grid-template-columns: minmax(320px, .65fr) minmax(240px, 1fr); }
.stt-settings-grid .stt-url-field { max-width: 720px; }
@media (max-width: 1100px) { .settings-cluster-grid { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
@media (max-width: 900px) { .stt-settings-grid { grid-template-columns: 1fr; } .stt-settings-grid .stt-url-field { max-width: none; } }
/* ── Toast ──────────────────────────────────────────────────────────────── */
#toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 14px 20px; border-radius: var(--radius); font-size: 14px; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; max-width: 380px; box-shadow: var(--shadow); }
#toast.show { opacity: 1; }
#toast.success { border-color: var(--green); color: var(--green); }
#toast.error { border-color: var(--red); color: var(--red); }
/* ── Status bar ─────────────────────────────────────────────────────────── */
#status-bar { background: var(--surface); border-top: 1px solid var(--border); padding: 5px 18px; font-size: 12px; color: var(--subtext); min-height: 24px; flex-shrink: 0; }
.info-box { background: var(--panel); border-radius: var(--radius); padding: 12px 16px; font-family: monospace; font-size: 13px; color: var(--accent); word-break: break-all; }
hr { border: none; border-top: 1px solid var(--border); }
.note { font-size: 13px; color: var(--subtext); line-height: 1.5; }
code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-family: monospace; font-size: 12px; }
/* ── Library ────────────────────────────────────────────────────────────── */
.lib-toolbar { display: flex; align-items: center; gap: 12px; min-height: 34px; margin-bottom: 6px; }
.lib-toolbar h2 { flex: 0 0 auto; }
.lib-toolbar .spacer { flex: 1 1 auto; }
.show-disabled-control { font-size: 13px; color: var(--subtext); display:flex; align-items:center; gap:7px; cursor:pointer; white-space:nowrap; }
.loading-panel { min-height: 170px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--subtext); padding: 18px; }
.loading-box { width: min(680px,100%); display: flex; flex-direction: column; gap: 13px; }
.loading-head { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 700; font-size: 14px; }
.spinner { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: spin .8s linear infinite; flex: 0 0 auto; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-sub { font-size: 13px; line-height: 1.4; }
.skeleton-stack { display: flex; flex-direction: column; gap: 8px; }
.skeleton-row { height: 38px; border-radius: 6px; border: 1px solid var(--border); background: linear-gradient(90deg, var(--panel), rgba(37,99,235,.06), var(--panel)); background-size: 220% 100%; animation: skeletonSweep 1.15s ease-in-out infinite; }
@keyframes skeletonSweep { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
.library-benchmark-panel { margin-top: 12px; display: grid; grid-template-columns: minmax(260px,1fr) 210px auto; gap: 10px; align-items: end; }
.library-benchmark-panel .field { min-width: 0; }
#benchmark-sample-text { min-height: 62px; resize: vertical; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 10px 14px; font-size: 14px; font-family: inherit; width: 100%; }
.benchmark-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.volume-actions { align-items: flex-end; }
.target-db-field { display: flex; flex-direction: column; gap: 4px; flex: 0 0 112px; color: var(--subtext); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.target-db-field input { width: 112px; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 10px; font-size: 14px; }
.play-mode-field select { width: 100%; }
.benchmark-progress { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); display: flex; flex-direction: column; gap: 7px; }
.benchmark-progress[hidden] { display: none; }
.benchmark-progress-head { display: flex; justify-content: space-between; gap: 12px; color: var(--subtext); font-size: 13px; }
#benchmark-progress-label { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.benchmark-progress-track { height: 8px; background: var(--bg); border-radius: 999px; overflow: hidden; border: 1px solid var(--border); }
#benchmark-progress-bar { height: 100%; width: 0%; background: var(--accent); transition: width .2s ease; }
.benchmark-live-stats { display: grid; grid-template-columns: repeat(6, minmax(72px,1fr)); gap: 6px; font-size: 12px; color: var(--subtext); }
.benchmark-live-stats span { border: 1px solid var(--border); border-radius: 5px; padding: 5px 7px; background: var(--bg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.benchmark-live-last { min-height: 1.3em; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.library-insights { display: grid; grid-template-columns: repeat(6, minmax(100px,1fr)); gap: 6px; margin-top: 5px; }
.insight { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); min-height: 30px; padding: 5px 9px; display:flex; align-items:baseline; gap:8px; }
.insight[data-filter] { cursor: pointer; }
.insight[data-filter]:hover, .insight.active { border-color: var(--accent); background: rgba(37,99,235,.08); }
.insight strong { display: block; color: var(--text); font-size: 14px; line-height: 1.1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width:0; }
.insight span { display: block; color: var(--subtext); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; margin-top: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width:0; line-height: 1.1; }
.library-filter-note { border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.05); color: var(--subtext); padding: 8px 10px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 8px 0; }
.library-filter-note strong { color: var(--text); }
.library-filter-note button { flex: 0 0 auto; }
.library-control-panel { display: grid; grid-template-columns: minmax(220px,.9fr) minmax(280px,1.2fr) repeat(4, minmax(120px,.45fr)) auto; gap: 9px; align-items: end; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: rgba(37,99,235,.035); }
.library-control-panel .field { min-width: 0; }
.library-control-panel input, .library-control-panel select { width: 100%; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 10px; }
.library-list-title { display:flex; justify-content:space-between; align-items:center; gap:12px; padding: 8px 2px 0; color: var(--subtext); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 800; }
.library-list-title strong { color: var(--text); font-size: 13px; }
.library-action-row { display: grid; grid-template-columns: 132px minmax(360px,1.7fr) 60px 70px 86px 112px 132px minmax(130px,.55fr) 54px; gap: 8px; align-items: center; margin: 4px 0 8px; }
.voice-action-group { min-width: 0; display:flex; gap: 8px; align-items:center; }
.voice-action-group button { width:100%; min-width:0; min-height:34px; padding: 7px 10px; white-space:nowrap; }
.action-add { grid-column: 1 / 2; }
.action-refresh { grid-column: 2 / 3; justify-content:flex-start; }
.action-refresh button { flex: 0 0 auto; }
.action-db { grid-column: 5 / 6; }
.action-benchmark { grid-column: 6 / 7; }
.action-copy { grid-column: 7 / 8; justify-content:flex-start; }
.action-copy button { width:auto; flex:0 0 auto; min-width:0; font-size:12px; }
.action-show-disabled { grid-column: 8 / 10; justify-self:end; align-self:center; min-height:34px; padding: 0 4px; margin:0; }
.benchmark-confirm { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 0 132px; padding: 10px 12px; max-width: 760px; border: 1px solid rgba(217,119,6,.45); border-radius: 7px; background: rgba(217,119,6,.07); color: var(--text); box-shadow: var(--shadow); }
.benchmark-confirm[hidden] { display: none; }
.benchmark-confirm strong { color: var(--yellow); font-size: 13px; }
.benchmark-confirm span { color: var(--subtext); font-size: 13px; line-height: 1.35; flex: 1 1 260px; }
.benchmark-confirm button { white-space: nowrap; }
.benchmark-confirm .benchmark-confirm-start { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.disabled-info { background: rgba(37,99,235,.06); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 15px; font-size: 13px; color: var(--subtext); line-height: 1.6; }
.disabled-info strong { color: var(--text); }
/* Voice list rows */
.vl-grid { display: grid; grid-template-columns: 132px minmax(360px,1.7fr) 60px 70px 86px 112px 132px minmax(130px,.55fr) 54px; gap: 8px; align-items: center; }
.vl-header { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); position: sticky; top: 0; z-index: 5; }
.vl-header div { white-space: nowrap; overflow: hidden; font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 3px; transition: color .15s; padding: 2px 0; }
.vl-header div:hover { color: var(--text); }
.vl-header div.sort-asc::after { content: ' ▲'; font-size: 11px; color: var(--accent); font-weight: 900; }
.vl-header div.sort-desc::after { content: ' ▼'; font-size: 11px; color: var(--accent); font-weight: 900; }
.vl-header div.sort-asc, .vl-header div.sort-desc { color: var(--accent); }
.vl-header div.no-sort { cursor: default; }
.vl-header div.no-sort:hover { color: var(--subtext); }
.vl-row { position: relative; min-height: 136px; padding: 12px 10px 10px 132px; overflow: visible; flex: 0 0 auto; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); transition: border-color .15s, background .15s; display: flex; flex-direction: column; gap: 8px; }
.vl-row:hover { border-color: var(--accent); background: rgba(37,99,235,.03); }
.vl-row.vr-disabled { opacity: .42; }
.vl-row.edit-open { gap: 10px; border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,.15) inset; }
.vr-main-row { display: grid; grid-template-columns: minmax(360px,1.7fr) 60px 70px 86px 112px 132px minmax(130px,.55fr) 54px; grid-template-areas: "identity type length db bench rating play edit"; align-items: center; gap: 8px; min-width: 0; }
.vr-photo { position:absolute; left:12px; top:12px; width: 104px; height: 104px; aspect-ratio: 1/1; border-radius: 7px; overflow: hidden; cursor: pointer; background: var(--border); flex-shrink: 0; }
.vr-photo img { width: 100%; height: 100%; object-fit: cover; }
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 34px; color: var(--subtext); }
.vr-photo:hover::after { content: '+'; position: absolute; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; font-size: 20px; color: #fff; font-weight: bold; }
.vr-photo input[type=file] { display: none; }
.vr-identity { grid-area: identity; display:grid; grid-template-columns: 58px 38px minmax(0,1fr); gap:16px; align-items:center; min-width:0; padding-left: 18px; }
.vr-flag { position: relative; cursor: pointer; user-select: none; display: flex; flex-direction: column; align-items: center; gap: 1px; }
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
.vr-flag .flag-code { font-size: 10px; font-family: monospace; font-weight: 700; color: var(--subtext); letter-spacing: .04em; }
.vr-flag:hover .flag-emoji { transform: scale(1.18); }
.flag-picker { position: absolute; top: 100%; left: 0; z-index: 50; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; display: none; flex-wrap: wrap; gap: 4px; width: max-content; max-width: 320px; max-height: 260px; overflow-y: auto; box-shadow: var(--shadow); }
.flag-picker.open { display: flex; }
.flag-opt { display: flex; flex-direction: column; align-items: center; gap: 2px; cursor: pointer; padding: 5px 7px; border-radius: 6px; font-size: 20px; transition: background .1s; }
.flag-opt .fo-code { font-size: 10px; font-family: monospace; font-weight: 700; color: var(--subtext); }
.flag-opt:hover { background: var(--border); }
.flag-opt.active { background: rgba(37,99,235,.15); }
.vr-gender { grid-area: auto; text-align: center; cursor: pointer; user-select: none; line-height: 1; display:flex; align-items:center; justify-content:center; }
.vr-gender span { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:50%; background:rgba(37,99,235,.08); border:1px solid rgba(37,99,235,.22); font-size:28px; font-weight:800; transition: transform .15s, background .15s, border-color .15s; }
.vr-gender span:hover { transform: scale(1.12); background:rgba(37,99,235,.15); border-color:var(--accent); }
.g-f { color: #e91e8c; }
.g-m { color: #0ea5e9; }
.g-n { color: #8b5cf6; }
.vr-name { display: flex; align-items: center; gap: 4px; min-width: 0; }
.vr-name-text { font-weight: 700; color: var(--accent); font-family: monospace; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; cursor: text; }
.vr-name-input { flex: 1; background: var(--bg); border: 1px solid var(--accent); color: var(--text); border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: monospace; min-width: 0; }
.icon-btn { background: none; border: none; padding: 3px 5px; font-size: 14px; color: var(--subtext); border-radius: 4px; cursor: pointer; flex-shrink: 0; }
.icon-btn:hover { background: var(--border); color: var(--text); }
.rename-confirm { display: none; gap: 3px; }
.rename-confirm.show { display: flex; }
.vr-type { grid-area:type; font-family: monospace; font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-length { grid-area:length; font-family: monospace; font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-db { grid-area:db; display: flex; align-items: center; gap: 4px; font-family: monospace; font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-bench { grid-area:bench; display: grid; grid-template-columns: minmax(0,1fr) 26px; gap: 4px; align-items: center; font-family: monospace; font-size: 12px; color: var(--subtext); }
.vr-bench-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vr-bench.bench-ok { color: var(--green); }
.vr-bench.bench-warn { color: var(--yellow); }
.vr-bench.bench-bad { color: var(--red); }
.normalize-voice-btn { width: 24px; height: 24px; padding: 0; border-radius: 6px; background: rgba(37,99,235,.08); border: 1px solid var(--border); color: var(--accent); font-size: 13px; line-height: 1; }
.normalize-voice-btn:hover { border-color: var(--accent); background: rgba(37,99,235,.18); filter: none; }
.vr-edit { grid-area:edit; display: flex; justify-content: flex-end; }
.edit-audio-btn { width: 38px; height: 34px; border-radius: 7px; padding: 0; background: rgba(37,99,235,.10); border: 1px solid var(--border); color: var(--accent); font-size: 22px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.edit-audio-btn:hover { border-color: var(--accent); background: rgba(37,99,235,.18); filter: none; }
.benchmark-one-btn { width: 26px; height: 26px; border-radius: 6px; padding: 0; font-size: 12px; }
.vr-rating { grid-area:rating; display: flex; gap: 2px; font-size: 18px; justify-content:flex-start; align-items:center; min-height: 31px; }
.star { cursor: pointer; color: var(--border); transition: color .1s; }
.star.on { color: var(--yellow); }
.vr-rating:hover .star { color: var(--yellow); }
.vr-rating:hover .star ~ .star { color: var(--border); }
.vr-play { grid-area:play; display:flex; align-items:center; justify-content:flex-end; }
.vr-play-group { display:flex; gap:8px; align-items:center; justify-content:flex-end; }
.vr-play button { width: 40px; height: 40px; border-radius: 50%; background: var(--accent); color: var(--on-accent); font-size: 13px; display: flex; align-items: center; justify-content: center; padding: 0; }
.vr-play-synth button { background: var(--teal); }
.vr-toggle { display: flex; justify-content: center; }
.toggle { position: relative; display: inline-block; width: 42px; height: 24px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.t-slider { position: absolute; cursor: pointer; inset: 0; background: var(--border); border-radius: 24px; transition: .2s; }
.t-slider::before { position: absolute; content: ''; height: 18px; width: 18px; left: 3px; bottom: 3px; background: var(--bg); border-radius: 50%; transition: .2s; }
.toggle input:checked + .t-slider { background: var(--green); }
.toggle input:checked + .t-slider::before { transform: translateX(18px); }
.vr-delete { display: flex; justify-content: flex-end; }
.delete-btn { width: 30px; height: 30px; border-radius: 6px; padding: 0; background: none; border: 1px solid transparent; font-size: 15px; color: var(--subtext); display: flex; align-items: center; justify-content: center; transition: background .15s, border-color .15s, color .15s; }
.delete-btn:hover { background: rgba(220,38,38,.12); border-color: var(--red); color: var(--red); filter: none; }
.delete-confirm { position: absolute; right: 38px; top: 16px; z-index: 20; display: none; align-items: center; gap: 8px; min-width: 270px; max-width: min(380px, calc(100vw - 80px)); padding: 8px 9px; border: 1px solid rgba(220,38,38,.45); border-radius: 7px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.delete-confirm strong { font-size: 12px; color: var(--red); white-space: nowrap; }
.delete-confirm span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; color:var(--subtext); }
.delete-confirm button { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 12px; white-space:nowrap; }
.delete-confirm .delete-confirm-go { background: rgba(220,38,38,.12); border-color: rgba(220,38,38,.45); color: var(--red); }
.vl-row.delete-pending { border-color: rgba(220,38,38,.65); }
.vl-row.delete-pending .delete-confirm { display:flex; }
.vl-row.delete-pending .delete-btn { background: rgba(220,38,38,.12); border-color: var(--red); color: var(--red); }
.vr-detail-row { display:grid; grid-template-columns: minmax(320px,1.35fr) minmax(260px,1fr) 126px 42px; padding-left: 10px; align-items:start; gap: 10px; border-top: 1px solid rgba(37,99,235,.12); padding-top: 8px; }
.vr-detail-active { display:flex; flex-direction:column; align-items:flex-end; gap:3px; min-height:31px; }
.vr-detail-active .vr-active-tools { justify-content:flex-end; width:100%; }
.vr-detail-delete { display:flex; align-items:start; justify-content:flex-end; min-height:31px; padding-top:18px; }
.vr-active-tools { display:flex; gap:10px; align-items:center; }
.vr-ref, .vr-note { display: flex; flex-direction: column; align-items: stretch; gap: 2px; }
.vr-ref .vr-inline, .vr-note .vr-inline { display:flex; gap:6px; align-items:center; min-width:0; }
.vr-ref input { width: 100%; min-width: 0; background: transparent; border: none; border-bottom: 1px solid transparent; color: var(--subtext); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; }
.vr-ref input:focus { border-bottom-color: var(--accent); color: var(--text); }
.vr-ref input::placeholder { color: var(--border); font-style: italic; }
.ref-transcribe-btn { width: 28px; height: 28px; padding: 0; border-radius: 6px; background: none; border: 1px solid transparent; color: var(--subtext); flex-shrink: 0; font-size: 14px; }
.ref-transcribe-btn:hover { background: var(--border); color: var(--text); filter: none; }
.vr-note input { width: 100%; background: transparent; border: none; border-bottom: 1px solid var(--border); color: var(--text); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; }
.vr-note input:focus { border-bottom-color: var(--accent); }
.vr-note input::placeholder { color: var(--border); }
.vr-row-subtle { color: var(--subtext); font-size: 11px; font-family: monospace; white-space: nowrap; overflow:hidden; text-overflow:ellipsis; }
.vr-optimizer { grid-column: 1 / -1; display: none; padding: 12px; border-top: 1px solid var(--border); margin-top: 6px; background: rgba(37,99,235,.04); border-radius: 6px; margin-left: 0; margin-top: 10px; width: 100%; box-sizing: border-box; }
.vl-row.edit-open .vr-optimizer { display: block; }
.optimizer-grid { display: grid; grid-template-columns: minmax(280px,1.2fr) minmax(280px,1fr); gap: 12px; align-items: start; }
.opt-group { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; }
.opt-group-title { font-size: 11px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
.opt-group-note { font-size: 12px; color: var(--subtext); line-height: 1.4; margin-top: 7px; }
.opt-compare-panel { grid-column: 1 / -1; }
.opt-compare-grid { display: grid; grid-template-columns: repeat(2, minmax(240px,1fr)); gap: 10px; }
.opt-compare-card { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.opt-compare-card strong { font-size: 12px; color: var(--text); }
.opt-compare-card audio { width: 100%; }
.opt-style-panel { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(280px,1fr) minmax(260px,.8fr); gap: 10px; }
.opt-style-preview-box { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.opt-style-audio { width: 100%; display: none; }
.backend-help { border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.05); padding: 10px 12px; color: var(--subtext); font-size: 13px; line-height: 1.45; display: grid; gap: 7px; min-width: min(100%, 620px); }
.backend-help strong { color: var(--text); }
.backend-help-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.backend-tag { border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; background: var(--panel); color: var(--subtext); font-size: 12px; }
.backend-tag.good { color: var(--green); border-color: rgba(22,163,74,.35); }
.backend-tag.warn { color: var(--yellow); border-color: rgba(217,119,6,.35); }
.opt-wave { cursor: crosshair; touch-action: none; width: 100%; height: 86px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; display: block; }
.opt-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin-top: 8px; }
.crop-duration-hint { align-self: center; color: var(--subtext); font-size: 12px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
.crop-duration-hint.ok { color: var(--green); border-color: rgba(22,163,74,.35); }
.crop-duration-hint.warn { color: var(--yellow); border-color: rgba(217,119,6,.35); }
.optimizer-workflow { grid-column: 1 / -1; }
.opt-maintenance { grid-column: 1 / -1; }
.opt-field { display: flex; flex-direction: column; gap: 4px; }
.opt-field label { font-size: 10px; color: var(--subtext); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.opt-field input { width: 74px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 6px 8px; font-size: 12px; font-family: monospace; }
.opt-field select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 6px 8px; font-size: 12px; font-family: monospace; }
.opt-field.wide { flex: 1 1 220px; }
.opt-field.wide input, .opt-field.wide select { width: 100%; min-width: 180px; }
.opt-transcript { width: 100%; min-height: 86px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 10px; font-size: 13px; resize: vertical; }
.opt-status { font-size: 12px; color: var(--subtext); min-height: 16px; margin-top: 6px; }
.opt-restart-note { font-size: 12px; color: var(--yellow); align-self: center; }
.opt-restart-note[hidden] { display: none; }
.opt-restart-needed .opt-restart-tts { border-color: var(--yellow); color: var(--yellow); }
/* ── Audio bar ──────────────────────────────────────────────────────────── */
#lib-audio-bar { background: var(--panel); border: 1px solid var(--accent); border-radius: var(--radius); padding: 10px 14px; }
#lib-audio-bar audio { width: 100%; }
#lib-audio-bar .lib-audio-label { font-size: 13px; color: var(--accent); font-family: monospace; margin-bottom: 5px; font-weight: 700; }
/* ── Library add panel ──────────────────────────────────────────────────── */
.lib-add-panel { display: none; border: 1px solid rgba(37,99,235,.3); border-radius: 8px; background: rgba(37,99,235,.04); padding: 16px; margin: 6px 0 12px; }
.lib-add-panel.open { display: block; }
.lib-add-stack { display: flex; flex-direction: column; gap: 16px; }
.lib-add-section { border: 1px solid rgba(37,99,235,.2); border-radius: 8px; background: var(--surface); padding: 16px; }
.lib-add-section > h2 { color: var(--accent); margin-bottom: 14px !important; letter-spacing: .08em; }
.lib-add-input-grid { display: grid; grid-template-columns: minmax(300px,.9fr) minmax(420px,1.1fr); gap: 12px; align-items: stretch; }
.lib-add-import-box { border: 1px solid rgba(37,99,235,.2); border-radius: 8px; background: var(--panel); padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.lib-add-record-box { border: 1px solid rgba(37,99,235,.2); border-radius: 8px; background: var(--panel); padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.lib-add-drop { border: 2px dashed rgba(37,99,235,.4); border-radius: 8px; min-height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; cursor: pointer; color: var(--subtext); text-align: center; padding: 16px; transition: border-color .15s, background .15s, color .15s; background: rgba(37,99,235,.03); }
.lib-add-drop.drag-over { border-color: var(--accent); background: rgba(37,99,235,.08); color: var(--accent); }
.lib-add-drop strong { color: var(--text); font-size: 16px; }
.lib-add-drop span { font-size: 12px; color: var(--subtext); }
.lib-add-source-box { display: flex; flex-direction: column; gap: 10px; }
.lib-add-source-box input[type=text] { width: 100%; background: var(--surface); border: 1px solid rgba(37,99,235,.22); color: var(--text); border-radius: var(--radius); padding: 9px 12px; font-size: 14px; }
.lib-add-source-preview { display: none; align-items: center; gap: 10px; border: 1px solid rgba(37,99,235,.25); border-radius: 8px; background: rgba(37,99,235,.06); padding: 8px 10px; min-width: 0; }
.lib-add-source-preview.open { display: flex; }
.lib-add-source-preview img { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border); background: var(--bg); }
.lib-add-source-preview strong { display: block; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-add-source-preview span { display: block; color: var(--subtext); font-size: 12px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.record-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.record-head .note { font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); }
.record-head-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.record-head-actions button { padding: 6px 10px; font-size: 12px; }
.lib-add-recorder-tools { display: flex; flex-direction: column; gap: 10px; }
.lib-add-mic-row { display: grid; grid-template-columns: minmax(240px,1fr) minmax(260px,.9fr); gap: 10px; align-items: stretch; }
.sample-read-box, .mic-monitor-box { border: 1px solid rgba(37,99,235,.18); border-radius: 6px; background: var(--surface); padding: 12px; }
.mic-monitor-box { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.sample-head, .mic-monitor-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
.sample-head label, .mic-monitor-head span:first-child { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: .06em; }
.sample-head select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 6px 8px; font-size: 13px; min-width: 150px; }
.sample-sentence { width: 100%; min-height: 120px; font-size: 16px; line-height: 1.55; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 6px; padding: 10px 12px; resize: vertical; font-family: inherit; }
.sample-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
.sample-actions button { padding: 6px 10px; font-size: 12px; }
.recording-tips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.recording-tip { font-size: 11px; color: var(--subtext); border: 1px solid rgba(37,99,235,.25); border-radius: 999px; padding: 3px 8px; background: rgba(37,99,235,.07); }
.meter-readout { font-family: monospace; color: var(--subtext); font-size: 12px; white-space: nowrap; }
.mic-meter { display: grid; grid-template-columns: repeat(18, 1fr); gap: 3px; height: 24px; align-items: end; margin-bottom: 8px; }
.mic-meter .bar { height: 7px; border-radius: 3px; background: var(--border); transition: height .08s linear, background .08s linear, opacity .08s linear; opacity: .55; }
.mic-meter .bar.on { opacity: 1; background: var(--green); }
.mic-meter .bar.hot { background: var(--yellow); }
.mic-meter .bar.clip { background: var(--red); }
.mic-buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px,1fr)); gap: 8px; align-content: start; min-width: 0; }
.mic-buttons button { padding-left: 10px; padding-right: 10px; min-height: 44px; }
.mic-timer { font-family: monospace; font-size: 13px; color: var(--subtext); }
.mic-gain-row { display: grid; grid-template-columns: auto minmax(120px,1fr) 42px; gap: 8px; align-items: center; }
.mic-gain-row label { font-size: 12px; color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.mic-gain-row input[type=range] { width: 100%; accent-color: var(--accent); }
.mic-gain-value { font-family: monospace; color: var(--text); font-size: 12px; text-align: right; }
.mic-help-panel { display: none; border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.06); padding: 10px; color: var(--subtext); font-size: 12px; line-height: 1.45; }
.mic-help-panel.open { display: block; }
.mic-help-panel strong { color: var(--text); }
.mic-help-panel ul { margin: 7px 0 0 18px; }
.mic-help-panel li { margin: 4px 0; }
.lib-add-audio-row { display: grid; grid-template-columns: minmax(240px,1fr) auto; gap: 10px; align-items: center; margin-top: 14px; }
.lib-add-panel audio { width: 100%; }
.lib-add-panel .opt-wave { cursor: crosshair; touch-action: none; }
.lib-add-panel textarea { width: 100%; min-height: 80px; background: var(--surface); border: 1px solid rgba(37,99,235,.22); color: var(--text); border-radius: 6px; padding: 8px 10px; font-size: 13px; resize: vertical; }
.lib-add-panel textarea.sample-sentence { min-height: 128px; font-size: 16px; line-height: 1.55; border-left: 3px solid var(--accent); padding: 10px 12px; }
.lib-add-name-grid { display: grid; grid-template-columns: 100px 100px minmax(240px,1fr); gap: 8px; align-items: end; }
.lib-add-name-grid .opt-field input, .lib-add-name-grid .opt-field select { width: 100%; }
#lib-add-status { border-radius: 6px; background: rgba(37,99,235,.07); padding: 8px 10px; color: var(--subtext); font-size: 12px; }
/* ── Design preset library ──────────────────────────────────────────────── */
.design-preset-library { display: flex; flex-direction: column; gap: 6px; }
.design-preset-row { display: grid; grid-template-columns: minmax(120px,.8fr) 70px 90px minmax(180px,1.5fr) minmax(180px,1.4fr) 220px; gap: 8px; align-items: center; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 8px 10px; font-size: 13px; }
.design-preset-row .preset-desc, .design-preset-row .preset-transcript { color: var(--subtext); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.design-preset-row .preset-actions { display: flex; gap: 6px; justify-content: flex-end; }
/* ── Get voices ─────────────────────────────────────────────────────────── */
.getvoices-source-editor { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: stretch; }
.getvoices-source-editor textarea { width: 100%; min-height: 96px; resize: vertical; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 10px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
.getvoices-source-buttons { display: flex; flex-direction: column; gap: 8px; min-width: 132px; }
.getvoices-toolbar { display: grid; grid-template-columns: minmax(160px,1fr) 170px 150px 130px 120px auto; gap: 8px; align-items: center; margin-top: 10px; }
.getvoices-toolbar input, .getvoices-toolbar select { width: 100%; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 9px 12px; }
.getvoices-summary { display: grid; grid-template-columns: repeat(4, minmax(130px,1fr)); gap: 8px; }
.getvoices-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 12px; }
.voice-source-card { border: 1px solid var(--border); border-radius: 7px; background: var(--surface); padding: 12px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.voice-source-head { display: flex; gap: 12px; align-items: flex-start; min-width: 0; }
.voice-source-thumb { width: 72px; height: 72px; border-radius: 7px; object-fit: cover; background: var(--bg); border: 1px solid var(--border); flex: 0 0 auto; }
.voice-source-title { min-width: 0; flex: 1; }
.voice-source-title strong { display: block; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.voice-source-title span { display: block; color: var(--subtext); font-size: 12px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.voice-source-desc { color: var(--subtext); font-size: 13px; line-height: 1.4; min-height: 2.8em; }
.voice-source-card audio { width: 100%; min-height: 32px; }
.voice-source-actions { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.voice-source-actions a, .voice-source-actions button { font-size: 12px; padding: 7px 9px; min-height: 32px; }
.voice-source-pill { border: 1px solid var(--border); color: var(--subtext); border-radius: 999px; padding: 3px 7px; font-size: 12px; }
/* ── Integrations ───────────────────────────────────────────────────────── */
.integration-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.integration-grid { display: flex; flex-direction: column; gap: 12px; }
.integration-card { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 13px; display: flex; flex-direction: column; gap: 10px; width: 100%; }
.integration-card h3 { font-size: 15px; color: var(--text); margin: 0; }
.integration-card p { font-size: 13px; color: var(--subtext); line-height: 1.5; margin: 0; }
.integration-card pre { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 10px 12px; font-size: 12px; line-height: 1.45; overflow: auto; min-height: 118px; max-height: 360px; white-space: pre-wrap; }
.integration-card code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: none; padding: 0; border-radius: 0; }
/* ── Routing ────────────────────────────────────────────────────────────── */
.routing-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.routing-grid { display: grid; grid-template-columns: 42px minmax(104px,.7fr) minmax(92px,.55fr) 82px minmax(112px,.6fr) minmax(140px,1fr) minmax(120px,.65fr) minmax(120px,.65fr) 42px; gap: 8px; align-items: center; }
.routing-header { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); }
.routing-header div { font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; }
.routing-row { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 8px 10px; }
.routing-row input, .routing-row select { width: 100%; min-width: 0; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 7px 8px; font-size: 13px; }
.route-sound-cell { display: grid; grid-template-columns: minmax(0,1fr) auto auto; gap: 5px; align-items: center; }
.route-sound-cell .route-sound-upload, .route-sound-cell .route-sound-pick { min-height: 32px; padding: 6px 8px; font-size: 12px; }
.routing-row .toggle { justify-self: center; }
.routing-delete { width: 34px; height: 32px; padding: 0; }
.routing-help { border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.05); padding: 12px; color: var(--subtext); font-size: 13px; line-height: 1.55; }
.routing-url-warning { display: none; margin-top: 7px; color: var(--yellow); }
.routing-url-warning.show { display: block; }
.routing-test-row { margin-top: 12px; display: grid; grid-template-columns: minmax(120px,.35fr) minmax(110px,.3fr) minmax(220px,1fr) auto; gap: 8px; align-items: stretch; }
.routing-test-row input, .routing-test-row textarea { width: 100%; min-width: 0; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; }
.routing-test-row textarea { min-height: 38px; resize: vertical; }
.routing-test-result { margin-top: 8px; font-size: 13px; color: var(--subtext); line-height: 1.45; }
.routing-test-result.warn { color: var(--yellow); }
.routing-test-result.ok { color: var(--green); }
.routing-log-head { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; }
.routing-log-actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.routing-log-filter { min-height:36px; border:1px solid var(--border); border-radius:6px; background:var(--panel); color:var(--text); padding:0 10px; font:inherit; min-width:170px; }
.routing-log-list { display:flex; flex-direction:column; gap:7px; max-height:340px; overflow:auto; padding-right:4px; }
.routing-log-empty { color:var(--subtext); font-size:13px; padding:10px; border:1px dashed var(--border); border-radius:6px; background:var(--panel); }
.routing-log-entry { display:grid; grid-template-columns: 78px 92px minmax(120px,.75fr) minmax(180px,1.1fr) minmax(150px,.8fr) minmax(220px,1.2fr); gap:8px; align-items:center; border:1px solid var(--border); border-radius:6px; background:var(--surface); padding:8px 10px; font-size:12px; min-width: 860px; }
.routing-log-entry .log-time { color:var(--subtext); font-family:monospace; }
.routing-log-entry .log-app, .routing-log-entry .log-voice { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.routing-log-entry .log-meta, .routing-log-entry .log-text { color:var(--subtext); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.routing-log-badge { justify-self:start; border:1px solid var(--border); border-radius:999px; padding:3px 8px; font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.06em; color:var(--subtext); background:rgba(37,99,235,.06); }
.routing-log-entry.status-ok .routing-log-badge, .routing-log-entry.status-matched .routing-log-badge, .routing-log-entry.status-streaming .routing-log-badge { color:var(--green); }
.routing-log-entry.status-no_match .routing-log-badge { color:var(--yellow); }
.routing-log-entry.status-error .routing-log-badge { color:var(--red); }
.routing-log-scroll { overflow-x:auto; }
.routing-sound-browser-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.routing-sound-browser-actions { display:flex; gap:8px; flex-wrap:wrap; }
.routing-sound-list { display:flex; flex-direction:column; gap:6px; max-height:320px; overflow:auto; padding-right:4px; }
.routing-sound-search { width:100%; background:var(--panel); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:8px 10px; }
.routing-sound-item { display:grid; grid-template-columns:44px minmax(220px,1fr) 90px 96px; gap:8px; align-items:center; border:1px solid var(--border); background:var(--surface); border-radius:6px; padding:8px; }
.routing-sound-item .sound-path { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; font-family:monospace; font-size:12px; }
.routing-sound-item .sound-meta { color:var(--subtext); font-size:12px; }
.routing-sound-item button { min-height:32px; padding:6px 9px; font-size:12px; }
.routing-sound-preview { width:100%; margin-top:8px; }
/* ── Section dividers between subsections ───────────────────────────────── */
.subsection-head {
display: flex; align-items: center; gap: 8px;
padding: 16px 0 8px; border-bottom: 1px solid var(--border); margin-bottom: 12px;
}
.subsection-head h3 {
font-size: 14px; font-weight: 700; color: var(--subtext);
text-transform: uppercase; letter-spacing: .06em; margin: 0;
}
/* ── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.getvoices-source-editor, .getvoices-toolbar, .getvoices-summary { grid-template-columns: 1fr; }
.getvoices-source-buttons { flex-direction: row; min-width: 0; }
.design-sample-grid { grid-template-columns: 1fr; }
.qwen-sample-actions { justify-content: flex-start; }
.design-preset-row, .routing-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
#sidebar { display: none; }
#main-content { padding: 0 12px 60px; }
}
@media (max-width: 1100px) {
.library-control-panel { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.library-insights { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.settings-cluster-grid { grid-template-columns: 1fr; }
.lib-add-input-grid, .lib-add-mic-row { grid-template-columns: 1fr; }
}

376
ttsvc_cli.py Executable file
View File

@ -0,0 +1,376 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import requests
DEFAULT_BASE_URL = "http://localhost:7860"
def die(message: str, code: int = 1) -> None:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(code)
def clean_base(url: str) -> str:
return str(url or DEFAULT_BASE_URL).rstrip("/")
def request(args, method: str, path: str, **kwargs):
url = clean_base(args.base_url) + path
try:
resp = requests.request(method, url, timeout=args.timeout, **kwargs)
except requests.RequestException as exc:
die(f"{method} {url} failed: {exc}")
if not resp.ok:
detail = resp.text
try:
detail = resp.json().get("detail", detail)
except Exception:
pass
die(f"{method} {url} returned HTTP {resp.status_code}: {detail}")
return resp
def get_json(args, path: str):
return request(args, "GET", path).json()
def post_json(args, path: str, payload: dict):
return request(args, "POST", path, json=payload).json()
def print_json(data) -> None:
print(json.dumps(data, indent=2, ensure_ascii=False))
def print_table(rows: list[dict], columns: list[tuple[str, str]]) -> None:
if not rows:
print("(none)")
return
widths = {}
for key, title in columns:
widths[key] = max(len(title), *(len(str(r.get(key, ""))) for r in rows))
print(" ".join(title.ljust(widths[key]) for key, title in columns))
print(" ".join("-" * widths[key] for key, _title in columns))
for row in rows:
print(" ".join(str(row.get(key, "")).ljust(widths[key]) for key, _title in columns))
def cmd_settings(args) -> None:
if args.settings_cmd == "show":
print_json(get_json(args, "/api/settings"))
return
current = get_json(args, "/api/settings")
updates = {
"whisper_url": args.whisper_url,
"whisper_api_key": args.whisper_key,
"tts_url": args.tts_url,
"tts_api_key": args.tts_key,
"tts_backend": args.tts_backend,
"voice_design_url": args.voice_design_url,
"voice_design_api_key": args.voice_design_key,
"nvidia_router_url": args.nvidia_router_url,
"nvidia_tts_url": args.nvidia_tts_url,
"nvidia_asr_url": args.nvidia_asr_url,
"nvidia_clone_url": args.nvidia_clone_url,
"voices_scan_dir": args.voices_scan_dir,
"output_dir": args.output_dir,
}
current.update({k: v for k, v in updates.items() if v is not None})
print_json(post_json(args, "/api/settings", current))
def cmd_voices(args) -> None:
if args.voices_cmd == "list":
voices = get_json(args, "/api/voices")
if not args.all:
voices = [v for v in voices if v.get("enabled") is not False]
if args.json:
print_json(voices)
return
rows = [{
"id": v.get("id", ""),
"lang": v.get("language") or v.get("flag") or "",
"gender": v.get("gender", ""),
"type": v.get("file_type", ""),
"duration": f"{float(v.get('duration') or 0):.1f}",
"db": (v.get("loudness") or {}).get("dbfs", ""),
"bench": (v.get("benchmark") or {}).get("elapsed_sec", ""),
"ttfa": (v.get("benchmark") or {}).get("ttfa_ms", ""),
"speed": (v.get("benchmark") or {}).get("speed", ""),
"active": "yes" if v.get("enabled") is not False else "no",
} for v in voices]
print_table(rows, [("id", "Voice"), ("lang", "Lang"), ("gender", "Sex"), ("type", "Type"), ("duration", "Sec"), ("db", "dB"), ("bench", "Bench"), ("ttfa", "TTFA"), ("speed", "Speed"), ("active", "Active")])
elif args.voices_cmd in {"enable", "disable"}:
print_json(post_json(args, "/api/voice/meta", {"voice_id": args.voice, "enabled": args.voices_cmd == "enable"}))
elif args.voices_cmd == "delete":
if not args.yes:
die("delete needs --yes")
print_json(request(args, "DELETE", f"/api/voice/{args.voice}").json())
elif args.voices_cmd == "rename":
print_json(post_json(args, "/api/voice/rename", {"voice_id": args.voice, "new_id": args.new_id}))
elif args.voices_cmd == "normalize":
payload = {"voice_id": args.voice}
if args.target_dbfs is not None:
payload["target_dbfs"] = args.target_dbfs
print_json(post_json(args, "/api/voice/normalize", payload))
elif args.voices_cmd == "normalize-active":
print_json(post_json(args, "/api/voices/normalize-active", {}))
elif args.voices_cmd == "calculate-db":
print_json(post_json(args, "/api/voices/calculate-db", {}))
elif args.voices_cmd == "benchmark":
payload = {
"active_only": not args.all,
"limit": args.limit,
"text": args.text,
}
if args.voice:
payload["voice_id"] = args.voice
print_json(post_json(args, "/api/voices/benchmark", payload))
elif args.voices_cmd == "sync":
print_json(post_json(args, "/api/voices/sync-folders", {}))
elif args.voices_cmd == "restart-tts":
print_json(post_json(args, "/api/tts/restart", {}))
def load_routes(args) -> list[dict]:
return get_json(args, "/api/tts-routes").get("routes", [])
def save_routes(args, routes: list[dict]) -> None:
print_json(post_json(args, "/api/tts-routes", {"routes": routes}))
def cmd_routes(args) -> None:
routes = load_routes(args)
if args.routes_cmd == "list":
if args.json:
print_json(routes)
return
rows = []
for i, r in enumerate(routes):
rows.append({
"idx": i,
"on": "yes" if r.get("enabled", True) else "no",
"app": r.get("app", "*"),
"input": r.get("input_voice", "*"),
"lang": r.get("language", "*"),
"output": r.get("output_voice", ""),
"before": r.get("before_sound", ""),
"after": r.get("after_sound", ""),
})
print_table(rows, [("idx", "#"), ("on", "On"), ("app", "App"), ("input", "Input"), ("lang", "Lang"), ("output", "Output"), ("before", "Before"), ("after", "After")])
elif args.routes_cmd == "add":
routes.append({
"enabled": not args.disabled,
"app": args.app,
"input_voice": args.input_voice,
"language": args.language.upper(),
"output_voice": args.output_voice,
"before_sound": args.before_sound or "",
"after_sound": args.after_sound or "",
})
save_routes(args, routes)
elif args.routes_cmd == "delete":
if args.index < 0 or args.index >= len(routes):
die(f"route index {args.index} out of range")
del routes[args.index]
save_routes(args, routes)
elif args.routes_cmd == "clear":
if not args.yes:
die("clear needs --yes")
save_routes(args, [])
elif args.routes_cmd == "openwebui-defaults":
if args.en:
routes.append({"enabled": True, "app": "Open WebUI", "input_voice": "default", "language": "EN", "output_voice": args.en})
if args.de:
routes.append({"enabled": True, "app": "Open WebUI", "input_voice": "default", "language": "DE", "output_voice": args.de})
if not args.en and not args.de:
die("provide --en and/or --de output voice")
save_routes(args, routes)
def cmd_tts(args) -> None:
if args.tts_cmd == "voices":
print_json(get_json(args, "/v1/audio/voices"))
return
payload = {
"model": args.model,
"voice": args.voice,
"input": args.text,
"response_format": args.format,
}
resp = request(args, "POST", "/v1/audio/speech", json=payload)
out = Path(args.out)
out.write_bytes(resp.content)
print(f"wrote {out}")
routed = resp.headers.get("x-tts-voice-routed")
requested = resp.headers.get("x-tts-voice-requested")
if routed and requested and routed != requested:
print(f"routed {requested} -> {routed} ({resp.headers.get('x-tts-route-language', '')})")
def cmd_create_voice(args) -> None:
path = Path(args.file)
if not path.exists():
die(f"file not found: {path}")
with path.open("rb") as fh:
upload = request(args, "POST", "/api/upload", files={"file": (path.name, fh)}).json()
start = args.start
end = args.end if args.end is not None else upload.get("duration", 0)
processed = post_json(args, "/api/process", {"id": upload["id"], "start": start, "end": end})
saved = post_json(args, "/api/save", {
"id": processed["id"],
"voice_id": args.voice_id,
"transcript": args.transcript or "",
})
print_json({"upload": upload, "processed": processed, "saved": saved})
def cmd_presets(args) -> None:
presets = get_json(args, "/api/voice-design-presets")
if args.presets_cmd == "list":
print_json(presets)
elif args.presets_cmd == "delete":
presets.pop(args.name, None)
print_json(post_json(args, "/api/voice-design-presets", presets))
elif args.presets_cmd == "save":
presets[args.name] = {
"description": args.description,
"language": args.language,
"gender": args.gender,
"sample_text": args.sample_text,
}
print_json(post_json(args, "/api/voice-design-presets", presets))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ttsvc",
description="Command line control for TTS Voice Creator - Clone and Design.",
)
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=f"Creator app URL, default: {DEFAULT_BASE_URL}")
parser.add_argument("--timeout", type=float, default=120, help="HTTP timeout in seconds")
sub = parser.add_subparsers(dest="cmd", required=True)
settings = sub.add_parser("settings", help="Show or update app settings")
settings_sub = settings.add_subparsers(dest="settings_cmd", required=True)
settings_sub.add_parser("show", help="Print current settings").set_defaults(func=cmd_settings)
settings_set = settings_sub.add_parser("set", help="Update selected settings")
settings_set.add_argument("--whisper-url")
settings_set.add_argument("--whisper-key")
settings_set.add_argument("--tts-url")
settings_set.add_argument("--tts-key")
settings_set.add_argument("--tts-backend", choices=["openai", "localai", "pocket", "nvidia_magpie"])
settings_set.add_argument("--voice-design-url")
settings_set.add_argument("--voice-design-key")
settings_set.add_argument("--nvidia-router-url")
settings_set.add_argument("--nvidia-tts-url")
settings_set.add_argument("--nvidia-asr-url")
settings_set.add_argument("--nvidia-clone-url")
settings_set.add_argument("--voices-scan-dir")
settings_set.add_argument("--output-dir")
settings_set.set_defaults(func=cmd_settings)
voices = sub.add_parser("voices", help="Manage cloned voices")
voices_sub = voices.add_subparsers(dest="voices_cmd", required=True)
voices_list = voices_sub.add_parser("list", help="List voices")
voices_list.add_argument("--all", action="store_true", help="Include disabled voices")
voices_list.add_argument("--json", action="store_true", help="Print raw JSON")
voices_list.set_defaults(func=cmd_voices)
for name in ("enable", "disable", "delete", "normalize"):
p = voices_sub.add_parser(name, help=f"{name.title()} a voice")
p.add_argument("voice")
if name == "delete":
p.add_argument("--yes", action="store_true", help="Confirm deletion")
if name == "normalize":
p.add_argument("--target-dbfs", type=float)
p.set_defaults(func=cmd_voices)
rename = voices_sub.add_parser("rename", help="Rename a voice package")
rename.add_argument("voice")
rename.add_argument("new_id")
rename.set_defaults(func=cmd_voices)
benchmark = voices_sub.add_parser("benchmark", help="Benchmark voice synthesis speed")
benchmark.add_argument("--all", action="store_true", help="Include disabled voices")
benchmark.add_argument("--limit", type=int, default=0, help="Stop after N voices")
benchmark.add_argument("--voice", default="", help="Benchmark one voice ID")
benchmark.add_argument("--text", default="", help="Benchmark sentence")
benchmark.set_defaults(func=cmd_voices)
for name in ("normalize-active", "calculate-db", "sync", "restart-tts"):
voices_sub.add_parser(name, help=name.replace("-", " ").title()).set_defaults(func=cmd_voices)
routes = sub.add_parser("routes", help="Manage /v1 TTS routing rules")
routes_sub = routes.add_subparsers(dest="routes_cmd", required=True)
routes_list = routes_sub.add_parser("list", help="List routing rules")
routes_list.add_argument("--json", action="store_true")
routes_list.set_defaults(func=cmd_routes)
routes_add = routes_sub.add_parser("add", help="Add one route")
routes_add.add_argument("--app", default="Open WebUI")
routes_add.add_argument("--input-voice", default="default")
routes_add.add_argument("--language", default="*", help="*, AUTO, EN, DE, FR, ES, IT, PT, NL, PL")
routes_add.add_argument("--output-voice", required=True)
routes_add.add_argument("--before-sound", help="Audio path inside voices dir to prepend, e.g. sounds/start.wav")
routes_add.add_argument("--after-sound", help="Audio path inside voices dir to append, e.g. sounds/end.wav")
routes_add.add_argument("--disabled", action="store_true")
routes_add.set_defaults(func=cmd_routes)
routes_delete = routes_sub.add_parser("delete", help="Delete route by index")
routes_delete.add_argument("index", type=int)
routes_delete.set_defaults(func=cmd_routes)
routes_clear = routes_sub.add_parser("clear", help="Delete all routes")
routes_clear.add_argument("--yes", action="store_true")
routes_clear.set_defaults(func=cmd_routes)
routes_examples = routes_sub.add_parser("openwebui-defaults", help="Add default EN/DE Open WebUI routes")
routes_examples.add_argument("--en")
routes_examples.add_argument("--de")
routes_examples.set_defaults(func=cmd_routes)
tts = sub.add_parser("tts", help="Use the OpenAI-compatible TTS proxy")
tts_sub = tts.add_subparsers(dest="tts_cmd", required=True)
tts_sub.add_parser("voices", help="List /v1 voices and route aliases").set_defaults(func=cmd_tts)
speak = tts_sub.add_parser("speak", help="Generate speech through /v1/audio/speech")
speak.add_argument("--voice", required=True)
speak.add_argument("--text", required=True)
speak.add_argument("--out", default="speech.wav")
speak.add_argument("--model", default="tts-1")
speak.add_argument("--format", default="wav")
speak.set_defaults(func=cmd_tts)
create = sub.add_parser("create-voice", help="Upload, trim, and save a voice from an audio/video file")
create.add_argument("--file", required=True)
create.add_argument("--voice-id", required=True)
create.add_argument("--transcript", default="")
create.add_argument("--start", type=float, default=0)
create.add_argument("--end", type=float)
create.set_defaults(func=cmd_create_voice)
presets = sub.add_parser("presets", help="Manage Voice Design presets")
presets_sub = presets.add_subparsers(dest="presets_cmd", required=True)
presets_sub.add_parser("list", help="List presets").set_defaults(func=cmd_presets)
preset_save = presets_sub.add_parser("save", help="Create or replace a preset")
preset_save.add_argument("name")
preset_save.add_argument("--description", required=True)
preset_save.add_argument("--language", default="Auto")
preset_save.add_argument("--gender", default="N")
preset_save.add_argument("--sample-text", default="")
preset_save.set_defaults(func=cmd_presets)
preset_delete = presets_sub.add_parser("delete", help="Delete a preset")
preset_delete.add_argument("name")
preset_delete.set_defaults(func=cmd_presets)
return parser
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()