From 631ca16290e45073e811720d36de69b12b9f219d Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 25 May 2026 19:53:01 +0200 Subject: [PATCH] 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 --- .gitignore | 30 + Dockerfile | 36 + README.md | 563 ++++ docker-compose.yml | 35 + guided_installer.py | 398 +++ portainer-stack.yml | 49 + requirements.txt | 8 + server.py | 3942 +++++++++++++++++++++++ start-guided-installer.bat | 5 + start-guided-installer.command | 3 + start-guided-installer.sh | 3 + static/app.js | 5222 +++++++++++++++++++++++++++++++ static/index.html | 94 + static/loader.js | 36 + static/nav.js | 67 + static/sections/s-clone.html | 112 + static/sections/s-connect.html | 63 + static/sections/s-design.html | 160 + static/sections/s-routing.html | 87 + static/sections/s-settings.html | 245 ++ static/sections/s-studio.html | 36 + static/sections/s-tryout.html | 157 + static/sections/s-voices.html | 250 ++ static/style.css | 704 +++++ ttsvc_cli.py | 376 +++ 25 files changed, 12681 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100755 guided_installer.py create mode 100644 portainer-stack.yml create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 start-guided-installer.bat create mode 100755 start-guided-installer.command create mode 100755 start-guided-installer.sh create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/loader.js create mode 100644 static/nav.js create mode 100644 static/sections/s-clone.html create mode 100644 static/sections/s-connect.html create mode 100644 static/sections/s-design.html create mode 100644 static/sections/s-routing.html create mode 100644 static/sections/s-settings.html create mode 100644 static/sections/s-studio.html create mode 100644 static/sections/s-tryout.html create mode 100644 static/sections/s-voices.html create mode 100644 static/style.css create mode 100755 ttsvc_cli.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfb05df --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbcd43a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee957b9 --- /dev/null +++ b/README.md @@ -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://: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:** 5–20 seconds (10–15 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= +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 ` 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_ +``` + +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 | **5–20 seconds** | +| Companion file | `.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 | +|---|---| +| `.reference.txt` | Transcript of the reference audio (required for voice cloning) | +| `.meta.json` | Note, star rating, flag, gender, enabled state | +| `.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 +``` + +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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99e983b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/guided_installer.py b/guided_installer.py new file mode 100755 index 0000000..b790af3 --- /dev/null +++ b/guided_installer.py @@ -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() diff --git a/portainer-stack.yml b/portainer-stack.yml new file mode 100644 index 0000000..d8291a6 --- /dev/null +++ b/portainer-stack.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b34ccab --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/server.py b/server.py new file mode 100644 index 0000000..f0fcb21 --- /dev/null +++ b/server.py @@ -0,0 +1,3942 @@ +"""TTS Voice Creator - Clone and Design — FastAPI backend""" +from __future__ import annotations + +import asyncio +import glob +import ipaddress +import io +import json +import logging +import os +import re +import shutil +import socket +import struct +import subprocess +import tempfile +import time +import uuid +import wave +from datetime import datetime, timezone +from html import unescape +from pathlib import Path +from typing import AsyncGenerator +from urllib.parse import quote, urljoin, urlparse, urlsplit + +import requests +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, Response, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydub import AudioSegment + +logger = logging.getLogger("uvicorn.error") + +# ── Boot-time defaults ──────────────────────────────────────────────────────── + +_VOICES_DIR_DEFAULT = os.environ.get("VOICES_DIR", "/voices") +_OUTPUT_DIR_DEFAULT = os.environ.get("OUTPUT_DIR", "/voices/active_voices") +_WHISPER_DEFAULT = os.environ.get("WHISPER_URL", "http://host.docker.internal:8010") +_TTS_DEFAULT = os.environ.get("TTS_URL", "http://host.docker.internal:8020") +_TTS_STREAM_DEFAULT = os.environ.get("TTS_STREAM_URL", "http://host.docker.internal:8023") +_CUSTOMVOICE_DEFAULT = os.environ.get("CUSTOMVOICE_URL", "http://host.docker.internal:8022") +_VOICE_DESIGN_DEFAULT = os.environ.get("VOICE_DESIGN_URL", "http://host.docker.internal:8021") +_NVIDIA_ROUTER_DEFAULT = os.environ.get("NVIDIA_SPEECH_ROUTER_URL", "http://host.docker.internal:8090") +_NVIDIA_TTS_DEFAULT = os.environ.get("NVIDIA_MAGPIE_TTS_URL", "http://host.docker.internal:8091") +_NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://host.docker.internal:8092") +_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.docker.internal:8093") +_NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT) +_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094") +_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") +_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") +_VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0")) +_VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0")) +_MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_MB", "1024")) * 1024 * 1024 +_MAX_PICTURE_BYTES = int(os.environ.get("MAX_PICTURE_MB", "10")) * 1024 * 1024 +_MAX_SOUND_BYTES = int(os.environ.get("MAX_SOUND_MB", "25")) * 1024 * 1024 +_MAX_TTS_OUTPUT_SECONDS = float(os.environ.get("MAX_TTS_OUTPUT_SECONDS", "30")) +_STT_REQUEST_TIMEOUT = float(os.environ.get("STT_REQUEST_TIMEOUT", os.environ.get("REQUEST_TIMEOUT", "900"))) +_BENCHMARK_TEXT = os.environ.get("VOICE_BENCHMARK_TEXT", "This is a short realtime voice benchmark.") +_BENCHMARK_SENTENCES = [ + ("short", _BENCHMARK_TEXT), + ("medium", "The quick brown fox jumps over the lazy dog near the river bank."), + ( + "long", + "Artificial intelligence is transforming the way we interact with technology. " + "From voice assistants to autonomous vehicles, machine learning models are becoming " + "an integral part of everyday life.", + ), +] +_ALLOW_PRIVATE_DOWNLOADS = os.environ.get("ALLOW_PRIVATE_DOWNLOADS", "").lower() in {"1", "true", "yes"} + +def _default_config_dir() -> Path: + env_dir = os.environ.get("CONFIG_DIR") + if env_dir: + return Path(env_dir) + current = Path("/home/app/.config/tts-voice-creator") + legacy = Path("/home/app/.config/voice-clone-factory") + if legacy.exists(): + legacy.mkdir(parents=True, exist_ok=True) + if current.exists(): + for name in ("settings.json", "voice_design_presets.json", "tts_routes.json"): + src, dst = current / name, legacy / name + if src.exists() and not dst.exists(): + try: + shutil.copy2(src, dst) + except Exception as exc: + logger.warning("Could not migrate config %s to mounted config dir: %s", name, exc) + return legacy + return current + + +CONFIG_DIR = _default_config_dir() +CONFIG_FILE = CONFIG_DIR / "settings.json" +DESIGN_PRESETS_FILE = CONFIG_DIR / "voice_design_presets.json" +TTS_ROUTES_FILE = CONFIG_DIR / "tts_routes.json" + +# ── Temp file registry ──────────────────────────────────────────────────────── + +TEMP_DIR = Path(tempfile.mkdtemp(prefix="vcf_")) +_registry: dict[str, Path] = {} + +app = FastAPI(title="TTS Voice Creator - Clone and Design") + +_ROUTING_LOG_MAX = int(os.environ.get("TTS_ROUTING_LOG_MAX", "120")) +_routing_log: list[dict] = [] + + +def _routing_log_add(**entry) -> None: + item = { + "ts": datetime.now(timezone.utc).isoformat(), + **entry, + } + _routing_log.insert(0, item) + del _routing_log[_ROUTING_LOG_MAX:] + + +def _routing_log_request( + request: Request, + *, + status: str, + app: str, + requested_voice: str, + routed_voice: str, + backend: str, + route: dict | None, + response_format: str, + text: str, + **extra, +) -> None: + route = route or {} + _routing_log_add( + kind="proxy", + status=status, + app=app, + requested_voice=requested_voice, + routed_voice=routed_voice, + backend=backend, + language=str(route.get("detected_language", "")) or (_detect_text_language(text) if text else ""), + matched=bool(route), + route_id=str(route.get("id", "")), + response_format=response_format, + text_preview=text[:160], + client=request.client.host if request.client else "", + user_agent=str(request.headers.get("user-agent", ""))[:160], + **extra, + ) + + +def _safe_child_path(root: Path, candidate: Path) -> Path: + root_resolved = root.resolve() + candidate_resolved = candidate.resolve() + try: + candidate_resolved.relative_to(root_resolved) + except ValueError: + raise HTTPException(403, "Access denied") + return candidate_resolved + + +def _validate_http_url(raw: str, *, allow_private: bool = True) -> str: + raw = str(raw or "").strip() + if not raw: + raise HTTPException(400, "URL is required") + parts = urlsplit(raw) + if parts.scheme not in {"http", "https"}: + raise HTTPException(400, "Only http:// and https:// URLs are allowed") + if not parts.hostname: + raise HTTPException(400, "URL must include a hostname") + if parts.username or parts.password: + raise HTTPException(400, "URLs with embedded credentials are not allowed") + if allow_private: + return raw + try: + infos = socket.getaddrinfo(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80), type=socket.SOCK_STREAM) + except socket.gaierror: + raise HTTPException(400, "URL hostname could not be resolved") + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved: + raise HTTPException(400, "Private, local, and reserved network URLs are not allowed for downloads") + return raw + + +def _copy_limited(src, dest, limit: int) -> int: + total = 0 + while True: + chunk = src.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > limit: + raise HTTPException(413, "Uploaded file is too large") + dest.write(chunk) + return total + + +def _docker_post(path: str) -> tuple[int, str]: + sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") + if not Path(sock_path).exists(): + raise RuntimeError(f"Docker socket not found: {sock_path}") + + request = ( + f"POST {path} HTTP/1.1\r\n" + "Host: docker\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n" + ).encode("utf-8") + + chunks = [] + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(20) + sock.connect(sock_path) + sock.sendall(request) + while True: + data = sock.recv(8192) + if not data: + break + chunks.append(data) + + raw = b"".join(chunks).decode("utf-8", errors="replace") + status_line = raw.splitlines()[0] if raw else "" + match = re.match(r"HTTP/\S+\s+(\d+)", status_line) + if not match: + raise RuntimeError("Invalid Docker API response") + return int(match.group(1)), raw + +# ── Settings ────────────────────────────────────────────────────────────────── + +_SETTINGS_KEYS = { + "whisper_url", "tts_url", "tts_stream_url", "tts_stream_mode", "tts_backend", + "output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url", + "nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url", + "nvidia_zeroshot_url", "nvidia_flow_url", + "whisper_api_key", "tts_api_key", "voice_design_api_key", + "tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend", +} + + + +_TTS_STABILITY_DEFAULT = {"temperature": 0.1, "top_p": 0.8, "seed": 0} +_TTS_STABILITY_BY_BACKEND_DEFAULT = { + "voice_clone": dict(_TTS_STABILITY_DEFAULT), + "streaming": dict(_TTS_STABILITY_DEFAULT), + "customvoice": dict(_TTS_STABILITY_DEFAULT), + "voice_design": dict(_TTS_STABILITY_DEFAULT), + "nvidia_magpie": {}, + "nvidia_zeroshot": {}, + "nvidia_flow": {}, +} +_TTS_PAYLOAD_CORE_KEYS = {"model", "input", "voice", "response_format", "instruct", "language"} + + +def _settings_bool(value, default: bool = True) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() not in {"0", "false", "off", "no"} + + +def _clean_tts_param_dict(raw, fallback: dict | None = None) -> dict: + if raw in (None, ""): + raw = fallback or {} + if isinstance(raw, str): + try: + raw = json.loads(raw) + except Exception: + raw = fallback or {} + if not isinstance(raw, dict): + return {} + params = {} + for key, value in raw.items(): + if key in _TTS_PAYLOAD_CORE_KEYS or str(key).startswith("_"): + continue + if isinstance(value, (str, int, float, bool)) or value is None: + params[str(key)] = value + return params + + +def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict: + if not _settings_bool(settings.get("tts_stability_enabled"), True): + return {} + backend = _clean_preview_backend(backend) if "_clean_preview_backend" in globals() else str(backend or "voice_clone") + by_backend = settings.get("tts_extra_params_by_backend") + if isinstance(by_backend, str): + try: + by_backend = json.loads(by_backend) + except Exception: + by_backend = None + if isinstance(by_backend, dict): + fallback = _TTS_STABILITY_BY_BACKEND_DEFAULT.get(backend, _TTS_STABILITY_DEFAULT) + if backend in by_backend: + return _clean_tts_param_dict(by_backend.get(backend), fallback) + return _clean_tts_param_dict(fallback, fallback) + return _clean_tts_param_dict(settings.get("tts_extra_params", _TTS_STABILITY_BY_BACKEND_DEFAULT.get(backend, _TTS_STABILITY_DEFAULT)), _TTS_STABILITY_BY_BACKEND_DEFAULT.get(backend, _TTS_STABILITY_DEFAULT)) + + +def _apply_tts_extra_params(payload: dict, settings: dict, backend: str = "voice_clone") -> dict: + params = _tts_extra_params(settings, backend) + if params: + payload.update(params) + return payload + + +def _strip_tts_extra_params(payload: dict) -> dict: + return {k: v for k, v in payload.items() if k in _TTS_PAYLOAD_CORE_KEYS} + + +def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response: + resp = requests.post(endpoint, json=payload, headers=headers, **kwargs) + fallback = _strip_tts_extra_params(payload) + if resp.status_code in {400, 404, 415, 422} and fallback != payload: + try: + resp.close() + except Exception: + pass + logger.warning("TTS backend rejected extra generation params; retrying without them") + return requests.post(endpoint, json=fallback, headers=headers, **kwargs) + return resp + + +def _clean_preview_backend(value: str) -> str: + key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_") + aliases = { + "clone": "voice_clone", + "base": "voice_clone", + "voiceclone": "voice_clone", + "voice_clone_base": "voice_clone", + "stream": "streaming", + "tts_streaming": "streaming", + "custom": "customvoice", + "custom_voice": "customvoice", + "voice_design": "voice_design", + "voicedesign": "voice_design", + "design": "voice_design", + "nvidia": "nvidia_magpie", + "magpie": "nvidia_magpie", + "nvidia_tts": "nvidia_magpie", + "nvidia_magpie_tts": "nvidia_magpie", + "nvidia_clone": "nvidia_zeroshot", + "nvidia_zeroshot_tts": "nvidia_zeroshot", + "magpie_zeroshot": "nvidia_zeroshot", + "zeroshot": "nvidia_zeroshot", + "zero_shot": "nvidia_zeroshot", + "nvidia_flow_tts": "nvidia_flow", + "magpie_flow": "nvidia_flow", + "flow": "nvidia_flow", + } + key = aliases.get(key, key) + return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"} else "voice_clone" + + +def _preview_backend_base_url(settings: dict, backend: str) -> str: + backend = _clean_preview_backend(backend) + if backend == "streaming": + return settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT + if backend == "customvoice": + return settings.get("customvoice_url") or _CUSTOMVOICE_DEFAULT + if backend == "voice_design": + return settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT + if backend == "nvidia_magpie": + return settings.get("nvidia_tts_url") or settings.get("nvidia_router_url") or _NVIDIA_TTS_DEFAULT + if backend == "nvidia_zeroshot": + return settings.get("nvidia_zeroshot_url") or settings.get("nvidia_clone_url") or settings.get("nvidia_router_url") or _NVIDIA_ZEROSHOT_DEFAULT + if backend == "nvidia_flow": + return settings.get("nvidia_flow_url") or settings.get("nvidia_clone_url") or settings.get("nvidia_router_url") or _NVIDIA_FLOW_DEFAULT + return settings.get("tts_url") or _TTS_DEFAULT + + +def _normalize_settings(s: dict) -> dict: + scan_dir = Path(s.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + output_dir = Path(s.get("output_dir", _OUTPUT_DIR_DEFAULT)) + legacy_output_dirs = { + Path("/voices/speakers"), + scan_dir / "speakers", + } + if output_dir in legacy_output_dirs: + s["output_dir"] = str(scan_dir / "active_voices") + if s.get("tts_stream_mode") not in {"auto", "streaming", "buffered"}: + s["tts_stream_mode"] = "auto" + return s + + +def _load_settings() -> dict: + defaults = { + "whisper_url": _WHISPER_DEFAULT, + "tts_url": _TTS_DEFAULT, + "tts_stream_url": _TTS_STREAM_DEFAULT, + "tts_stream_mode": "auto", + "tts_backend": "openai", + "output_dir": _OUTPUT_DIR_DEFAULT, + "voices_scan_dir": _VOICES_DIR_DEFAULT, + "voice_design_url": _VOICE_DESIGN_DEFAULT, + "customvoice_url": _CUSTOMVOICE_DEFAULT, + "nvidia_router_url": _NVIDIA_ROUTER_DEFAULT, + "nvidia_tts_url": _NVIDIA_TTS_DEFAULT, + "nvidia_asr_url": _NVIDIA_ASR_DEFAULT, + "nvidia_clone_url": _NVIDIA_CLONE_DEFAULT, + "nvidia_zeroshot_url": _NVIDIA_ZEROSHOT_DEFAULT, + "nvidia_flow_url": _NVIDIA_FLOW_DEFAULT, + "whisper_api_key": "", + "tts_api_key": "", + "voice_design_api_key": "", + "tts_stability_enabled": True, + "tts_extra_params": _TTS_STABILITY_DEFAULT, + "tts_extra_params_by_backend": _TTS_STABILITY_BY_BACKEND_DEFAULT, + } + if CONFIG_FILE.exists(): + try: + saved = json.loads(CONFIG_FILE.read_text()) + defaults.update({k: v for k, v in saved.items() if k in _SETTINGS_KEYS}) + except Exception: + pass + return _normalize_settings(defaults) + + +def _save_settings(s: dict) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(s, indent=2)) + + +@app.get("/api/settings") +async def get_settings(): + return _load_settings() + + +@app.post("/api/settings") +async def post_settings(request: Request): + data = await request.json() + s = _load_settings() + s.update({k: v for k, v in data.items() if k in _SETTINGS_KEYS}) + s = _normalize_settings(s) + _save_settings(s) + return {"ok": True} + + +# ── TTS routing rules ───────────────────────────────────────────────────────── + +_ROUTE_LANGS = {"AUTO", "*", "EN", "DE", "FR", "ES", "IT", "PT", "NL", "PL"} +_ROUTE_BACKENDS = {"voice_clone", "streaming", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"} + + +def _clean_route_token(value: str, default: str = "*") -> str: + value = str(value or "").strip() + if not value: + return default + value = re.sub(r"[^A-Za-z0-9_\-\.\* ]+", "_", value) + return value[:80] or default + + +def _clean_route_sound(value: str) -> str: + value = str(value or "").strip() + if not value: + return "" + value = re.sub(r"[^A-Za-z0-9_\-\.\* /]+", "_", value) + return value[:240] + + + +def _clean_route_backend(value: str) -> str: + key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_") + aliases = { + "clone": "voice_clone", + "base": "voice_clone", + "tts": "voice_clone", + "standard": "voice_clone", + "voiceclone": "voice_clone", + "voice_clone_base": "voice_clone", + "stream": "streaming", + "tts_streaming": "streaming", + "voice_design": "voice_design", + "voicedesign": "voice_design", + "design": "voice_design", + "nvidia": "nvidia_magpie", + "magpie": "nvidia_magpie", + "nvidia_tts": "nvidia_magpie", + "nvidia_magpie_tts": "nvidia_magpie", + "nvidia_clone": "nvidia_zeroshot", + "nvidia_zeroshot_tts": "nvidia_zeroshot", + "magpie_zeroshot": "nvidia_zeroshot", + "zeroshot": "nvidia_zeroshot", + "zero_shot": "nvidia_zeroshot", + "nvidia_flow_tts": "nvidia_flow", + "magpie_flow": "nvidia_flow", + "flow": "nvidia_flow", + } + key = aliases.get(key, key) + return key if key in _ROUTE_BACKENDS else "voice_clone" + + +def _route_backend(route: dict | None, voice: str = "") -> str: + if _resolve_virtual_voice(voice): + return "voice_design" + return _clean_route_backend((route or {}).get("backend", "voice_clone")) + +def _normalize_route(rule: dict, idx: int = 0) -> dict: + lang = str(rule.get("language", "*") or "*").strip().upper() + if lang not in _ROUTE_LANGS: + lang = "*" + output_voice = _clean_route_token(rule.get("output_voice", ""), "") + return { + "id": _clean_route_token(rule.get("id", f"route_{idx+1}"), f"route_{idx+1}"), + "enabled": bool(rule.get("enabled", True)), + "app": _clean_route_token(rule.get("app", "Open WebUI"), "Open WebUI"), + "input_voice": _clean_route_token(rule.get("input_voice", "default"), "default"), + "language": lang, + "backend": _clean_route_backend(rule.get("backend", "voice_clone")), + "output_voice": output_voice, + "before_sound": _clean_route_sound(rule.get("before_sound", "")), + "after_sound": _clean_route_sound(rule.get("after_sound", "")), + } + + +def _load_tts_routes() -> list[dict]: + if not TTS_ROUTES_FILE.exists(): + return [] + try: + raw = json.loads(TTS_ROUTES_FILE.read_text()) + routes = raw.get("routes", raw) if isinstance(raw, dict) else raw + if not isinstance(routes, list): + return [] + return [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)] + except Exception: + return [] + + +def _save_tts_routes(routes: list[dict]) -> None: + TTS_ROUTES_FILE.parent.mkdir(parents=True, exist_ok=True) + clean = [_normalize_route(r, i) for i, r in enumerate(routes)] + TTS_ROUTES_FILE.write_text(json.dumps({"routes": clean}, indent=2)) + + +def _app_key(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "", str(value or "").lower()) + + +def _canonical_app_name(value: str) -> str: + key = _app_key(value) + if key in {"openwebui", "openwebuiapp"}: + return "Open WebUI" + if key == "sillytavern": + return "SillyTavern" + if key in {"homeassistant", "ha"}: + return "Home Assistant" + return str(value or "").strip() or "Open WebUI" + + +def _request_app_name(request: Request) -> str: + explicit = request.headers.get("x-tts-app") or request.headers.get("x-client-app") + if explicit: + return _canonical_app_name(explicit) + header = ( + request.headers.get("x-openwebui-app") + or request.headers.get("x-openwebui-user-name") + or request.headers.get("referer") + or request.headers.get("origin") + or request.headers.get("user-agent") + or "" + ) + h = _app_key(header) + if "openwebui" in h: + return "Open WebUI" + if "sillytavern" in h: + return "SillyTavern" + if "homeassistant" in h: + return "Home Assistant" + return "Open WebUI" + + +def _detect_text_language(text: str) -> str: + low = f" {text.lower()} " + if re.search(r"[äöüß]", low) or re.search(r"\b(und|der|die|das|ich|nicht|mit|ist|ein|eine|auf|für)\b", low): + return "DE" + if re.search(r"[éèêàçùœ]", low) or re.search(r"\b(et|le|la|les|des|une|avec|pour|est|pas|que)\b", low): + return "FR" + if re.search(r"[áéíóúñ¿¡]", low) or re.search(r"\b(el|la|los|las|una|con|para|que|pero|está|hola)\b", low): + return "ES" + if re.search(r"\b(il|lo|gli|una|con|per|che|ciao|grazie|sono|della)\b", low): + return "IT" + if re.search(r"[ãõç]", low) or re.search(r"\b(com|para|uma|que|não|está|obrigado)\b", low): + return "PT" + if re.search(r"\b(het|een|niet|met|voor|zijn|maar|dank|goede)\b", low): + return "NL" + if re.search(r"[ąćęłńóśźż]", low) or re.search(r"\b(jest|nie|tak|dla|oraz|dzień|dziękuję)\b", low): + return "PL" + return "EN" + + +def _route_specificity(rule: dict, app: str, voice: str, lang: str) -> tuple[int, int, int, int] | None: + if not rule.get("enabled", True) or not rule.get("output_voice"): + return None + r_app = str(rule.get("app", "*")) + r_voice = str(rule.get("input_voice", "*")) + r_lang = str(rule.get("language", "*")).upper() + app_ok = r_app == "*" or _app_key(r_app) == _app_key(app) + voice_ok = r_voice == "*" or r_voice.lower() == voice.lower() + lang_ok = r_lang in {"*", "AUTO"} or r_lang == lang + if not (app_ok and voice_ok and lang_ok): + return None + return ( + 1 if r_app != "*" else 0, + 1 if r_voice != "*" else 0, + 1 if r_lang not in {"*", "AUTO"} else 0, + 0, + ) + + +def _resolve_tts_route(app: str, voice: str, text: str) -> tuple[str, dict | None]: + lang = _detect_text_language(text) + best: tuple[tuple[int, int, int, int], dict] | None = None + for idx, rule in enumerate(_load_tts_routes()): + spec = _route_specificity(rule, app, voice, lang) + if spec is None: + continue + spec = (spec[0], spec[1], spec[2], -idx) + if best is None or spec > best[0]: + best = (spec, rule) + if not best: + return voice, None + routed = dict(best[1]) + routed["detected_language"] = lang + routed["requested_voice"] = voice + routed["app"] = app + return str(routed["output_voice"]), routed + + +@app.get("/api/tts-routes") +async def get_tts_routes(): + return {"routes": _load_tts_routes()} + + +@app.post("/api/tts-routes") +async def post_tts_routes(request: Request): + data = await request.json() + routes = data.get("routes", data) if isinstance(data, dict) else data + if not isinstance(routes, list): + raise HTTPException(400, "routes must be a list") + _save_tts_routes(routes) + return {"ok": True, "routes": _load_tts_routes()} + + +@app.get("/api/tts-routing-log") +async def get_tts_routing_log(limit: int = 60): + limit = max(1, min(int(limit or 60), _ROUTING_LOG_MAX)) + return {"items": _routing_log[:limit], "max": _ROUTING_LOG_MAX} + + +@app.delete("/api/tts-routing-log") +async def clear_tts_routing_log(): + _routing_log.clear() + return {"ok": True, "items": []} + + +# ── VoiceDesign virtual voice presets ───────────────────────────────────────── + +_DEFAULT_DESIGN_PRESETS = { + "EN_M_Young_Energetic": { + "description": "Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.", + "sample_text": "Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.", + "language": "English", + "gender": "M", + }, + "EN_F_Warm_Narrator": { + "description": "Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.", + "sample_text": "The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.", + "language": "English", + "gender": "F", + }, + "DE_M_Elderly_Documentary": { + "description": "Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.", + "sample_text": "Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.", + "language": "German", + "gender": "M", + }, + "DE_F_Young_Friendly": { + "description": "Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.", + "sample_text": "Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.", + "language": "German", + "gender": "F", + }, + "EN_N_Old_Wise_Assistant": { + "description": "Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.", + "sample_text": "Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.", + "language": "English", + "gender": "N", + }, +} + + +def _slug_voice_design_name(name: str) -> str: + slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip()).strip("._-") + return slug or "VoiceDesign" + + +def _load_design_presets() -> dict: + presets = dict(_DEFAULT_DESIGN_PRESETS) + if DESIGN_PRESETS_FILE.exists(): + try: + saved = json.loads(DESIGN_PRESETS_FILE.read_text()) + if isinstance(saved, dict): + for name, preset in saved.items(): + if isinstance(preset, dict): + presets[_slug_voice_design_name(str(name))] = { + "description": str(preset.get("description", "")), + "sample_text": str(preset.get("sample_text", preset.get("text", ""))), + "language": str(preset.get("language", "Auto")), + "gender": str(preset.get("gender", "N")), + } + except Exception: + pass + return presets + + +def _save_design_presets(presets: dict) -> None: + cleaned = {} + for name, preset in presets.items(): + if not isinstance(preset, dict): + continue + key = _slug_voice_design_name(str(name)) + cleaned[key] = { + "description": str(preset.get("description", "")), + "sample_text": str(preset.get("sample_text", preset.get("text", ""))), + "language": str(preset.get("language", "Auto")), + "gender": str(preset.get("gender", "N")), + } + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + DESIGN_PRESETS_FILE.write_text(json.dumps(cleaned, indent=2)) + + +@app.get("/api/voice-design-presets") +async def get_voice_design_presets(): + return _load_design_presets() + + +@app.post("/api/voice-design-presets") +async def post_voice_design_presets(request: Request): + data = await request.json() + if not isinstance(data, dict): + raise HTTPException(400, "Expected a preset object") + _save_design_presets(data) + return {"ok": True, "presets": _load_design_presets()} + + +# ── Audio helpers ───────────────────────────────────────────────────────────── + +def _to_wav_24k(src: Path) -> Path: + out = TEMP_DIR / f"{src.stem}_24k.wav" + seg = AudioSegment.from_file(str(src)) + seg = seg.set_frame_rate(24000).set_channels(1).set_sample_width(2) + seg.export(str(out), format="wav") + return out + + +def _trim(src: Path, start_s: float, end_s: float) -> Path: + seg = AudioSegment.from_file(str(src)) + trimmed = seg[int(start_s * 1000):int(end_s * 1000)] + trimmed, _ = _normalize_segment(trimmed) + out = TEMP_DIR / f"{uuid.uuid4().hex}_trimmed.wav" + trimmed.export(str(out), format="wav") + return out + + +def _duration(path: Path) -> float: + if path.suffix.lower() == ".wav": + try: + with wave.open(str(path), "rb") as wf: + frames = wf.getnframes() + rate = wf.getframerate() + if rate: + return frames / float(rate) + except Exception: + pass + + try: + seg = AudioSegment.from_file(str(path)) + return len(seg) / 1000.0 + except Exception: + pass + + probe = subprocess.run( + [ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + timeout=10, + ) + if probe.returncode != 0: + raise RuntimeError(probe.stderr.strip() or "ffprobe failed") + return float(probe.stdout.strip()) + + +def _normalize_segment(seg: AudioSegment, target_dbfs: float = _VOICE_TARGET_DBFS, + peak_dbfs: float = _VOICE_PEAK_DBFS) -> tuple[AudioSegment, dict]: + before_dbfs = seg.dBFS if seg.dBFS != float("-inf") else None + before_peak = seg.max_dBFS if seg.max_dBFS != float("-inf") else None + if before_dbfs is None or before_peak is None: + return seg, {"before_dbfs": before_dbfs, "after_dbfs": before_dbfs, "gain_db": 0.0, "peak_dbfs": before_peak} + + gain = target_dbfs - before_dbfs + if before_peak + gain > peak_dbfs: + gain = peak_dbfs - before_peak + normalized = seg.apply_gain(gain) + after_dbfs = normalized.dBFS if normalized.dBFS != float("-inf") else None + after_peak = normalized.max_dBFS if normalized.max_dBFS != float("-inf") else None + return normalized, { + "before_dbfs": round(before_dbfs, 2), + "after_dbfs": round(after_dbfs, 2) if after_dbfs is not None else None, + "gain_db": round(gain, 2), + "peak_dbfs": round(after_peak, 2) if after_peak is not None else None, + } + + +def _export_normalized_wav(src: Path, dest: Path, target_dbfs: float = _VOICE_TARGET_DBFS) -> dict: + seg = AudioSegment.from_file(str(src)) + seg = seg.set_frame_rate(24000).set_channels(1).set_sample_width(2) + seg, info = _normalize_segment(seg, target_dbfs=target_dbfs) + seg.export(str(dest), format="wav") + return info + + +def _loudness_info(path: Path) -> dict: + seg = AudioSegment.from_file(str(path)) + dbfs = seg.dBFS if seg.dBFS != float("-inf") else None + peak = seg.max_dBFS if seg.max_dBFS != float("-inf") else None + return { + "dbfs": round(dbfs, 2) if dbfs is not None else None, + "peak_dbfs": round(peak, 2) if peak is not None else None, + "target_dbfs": _VOICE_TARGET_DBFS, + } + + +def _auto_trim_bounds(path: Path) -> dict: + seg = AudioSegment.from_file(str(path)).set_channels(1) + dur_ms = len(seg) + if dur_ms <= 20_000: + return { + "start": 0.0, + "end": dur_ms / 1000.0, + "duration": dur_ms / 1000.0, + "reason": "Audio is already short enough.", + } + + chunk_ms = 250 + chunks = [] + overall_db = seg.dBFS if seg.dBFS != float("-inf") else -60.0 + speech_floor = max(overall_db - 18.0, -45.0) + + for pos in range(0, dur_ms, chunk_ms): + ch = seg[pos:pos + chunk_ms] + db = ch.dBFS if ch.dBFS != float("-inf") else -80.0 + max_db = ch.max_dBFS if ch.max_dBFS != float("-inf") else -80.0 + chunks.append({"db": db, "speech": db >= speech_floor, "clipped": max_db > -1.0}) + + def score_window(start_ms: int, length_ms: int) -> tuple[float, dict]: + first = max(0, start_ms // chunk_ms) + last = min(len(chunks), (start_ms + length_ms + chunk_ms - 1) // chunk_ms) + win = chunks[first:last] + if not win: + return -9999.0, {} + speech_ratio = sum(1 for c in win if c["speech"]) / len(win) + silence_ratio = 1.0 - speech_ratio + clip_ratio = sum(1 for c in win if c["clipped"]) / len(win) + speech_dbs = [c["db"] for c in win if c["speech"]] + avg_db = sum(speech_dbs) / len(speech_dbs) if speech_dbs else -80.0 + variance = sum((x - avg_db) ** 2 for x in speech_dbs) / len(speech_dbs) if speech_dbs else 100.0 + loudness_penalty = abs(avg_db - (-20.0)) * 1.7 + steadiness_penalty = min(18.0, variance ** 0.5 * 1.4) + duration_s = length_ms / 1000.0 + duration_penalty = abs(duration_s - 12.0) * 0.9 + score = ( + speech_ratio * 100.0 + - silence_ratio * 55.0 + - clip_ratio * 85.0 + - loudness_penalty + - steadiness_penalty + - duration_penalty + ) + return score, { + "speech_ratio": speech_ratio, + "silence_ratio": silence_ratio, + "clip_ratio": clip_ratio, + "avg_db": avg_db, + } + + best = None + window_lengths = [8_000, 10_000, 12_000, 15_000, 18_000] + for length_ms in window_lengths: + if length_ms > dur_ms: + continue + for start_ms in range(0, dur_ms - length_ms + 1, 500): + score, metrics = score_window(start_ms, length_ms) + if best is None or score > best["score"]: + best = {"start_ms": start_ms, "length_ms": length_ms, "score": score, "metrics": metrics} + + if best is None: + end_ms = min(dur_ms, 12_000) + return {"start": 0.0, "end": end_ms / 1000.0, "duration": end_ms / 1000.0, + "reason": "Using the beginning because no stable speech window was found."} + + start_s = best["start_ms"] / 1000.0 + end_s = (best["start_ms"] + best["length_ms"]) / 1000.0 + m = best["metrics"] + return { + "start": round(start_s, 2), + "end": round(end_s, 2), + "duration": round(end_s - start_s, 2), + "score": round(best["score"], 2), + "reason": ( + f"Selected {end_s - start_s:.1f}s with " + f"{m.get('speech_ratio', 0) * 100:.0f}% speech, " + f"{m.get('silence_ratio', 0) * 100:.0f}% silence, " + f"avg {m.get('avg_db', -80):.1f} dBFS." + ), + } + + +# ── Voice meta helpers ──────────────────────────────────────────────────────── + +_PICTURE_EXTS = [".jpg", ".jpeg", ".png", ".webp"] +_SOUND_ASSET_DIRS = {"sound", "sounds", "sfx", "effects", "sound_effects", "route_sounds"} +_SOUND_ASSET_PREFIXES = ("computerbeep", "beep", "ding", "chime", "notification") +_AUDIO_EXTS = [".wav", ".mp3", ".m4a", ".flac", ".ogg", ".opus"] +_UPLOAD_EXTS = _AUDIO_EXTS + [".mp4", ".mkv", ".webm", ".mov", ".avi"] +_AUDIO_MIME = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", +} + +# Default flag (ISO country code) for each language code +_LANG_FLAG_DEFAULT = { + "EN": "GB", "DE": "DE", "ZH": "CN", "FR": "FR", "ES": "ES", + "JA": "JP", "KO": "KR", "IT": "IT", "PT": "BR", "RU": "RU", + "AR": "SA", "PL": "PL", "NL": "NL", "SV": "SE", "TR": "TR", "HI": "IN", +} + + +def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None: + for ext in _AUDIO_EXTS: + for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")): + if not _is_internal_voice_file(p) and not _is_sound_asset_file(p): + return p + return None + + +def _meta_path(wav: Path) -> Path: + return wav.with_suffix(".meta.json") + + +def _load_meta(wav: Path) -> dict: + mp = _meta_path(wav) + if mp.exists(): + try: + return json.loads(mp.read_text()) + except Exception: + pass + # Auto-detect flag and gender from voice_id + parts = wav.stem.split("_", 2) + lang = parts[0].upper() if parts else "" + gender = parts[1].upper() if len(parts) >= 2 and parts[1].upper() in ("F", "M", "N") else "" + return { + "note": "", + "rating": 0, + "flag": _LANG_FLAG_DEFAULT.get(lang, ""), + "gender": gender, + "enabled": True, + } + + +def _save_meta(wav: Path, meta: dict) -> None: + _meta_path(wav).write_text(json.dumps(meta, indent=2)) + + +def _picture_path(wav: Path) -> Path | None: + for ext in _PICTURE_EXTS: + p = wav.with_suffix(ext) + if p.exists(): + return p + return None + + +def _picture_mime(path: Path) -> str: + return {".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".webp": "image/webp"}.get(path.suffix.lower(), "image/jpeg") + + +def _hidden_voices_dir(settings: dict) -> Path: + return Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) / "hidden_voices" + + +def _active_voices_dir(settings: dict) -> Path: + return Path(settings.get("output_dir", _OUTPUT_DIR_DEFAULT)) + + +def _voice_package_paths(audio: Path) -> list[Path]: + parent = audio.parent + paths = [] + for sfx in _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS: + p = parent / f"{audio.stem}{sfx}" + if p.exists(): + paths.append(p) + paths.extend( + p for p in _backup_candidates(audio, _load_meta(audio)) + if p.exists() and p.parent.resolve() == parent.resolve() + ) + return paths + + +def _is_internal_voice_file(path: Path) -> bool: + stem = path.stem.lower() + name = path.name.lower() + return ( + stem.endswith(".original") + or ".normalized.tmp" in stem + or name.startswith(".") + or name.endswith(".bak") + ) + + + +def _is_sound_asset_file(path: Path) -> bool: + stem = path.stem.lower() + parent_names = {part.lower() for part in path.parts} + return ( + any(part in _SOUND_ASSET_DIRS for part in parent_names) + or stem.startswith(_SOUND_ASSET_PREFIXES) + ) + +def _voice_audio_files(root: Path): + for ext in _AUDIO_EXTS: + for p in root.rglob(f"*{ext}"): + if not _is_internal_voice_file(p) and not _is_sound_asset_file(p): + yield p + + +def _backup_path(audio: Path) -> Path: + return audio.with_name(f".{audio.stem}.original{audio.suffix}.bak") + + +def _legacy_backup_path(audio: Path) -> Path: + return audio.with_name(f"{audio.stem}.original{audio.suffix}") + + +def _backup_candidates(audio: Path, meta: dict | None = None) -> list[Path]: + candidates = [_backup_path(audio), _legacy_backup_path(audio)] + if meta and meta.get("original_backup"): + candidates.insert(0, Path(str(meta["original_backup"]))) + + seen: set[Path] = set() + unique = [] + for candidate in candidates: + try: + key = candidate.resolve() if candidate.exists() else candidate + except Exception: + key = candidate + if key not in seen: + seen.add(key) + unique.append(candidate) + return unique + + +def _backup_audio_suffix(backup: Path, voice_id: str) -> str | None: + name = backup.name + for ext in _AUDIO_EXTS: + if name == f".{voice_id}.original{ext}.bak" or name == f"{voice_id}.original{ext}": + return ext + return None + + +def _remove_audio_variants(parent: Path, voice_id: str, keep: Path | None = None) -> None: + keep_resolved = keep.resolve() if keep and keep.exists() else None + for ext in _AUDIO_EXTS: + p = parent / f"{voice_id}{ext}" + if p.exists() and not _is_internal_voice_file(p) and (keep_resolved is None or p.resolve() != keep_resolved): + p.unlink() + + +def _remove_voice_package(audio: Path, keep: set[Path] | None = None) -> None: + keep_resolved = {p.resolve() for p in (keep or set()) if p.exists()} + for p in _voice_package_paths(audio): + if p.exists() and p.resolve() not in keep_resolved: + p.unlink() + + +def _backup_original_voice(audio: Path) -> Path | None: + if not audio.exists() or audio.suffix.lower() not in _AUDIO_EXTS: + return None + backup = _backup_path(audio) + if not backup.exists(): + legacy = _legacy_backup_path(audio) + if legacy.exists(): + shutil.move(str(legacy), str(backup)) + else: + shutil.copy2(str(audio), str(backup)) + return backup + + +def _voice_audio_from_request(data: dict, scan_dir: Path) -> Path | None: + requested_path = data.get("path") + if requested_path: + p = _safe_child_path(scan_dir, Path(requested_path)) + if not p.exists() or not p.is_file() or p.suffix.lower() not in _AUDIO_EXTS: + raise HTTPException(404, "Voice file not found") + return p + + voice_id = data.get("voice_id", "") + if not voice_id: + raise HTTPException(400, "voice_id or path is required") + return _find_voice_audio(voice_id, scan_dir) + + +def _move_voice_package(audio: Path, target_dir: Path) -> Path: + if audio.parent.resolve() == target_dir.resolve(): + return audio + + target_dir.mkdir(parents=True, exist_ok=True) + paths = _voice_package_paths(audio) + for src in paths: + dest = target_dir / src.name + if dest.exists() and dest.resolve() != src.resolve(): + raise HTTPException(409, f"Target file already exists: {dest}") + + moved_audio = target_dir / audio.name + for src in paths: + dest = target_dir / src.name + if dest.resolve() != src.resolve(): + shutil.move(str(src), str(dest)) + return moved_audio + + +def _read_reference_text(audio: Path) -> tuple[bool, str]: + ref = audio.with_suffix(".reference.txt") + if not ref.exists(): + return False, "" + return True, ref.read_text(encoding="utf-8").strip() + + +def _voice_entry(p: Path) -> dict: + meta = _load_meta(p) + parts = p.stem.split("_", 2) + lang = parts[0].upper() if parts else "" + try: + duration = round(_duration(p), 2) + except Exception: + duration = None + has_ref, transcript = _read_reference_text(p) + loudness = meta.get("loudness", {}) + benchmark = meta.get("benchmark", {}) + if not meta.get("flag") and lang in _LANG_FLAG_DEFAULT: + meta["flag"] = _LANG_FLAG_DEFAULT[lang] + health = _voice_health(p, meta=meta, duration=duration, transcript=transcript) + return { + "id": p.stem, + "path": str(p), + "file_type": p.suffix.lower().lstrip("."), + "duration": duration, + "loudness": loudness, + "benchmark": benchmark, + "has_ref": has_ref, + "transcript": transcript, + "has_picture": _picture_path(p) is not None, + "health": health, + "lang": lang, + **meta, + } + + +def _voice_health( + p: Path, + *, + meta: dict | None = None, + duration: float | None = None, + transcript: str | None = None, +) -> dict: + if transcript is None: + _has_ref, transcript = _read_reference_text(p) + if duration is None: + try: + duration = float(_duration(p)) + except Exception: + duration = 0.0 + else: + duration = float(duration) + if meta is None: + meta = _load_meta(p) + words = re.findall(r"\b[\w'-]+\b", transcript, flags=re.UNICODE) + word_count = len(words) + words_per_sec = (word_count / duration) if duration > 0 else 0.0 + warnings: list[str] = [] + if not transcript: + warnings.append("Missing reference transcript") + if duration > 25: + warnings.append("Reference audio is longer than the recommended 10-20 seconds") + if duration > 0 and word_count and words_per_sec > 4.5: + warnings.append("Reference transcript is too long for the audio; re-transcribe or shorten it") + loudness = meta.get("loudness", {}) + peak = loudness.get("peak_dbfs") + if peak is not None and float(peak) > -0.1: + warnings.append("Reference audio is clipping or too loud") + return { + "ok": not warnings, + "warnings": warnings, + "duration": round(duration, 2) if duration else None, + "word_count": word_count, + "words_per_sec": round(words_per_sec, 2) if words_per_sec else 0, + "loudness": loudness, + } + + +def _benchmark_advice(audio: Path, elapsed_sec: float | None, audio_sec: float | None, + clipped: bool = False, error: str = "") -> tuple[bool, list[str]]: + advice: list[str] = [] + try: + ref_duration = float(_duration(audio)) + except Exception: + ref_duration = 0.0 + + name = audio.stem.lower() + if ref_duration >= 40 or "privat_" in name or "privat-" in name: + advice.append("Avoid for real-time assistants; long clone samples often benchmark slowly. Trim or remake as a 10-20 second voice.") + elif ref_duration > 25: + advice.append("Reference is longer than recommended. Trim to a clean 10-20 second sample.") + if clipped: + advice.append("Generated output hit the max-duration guard. Re-transcribe the reference exactly or remake this clone.") + if elapsed_sec is not None and elapsed_sec > 12: + advice.append("Slow synthesis. Prefer a shorter optimized voice for Open WebUI or Home Assistant.") + if audio_sec and elapsed_sec: + rtf = elapsed_sec / max(audio_sec, 0.01) + if rtf > 2.0: + advice.append("High real-time factor. Use a shorter reference, normalize volume, and remove silence/noise.") + if error: + advice.append("Benchmark failed. Check that Qwen3-TTS has rescanned this voice and that the reference files are valid.") + + realtime_ok = not error and not clipped and (elapsed_sec or 999) <= 8 and ref_duration <= 25 + return realtime_ok, advice + + +def _benchmark_summary(runs: list[dict]) -> dict: + ok_runs = [r for r in runs if r.get("ok")] + if not ok_runs: + return {} + + def avg(key: str) -> float | None: + vals = [float(r[key]) for r in ok_runs if r.get(key) is not None] + return round(sum(vals) / len(vals), 3) if vals else None + + return { + "avg_ttfa_ms": avg("ttfa_ms"), + "avg_total_sec": avg("total_sec"), + "avg_audio_sec": avg("audio_sec"), + "avg_rtf": avg("rtf"), + "avg_speed": avg("speed"), + } + + +def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str]]) -> dict: + runs: list[dict] = [] + for label, text in sentences: + try: + runs.append(_tts_benchmark_request(text, audio.stem, settings, label)) + except Exception as e: + runs.append({"ok": False, "label": label, "text": text, "error": str(e)}) + + ok_runs = [r for r in runs if r.get("ok")] + summary = _benchmark_summary(runs) + elapsed = summary.get("avg_total_sec") if summary else None + audio_sec = summary.get("avg_audio_sec") if summary else None + errors = [r.get("error", "Benchmark failed") for r in runs if not r.get("ok")] + realtime_ok, advice = _benchmark_advice(audio, elapsed, audio_sec, error="; ".join(errors)) + if summary.get("avg_rtf") is not None and summary["avg_rtf"] > 2: + advice.append("RTF is above 2.0. This voice is likely too slow for real-time assistants.") + + return { + "ok": bool(ok_runs) and not errors, + "realtime_ok": realtime_ok and not errors, + "elapsed_sec": round(elapsed, 2) if elapsed is not None else None, + "audio_sec": round(audio_sec, 2) if audio_sec is not None else None, + "rtf": round(summary.get("avg_rtf"), 2) if summary.get("avg_rtf") is not None else None, + "speed": round(summary.get("avg_speed"), 2) if summary.get("avg_speed") is not None else None, + "ttfa_ms": round(summary.get("avg_ttfa_ms"), 0) if summary.get("avg_ttfa_ms") is not None else None, + "bytes": sum(int(r.get("bytes") or 0) for r in ok_runs), + "clipped": False, + "benchmarked_at": datetime.now(timezone.utc).isoformat(), + "text": sentences[0][1] if len(sentences) == 1 else "short / medium / long", + "runs": runs, + "summary": summary, + "advice": advice, + **({"error": "; ".join(errors)} if errors else {}), + } + + +# ── Static files ────────────────────────────────────────────────────────────── + +STATIC_DIR = Path(__file__).parent / "static" +STATIC_DIR.mkdir(exist_ok=True) + +@app.get("/") +async def index(): + return FileResponse( + STATIC_DIR / "index.html", + headers={"Cache-Control": "no-store, max-age=0"}, + ) + + +@app.get("/favicon.ico") +async def favicon(): + return Response(status_code=204) + + +# ── Upload ──────────────────────────────────────────────────────────────────── + +@app.post("/api/upload") +async def upload(file: UploadFile = File(...)): + suffix = Path(file.filename or "audio").suffix.lower() or ".bin" + if suffix not in _UPLOAD_EXTS: + raise HTTPException(400, "Unsupported audio/video file type") + dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" + with dest.open("wb") as f: + _copy_limited(file.file, f, _MAX_UPLOAD_BYTES) + try: + wav = _to_wav_24k(dest) + except Exception as e: + raise HTTPException(400, f"Audio conversion failed: {e}") + fid = uuid.uuid4().hex + _registry[fid] = wav + return {"id": fid, "duration": _duration(wav), "filename": file.filename} + + +@app.get("/api/route-sounds") +async def list_route_sounds(): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + sounds = [] + seen = set() + for dirname in sorted(_SOUND_ASSET_DIRS): + root = scan_dir / dirname + if not root.exists() or not root.is_dir(): + continue + for path in sorted(root.rglob("*"), key=lambda p: str(p).lower()): + if not path.is_file() or path.suffix.lower() not in _AUDIO_EXTS: + continue + try: + safe = _safe_child_path(scan_dir, path) + rel = str(safe.relative_to(scan_dir)) + except Exception: + continue + if rel in seen: + continue + seen.add(rel) + try: + size = path.stat().st_size + except Exception: + size = None + sounds.append({ + "path": rel, + "name": path.name, + "folder": str(path.parent.relative_to(scan_dir)), + "duration": None, + "size": size, + "type": path.suffix.lower().lstrip("."), + }) + return {"sounds": sounds} + + +@app.get("/api/route-sounds/file/{sound_path:path}") +async def route_sound_file(sound_path: str): + try: + path = _route_sound_path(_load_settings(), sound_path) + except Exception: + raise HTTPException(404, "Sound not found") + if path is None: + raise HTTPException(404, "Sound not found") + mime = _AUDIO_MIME.get(path.suffix.lower(), "audio/wav") + return FileResponse(str(path), media_type=mime, filename=path.name) + + +@app.post("/api/route-sounds/upload") +async def upload_route_sound(file: UploadFile = File(...), name: str = Form("")): + suffix = Path(file.filename or "sound").suffix.lower() or ".bin" + if suffix not in _AUDIO_EXTS: + raise HTTPException(400, "Unsupported sound file type") + raw_name = Path(name or file.filename or "sound").stem.strip() + safe_name = re.sub(r"[^A-Za-z0-9_\-.]+", "_", raw_name).strip("._-")[:80] or f"sound_{uuid.uuid4().hex[:8]}" + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + sounds_dir = scan_dir / "sounds" + sounds_dir.mkdir(parents=True, exist_ok=True) + target = sounds_dir / f"{safe_name}.wav" + tmp = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" + with tmp.open("wb") as f: + _copy_limited(file.file, f, _MAX_SOUND_BYTES) + try: + segment = AudioSegment.from_file(str(tmp)).set_channels(1).set_sample_width(2).set_frame_rate(24000) + segment.export(str(target), format="wav") + duration = len(segment) / 1000.0 + except Exception as e: + raise HTTPException(400, f"Sound conversion failed: {e}") + finally: + try: + tmp.unlink(missing_ok=True) + except Exception: + pass + return {"ok": True, "path": str(target.relative_to(scan_dir)), "filename": target.name, "duration": round(duration, 2)} + + +def _source_import_url(value: str) -> str: + raw = str(value or "").strip() + validator = globals().get("_validate_download_url") + if validator: + return validator(raw) + if not re.match(r"^https?://", raw, re.I): + raise HTTPException(400, "Expected an http(s) URL") + return raw + + +def _source_download_limit(name: str, fallback_mb: int) -> int: + value = globals().get(name) + if isinstance(value, int): + return value + return int(os.environ.get(name.lstrip("_").replace("_BYTES", "_MB"), str(fallback_mb))) * 1024 * 1024 + + +@app.post("/api/import-source-audio") +async def import_source_audio(request: Request): + data = await request.json() + audio_url = _source_import_url(str(data.get("audio_url") or "")) + filename = str(data.get("name") or Path(urlparse(audio_url).path).name or "source-audio").strip() + suffix = Path(urlparse(audio_url).path).suffix.lower() + if suffix not in _AUDIO_EXTS: + suffix = ".bin" + dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" + try: + with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: + r.raise_for_status() + total = 0 + with dest.open("wb") as f: + for chunk in r.iter_content(1024 * 1024): + if not chunk: + continue + total += len(chunk) + if total > _source_download_limit("_MAX_UPLOAD_BYTES", 1024): + raise HTTPException(413, "Downloaded audio is too large") + f.write(chunk) + wav = _to_wav_24k(dest) + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, f"Source audio import failed: {e}") + fid = uuid.uuid4().hex + _registry[fid] = wav + return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url} + + +# ── YouTube download (SSE) ──────────────────────────────────────────────────── + +@app.get("/api/download-yt") +async def download_yt(url: str): + url = _validate_http_url(url, allow_private=_ALLOW_PRIVATE_DOWNLOADS) + out_path = TEMP_DIR / f"{uuid.uuid4().hex}.%(ext)s" + + async def event_stream() -> AsyncGenerator[str, None]: + cmd = [ + "yt-dlp", "--extract-audio", "--audio-format", "wav", + "--audio-quality", "0", "--output", str(out_path), + "--no-playlist", "--progress", "--newline", url, + ] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + downloaded_path: Path | None = None + assert proc.stdout is not None + async for raw in proc.stdout: + line = raw.decode(errors="replace").rstrip() + if not line: + continue + if "Destination:" in line and "[ExtractAudio]" not in line: + m = re.search(r"Destination:\s+(.+)$", line) + if m: + downloaded_path = Path(m.group(1).strip()) + if line.startswith("[download]") or line.startswith("[ExtractAudio]"): + pct_m = re.search(r"(\d+\.\d+)%", line) + pct = pct_m.group(1) if pct_m else None + yield f"data: {json.dumps({'msg': line, 'pct': pct})}\n\n" + await proc.wait() + if downloaded_path is None or not downloaded_path.exists(): + matches = glob.glob(str(out_path).replace("%(ext)s", "*")) + if matches: + downloaded_path = Path(matches[0]) + if downloaded_path is None or not downloaded_path.exists(): + yield f"data: {json.dumps({'error': 'Download failed — no output file found'})}\n\n" + return + try: + wav = _to_wav_24k(downloaded_path) + except Exception as e: + yield f"data: {json.dumps({'error': f'Conversion failed: {e}'})}\n\n" + return + fid = uuid.uuid4().hex + _registry[fid] = wav + yield f"data: {json.dumps({'done': True, 'id': fid, 'duration': _duration(wav)})}\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# ── Audio serving ───────────────────────────────────────────────────────────── + +@app.get("/api/audio/{fid}") +async def serve_audio(fid: str): + path = _registry.get(fid) + if path is None or not path.exists(): + raise HTTPException(404, "File not found") + return FileResponse(str(path), media_type="audio/wav") + + +# ── Process (trim + convert) ────────────────────────────────────────────────── + +@app.post("/api/auto-trim") +async def auto_trim(request: Request): + data = await request.json() + fid: str = data["id"] + src = _registry.get(fid) + if src is None or not src.exists(): + raise HTTPException(404, "Source file not found") + try: + return _auto_trim_bounds(src) + except Exception as e: + raise HTTPException(400, f"Auto trim failed: {e}") + + +@app.post("/api/process") +async def process(request: Request): + data = await request.json() + fid: str = data["id"] + start = float(data.get("start", 0)) + end: float | None = data.get("end") + src = _registry.get(fid) + if src is None or not src.exists(): + raise HTTPException(404, "Source file not found") + dur = _duration(src) + if end is None or end <= start: + end = dur + trimmed = _trim(src, start, end) + nid = uuid.uuid4().hex + _registry[nid] = trimmed + return {"id": nid, "duration": _duration(trimmed)} + + +# ── Transcribe ──────────────────────────────────────────────────────────────── + +_STT_BACKEND_ALIASES = { + "": "configured", + "default": "configured", + "whisper": "configured", + "configured_whisper": "configured", + "configured_stt": "configured", + "parakeet": "nvidia_parakeet", + "nvidia": "nvidia_parakeet", + "nvidia_asr": "nvidia_parakeet", + "nvidia_parakeet_asr": "nvidia_parakeet", + "router": "nvidia_router", + "speech_router": "nvidia_router", + "nvidia_speech_router": "nvidia_router", +} + + +def _clean_stt_backend(value: str) -> str: + key = re.sub(r"[^a-z0-9]+", "_", str(value or "configured").lower()).strip("_") + key = _STT_BACKEND_ALIASES.get(key, key) + return key if key in {"configured", "nvidia_parakeet", "nvidia_router"} else "configured" + + +def _stt_backend_url(settings: dict, backend: str) -> str: + backend = _clean_stt_backend(backend) + if backend == "nvidia_parakeet": + return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT + if backend == "nvidia_router": + return settings.get("nvidia_router_url") or _NVIDIA_ROUTER_DEFAULT + return settings.get("whisper_url") or _WHISPER_DEFAULT + + +def _stt_backend_model(backend: str) -> str: + backend = _clean_stt_backend(backend) + return "whisper-1" if backend in {"nvidia_parakeet", "nvidia_router"} else "large-v3" + + +def _stt_backend_label(backend: str, url: str) -> str: + labels = { + "configured": "Configured Whisper/STT", + "nvidia_parakeet": "NVIDIA Parakeet ASR", + "nvidia_router": "NVIDIA Speech Router", + } + port = _backend_port_label(url) + return f"{port} {labels.get(backend, backend)}" if port else labels.get(backend, backend) + + +def _stt_backend_health(url: str) -> tuple[bool, list[str]]: + base = _validate_http_url(url, allow_private=True).rstrip("/") + models: list[str] = [] + ok = False + try: + r = requests.get(f"{base}/health", timeout=2) + ok = r.status_code == 200 + except Exception: + pass + try: + r = requests.get(f"{base}/v1/models", timeout=3) + if r.status_code == 200: + ok = True + payload = r.json() + data = payload.get("data", []) if isinstance(payload, dict) else [] + for item in data: + if isinstance(item, dict) and item.get("id"): + models.append(str(item["id"])) + elif isinstance(item, str): + models.append(item) + except Exception: + pass + return ok, models + + +@app.get("/api/stt-backends") +async def stt_backends(): + settings = _load_settings() + items = [] + seen_urls: set[tuple[str, str]] = set() + for backend in ("configured", "nvidia_parakeet", "nvidia_router"): + url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/") + key = (backend, url) + if key in seen_urls: + continue + seen_urls.add(key) + ok, models = _stt_backend_health(url) + items.append({ + "id": backend, + "label": _stt_backend_label(backend, url), + "url": url, + "port": _backend_port_label(url), + "available": ok, + "model": _stt_backend_model(backend), + "models": models, + }) + return {"backends": items} + + +def _transcription_text_from_response(resp: requests.Response) -> str: + try: + payload = resp.json() + if isinstance(payload, str): + return payload.strip() + if isinstance(payload, dict): + for key in ("text", "transcript", "transcription"): + if payload.get(key) is not None: + return str(payload[key]).strip() + except Exception: + pass + return resp.text.strip() + + +def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]: + backend = _clean_stt_backend(backend) + stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/") + stt_key = settings.get("whisper_api_key", "").strip() + hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {} + model = _stt_backend_model(backend) + with src.open("rb") as f: + resp = requests.post( + f"{stt_url}/v1/audio/transcriptions", + files={"file": ("audio.wav", f, "audio/wav")}, + data={"model": model, "response_format": "text"}, + headers=hdrs, + timeout=_STT_REQUEST_TIMEOUT, + ) + if resp.status_code in {400, 404, 422} and model != "whisper-1": + with src.open("rb") as f: + resp = requests.post( + f"{stt_url}/v1/audio/transcriptions", + files={"file": ("audio.wav", f, "audio/wav")}, + data={"model": "whisper-1", "response_format": "text"}, + headers=hdrs, + timeout=60, + ) + resp.raise_for_status() + return _transcription_text_from_response(resp), backend + + +@app.post("/api/transcribe") +async def transcribe(request: Request): + data = await request.json() + fid: str = data["id"] + src = _registry.get(fid) + if src is None or not src.exists(): + raise HTTPException(404, "Audio not found") + settings = _load_settings() + backend = _clean_stt_backend(str(data.get("backend") or data.get("stt_backend") or "configured")) + try: + text, used_backend = await asyncio.to_thread(_transcribe_audio, src, settings, backend) + return {"text": text, "backend": used_backend} + except Exception as e: + raise HTTPException(502, f"STT error ({backend}): {e}") + + +# ── Save voice ──────────────────────────────────────────────────────────────── + +@app.post("/api/save") +async def save_voice(request: Request): + data = await request.json() + fid: str = data["id"] + voice_id: str = data["voice_id"].strip() + transcript: str = data.get("transcript", "").strip() + + if not voice_id: + raise HTTPException(400, "Voice ID is required") + if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): + raise HTTPException(400, "Voice ID may only contain A-Z, 0-9, _, -, .") + + src = _registry.get(fid) + if src is None or not src.exists(): + raise HTTPException(404, "Processed audio not found") + + settings = _load_settings() + out_dir = _active_voices_dir(settings) + out_dir.mkdir(parents=True, exist_ok=True) + + wav_dest = out_dir / f"{voice_id}.wav" + txt_dest = out_dir / f"{voice_id}.reference.txt" + _remove_audio_variants(out_dir, voice_id) + loudness = _export_normalized_wav(src, wav_dest) + txt_dest.write_text(transcript, encoding="utf-8") + meta = _load_meta(wav_dest) + meta["enabled"] = True + meta["loudness"] = loudness + _save_meta(wav_dest, meta) + return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness} + + +# ── Voice library ───────────────────────────────────────────────────────────── + +@app.post("/api/voice-load") +@app.post("/api/voice/load") +async def load_voice_for_edit(request: Request): + data = await request.json() + voice_id: str = data.get("voice_id", "") + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + audio = _voice_audio_from_request(data, scan_dir) + if audio is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + + try: + wav = _to_wav_24k(audio) + except Exception as e: + raise HTTPException(400, f"Audio conversion failed: {e}") + + fid = uuid.uuid4().hex + _registry[fid] = wav + ref = audio.with_suffix(".reference.txt") + return { + "id": fid, + "voice_id": voice_id or audio.stem, + "path": str(audio), + "duration": _duration(wav), + "transcript": ref.read_text(encoding="utf-8").strip() if ref.exists() else "", + "file_type": audio.suffix.lower().lstrip("."), + } + + +@app.post("/api/voice-replace") +@app.post("/api/voice/replace") +async def replace_voice_audio(request: Request): + data = await request.json() + fid: str = data["id"] + voice_id: str = data["voice_id"].strip() + transcript: str = data.get("transcript", "").strip() + + if not voice_id: + raise HTTPException(400, "Voice ID is required") + if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): + raise HTTPException(400, "Voice ID may only contain A-Z, 0-9, _, -, .") + + src = _registry.get(fid) + if src is None or not src.exists(): + raise HTTPException(404, "Processed audio not found") + + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + current = _voice_audio_from_request(data, scan_dir) + if current is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + + parent = current.parent + old_id = current.stem + meta = _load_meta(current) + wav_dest = parent / f"{voice_id}.wav" + txt_dest = parent / f"{voice_id}.reference.txt" + if voice_id != old_id: + old_files = {p.resolve() for p in _voice_package_paths(current)} + suffixes = _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS + for sfx in suffixes: + target = parent / f"{voice_id}{sfx}" + if target.exists() and target.resolve() not in old_files: + raise HTTPException(409, f"Voice '{voice_id}' already exists") + + backup = _backup_original_voice(current) if voice_id == old_id else None + _remove_audio_variants(parent, voice_id, keep=wav_dest) + loudness = _export_normalized_wav(src, wav_dest) + txt_dest.write_text(transcript, encoding="utf-8") + meta["loudness"] = loudness + if backup: + meta["original_backup"] = str(backup) + meta["needs_tts_restart"] = True + _save_meta(wav_dest, meta) + if voice_id != old_id: + keep = {wav_dest, txt_dest, _meta_path(wav_dest)} + for ext in _PICTURE_EXTS: + old_pic = parent / f"{old_id}{ext}" + if old_pic.exists(): + new_pic = parent / f"{voice_id}{ext}" + shutil.copy2(str(old_pic), str(new_pic)) + keep.add(new_pic) + _remove_voice_package(current, keep) + return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), + "duration": _duration(wav_dest), "file_type": "wav", "loudness": loudness, + "backup": str(backup) if backup else None} + +@app.get("/api/voices") +async def list_voices(): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + active_dir = _active_voices_dir(settings) + hidden_dir = _hidden_voices_dir(settings) + voices = [] + if scan_dir.exists(): + seen: set[str] = set() + all_audio = list(_voice_audio_files(scan_dir)) + def voice_sort_key(path: Path): + try: + path.relative_to(active_dir) + folder_rank = 0 + except ValueError: + try: + path.relative_to(hidden_dir) + folder_rank = 1 + except ValueError: + folder_rank = 2 + return (path.stem.lower(), folder_rank, str(path).lower()) + + for p in sorted(all_audio, key=voice_sort_key): + if p.stem in seen: + continue # prefer first extension found (wav beats mp3 etc.) + seen.add(p.stem) + entry = _voice_entry(p) + try: + p.relative_to(hidden_dir) + entry["enabled"] = False + except ValueError: + try: + p.relative_to(active_dir) + entry["enabled"] = True + except ValueError: + pass + voices.append(entry) + return voices + + +# ── Voice meta update ───────────────────────────────────────────────────────── + +@app.post("/api/voice/meta") +async def update_voice_meta(request: Request): + data = await request.json() + voice_id: str = data["voice_id"] + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + + wav = _find_voice_audio(voice_id, scan_dir) + if wav is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + + meta = _load_meta(wav) + if "enabled" in data: + enabled = bool(data["enabled"]) + wav = _move_voice_package(wav, _active_voices_dir(settings) if enabled else _hidden_voices_dir(settings)) + meta = _load_meta(wav) + meta["enabled"] = enabled + + for field in ("note", "rating", "flag", "gender", "loudness"): + if field in data: + meta[field] = data[field] + if "transcript" in data: + meta["needs_tts_restart"] = True + _save_meta(wav, meta) + if "transcript" in data: + wav.with_suffix(".reference.txt").write_text(data.get("transcript", "").strip(), encoding="utf-8") + return {"ok": True, "path": str(wav), "enabled": meta.get("enabled", True), + "transcript": wav.with_suffix(".reference.txt").read_text(encoding="utf-8").strip() + if wav.with_suffix(".reference.txt").exists() else ""} + + +@app.post("/api/voices/sync-folders") +async def sync_voice_folders(): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + active_dir = _active_voices_dir(settings) + hidden_dir = _hidden_voices_dir(settings) + moved = {"active": 0, "hidden": 0} + conflicts = [] + + if not scan_dir.exists(): + return {"moved": moved, "conflicts": conflicts} + + seen: set[str] = set() + all_audio = list(_voice_audio_files(scan_dir)) + + for p in sorted(all_audio, key=lambda x: x.stem.lower()): + if p.stem in seen or not p.exists(): + continue + seen.add(p.stem) + meta = _load_meta(p) + enabled = meta.get("enabled") is not False + target_dir = active_dir if enabled else hidden_dir + try: + new_audio = _move_voice_package(p, target_dir) + meta = _load_meta(new_audio) + meta["enabled"] = enabled + _save_meta(new_audio, meta) + moved["active" if enabled else "hidden"] += int(new_audio != p) + except HTTPException as e: + conflicts.append({"voice_id": p.stem, "detail": e.detail}) + + return {"moved": moved, "conflicts": conflicts, + "active_dir": str(active_dir), "hidden_dir": str(hidden_dir)} + + +@app.post("/api/voices/calculate-db") +async def calculate_voice_db(): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + results = [] + errors = [] + if not scan_dir.exists(): + return {"calculated": 0, "errors": errors, "voices": results} + + seen: set[str] = set() + all_audio = list(_voice_audio_files(scan_dir)) + + for audio in sorted(all_audio, key=lambda p: p.stem.lower()): + if audio.stem in seen: + continue + seen.add(audio.stem) + try: + loudness = _loudness_info(audio) + meta = _load_meta(audio) + meta["loudness"] = loudness + _save_meta(audio, meta) + results.append({"voice_id": audio.stem, "path": str(audio), "loudness": loudness}) + except Exception as e: + errors.append({"voice_id": audio.stem, "detail": str(e)}) + + return {"calculated": len(results), "errors": errors, "voices": results, + "target_dbfs": _VOICE_TARGET_DBFS, "peak_dbfs": _VOICE_PEAK_DBFS} + + +@app.post("/api/voices/benchmark") +async def benchmark_voices(request: Request): + data = await request.json() + active_only = bool(data.get("active_only", True)) + limit = int(data.get("limit") or 0) + sample_text = str(data.get("text") or "").strip() + sentences = [("sample", sample_text)] if sample_text else list(_BENCHMARK_SENTENCES) + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + active_dir = _active_voices_dir(settings) + results = [] + errors = [] + if not scan_dir.exists(): + return { + "benchmarked": 0, + "errors": errors, + "voices": results, + "text": sample_text or "short / medium / long", + "sentences": [{"label": label, "text": sent_text} for label, sent_text in sentences], + "active_only": active_only, + "max_tts_output_seconds": _MAX_TTS_OUTPUT_SECONDS, + } + + target_voice = str(data.get("voice_id") or "").strip() + seen: set[str] = set() + if target_voice: + audio = _find_voice_audio(target_voice, scan_dir) + if audio is None: + raise HTTPException(404, f"Voice '{target_voice}' not found") + all_audio = [audio] + else: + all_audio = list(_voice_audio_files(active_dir if active_only and active_dir.exists() else scan_dir)) + + for audio in sorted(all_audio, key=lambda p: p.stem.lower()): + if audio.stem in seen: + continue + seen.add(audio.stem) + if limit and len(results) >= limit: + break + benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, sentences) + try: + meta = _load_meta(audio) + meta["benchmark"] = benchmark + _save_meta(audio, meta) + except Exception as e: + errors.append({"voice_id": audio.stem, "detail": f"Could not save benchmark: {e}"}) + item = {"voice_id": audio.stem, "path": str(audio), "benchmark": benchmark} + results.append(item) + if not benchmark.get("ok", False): + errors.append({"voice_id": audio.stem, "detail": benchmark.get("error", "Benchmark failed")}) + + return { + "benchmarked": len(results), + "errors": errors, + "voices": results, + "text": sample_text or "short / medium / long", + "sentences": [{"label": label, "text": sent_text} for label, sent_text in sentences], + "active_only": active_only, + "max_tts_output_seconds": _MAX_TTS_OUTPUT_SECONDS, + } + + +@app.post("/api/voices/normalize-active") +async def normalize_active_voices(): + settings = _load_settings() + active_dir = _active_voices_dir(settings) + if not active_dir.exists(): + return {"normalized": 0, "skipped": 0, "errors": [], "target_dbfs": _VOICE_TARGET_DBFS} + + normalized = [] + skipped = [] + errors = [] + for wav in sorted((p for p in active_dir.rglob("*.wav") if not _is_internal_voice_file(p)), key=lambda p: p.stem.lower()): + try: + tmp = wav.with_suffix(".normalized.tmp.wav") + loudness = _export_normalized_wav(wav, tmp) + shutil.move(str(tmp), str(wav)) + meta = _load_meta(wav) + meta["enabled"] = True + meta["loudness"] = loudness + _save_meta(wav, meta) + normalized.append({"voice_id": wav.stem, **loudness}) + except Exception as e: + errors.append({"voice_id": wav.stem, "detail": str(e)}) + try: + tmp = wav.with_suffix(".normalized.tmp.wav") + if tmp.exists(): + tmp.unlink() + except Exception: + pass + + for ext in [e for e in _AUDIO_EXTS if e != ".wav"]: + skipped.extend(str(p) for p in active_dir.rglob(f"*{ext}") if not _is_internal_voice_file(p)) + + return { + "normalized": len(normalized), + "skipped": len(skipped), + "errors": errors, + "target_dbfs": _VOICE_TARGET_DBFS, + "peak_dbfs": _VOICE_PEAK_DBFS, + "voices": normalized, + } + + +@app.post("/api/voice/normalize") +async def normalize_voice(request: Request): + data = await request.json() + target_dbfs = float(data.get("target_dbfs", _VOICE_TARGET_DBFS)) + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + audio = _voice_audio_from_request(data, scan_dir) + if audio is None: + raise HTTPException(404, "Voice not found") + if audio.suffix.lower() != ".wav": + raise HTTPException(400, "Only WAV voices can be normalized in place") + + tmp = audio.with_suffix(".normalized.tmp.wav") + try: + loudness = _export_normalized_wav(audio, tmp, target_dbfs=target_dbfs) + shutil.move(str(tmp), str(audio)) + meta = _load_meta(audio) + meta["loudness"] = loudness + meta["needs_tts_restart"] = True + _save_meta(audio, meta) + return {"ok": True, "voice_id": audio.stem, "path": str(audio), + "duration": _duration(audio), "file_type": "wav", "loudness": loudness} + except Exception as e: + if tmp.exists(): + tmp.unlink() + raise HTTPException(400, f"Normalize failed: {e}") + + +@app.post("/api/voice/undo") +async def undo_voice_edit(request: Request): + data = await request.json() + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + current = _voice_audio_from_request(data, scan_dir) + if current is None: + raise HTTPException(404, "Voice not found") + meta = _load_meta(current) + backup = next((p for p in _backup_candidates(current, meta) if p.exists()), None) + if backup is None: + raise HTTPException(404, "No original backup found") + + original_suffix = _backup_audio_suffix(backup, current.stem) or current.suffix.lower() + restored = current.with_suffix(original_suffix) + shutil.copy2(str(backup), str(restored)) + if restored.resolve() != current.resolve(): + _remove_audio_variants(current.parent, current.stem, keep=restored) + try: + meta["loudness"] = _loudness_info(restored) + except Exception: + meta.pop("loudness", None) + meta["needs_tts_restart"] = True + _save_meta(restored, meta) + return { + "ok": True, + "voice_id": restored.stem, + "path": str(restored), + "duration": _duration(restored), + "file_type": restored.suffix.lower().lstrip("."), + "loudness": meta.get("loudness", {}), + } + + +# ── Voice rename ────────────────────────────────────────────────────────────── + +@app.post("/api/voice/rename") +async def rename_voice(request: Request): + data = await request.json() + old_id: str = data["old_id"] + new_id: str = data["new_id"].strip() + + if not re.match(r"^[A-Za-z0-9_\-\.]+$", new_id): + raise HTTPException(400, "Invalid voice ID characters") + + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + wav = _find_voice_audio(old_id, scan_dir) + if wav is None: + raise HTTPException(404, f"Voice '{old_id}' not found") + + parent = wav.parent + if any((parent / f"{new_id}{ext}").exists() for ext in _AUDIO_EXTS): + raise HTTPException(409, f"Voice '{new_id}' already exists") + + meta = _load_meta(wav) + backup = next((p for p in _backup_candidates(wav, meta) if p.exists()), None) + new_audio = parent / f"{new_id}{wav.suffix.lower()}" + new_backup = _backup_path(new_audio) + if backup and backup.exists() and backup.resolve() != new_backup.resolve() and new_backup.exists(): + raise HTTPException(409, f"Backup already exists for '{new_id}'") + + suffixes = _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS + for sfx in suffixes: + src = parent / f"{old_id}{sfx}" + if src.exists(): + src.rename(parent / f"{new_id}{sfx}") + + if backup and backup.exists(): + if backup.resolve() != new_backup.resolve(): + backup.rename(new_backup) + new_meta = _load_meta(new_audio) + new_meta["original_backup"] = str(new_backup) + _save_meta(new_audio, new_meta) + return {"new_id": new_id, "path": str(new_audio), "file_type": new_audio.suffix.lower().lstrip(".")} + + +# ── Voice delete ───────────────────────────────────────────────────────────── + +@app.delete("/api/voice/{voice_id}") +async def delete_voice(voice_id: str): + if not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): + raise HTTPException(400, "Invalid voice ID") + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + wav = _find_voice_audio(voice_id, scan_dir) + if wav is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + deleted = [] + for f in _voice_package_paths(wav): + if f.exists(): + f.unlink() + deleted.append(f.name) + return {"deleted": deleted} + + +# ── Voice picture upload ────────────────────────────────────────────────────── + +@app.post("/api/voice/picture") +async def upload_picture(voice_id: str = Form(...), file: UploadFile = File(...)): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + wav = _find_voice_audio(voice_id, scan_dir) + if wav is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + + orig_suffix = Path(file.filename or "photo.jpg").suffix.lower() + if orig_suffix not in _PICTURE_EXTS: + orig_suffix = ".jpg" + + # Remove any existing picture first + for ext in _PICTURE_EXTS: + old = wav.with_suffix(ext) + if old.exists(): + old.unlink() + + dest = wav.with_suffix(orig_suffix) + with dest.open("wb") as f: + _copy_limited(file.file, f, _MAX_PICTURE_BYTES) + + return {"ok": True, "path": str(dest)} + + +@app.post("/api/voice/picture-url") +async def upload_picture_url(request: Request): + data = await request.json() + voice_id = str(data.get("voice_id") or "").strip() + image_url = _source_import_url(str(data.get("image_url") or "")) + if not voice_id or not re.match(r"^[A-Za-z0-9_\-\.]+$", voice_id): + raise HTTPException(400, "Invalid voice ID") + + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + wav = _find_voice_audio(voice_id, scan_dir) + if wav is None: + raise HTTPException(404, f"Voice '{voice_id}' not found") + + suffix = Path(urlparse(image_url).path).suffix.lower() + if suffix not in _PICTURE_EXTS: + suffix = ".jpg" + dest = wav.with_suffix(suffix) + try: + with requests.get(image_url, headers=_VOICE_SOURCE_HEADERS, timeout=20, stream=True) as r: + r.raise_for_status() + content_type = (r.headers.get("content-type") or "").split(";", 1)[0].lower() + if content_type and not content_type.startswith("image/"): + raise HTTPException(400, "Image URL did not return an image") + for ext in _PICTURE_EXTS: + old = wav.with_suffix(ext) + if old.exists(): + old.unlink() + total = 0 + with dest.open("wb") as f: + for chunk in r.iter_content(256 * 1024): + if not chunk: + continue + total += len(chunk) + if total > _source_download_limit("_MAX_PICTURE_BYTES", 10): + raise HTTPException(413, "Image file is too large") + f.write(chunk) + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, f"Image import failed: {e}") + return {"ok": True, "voice_id": voice_id, "path": str(dest)} + + +# ── Voice picture serve ─────────────────────────────────────────────────────── + +@app.get("/api/voice/picture/{voice_id}") +async def serve_picture(voice_id: str): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + wav = _find_voice_audio(voice_id, scan_dir) + if wav is None: + raise HTTPException(404, "Voice not found") + pic = _picture_path(wav) + if pic is None: + raise HTTPException(404, "No picture") + return FileResponse(str(pic), media_type=_picture_mime(pic)) + + +# ── Serve voice WAV from library ────────────────────────────────────────────── + +@app.get("/api/voice-file") +async def voice_file(path: str): + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + p = _safe_child_path(scan_dir, Path(path)) + if not p.exists() or not p.is_file(): + raise HTTPException(404, "File not found") + if p.suffix.lower() not in _AUDIO_EXTS: + raise HTTPException(404, "File not found") + mime = _AUDIO_MIME.get(p.suffix.lower(), "audio/wav") + return FileResponse( + str(p), + media_type=mime, + headers={"Cache-Control": "no-store, max-age=0"}, + ) + + + +# -- External voice source discovery ----------------------------------------- + +_VOICE_SOURCE_HEADERS = { + "User-Agent": "TTS Voice Creator voice-source-scraper/1.0 (+local)", + "Accept": "text/html,application/json,text/plain;q=0.9,*/*;q=0.8", +} + +DEFAULT_VOICE_SOURCE_URLS = [ + "https://aiartes.com/voiceai", + "https://sample-files.com/downloads/audio/wav/voice-sample.wav", + "https://freesound.org/people/Scott%20Simpson/", + "https://lanceblairvo.com/raw-voiceover-samples/", + "https://github.com/yaph/tts-samples/tree/main/mp3", + "https://github.com/jim-schwoebel/voice_datasets", +] + +_DIRECT_AUDIO_RE = re.compile(r"\.(?:mp3|wav|ogg|flac|m4a|aac)(?:$|[?#])", re.I) +_LANG_HINTS = { + "english": "English", "german": "German", "deutsch": "German", "french": "French", + "spanish": "Spanish", "italian": "Italian", "portuguese": "Portuguese", + "dutch": "Dutch", "polish": "Polish", "russian": "Russian", "japanese": "Japanese", + "korean": "Korean", "chinese": "Chinese", "arabic": "Arabic", "swedish": "Swedish", + "turkish": "Turkish", "hindi": "Hindi", +} + + +def _source_get(url: str, timeout: int = 12) -> requests.Response: + r = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=timeout) + r.raise_for_status() + return r + + +def _plain_text(value: str) -> str: + value = re.sub(r"<[^>]+>", " ", str(value or "")) + value = unescape(value) + return re.sub(r"\s+", " ", value).strip() + + +def _voice_source_id(url: str) -> str: + parsed = urlparse(url) + base = (parsed.netloc + parsed.path).strip("/").lower() + base = re.sub(r"[^a-z0-9]+", "-", base).strip("-") + return base[:80] or "source" + + +def _source_name_from_url(url: str) -> str: + parsed = urlparse(url) + host = parsed.netloc.replace("www.", "") + tail = Path(parsed.path.rstrip("/")).stem.replace("-", " ").replace("_", " ").strip() + return f"{host} / {tail}" if tail else host or url + + +def _guess_language(*values: str) -> str: + text = " ".join(str(v or "") for v in values).lower() + for key, label in _LANG_HINTS.items(): + if re.search(rf"\b{re.escape(key)}\b", text): + return label + code = re.search(r"(?:^|[^a-z])(en|de|fr|es|it|pt|nl|pl|ru|ja|ko|zh|ar|sv|tr|hi)(?:[^a-z]|$)", text) + return {"en": "English", "de": "German", "fr": "French", "es": "Spanish", "it": "Italian", + "pt": "Portuguese", "nl": "Dutch", "pl": "Polish", "ru": "Russian", "ja": "Japanese", + "ko": "Korean", "zh": "Chinese", "ar": "Arabic", "sv": "Swedish", "tr": "Turkish", + "hi": "Hindi"}.get(code.group(1), "Unknown") if code else "Unknown" + + +def _guess_gender(*values: str) -> str: + text = " ".join(str(v or "") for v in values).lower() + if re.search(r"\b(female|woman|girl|fem|_f_|-f-)\b", text): + return "Female" + if re.search(r"\b(male|man|boy|masc|_m_|-m-)\b", text): + return "Male" + return "Unknown" + + +def _source_item(source_id: str, source_name: str, name: str, kind: str, + page_url: str, audio_url: str = "", image_url: str = "", + category: str = "", description: str = "", file_type: str = "", + language: str = "", gender: str = "") -> dict: + if not file_type and audio_url: + match = re.search(r"\.([A-Za-z0-9]+)(?:$|[?#])", audio_url) + file_type = match.group(1).lower() if match else "audio" + language = language or _guess_language(name, kind, category, description, page_url, audio_url) + gender = gender or _guess_gender(name, kind, category, description, page_url, audio_url) + return { + "id": f"{source_id}:{uuid.uuid5(uuid.NAMESPACE_URL, page_url + audio_url + name + kind)}", + "source_id": source_id, + "source": source_name, + "name": _plain_text(name)[:160], + "kind": _plain_text(kind)[:80], + "category": _plain_text(category)[:80], + "description": _plain_text(description)[:420], + "page_url": page_url, + "audio_url": audio_url, + "image_url": image_url, + "file_type": file_type, + "language": language, + "gender": gender, + "direct_audio": bool(audio_url), + } + + +def _source_result(source_id: str, name: str, homepage: str, description: str, items: list[dict]) -> dict: + return { + "id": source_id, + "name": name, + "homepage": homepage, + "description": description, + "items": items, + "count": len(items), + "direct_audio": sum(1 for item in items if item.get("direct_audio")), + } + + +def _direct_audio_source(url: str) -> dict: + source_id = _voice_source_id(url) + name = _source_name_from_url(url) + item = _source_item( + source_id, + name, + Path(urlparse(url).path).stem.replace("-", " ").replace("_", " ") or "Voice sample", + "Direct audio file", + url, + audio_url=url, + category="Direct audio", + description="Direct audio URL from the editable source list.", + ) + return _source_result(source_id, name, url, "Single direct audio URL.", [item]) + + +def _generic_audio_page_source(url: str) -> dict: + source_id = _voice_source_id(url) + source_name = _source_name_from_url(url) + body = _source_get(url).text + title = re.search(r"]*>(.*?)", body, re.I | re.S) + page_title = _plain_text(title.group(1)) if title else source_name + seen = set() + items = [] + for match in re.finditer(r"""(?:src|href)=["']([^"']+\.(?:mp3|wav|ogg|flac|m4a|aac)(?:[^"']*)?)["']""", body, re.I): + audio_url = urljoin(url, unescape(match.group(1))) + if audio_url in seen: + continue + seen.add(audio_url) + stem = Path(urlparse(audio_url).path).stem.replace("-", " ").replace("_", " ").strip() or page_title + items.append(_source_item( + source_id, + source_name, + stem, + "Audio sample", + url, + audio_url=audio_url, + category="Page audio", + description=f"Audio link found on {page_title}.", + )) + if not items: + items.append(_source_item( + source_id, + source_name, + page_title, + "Source page", + url, + category="Source page", + description="No direct audio file links were found. Open the source page for previews, licensing, and download details.", + )) + return _source_result(source_id, source_name, url, f"Audio links scraped from {page_title}.", items) + + +def _scrape_aiartes_voiceai() -> dict: + homepage = "https://aiartes.com/voiceai" + body = _source_get(homepage).text + items = [] + for card in body.split('
')[1:]: + title = re.search(r'
\s*([^<]+?)\s*
', card, re.S) + if not title: + continue + image = re.search(r']+src="([^"]+)"', card, re.S) + image_url = urljoin(homepage, image.group(1)) if image else "" + pairs = re.findall( + r'
\s*([^<]+?)\s*
\s*]*>\s* dict: + return _direct_audio_source("https://sample-files.com/downloads/audio/wav/voice-sample.wav") + + +def _scrape_freesound_scott_simpson() -> dict: + return _generic_audio_page_source("https://freesound.org/people/Scott%20Simpson/") + + +def _scrape_lanceblair_raw_samples() -> dict: + return _generic_audio_page_source("https://lanceblairvo.com/raw-voiceover-samples/") + + +def _scrape_yaph_tts_samples() -> dict: + homepage = "https://github.com/yaph/tts-samples/tree/main/mp3" + api = "https://api.github.com/repos/yaph/tts-samples/git/trees/main?recursive=1" + data = _source_get(api).json() + items = [] + for entry in data.get("tree", []): + path = entry.get("path", "") + if entry.get("type") != "blob" or not path.lower().startswith("mp3/") or not path.lower().endswith(".mp3"): + continue + parts = path.split("/") + language = parts[1] if len(parts) > 2 else "Unknown" + stem = Path(path).stem.replace("_", " ").replace("-", " ").strip() or Path(path).stem + raw_path = quote(path, safe="/") + raw_url = f"https://raw.githubusercontent.com/yaph/tts-samples/main/{raw_path}" + page_url = f"https://github.com/yaph/tts-samples/blob/main/{raw_path}" + items.append(_source_item( + "yaph-tts-samples", + "yaph/tts-samples", + stem, + "MP3 sample", + page_url, + audio_url=raw_url, + category=language, + description=f"Language folder: {language}. Synthetic TTS sample MP3 from yaph/tts-samples.", + file_type="mp3", + language=language, + )) + items.sort(key=lambda x: (x.get("category", ""), x.get("name", ""))) + return { + "id": "yaph-tts-samples", + "name": "yaph/tts-samples mp3", + "homepage": homepage, + "description": "GitHub-hosted TTS sample MP3 files grouped by language.", + "items": items, + "count": len(items), + "direct_audio": len(items), + } + + +def _scrape_jim_voice_datasets() -> dict: + homepage = "https://github.com/jim-schwoebel/voice_datasets" + raw = "https://raw.githubusercontent.com/jim-schwoebel/voice_datasets/master/README.md" + text = _source_get(raw).text + speech = text + start = text.find("### Speech datasets") + end = text.find("### Audio events", start if start >= 0 else 0) + if start >= 0: + speech = text[start:end if end >= 0 else len(text)] + items = [] + for name, url, desc in re.findall(r"^\* \[([^\]]+)\]\(([^)]+)\)\s*-\s*(.+)$", speech, re.M): + items.append(_source_item( + "voice-datasets", + "jim-schwoebel/voice_datasets", + name, + "Dataset link", + urljoin(homepage, url.strip()), + category="Speech dataset", + description=desc, + )) + return { + "id": "voice-datasets", + "name": "jim-schwoebel/voice_datasets", + "homepage": homepage, + "description": "Curated speech and voice dataset links from the repository README.", + "items": items, + "count": len(items), + "direct_audio": 0, + } + + +def _normalize_voice_source_urls(urls: list[str] | None = None) -> list[str]: + raw_urls = urls or DEFAULT_VOICE_SOURCE_URLS + normalized = [] + seen = set() + for value in raw_urls: + url = str(value or "").strip() + if not url or not re.match(r"^https?://", url, re.I): + continue + if url not in seen: + seen.add(url) + normalized.append(url) + return normalized[:24] + + +def _scrape_voice_source_url(url: str) -> dict: + lower = url.lower() + if "aiartes.com/voiceai" in lower: + return _scrape_aiartes_voiceai() + if "github.com/yaph/tts-samples" in lower: + return _scrape_yaph_tts_samples() + if "github.com/jim-schwoebel/voice_datasets" in lower: + return _scrape_jim_voice_datasets() + if "sample-files.com/downloads/audio/wav/voice-sample.wav" in lower: + return _scrape_sample_files_voice_sample() + if "freesound.org/people/scott%20simpson" in lower or "freesound.org/people/scott simpson" in lower: + return _scrape_freesound_scott_simpson() + if "lanceblairvo.com/raw-voiceover-samples" in lower: + return _scrape_lanceblair_raw_samples() + if _DIRECT_AUDIO_RE.search(lower): + return _direct_audio_source(url) + return _generic_audio_page_source(url) + + +def _voice_sources_payload(urls: list[str] | None = None) -> dict: + sources = [] + errors = [] + source_urls = _normalize_voice_source_urls(urls) + for url in source_urls: + try: + sources.append(_scrape_voice_source_url(url)) + except Exception as e: + errors.append({"source": url, "detail": str(e)}) + return { + "scraped_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "source_urls": source_urls, + "sources": sources, + "total": sum(src.get("count", 0) for src in sources), + "direct_audio": sum(src.get("direct_audio", 0) for src in sources), + "errors": errors, + } + + +@app.get("/api/voice-sources") +async def voice_sources(): + return _voice_sources_payload(DEFAULT_VOICE_SOURCE_URLS) + + +@app.post("/api/voice-sources") +async def voice_sources_custom(request: Request): + data = await request.json() + return _voice_sources_payload(data.get("urls") if isinstance(data, dict) else None) + +# ── TTS voices from server ──────────────────────────────────────────────────── + +def _active_library_voice_options(settings: dict) -> list[dict]: + active_dir = _active_voices_dir(settings) + voices = [] + seen = set() + if not active_dir.exists(): + return voices + for audio in sorted(_voice_audio_files(active_dir), key=lambda p: p.stem.lower()): + if audio.stem in seen: + continue + seen.add(audio.stem) + meta = _load_meta(audio) + if meta.get("enabled", True) is False: + continue + has_ref, transcript = _read_reference_text(audio) + try: + duration = round(_duration(audio), 2) + except Exception: + duration = None + voices.append({ + "id": audio.stem, + "name": audio.stem, + "duration": duration, + "has_ref": has_ref, + "has_transcript": bool(transcript), + }) + return voices + + +_TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers") + + +def _voice_ids_from_payload(payload) -> list: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + for key in ("data", "voices", "speakers"): + value = payload.get(key) + if isinstance(value, list): + if key == "data": + return [m.get("id", m) if isinstance(m, dict) else m for m in value] + return value + grouped = [] + for value in payload.values(): + if isinstance(value, list): + grouped.extend(value) + elif isinstance(value, dict): + nested = _voice_ids_from_payload(value) + if nested: + grouped.extend(nested) + return grouped + + +def _fetch_backend_voices(settings: dict, backend: str) -> list: + backend = _clean_preview_backend(backend) + if backend in {"nvidia_zeroshot", "nvidia_flow"}: + return _active_library_voice_options(settings) + tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") + key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or "" + tts_hdrs = {"Authorization": f"Bearer {key.strip()}"} if key.strip() else {} + for ep in _TTS_VOICE_ENDPOINTS: + try: + r = requests.get(f"{tts_url}{ep}", headers=tts_hdrs, timeout=5) + if r.status_code == 200: + voices = _voice_ids_from_payload(r.json()) + if voices: + return voices + except Exception: + continue + return [] + + +@app.get("/api/tts-voices") +async def tts_voices(backend: str = "voice_clone"): + return _fetch_backend_voices(_load_settings(), backend) + + +def _backend_port_label(url: str) -> str: + try: + parts = urlsplit(url) + if parts.port: + return str(parts.port) + except Exception: + pass + return "" + + +def _backend_display_name(backend: str, url: str) -> str: + names = { + "voice_clone": "Voice Clone/Base (WAV File)", + "voice_design": "Voice Design", + "customvoice": "CustomVoice", + "streaming": "Streaming (WAV File)", + "nvidia_magpie": "NVIDIA Magpie TTS", + "nvidia_zeroshot": "NVIDIA Magpie Zeroshot Clone", + "nvidia_flow": "NVIDIA Magpie Flow Clone", + } + port = _backend_port_label(url) + return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend) + + +def _backend_capabilities(backend: str) -> dict: + caps = { + "voice_clone": { + "purpose": "Clone a speaker from a short WAV/reference clip.", + "identity": "Strongest match to saved WAV voices.", + "style": "Weak per-request style; instruct may be ignored.", + "best_for": "Known voices, multilingual cloning, benchmarks, and reliable speaker identity.", + "uses_wav": True, + "style_aware": False, + "true_streaming": False, + }, + "voice_design": { + "purpose": "Create or reuse prompt-designed voices from natural-language descriptions.", + "identity": "Prompt persona, not the selected WAV speaker unless you first export/clone it.", + "style": "Strong style and emotion control through instruct text.", + "best_for": "New characters, personas, dialogue, and designing reference WAVs to clone later.", + "uses_wav": False, + "style_aware": True, + "true_streaming": False, + }, + "customvoice": { + "purpose": "Generate speech with the CustomVoice model voices.", + "identity": "Uses CustomVoice speakers, not arbitrary active WAV voices unless trained/configured there.", + "style": "Good per-request style and emotion control.", + "best_for": "Controlled style with configured CustomVoice speakers.", + "uses_wav": False, + "style_aware": True, + "true_streaming": False, + }, + "streaming": { + "purpose": "Low-latency playback from saved WAV/reference voices.", + "identity": "Same WAV voice identity path as Base.", + "style": "Weak per-request style in the current streaming server.", + "best_for": "Long text, assistants, Open WebUI/SillyTavern playback that can start before completion.", + "uses_wav": True, + "style_aware": False, + "true_streaming": True, + }, + "nvidia_magpie": { + "purpose": "Generate speech with NVIDIA Magpie fixed speaker voices.", + "identity": "Uses Magpie speaker aliases such as sofia, aria, jason, leo, and john; it is not a WAV voice-cloning model.", + "style": "Language and speaker are controlled by the backend voice config; per-request style text is usually ignored.", + "best_for": "Fast local NVIDIA TTS voices and OpenAI-compatible assistant playback.", + "uses_wav": False, + "style_aware": False, + "true_streaming": False, + }, + "nvidia_zeroshot": { + "purpose": "Clone a saved library voice through NVIDIA Magpie TTS Zeroshot NIM.", + "identity": "Sends the selected WAV as audio_prompt; no prompt transcript is required.", + "style": "Best with a clear 3-10 second prompt. Optional quality params can be configured in Settings.", + "best_for": "Fast NVIDIA reference-audio cloning, streaming-class use cases, live agents, and games.", + "uses_wav": True, + "style_aware": False, + "true_streaming": False, + }, + "nvidia_flow": { + "purpose": "Clone a saved library voice through NVIDIA Magpie TTS Flow NIM.", + "identity": "Sends the selected WAV plus its exact saved reference transcript.", + "style": "Offline high-fidelity clone path; prompt transcript must match the reference audio.", + "best_for": "Studio-style dubbing, narration, and podcast-quality offline generation.", + "uses_wav": True, + "style_aware": False, + "true_streaming": False, + }, + } + return caps.get(_clean_preview_backend(backend), {}) + + +def _backend_health(url: str) -> bool: + base = url.rstrip("/") + for ep in ("/health", "/v1/health", "/v1/audio/list_voices"): + try: + r = requests.get(f"{base}{ep}", timeout=2) + if r.status_code == 200: + return True + except Exception: + continue + return False + + +def _backend_available(backend: str, voices: list, health: bool) -> bool: + if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow"}: + return health + return bool(voices) or health + + +@app.get("/api/tts-backends") +async def tts_backends(): + settings = _load_settings() + items = [] + for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"): + url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") + voices = _fetch_backend_voices(settings, backend) + health = _backend_health(url) + available = _backend_available(backend, voices, health) + items.append({ + "id": backend, + "label": _backend_display_name(backend, url), + "url": url, + "port": _backend_port_label(url), + "available": available, + "voice_count": len(voices) if isinstance(voices, list) else 0, + **_backend_capabilities(backend), + }) + return {"backends": items} + + + +def _clear_tts_restart_flags(settings: dict | None = None) -> int: + cleared = 0 + settings = settings or _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + if scan_dir.exists(): + seen: set[str] = set() + for audio in _voice_audio_files(scan_dir): + if audio.stem in seen: + continue + seen.add(audio.stem) + meta = _load_meta(audio) + if meta.pop("needs_tts_restart", None) is not None: + _save_meta(audio, meta) + cleared += 1 + return cleared + +@app.post("/api/tts/restart") +async def restart_tts_container(): + container = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip() + if not container: + raise HTTPException(400, "TTS_CONTAINER_NAME is not configured") + + path = f"/containers/{quote(container, safe='')}/restart?t=10" + try: + code, raw = _docker_post(path) + except PermissionError: + raise HTTPException(502, "No permission to access /var/run/docker.sock from this container") + except Exception as e: + raise HTTPException(502, f"Docker restart failed: {e}") + + if code not in (204, 304): + detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"Docker API returned HTTP {code}" + raise HTTPException(502, detail) + + cleared = 0 + try: + cleared = _clear_tts_restart_flags() + except Exception as e: + logger.warning("Could not clear TTS restart flags: %s", e) + return {"ok": True, "container": container, "cleared_restart_flags": cleared} + + +@app.post("/api/tts/restart-flags/clear") +async def clear_tts_restart_flags(): + try: + cleared = _clear_tts_restart_flags() + except Exception as e: + raise HTTPException(500, f"Could not clear TTS restart flags: {e}") + return {"ok": True, "cleared_restart_flags": cleared} + + +# ── TTS preview ─────────────────────────────────────────────────────────────── + +def _tts_request_config(text: str, voice: str, settings: dict, response_format: str = "wav", instruct: str = "", url_override: str = "", api_key_override: str | None = None, backend_override: str = "", extra_backend: str = "") -> tuple[str, dict, dict]: + tts_url = _validate_http_url(url_override or settings.get("tts_url", _TTS_DEFAULT), allow_private=True).rstrip("/") + backend = backend_override or settings.get("tts_backend", "openai") + tts_key = (api_key_override if api_key_override is not None else settings.get("tts_api_key", "")).strip() + tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {} + + if backend == "localai": + endpoint, payload = f"{tts_url}/tts", {"input": text, "model": voice, "response_format": response_format} + elif backend == "pocket": + endpoint, payload = f"{tts_url}/v1/audio/speech", {"input": text, "voice": voice, "response_format": response_format} + else: + endpoint, payload = f"{tts_url}/v1/audio/speech", {"model": "tts-1", "input": text, "voice": voice, "response_format": response_format} + if instruct.strip(): + payload["instruct"] = instruct.strip() + _apply_tts_extra_params(payload, settings, extra_backend or backend_override or "voice_clone") + return endpoint, payload, tts_hdrs + + +def _tts_request_audio(text: str, voice: str, settings: dict, instruct: str = "", url_override: str = "", api_key_override: str | None = None, backend_override: str = "", extra_backend: str = "") -> tuple[bytes, str]: + endpoint, payload, tts_hdrs = _tts_request_config(text, voice, settings, "wav", instruct, url_override, api_key_override, backend_override, extra_backend) + resp = _post_tts_with_fallback(endpoint, payload, tts_hdrs, timeout=120) + resp.raise_for_status() + audio = resp.content + if not audio: + raise RuntimeError("backend returned empty audio") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return audio, media_type + + +def _wav_data_offset(data: bytes) -> int | None: + if len(data) < 12: + return None + if data[:4] != b"RIFF" or data[8:12] != b"WAVE": + return 0 + pos = 12 + while pos + 8 <= len(data): + chunk_sz = struct.unpack_from(" float | None: + try: + source_format = "wav" if audio[:4] == b"RIFF" or "wav" in media_type.lower() else None + return len(AudioSegment.from_file(io.BytesIO(audio), format=source_format)) / 1000.0 + except Exception: + offset = _wav_data_offset(audio) + if offset is not None and offset > 0 and len(audio) > offset: + return (len(audio) - offset) / (24000 * 2) + return None + + +def _tts_benchmark_request(text: str, voice: str, settings: dict, label: str) -> dict: + endpoint, payload, tts_hdrs = _tts_request_config(text, voice, settings, "wav") + start = time.perf_counter() + first_audio_at = None + raw = bytearray() + media_type = "audio/wav" + + with _post_tts_with_fallback(endpoint, payload, tts_hdrs, stream=True, timeout=180) as resp: + resp.raise_for_status() + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + for chunk in resp.iter_content(chunk_size=512): + if not chunk: + continue + raw.extend(chunk) + if first_audio_at is None: + if payload.get("response_format") == "wav" or "wav" in media_type.lower(): + offset = _wav_data_offset(bytes(raw)) + if offset is not None and len(raw) > offset: + first_audio_at = time.perf_counter() + else: + first_audio_at = time.perf_counter() + + total = time.perf_counter() - start + if not raw: + raise RuntimeError("backend returned empty audio") + audio_sec = _audio_duration_from_bytes(bytes(raw), media_type) + rtf = total / audio_sec if audio_sec and audio_sec > 0 else None + speed = audio_sec / total if audio_sec and total > 0 else None + return { + "ok": True, + "label": label, + "text": text, + "ttfa_ms": round(((first_audio_at or time.perf_counter()) - start) * 1000, 1), + "total_sec": round(total, 3), + "audio_sec": round(audio_sec, 3) if audio_sec is not None else None, + "rtf": round(rtf, 3) if rtf is not None else None, + "speed": round(speed, 3) if speed is not None else None, + "bytes": len(raw), + } + + +def _apply_voice_design_gender(instruct: str, gender: str) -> str: + gender = (gender or "").strip().upper() + if gender == "F": + prefix = ( + "MANDATORY SPEAKER IDENTITY: Female speaker / woman.\n" + "gender: Female.\n" + "Use a clearly feminine vocal timbre, light-to-medium resonance, and soprano or mezzo-soprano pitch range.\n" + "Avoid male baritone, bass, chest-heavy, or masculine vocal qualities." + ) + elif gender == "M": + prefix = ( + "MANDATORY SPEAKER IDENTITY: Male speaker / man.\n" + "gender: Male.\n" + "Use a clearly masculine vocal timbre, medium-to-deep resonance, and tenor, baritone, or bass pitch range.\n" + "Avoid feminine soprano or mezzo-soprano vocal qualities." + ) + else: + return instruct + return f"{prefix}\n\n{instruct.strip()}" + + +def _voice_design_request_audio(instruct: str, text: str, language: str, settings: dict, + gender: str = "") -> tuple[bytes, str]: + vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/") + vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip() + vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {} + payload = { + "model": _VOICE_DESIGN_MODEL, + "input": text, + "instruct": _apply_voice_design_gender(instruct, gender), + "language": language, + "response_format": "wav", + } + _apply_tts_extra_params(payload, settings, "voice_design") + resp = _post_tts_with_fallback(f"{vd_url}/v1/audio/speech", payload, vd_hdrs, timeout=180) + resp.raise_for_status() + audio = resp.content + if not audio: + raise RuntimeError("backend returned empty audio") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return audio, media_type + + + + +def _voice_design_voice_request_audio(voice: str, text: str, settings: dict, instruct: str = "", language: str = "Auto") -> tuple[bytes, str]: + vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/") + vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip() + vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {} + payload = { + "model": _VOICE_DESIGN_MODEL, + "input": text, + "voice": voice, + "response_format": "wav", + } + if instruct.strip(): + payload["instruct"] = instruct.strip() + if language and language != "Auto": + payload["language"] = language + _apply_tts_extra_params(payload, settings, "voice_design") + resp = _post_tts_with_fallback(f"{vd_url}/v1/audio/speech", payload, vd_hdrs, timeout=180) + resp.raise_for_status() + audio = resp.content + if not audio: + raise RuntimeError("backend returned empty audio") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return audio, media_type + + +_NVIDIA_LANGUAGE_CODES = { + "EN": "en-US", "DE": "de-DE", "ES": "es-ES", "FR": "fr-FR", + "IT": "it-IT", "PT": "pt-PT", "NL": "nl-NL", "PL": "pl-PL", +} + + +def _nvidia_clone_language_code(voice: str, language: str = "") -> str: + raw = str(language or "").strip() + if raw and raw.lower() not in {"auto", "*"}: + return raw + prefix = str(voice or "").split("_", 1)[0].upper() + return _NVIDIA_LANGUAGE_CODES.get(prefix, "en-US") + + +def _form_value(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _nvidia_clone_request_audio(text: str, voice: str, settings: dict, mode: str = "zeroshot", + reference_transcript: str = "", language: str = "") -> tuple[bytes, str]: + mode = "flow" if str(mode).lower().endswith("flow") else "zeroshot" + backend = "nvidia_flow" if mode == "flow" else "nvidia_zeroshot" + base_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") + key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip() + headers = {"Authorization": f"Bearer {key}"} if key else {} + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + audio_path = _find_voice_audio(voice, scan_dir) + if audio_path is None: + raise RuntimeError(f"reference voice not found in library: {voice}") + prompt_wav = _to_wav_24k(audio_path) + has_ref, saved_transcript = _read_reference_text(audio_path) + prompt_transcript = str(reference_transcript or saved_transcript or "").strip() + if mode == "flow" and not prompt_transcript: + raise RuntimeError("NVIDIA Magpie Flow requires the selected voice to have an exact saved reference transcript") + data = { + "language": _nvidia_clone_language_code(voice, language), + "text": text, + } + if mode == "flow": + data["audio_prompt_transcript"] = prompt_transcript + params = _tts_extra_params(settings, backend) + for k, v in params.items(): + if k in {"audio_prompt", "audio_prompt_transcript", "text", "language"}: + continue + data[k] = _form_value(v) + endpoint = f"{base_url}/v1/audio/synthesize" + with prompt_wav.open("rb") as f: + files = {"audio_prompt": ("prompt.wav", f, "audio/wav")} + resp = requests.post(endpoint, data=data, files=files, headers=headers, timeout=180) + if resp.status_code in {400, 404, 415, 422} and params: + try: + resp.close() + except Exception: + pass + fallback = {k: v for k, v in data.items() if k in {"language", "text", "audio_prompt_transcript"}} + with prompt_wav.open("rb") as f: + files = {"audio_prompt": ("prompt.wav", f, "audio/wav")} + resp = requests.post(endpoint, data=fallback, files=files, headers=headers, timeout=180) + resp.raise_for_status() + audio = resp.content + if not audio: + raise RuntimeError("NVIDIA clone backend returned empty audio") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return audio, media_type + + +def _preview_request_audio(text: str, voice: str, settings: dict, instruct: str = "", backend: str = "voice_clone") -> tuple[bytes, str]: + backend = _clean_preview_backend(backend) + if backend == "streaming": + resp = _open_tts_stream_response(text, voice, settings, instruct) + audio, media_type = _read_tts_stream_response(resp) + if not audio: + raise RuntimeError("backend returned empty audio") + return audio, media_type + if backend == "customvoice": + return _tts_request_audio( + text, + voice, + settings, + instruct, + _preview_backend_base_url(settings, "customvoice"), + settings.get("tts_api_key", ""), + "openai", + "customvoice", + ) + if backend == "voice_design": + return _voice_design_voice_request_audio(voice, text, settings, instruct) + if backend == "nvidia_magpie": + return _tts_request_audio( + text, + voice, + settings, + instruct, + _preview_backend_base_url(settings, "nvidia_magpie"), + settings.get("tts_api_key", ""), + "nvidia_magpie", + "nvidia_magpie", + ) + if backend == "nvidia_zeroshot": + return _nvidia_clone_request_audio(text, voice, settings, "zeroshot") + if backend == "nvidia_flow": + return _nvidia_clone_request_audio(text, voice, settings, "flow") + return _tts_request_audio(text, voice, settings, instruct) + + +def _requested_response_format(data: dict) -> str: + requested = str(data.get("response_format") or data.get("format") or "wav").strip().lower() + if requested in {"mp3", "mpeg"}: + return "mp3" + if requested in {"wav", "pcm"}: + return "wav" + return "wav" + + +def _audio_ext_media(response_format: str) -> tuple[str, str]: + if response_format == "mp3": + return "mp3", "audio/mpeg" + return "wav", "audio/wav" + + +def _route_sound_path(settings: dict, value: str) -> Path | None: + value = str(value or "").strip() + if not value: + return None + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + candidate = Path(value) + if not candidate.is_absolute(): + candidate = scan_dir / candidate + path = _safe_child_path(scan_dir, candidate) + if not path.exists() or not path.is_file() or path.suffix.lower() not in _AUDIO_EXTS: + raise RuntimeError(f"Route sound not found or unsupported: {value}") + return path + + +def _sound_segment(path: Path) -> AudioSegment: + return AudioSegment.from_file(str(path)).set_channels(1).set_sample_width(2).set_frame_rate(24000) + + +def _apply_route_sounds(audio: bytes, media_type: str, route: dict | None, settings: dict) -> tuple[bytes, str, list[str]]: + if not route: + return audio, media_type, [] + before = _route_sound_path(settings, str(route.get("before_sound", ""))) + after = _route_sound_path(settings, str(route.get("after_sound", ""))) + if not before and not after: + return audio, media_type, [] + + source_format = "wav" if audio[:4] == b"RIFF" or "wav" in media_type.lower() else None + speech = AudioSegment.from_file(io.BytesIO(audio), format=source_format) + speech = speech.set_channels(1).set_sample_width(2).set_frame_rate(24000) + combined = AudioSegment.empty() + applied = [] + if before: + combined += _sound_segment(before) + applied.append(f"before:{before.name}") + combined += speech + if after: + combined += _sound_segment(after) + applied.append(f"after:{after.name}") + + out = io.BytesIO() + combined.export(out, format="wav") + return out.getvalue(), "audio/wav", applied + + +def _prepare_proxy_audio(audio: bytes, media_type: str, response_format: str) -> tuple[bytes, str, str, float | None, bool]: + """Rewrite backend audio so clients receive playable headers and requested format.""" + try: + source_format = "wav" if audio[:4] == b"RIFF" or "wav" in media_type.lower() else None + segment = AudioSegment.from_file(io.BytesIO(audio), format=source_format) + except Exception: + ext, wanted_media = _audio_ext_media(response_format) + return audio, media_type or wanted_media, ext, None, False + + duration = len(segment) / 1000.0 + clipped = False + if _MAX_TTS_OUTPUT_SECONDS > 0 and duration > _MAX_TTS_OUTPUT_SECONDS: + segment = segment[:int(_MAX_TTS_OUTPUT_SECONDS * 1000)] + duration = len(segment) / 1000.0 + clipped = True + ext, wanted_media = _audio_ext_media(response_format) + out = io.BytesIO() + export_format = "mp3" if response_format == "mp3" else "wav" + segment.export(out, format=export_format) + return out.getvalue(), wanted_media, ext, duration, clipped + + +def _parse_voice_design_dialogue(instruct: str, script: str) -> tuple[dict[str, str], list[tuple[str, str]]] | None: + speakers: dict[str, str] = {} + for raw in instruct.splitlines(): + line = raw.strip() + if not line: + continue + match = re.match(r'^"?([^":]+)"?\s*:\s*"?(.+?)"?$', line) + if match: + speakers[match.group(1).strip()] = match.group(2).strip() + + turns: list[tuple[str, str]] = [] + for raw in script.splitlines(): + line = raw.strip() + if not line: + continue + match = re.match(r"^([^:]{1,40}):\s*(.+)$", line) + if match: + speaker = match.group(1).strip() + text = match.group(2).strip() + if speaker in speakers and text: + turns.append((speaker, text)) + + if len(speakers) < 2 or len(turns) < 2: + return None + if len({speaker for speaker, _text in turns}) < 2: + return None + return speakers, turns + + +def _infer_voice_design_gender(description: str) -> str: + text = f" {description.lower()} " + if re.search(r"\b(female|woman|girl|feminine|soprano|mezzo-soprano|mezzo)\b", text): + return "F" + if re.search(r"\b(male|man|boy|masculine|tenor|baritone|bass)\b", text): + return "M" + return "" + + +def _audio_segment_from_wav(audio: bytes) -> AudioSegment: + segment = AudioSegment.from_file(io.BytesIO(audio), format="wav") + return segment.set_channels(1).set_sample_width(2).set_frame_rate(24000) + + +def _wav_bytes_from_segment(segment: AudioSegment) -> bytes: + out = io.BytesIO() + segment.export(out, format="wav") + return out.getvalue() + + +def _voice_design_dialogue_request_audio( + speakers: dict[str, str], + turns: list[tuple[str, str]], + language: str, + settings: dict, +) -> tuple[bytes, str]: + combined = AudioSegment.silent(duration=120, frame_rate=24000).set_channels(1).set_sample_width(2) + pause = AudioSegment.silent(duration=180, frame_rate=24000).set_channels(1).set_sample_width(2) + + for speaker, text in turns: + description = speakers[speaker] + turn_instruct = f'Speaker "{speaker}".\n{description}' + audio, _media_type = _voice_design_request_audio( + turn_instruct, + text, + language, + settings, + gender=_infer_voice_design_gender(description), + ) + combined += _audio_segment_from_wav(audio) + pause + + return _wav_bytes_from_segment(combined), "audio/wav" + + +@app.post("/api/tts-preview") +async def tts_preview(request: Request): + data = await request.json() + text: str = data["text"] + voice: str = data["voice"] + response_format = _requested_response_format(data) + instruct = str(data.get("instruct") or data.get("style_instruction") or "") + backend = _clean_preview_backend(data.get("backend", "voice_clone")) + settings = _load_settings() + + try: + audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend) + except Exception as e: + raise HTTPException(502, f"TTS error: {e}") + audio, media_type, ext, _duration_sec, _clipped = _prepare_proxy_audio(audio, media_type, response_format) + return Response( + content=audio, + media_type=media_type, + headers={"Content-Disposition": f'inline; filename="{voice}_preview.{ext}"'}, + ) + + +@app.post("/api/tts-style-variation") +async def tts_style_variation(request: Request): + data = await request.json() + source_voice = str(data.get("source_voice") or data.get("voice") or "").strip() + new_voice_id = str(data.get("voice_id") or data.get("new_voice_id") or "").strip() + text = str(data.get("text") or data.get("transcript") or "").strip() + instruct = str(data.get("instruct") or data.get("style_instruction") or "").strip() + backend = _clean_preview_backend(data.get("backend", "customvoice")) + if not source_voice: + raise HTTPException(400, "source_voice is required") + if not new_voice_id: + raise HTTPException(400, "new voice id is required") + if not re.match(r"^[A-Za-z0-9_\-\.]+$", new_voice_id): + raise HTTPException(400, "Voice ID may only contain A-Z, 0-9, _, -, .") + if not text: + raise HTTPException(400, "text/transcript is required") + if not instruct: + raise HTTPException(400, "style instruction is required") + + settings = _load_settings() + try: + audio, media_type = await asyncio.to_thread(_preview_request_audio, text, source_voice, settings, instruct, backend) + audio, media_type, _ext, duration, _clipped = _prepare_proxy_audio(audio, media_type, "wav") + except Exception as e: + raise HTTPException(502, f"Style variation synthesis failed: {e}") + + tmp = TEMP_DIR / f"{uuid.uuid4().hex}_style_variation.wav" + tmp.write_bytes(audio) + out_dir = _active_voices_dir(settings) + out_dir.mkdir(parents=True, exist_ok=True) + wav_dest = out_dir / f"{new_voice_id}.wav" + txt_dest = out_dir / f"{new_voice_id}.reference.txt" + _remove_audio_variants(out_dir, new_voice_id) + loudness = _export_normalized_wav(tmp, wav_dest) + txt_dest.write_text(text, encoding="utf-8") + meta = _load_meta(wav_dest) + meta.update({ + "enabled": True, + "loudness": loudness, + "source_voice": source_voice, + "style_instruction": instruct, + "style_backend": backend, + "note": f"Style variation of {source_voice}: {instruct[:180]}", + "needs_tts_restart": True, + }) + _save_meta(wav_dest, meta) + return { + "ok": True, + "voice_id": new_voice_id, + "source_voice": source_voice, + "backend": backend, + "wav": str(wav_dest), + "txt": str(txt_dest), + "duration": duration, + "loudness": loudness, + "needs_tts_restart": True, + } + + +_TTS_STREAM_SESSION_TTL = 15 * 60 +_tts_stream_sessions: dict[str, dict] = {} + + +def _purge_tts_stream_sessions() -> None: + now = time.time() + expired = [sid for sid, item in _tts_stream_sessions.items() if now - item.get("created", 0) > _TTS_STREAM_SESSION_TTL] + for sid in expired: + _tts_stream_sessions.pop(sid, None) + + +def _tts_stream_request_config(text: str, voice: str, settings: dict, instruct: str = "") -> tuple[str, dict, dict]: + stream_url = settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT + tts_url = _validate_http_url(stream_url, allow_private=True).rstrip("/") + tts_key = settings.get("tts_api_key", "").strip() + tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {} + payload = {"model": "tts-1", "input": text, "voice": voice, "response_format": "wav"} + if instruct.strip(): + payload["instruct"] = instruct.strip() + _apply_tts_extra_params(payload, settings, "streaming") + return f"{tts_url}/v1/audio/speech", payload, tts_hdrs + + +def _open_tts_stream_response(text: str, voice: str, settings: dict, instruct: str = "") -> requests.Response: + endpoint, payload, tts_hdrs = _tts_stream_request_config(text, voice, settings, instruct) + resp = _post_tts_with_fallback(endpoint, payload, tts_hdrs, stream=True, timeout=(10, 900)) + try: + resp.raise_for_status() + except Exception as exc: + detail = "" + try: + detail = resp.text[:500] + except Exception: + pass + resp.close() + raise RuntimeError(f"streaming backend error: {exc}{(': ' + detail) if detail else ''}") from exc + return resp + + + + +def _read_tts_stream_response(resp: requests.Response) -> tuple[bytes, str]: + try: + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return b"".join(chunk for chunk in resp.iter_content(chunk_size=64 * 1024) if chunk), media_type + finally: + resp.close() + +def _iter_tts_stream_response(resp: requests.Response): + try: + for chunk in resp.iter_content(chunk_size=64 * 1024): + if chunk: + yield chunk + finally: + resp.close() + + +@app.get("/api/tts-stream-health") +async def tts_stream_health(): + settings = _load_settings() + try: + stream_url = settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT + base_url = _validate_http_url(stream_url, allow_private=True).rstrip("/") + resp = await asyncio.to_thread(requests.get, f"{base_url}/health", timeout=3) + return {"ok": resp.ok, "status_code": resp.status_code, "url": base_url} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@app.post("/api/tts-stream-session") +async def tts_stream_session(request: Request): + data = await request.json() + text = str(data.get("text", "")).strip() + voice = str(data.get("voice", "")).strip() + if not voice: + raise HTTPException(400, "voice is required") + if not text: + raise HTTPException(400, "text is required") + _purge_tts_stream_sessions() + sid = uuid.uuid4().hex + instruct = str(data.get("instruct") or data.get("style_instruction") or "").strip() + _tts_stream_sessions[sid] = {"created": time.time(), "text": text, "voice": voice, "instruct": instruct} + return {"ok": True, "url": f"/api/tts-stream-session/{sid}"} + + +@app.get("/api/tts-stream-session/{sid}") +async def tts_stream_playback(sid: str): + _purge_tts_stream_sessions() + item = _tts_stream_sessions.pop(sid, None) + if item is None: + raise HTTPException(404, "stream session expired or not found") + settings = _load_settings() + try: + resp = await asyncio.to_thread(_open_tts_stream_response, item["text"], item["voice"], settings, item.get("instruct", "")) + except Exception as e: + raise HTTPException(502, f"TTS stream error: {e}") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return StreamingResponse( + _iter_tts_stream_response(resp), + media_type=media_type, + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + "Content-Disposition": f"inline; filename=\"{item['voice']}_stream.wav\"", + }, + ) + + +# ── Voice design ────────────────────────────────────────────────────────────── + +@app.post("/api/voice-design") +async def voice_design(request: Request): + data = await request.json() + instruct: str = data.get("instruct", "").strip() + sample_text: str = data.get("sample_text", "Hello! This is a voice design sample.").strip() + language: str = data.get("language", "Auto") + gender: str = data.get("gender", "") + dialogue = bool(data.get("dialogue")) + + if not instruct: + raise HTTPException(400, "instruct (voice description) is required") + + settings = _load_settings() + try: + if dialogue: + parsed = _parse_voice_design_dialogue(instruct, sample_text) + if not parsed: + raise RuntimeError("dialogue mode needs speaker profiles and Speaker: text turns") + audio, _media_type = await asyncio.to_thread( + _voice_design_request_audio, + instruct, + sample_text, + language, + settings, + "", + ) + else: + audio, _media_type = await asyncio.to_thread( + _voice_design_request_audio, + instruct, + sample_text, + language, + settings, + gender, + ) + except Exception as e: + raise HTTPException(502, f"Voice design error: {e}") + + audio, _media_type, _ext, _duration_sec, _clipped = _prepare_proxy_audio(audio, "audio/wav", "wav") + tmp = TEMP_DIR / f"{uuid.uuid4().hex}_designed.wav" + tmp.write_bytes(audio) + + fid = uuid.uuid4().hex + _registry[fid] = tmp + return {"id": fid, "duration": _duration(tmp)} + + +# ── OpenAI-compatible proxy for exported and virtual VoiceDesign voices ─────── + +def _virtual_voice_id(name: str) -> str: + return f"vd_{_slug_voice_design_name(name)}" + + +def _resolve_virtual_voice(voice: str) -> tuple[str, dict] | None: + if not voice.startswith("vd_"): + return None + wanted = _slug_voice_design_name(voice[3:]) + presets = _load_design_presets() + for name, preset in presets.items(): + if _slug_voice_design_name(name) == wanted: + return name, preset + return None + + +@app.get("/v1/models") +async def openai_models_proxy(): + now = 1686935002 + data = [] + seen = set() + for name in sorted(_load_design_presets()): + seen.add(_virtual_voice_id(name)) + data.append({ + "id": _virtual_voice_id(name), + "object": "model", + "created": now, + "owned_by": "voice-design", + }) + + for rule in _load_tts_routes(): + alias = str(rule.get("input_voice", "")).strip() + if rule.get("enabled", True) and alias and alias != "*" and alias not in seen: + seen.add(alias) + data.append({ + "id": alias, + "object": "model", + "created": now, + "owned_by": f"route:{rule.get('app', '*')}", + }) + + try: + voices = await tts_voices() + for item in voices: + if isinstance(item, str): + voice_id = item + elif isinstance(item, dict): + voice_id = item.get("id") or item.get("voice") + else: + voice_id = str(item) if item is not None else "" + if voice_id and voice_id not in seen: + seen.add(str(voice_id)) + data.append({ + "id": str(voice_id), + "object": "model", + "created": now, + "owned_by": "qwen", + }) + except Exception: + pass + return {"object": "list", "data": data} + + +@app.get("/v1/audio/models") +async def openai_audio_models_proxy(): + return await openai_models_proxy() + + +@app.get("/v1/audio/voices") +async def openai_audio_voices_proxy(): + models = await openai_models_proxy() + return [m["id"] for m in models["data"]] + + +@app.post("/api/tts-route-test") +async def tts_route_test(request: Request): + data = await request.json() + text = str(data.get("input") or data.get("text") or "").strip() + voice = str(data.get("voice") or "default").strip() + app_name = _canonical_app_name(str(data.get("app") or data.get("client") or "Open WebUI").strip()) + if not text: + raise HTTPException(400, "input is required") + routed_voice, route = _resolve_tts_route(app_name, voice, text) + backend = _route_backend(route, routed_voice) + settings = _load_settings() + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + routed_audio = None if backend in {"voice_design", "nvidia_magpie"} else _find_voice_audio(routed_voice, scan_dir) + sound_status = {} + for key in ("before_sound", "after_sound"): + raw = str((route or {}).get(key, "")) + if raw: + try: + path = _route_sound_path(settings, raw) + sound_status[key] = {"ok": True, "path": str(path)} + except Exception as e: + sound_status[key] = {"ok": False, "error": str(e), "path": raw} + result = { + "app": app_name, + "requested_voice": voice, + "routed_voice": routed_voice, + "backend": backend, + "detected_language": str(route.get("detected_language", _detect_text_language(text))) if route else _detect_text_language(text), + "matched": bool(route), + "route": route, + "voice_health": _voice_health(routed_audio) if routed_audio else None, + "sounds": sound_status, + } + _routing_log_add( + kind="test", + status="matched" if route else "no_match", + app=app_name, + requested_voice=voice, + routed_voice=routed_voice, + backend=backend, + language=result["detected_language"], + matched=bool(route), + route_id=str((route or {}).get("id", "")), + text_preview=text[:160], + sounds=sound_status, + ) + return result + + +@app.post("/v1/audio/speech") +async def openai_speech_proxy(request: Request): + data = await request.json() + text = str(data.get("input") or data.get("text") or "").strip() + voice = str(data.get("voice") or data.get("model") or "").strip() + response_format = _requested_response_format(data) + request_app = _canonical_app_name(str(data.get("app") or data.get("client") or "").strip()) if (data.get("app") or data.get("client")) else _request_app_name(request) + original_voice = voice + if not text: + _routing_log_request( + request, + status="error", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend="", + route=None, + response_format=response_format, + text=text, + error="input is required", + ) + raise HTTPException(400, "input is required") + if not voice: + _routing_log_request( + request, + status="error", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend="", + route=None, + response_format=response_format, + text=text, + error="voice is required", + ) + raise HTTPException(400, "voice is required") + + settings = _load_settings() + voice, route = _resolve_tts_route(request_app, voice, text) + backend = _route_backend(route, voice) + style_instruction = str(data.get("instruct") or data.get("style_instruction") or "") + virtual = _resolve_virtual_voice(voice) + route_has_sounds = bool((route or {}).get("before_sound") or (route or {}).get("after_sound")) + + if backend == "streaming" and not virtual and response_format == "wav" and not route_has_sounds: + try: + resp = await asyncio.to_thread(_open_tts_stream_response, text, voice, settings, style_instruction) + except Exception as e: + _routing_log_request( + request, + status="error", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend=backend, + route=route, + response_format=response_format, + text=text, + error=f"TTS stream proxy error: {e}", + ) + raise HTTPException(502, f"TTS stream proxy error: {e}") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + _routing_log_request( + request, + status="streaming", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend=backend, + route=route, + response_format=response_format, + text=text, + media_type=media_type, + sounds=[], + ) + return StreamingResponse( + _iter_tts_stream_response(resp), + media_type=media_type, + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + "Content-Disposition": f'inline; filename="{voice}_speech.wav"', + "X-TTS-Voice-Requested": original_voice, + "X-TTS-Voice-Routed": voice, + "X-TTS-Route-Backend": backend, + "X-TTS-Route-Language": str(route.get("detected_language", "")) if route else "", + "X-TTS-Response-Format": response_format, + "X-TTS-Audio-Clipped": "false", + "X-TTS-Route-Sounds": "", + }, + ) + + try: + if virtual: + _name, preset = virtual + instruct = str(preset.get("description", "")).strip() + if not instruct: + raise RuntimeError(f"Virtual voice '{voice}' has no description") + audio, media_type = await asyncio.to_thread( + _voice_design_request_audio, + instruct, + text, + str(preset.get("language", "Auto")), + settings, + str(preset.get("gender", "")), + ) + elif backend == "voice_design": + audio, media_type = await asyncio.to_thread( + _voice_design_voice_request_audio, + voice, + text, + settings, + style_instruction, + str(data.get("language") or "Auto"), + ) + elif backend == "streaming": + resp = await asyncio.to_thread(_open_tts_stream_response, text, voice, settings, style_instruction) + audio, media_type = await asyncio.to_thread(_read_tts_stream_response, resp) + if not audio: + raise RuntimeError("backend returned empty audio") + elif backend == "nvidia_magpie": + audio, media_type = await asyncio.to_thread( + _tts_request_audio, + text, + voice, + settings, + style_instruction, + _preview_backend_base_url(settings, "nvidia_magpie"), + settings.get("tts_api_key", ""), + "nvidia_magpie", + "nvidia_magpie", + ) + elif backend in {"nvidia_zeroshot", "nvidia_flow"}: + audio, media_type = await asyncio.to_thread( + _nvidia_clone_request_audio, + text, + voice, + settings, + "flow" if backend == "nvidia_flow" else "zeroshot", + str(data.get("audio_prompt_transcript") or ""), + str(data.get("language") or ""), + ) + else: + audio, media_type = await asyncio.to_thread( + _tts_request_audio, + text, + voice, + settings, + style_instruction, + ) + except Exception as e: + _routing_log_request( + request, + status="error", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend=backend, + route=route, + response_format=response_format, + text=text, + error=f"TTS proxy error: {e}", + ) + raise HTTPException(502, f"TTS proxy error: {e}") + + try: + audio, media_type, applied_sounds = _apply_route_sounds(audio, media_type, route, settings) + except Exception as e: + _routing_log_request( + request, + status="error", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend=backend, + route=route, + response_format=response_format, + text=text, + error=f"TTS route sound error: {e}", + ) + raise HTTPException(502, f"TTS route sound error: {e}") + audio, media_type, ext, duration, clipped = _prepare_proxy_audio(audio, media_type, response_format) + logger.info( + "TTS proxy app=%s voice=%s routed=%s backend=%s lang=%s format=%s bytes=%s duration=%s clipped=%s sounds=%s", + request_app, + original_voice, + voice, + backend, + str(route.get("detected_language", "")) if route else "", + response_format, + len(audio), + f"{duration:.2f}" if duration is not None else "?", + clipped, + ",".join(applied_sounds) if applied_sounds else "-", + ) + _routing_log_request( + request, + status="ok" if route else "no_match", + app=request_app, + requested_voice=original_voice, + routed_voice=voice, + backend=backend, + route=route, + response_format=response_format, + text=text, + bytes=len(audio), + duration=duration, + clipped=clipped, + media_type=media_type, + sounds=applied_sounds, + ) + + return Response( + content=audio, + media_type=media_type, + headers={ + "Content-Disposition": f'inline; filename="{voice}_speech.{ext}"', + "X-TTS-Voice-Requested": original_voice, + "X-TTS-Voice-Routed": voice, + "X-TTS-Route-Backend": backend, + "X-TTS-Route-Language": str(route.get("detected_language", "")) if route else "", + "X-TTS-Response-Format": response_format, + "X-TTS-Audio-Duration": f"{duration:.3f}" if duration is not None else "", + "X-TTS-Audio-Clipped": "true" if clipped else "false", + "X-TTS-Route-Sounds": ",".join(applied_sounds), + }, + ) + + +@app.post("/v1") +async def openai_speech_proxy_v1_shortcut(request: Request): + return await openai_speech_proxy(request) + + +# ── Static ──────────────────────────────────────────────────────────────────── + +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info") diff --git a/start-guided-installer.bat b/start-guided-installer.bat new file mode 100644 index 0000000..99996d9 --- /dev/null +++ b/start-guided-installer.bat @@ -0,0 +1,5 @@ +@echo off +cd /d "%~dp0" +py -3 guided_installer.py +if errorlevel 1 python guided_installer.py +pause diff --git a/start-guided-installer.command b/start-guided-installer.command new file mode 100755 index 0000000..36e8800 --- /dev/null +++ b/start-guided-installer.command @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +cd "$(dirname "$0")" || exit 1 +python3 guided_installer.py diff --git a/start-guided-installer.sh b/start-guided-installer.sh new file mode 100755 index 0000000..36e8800 --- /dev/null +++ b/start-guided-installer.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +cd "$(dirname "$0")" || exit 1 +python3 guided_installer.py diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..725b989 --- /dev/null +++ b/static/app.js @@ -0,0 +1,5222 @@ +// ── Utility ─────────────────────────────────────────────────────────────── + +const $ = id => document.getElementById(id); +let _toastTimer; +function toast(msg, type = '') { + const el = $('toast'); el.textContent = msg; el.className = 'show ' + type; + clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.className = '', 3500); +} +function status(msg) { $('status-bar').textContent = msg; } +function escHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; } +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + } catch(e) { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } +} +function fmtDuration(sec) { + if (sec == null || Number.isNaN(Number(sec))) return '-'; + const total = Math.max(0, Math.round(Number(sec))); + const m = Math.floor(total / 60), s = total % 60; + return `${m}:${String(s).padStart(2,'0')}`; +} + +function loadingMarkup(title, detail = '', rows = 4) { + const skeleton = Array.from({length: rows}, () => '
').join(''); + return ` +
+
+
${escHtml(title)}
+ ${detail ? `
${escHtml(detail)}
` : ''} +
${skeleton}
+
+
`; +} + +function setBusyButton(id, busy) { + const el = $(id); + if (el) el.disabled = !!busy; +} +async function clientAutoTrimBounds(fid) { + const resp = await fetch('/api/audio/' + encodeURIComponent(fid)); + if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); + const audioData = await resp.arrayBuffer(); + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const buffer = await ctx.decodeAudioData(audioData.slice(0)); + const samples = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const dur = buffer.duration; + if (dur <= 20) return {start:0, end:dur, duration:dur, reason:'Audio is already short enough.'}; + + const chunkSec = 0.25, chunkSize = Math.max(1, Math.floor(sr * chunkSec)); + const chunks = []; + for (let i = 0; i < samples.length; i += chunkSize) { + let sum = 0, peak = 0, n = Math.min(chunkSize, samples.length - i); + for (let j = 0; j < n; j++) { + const v = samples[i + j]; + sum += v * v; + peak = Math.max(peak, Math.abs(v)); + } + const rms = Math.sqrt(sum / Math.max(1, n)); + const db = rms > 0 ? 20 * Math.log10(rms) : -80; + chunks.push({db, speech:false, clipped:peak > 0.96}); + } + const avgDb = chunks.reduce((a,c)=>a+c.db,0) / chunks.length; + const floor = Math.max(avgDb - 18, -45); + chunks.forEach(c => c.speech = c.db >= floor); + + function scoreWindow(startSec, lengthSec) { + const first = Math.floor(startSec / chunkSec); + const last = Math.min(chunks.length, Math.ceil((startSec + lengthSec) / chunkSec)); + const win = chunks.slice(first, last); + if (!win.length) return {score:-9999}; + const speech = win.filter(c => c.speech); + const speechRatio = speech.length / win.length; + const silenceRatio = 1 - speechRatio; + const clipRatio = win.filter(c => c.clipped).length / win.length; + const speechAvg = speech.length ? speech.reduce((a,c)=>a+c.db,0) / speech.length : -80; + const variance = speech.length ? speech.reduce((a,c)=>a+Math.pow(c.db-speechAvg,2),0) / speech.length : 100; + const score = speechRatio * 100 - silenceRatio * 55 - clipRatio * 85 - Math.abs(speechAvg + 20) * 1.7 - Math.min(18, Math.sqrt(variance) * 1.4) - Math.abs(lengthSec - 12) * 0.9; + return {score, speechRatio, silenceRatio, speechAvg}; + } + + let best = null; + [8,10,12,15,18].forEach(length => { + if (length > dur) return; + for (let start = 0; start <= dur - length; start += 0.5) { + const s = scoreWindow(start, length); + if (!best || s.score > best.score) best = {start, end:start + length, duration:length, ...s}; + } + }); + if (!best) return {start:0, end:Math.min(12,dur), duration:Math.min(12,dur), reason:'Using the beginning because no stable speech window was found.'}; + return { + start:Number(best.start.toFixed(2)), + end:Number(best.end.toFixed(2)), + duration:Number(best.duration.toFixed(2)), + reason:`Selected ${best.duration.toFixed(1)}s with ${Math.round(best.speechRatio*100)}% speech, ${Math.round(best.silenceRatio*100)}% silence, avg ${best.speechAvg.toFixed(1)} dBFS.` + }; +} + +// ── Light / dark theme ──────────────────────────────────────────────────── + +function applyTheme(t) { + document.documentElement.dataset.theme = t; + $('theme-btn').textContent = t === 'dark' ? '☀️' : '🌙'; + $('theme-btn').title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + localStorage.setItem('vcf-theme', t); +} +$('theme-btn').addEventListener('click', () => + applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark') +); +applyTheme(localStorage.getItem('vcf-theme') || 'dark'); + +// ── Language helpers ────────────────────────────────────────────────────── + +function cc2flag(cc) { + if (!cc || cc.length !== 2) return '🌐'; + return cc.toUpperCase().replace(/./g, c => String.fromCodePoint(c.charCodeAt(0) + 127397)); +} + +// Display label for a country code (3-letter where conventional) +const CC_DISPLAY = { + AU:'AUS', NZ:'NZL', GB:'GB', US:'US', CA:'CA', IE:'IRE', ZA:'ZAF', IN:'IND', SG:'SGP', PH:'PHL', NG:'NGA', KE:'KEN', GH:'GHA', JM:'JAM', TT:'TTO', MT:'MLT', + CH:'CH', AT:'AT', BE:'BE', CN:'CN', TW:'TWN', HK:'HKG', MO:'MAC', JP:'JP', KR:'KR', VN:'VNM', TH:'THA', ID:'IDN', MY:'MYS', PK:'PAK', BD:'BGD', LK:'LKA', NP:'NPL', IR:'IRN', IL:'ISR', + MX:'MEX', AR:'ARG', CO:'COL', CL:'CHL', PE:'PER', VE:'VEN', UY:'URY', EC:'ECU', BO:'BOL', CR:'CRI', CU:'CUB', DO:'DOM', + BR:'BRA', SA:'SAU', EG:'EGY', AE:'UAE', MA:'MAR', QA:'QAT', KW:'KWT', OM:'OMN', JO:'JOR', LB:'LBN', IQ:'IRQ', + DK:'DNK', NO:'NOR', SE:'SE', FI:'FIN', IS:'ISL', NL:'NL', LU:'LUX', LI:'LIE', FR:'FR', DE:'DE', ES:'ES', PT:'PT', IT:'IT', GR:'GRC', CY:'CYP', TR:'TR', PL:'PL', CZ:'CZE', SK:'SVK', HU:'HUN', RO:'ROU', BG:'BGR', HR:'HRV', SI:'SVN', RS:'SRB', BA:'BIH', ME:'MNE', MK:'MKD', AL:'ALB', EE:'EST', LV:'LVA', LT:'LTU', UA:'UKR', RU:'RU', BY:'BLR', MD:'MDA' +}; +function ccDisplay(cc) { return CC_DISPLAY[cc] || cc; } + +const EUROPE_FLAGS = [ + ['GB','GB - British'], ['IE','IRE - Irish'], ['DE','DE - German'], ['AT','AT - Austrian'], ['CH','CH - Swiss'], ['FR','FR - French'], ['BE','BE - Belgian'], ['NL','NL - Dutch'], ['LU','LUX - Luxembourgish'], ['LI','LIE - Liechtenstein'], + ['ES','ES - Spanish'], ['PT','PT - Portuguese'], ['IT','IT - Italian'], ['MT','MLT - Maltese'], ['GR','GRC - Greek'], ['CY','CYP - Cypriot'], + ['DK','DNK - Danish'], ['NO','NOR - Norwegian'], ['SE','SE - Swedish'], ['FI','FIN - Finnish'], ['IS','ISL - Icelandic'], + ['PL','PL - Polish'], ['CZ','CZE - Czech'], ['SK','SVK - Slovak'], ['HU','HUN - Hungarian'], ['RO','ROU - Romanian'], ['BG','BGR - Bulgarian'], ['HR','HRV - Croatian'], ['SI','SVN - Slovenian'], ['RS','SRB - Serbian'], ['BA','BIH - Bosnian'], ['ME','MNE - Montenegrin'], ['MK','MKD - Macedonian'], ['AL','ALB - Albanian'], + ['EE','EST - Estonian'], ['LV','LVA - Latvian'], ['LT','LTU - Lithuanian'], ['UA','UKR - Ukrainian'], ['RU','RU - Russian'], ['BY','BLR - Belarusian'], ['MD','MDA - Moldovan'], ['TR','TR - Turkish'], +]; +const ASIA_FLAGS = [ + ['CN','CN - Mainland Chinese'], ['TW','TWN - Taiwanese'], ['HK','HKG - Hong Kong'], ['MO','MAC - Macau'], ['SG','SGP - Singapore'], ['JP','JP - Japanese'], ['KR','KR - Korean'], + ['VN','VNM - Vietnamese'], ['TH','THA - Thai'], ['ID','IDN - Indonesian'], ['MY','MYS - Malaysian'], ['PH','PHL - Filipino'], ['IN','IND - Indian'], ['PK','PAK - Pakistani'], ['BD','BGD - Bangladeshi'], ['LK','LKA - Sri Lankan'], ['NP','NPL - Nepali'], + ['SA','SAU - Saudi'], ['AE','UAE - Emirati'], ['QA','QAT - Qatari'], ['KW','KWT - Kuwaiti'], ['OM','OMN - Omani'], ['JO','JOR - Jordanian'], ['LB','LBN - Lebanese'], ['IQ','IRQ - Iraqi'], ['IR','IRN - Iranian'], ['IL','ISR - Israeli'], +]; +const ENGLISH_FLAGS = [ + ['GB','GB - British'], ['US','US - American'], ['CA','CA - Canadian'], ['AU','AUS - Australian'], ['NZ','NZL - New Zealand'], ['IE','IRE - Irish'], ['ZA','ZAF - South African'], ['IN','IND - Indian English'], ['SG','SGP - Singapore English'], ['PH','PHL - Filipino English'], ['NG','NGA - Nigerian English'], ['KE','KEN - Kenyan English'], ['GH','GHA - Ghanaian English'], ['JM','JAM - Jamaican English'], ['TT','TTO - Trinidad and Tobago English'], ['MT','MLT - Maltese English'], +]; +const LATAM_FLAGS = [ + ['MX','MEX - Mexican'], ['AR','ARG - Argentine'], ['CO','COL - Colombian'], ['CL','CHL - Chilean'], ['PE','PER - Peruvian'], ['VE','VEN - Venezuelan'], ['UY','URY - Uruguayan'], ['EC','ECU - Ecuadorian'], ['BO','BOL - Bolivian'], ['CR','CRI - Costa Rican'], ['CU','CUB - Cuban'], ['DO','DOM - Dominican'], ['BR','BRA - Brazilian'], +]; +function uniqueFlagOptions(groups) { + const seen = new Set(), out = []; + groups.flat().forEach(item => { if (item && !seen.has(item[0])) { seen.add(item[0]); out.push(item); } }); + return out; +} + +const FLAG_OPTIONS = { + EN: ENGLISH_FLAGS, + DE: uniqueFlagOptions([[['DE','DE - German'],['AT','AT - Austrian'],['CH','CH - Swiss (DE)']], EUROPE_FLAGS]), + FR: uniqueFlagOptions([[['FR','FR - French'],['BE','BE - Belgian'],['CH','CH - Swiss (FR)'],['CA','CA - Canadian']], EUROPE_FLAGS]), + ZH: uniqueFlagOptions([[['CN','CN - Mainland'],['TW','TWN - Taiwanese'],['HK','HKG - Hong Kong'],['SG','SGP - Singaporean']], ASIA_FLAGS]), + ES: uniqueFlagOptions([[['ES','ES - Spain']], LATAM_FLAGS, EUROPE_FLAGS]), + PT: uniqueFlagOptions([[['PT','PT - Portuguese'],['BR','BRA - Brazilian']], EUROPE_FLAGS, LATAM_FLAGS]), + AR: uniqueFlagOptions([[['SA','SAU - Saudi'],['EG','EGY - Egyptian'],['AE','UAE - Emirati'],['MA','MAR - Moroccan']], ASIA_FLAGS]), + NL: uniqueFlagOptions([[['NL','NL - Dutch'],['BE','BE - Belgian']], EUROPE_FLAGS]), + JA: uniqueFlagOptions([[['JP','JP - Japanese']], ASIA_FLAGS]), + KO: uniqueFlagOptions([[['KR','KR - Korean']], ASIA_FLAGS]), + IT: uniqueFlagOptions([[['IT','IT - Italian']], EUROPE_FLAGS]), + RU: uniqueFlagOptions([[['RU','RU - Russian']], EUROPE_FLAGS, ASIA_FLAGS]), + PL: uniqueFlagOptions([[['PL','PL - Polish']], EUROPE_FLAGS]), + SV: uniqueFlagOptions([[['SE','SE - Swedish']], EUROPE_FLAGS]), + TR: uniqueFlagOptions([[['TR','TR - Turkish']], EUROPE_FLAGS, ASIA_FLAGS]), + HI: uniqueFlagOptions([[['IN','IND - Indian']], ASIA_FLAGS]), +}; + +const LANG_FLAG_DEFAULT = { + EN:'GB', DE:'DE', ZH:'CN', FR:'FR', ES:'ES', JA:'JP', KO:'KR', + IT:'IT', PT:'BR', RU:'RU', AR:'SA', PL:'PL', NL:'NL', SV:'SE', TR:'TR', HI:'IN', +}; + +const ALL_FLAGS = uniqueFlagOptions([ENGLISH_FLAGS, EUROPE_FLAGS, ASIA_FLAGS, LATAM_FLAGS, [ + ['EG','EGY - Egyptian'], ['MA','MAR - Moroccan'], ['ZA','ZAF - South African'], ['NG','NGA - Nigerian'], ['KE','KEN - Kenyan'], ['GH','GHA - Ghanaian'], +]]); + +// ── Tabs ────────────────────────────────────────────────────────────────── + +function disabledBackendTabMessage(tab) { + const required = tab?.dataset.backendRequired || ''; + if (required === 'any_tts') return 'No Qwen3-TTS backend is running or configured. Check Settings.'; + const backend = (_ttsBackends || []).find(b => b.id === required); + const label = backend?.label || required.replace(/_/g, ' '); + const url = backend?.url ? ` (${backend.url})` : ''; + return `${label}${url} is not running or not configured in Settings.`; +} + +function switchTab(name) { + const targetTab = document.querySelector(`.tab[data-tab="${name}"]`); + if (targetTab?.classList.contains('backend-unavailable')) { + toast(disabledBackendTabMessage(targetTab), 'error'); + return false; + } + document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); + document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name)); + if (name === 'library') loadVoiceLibrary(); + if (name === 'integrations') { + if (!_voices.length) loadVoiceLibrary(); + renderIntegrationSnippets(); + } + if (name === 'routing') loadRoutingTab(); + if (name === 'getvoices') loadGetVoices(); + return true; +} +document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab))); +document.addEventListener('click', e => { + const btn = e.target.closest('.backend-jump'); + if (!btn) return; + switchTab('generation'); + const backend = btn.dataset.backend; + const sel = $('tts-backend-select'); + if (sel && [...sel.options].some(o => o.value === backend)) { + sel.value = backend; + sel.dispatchEvent(new Event('change')); + } +}); + + +// -- Get voices --------------------------------------------------------------- +const DEFAULT_VOICE_SOURCE_URLS = [ + 'https://aiartes.com/voiceai', + 'https://sample-files.com/downloads/audio/wav/voice-sample.wav', + 'https://freesound.org/people/Scott%20Simpson/', + 'https://lanceblairvo.com/raw-voiceover-samples/', + 'https://github.com/yaph/tts-samples/tree/main/mp3', + 'https://github.com/jim-schwoebel/voice_datasets', +]; +const VOICE_SOURCE_STORAGE_KEY = 'ttsvc-getvoices-sources'; +let _voiceSourcePayload = null; +let _voiceSourceItems = []; + +function sourceTextareaValueFromDefaults() { + return DEFAULT_VOICE_SOURCE_URLS.join('\n'); +} + +function initGetVoiceSourcesEditor() { + const box = $('getvoices-sources'); + if (!box || box.dataset.ready) return; + box.value = localStorage.getItem(VOICE_SOURCE_STORAGE_KEY) || sourceTextareaValueFromDefaults(); + box.dataset.ready = '1'; + box.addEventListener('input', () => { + localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value); + _voiceSourcePayload = null; + $('getvoices-status').textContent = 'Source list changed.'; + }); +} + +function getEditableVoiceSourceUrls() { + initGetVoiceSourcesEditor(); + return ($('getvoices-sources')?.value || '') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); +} + +function getVoiceSourceItems() { + const sources = (_voiceSourcePayload && _voiceSourcePayload.sources) || []; + return sources.flatMap(src => (src.items || []).map(item => ({...item, _sourceName: src.name, _sourceHomepage: src.homepage}))); +} + +function voiceSourceSearchText(item) { + return [item.name, item.kind, item.category, item.language, item.gender, item.description, item.source, item._sourceName].join(' ').toLowerCase(); +} + +function setOptions(selectId, values, allLabel) { + const sel = $(selectId); + if (!sel) return; + const current = sel.value || 'all'; + sel.innerHTML = `` + values.map(value => ``).join(''); + sel.value = values.includes(current) ? current : 'all'; +} + +function renderGetVoices() { + initGetVoiceSourcesEditor(); + const list = $('getvoices-list'); + const summary = $('getvoices-summary'); + if (!list || !summary) return; + const payload = _voiceSourcePayload || {sources:[], total:0, direct_audio:0, errors:[]}; + const sources = payload.sources || []; + const sourceFilter = $('getvoices-source-filter')?.value || 'all'; + const languageFilter = $('getvoices-language-filter')?.value || 'all'; + const genderFilter = $('getvoices-gender-filter')?.value || 'all'; + const filetypeFilter = $('getvoices-filetype-filter')?.value || 'all'; + const q = ($('getvoices-search')?.value || '').trim().toLowerCase(); + const directOnly = !!$('getvoices-direct-only')?.checked; + _voiceSourceItems = getVoiceSourceItems(); + + summary.innerHTML = [ + [`${payload.total || 0}`, 'Items found'], + [`${payload.direct_audio || 0}`, 'Direct audio'], + [`${sources.length}`, 'Sources OK'], + [`${(payload.errors || []).length}`, 'Errors'], + ].map(([value, label]) => `
${escHtml(value)}${escHtml(label)}
`).join(''); + + setOptions('getvoices-source-filter', sources.map(src => src.id).filter(Boolean), 'Source: all'); + const sourceSelect = $('getvoices-source-filter'); + if (sourceSelect) { + [...sourceSelect.options].forEach(option => { + if (option.value === 'all') return; + const src = sources.find(s => s.id === option.value); + if (src) option.textContent = src.name || src.id; + }); + } + const languages = [...new Set(_voiceSourceItems.map(item => item.language || 'Unknown'))].sort((a, b) => a.localeCompare(b)); + const genders = [...new Set(_voiceSourceItems.map(item => item.gender || 'Unknown'))].sort((a, b) => a.localeCompare(b)); + const filetypes = [...new Set(_voiceSourceItems.map(item => (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase()))].sort((a, b) => a.localeCompare(b)); + setOptions('getvoices-language-filter', languages, 'Language: all'); + setOptions('getvoices-gender-filter', genders, 'Sex: all'); + setOptions('getvoices-filetype-filter', filetypes, 'Filetype: all'); + + let items = _voiceSourceItems.filter(item => { + if (sourceFilter !== 'all' && item.source_id !== sourceFilter) return false; + if (languageFilter !== 'all' && (item.language || 'Unknown') !== languageFilter) return false; + if (genderFilter !== 'all' && (item.gender || 'Unknown') !== genderFilter) return false; + const itemFiletype = (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase(); + if (filetypeFilter !== 'all' && itemFiletype !== filetypeFilter) return false; + if (directOnly && !item.direct_audio) return false; + if (q && !voiceSourceSearchText(item).includes(q)) return false; + return true; + }); + const shown = items.slice(0, 240); + const more = items.length - shown.length; + if (!shown.length) { + const errorText = (payload.errors || []).map(e => `${e.source}: ${e.detail}`).join(' | '); + list.innerHTML = `

No matching sources found.${errorText ? ' Source errors: ' + escHtml(errorText) : ''}

`; + return; + } + list.innerHTML = shown.map(item => { + const thumb = item.image_url ? `` : `
`; + const audio = item.audio_url ? `` : ''; + const audioLink = item.audio_url ? `Open audio` : ''; + const canGetVoice = !!item.audio_url; + const getVoice = canGetVoice ? `` : ''; + const type = item.file_type ? `${escHtml(String(item.file_type).toUpperCase())}` : ''; + const language = item.language ? `${escHtml(item.language)}` : ''; + const gender = item.gender ? `${escHtml(item.gender)}` : ''; + return `
+
+ ${thumb} +
+ ${escHtml(item.name || 'Untitled')} + ${escHtml(item._sourceName || item.source || '')}${item.category ? ' · ' + escHtml(item.category) : ''} +
+
+
${escHtml(item.kind || '')}${item.description ? ' - ' + escHtml(item.description) : ''}
+ ${audio} +
+ ${type}${language}${gender} + ${getVoice} + ${audioLink} + Source page + +
+
`; + }).join('') + (more > 0 ? `

${more} more matches. Narrow the search or filters to see them.

` : ''); +} + +async function loadGetVoices(force = false) { + initGetVoiceSourcesEditor(); + if (_voiceSourcePayload && !force) { renderGetVoices(); return; } + const urls = getEditableVoiceSourceUrls(); + const list = $('getvoices-list'); + if (!urls.length) { + if (list) list.innerHTML = '

Add at least one source URL, then scrape again.

'; + $('getvoices-status').textContent = 'No sources.'; + return; + } + if (list) list.innerHTML = loadingMarkup('Scraping voice sources', `Fetching ${urls.length} source${urls.length === 1 ? '' : 's'} from the editable list.`, 6); + $('getvoices-status').textContent = 'Scraping...'; + $('getvoices-refresh-btn').disabled = true; + try { + const r = await fetch('/api/voice-sources', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({urls}), + }); + if (!r.ok) { + const text = await r.text().catch(() => ''); + let message = r.statusText || `HTTP ${r.status}`; + try { message = JSON.parse(text).detail || message; } catch (_) { if (text) message = text.slice(0, 160); } + throw new Error(message); + } + _voiceSourcePayload = await r.json(); + renderGetVoices(); + const errors = (_voiceSourcePayload.errors || []).length; + $('getvoices-status').textContent = `${_voiceSourcePayload.total || 0} found${errors ? `, ${errors} source errors` : ''}`; + } catch(e) { + if (list) list.innerHTML = `

Scrape failed: ${escHtml(e.message)}

Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.

`; + $('getvoices-status').textContent = 'Scrape failed'; + toast('Voice source scrape failed: ' + e.message, 'error'); + } finally { + $('getvoices-refresh-btn').disabled = false; + } +} + +['getvoices-search', 'getvoices-source-filter', 'getvoices-language-filter', 'getvoices-gender-filter', 'getvoices-filetype-filter', 'getvoices-direct-only'].forEach(id => { + const el = $(id); + if (el) el.addEventListener(id === 'getvoices-search' ? 'input' : 'change', renderGetVoices); +}); +$('getvoices-refresh-btn')?.addEventListener('click', () => loadGetVoices(true)); +$('getvoices-reset-sources-btn')?.addEventListener('click', () => { + const box = $('getvoices-sources'); + if (!box) return; + box.value = sourceTextareaValueFromDefaults(); + localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value); + _voiceSourcePayload = null; + renderGetVoices(); + $('getvoices-status').textContent = 'Source list reset.'; +}); +const SOURCE_LANGUAGE_CODES = { + english:'EN', german:'DE', deutsch:'DE', french:'FR', spanish:'ES', japanese:'JA', korean:'KO', + italian:'IT', portuguese:'PT', russian:'RU', arabic:'AR', polish:'PL', dutch:'NL', swedish:'SV', + turkish:'TR', hindi:'HI', chinese:'ZH' +}; +function sourceLanguageCode(language) { + const raw = String(language || '').trim(); + if (/^[A-Z]{2}$/.test(raw)) return raw; + return SOURCE_LANGUAGE_CODES[raw.toLowerCase()] || 'EN'; +} +function sourceGenderCode(gender, name = '') { + const text = `${gender || ''} ${name || ''}`.toLowerCase(); + if (/female|woman|girl|\bf\b/.test(text)) return 'F'; + if (/male|man|boy|\bm\b/.test(text)) return 'M'; + return 'N'; +} +function suggestedVoiceIdFromSourceName(name, language = 'EN', gender = 'N') { + const base = String(name || 'SourceVoice') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 48) || 'SourceVoice'; + return `${language || 'EN'}_${gender || 'N'}_${base}`; +} + +function setLibAddSourcePreview(meta = {}) { + const sourceBox = document.querySelector('.lib-add-source-box'); + if (!sourceBox) return; + let preview = $('lib-add-source-preview'); + if (!preview) { + preview = document.createElement('div'); + preview.id = 'lib-add-source-preview'; + preview.className = 'lib-add-source-preview'; + sourceBox.appendChild(preview); + } + if (!meta.name && !meta.imageUrl) { + preview.classList.remove('open'); + preview.innerHTML = ''; + return; + } + const image = meta.imageUrl ? `` : '
'; + preview.innerHTML = `${image}
${escHtml(meta.name || 'Source voice')}${escHtml([meta.language, meta.gender, meta.kind].filter(Boolean).join(' · ') || 'Source metadata will be saved with the voice')}
`; + preview.classList.add('open'); +} + +async function getSourceVoiceInLibrary(meta) { + if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; } + switchTab('library'); + const panel = $('lib-add-panel'); + if (panel && !panel.classList.contains('open')) panel.classList.add('open'); + const lang = sourceLanguageCode(meta.language); + const gender = sourceGenderCode(meta.gender, meta.name); + if ($('lib-add-url')) $('lib-add-url').value = meta.url; + if ($('lib-add-lang')) $('lib-add-lang').value = lang; + if ($('lib-add-gender')) $('lib-add-gender').value = gender; + if ($('lib-add-voice-id')) $('lib-add-voice-id').value = suggestedVoiceIdFromSourceName(meta.name, lang, gender); + if ($('lib-add-transcript')) $('lib-add-transcript').value = ''; + if (window.libAddState) window.libAddState.pendingSource = {...meta, language: lang, gender}; + setLibAddSourcePreview({...meta, language: lang, gender}); + setLibAddStatus(`Importing source audio: ${meta.name || 'external voice'}...`); + try { + const r = await fetch('/api/import-source-audio', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({audio_url: meta.url, name: meta.name || 'Source voice'}) + }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + loadLibAddAudio(d.id, d.duration, meta.name || 'Source voice'); + setLibAddStatus(`Source voice loaded: ${meta.name || 'external voice'}${meta.imageUrl ? ' (image will attach on save)' : ''}`); + toast('Voice imported into Voice Clone', 'success'); + } catch(e) { + setLibAddStatus('Source import failed'); + toast('Source import failed: ' + e.message, 'error'); + } +} + +$('getvoices-list')?.addEventListener('click', async e => { + const getBtn = e.target.closest('.get-source-voice'); + if (getBtn) { + await getSourceVoiceInLibrary({ + url: getBtn.dataset.url || '', name: getBtn.dataset.name || '', imageUrl: getBtn.dataset.image || '', + language: getBtn.dataset.language || '', gender: getBtn.dataset.gender || '', kind: getBtn.dataset.kind || '', + description: getBtn.dataset.description || '', pageUrl: getBtn.dataset.page || '' + }); + return; + } + const btn = e.target.closest('.copy-source-url'); + if (!btn) return; + await copyText(btn.dataset.url || ''); + toast('Source URL copied', 'success'); +}); + +// ── Integration samples ────────────────────────────────────────────────── + +function cleanBaseUrl(url) { + return String(url || '').trim().replace(/\/+$/, ''); +} +function getTtsBaseUrl() { + return cleanBaseUrl($('s-tts-url')?.value) || 'http://localhost:8020'; +} +function getTtsV1Url() { + const base = getTtsBaseUrl(); + return base.endsWith('/v1') ? base : base + '/v1'; +} +function getTtsStreamBaseUrl() { + return cleanBaseUrl($('s-tts-stream-url')?.value || _appSettings.tts_stream_url) || 'http://localhost:8023'; +} +function getTtsStreamV1Url() { + const base = getTtsStreamBaseUrl(); + return base.endsWith('/v1') ? base : base + '/v1'; +} +function getCreatorV1Url() { + const loc = window.location; + const protocol = loc.protocol || 'http:'; + const port = loc.port ? ':' + loc.port : ''; + const host = loc.hostname === '0.0.0.0' ? 'localhost' : loc.hostname; + return `${protocol}//${host}${port}/v1`; +} +function updateCreatorUrlHints() { + const warning = $('routing-url-warning'); + const badBindHost = window.location.hostname === '0.0.0.0'; + if (warning) warning.classList.toggle('show', badBindHost); +} +function activeVoiceIds() { + return (_voices || []) + .filter(v => v.enabled !== false) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .map(v => v.id); +} +function integrationVoiceExample() { + return activeVoiceIds()[0] || 'EN_F_ExampleVoice'; +} +function integrationVoiceList() { + const ids = activeVoiceIds(); + return ids.length ? ids.join(', ') : 'EN_F_ExampleVoice, DE_M_ExampleVoice'; +} +function virtualDesignVoiceIds() { + return Object.keys(loadDesignPresets ? loadDesignPresets() : {}) + .sort((a,b)=>a.localeCompare(b)) + .map(name => 'vd_' + name.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '')); +} +function renderIntegrationSnippets() { + if (!$('snippet-sillytavern')) return; + const base = getTtsBaseUrl(); + const v1 = getTtsV1Url(); + const streamV1 = getTtsStreamV1Url(); + const proxyV1 = getCreatorV1Url(); + updateCreatorUrlHints(); + const voice = integrationVoiceExample(); + const voices = integrationVoiceList(); + const vdVoices = virtualDesignVoiceIds(); + const vdVoice = vdVoices[0] || 'vd_EN_F_Warm_Narrator'; + $('integration-url-label').textContent = 'TTS backend: ' + base; + $('snippet-sillytavern').textContent = +`Provider: OpenAI compatible TTS +API base URL: ${v1} +API key: dummy +Model: qwen3-tts +Voice: ${voice} +Custom voices: ${voices} + +Streaming backend, if your SillyTavern TTS extension supports progressive playback: +API base URL: ${streamV1} +Endpoint: /audio/speech +Format: wav`; + $('snippet-streaming-howto').textContent = +`Direct streaming backend, no creator routing: +API base URL: ${streamV1} +Endpoint: /audio/speech +Model: tts-1 +Voice: ${voice} +Response format: wav +Requirement: the app must start playback while the HTTP response is still arriving. +Current 8023 streaming service ignores per-request instruct/style text. + +Streaming through TTS Voice Creator routing: +API base URL: ${proxyV1} +Voice: default or another incoming route voice +Routing tab: set Backend = Streaming for the matching rule +Response format: wav +Avoid before/after sounds for true streaming; route sounds and MP3 require buffering. + +SillyTavern note: +Use OpenAI-compatible TTS if your extension supports progressive audio responses. If it waits for the whole file before playing, streaming works technically but will feel like buffered TTS.`; + $('snippet-open-webui').textContent = +`Admin settings -> Audio -> Text-to-Speech +Engine/provider: OpenAI compatible +API base URL: ${proxyV1} +API key: dummy +Model: tts-1 +Voice: default + +Routing tab example: +default + EN -> ${voice} +default + DE -> DE_M_YourGermanVoice`; + $('snippet-home-assistant').textContent = +`Home Assistant OpenAI TTS agent: +Base URL: ${proxyV1} +API key: dummy +Model: tts-1 +Voice: default +Extra JSON payload: {"app":"Home Assistant"} +Audio format: mp3 or wav + +REST command example using routing: +rest_command: + routed_tts: + url: "${proxyV1}/audio/speech" + method: POST + content_type: "application/json" + headers: + Authorization: "Bearer dummy" + payload: > + {"model":"tts-1","voice":"default","input":"{{ text }}","response_format":"mp3","app":"Home Assistant"} + +Important: use this Creator proxy URL, not the direct Qwen3 backend URL ${v1}. Routing only runs through the Creator proxy.`; + $('snippet-curl').textContent = +`curl -s "${v1}/models" + +curl -s "${v1}/audio/speech" \\ + -H "Authorization: Bearer dummy" \\ + -H "Content-Type: application/json" \\ + -d '{"model":"qwen3-tts","voice":"${voice}","input":"Hello from Qwen3 TTS","response_format":"wav"}' \\ + --output qwen3-tts-test.wav`; + $('snippet-voice-design-proxy').textContent = +`Virtual VoiceDesign mode - no WAV export needed + +Use this app as the OpenAI-compatible TTS endpoint: +API base URL: ${proxyV1} +API key: dummy +Model: tts-1 +Voice: ${vdVoice} + +Available virtual voices: +${vdVoices.length ? vdVoices.join(', ') : 'Save a Voice Design prompt preset first.'} + +curl -s "${proxyV1}/models" + +curl -s "${proxyV1}/audio/speech" \\ + -H "Authorization: Bearer dummy" \\ + -H "Content-Type: application/json" \\ + -d '{"model":"tts-1","voice":"${vdVoice}","input":"This line is generated through the VoiceDesign container.","response_format":"wav"}' \\ + --output voicedesign-virtual.wav`; +} +document.querySelectorAll('.copy-snippet').forEach(btn => btn.addEventListener('click', async () => { + const el = $(btn.dataset.snippet); + if (!el) return; + await copyText(el.textContent); + toast('Snippet copied', 'success'); +})); +$('show-api-btn')?.addEventListener('click', () => { + window.open('/docs', '_blank', 'noopener'); +}); +$('integration-refresh-btn')?.addEventListener('click', () => { + renderIntegrationSnippets(); + toast('Integration examples refreshed', 'success'); +}); +$('copy-active-voices-btn-integrations')?.addEventListener('click', async () => { + const active = activeVoiceIds(); + if (!active.length) { toast('No active voices to copy', 'error'); return; } + await copyText(active.join(', ')); + toast('Copied ' + active.length + ' active voices', 'success'); + status('Copied active voices to clipboard'); +}); + +// ── TTS routing ────────────────────────────────────────────────────────── + +let _ttsRoutes = []; +const ROUTE_LANGS = [ + ['*', 'Any'], + ['AUTO', 'Auto'], + ['EN', 'English'], + ['DE', 'German'], + ['FR', 'French'], + ['ES', 'Spanish'], + ['IT', 'Italian'], + ['PT', 'Portuguese'], + ['NL', 'Dutch'], + ['PL', 'Polish'], +]; + +function routeSelectOptions(value) { + return ROUTE_LANGS.map(([code, label]) => + `` + ).join(''); +} + +const ROUTE_BACKENDS = [ + ['voice_clone', 'Voice Clone'], + ['streaming', 'Streaming'], + ['voice_design', 'Voice Design'], + ['nvidia_magpie', 'NVIDIA Magpie'], + ['nvidia_zeroshot', 'NVIDIA Zeroshot'], + ['nvidia_flow', 'NVIDIA Flow'], +]; +let _routeSounds = []; + +function routeSoundOptions(value = '') { + const current = String(value || ''); + const listed = new Set(_routeSounds.map(s => String(s.path || ''))); + const options = ['']; + if (current && !listed.has(current)) options.push(``); + _routeSounds.forEach(sound => { + const path = String(sound.path || ''); + if (!path) return; + const duration = sound.duration != null ? ` · ${Number(sound.duration).toFixed(1)}s` : ''; + options.push(``); + }); + return options.join(''); +} + +async function loadRouteSounds() { + try { + const r = await fetch('/api/route-sounds'); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + _routeSounds = Array.isArray(d.sounds) ? d.sounds : []; + } catch (_) { + _routeSounds = []; + } +} + +let _routeSoundPickerTarget = null; +let _routeSoundPlayingButton = null; + +function routeSoundUrl(path) { + return '/api/route-sounds/file/' + String(path || '').split('/').map(encodeURIComponent).join('/'); +} + +function setRouteSoundField(row, target, path) { + const field = row?.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound'); + if (!field) return; + field.value = path || ''; + readRoutingForm(); +} + +function renderRouteSoundBrowser() { + const list = $('routing-sound-list'); + if (!list) return; + const query = String($('routing-sound-search')?.value || '').trim().toLowerCase(); + const sounds = query + ? _routeSounds.filter(sound => String(sound.path || '').toLowerCase().includes(query) || String(sound.name || '').toLowerCase().includes(query)) + : _routeSounds; + if (!_routeSounds.length) { + list.innerHTML = '
No sounds found yet. Upload one in a route row; only the selected file is imported.
'; + return; + } + if (!sounds.length) { + list.innerHTML = '
No sounds match this search.
'; + return; + } + list.innerHTML = sounds.map(sound => { + const path = String(sound.path || ''); + const size = sound.size != null ? Math.max(1, Math.round(Number(sound.size) / 1024)) + ' KB' : ''; + const duration = sound.duration != null ? Number(sound.duration).toFixed(1) + 's' : size; + const type = sound.type ? String(sound.type).toUpperCase() : ''; + const targetLabel = _routeSoundPickerTarget?.target === 'after' ? 'Use after' : 'Use before'; + return ` +
+ +
${escHtml(path)}
+
${escHtml(duration || '-')} ${type ? '· ' + escHtml(type) : ''}
+ +
`; + }).join(''); +} + +async function openRouteSoundBrowser(row, target) { + _routeSoundPickerTarget = {row, target}; + await loadRouteSounds(); + renderRouteSoundBrowser(); + const panel = $('routing-sound-browser'); + if (panel) { + panel.hidden = false; + panel.scrollIntoView({block:'nearest', behavior:'smooth'}); + } + const label = target === 'before' ? 'before sound' : 'after sound'; + const note = $('routing-sound-browser-note'); + if (note) note.textContent = `Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`; +} + +function closeRouteSoundBrowser() { + const panel = $('routing-sound-browser'); + if (panel) panel.hidden = true; + const audio = $('routing-sound-preview'); + if (audio) { audio.pause(); audio.hidden = true; audio.removeAttribute('src'); } + if (_routeSoundPlayingButton) _routeSoundPlayingButton.textContent = '▶'; + _routeSoundPlayingButton = null; + _routeSoundPickerTarget = null; +} + +function playRouteSound(path, btn) { + const audio = $('routing-sound-preview'); + if (!audio || !path) return; + if (_routeSoundPlayingButton && _routeSoundPlayingButton !== btn) _routeSoundPlayingButton.textContent = '▶'; + _routeSoundPlayingButton = btn; + btn.textContent = '❚❚'; + audio.hidden = false; + audio.src = routeSoundUrl(path); + audio.onended = () => { btn.textContent = '▶'; }; + audio.onpause = () => { if (_routeSoundPlayingButton === btn) btn.textContent = '▶'; }; + audio.onplay = () => { btn.textContent = '❚❚'; }; + audio.play().catch(e => { + btn.textContent = '▶'; + toast('Sound preview failed: ' + e.message, 'error'); + }); +} + +function useRouteSound(path, target) { + const selected = _routeSoundPickerTarget || {}; + const row = selected.row || document.querySelector('.routing-row'); + const useTarget = target || selected.target || 'before'; + setRouteSoundField(row, useTarget, path); + toast(`${useTarget === 'before' ? 'Before' : 'After'} sound selected`, 'success'); +} + +function routeBackendOptions(value) { + const current = value || 'voice_clone'; + return ROUTE_BACKENDS.map(([code, label]) => + `` + ).join(''); +} + +function refreshRoutingVoiceOptions() { + const dl = $('routing-voice-options'); + if (dl) { + const ids = [...activeVoiceIds(), ...virtualDesignVoiceIds()]; + dl.innerHTML = [...new Set(ids)].map(id => ``).join(''); + } + const soundsDl = $('routing-sound-options'); + if (soundsDl) soundsDl.innerHTML = _routeSounds.map(sound => ``).join(''); +} + +function newRoute(app = 'Open WebUI', inputVoice = 'default', language = '*', outputVoice = '') { + return { + id: 'route_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6), + enabled: true, + app, + input_voice: inputVoice, + language, + backend: 'voice_clone', + output_voice: outputVoice, + before_sound: '', + after_sound: '', + }; +} + +function renderRoutingList() { + if (!$('routing-list')) return; + refreshRoutingVoiceOptions(); + $('routing-proxy-url').textContent = getCreatorV1Url(); + updateCreatorUrlHints(); + $('routing-status').textContent = _ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.'; + if (!_ttsRoutes.length) { + $('routing-list').innerHTML = '
No routing rules yet. Add a route or add the Open WebUI default examples.
'; + return; + } + $('routing-list').innerHTML = _ttsRoutes.map((r, i) => ` +
+ + + + + + +
+ + + +
+
+ + + +
+ +
+ `).join(''); +} + +function readRoutingForm() { + _ttsRoutes = [...document.querySelectorAll('.routing-row')].map((row, i) => { + const existing = _ttsRoutes[Number(row.dataset.index)] || {}; + return { + id: existing.id || `route_${i+1}`, + enabled: row.querySelector('.route-enabled').checked, + app: row.querySelector('.route-app').value.trim() || '*', + input_voice: row.querySelector('.route-input').value.trim() || 'default', + language: row.querySelector('.route-lang').value || '*', + backend: row.querySelector('.route-backend').value || 'voice_clone', + output_voice: row.querySelector('.route-output').value.trim(), + before_sound: row.querySelector('.route-before-sound').value.trim(), + after_sound: row.querySelector('.route-after-sound').value.trim(), + }; + }); +} + +async function loadRoutingTab() { + if (!$('routing-list')) return; + $('routing-proxy-url').textContent = getCreatorV1Url(); + updateCreatorUrlHints(); + $('routing-status').textContent = 'Loading routing…'; + $('routing-list').innerHTML = loadingMarkup('Loading routing', 'Loading active voices and routing rules for the proxy.', 5); + setBusyButton('routing-refresh-btn', true); + try { + if (!_voices.length) await loadVoiceLibrary(); + await loadRouteSounds(); + const r = await fetch('/api/tts-routes'); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + _ttsRoutes = Array.isArray(d.routes) ? d.routes : []; + renderRoutingList(); + status('Routing loaded'); + loadRoutingLog(); + } catch(e) { + $('routing-status').textContent = 'Load failed'; + $('routing-list').innerHTML = '
Failed to load routes
'; + status('Routing load failed'); + } finally { + setBusyButton('routing-refresh-btn', false); + } +} + +async function saveRoutingTab() { + readRoutingForm(); + $('routing-save-btn').disabled = true; + try { + const r = await fetch('/api/tts-routes', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({routes:_ttsRoutes}), + }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + _ttsRoutes = d.routes || _ttsRoutes; + renderRoutingList(); + toast('Routing saved', 'success'); + status('TTS routing saved'); + } catch(e) { + toast('Save routes failed: ' + e.message, 'error'); + } finally { + $('routing-save-btn').disabled = false; + } +} + +function renderRouteTestResult(d) { + const el = $('routing-test-result'); + if (!el) return; + const health = d.voice_health || {}; + const warnings = Array.isArray(health.warnings) ? health.warnings : []; + el.className = 'routing-test-result ' + (warnings.length ? 'warn' : 'ok'); + const parts = [ + `${escHtml(d.requested_voice || '')} → ${escHtml(d.routed_voice || '')}`, + `app ${escHtml(d.app || '-')}`, + `backend ${escHtml(d.backend || 'voice_clone')}`, + `language ${escHtml(d.detected_language || '-')}`, + d.matched ? 'matched route' : 'no route matched', + ]; + if (health.duration) parts.push(`reference ${health.duration}s`); + if (health.word_count != null) parts.push(`${health.word_count} words`); + if (health.words_per_sec) parts.push(`${health.words_per_sec} words/s`); + if (warnings.length) { + parts.push('Warning: ' + warnings.map(escHtml).join('; ')); + } + const sounds = d.sounds || {}; + for (const [key, sound] of Object.entries(sounds)) { + const label = key === 'before_sound' ? 'before sound' : 'after sound'; + parts.push(sound.ok ? `${label} OK` : `${label}: ${escHtml(sound.error || 'not found')}`); + if (!sound.ok) el.className = 'routing-test-result warn'; + } + el.innerHTML = parts.join(' · '); +} + +function routingLogTime(ts) { + if (!ts) return '--:--:--'; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return String(ts).slice(11, 19) || '--:--:--'; + return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); +} + +function routingLogBadge(item) { + const status = String(item.status || item.kind || 'log'); + if (item.kind === 'test' && status === 'matched') return 'test ok'; + if (item.kind === 'test' && status === 'no_match') return 'test miss'; + return status.replace(/_/g, ' '); +} + +function routingLogMeta(item) { + const parts = []; + if (item.backend) parts.push(item.backend); + if (item.language) parts.push('lang ' + item.language); + if (item.response_format) parts.push(item.response_format); + if (item.duration != null) parts.push(Number(item.duration).toFixed(2) + 's'); + if (item.bytes != null) parts.push(Math.round(Number(item.bytes) / 1024) + ' KB'); + if (item.sounds && Array.isArray(item.sounds) && item.sounds.length) parts.push('sounds ' + item.sounds.join(',')); + if (item.route_id) parts.push('route ' + item.route_id); + if (item.client) parts.push(item.client); + return parts.join(' · '); +} + +let _routingLogItems = []; +let _routingLogFilter = 'all'; + +function routingLogPassesFilter(item) { + const status = String(item?.status || '').toLowerCase(); + const isError = status === 'error' || Boolean(item?.error); + const isNoMatch = status === 'no_match' || item?.matched === false; + if (_routingLogFilter === 'error') return isError; + if (_routingLogFilter === 'no_match') return isNoMatch; + if (_routingLogFilter === 'attention') return isError || isNoMatch; + return true; +} + +function renderCurrentRoutingLog() { + renderRoutingLog(_routingLogItems.filter(routingLogPassesFilter)); +} + +function renderRoutingLog(items = []) { + const el = $('routing-log-list'); + if (!el) return; + if (!items.length) { + const filtered = _routingLogItems.length && _routingLogFilter !== 'all'; + el.innerHTML = `
${filtered ? 'No routing log entries match this filter.' : 'No routing log entries yet. Test a route or send a TTS request through the Creator proxy.'}
`; + return; + } + el.innerHTML = items.map(item => { + const status = String(item.status || 'log').replace(/[^a-z0-9_-]/gi, '_'); + const requested = item.requested_voice || '-'; + const routed = item.routed_voice || '-'; + const voice = requested === routed ? requested : `${requested} → ${routed}`; + const text = item.error ? `Error: ${item.error}` : (item.text_preview || ''); + return ` +
+
${escHtml(routingLogTime(item.ts))}
+
${escHtml(routingLogBadge(item))}
+
${escHtml(item.app || '-')}
+
${escHtml(voice)}
+
${escHtml(routingLogMeta(item) || '-')}
+
${escHtml(text || '-')}
+
`; + }).join(''); +} + +async function loadRoutingLog() { + const el = $('routing-log-list'); + if (!el) return; + try { + const r = await fetch('/api/tts-routing-log?limit=80'); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + _routingLogItems = Array.isArray(d.items) ? d.items : []; + renderCurrentRoutingLog(); + } catch(e) { + el.innerHTML = `
Routing log unavailable: ${escHtml(e.message)}
`; + } +} + +async function clearRoutingLog() { + const btn = $('routing-log-clear-btn'); + if (btn) btn.disabled = true; + try { + const r = await fetch('/api/tts-routing-log', {method:'DELETE'}); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + _routingLogItems = []; + renderRoutingLog([]); + toast('Routing log cleared', 'success'); + } catch(e) { + toast('Clear log failed: ' + e.message, 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + +async function testRouting() { + readRoutingForm(); + const btn = $('routing-test-btn'); + const el = $('routing-test-result'); + btn.disabled = true; + el.className = 'routing-test-result'; + el.textContent = 'Testing route...'; + try { + const r = await fetch('/api/tts-route-test', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + app: $('routing-test-app').value.trim() || 'Open WebUI', + voice: $('routing-test-voice').value.trim() || 'default', + input: $('routing-test-text').value.trim(), + }), + }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + renderRouteTestResult(await r.json()); + loadRoutingLog(); + } catch(e) { + el.className = 'routing-test-result warn'; + el.textContent = 'Route test failed: ' + e.message; + } finally { + btn.disabled = false; + } +} + +async function uploadRouteSoundForRow(row, target) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.multiple = false; + input.onchange = async () => { + if (!input.files || !input.files.length) return; + const btn = row.querySelector(`.route-sound-upload[data-target="${target}"]`); + const field = row.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound'); + if (btn) btn.disabled = true; + try { + const fd = new FormData(); + fd.append('file', input.files[0]); + const r = await fetch('/api/route-sounds/upload', { method:'POST', body:fd }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + field.value = d.path || ''; + await loadRouteSounds(); + readRoutingForm(); + renderRoutingList(); + toast(`${target === 'before' ? 'Before' : 'After'} sound uploaded`, 'success'); + status(`Uploaded route sound: ${d.path}`); + } catch(e) { + toast('Sound upload failed: ' + e.message, 'error'); + status('Sound upload failed'); + } finally { + if (btn) btn.disabled = false; + } + }; + input.click(); +} + +$('routing-refresh-btn')?.addEventListener('click', loadRoutingTab); +$('routing-add-btn')?.addEventListener('click', () => { + readRoutingForm(); + _ttsRoutes.push(newRoute()); + renderRoutingList(); +}); +$('routing-add-openwebui-btn')?.addEventListener('click', () => { + readRoutingForm(); + const voices = activeVoiceIds(); + const firstByLang = lang => voices.find(v => v.toUpperCase().startsWith(lang + '_')) || ''; + _ttsRoutes.push(newRoute('Open WebUI', 'default', 'EN', firstByLang('EN'))); + _ttsRoutes.push(newRoute('Open WebUI', 'default', 'DE', firstByLang('DE'))); + renderRoutingList(); +}); +$('routing-save-btn')?.addEventListener('click', saveRoutingTab); +$('routing-test-btn')?.addEventListener('click', testRouting); +$('routing-log-refresh-btn')?.addEventListener('click', loadRoutingLog); +$('routing-log-clear-btn')?.addEventListener('click', clearRoutingLog); +$('routing-log-filter')?.addEventListener('change', (e) => { + _routingLogFilter = e.target.value || 'all'; + renderCurrentRoutingLog(); +}); +$('routing-list')?.addEventListener('change', e => { + const picker = e.target.closest('.route-sound-picker'); + if (!picker) return; + const row = picker.closest('.routing-row'); + const field = row.querySelector(picker.dataset.target === 'before' ? '.route-before-sound' : '.route-after-sound'); + if (field) field.value = picker.value || ''; + readRoutingForm(); +}); +$('routing-list')?.addEventListener('click', e => { + const pickBtn = e.target.closest('.route-sound-pick'); + if (pickBtn) { + const row = pickBtn.closest('.routing-row'); + openRouteSoundBrowser(row, pickBtn.dataset.target); + return; + } + const uploadBtn = e.target.closest('.route-sound-upload'); + if (uploadBtn) { + const row = uploadBtn.closest('.routing-row'); + uploadRouteSoundForRow(row, uploadBtn.dataset.target); + return; + } + const btn = e.target.closest('.routing-delete'); + if (!btn) return; + readRoutingForm(); + const row = btn.closest('.routing-row'); + _ttsRoutes.splice(Number(row.dataset.index), 1); + renderRoutingList(); +}); + +$('routing-sound-search')?.addEventListener('input', debounce(renderRouteSoundBrowser, 120)); +$('routing-sound-refresh-btn')?.addEventListener('click', async () => { + await loadRouteSounds(); + renderRouteSoundBrowser(); +}); +$('routing-sound-close-btn')?.addEventListener('click', closeRouteSoundBrowser); +$('routing-sound-list')?.addEventListener('click', e => { + const item = e.target.closest('.routing-sound-item'); + if (!item) return; + const path = item.dataset.path || ''; + const playBtn = e.target.closest('.sound-play'); + if (playBtn) { + playRouteSound(path, playBtn); + return; + } + if (e.target.closest('.sound-use-current')) { + useRouteSound(path, _routeSoundPickerTarget?.target || 'before'); + } +}); + +// ── Settings ────────────────────────────────────────────────────────────── + +const SETTINGS_SEEN_KEY = 'vcf-settings-seen'; +let _appSettings = {}; +let _ttsBackends = []; + +function availableTtsBackends() { + return (_ttsBackends || []).filter(b => b.available); +} + +function ttsBackendOptions(selected = '') { + const backends = availableTtsBackends(); + if (!backends.length) return ''; + const current = selected || backends[0].id; + return backends.map(b => ``).join(''); +} + +function styleBackendOptions(selected = 'customvoice') { + const backends = availableTtsBackends(); + if (!backends.length) return ''; + const preferred = backends.some(b => b.id === selected) ? selected : backends[0].id; + return backends.map(b => ``).join(''); +} + +function backendById(id) { + return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null; +} + +function backendHelpHtml(b, compact = false) { + if (!b) return 'No TTS backend is reachable.
Start at least one TTS service or check Settings URLs.
'; + const tags = [ + b.uses_wav ? ['good', 'uses WAV identity'] : ['warn', 'prompt/model voice'], + b.style_aware ? ['good', 'style-aware'] : ['warn', 'weak style'], + b.true_streaming ? ['good', 'true streaming'] : ['', 'buffered/normal'], + ].map(([cls, text]) => `${escHtml(text)}`).join(''); + const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}
Identity: ${escHtml(b.identity || '')}
Style: ${escHtml(b.style || '')}
Best for: ${escHtml(b.best_for || '')}`; + return `${escHtml(b.label)}
${tags}
${detail}
`; +} + +function updateBackendHelp() { + const b = backendById($('tts-backend-select')?.value || ''); + const help = $('tts-backend-help'); + if (help) help.innerHTML = backendHelpHtml(b); + const sttB = backendById($('stt-tts-backend-select')?.value || ''); + const sttHelp = $('stt-tts-backend-help'); + if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB); +} + +function updateStyleBackendHelp(scope = document) { + scope.querySelectorAll('.opt-style-backend').forEach(sel => { + const box = sel.closest('.opt-style-panel')?.querySelector('.opt-style-backend-help'); + if (box) box.innerHTML = backendHelpHtml(backendById(sel.value), true); + }); +} + +function updateBackendDependentTabs() { + const availableBackends = availableTtsBackends(); + const available = new Set(availableBackends.map(b => b.id)); + document.querySelectorAll('.tab[data-backend-required]').forEach(tab => { + const originalSubtitle = tab.dataset.originalSubtitle || tab.querySelector('.tab-subtitle')?.textContent || ''; + const originalTooltip = tab.dataset.originalTooltip || tab.querySelector('.tab-tooltip')?.textContent || ''; + tab.dataset.originalSubtitle = originalSubtitle; + tab.dataset.originalTooltip = originalTooltip; + const required = tab.dataset.backendRequired; + const ok = required === 'any_tts' ? availableBackends.length > 0 : available.has(required); + tab.hidden = false; + tab.classList.toggle('backend-unavailable', !ok); + tab.setAttribute('aria-disabled', ok ? 'false' : 'true'); + tab.tabIndex = ok ? 0 : -1; + const subtitle = tab.querySelector('.tab-subtitle'); + const tooltip = tab.querySelector('.tab-tooltip'); + if (subtitle) subtitle.textContent = ok ? originalSubtitle : 'not running/configured'; + if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`; + }); + const active = document.querySelector('.tab.active'); + if (!active || active.classList.contains('backend-unavailable')) { + const first = document.querySelector('.tab:not(.backend-unavailable)'); + if (first) switchTab(first.dataset.tab); + } +} + +async function refreshTtsBackendAvailability(selected = '') { + try { + const d = await fetch('/api/tts-backends').then(r => r.json()); + _ttsBackends = (d.backends || []).filter(b => b && b.id); + } catch (_) { + _ttsBackends = []; + } + const preview = $('tts-backend-select'); + if (preview) { + const prev = selected || preview.value; + preview.innerHTML = ttsBackendOptions(prev); + preview.disabled = !availableTtsBackends().length; + } + const sttTtsBackend = $('stt-tts-backend-select'); + if (sttTtsBackend) { + const prev = selected || sttTtsBackend.value; + sttTtsBackend.innerHTML = ttsBackendOptions(prev); + sttTtsBackend.disabled = !availableTtsBackends().length; + } + const libraryTts = $('library-tts-backend-select'); + if (libraryTts) { + const prev = libraryTts.value || 'voice_clone'; + libraryTts.innerHTML = ttsBackendOptions(prev); + libraryTts.disabled = !availableTtsBackends().length; + } + document.querySelectorAll('.opt-style-backend').forEach(sel => { + const prev = sel.value; + sel.innerHTML = styleBackendOptions(prev); + sel.disabled = !availableTtsBackends().length; + }); + document.querySelectorAll('.opt-compare-backend').forEach(sel => { + const prev = sel.value && sel.value !== '' ? sel.value : 'voice_clone'; + sel.innerHTML = styleBackendOptions(prev); + sel.disabled = !availableTtsBackends().length; + }); + updateBackendHelp(); + updateStyleBackendHelp(); + updateBackendDependentTabs(); + return _ttsBackends; +} + +async function loadSettings() { + const s = await fetch('/api/settings').then(r => r.json()); + $('s-whisper-url').value = s.whisper_url || ''; + $('s-whisper-key').value = s.whisper_api_key || ''; + $('s-tts-url').value = s.tts_url || ''; + _appSettings = s; + $('s-tts-stream-url').value = s.tts_stream_url || ''; + $('s-customvoice-url').value = s.customvoice_url || 'http://host.docker.internal:8022'; + $('s-nvidia-router-url').value = s.nvidia_router_url || 'http://host.docker.internal:8090'; + $('s-nvidia-tts-url').value = s.nvidia_tts_url || 'http://host.docker.internal:8091'; + $('s-nvidia-asr-url').value = s.nvidia_asr_url || 'http://host.docker.internal:8092'; + $('s-nvidia-zeroshot-url').value = s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093'; + $('s-nvidia-flow-url').value = s.nvidia_flow_url || 'http://host.docker.internal:8094'; + $('s-tts-stream-mode').value = s.tts_stream_mode || 'auto'; + $('s-tts-key').value = s.tts_api_key || ''; + $('s-tts-backend').value = s.tts_backend || 'openai'; + const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0}; + const byBackend = s.tts_extra_params_by_backend || {}; + $('s-tts-extra-voice-clone').value = JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2); + $('s-tts-extra-streaming').value = JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2); + $('s-tts-extra-customvoice').value = JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2); + $('s-tts-extra-voice-design').value = JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2); + $('s-tts-extra-nvidia-magpie').value = JSON.stringify(byBackend.nvidia_magpie || {}, null, 2); + $('s-tts-extra-nvidia-zeroshot').value = JSON.stringify(byBackend.nvidia_zeroshot || {}, null, 2); + $('s-tts-extra-nvidia-flow').value = JSON.stringify(byBackend.nvidia_flow || {}, null, 2); + $('s-voice-design-url').value = s.voice_design_url || 'http://host.docker.internal:8021'; + $('s-vd-key').value = s.voice_design_api_key || ''; + $('s-voices-scan-dir').value = s.voices_scan_dir || ''; + $('s-output-dir').value = s.output_dir || ''; + await refreshTtsBackendAvailability(); +} + +function markSettingsSeen() { + localStorage.setItem(SETTINGS_SEEN_KEY, '1'); +} +function openSettings(firstRun = false) { + $('settings-first-run-note').style.display = firstRun ? '' : 'none'; + if (firstRun) markSettingsSeen(); + switchTab('settings'); +} +function closeSettings(markSeen = true) { + if (markSeen) markSettingsSeen(); +} + +document.querySelectorAll('.s-eye-btn').forEach(btn => { + btn.addEventListener('click', () => { + const inp = $(btn.dataset.target); + inp.type = inp.type === 'password' ? 'text' : 'password'; + }); +}); +$('settings-btn').addEventListener('click', async () => { await loadSettings(); openSettings(false); }); +$('s-close-btn').addEventListener('click', async () => { await loadSettings(); toast('Settings reloaded', 'success'); }); +$('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; }); +$('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; }); +$('s-save-btn').addEventListener('click', async () => { + let ttsExtraParamsByBackend = {}; + const paramFields = [ + ['voice_clone', 's-tts-extra-voice-clone', 'Voice Clone/Base'], + ['streaming', 's-tts-extra-streaming', 'Streaming'], + ['customvoice', 's-tts-extra-customvoice', 'CustomVoice'], + ['voice_design', 's-tts-extra-voice-design', 'Voice Design'], + ['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'], + ['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'], + ['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'], + ]; + try { + for (const [key, id, label] of paramFields) { + ttsExtraParamsByBackend[key] = JSON.parse($(id).value || '{}'); + } + } catch (e) { + toast('TTS params JSON is invalid: ' + e.message, 'error'); + return; + } + await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + whisper_url: $('s-whisper-url').value, + whisper_api_key: $('s-whisper-key').value, + tts_url: $('s-tts-url').value, + tts_stream_url: $('s-tts-stream-url').value, + customvoice_url: $('s-customvoice-url').value, + nvidia_router_url: $('s-nvidia-router-url').value, + nvidia_tts_url: $('s-nvidia-tts-url').value, + nvidia_asr_url: $('s-nvidia-asr-url').value, + nvidia_clone_url: $('s-nvidia-zeroshot-url').value, + nvidia_zeroshot_url: $('s-nvidia-zeroshot-url').value, + nvidia_flow_url: $('s-nvidia-flow-url').value, + tts_stream_mode: $('s-tts-stream-mode').value, + tts_api_key: $('s-tts-key').value, + tts_backend: $('s-tts-backend').value, + tts_extra_params_by_backend: ttsExtraParamsByBackend, + voice_design_url: $('s-voice-design-url').value, + voice_design_api_key: $('s-vd-key').value, + voices_scan_dir: $('s-voices-scan-dir').value, + output_dir: $('s-output-dir').value, + }) }); + _appSettings.tts_stream_url = $('s-tts-stream-url').value; + _appSettings.customvoice_url = $('s-customvoice-url').value; + _appSettings.nvidia_router_url = $('s-nvidia-router-url').value; + _appSettings.nvidia_tts_url = $('s-nvidia-tts-url').value; + _appSettings.nvidia_asr_url = $('s-nvidia-asr-url').value; + _appSettings.nvidia_clone_url = $('s-nvidia-zeroshot-url').value; + _appSettings.nvidia_zeroshot_url = $('s-nvidia-zeroshot-url').value; + _appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value; + _appSettings.voice_design_url = $('s-voice-design-url').value; + _appSettings.tts_stream_mode = $('s-tts-stream-mode').value; + _ttsStreamHealth = null; + await refreshTtsBackendAvailability($('tts-backend-select')?.value || ''); + markSettingsSeen(); + renderIntegrationSnippets(); + toast('Settings saved', 'success'); + toast('Settings saved', 'success'); +}); + +// ── Voice ID field (tab 3) ──────────────────────────────────────────────── + +function validateVoiceId(v) { return /^[A-Za-z0-9_\-\.]+$/.test(v); } + +$('voice-id-input').addEventListener('input', () => { + const val = $('voice-id-input').value; + const ok = val && validateVoiceId(val); + $('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : ''; + $('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : ''; +}); +$('helper-apply-btn').addEventListener('click', () => { + const name = $('name-input').value.trim(); + if (!name) { toast('Enter a name first', 'error'); return; } + $('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`; + $('voice-id-input').dispatchEvent(new Event('input')); +}); + +// ── WaveSurfer ──────────────────────────────────────────────────────────── + +let ws = null, wsRegions = null, currentFileId = null, trimmedFileId = null, designedFileId = null, editingVoiceId = null, editingVoicePath = null; + +function initWaveSurfer() { + if (ws) { ws.destroy(); ws = null; wsRegions = null; } + wsRegions = WaveSurfer.Regions.create(); + ws = WaveSurfer.create({ container:'#waveform', waveColor:'#45475a', progressColor:'#89b4fa', + cursorColor:'#cba6f7', height:90, normalize:true, plugins:[wsRegions] }); + ws.on('ready', () => { + const dur = ws.getDuration(); + $('trim-end').value = dur.toFixed(2); $('trim-end').max = dur.toFixed(2); $('trim-start').max = dur.toFixed(2); + updateRegion(); + }); + wsRegions.on('region-updated', r => { + $('trim-start').value = r.start.toFixed(2); $('trim-end').value = r.end.toFixed(2); updateDurationLabel(); + }); +} +function updateRegion() { + wsRegions.clearRegions(); + const s = parseFloat($('trim-start').value)||0, e = parseFloat($('trim-end').value)||(ws?ws.getDuration():0); + wsRegions.addRegion({ start:s, end:e, color:'rgba(137,180,250,0.25)', drag:true, resize:true }); + updateDurationLabel(); +} +function updateDurationLabel() { + const d = Math.max(0, (parseFloat($('trim-end').value)||0) - (parseFloat($('trim-start').value)||0)); + const el = $('trim-duration'); el.textContent = d.toFixed(1)+' s'; + el.className = d>=5&&d<=20 ? 'dur-ok' : d>20 ? 'dur-warn' : 'dur-bad'; +} +['trim-start','trim-end'].forEach(id => $(id).addEventListener('input', () => { if(ws) updateRegion(); })); +$('play-btn').addEventListener('click', () => { if(ws) ws.playPause(); }); +$('play-selection-btn').addEventListener('click', () => { + if (!ws) return; + ws.play(parseFloat($('trim-start').value)||0, parseFloat($('trim-end').value)||ws.getDuration()); +}); +$('auto-trim-btn').addEventListener('click', async () => { + if (!currentFileId) { toast('No audio loaded','error'); return; } + $('auto-trim-btn').disabled = true; + status('Finding best TTS reference segment…'); + try { + const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:currentFileId})}); + let d; + if (r.ok) { + d = await r.json(); + } else if (r.status === 404 || r.status === 405) { + status('Backend auto trim unavailable; analysing audio in browser…'); + d = await clientAutoTrimBounds(currentFileId); + } else { + const e = await r.json().catch(() => ({})); + throw new Error(e.detail || r.statusText || 'Auto trim failed'); + } + $('trim-start').value = Number(d.start).toFixed(2); + $('trim-end').value = Number(d.end).toFixed(2); + if (ws) updateRegion(); + toast('Auto trim set: '+Number(d.duration).toFixed(1)+' s','success'); + status(d.reason || 'Auto trim ready'); + } catch(e) { + toast('Auto trim failed: '+e.message,'error'); + status('Auto trim failed'); + } finally { $('auto-trim-btn').disabled = false; } +}); + +function loadAudioId(id, dur, opts = {}) { + currentFileId = id; trimmedFileId = null; designedFileId = null; + editingVoiceId = opts.editingVoiceId || null; + editingVoicePath = opts.editingVoicePath || null; + $('trim-start').value='0'; $('trim-end').value=dur.toFixed(2); + $('waveform-card').style.display=''; initWaveSurfer(); ws.load('/api/audio/'+id); + $('save-result').style.display='none'; $('trim-audio').style.display='none'; $('no-audio-hint').style.display=''; + if (editingVoiceId) { + $('voice-id-input').value = editingVoiceId; + $('voice-id-input').dispatchEvent(new Event('input')); + $('transcript-area').value = opts.transcript || ''; + status('Editing existing voice: ' + editingVoiceId); + } +} + +// ── Drop zone ───────────────────────────────────────────────────────────── + +const dropZone = $('drop-zone'), fileInput = $('file-input'); +dropZone.addEventListener('click', () => fileInput.click()); +dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); +dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); +dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); }); +fileInput.addEventListener('change', () => { if(fileInput.files.length) uploadFile(fileInput.files[0]); }); + +async function uploadFile(file) { + status('Uploading '+file.name+'…'); + const fd = new FormData(); fd.append('file', file); + try { + const r = await fetch('/api/upload', { method:'POST', body:fd }); + if (!r.ok) { const e = await r.json(); throw new Error(e.detail||r.statusText); } + const d = await r.json(); + loadAudioId(d.id, d.duration); status('Loaded: '+file.name+' ('+d.duration.toFixed(1)+' s)'); + toast('File loaded', 'success'); + } catch(e) { toast('Upload failed: '+e.message, 'error'); status('Upload failed'); } +} + +async function loadLibraryVoiceAudio(v) { + const audioResp = await fetch(voiceFileUrl(v), {cache:'no-store'}); + if (!audioResp.ok) { + const e = await audioResp.json().catch(() => ({})); + throw new Error(e.detail || audioResp.statusText); + } + + const blob = await audioResp.blob(); + const ext = (v.file_type || 'wav').toLowerCase(); + const fd = new FormData(); + fd.append('file', new File([blob], `${v.id}.${ext}`, {type:blob.type || 'audio/wav'})); + const upload = await fetch('/api/upload', { method:'POST', body:fd }); + if (!upload.ok) { + const e = await upload.json().catch(() => ({})); + throw new Error(e.detail || upload.statusText); + } + const d = await upload.json(); + return { id:d.id, voice_id:v.id, duration:d.duration, transcript:v.transcript || '', file_type:ext, path:v.path }; +} + +// ── YouTube ─────────────────────────────────────────────────────────────── + +$('yt-btn').addEventListener('click', () => { + const url = $('yt-url').value.trim(); if(!url) return; + $('yt-btn').disabled=true; $('yt-progress').textContent='Starting download…'; + const es = new EventSource('/api/download-yt?url='+encodeURIComponent(url)); + es.onmessage = e => { + const d = JSON.parse(e.data); + if (d.error) { toast('Download failed: '+d.error,'error'); $('yt-progress').textContent=d.error; $('yt-btn').disabled=false; es.close(); } + else if (d.done) { es.close(); $('yt-btn').disabled=false; $('yt-progress').textContent='Done!'; loadAudioId(d.id,d.duration); toast('YouTube audio loaded','success'); } + else { $('yt-progress').textContent=d.msg||''; if(d.pct) status('Downloading… '+d.pct+'%'); } + }; + es.onerror = () => { es.close(); $('yt-btn').disabled=false; }; +}); + +// ── Microphone ──────────────────────────────────────────────────────────── + +const RAW_MIC_CONSTRAINTS = { + echoCancellation:false, + noiseSuppression:false, + autoGainControl:false +}; + +async function visibleMicrophoneCount() { + if (!navigator.mediaDevices?.enumerateDevices) return null; + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter(device => device.kind === 'audioinput').length; + } catch(e) { + return null; + } +} + +async function microphoneErrorMessage(error) { + const name = error?.name || ''; + const message = error?.message || ''; + const lowerMessage = message.toLowerCase(); + const micCount = await visibleMicrophoneCount(); + if (name === 'NotFoundError' || lowerMessage.includes('requested device not found')) { + return micCount === 0 + ? 'No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.' + : 'The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.'; + } + if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { + return 'Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.'; + } + if (name === 'NotReadableError') { + return 'The microphone is busy or unavailable. Close other apps using it, then try again.'; + } + if (name === 'SecurityError') { + return 'Microphone access requires localhost or HTTPS.'; + } + return message || 'Microphone failed.'; +} + +async function requestMicrophoneStream(options = {}) { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Microphone requires HTTPS. Open the app via https://... or access it on localhost.'); + } + if (!options.raw) return navigator.mediaDevices.getUserMedia({audio:true}); + try { + return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS}); + } catch(e) { + if (e?.name === 'OverconstrainedError' || e?.name === 'NotFoundError') { + return navigator.mediaDevices.getUserMedia({audio:true}); + } + throw e; + } +} + +let mediaRec=null, recChunks=[], recTimer=null, recSecs=0; +$('rec-start-btn').addEventListener('click', async () => { + try { + const stream = await requestMicrophoneStream(); + recChunks=[]; recSecs=0; $('rec-time').textContent='0:00'; + $('rec-indicator').classList.add('active'); $('rec-start-btn').disabled=true; $('rec-stop-btn').disabled=false; + recTimer = setInterval(() => { recSecs++; $('rec-time').textContent=Math.floor(recSecs/60)+':'+String(recSecs%60).padStart(2,'0'); }, 1000); + mediaRec = new MediaRecorder(stream); + mediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data); }; + mediaRec.onstop = async () => { + clearInterval(recTimer); $('rec-indicator').classList.remove('active'); stream.getTracks().forEach(t=>t.stop()); + const blob = new Blob(recChunks, {type:mediaRec.mimeType||'audio/webm'}); + const ext = (mediaRec.mimeType||'').includes('ogg') ? '.ogg' : '.webm'; + await uploadFile(new File([blob], 'recording'+ext, {type:blob.type})); + }; + mediaRec.start(100); status('Recording…'); + } catch(e) { toast(await microphoneErrorMessage(e), 'error'); } +}); +$('rec-stop-btn').addEventListener('click', () => { + if(mediaRec&&mediaRec.state!=='inactive') mediaRec.stop(); + $('rec-start-btn').disabled=false; $('rec-stop-btn').disabled=true; +}); + +// ── Trim ────────────────────────────────────────────────────────────────── + +$('trim-btn').addEventListener('click', async () => { + if (!currentFileId) { toast('No audio loaded','error'); return; } + try { + const r = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({id:currentFileId, start:parseFloat($('trim-start').value)||0, end:parseFloat($('trim-end').value)||0}) }); + if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } + const d = await r.json(); trimmedFileId=d.id; designedFileId=null; + $('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none'; + switchTab('save'); toast('Trim done','success'); + } catch(e) { toast('Trim failed: '+e.message,'error'); } +}); + +// ── Voice design naming helpers ──────────────────────────────────────────── + +const DESIGN_LANG_CODE = { + Auto:'EN', English:'EN', Chinese:'ZH', Japanese:'JA', Korean:'KO', + German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Portuguese:'PT', Russian:'RU', +}; +const DESIGN_GENDER_WORD = { F:'female', M:'male', N:'neutral' }; +const DESIGN_PRESET_KEY = 'vcf-design-presets'; +const DESIGN_PRESET_SEEDED_KEY = 'vcf-design-presets-seeded-v2'; +const DEFAULT_DESIGN_PRESETS = { + 'EN_M_Young_Energetic': { + description: 'Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.', + sample_text: 'Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.', + language: 'English', + gender: 'M', + }, + 'EN_F_Warm_Narrator': { + description: 'Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.', + sample_text: 'The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.', + language: 'English', + gender: 'F', + }, + 'DE_M_Elderly_Documentary': { + description: 'Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.', + sample_text: 'Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.', + language: 'German', + gender: 'M', + }, + 'DE_F_Young_Friendly': { + description: 'Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.', + sample_text: 'Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.', + language: 'German', + gender: 'F', + }, + 'EN_N_Old_Wise_Assistant': { + description: 'Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.', + sample_text: 'Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.', + language: 'English', + gender: 'N', + }, +}; +const QWEN_DESIGN_SAMPLES = { + 'qwen-timbre-reuse': { + title: 'Qwen Timbre Reuse', + summary: 'Reference clip for designing a reusable teen character timbre.', + description: 'Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous', + text: "H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?", + language: 'English', + gender: 'M', + }, + 'acoustic-sausage-announcer': { + title: 'Acoustic Attribute Control - British announcer', + summary: 'Fast, loud, articulate British male delivery with excitement and performative authority.', + description: `gender: Male. +pitch: Low male pitch with significant upward inflections for emphasis and excitement. +speed: Fast-paced delivery with deliberate pauses for dramatic effect. +volume: Loud and projecting, increasing notably during moments of praise and announcements. +age: Young adult to middle-aged adult. +clarity: Highly articulate and distinct pronunciation. +fluency: Very fluent speech with no hesitations. +accent: British English. +texture: Bright and clear vocal texture. +emotion: Enthusiastic and excited, especially when complimenting. +tone: Upbeat, authoritative, and performative. +personality: Confident, extroverted, and engaging.`, + text: 'Nine different, exciting ways of cooking sausage. Incredible. There were three outstanding deliveries in terms of the sausage being the hero. The first dish that we want to dissect, this individual smartly combined different proteins in their sausage. Great seasoning. The blend was absolutely spot on. Congratulations. Please step forward. Natasha.', + language: 'English', + gender: 'M', + }, + 'acoustic-character-laugh': { + title: 'Acoustic Attribute Control - theatrical character', + summary: 'Artificially high male character voice shifting from loud forced amusement to deliberate resignation.', + description: `gender: Male. +pitch: Artificially high-pitched, slightly lowering after the initial laugh. +speed: Rapid during the laugh, then slowing to a deliberate pace. +volume: Loud laugh transitioning to a standard conversational level. +age: Young adult to middle-aged, performing a character voice. +clarity: Clear and distinct articulation. +fluency: Fluent delivery without hesitation. +accent: American English. +texture: Slightly strained and somewhat nasal quality. +emotion: Forced amusement shifting to feigned resignation. +tone: Initially playful, then shifts to a slightly put-upon tone. +personality: Theatrical and expressive.`, + text: "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.", + language: 'English', + gender: 'M', + }, + 'age-control-surly-elvis': { + title: 'Age Control - middle-aged gravel', + summary: 'Low, resonant, slightly gravelly American male voice with a commanding opening.', + description: `gender: Male. +pitch: Low male pitch, generally stable. +speed: Deliberate pace, slowing slightly after the initial exclamation. +volume: Starts loud, then transitions to a projected conversational volume. +age: Middle-aged adult. +clarity: High clarity with distinct pronunciation. +fluency: Highly fluent. +accent: American English. +texture: Resonant and slightly gravelly. +emotion: Initially commanding, shifting to narrative amusement. +tone: Authoritative start, moving to an engaging, descriptive tone. +personality: Confident and performative.`, + text: 'Older gentleman, 110, maybe 111 years old, sort of a surly Elvis thing happening with him. He smiles like this. Seen him around?', + language: 'English', + gender: 'M', + }, + 'gradual-control-anger': { + title: 'Gradual Control - emotional escalation', + summary: 'Female voice that begins neutral and quickly escalates into sharp anger and accusation.', + description: `gender: Female. +pitch: Mid-range female pitch, rising sharply with frustration. +speed: Starts measured, then accelerates rapidly during emotional outburst. +volume: Begins conversational, escalates quickly to loud and forceful. +age: Young adult to middle-aged. +clarity: High clarity and distinct articulation throughout. +fluency: Highly fluent with no significant pauses or fillers. +accent: General American English. +texture: Bright and clear vocal quality. +emotion: Shifts abruptly from neutral acceptance to intense resentment and anger. +tone: Initially accepting, becomes sharply accusatory and confrontational. +personality: Assertive and emotionally expressive when provoked.`, + text: 'Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you-', + language: 'English', + gender: 'F', + }, + 'human-likeness-digital-nomad': { + title: 'Human-likeness - casual self-aware monologue', + summary: 'Warm male conversational voice with natural laughter, hesitations, and self-deprecating humor.', + description: 'A relaxed, naturally expressive male voice in his late twenties to early thirties, with a moderately low pitch, casual speaking rate, and conversational volume; deliver lines with a light, self-deprecating tone, breaking into genuine, easygoing laughter at moments of embarrassment, while maintaining clear articulation and an overall warm, approachable clarity.', + text: `Yeah, so--uh--I'm a digital nomad, right? So... pretty much all my communication is just, like, texts and messages. And now, you know, there's these AI agents that can, uh... reply for you? Which is--heh--convenient, sure, I guess? But also... kinda delicate, you know? +Like, you'll type something super short--like, "Yep, sounds good"--and it'll turn that into this whole... warm, polished paragraph. Like, way nicer than I'd ever write myself. huh... ha Seriously, I sound like a Hallmark card all of a sudden. +But then... once you outsource that... what's the other person actually hearing? Are they hearing me... or just some... generic, friendly-bot voice? Man, that's weird to even say out loud.`, + language: 'English', + gender: 'M', + }, + 'background-marcus-cole': { + title: 'Background Information - Marcus Cole', + summary: 'Broadcast booth announcer profile with bright, agile, urgent delivery.', + description: `Character Name: Marcus Cole +Voice Profile: A bright, agile male voice with a natural upward lift, delivering lines at a brisk, energetic pace. Pitch leans high with spark, volume projects clearly--near-shouting at peaks--to convey urgency and excitement. Speech flows seamlessly, fluently, each word sharply defined, riding a current of dynamic rhythm. +Background: Longtime broadcast booth announcer for national television, specializing in live interstitials and public engagement spots. His voice bridges segments, rallies action, and keeps momentum alive--from voter drives to entertainment news. +Presence: Late 50s, neatly groomed, dressed in a crisp shirt under studio lights. Moves with practiced ease, eyes locked on the script, energy coiled and ready. +Personality: Energetic, precise, inherently engaging. He doesn't just read--he propels. Behind the speed is intent: to inform fast, to move people to act. Whether it's "text VOTE to 5703" or a star-studded tease, he makes it feel immediate, vital.`, + text: "Lot being you watching. 1-866-IDLE-03 for JPL. That's 1-866-436-5703. Or text the word VOTE to 5703. Diana DeGarmo's next with more from the movies right after this brief intermission on American Idol.", + language: 'English', + gender: 'M', + }, + 'timbre-reuse-lucas-mia': { + title: 'Timbre Reuse - Lucas and Mia', + summary: 'Two-character teen dialogue using native VoiceDesign speaker-profile switching.', + description: `"Lucas": "Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous" +"Mia": "Female, 16 years old, mezzo-soprano range, softening - lowering register to intimate speaking voice, consonants softening"`, + text: `Lucas:H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe? +Mia:Oh wow, my mortal enemy - Mr. Thompson's problem sets. Thanks for rescuing me from that F. +Lucas:No problem! I actually... kinda finished those already? If you want to compare answers or something... +Mia:Is this your sneaky way of saying you want to study together, Lucas? Because I saw you staring during lab partners sign-up. +Lucas:What? No! I mean yes but not like... I just think you're... your titration technique is really precise! +Mia:That's the nerdiest compliment I've ever gotten. Tell you what - help me survive pre-calc and I'll teach you how to actually flirt. +Lucas:Wow, harsh. And here I thought my titration line was smooth. +Mia:It was adorable. Like when you tripped over your shoelaces in the hall yesterday. Or that time you- +Lucas:Okay okay! I get it, I'm a disaster. So... library after school? I'll bring the graphing calculators? +Mia:Only if you promise not to spill coffee on my notes again... though I guess watching you panic-clean was pretty cute.`, + language: 'English', + gender: 'N', + dialogue: true, + }, +}; +let currentDesignSource = null; + +function loadDesignPresets() { + try { return JSON.parse(localStorage.getItem(DESIGN_PRESET_KEY) || '{}'); } + catch { return {}; } +} + +function saveDesignPresets(presets) { + localStorage.setItem(DESIGN_PRESET_KEY, JSON.stringify(presets)); +} + +async function syncDesignPresetsToServer() { + try { + await fetch('/api/voice-design-presets', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(loadDesignPresets()), + }); + renderIntegrationSnippets(); + } catch(e) { + status('Voice Design preset sync failed: ' + e.message); + } +} + +function seedDesignPresets() { + const presets = loadDesignPresets(); + let changed = false; + Object.entries(DEFAULT_DESIGN_PRESETS).forEach(([name, preset]) => { + if (!presets[name]) { + presets[name] = preset; + changed = true; + return; + } + ['description', 'sample_text', 'language', 'gender'].forEach(key => { + if (!presets[name][key] && preset[key]) { + presets[name][key] = preset[key]; + changed = true; + } + }); + }); + if (changed || !localStorage.getItem(DESIGN_PRESET_SEEDED_KEY)) saveDesignPresets(presets); + localStorage.setItem(DESIGN_PRESET_SEEDED_KEY, '1'); + if (changed) syncDesignPresetsToServer(); +} + +function refreshDesignPresetSelect() { + const presets = loadDesignPresets(); + const sel = $('design-preset-select'); + const prev = sel.value; + sel.innerHTML = ''; + Object.keys(presets).sort((a,b)=>a.localeCompare(b)).forEach(name => { + const opt = document.createElement('option'); + opt.value = opt.textContent = name; + sel.appendChild(opt); + }); + if (presets[prev]) sel.value = prev; + renderDesignPresetLibrary(); +} + +function applyDesignPreset(name) { + const preset = loadDesignPresets()[name]; + if (!preset) { toast('Preset not found', 'error'); return; } + $('design-instruct').value = preset.description || ''; + $('design-sample-text').value = preset.sample_text || preset.text || $('design-sample-text').value || ''; + $('design-language').value = preset.language || 'Auto'; + $('design-gender').value = preset.gender || 'N'; + $('design-preset-name').value = name; + $('design-preset-select').value = name; + currentDesignSource = { name, gender:preset.gender || 'N', language:preset.language || 'Auto', text:$('design-sample-text').value || '', description:preset.description || '' }; + toast('Preset loaded: ' + name, 'success'); +} + +function renderDesignPresetLibrary() { + const lib = $('design-preset-library'); + if (!lib) return; + const presets = loadDesignPresets(); + const names = Object.keys(presets).sort((a,b)=>a.localeCompare(b)); + if (!names.length) { + lib.innerHTML = '
No saved prompt presets yet.
'; + return; + } + lib.innerHTML = ''; + names.forEach(name => { + const p = presets[name]; + const row = document.createElement('div'); + row.className = 'design-preset-row'; + row.innerHTML = ` + ${escHtml(name)} + ${escHtml(p.gender || 'N')} + ${escHtml(p.language || 'Auto')} + ${escHtml(p.description || '')} + ${escHtml(p.sample_text || p.text || '')} + + + + + + `; + row.querySelector('.preset-use').addEventListener('click', () => applyDesignPreset(name)); + row.querySelector('.preset-delete').addEventListener('click', () => { + const all = loadDesignPresets(); + delete all[name]; + saveDesignPresets(all); + syncDesignPresetsToServer(); + refreshDesignPresetSelect(); + toast('Preset deleted: ' + name, 'success'); + }); + row.querySelector('.preset-preview').addEventListener('click', async e => { + e.currentTarget.disabled = true; + try { + const sampleText = p.sample_text || p.text || $('design-sample-text').value; + const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify(voiceDesignPayload(p.description || '', sampleText, p.language || 'Auto', p))}); + if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); } + const d = await r.json(); + $('design-audio').src = '/api/audio/' + d.id; + $('design-result').style.display = 'flex'; + $('design-audio').play().catch(()=>{}); + } catch(err) { toast('Preset preview failed: ' + err.message, 'error'); } + finally { e.currentTarget.disabled = false; } + }); + lib.appendChild(row); + }); +} + +function renderQwenSampleCards() { + const list = $('qwen-sample-list'); + if (!list) return; + list.innerHTML = ''; + Object.entries(QWEN_DESIGN_SAMPLES).forEach(([key, sample]) => { + const card = document.createElement('div'); + card.className = 'qwen-sample design-sample-grid'; + card.dataset.qwenSample = key; + card.innerHTML = ` + ${escHtml(sample.title || key)} + ${escHtml(sample.gender || 'N')} + ${escHtml(sample.language || 'Auto')} + ${escHtml(sample.description || '')} + ${escHtml(sample.text || '')} +
+ + + +
+ + `; + list.appendChild(card); + }); +} + +function applyQwenSample(sample) { + $('design-instruct').value = sample.description; + $('design-sample-text').value = sample.text; + $('design-language').value = sample.language; + $('design-gender').value = sample.gender; + currentDesignSource = sample; + $('design-result').style.display = 'none'; + $('design-save-result').style.display = 'none'; + $('design-instruct').scrollIntoView({behavior:'smooth', block:'nearest'}); +} + +function isDialogueDesign(instruct, text, source = null) { + if (source && source.dialogue) return true; + const speakers = new Set(); + String(instruct || '').split(/\n+/).forEach(line => { + const match = line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/); + if (match) speakers.add(match[1].trim()); + }); + if (speakers.size < 2) return false; + const turnSpeakers = new Set(); + String(text || '').split(/\n+/).forEach(line => { + const match = line.trim().match(/^([^:]{1,40}):\s*(.+)$/); + if (match && speakers.has(match[1].trim())) turnSpeakers.add(match[1].trim()); + }); + return turnSpeakers.size >= 2; +} + +function voiceDesignPayload(instruct, sampleText, language, source = null, gender = null) { + return { + instruct, + sample_text: sampleText, + language, + gender: gender || source?.gender || $('design-gender')?.value || '', + dialogue: isDialogueDesign(instruct, sampleText, source), + }; +} + +let _dVoiceIdManual = false; + +function designSafeName(name) { + return String(name || 'VoiceDesign') + .replace(/^[A-Z]{2}_[FMN]_/, '') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 42) || 'VoiceDesign'; +} + +function voiceIdSafePart(value, fallback = 'style') { + return String(value || fallback) + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 32) || fallback; +} + +function suggestedStyleVoiceId(baseId, style) { + const suffix = voiceIdSafePart(style || 'style'); + return `${baseId}_${suffix}`.slice(0, 96); +} + +function _updateDVoiceId() { + if (_dVoiceIdManual) return; + const lang = $('d-lang').value, gender = $('d-gender').value, name = $('d-name').value.trim(); + $('d-voice-id').value = name ? `${lang}_${gender}_${name}` : ''; +} + +['d-lang','d-gender'].forEach(id => $(id).addEventListener('change', _updateDVoiceId)); +$('d-name').addEventListener('input', () => { _dVoiceIdManual = false; _updateDVoiceId(); }); +$('d-voice-id').addEventListener('input', () => { _dVoiceIdManual = true; }); + +seedDesignPresets(); +refreshDesignPresetSelect(); +renderQwenSampleCards(); +syncDesignPresetsToServer(); +$('design-preset-select').addEventListener('change', () => { + if ($('design-preset-select').value) applyDesignPreset($('design-preset-select').value); +}); +$('design-preset-load').addEventListener('click', () => { + const name = $('design-preset-select').value || $('design-preset-name').value.trim(); + if (!name) { toast('Select a preset first', 'error'); return; } + applyDesignPreset(name); +}); +$('design-preset-save').addEventListener('click', () => { + const name = $('design-preset-name').value.trim() || $('design-preset-select').value; + if (!name) { toast('Enter a preset name', 'error'); $('design-preset-name').focus(); return; } + const presets = loadDesignPresets(); + presets[name] = { + description: $('design-instruct').value, + sample_text: $('design-sample-text').value, + language: $('design-language').value, + gender: $('design-gender').value, + dialogue: isDialogueDesign($('design-instruct').value, $('design-sample-text').value, currentDesignSource), + }; + saveDesignPresets(presets); + syncDesignPresetsToServer(); + refreshDesignPresetSelect(); + $('design-preset-select').value = name; + toast('Preset saved: ' + name, 'success'); +}); +$('design-preset-delete').addEventListener('click', () => { + const name = $('design-preset-select').value || $('design-preset-name').value.trim(); + if (!name) { toast('Select a preset first', 'error'); return; } + const presets = loadDesignPresets(); + if (!presets[name]) { toast('Preset not found', 'error'); return; } + delete presets[name]; + saveDesignPresets(presets); + syncDesignPresetsToServer(); + refreshDesignPresetSelect(); + $('design-preset-name').value = ''; + toast('Preset deleted: ' + name, 'success'); +}); + +['design-instruct','design-sample-text'].forEach(id => $(id).addEventListener('input', () => { + currentDesignSource = null; + if (id === 'design-sample-text') $('d-transcript').value = $('design-sample-text').value; +})); + +document.querySelectorAll('.qwen-sample').forEach(card => { + const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample]; + const state = card.querySelector('.qwen-state'); + const audio = card.querySelector('audio'); + card.querySelector('.qwen-use').addEventListener('click', () => { + applyQwenSample(sample); + toast('Voice Design sample loaded', 'success'); + }); + card.querySelector('.qwen-preview').addEventListener('click', async e => { + const btn = e.currentTarget; + btn.disabled = true; + state.textContent = 'Generating preview…'; + try { + const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify(voiceDesignPayload(sample.description, sample.text, sample.language, sample))}); + if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); } + const d = await r.json(); + audio.src = '/api/audio/' + d.id; + audio.style.display = ''; + audio.play().catch(()=>{}); + state.textContent = 'Preview ready'; + } catch(err) { + state.textContent = 'Preview failed'; + toast('Sample preview failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + } + }); +}); + +// ── Voice design ────────────────────────────────────────────────────────── + +async function runVoiceDesign() { + const baseInstruct=$('design-instruct').value.trim(), sample=$('design-sample-text').value.trim(); + const dialogue = isDialogueDesign(baseInstruct, sample, currentDesignSource); + const instruct = baseInstruct; + if (!instruct) { toast('Enter a voice description first','error'); return; } + $('design-generate-btn').disabled=true; $('design-status').textContent='Generating…'; + $('design-result').style.display='none'; $('design-save-result').style.display='none'; + status('Generating voice design…'); + try { + const r = await fetch('/api/voice-design', { method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify(voiceDesignPayload(instruct, sample, $('design-language').value, currentDesignSource, $('design-gender').value)) }); + if (!r.ok) { const e=await r.json(); throw new Error(e.detail||r.statusText); } + const d = await r.json(); designedFileId=d.id; trimmedFileId=null; editingVoiceId=null; + $('design-audio').src='/api/audio/'+d.id; + $('design-result').style.display='flex'; + $('design-status').textContent='Done ('+d.duration.toFixed(1)+' s)'; + const langCode = DESIGN_LANG_CODE[$('design-language').value] || 'EN'; + $('d-lang').value = langCode; + $('d-gender').value = $('design-gender').value; + $('d-name').value = designSafeName(currentDesignSource?.title || currentDesignSource?.name || $('design-preset-name').value || 'VoiceDesign'); + _dVoiceIdManual = false; _updateDVoiceId(); + $('d-transcript').value = sample; + $('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none'; + if (!$('transcript-area').value) $('transcript-area').value=sample; + $('design-audio').play().catch(()=>{}); + $('design-result').scrollIntoView({behavior:'smooth',block:'nearest'}); + toast('Voice generated and export fields filled.','success'); + status('Voice design ready'); + } catch(e) { + $('design-status').textContent='Failed: '+e.message; + toast('Voice design failed: '+e.message,'error'); + status('Voice design failed'); + } finally { $('design-generate-btn').disabled=false; } +} +$('design-generate-btn').addEventListener('click', runVoiceDesign); +$('design-retry-btn').addEventListener('click', runVoiceDesign); + +$('design-save-btn').addEventListener('click', async () => { + if (!designedFileId) { toast('No voice generated yet','error'); return; } + const voiceId = $('d-voice-id').value.trim(); + if (!voiceId) { toast('Enter a Voice ID first','error'); $('d-name').focus(); return; } + if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; } + $('design-save-btn').disabled=true; + try { + const r = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id:designedFileId, voice_id:voiceId, transcript:$('d-transcript').value})}); + if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } + const saved = await r.json(); + await saveMeta(saved.voice_id, { + gender: $('d-gender').value, + flag: LANG_FLAG_DEFAULT[$('d-lang').value] || undefined, + transcript: $('d-transcript').value, + note: 'Voice Design: ' + $('design-instruct').value.slice(0, 240), + }).catch(()=>{}); + await loadVoiceLibrary().catch(()=>{}); + $('design-save-result').style.display='flex'; + $('design-save-result').scrollIntoView({behavior:'smooth',block:'nearest'}); + toast('Exported to Voice Clone Library: '+saved.voice_id,'success'); + status('Exported to Voice Clone Library: '+saved.voice_id); + } catch(e) { toast('Save failed: '+e.message,'error'); } + finally { $('design-save-btn').disabled=false; } +}); + +$('design-download-btn').addEventListener('click', () => { + if (!designedFileId) return; + const a=document.createElement('a'); a.href='/api/audio/'+designedFileId; a.download=($('d-voice-id').value.trim() || 'voice_design') + '.wav'; a.click(); +}); + +// ── Transcribe ──────────────────────────────────────────────────────────── + +$('transcribe-btn').addEventListener('click', async () => { + const id = trimmedFileId||designedFileId||currentFileId; + if (!id) { toast('No audio to transcribe','error'); return; } + $('transcribe-btn').disabled=true; $('transcribe-status').textContent='Transcribing…'; + try { + const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}); + if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } + const d = await r.json(); $('transcript-area').value=d.text; $('transcribe-status').textContent='Done'; + toast('Transcription complete','success'); + } catch(e) { $('transcribe-status').textContent='Failed: '+e.message; toast('Transcription failed: '+e.message,'error'); } + finally { $('transcribe-btn').disabled=false; } +}); + +// ── Save voice ──────────────────────────────────────────────────────────── + +$('save-btn').addEventListener('click', async () => { + const id = trimmedFileId||designedFileId||currentFileId; + if (!id) { toast('No audio ready','error'); return; } + const voiceId=$('voice-id-input').value.trim(); + if (!voiceId) { toast('Enter a Voice ID','error'); return; } + if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; } + $('save-btn').disabled=true; + try { + const payload = {id, voice_id:voiceId, path:editingVoicePath, transcript:$('transcript-area').value}; + const sendSave = endpoint => fetch(endpoint, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); + let fallbackSave = false; + let r = await sendSave(editingVoiceId ? '/api/voice-replace' : '/api/save'); + if (editingVoiceId && (r.status === 404 || r.status === 405)) { + fallbackSave = true; + status('Update endpoint unavailable; saving as a regular voice…'); + r = await sendSave('/api/save'); + } + if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } + const d = await r.json(); $('save-result').style.display=''; toast((editingVoiceId ? 'Voice updated: ' : 'Voice saved: ')+d.voice_id,'success'); + if (fallbackSave && editingVoiceId && voiceId !== editingVoiceId) { + const del = await fetch('/api/voice/' + encodeURIComponent(editingVoiceId), {method:'DELETE'}); + if (!del.ok) status('Saved renamed voice; old library entry may need manual deletion.'); + } + editingVoiceId = null; editingVoicePath = null; + } catch(e) { toast('Save failed: '+e.message,'error'); } + finally { $('save-btn').disabled=false; } +}); + +// ══════════════════════════════════════════════════════════════════════════ +// LIBRARY — sort + render +// ══════════════════════════════════════════════════════════════════════════ + +let _voices = []; +let _sortField = 'id'; +let _sortDir = 1; // 1 = asc, -1 = desc +let _libraryIssueFilter = ''; +let _activePlayButton = null; +let _activePlayVoiceId = null; +let _activePlayUrl = null; +let _libraryLoadPromise = null; +const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text'; +const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''}; +const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.'; + +function benchmarkSampleText() { + const el = $('benchmark-sample-text'); + return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT; +} + +function initBenchmarkSampleControls() { + const sample = $('benchmark-sample-text'); + if (!sample) return; + sample.value = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY) || DEFAULT_BENCHMARK_SAMPLE_TEXT; + sample.addEventListener('input', debounce(() => { + localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value.trim()); + status('Benchmark sample sentence saved'); + }, 500)); + $('benchmark-reset-sample-btn')?.addEventListener('click', () => { + sample.value = DEFAULT_BENCHMARK_SAMPLE_TEXT; + localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value); + status('Benchmark sample sentence reset'); + }); + $('benchmark-use-preview-btn')?.addEventListener('click', () => { + const text = $('preview-text-area')?.value.trim(); + if (!text) { toast('Preview text is empty', 'error'); return; } + sample.value = text; + localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); + status('Benchmark sample sentence copied from TTS preview'); + }); +} + +function getSortValue(v, field) { + switch(field) { + case 'has_picture': return v.has_picture ? 1 : 0; + case 'flag': return (v.flag || '').toLowerCase(); + case 'gender': return {'F':0,'M':1,'N':2}[v.gender] ?? 3; + case 'id': return v.id.toLowerCase(); + case 'file_type': return voiceFileType(v); + case 'duration': return v.duration || 0; + case 'dbfs': return voiceDbfs(v) ?? -999; + case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; + case 'transcript': return (v.transcript || '').toLowerCase(); + case 'note': return (v.note || '').toLowerCase(); + case 'rating': return v.rating || 0; + case 'enabled': return v.enabled === false ? 0 : 1; + default: return ''; + } +} + +function setSort(field) { + _sortDir = (_sortField === field) ? _sortDir * -1 : 1; + _sortField = field; + syncSortHeaders(); + renderVoiceList(); +} + +function syncSortHeaders() { + document.querySelectorAll('.vl-header [data-sort]').forEach(el => { + el.classList.remove('sort-asc', 'sort-desc'); + if (el.dataset.sort === _sortField) + el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc'); + }); +} + +const FLAG_LANGUAGE_CANDIDATES = { + GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'], + CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'], + DE:['DE'], AT:['DE'], CH:['DE','FR','IT'], + FR:['FR'], BE:['FR','NL'], LU:['FR','DE'], + ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'], + PT:['PT'], BR:['PT'], + IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'], + CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'], + EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'], + CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'], + SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'], +}; +const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]])); + +const LANGUAGE_LABELS = { + EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish', + DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian', + HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian', + RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali', + AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish', +}; +const SEX_FILTER_LABELS = { + F:'♀ Female', + M:'♂ Male', + N:'⚥ Diverse / neutral', +}; + +function voiceLangFromName(v) { + return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase(); +} + +function libraryVoiceLang(v) { + const fromName = voiceLangFromName(v); + const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()]; + if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0]; + return fromName; +} + +function libraryLanguageLabel(code) { + return LANGUAGE_LABELS[code] || code; +} + +function populateLibraryFilters() { + const langSel = $('library-filter-lang'); + const sexSel = $('library-filter-sex'); + const typeSel = $('library-filter-type'); + if (!langSel || !sexSel || !typeSel) return; + const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value}; + const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))); + const sexOrder = ['F','M','N']; + const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))] + .sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b))); + const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort(); + langSel.innerHTML = '' + langs.map(x => ``).join(''); + sexSel.innerHTML = '' + sexes.map(x => ``).join(''); + typeSel.innerHTML = '' + types.map(x => ``).join(''); + langSel.value = langs.includes(keep.lang) ? keep.lang : ''; + sexSel.value = sexes.includes(keep.sex) ? keep.sex : ''; + typeSel.value = types.includes(keep.type) ? keep.type : ''; +} + +function readLibraryFilters() { + _libraryFilters.text = ($('library-filter-text')?.value || '').trim().toLowerCase(); + _libraryFilters.lang = $('library-filter-lang')?.value || ''; + _libraryFilters.sex = $('library-filter-sex')?.value || ''; + _libraryFilters.type = $('library-filter-type')?.value || ''; + _libraryFilters.rating = $('library-filter-rating')?.value || ''; +} + +function libraryFilterMatch(v) { + const f = _libraryFilters; + if (f.lang && libraryVoiceLang(v) !== f.lang) return false; + if (f.sex && (v.gender || '') !== f.sex) return false; + if (f.type && voiceFileType(v) !== f.type) return false; + if (f.rating) { + const r = Number(v.rating || 0); + const wanted = Number(f.rating); + if (wanted === 0 && r !== 0) return false; + if (wanted === 1 && r < 1) return false; + if (wanted > 1 && r < wanted) return false; + } + if (f.text) { + const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender].map(x => String(x || '').toLowerCase()).join(' '); + if (!hay.includes(f.text)) return false; + } + return true; +} + +function clearLibraryFilters() { + ['library-filter-text','library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { const el = $(id); if (el) el.value = ''; }); + readLibraryFilters(); + renderVoiceList(); +} + +function libraryTtsBackend() { + return $('library-tts-backend-select')?.value || 'voice_clone'; +} + +function needsDuration(v) { + return v.duration == null || Number.isNaN(Number(v.duration)); +} + +function voiceFileType(v) { + if (v.file_type) return String(v.file_type).replace(/^\./, '').toLowerCase(); + const source = String(v.path || v.filename || ''); + const match = source.match(/\.([A-Za-z0-9]+)(?:$|[?#])/); + return match ? match[1].toLowerCase() : 'wav'; +} + +function voiceDbfs(v) { + const value = v.loudness && (v.loudness.dbfs ?? v.loudness.after_dbfs); + return value == null || Number.isNaN(Number(value)) ? null : Number(value); +} + +function fmtDbfs(v) { + const db = voiceDbfs(v); + return db == null ? '-' : db.toFixed(1); +} + +function voiceBenchmark(v) { + return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null; +} + +function voiceBenchmarkElapsed(v) { + const b = voiceBenchmark(v); + const value = b && b.elapsed_sec; + return value == null || Number.isNaN(Number(value)) ? null : Number(value); +} + +function fmtBenchmark(v) { + const b = voiceBenchmark(v); + if (!b) return '-'; + if (!b.ok) return 'ERR'; + const elapsed = voiceBenchmarkElapsed(v); + if (elapsed == null) return '-'; + const speed = b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''; + return elapsed.toFixed(1) + 's' + speed; +} + +function benchmarkClass(v) { + const b = voiceBenchmark(v); + if (!b) return ''; + if (!b.ok || b.clipped || b.realtime_ok === false) return 'bench-bad'; + const elapsed = voiceBenchmarkElapsed(v); + return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn'; +} + +function voiceFileUrl(v) { + const version = v._audioVersion || v.updated_at || v.benchmarked_at || ''; + const bust = version || Date.now(); + return `/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`; +} + +function markVoiceAudioChanged(v) { + v._audioVersion = Date.now(); +} + +function benchmarkTitle(v) { + const b = voiceBenchmark(v); + if (!b) return 'Not benchmarked yet'; + const parts = []; + if (b.ok) { + parts.push(`total ${Number(b.elapsed_sec || 0).toFixed(2)}s`); + if (b.ttfa_ms != null) parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`); + if (b.audio_sec != null) parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`); + if (b.rtf != null) parts.push(`RTF ${Number(b.rtf).toFixed(2)}`); + if (b.speed != null) parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`); + if (b.clipped) parts.push('output clipped'); + } else { + parts.push('benchmark failed'); + if (b.error) parts.push(b.error); + } + if (Array.isArray(b.advice) && b.advice.length) parts.push(b.advice.join(' | ')); + if (b.benchmarked_at) parts.push(`saved ${b.benchmarked_at}`); + return parts.join(' · '); +} + +async function clientVoiceLoudness(v) { + if (!v.path) throw new Error('No audio path'); + const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); + if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); + const audioData = await resp.arrayBuffer(); + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const buffer = await ctx.decodeAudioData(audioData.slice(0)); + let sum = 0, peak = 0, count = 0; + for (let ch = 0; ch < buffer.numberOfChannels; ch++) { + const data = buffer.getChannelData(ch); + count += data.length; + for (let i = 0; i < data.length; i++) { + const sample = data[i]; + sum += sample * sample; + peak = Math.max(peak, Math.abs(sample)); + } + } + const rms = Math.sqrt(sum / Math.max(1, count)); + const dbfs = rms > 0 ? 20 * Math.log10(rms) : null; + const peakDbfs = peak > 0 ? 20 * Math.log10(peak) : null; + return { + dbfs: dbfs == null ? null : Number(dbfs.toFixed(2)), + peak_dbfs: peakDbfs == null ? null : Number(peakDbfs.toFixed(2)), + }; +} + +async function clientCalculateVoiceDb() { + const voices = visibleLibraryVoices(); + const errors = []; + let calculated = 0; + const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'}; + setBenchmarkProgress(0, voices.length, 'Preparing dB scan...', stats); + for (const v of voices) { + setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats); + try { + v.loudness = await clientVoiceLoudness(v); + await saveMeta(v.id, { loudness: v.loudness }).catch(()=>{}); + calculated++; + stats.ok++; + stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`; + status(`Calculated dB: ${calculated} / ${voices.length}`); + } catch(e) { + errors.push({voice_id:v.id, detail:e.message}); + stats.errors++; + stats.last = `${v.id}: ${e.message}`; + } + setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats); + await new Promise(resolve => setTimeout(resolve, 0)); + } + setBenchmarkProgress(voices.length, voices.length, 'dB scan complete', stats); + return { calculated, errors, voices: voices.map(v => ({voice_id:v.id, loudness:v.loudness})) }; +} + +async function hydrateVoiceDuration(v, el) { + if (!v.path || !needsDuration(v) || v._durationLoading) return; + v._durationLoading = true; + try { + const audio = new Audio(); + audio.preload = 'metadata'; + audio.src = voiceFileUrl(v); + await new Promise((resolve, reject) => { + audio.onloadedmetadata = resolve; + audio.onerror = () => reject(new Error('Could not read duration')); + }); + if (Number.isFinite(audio.duration) && audio.duration > 0) { + v.duration = audio.duration; + if (el && document.body.contains(el)) { + el.textContent = fmtDuration(v.duration); + el.title = String(v.duration.toFixed(2)); + } + } + audio.removeAttribute('src'); + audio.load(); + } catch(e) { + if (el && document.body.contains(el)) el.title = e.message; + } finally { + v._durationLoading = false; + } +} + +document.querySelectorAll('.vl-header [data-sort]').forEach(el => + el.addEventListener('click', () => setSort(el.dataset.sort)) +); + +function dominantLanguages(limit = 3) { + const counts = new Map(); + (_voices || []).forEach(v => { + const lang = (v.lang || String(v.id || '').split('_')[0] || '?').toUpperCase(); + counts.set(lang, (counts.get(lang) || 0) + 1); + }); + return [...counts.entries()] + .sort((a,b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([lang, count]) => `${lang} ${count}`) + .join(' · ') || '-'; +} + +function updateLibraryInsights(state = 'ready') { + const el = $('library-insights'); + if (!el) return; + if (state === 'loading') { + el.innerHTML = [ + ['…', 'Loading'], ['…', 'Active'], ['…', 'Languages'], ['…', 'Benchmarks'], ['…', 'Quality'], ['…', 'Actions'] + ].map(([value, label]) => `
${value}${label}
`).join(''); + return; + } + if (state === 'error') { + el.innerHTML = '
FailedLibrary load
'; + return; + } + const total = _voices.length; + const active = _voices.filter(v => v.enabled !== false).length; + const hidden = total - active; + const bench = _voices.map(voiceBenchmark).filter(Boolean); + const slow = bench.filter(b => b && b.ok && b.realtime_ok === false).length; + const dbValues = _voices.map(voiceDbfs).filter(v => v != null); + const avgDb = dbValues.length ? (dbValues.reduce((a,b) => a + b, 0) / dbValues.length).toFixed(1) : '-'; + const missingRef = _voices.filter(v => !v.transcript).length; + const restart = _voices.filter(v => v.needs_tts_restart).length; + const visible = _voices.filter(v => $('show-disabled-cb').checked || v.enabled !== false).length; + const tiles = [ + {value:`${visible}/${total}`, label:'Visible'}, + {value:`${active} on`, label:hidden ? `${hidden} hidden` : 'Active'}, + {value:dominantLanguages(), label:'Languages'}, + {value:bench.length ? `${bench.length} done` : '-', label:slow ? `${slow} slow` : 'Benchmarks', filter: slow ? 'slow' : '', title: slow ? describeIssueVoices('slow') : 'No slow voices'}, + {value:avgDb === '-' ? '-' : `${avgDb} dB`, label:missingRef ? `${missingRef} no text` : 'Avg loudness', filter: missingRef ? 'no_text' : '', title: missingRef ? describeIssueVoices('no_text') : 'All visible voices have reference text'}, + {value:restart || '-', label:restart ? 'Need restart' : 'Restart flags', filter: restart ? 'restart' : '', title: restart ? describeIssueVoices('restart') : 'No voices need restart'}, + ]; + el.innerHTML = tiles.map(item => { + const filter = item.filter ? ` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"` : ''; + const activeCls = item.filter && item.filter === _libraryIssueFilter ? ' active' : ''; + const title = item.title ? ` title="${escHtml(item.title)}"` : ''; + return `
${escHtml(item.value)}${escHtml(item.label)}
`; + }).join(''); + el.querySelectorAll('[data-filter]').forEach(tile => { + const activate = () => setLibraryIssueFilter(tile.dataset.filter || ''); + tile.addEventListener('click', activate); + tile.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }); + }); +} + +async function loadVoiceLibrary() { + if (_libraryLoadPromise) return _libraryLoadPromise; + _libraryLoadPromise = (async () => { + setBusyButton('refresh-voices-btn', true); + const list = $('voice-list'); + if (list) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8); + $('voice-count').textContent = 'Loading voices…'; + updateLibraryInsights('loading'); + status('Loading voice library…'); + try { + const r = await fetch('/api/voices'); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + _voices = await r.json(); + renderVoiceList(); + updatePreviewVoiceMatchPanel(); + status(`Loaded ${_voices.length} voices`); + } catch(e) { + if (list) list.innerHTML = '
Failed to load voices
'; + $('voice-count').textContent = 'Load failed'; + updateLibraryInsights('error'); + status('Voice library load failed'); + throw e; + } finally { + setBusyButton('refresh-voices-btn', false); + _libraryLoadPromise = null; + } + })(); + return _libraryLoadPromise; +} + +$('refresh-voices-btn').addEventListener('click', loadVoiceLibrary); +$('sync-voice-folders-btn').addEventListener('click', async () => { + $('sync-voice-folders-btn').disabled = true; + status('Syncing active_voices and hidden_voices…'); + try { + const r = await fetch('/api/voices/sync-folders', { method:'POST' }); + if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + await loadVoiceLibrary(); + const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : ''; + toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success'); + status(`Synced folders. Restart Qwen3-TTS after changing active voices.`); + } catch(e) { + toast('Sync failed: ' + e.message, 'error'); + status('Folder sync failed'); + } finally { $('sync-voice-folders-btn').disabled = false; } +}); + +function visibleLibraryVoices() { + const showDisabled = $('show-disabled-cb').checked; + return _voices.filter(v => showDisabled || v.enabled !== false); +} +function libraryIssueMatch(v, filter = _libraryIssueFilter) { + const b = voiceBenchmark(v); + if (filter === 'slow') return Boolean(b && b.ok && b.realtime_ok === false); + if (filter === 'no_text') return !String(v.transcript || '').trim(); + if (filter === 'restart') return Boolean(v.needs_tts_restart); + return true; +} +function libraryIssueLabel(filter = _libraryIssueFilter) { + return {slow:'slow benchmark voices', no_text:'voices without reference text', restart:'voices needing TTS restart'}[filter] || 'all voices'; +} +function libraryIssueVoices(filter = _libraryIssueFilter) { + return visibleLibraryVoices().filter(v => libraryIssueMatch(v, filter)); +} +function describeIssueVoices(filter = _libraryIssueFilter, limit = 12) { + const voices = libraryIssueVoices(filter).map(v => v.id); + if (!voices.length) return 'No matching voices'; + const extra = voices.length > limit ? `, +${voices.length - limit} more` : ''; + return voices.slice(0, limit).join(', ') + extra; +} +function setLibraryIssueFilter(filter = '') { + _libraryIssueFilter = _libraryIssueFilter === filter ? '' : filter; + renderVoiceList(); + if (_libraryIssueFilter) status(`${libraryIssueLabel()}: ${describeIssueVoices()}`); + else status('Showing all visible voices'); +} + +function libraryTargetDb() { + const input = $('library-target-db'); + const raw = Number(input?.value ?? -20); + const value = Number.isFinite(raw) ? Math.min(-1, Math.max(-60, raw)) : -20; + if (input) input.value = String(value); + return value; +} + +$('calculate-db-btn').addEventListener('click', async () => { + $('calculate-db-btn').disabled = true; + status('Calculating voice loudness…'); + try { + const d = await clientCalculateVoiceDb(); + renderVoiceList(); + const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : ''; + toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success'); + status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`); + } catch(e) { + toast('Calculate dB failed: ' + e.message, 'error'); + status('dB calculation failed'); + } finally { $('calculate-db-btn').disabled = false; } +}); + +$('normalize-volume-btn').addEventListener('click', async () => { + const target = libraryTargetDb(); + const visible = visibleLibraryVoices(); + const voices = visible.filter(v => voiceFileType(v) === 'wav'); + const skipped = visible.length - voices.length; + if (!voices.length) { + toast('No visible WAV voices to normalize', 'error'); + return; + } + if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return; + + $('normalize-volume-btn').disabled = true; + $('calculate-db-btn').disabled = true; + const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'}; + const errors = []; + let normalized = 0; + setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats); + status(`Normalizing ${voices.length} voices to ${target} dBFS...`); + try { + for (const v of voices) { + setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats); + try { + const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})}); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + v.loudness = d.loudness || v.loudness; + v.duration = d.duration ?? v.duration; + v.file_type = d.file_type || v.file_type; + v.path = d.path || v.path; + v.needs_tts_restart = true; + markVoiceAudioChanged(v); + normalized++; + stats.ok++; + stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`; + } catch(e) { + errors.push({voice_id:v.id, detail:e.message}); + stats.errors++; + stats.last = `${v.id}: ${e.message}`; + } + setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats); + status(`Normalized ${normalized} / ${voices.length}`); + await new Promise(resolve => setTimeout(resolve, 0)); + } + setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats); + renderVoiceList(); + updateLibraryInsights(); + const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`; + toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success'); + status('Volume normalized. Restart TTS before rebenchmarking these voices.'); + } catch(e) { + toast('Normalize volume failed: ' + e.message, 'error'); + status('Normalize volume failed'); + } finally { + $('normalize-volume-btn').disabled = false; + $('calculate-db-btn').disabled = false; + } +}); + +function fmtClock(ms) { + if (!Number.isFinite(ms) || ms < 0) return '-'; + const total = Math.round(ms / 1000); + const m = Math.floor(total / 60), s = total % 60; + return `${m}:${String(s).padStart(2,'0')}`; +} + +function setBenchmarkProgress(done, total, label = '', stats = {}) { + const panel = $('benchmark-progress'); + const track = panel.querySelector('.benchmark-progress-track'); + const pct = total ? Math.round(done / total * 100) : 0; + panel.hidden = false; + $('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...'); + $('benchmark-progress-count').textContent = `${done} / ${total}`; + $('benchmark-progress-bar').style.width = pct + '%'; + track.setAttribute('aria-valuenow', String(pct)); + const live = $('benchmark-live-stats'); + if (live) { + const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0; + const avg = done > 0 ? elapsed / done : 0; + const eta = done > 0 && total > done ? avg * (total - done) : 0; + live.innerHTML = [ + `Elapsed ${fmtClock(elapsed)}`, + `Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`, + `ETA ${done && total > done ? fmtClock(eta) : '-'}`, + `OK ${stats.ok || 0}`, + `${stats.middleLabel || 'Slow'} ${stats.slow || 0}`, + `${stats.errorLabel || 'Errors'} ${stats.errors || 0}`, + ].map(x => `${escHtml(x)}`).join(''); + } + const last = $('benchmark-live-last'); + if (last && stats.last) last.textContent = stats.last; +} + +function hideBenchmarkProgress() { + $('benchmark-progress').hidden = true; + $('benchmark-progress-bar').style.width = '0%'; + if ($('benchmark-live-last')) $('benchmark-live-last').textContent = ''; +} + +async function clearTtsRestartFlags() { + const r = await fetch('/api/tts/restart-flags/clear', { method:'POST' }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + _voices.forEach(voice => { voice.needs_tts_restart = false; }); + document.querySelectorAll('.vl-row.edit-open').forEach(row => row.classList.remove('opt-restart-needed')); + updateLibraryInsights(); + return d; +} + +async function runVoiceBenchmark(voiceId = '', opts = {}) { + const text = opts.text ?? benchmarkSampleText(); + if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } + const payload = {active_only:true, text}; + if (voiceId) payload.voice_id = voiceId; + const r = await fetch('/api/voices/benchmark', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(payload), + }); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + return r.json(); +} + +async function runVoiceBenchmarkBatch() { + const voices = _voices.filter(v => v.enabled !== false); + const text = benchmarkSampleText(); + if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } + if (!voices.length) { toast('No active voices to benchmark', 'error'); return null; } + const total = voices.length; + const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true}; + const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''}; + setBenchmarkProgress(0, total, 'Starting benchmark...', stats); + for (let i = 0; i < voices.length; i++) { + const voice = voices[i]; + setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats); + status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`); + try { + const d = await runVoiceBenchmark(voice.id, {text}); + if (d) { + aggregate.benchmarked += Number(d.benchmarked || 0); + aggregate.errors.push(...(d.errors || [])); + aggregate.voices.push(...(d.voices || [])); + mergeBenchmarkResults(d); + const hit = (d.voices || []).find(x => x.voice_id === voice.id); + const b = hit && hit.benchmark; + if (b && b.ok) { + stats.ok++; + if (b.realtime_ok === false) stats.slow++; + stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`; + } else { + stats.errors++; + stats.last = `${voice.id}: failed${b && b.error ? ' · ' + b.error : ''}`; + } + } + } catch(e) { + aggregate.errors.push({voice_id: voice.id, detail: e.message}); + stats.errors++; + stats.last = `${voice.id}: failed · ${e.message}`; + } + setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats); + } + setBenchmarkProgress(total, total, 'Benchmark complete', stats); + return aggregate; +} + +function mergeBenchmarkResults(d) { + const byId = new Map((d.voices || []).map(x => [x.voice_id, x])); + _voices.forEach(v => { + const hit = byId.get(v.id); + if (hit && hit.benchmark) v.benchmark = hit.benchmark; + }); +} + +function activeBenchmarkVoices() { + return (_voices || []).filter(v => v.enabled !== false); +} + +function showBenchmarkConfirm() { + const voices = activeBenchmarkVoices(); + const text = benchmarkSampleText(); + if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; } + if (!voices.length) { toast('No active voices to benchmark', 'error'); return; } + const staleCount = voices.filter(v => v.needs_tts_restart).length; + $('benchmark-confirm-title').textContent = `Benchmark ${voices.length} active voices?`; + $('benchmark-confirm-text').textContent = 'This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice.' + + (staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : ''); + $('benchmark-confirm').hidden = false; + $('benchmark-confirm-start').focus(); +} + +function hideBenchmarkConfirm() { + const panel = $('benchmark-confirm'); + if (panel) panel.hidden = true; +} + +$('benchmark-voices-btn').addEventListener('click', showBenchmarkConfirm); +$('benchmark-confirm-cancel')?.addEventListener('click', hideBenchmarkConfirm); +$('benchmark-confirm-start')?.addEventListener('click', async () => { + hideBenchmarkConfirm(); + $('benchmark-voices-btn').disabled = true; + $('benchmark-confirm-start').disabled = true; + status('Benchmarking active voices...'); + try { + const d = await runVoiceBenchmarkBatch(); + if (!d) return; + mergeBenchmarkResults(d); + await loadVoiceLibrary(); + const slow = (d.voices || []).filter(x => x.benchmark && x.benchmark.realtime_ok === false).length; + const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : ''; + toast(`Benchmarked ${d.benchmarked} voices${slow ? `, ${slow} slow` : ''}${extra}`, d.errors && d.errors.length ? 'error' : 'success'); + status('Benchmark saved with TTFA, total time, RTF, and speed.'); + } catch(e) { + toast('Benchmark failed: ' + e.message, 'error'); + status('Benchmark failed'); + } finally { + $('benchmark-voices-btn').disabled = false; + $('benchmark-confirm-start').disabled = false; + } +}); + +$('copy-active-voices-btn').addEventListener('click', async () => { + const active = activeVoiceIds(); + if (!active.length) { toast('No active voices to copy', 'error'); return; } + await copyText(active.join(', ')); + toast('Copied ' + active.length + ' active voices', 'success'); + status('Copied active voices to clipboard'); +}); + +const LIB_ADD_SAMPLE_TEXTS = { + EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.', + DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.', + IT: 'La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.', + ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.', + FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.', + PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.', + NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.', + PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.' +}; +const LIB_ADD_SAMPLE_STORAGE_KEY = 'vcf-lib-add-sample-texts'; + +function libAddSampleOverrides() { + try { return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY) || '{}') || {}; } + catch(e) { return {}; } +} + +function getLibAddSampleText(code) { + return libAddSampleOverrides()[code] || LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN; +} + +function saveLibAddSampleText() { + const code = $('lib-add-sample-lang').value; + const text = $('lib-add-sample-text').value.trim(); + const overrides = libAddSampleOverrides(); + if (text && text !== LIB_ADD_SAMPLE_TEXTS[code]) overrides[code] = text; + else delete overrides[code]; + localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides)); + setLibAddStatus('Sample sentence saved'); +} + +function resetLibAddSampleText() { + const code = $('lib-add-sample-lang').value; + const overrides = libAddSampleOverrides(); + delete overrides[code]; + localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides)); + $('lib-add-sample-text').value = LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN; + setLibAddStatus('Sample sentence reset'); +} + +function updateLibAddSampleLanguage(lang) { + const code = LIB_ADD_SAMPLE_TEXTS[lang] ? lang : 'EN'; + $('lib-add-sample-lang').value = code; + $('lib-add-lang').value = code; + $('lib-add-sample-text').value = getLibAddSampleText(code); + const voiceId = $('lib-add-voice-id').value.trim(); + if (voiceId && /^[A-Z]{2}_/.test(voiceId)) { + $('lib-add-voice-id').value = voiceId.replace(/^[A-Z]{2}_/, code + '_'); + } +} + +function renderLibAddMeter(level = 0, db = -Infinity, clipped = false) { + const meter = $('lib-add-mic-meter'); + if (!meter.children.length) { + for (let i = 0; i < 18; i++) { + const bar = document.createElement('div'); + bar.className = 'bar'; + meter.appendChild(bar); + } + } + const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length); + [...meter.children].forEach((bar, i) => { + bar.className = 'bar'; + bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px'; + if (i < active) { + bar.classList.add('on'); + if (db > -12 && i > 11) bar.classList.add('hot'); + if (clipped && i > 14) bar.classList.add('clip'); + } + }); + $('lib-add-db-readout').textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; +} + +function syncLibAddMicGain() { + const gain = parseFloat($('lib-add-mic-gain').value) || 0; + $('lib-add-mic-gain-value').textContent = gain.toFixed(2) + 'x'; + if (libAddState.gainNode) libAddState.gainNode.gain.value = gain; +} + +function startLibAddMeter() { + if (!libAddState.analyser) return; + if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf); + const data = new Float32Array(libAddState.analyser.fftSize); + const tick = () => { + libAddState.analyser.getFloatTimeDomainData(data); + let sum = 0, peak = 0; + for (const sample of data) { + sum += sample * sample; + peak = Math.max(peak, Math.abs(sample)); + } + const rms = Math.sqrt(sum / data.length); + const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity; + const level = Number.isFinite(db) ? (db + 60) / 60 : 0; + renderLibAddMeter(level, db, peak > 0.98); + libAddState.meterRaf = requestAnimationFrame(tick); + }; + tick(); +} + +async function ensureLibAddMicMonitor() { + if (libAddState.recordStream) return; + const AudioCtx = window.AudioContext || window.webkitAudioContext; + libAddState.stream = await requestMicrophoneStream({raw:true}); + if (AudioCtx) { + libAddState.audioCtx = new AudioCtx(); + libAddState.sourceNode = libAddState.audioCtx.createMediaStreamSource(libAddState.stream); + libAddState.gainNode = libAddState.audioCtx.createGain(); + libAddState.analyser = libAddState.audioCtx.createAnalyser(); + libAddState.analyser.fftSize = 1024; + const dest = libAddState.audioCtx.createMediaStreamDestination(); + syncLibAddMicGain(); + libAddState.sourceNode.connect(libAddState.gainNode); + libAddState.gainNode.connect(libAddState.analyser); + libAddState.gainNode.connect(dest); + libAddState.recordStream = dest.stream; + startLibAddMeter(); + } else { + libAddState.recordStream = libAddState.stream; + } + libAddState.monitoring = true; + $('lib-add-monitor-btn').disabled = true; + $('lib-add-monitor-stop').disabled = false; +} + +function stopLibAddMic() { + if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf); + libAddState.meterRaf = null; + [libAddState.sourceNode, libAddState.gainNode, libAddState.analyser].forEach(node => { + try { if (node) node.disconnect(); } catch(e) {} + }); + if (libAddState.stream) libAddState.stream.getTracks().forEach(t => t.stop()); + if (libAddState.recordStream) libAddState.recordStream.getTracks().forEach(t => t.stop()); + if (libAddState.audioCtx) libAddState.audioCtx.close().catch(()=>{}); + libAddState.stream = null; + libAddState.recordStream = null; + libAddState.sourceNode = null; + libAddState.gainNode = null; + libAddState.analyser = null; + libAddState.audioCtx = null; + libAddState.monitoring = false; + $('lib-add-monitor-btn').disabled = false; + $('lib-add-monitor-stop').disabled = true; + renderLibAddMeter(0, -Infinity, false); +} + +let libAddState = { + id:null, duration:0, audio:null, buffer:null, recorder:null, chunks:[], pendingSource:null, + stream:null, recordStream:null, timer:null, secs:0, audioCtx:null, + sourceNode:null, gainNode:null, analyser:null, meterRaf:null, monitoring:false +}; +window.libAddState = libAddState; +$('add-new-voice-btn').addEventListener('click', () => { + $('lib-add-panel').classList.toggle('open'); +}); +$('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value)); +$('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value)); +$('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500)); +$('lib-add-use-sample').addEventListener('click', () => { + $('lib-add-transcript').value = $('lib-add-sample-text').value.trim(); + setLibAddStatus('Sample sentence copied to transcript'); +}); +$('lib-add-reset-sample').addEventListener('click', resetLibAddSampleText); +$('lib-add-mic-help-btn').addEventListener('click', () => { + $('lib-add-mic-help').classList.toggle('open'); +}); +$('lib-add-monitor-btn').addEventListener('click', async () => { + try { + await ensureLibAddMicMonitor(); + setLibAddStatus('Mic level monitor active'); + } catch(e) { + stopLibAddMic(); + $('lib-add-mic-help').classList.add('open'); + const message = await microphoneErrorMessage(e); + toast(message, 'error'); + setLibAddStatus(message); + } +}); +$('lib-add-monitor-stop').addEventListener('click', () => { + stopLibAddMic(); + setLibAddStatus('Mic level monitor stopped'); +}); +$('lib-add-mic-gain').addEventListener('input', syncLibAddMicGain); +renderLibAddMeter(); +syncLibAddMicGain(); +updateLibAddSampleLanguage('EN'); + +function setLibAddStatus(msg) { + $('lib-add-status').textContent = msg; + status(msg); +} +function suggestLibVoiceId(filename) { + if ($('lib-add-voice-id').value.trim()) return; + const base = String(filename || 'NewVoice') + .replace(/\.[^.]+$/, '') + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 60) || 'NewVoice'; + $('lib-add-voice-id').value = `${$('lib-add-lang').value || 'EN'}_${$('lib-add-gender').value || 'N'}_${base}`; +} +function loadLibAddAudio(id, duration, label = 'Audio') { + libAddState.id = id; + libAddState.duration = Number(duration) || 0; + libAddState.buffer = null; + $('lib-add-start').value = '0.00'; + $('lib-add-end').value = libAddState.duration ? Math.min(libAddState.duration, 20).toFixed(2) : '0.00'; + $('lib-add-audio').src = '/api/audio/' + id; + $('lib-add-audio').style.display = ''; + $('lib-add-wave').style.display = ''; + attachLibAddWaveSelection(); + decodeTempAudio(id).then(buffer => { + if (libAddState.id !== id) return; + libAddState.buffer = buffer; + drawLibAddWave(); + }).catch(()=>{}); + setLibAddStatus(`${label} loaded${libAddState.duration ? ' (' + libAddState.duration.toFixed(1) + ' s)' : ''}`); +} +async function decodeTempAudio(id) { + const resp = await fetch('/api/audio/' + encodeURIComponent(id)); + if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); + const data = await resp.arrayBuffer(); + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + return ctx.decodeAudioData(data.slice(0)); +} +function clampLibAddTime(value) { + const duration = libAddState.duration || libAddState.buffer?.duration || 0; + return Math.max(0, Math.min(duration, Number(value) || 0)); +} +function setLibAddCropRange(start, end) { + const duration = libAddState.duration || libAddState.buffer?.duration || 0; + let a = clampLibAddTime(start), b = clampLibAddTime(end); + if (Math.abs(b - a) < 0.05) b = Math.min(duration, a + Math.min(1, duration || 1)); + if (b < a) [a, b] = [b, a]; + $('lib-add-start').value = a.toFixed(2); + $('lib-add-end').value = b.toFixed(2); + drawLibAddWave(); +} +function libAddWaveTimeFromEvent(e) { + const canvas = $('lib-add-wave'); + const rect = canvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + const duration = libAddState.duration || libAddState.buffer?.duration || 0; + return rect.width ? x / rect.width * duration : 0; +} +function updateLibAddCropHint() { + const hint = $('lib-add-crop-hint'); + if (!hint) return; + const start = parseFloat($('lib-add-start').value) || 0; + const end = parseFloat($('lib-add-end').value) || 0; + const dur = Math.max(0, end - start); + hint.textContent = dur ? `Selected ${dur.toFixed(1)}s. Aim for 3-20 seconds.` : 'Select 3-20 seconds for best cloning.'; + hint.className = 'crop-duration-hint ' + (dur >= 3 && dur <= 20 ? 'ok' : dur ? 'warn' : ''); +} +function drawLibAddWave() { + if (!libAddState.buffer) return; + drawOptimizerWave( + $('lib-add-wave'), + libAddState.buffer, + parseFloat($('lib-add-start').value) || 0, + parseFloat($('lib-add-end').value) || libAddState.duration || libAddState.buffer.duration + ); + updateLibAddCropHint(); +} +function libAddWaveSelectionPixels(e) { + const canvas = $('lib-add-wave'); + const rect = canvas.getBoundingClientRect(); + const duration = libAddState.duration || libAddState.buffer?.duration || 0; + const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0); + const end = clampLibAddTime(parseFloat($('lib-add-end').value) || duration); + const sx = duration && rect.width ? start / duration * rect.width : 0; + const ex = duration && rect.width ? end / duration * rect.width : rect.width; + const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + return {x, sx, ex, start, end, duration}; +} +function libAddWaveDragMode(e) { + const {x, sx, ex} = libAddWaveSelectionPixels(e); + const hit = 16; + if (Math.abs(x - sx) <= hit) return 'start'; + if (Math.abs(x - ex) <= hit) return 'end'; + return 'new'; +} +function attachLibAddWaveSelection() { + const canvas = $('lib-add-wave'); + if (!canvas || canvas.dataset.cropReady) return; + canvas.dataset.cropReady = '1'; + let drag = null; + canvas.addEventListener('pointerdown', e => { + if (!libAddState.buffer) return; + e.preventDefault(); + const mode = libAddWaveDragMode(e); + const t = libAddWaveTimeFromEvent(e); + const currentStart = parseFloat($('lib-add-start').value) || 0; + const currentEnd = parseFloat($('lib-add-end').value) || libAddState.duration || 0; + drag = {mode, anchor: t, start: currentStart, end: currentEnd}; + canvas.setPointerCapture?.(e.pointerId); + if (mode === 'start') setLibAddCropRange(t, currentEnd); + else if (mode === 'end') setLibAddCropRange(currentStart, t); + else setLibAddCropRange(t, t); + setLibAddStatus(mode === 'start' ? 'Dragging crop start handle' : mode === 'end' ? 'Dragging crop end handle' : 'Drag to choose a new crop range'); + }); + canvas.addEventListener('pointermove', e => { + if (!libAddState.buffer) return; + if (!drag) { + const mode = libAddWaveDragMode(e); + canvas.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : 'crosshair'; + return; + } + e.preventDefault(); + const t = libAddWaveTimeFromEvent(e); + if (drag.mode === 'start') setLibAddCropRange(t, drag.end); + else if (drag.mode === 'end') setLibAddCropRange(drag.start, t); + else setLibAddCropRange(drag.anchor, t); + }); + const finish = e => { + if (!drag) return; + e.preventDefault(); + const t = libAddWaveTimeFromEvent(e); + if (drag.mode === 'start') setLibAddCropRange(t, drag.end); + else if (drag.mode === 'end') setLibAddCropRange(drag.start, t); + else setLibAddCropRange(drag.anchor, t); + drag = null; + const start = parseFloat($('lib-add-start').value) || 0; + const end = parseFloat($('lib-add-end').value) || 0; + setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0, end - start).toFixed(1)}s) selected`); + }; + canvas.addEventListener('pointerup', finish); + canvas.addEventListener('pointerleave', () => { if (!drag) canvas.style.cursor = 'crosshair'; }); + canvas.addEventListener('pointercancel', () => { drag = null; canvas.style.cursor = 'crosshair'; }); +} +async function uploadLibAddFile(file) { + if (!file) return; + const fd = new FormData(); + fd.append('file', file); + setLibAddStatus('Uploading audio…'); + try { + const r = await fetch('/api/upload', {method:'POST', body:fd}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + suggestLibVoiceId(file.name); + loadLibAddAudio(d.id, d.duration, file.name || 'Audio'); + toast('Audio loaded', 'success'); + } catch(e) { toast('Load failed: ' + e.message, 'error'); setLibAddStatus('Load failed'); } +} + +const libAddDrop = $('lib-add-drop'); +libAddDrop.addEventListener('click', () => $('lib-add-file').click()); +libAddDrop.addEventListener('dragover', e => { e.preventDefault(); libAddDrop.classList.add('drag-over'); }); +libAddDrop.addEventListener('dragleave', () => libAddDrop.classList.remove('drag-over')); +libAddDrop.addEventListener('drop', e => { + e.preventDefault(); + libAddDrop.classList.remove('drag-over'); + if (e.dataTransfer.files.length) uploadLibAddFile(e.dataTransfer.files[0]); +}); +$('lib-add-file').addEventListener('change', async () => { + if ($('lib-add-file').files.length) await uploadLibAddFile($('lib-add-file').files[0]); + $('lib-add-file').value = ''; +}); + +$('lib-add-url-btn').addEventListener('click', () => { + const url = $('lib-add-url').value.trim(); + if (!url) { toast('Enter a YouTube or audio URL', 'error'); return; } + $('lib-add-url-btn').disabled = true; + setLibAddStatus('Starting download…'); + const es = new EventSource('/api/download-yt?url=' + encodeURIComponent(url)); + es.onmessage = e => { + const d = JSON.parse(e.data); + if (d.error) { + toast('Download failed: ' + d.error, 'error'); + setLibAddStatus(d.error); + $('lib-add-url-btn').disabled = false; + es.close(); + } else if (d.done) { + es.close(); + $('lib-add-url-btn').disabled = false; + suggestLibVoiceId(url.split('/').pop() || 'DownloadedVoice'); + loadLibAddAudio(d.id, d.duration, 'Downloaded audio'); + toast('URL audio loaded', 'success'); + } else { + setLibAddStatus(d.msg || 'Downloading…'); + } + }; + es.onerror = () => { + es.close(); + $('lib-add-url-btn').disabled = false; + setLibAddStatus('Download connection closed'); + }; +}); + +$('lib-add-rec-start').addEventListener('click', async () => { + try { + await ensureLibAddMicMonitor(); + libAddState.chunks = []; + libAddState.secs = 0; + $('lib-add-rec-time').textContent = '0:00'; + $('lib-add-rec-start').disabled = true; + $('lib-add-rec-stop').disabled = false; + $('lib-add-monitor-stop').disabled = true; + libAddState.timer = setInterval(() => { + libAddState.secs++; + $('lib-add-rec-time').textContent = Math.floor(libAddState.secs / 60) + ':' + String(libAddState.secs % 60).padStart(2, '0'); + }, 1000); + libAddState.recorder = new MediaRecorder(libAddState.recordStream); + libAddState.recorder.ondataavailable = e => { if (e.data.size) libAddState.chunks.push(e.data); }; + libAddState.recorder.onstop = async () => { + clearInterval(libAddState.timer); + $('lib-add-rec-start').disabled = false; + $('lib-add-rec-stop').disabled = true; + const blob = new Blob(libAddState.chunks, {type:libAddState.recorder.mimeType || 'audio/webm'}); + const ext = (libAddState.recorder.mimeType || '').includes('ogg') ? '.ogg' : '.webm'; + stopLibAddMic(); + suggestLibVoiceId('recording'); + await uploadLibAddFile(new File([blob], 'recording' + ext, {type:blob.type})); + }; + libAddState.recorder.start(100); + setLibAddStatus('Recording…'); + } catch(e) { + stopLibAddMic(); + $('lib-add-mic-help').classList.add('open'); + const message = await microphoneErrorMessage(e); + toast(message, 'error'); + setLibAddStatus(message); + $('lib-add-rec-start').disabled = false; + $('lib-add-rec-stop').disabled = true; + } +}); +$('lib-add-rec-stop').addEventListener('click', () => { + if (libAddState.recorder && libAddState.recorder.state !== 'inactive') libAddState.recorder.stop(); +}); +$('lib-add-auto-trim').addEventListener('click', async () => { + if (!libAddState.id) { toast('Load audio first', 'error'); return; } + $('lib-add-auto-trim').disabled = true; + try { + const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id})}); + let d; + if (r.ok) d = await r.json(); + else if (r.status === 404 || r.status === 405) d = await clientAutoTrimBounds(libAddState.id); + else { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + $('lib-add-start').value = Number(d.start).toFixed(2); + $('lib-add-end').value = Number(d.end).toFixed(2); + drawLibAddWave(); + setLibAddStatus(d.reason || 'Auto trim ready'); + } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); setLibAddStatus('Auto trim failed'); } + finally { $('lib-add-auto-trim').disabled = false; } +}); +async function transcribeLibAddCurrent(successMessage = 'Text recognised', audioId = libAddState.id) { + if (!audioId) throw new Error('Load audio first'); + setLibAddStatus('Recognising text...'); + const r = await fetch('/api/transcribe', { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id:audioId}) + }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + const text = d.text || ''; + $('lib-add-transcript').value = text; + setLibAddStatus(successMessage); + return text; +} +function openSavedLibraryVoice(voiceId) { + const openRow = () => { + const row = Array.from(document.querySelectorAll('.vl-row')).find(r => r.dataset.id === voiceId); + if (!row) return false; + row.scrollIntoView({behavior:'smooth', block:'center'}); + if (!row.classList.contains('edit-open')) row.querySelector('.edit-audio-btn')?.click(); + return true; + }; + if (!openRow()) setTimeout(openRow, 150); +} +async function applyLibAddCrop() { + if (!libAddState.id) { toast('Load audio first', 'error'); return; } + const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0); + const end = clampLibAddTime(parseFloat($('lib-add-end').value) || libAddState.duration); + const duration = end - start; + if (end <= start + 0.1) { toast('Crop range is too short', 'error'); setLibAddStatus('Crop range is too short'); return; } + if (duration < 3 || duration > 20) toast('Best clone references are 3-20 seconds; cropping anyway.', 'error'); + ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = true; }); + setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`); + try { + const r = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id, start, end})}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + loadLibAddAudio(d.id, d.duration, 'Cropped audio'); + toast('Crop applied', 'success'); + try { + await transcribeLibAddCurrent('Cropped audio loaded and text recognised', d.id); + } catch (e) { + toast('Crop applied, but recognition failed: ' + e.message, 'error'); + setLibAddStatus('Cropped audio loaded; recognition failed'); + } + } catch(e) { toast('Crop failed: ' + e.message, 'error'); setLibAddStatus('Crop failed'); } + finally { ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = false; }); } +} +$('lib-add-save-crop').addEventListener('click', applyLibAddCrop); +$('lib-add-save-crop-bottom').addEventListener('click', applyLibAddCrop); +['lib-add-start','lib-add-end'].forEach(id => $(id).addEventListener('input', drawLibAddWave)); +$('lib-add-play').addEventListener('click', () => { + if (!libAddState.id) return; + if (libAddState.audio) libAddState.audio.pause(); + libAddState.audio = new Audio('/api/audio/' + libAddState.id); + const start = parseFloat($('lib-add-start').value) || 0; + const end = parseFloat($('lib-add-end').value) || libAddState.duration; + libAddState.audio.currentTime = start; + libAddState.audio.ontimeupdate = () => { if (libAddState.audio.currentTime >= end) libAddState.audio.pause(); }; + libAddState.audio.play(); +}); +$('lib-add-recognize').addEventListener('click', async () => { + if (!libAddState.id) { toast('Load audio first', 'error'); return; } + try { + await transcribeLibAddCurrent('Text recognised'); + } catch(e) { toast('Recognition failed: ' + e.message, 'error'); setLibAddStatus('Recognition failed'); } +}); +$('lib-add-save').addEventListener('click', async () => { + if (!libAddState.id) { toast('Load audio first', 'error'); return; } + const voiceId = $('lib-add-voice-id').value.trim() || `${$('lib-add-lang').value}_${$('lib-add-gender').value}_NewVoice`; + if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters', 'error'); return; } + setLibAddStatus('Saving voice...'); + $('lib-add-save').disabled = true; + try { + const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id:libAddState.id, start:parseFloat($('lib-add-start').value)||0, end:parseFloat($('lib-add-end').value)||libAddState.duration})}); + if (!pr.ok) { const e = await pr.json().catch(()=>({})); throw new Error(e.detail || pr.statusText); } + const p = await pr.json(); + let transcript = $('lib-add-transcript').value.trim(); + if (!transcript) { + transcript = await transcribeLibAddCurrent('Final clip recognised; saving voice...', p.id); + if (!transcript.trim()) throw new Error('Recognition returned no transcript; add text or try recognising again.'); + } + const sr = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id:p.id, voice_id:voiceId, transcript})}); + if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); } + if (libAddState.pendingSource?.imageUrl) { + try { + await fetch('/api/voice/picture-url', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl}) + }); + } catch (_) {} + } + libAddState.pendingSource = null; + setLibAddSourcePreview({}); + $('lib-add-panel')?.classList.remove('open'); + toast('Voice saved: ' + voiceId, 'success'); + setLibAddStatus('Voice saved'); + await loadVoiceLibrary(); + openSavedLibraryVoice(voiceId); + } catch(e) { toast('Save failed: ' + e.message, 'error'); setLibAddStatus('Save failed'); } + finally { $('lib-add-save').disabled = false; } +}); + +$('show-disabled-cb').addEventListener('change', () => { + $('disabled-info').style.display = $('show-disabled-cb').checked ? '' : 'none'; + renderVoiceList(); +}); +['library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { + $(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); }); +}); +$('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180)); +$('library-clear-filters')?.addEventListener('click', clearLibraryFilters); +$('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); }); + +function renderVoiceList() { + const showDisabled = $('show-disabled-cb').checked; + const list = $('voice-list'); + list.innerHTML = ''; + + populateLibraryFilters(); + readLibraryFilters(); + let filtered = _voices.filter(v => showDisabled || v.enabled !== false); + const visibleCount = filtered.length; + filtered = filtered.filter(libraryFilterMatch); + const filterCount = filtered.length; + if (_libraryIssueFilter) filtered = filtered.filter(v => libraryIssueMatch(v)); + $('voice-count').textContent = filtered.length + ' / ' + _voices.length + ' voices'; + + filtered = filtered.slice().sort((a, b) => { + const av = getSortValue(a, _sortField), bv = getSortValue(b, _sortField); + if (av < bv) return -_sortDir; + if (av > bv) return _sortDir; + return 0; + }); + + updateLibraryInsights(); + if (_libraryIssueFilter) { + const note = document.createElement('div'); + note.className = 'library-filter-note'; + note.innerHTML = `${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`; + note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter('')); + list.appendChild(note); + } + if (!filtered.length) { + list.innerHTML += '
No voices found.
'; + return; + } + filtered.forEach(v => list.appendChild(makeVoiceRow(v))); + syncSortHeaders(); +} + +async function decodeVoiceAudio(v) { + const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); + if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); + const data = await resp.arrayBuffer(); + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + return ctx.decodeAudioData(data.slice(0)); +} + +function drawOptimizerWave(canvas, buffer, start = 0, end = buffer.duration) { + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, canvas.clientWidth); + const height = Math.max(1, canvas.clientHeight); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg') || '#111'; + ctx.fillRect(0, 0, width, height); + + const data = buffer.getChannelData(0); + const step = Math.max(1, Math.floor(data.length / width)); + const mid = height / 2; + ctx.strokeStyle = '#89b4fa'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < width; x++) { + let min = 1, max = -1; + const base = x * step; + for (let i = 0; i < step && base + i < data.length; i++) { + const v = data[base + i]; + if (v < min) min = v; + if (v > max) max = v; + } + ctx.moveTo(x, mid + min * mid * .92); + ctx.lineTo(x, mid + max * mid * .92); + } + ctx.stroke(); + + const sx = Math.max(0, Math.min(width, start / buffer.duration * width)); + const ex = Math.max(sx, Math.min(width, end / buffer.duration * width)); + ctx.fillStyle = 'rgba(166,227,161,.18)'; + ctx.fillRect(sx, 0, ex - sx, height); + ctx.strokeStyle = '#a6e3a1'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + .5, .5, Math.max(1, ex - sx - 1), height - 1); + + const selectedSec = Math.max(0, end - start); + if (ex - sx > 54 && selectedSec > 0) { + const label = `${selectedSec.toFixed(1)}s`; + ctx.font = '12px ui-monospace, Menlo, Consolas, monospace'; + const textW = ctx.measureText(label).width + 14; + const tx = Math.max(sx + 6, Math.min(ex - textW - 6, sx + (ex - sx - textW) / 2)); + ctx.fillStyle = 'rgba(30,30,46,.78)'; + ctx.fillRect(tx, 6, textW, 22); + ctx.fillStyle = '#ffffff'; + ctx.fillText(label, tx + 7, 21); + } + + const handleW = 12; + ctx.fillStyle = '#3d5ce8'; + ctx.strokeStyle = '#ffffff'; + [sx, ex].forEach((x, idx) => { + const hx = Math.max(0, Math.min(width - handleW, x - handleW / 2)); + ctx.fillRect(hx, 0, handleW, height); + ctx.strokeRect(hx + .5, .5, Math.max(1, handleW - 1), height - 1); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(hx + 3, Math.max(12, height / 2 - 11), 2, 22); + ctx.fillRect(hx + 7, Math.max(12, height / 2 - 11), 2, 22); + ctx.fillStyle = '#3d5ce8'; + }); +} + +function makeVoiceRow(v) { + const wrap = document.createElement('div'); + wrap.className = 'vl-row' + (v.enabled===false ? ' vr-disabled' : ''); + wrap.dataset.id = v.id; + + const langCode = v.lang || v.id.split('_')[0].toUpperCase(); + const langOpts = FLAG_OPTIONS[langCode] || []; + // Use language-specific variants if there are multiple; fall back to world picker otherwise + const flagOpts = langOpts.length > 1 ? langOpts : ALL_FLAGS; + const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || ''; + const flagEmoji = currentFlag ? cc2flag(currentFlag) : '🌐'; + const flagCode = currentFlag ? ccDisplay(currentFlag) : '?'; + const fileType = voiceFileType(v); + const dbfs = fmtDbfs(v); + const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; + const benchText = fmtBenchmark(v); + const benchTitle = benchmarkTitle(v); + const benchCls = benchmarkClass(v); + + const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'}; + const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'}; + const gender = v.gender || ''; + + const starsHtml = [1,2,3,4,5].map(i => + `` + ).join(''); + + const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null; + + const pickerHtml = flagOpts.length > 1 + ? flagOpts.map(([cc, label]) => + `${cc2flag(cc)}${ccDisplay(cc)}` + ).join('') + : `No regional variants`; + + wrap.innerHTML = ` +
+
+ ${picSrc ? `` : '
👤
'} + +
+ +
+
+ ${flagEmoji} + ${flagCode} +
${pickerHtml}
+
+ +
+ ${genderMap[gender]||'?'} +
+ +
+ ${escHtml(v.id)} + + + + + +
+
+ +
${escHtml(fileType.toUpperCase())}
+
${fmtDuration(v.duration)}
+ +
+ ${escHtml(dbfs)} + +
+ +
+ ${escHtml(benchText)} + +
+ +
${starsHtml}
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+ Delete? + ${escHtml(v.id)} + + +
+
+
+ +
+
+
+
1 Reference audio trim
+ +
+
+
+ + + + +
+
+
+
2 Reference text
+ +
+ + +
+
+
+
3 Voice match check
+
+
+ + +
+

Compare the saved reference WAV with a fresh synthesis of the same reference text. Restart TTS first after editing a voice, otherwise the backend may still use a cached version.

+
+
+ WAV file + +
+
+ Synthesized reference text + +
+
+
+
+
+
4 Style variation
+
+
+
+
+
+

Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; Base/Streaming are fastest but often ignore style.

+
+
+
+
+ + +
+ +
+
+
+
5 Volume and backend refresh
+
+
+ + + + + + + +
+
+
Click the pencil to load waveform and tools.
+
+
+ `; + hydrateVoiceDuration(v, wrap.querySelector('.vr-length')); + + // Photo upload + const photoCell = wrap.querySelector('.vr-photo'); + const photoInput = wrap.querySelector('.photo-input'); + photoCell.addEventListener('click', () => photoInput.click()); + photoInput.addEventListener('change', async () => { + if (!photoInput.files.length) return; + const fd = new FormData(); + fd.append('voice_id', v.id); + fd.append('file', photoInput.files[0]); + try { + const r = await fetch('/api/voice/picture', { method:'POST', body:fd }); + if (!r.ok) throw new Error((await r.json()).detail); + const img = document.createElement('img'); + img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`; + img.alt = ''; + photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput); + v.has_picture = true; toast('Photo uploaded','success'); + } catch(e) { toast('Photo upload failed: '+e.message,'error'); } + }); + + // Per-voice loudness normalization + const normalizeBtn = wrap.querySelector('.normalize-voice-btn'); + const dbValue = wrap.querySelector('.vr-db-value'); + const dbCell = wrap.querySelector('.vr-db'); + normalizeBtn.addEventListener('click', async () => { + const target = libraryTargetDb(); + if (!confirm(`Normalize "${v.id}" to ${target} dBFS?`)) return; + normalizeBtn.disabled = true; + status('Normalizing ' + v.id + '…'); + try { + const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})}); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + v.loudness = d.loudness || v.loudness; + v.duration = d.duration ?? v.duration; + v.file_type = d.file_type || v.file_type; + v.path = d.path || v.path; + v.needs_tts_restart = true; + markVoiceAudioChanged(v); + dbValue.textContent = fmtDbfs(v); + dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; + wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration); + toast('Normalized: ' + v.id, 'success'); + status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`); + } catch(e) { + toast('Normalize failed: ' + e.message, 'error'); + status('Normalize failed'); + } finally { + normalizeBtn.disabled = false; + } + }); + + // Language picker + const flagEmojiEl = wrap.querySelector('.flag-emoji'); + const flagCodeEl = wrap.querySelector('.flag-code'); + const flagPicker = wrap.querySelector('.flag-picker'); + const flagCell = wrap.querySelector('.vr-flag'); + + flagCell.addEventListener('click', e => { + e.stopPropagation(); + document.querySelectorAll('.flag-picker.open').forEach(fp => { if(fp!==flagPicker) fp.classList.remove('open'); }); + flagPicker.classList.toggle('open'); + }); + flagPicker.querySelectorAll('.flag-opt').forEach(opt => { + opt.addEventListener('click', async e => { + e.stopPropagation(); + const cc = opt.dataset.cc; + flagPicker.classList.remove('open'); + flagEmojiEl.textContent = cc2flag(cc); + flagCodeEl.textContent = ccDisplay(cc); + flagPicker.querySelectorAll('.flag-opt').forEach(o => o.classList.toggle('active', o.dataset.cc===cc)); + v.flag = cc; + await saveMeta(v.id, { flag: cc }); + }); + }); + + // Gender cycle F → M → N → F + const gBadge = wrap.querySelector('.gender-badge'); + gBadge.addEventListener('click', async () => { + const cycle = ['F','M','N']; + v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3]; + gBadge.textContent = genderMap[v.gender]; + gBadge.className = 'gender-badge ' + genderClass[v.gender]; + await saveMeta(v.id, { gender: v.gender }); + }); + + // Rename + const nameText = wrap.querySelector('.vr-name-text'); + const renameConf = wrap.querySelector('.rename-confirm'); + const nameInput = wrap.querySelector('.vr-name-input'); + const renameOk = wrap.querySelector('.rename-ok'); + const renameCancel= wrap.querySelector('.rename-cancel'); + + const startRename = () => { + nameText.style.display='none'; + renameConf.classList.add('show'); nameInput.focus(); nameInput.select(); + }; + nameText.addEventListener('dblclick', startRename); + const cancelRename = () => { + nameText.style.display=''; renameConf.classList.remove('show'); + nameInput.value = v.id; + }; + renameCancel.addEventListener('click', cancelRename); + const doRename = async () => { + const newId = nameInput.value.trim(); + if (!newId || newId===v.id) { cancelRename(); return; } + if (!/^[A-Za-z0-9_\-\.]+$/.test(newId)) { toast('Invalid characters in name','error'); return; } + try { + const r = await fetch('/api/voice/rename', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({old_id:v.id, new_id:newId})}); + if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } + const d = await r.json(); + v.id = newId; + if (d.path) v.path = d.path; + if (d.file_type) v.file_type = d.file_type; + nameText.textContent = newId; nameText.title = newId; + nameText.style.display=''; renameConf.classList.remove('show'); + wrap.dataset.id = newId; nameInput.value = newId; + toast('Renamed to '+newId,'success'); + } catch(e) { toast('Rename failed: '+e.message,'error'); } + }; + renameOk.addEventListener('click', doRename); + nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); }); + + // Inline optimizer in Library + const editAudioBtn = wrap.querySelector('.edit-audio-btn'); + const optPanel = wrap.querySelector('.vr-optimizer'); + const optCanvas = wrap.querySelector('.opt-wave'); + const optStart = wrap.querySelector('.opt-start'); + const optEnd = wrap.querySelector('.opt-end'); + const optTranscript = wrap.querySelector('.opt-transcript'); + const optTargetDb = wrap.querySelector('.opt-target-db'); + const optStyleInstruct = wrap.querySelector('.opt-style-instruct'); + const optStyleBackend = wrap.querySelector('.opt-style-backend'); + const optStyleVoiceId = wrap.querySelector('.opt-style-voice-id'); + const optCompareBackend = wrap.querySelector('.opt-compare-backend'); + const optPlayReferenceBtn = wrap.querySelector('.opt-play-reference'); + const optSynthReferenceBtn = wrap.querySelector('.opt-synth-reference'); + const optCompareRefAudio = wrap.querySelector('.opt-compare-ref-audio'); + const optCompareSynthAudio = wrap.querySelector('.opt-compare-synth-audio'); + const optPreviewStyleBtn = wrap.querySelector('.opt-preview-style'); + const optSaveStyleBtn = wrap.querySelector('.opt-save-style'); + const optStyleAudio = wrap.querySelector('.opt-style-audio'); + const optStatus = wrap.querySelector('.opt-status'); + const optSaveTextBtn = wrap.querySelector('.opt-save-text'); + const optRestartTtsBtn = wrap.querySelector('.opt-restart-tts'); + const optRebenchmarkBtn = wrap.querySelector('.opt-rebenchmark'); + const optRestartNote = wrap.querySelector('.opt-restart-note'); + let optState = { loaded:false, id:null, duration:0, buffer:null, audio:null, compareSynthUrl:null }; + const setOptStatus = msg => { optStatus.textContent = msg; status(msg); }; + const setVoiceRestartState = (required, msg = '') => { + v.needs_tts_restart = required; + optPanel.classList.toggle('opt-restart-needed', required); + optRestartNote.hidden = !required; + optRestartNote.textContent = required ? 'Restart TTS before benchmarking; the backend may still have the old voice cached.' : ''; + optRebenchmarkBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice'; + benchmarkOneBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice'; + if (msg) setOptStatus(msg); + }; + const markTtsRestartRequired = msg => setVoiceRestartState(true, msg); + const refreshOptimizerFromVoice = async () => { + markVoiceAudioChanged(v); + optState.loaded = false; + optState.buffer = null; + optState.id = null; + await loadOptimizer(); + wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration); + wrap.querySelector('.vr-length').title = String(v.duration ?? ''); + dbValue.textContent = fmtDbfs(v); + dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; + }; + + const saveOptimizerText = async () => { + const transcript = optTranscript.value.trim(); + setOptStatus('Saving reference text...'); + v.transcript = transcript; + refInput.value = transcript; + refInput.title = transcript; + refTranscribeBtn.style.display = transcript ? 'none' : ''; + await saveMeta(v.id, { transcript }); + markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.'); + toast('Reference text saved: ' + v.id, 'success'); + }; + + const redrawOpt = () => { + if (optState.buffer) drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration); + }; + const syncCompareReferenceAudio = () => { + if (!optState.id || !optCompareRefAudio) return; + const src = '/api/audio/' + optState.id; + if (!optCompareRefAudio.src.endsWith(src)) { + optCompareRefAudio.src = src; + optCompareRefAudio.load(); + } + const stopAtEnd = () => { + const end = parseFloat(optEnd.value) || optState.duration; + if (optCompareRefAudio.currentTime >= end) optCompareRefAudio.pause(); + }; + optCompareRefAudio.ontimeupdate = stopAtEnd; + }; + const optWaveTimeFromEvent = e => { + const rect = optCanvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + return optState.duration ? x / Math.max(1, rect.width) * optState.duration : 0; + }; + const setOptCropRange = (start, end) => { + start = Math.max(0, Math.min(optState.duration || 0, Number(start) || 0)); + end = Math.max(0, Math.min(optState.duration || 0, Number(end) || 0)); + if (end < start) [start, end] = [end, start]; + optStart.value = start.toFixed(2); + optEnd.value = end.toFixed(2); + redrawOpt(); + }; + const optWaveSelectionPixels = e => { + const rect = optCanvas.getBoundingClientRect(); + const duration = Math.max(0.01, optState.duration || 0.01); + const sx = (parseFloat(optStart.value) || 0) / duration * rect.width; + const ex = (parseFloat(optEnd.value) || optState.duration || 0) / duration * rect.width; + const x = e.clientX - rect.left; + return {x, sx, ex}; + }; + const optWaveDragMode = e => { + const {x, sx, ex} = optWaveSelectionPixels(e); + const hit = 18; + const nearStart = Math.abs(x - sx) <= hit; + const nearEnd = Math.abs(x - ex) <= hit; + if (nearStart && nearEnd) return Math.abs(x - sx) <= Math.abs(x - ex) ? 'start' : 'end'; + if (nearStart) return 'start'; + if (nearEnd) return 'end'; + if (x > sx && x < ex) return 'move'; + return 'new'; + }; + const attachOptWaveSelection = () => { + let drag = null; + optCanvas.addEventListener('pointerdown', e => { + if (!optState.buffer || !optState.duration) return; + e.preventDefault(); + optCanvas.setPointerCapture?.(e.pointerId); + const mode = optWaveDragMode(e); + const currentStart = parseFloat(optStart.value) || 0; + const currentEnd = parseFloat(optEnd.value) || optState.duration; + drag = {mode, anchor: optWaveTimeFromEvent(e), start: currentStart, end: currentEnd, length: Math.max(0.05, currentEnd - currentStart)}; + optCanvas.style.cursor = mode === 'move' ? 'grabbing' : 'ew-resize'; + if (mode === 'new') setOptCropRange(drag.anchor, drag.anchor); + }); + optCanvas.addEventListener('pointermove', e => { + if (!optState.buffer || !optState.duration) return; + if (!drag) { + const mode = optWaveDragMode(e); + optCanvas.style.cursor = mode === 'move' ? 'grab' : (mode === 'start' || mode === 'end') ? 'ew-resize' : 'crosshair'; + return; + } + e.preventDefault(); + const t = optWaveTimeFromEvent(e); + if (drag.mode === 'start') setOptCropRange(Math.min(t, drag.end - 0.05), drag.end); + else if (drag.mode === 'end') setOptCropRange(drag.start, Math.max(t, drag.start + 0.05)); + else if (drag.mode === 'move') { + let start = t - (drag.anchor - drag.start); + start = Math.max(0, Math.min((optState.duration || 0) - drag.length, start)); + setOptCropRange(start, start + drag.length); + } else setOptCropRange(drag.anchor, t); + }); + const finish = e => { + if (!drag) return; + optCanvas.releasePointerCapture?.(e.pointerId); + drag = null; + optCanvas.style.cursor = 'crosshair'; + }; + optCanvas.addEventListener('pointerup', finish); + optCanvas.addEventListener('pointercancel', finish); + optCanvas.addEventListener('pointerleave', () => { if (!drag) optCanvas.style.cursor = 'crosshair'; }); + }; + attachOptWaveSelection(); + const loadOptimizer = async () => { + if (optState.loaded) return; + setOptStatus('Loading voice optimizer…'); + const d = await loadLibraryVoiceAudio(v); + optState.id = d.id; + optState.duration = d.duration; + v.duration = d.duration; + optState.buffer = await decodeVoiceAudio(v); + optState.loaded = true; + optStart.value = '0.00'; + optEnd.value = d.duration.toFixed(2); + optEnd.max = d.duration.toFixed(2); + optTranscript.value = d.transcript || v.transcript || ''; + redrawOpt(); + syncCompareReferenceAudio(); + setVoiceRestartState(Boolean(v.needs_tts_restart)); + setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready'); + }; + + editAudioBtn.addEventListener('click', async () => { + editAudioBtn.disabled = true; + try { + const opening = !wrap.classList.contains('edit-open'); + document.querySelectorAll('.vl-row.edit-open').forEach(r => { if (r !== wrap) r.classList.remove('edit-open'); }); + wrap.classList.toggle('edit-open', opening); + if (opening) { + await loadOptimizer(); + wrap.scrollIntoView({behavior:'smooth', block:'nearest'}); + } + } catch(e) { + toast('Edit load failed: '+e.message,'error'); + status('Edit load failed'); + } finally { + editAudioBtn.disabled = false; + } + }); + [optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); })); + optStyleInstruct.addEventListener('input', () => { + if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value); + }); + optStyleBackend.addEventListener('change', () => updateStyleBackendHelp(wrap)); + optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`)); + if (optCompareBackend.value === '') { + optCompareBackend.innerHTML = styleBackendOptions('voice_clone'); + optCompareBackend.disabled = !availableTtsBackends().length; + } + updateStyleBackendHelp(wrap); + wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); }); + wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); }); + wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; }); + wrap.querySelector('.opt-play').addEventListener('click', async () => { + try { + await loadOptimizer(); + if (optState.audio) optState.audio.pause(); + optState.audio = new Audio('/api/audio/' + optState.id); + optState.audio.currentTime = parseFloat(optStart.value) || 0; + const end = parseFloat(optEnd.value) || optState.duration; + optState.audio.ontimeupdate = () => { if (optState.audio.currentTime >= end) optState.audio.pause(); }; + optState.audio.play(); + } catch(e) { toast('Preview failed: ' + e.message, 'error'); } + }); + optPlayReferenceBtn.addEventListener('click', async () => { + try { + await loadOptimizer(); + syncCompareReferenceAudio(); + optCompareRefAudio.currentTime = parseFloat(optStart.value) || 0; + await optCompareRefAudio.play().catch(()=>{}); + setOptStatus('Playing reference WAV selection for comparison.'); + } catch(e) { toast('Reference playback failed: ' + e.message, 'error'); } + }); + optSynthReferenceBtn.addEventListener('click', async () => { + const text = optTranscript.value.trim(); + if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return; } + if (v.needs_tts_restart) { + const ok = confirm('This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?'); + if (!ok) { + setOptStatus('Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.'); + return; + } + try { + const d = await clearTtsRestartFlags(); + setVoiceRestartState(false, `Restart flags cleared (${d.cleared_restart_flags || 0}). Synthesizing comparison...`); + toast('Restart flags cleared', 'success'); + } catch(e) { + toast('Could not clear restart flags: ' + e.message, 'error'); + setOptStatus('Could not clear restart flags'); + return; + } + } + optSynthReferenceBtn.disabled = true; + try { + await loadOptimizer(); + setOptStatus('Synthesizing reference text for comparison...'); + const source = await createTtsAudioSource(v.id, text, optCompareBackend.value, 'settings', ''); + if (optState.compareSynthUrl) URL.revokeObjectURL(optState.compareSynthUrl); + optCompareSynthAudio.src = source.url; + optState.compareSynthUrl = source.streaming ? null : source.url; + await optCompareSynthAudio.play().catch(()=>{}); + setOptStatus(source.streaming ? 'Streaming synthesized comparison.' : 'Synthesized comparison ready.'); + } catch(e) { + toast('Synthesis comparison failed: ' + e.message, 'error'); + setOptStatus('Synthesis comparison failed'); + } finally { + optSynthReferenceBtn.disabled = false; + } + }); + wrap.querySelector('.opt-auto-trim').addEventListener('click', async () => { + try { + await loadOptimizer(); + const d = await clientAutoTrimBounds(optState.id); + optStart.value = Number(d.start).toFixed(2); + optEnd.value = Number(d.end).toFixed(2); + redrawOpt(); + setOptStatus(d.reason || 'Auto trim ready'); + } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); } + }); + wrap.querySelector('.opt-recognize').addEventListener('click', async () => { + try { + await loadOptimizer(); + const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:optState.id})}); + if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + optTranscript.value = d.text || ''; + setOptStatus('Reference text recognised. Review it, then Save text.'); + } catch(e) { toast('Recognition failed: ' + e.message, 'error'); } + }); + optSaveTextBtn.addEventListener('click', async () => { + optSaveTextBtn.disabled = true; + try { + await saveOptimizerText(); + } catch(e) { + toast('Save text failed: ' + e.message, 'error'); + setOptStatus('Save text failed'); + } finally { + optSaveTextBtn.disabled = false; + } + }); + wrap.querySelector('.opt-save-crop').addEventListener('click', async () => { + try { + await loadOptimizer(); + const cropStart = Math.max(0, parseFloat(optStart.value) || 0); + const cropEnd = Math.min(optState.duration, parseFloat(optEnd.value) || optState.duration); + if (cropStart <= 0.01 && cropEnd >= optState.duration - 0.05) { + setOptStatus('No crop range selected. Adjust Start or End first, then Save crop.'); + toast('No crop range selected', 'error'); + return; + } + if (cropEnd <= cropStart + 0.1) { + setOptStatus('Crop range is too short.'); + toast('Crop range is too short', 'error'); + return; + } + setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`); + const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id:optState.id, start:cropStart, end:cropEnd})}); + if (!pr.ok) { const e = await pr.json(); throw new Error(e.detail || pr.statusText); } + const p = await pr.json(); + const rr = await fetch('/api/voice-replace', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id:p.id, voice_id:v.id, path:v.path, transcript:optTranscript.value})}); + if (!rr.ok) { const e = await rr.json().catch(()=>({})); throw new Error(e.detail || rr.statusText); } + const saved = await rr.json(); + v.transcript = optTranscript.value; + v.duration = saved.duration ?? p.duration; + if (saved.loudness) v.loudness = saved.loudness; + if (saved.path) v.path = saved.path; + if (saved.file_type) v.file_type = saved.file_type; + markVoiceAudioChanged(v); + refInput.value = v.transcript; refInput.title = v.transcript; + refTranscribeBtn.style.display = v.transcript ? 'none' : ''; + wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase(); + wrap.querySelector('.vr-type').title = voiceFileType(v); + await refreshOptimizerFromVoice(); + toast('Voice crop saved: ' + v.id, 'success'); + markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.'); + } catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); } + }); + const styleVariationInput = () => { + const style = optStyleInstruct.value.trim(); + const text = optTranscript.value.trim() || getBenchmarkSampleText(); + const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style); + if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; } + if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; } + if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; } + return {style, text, newId, backend: optStyleBackend.value}; + }; + + optPreviewStyleBtn.addEventListener('click', async () => { + const input = styleVariationInput(); + if (!input) return; + optPreviewStyleBtn.disabled = true; + try { + setOptStatus('Synthesizing style preview...'); + const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend); + if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src); + optStyleAudio.src = URL.createObjectURL(blob); + optStyleAudio.style.display = ''; + await optStyleAudio.play().catch(()=>{}); + setOptStatus('Style preview ready. If it sounds right, save it as a new voice.'); + } catch(e) { + toast('Style preview failed: ' + e.message, 'error'); + setOptStatus('Style preview failed'); + } finally { + optPreviewStyleBtn.disabled = false; + } + }); + + optSaveStyleBtn.addEventListener('click', async () => { + const input = styleVariationInput(); + if (!input) return; + optSaveStyleBtn.disabled = true; + try { + setOptStatus(`Synthesizing style variation ${input.newId}...`); + const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + toast('Style variation saved: ' + d.voice_id, 'success'); + setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.'); + await loadVoiceLibrary(); + renderIntegrationSnippets(); + } catch(e) { + toast('Style variation failed: ' + e.message, 'error'); + setOptStatus('Style variation failed'); + } finally { + optSaveStyleBtn.disabled = false; + } + }); + + wrap.querySelector('.opt-undo').addEventListener('click', async () => { + if (!confirm(`Restore the original backup for "${v.id}"?`)) return; + try { + setOptStatus('Restoring original…'); + const r = await fetch('/api/voice/undo', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({voice_id:v.id, path:v.path})}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + v.duration = d.duration ?? v.duration; + v.loudness = d.loudness || v.loudness; + v.path = d.path || v.path; + v.file_type = d.file_type || v.file_type; + markVoiceAudioChanged(v); + wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase(); + wrap.querySelector('.vr-type').title = voiceFileType(v); + await refreshOptimizerFromVoice(); + toast('Original restored: ' + v.id, 'success'); + markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.'); + } catch(e) { toast('Undo failed: ' + e.message, 'error'); setOptStatus('Undo failed'); } + }); + wrap.querySelector('.opt-save-volume').addEventListener('click', async () => { + try { + setOptStatus('Saving volume…'); + const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:Number(optTargetDb.value || -20)})}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + v.loudness = d.loudness || v.loudness; + v.duration = d.duration ?? v.duration; + if (d.path) v.path = d.path; + if (d.file_type) v.file_type = d.file_type; + markVoiceAudioChanged(v); + dbValue.textContent = fmtDbfs(v); + dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; + await refreshOptimizerFromVoice(); + toast('Volume saved: ' + v.id, 'success'); + markTtsRestartRequired('Volume saved. Restart TTS before rebenchmarking this voice.'); + } catch(e) { toast('Volume save failed: ' + e.message, 'error'); setOptStatus('Volume save failed'); } + }); + optRestartTtsBtn.addEventListener('click', async () => { + optRestartTtsBtn.disabled = true; + try { + setOptStatus('Restarting TTS so edited voices are rescanned...'); + const r = await fetch('/api/tts/restart', { method:'POST' }); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + _voices.forEach(voice => { voice.needs_tts_restart = false; }); + updateLibraryInsights(); + setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`); + toast('TTS restarted. Voices rescanned.', 'success'); + } catch(e) { + toast('Restart TTS failed: ' + e.message, 'error'); + setOptStatus('Restart TTS failed'); + } finally { + optRestartTtsBtn.disabled = false; + } + }); + + // Reference text input and recognition + const refInput = wrap.querySelector('.vr-ref input'); + const refTranscribeBtn = wrap.querySelector('.ref-transcribe-btn'); + refInput.addEventListener('input', debounce(async () => { + v.transcript = refInput.value; + refInput.title = v.transcript; + refTranscribeBtn.style.display = v.transcript ? 'none' : ''; + await saveMeta(v.id, { transcript: v.transcript }); + if (wrap.classList.contains('edit-open')) markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.'); + else v.needs_tts_restart = true; + }, 800)); + refTranscribeBtn.addEventListener('click', async () => { + refTranscribeBtn.disabled = true; + try { + const d = await loadLibraryVoiceAudio(v); + const tr = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})}); + if (!tr.ok) { const e=await tr.json(); throw new Error(e.detail); } + const text = await tr.json(); + v.transcript = text.text || ''; + refInput.value = v.transcript; + refInput.title = v.transcript; + refTranscribeBtn.style.display = v.transcript ? 'none' : ''; + await saveMeta(v.id, { transcript: v.transcript }); + v.needs_tts_restart = true; + toast('Reference text recognised; restart TTS before benchmarking','success'); + } catch(e) { + toast('Recognition failed: '+e.message,'error'); + } finally { refTranscribeBtn.disabled = false; } + }); + + // Note (debounced save) + const noteInput = wrap.querySelector('.vr-note input'); + noteInput.addEventListener('input', debounce(async () => { + v.note = noteInput.value; + await saveMeta(v.id, { note: v.note }); + }, 800)); + + // Stars + const starSpans = wrap.querySelectorAll('.star'); + starSpans.forEach(s => { + s.addEventListener('click', async () => { + const val = parseInt(s.dataset.val); + const newRating = val===v.rating ? 0 : val; + v.rating = newRating; + starSpans.forEach((ss,i) => ss.classList.toggle('on', i { + const val = parseInt(s.dataset.val); + starSpans.forEach((ss,i) => ss.classList.toggle('on', i { + starSpans.forEach((ss,i) => ss.classList.toggle('on', i<(v.rating||0))); + }); + }); + + // Per-voice benchmark + const benchmarkOneBtn = wrap.querySelector('.benchmark-one-btn'); + const benchmarkThisVoice = async triggerBtn => { + if (v.needs_tts_restart) { + const ok = confirm('This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?'); + if (!ok) { + setOptStatus('Restart TTS first, then rebenchmark this voice.'); + return; + } + } + triggerBtn.disabled = true; + status('Benchmarking ' + v.id + '...'); + try { + setBenchmarkProgress(0, 1, `Benchmarking ${v.id}`); + const d = await runVoiceBenchmark(v.id); + mergeBenchmarkResults(d); + const hit = (d.voices || []).find(x => x.voice_id === v.id); + if (hit && hit.benchmark) v.benchmark = hit.benchmark; + const benchCell = wrap.querySelector('.vr-bench'); + benchCell.className = 'vr-bench ' + benchmarkClass(v); + benchCell.title = benchmarkTitle(v); + benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v); + setBenchmarkProgress(1, 1, `Finished ${v.id}`); + toast('Benchmarked ' + v.id, 'success'); + setVoiceRestartState(false, 'Benchmark saved for ' + v.id); + } catch(e) { + toast('Benchmark failed: ' + e.message, 'error'); + setOptStatus('Benchmark failed'); + } finally { triggerBtn.disabled = false; } + }; + benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn)); + optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn)); + + // Play original recording or synthesized sample + const originalPlayBtn = wrap.querySelector('.vr-play-original button'); + const synthPlayBtn = wrap.querySelector('.vr-play-synth button'); + const playIcon = '▶', pauseIcon = '❙❙', generatingIcon = '⌛'; + function setLibraryPlayButtonState(btn, state) { + btn.classList.toggle('is-generating', state === 'generating'); + btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon); + btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording')); + } + async function playLibraryVoice(kind, playBtn) { + const bar = $('lib-audio-bar'), audio = $('lib-audio'); + const playKey = v.id + ':' + kind; + playBtn.dataset.playKind = kind; + + if (_activePlayVoiceId === playKey && !audio.paused) { + audio.pause(); + setLibraryPlayButtonState(playBtn, 'idle'); + return; + } + if (_activePlayVoiceId === playKey && audio.paused && audio.src) { + _activePlayButton = playBtn; + try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); } + return; + } + + if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle'); + _activePlayButton = playBtn; + _activePlayVoiceId = playKey; + if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; } + if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating'); + playBtn.disabled = true; + try { + if (kind === 'synth') { + if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error'); + const text = benchmarkSampleText(); + const backend = libraryTtsBackend(); + const source = await createTtsAudioSource(v.id, text, backend, 'settings', ''); + audio.src = source.url; + if (!source.streaming) _activePlayUrl = source.url; + $('lib-audio-label').textContent = v.id + ' · synthesized sample · ' + (backendById(backend)?.label || backend); + } else { + audio.src = voiceFileUrl(v); + $('lib-audio-label').textContent = v.id + ' · original recording'; + } + bar.style.display = ''; + audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; }; + audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); }; + audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); }; + await audio.play(); + } catch(e) { + setLibraryPlayButtonState(playBtn, 'idle'); + toast('Play failed: '+e.message,'error'); + } finally { + playBtn.disabled = false; + } + } + originalPlayBtn.dataset.playKind = 'original'; + synthPlayBtn.dataset.playKind = 'synth'; + setLibraryPlayButtonState(originalPlayBtn, 'idle'); + setLibraryPlayButtonState(synthPlayBtn, 'idle'); + originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn)); + synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn)); + + // Enable toggle + const toggleCb = wrap.querySelector('.toggle input'); + toggleCb.addEventListener('change', async () => { + const nextEnabled = toggleCb.checked; + const previousEnabled = v.enabled !== false; + toggleCb.disabled = true; + try { + const saved = await saveMeta(v.id, { enabled: nextEnabled }); + v.enabled = nextEnabled; + if (saved && saved.path) v.path = saved.path; + wrap.classList.toggle('vr-disabled', !v.enabled); + toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success'); + if (!v.enabled && !$('show-disabled-cb').checked) { + wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0'; + setTimeout(() => wrap.remove(), 400); + } + } catch(e) { + toggleCb.checked = previousEnabled; + v.enabled = previousEnabled; + wrap.classList.toggle('vr-disabled', !v.enabled); + toast('Move failed: ' + e.message, 'error'); + } finally { + toggleCb.disabled = false; + } + }); + + // Delete voice + const deleteBtn = wrap.querySelector('.delete-btn'); + const deleteConfirm = wrap.querySelector('.delete-confirm'); + const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel'); + const deleteGoBtn = wrap.querySelector('.delete-confirm-go'); + const closeDeleteConfirm = () => wrap.classList.remove('delete-pending'); + deleteBtn.addEventListener('click', e => { + e.stopPropagation(); + document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); }); + wrap.classList.add('delete-pending'); + deleteGoBtn.focus(); + }); + deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); }); + deleteConfirm.addEventListener('click', e => e.stopPropagation()); + deleteGoBtn.addEventListener('click', async e => { + e.stopPropagation(); + deleteGoBtn.disabled = true; + deleteCancelBtn.disabled = true; + try { + const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' }); + if (!r.ok) { const e = await r.json(); throw new Error(e.detail); } + _voices = _voices.filter(x => x.id !== v.id); + wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0'; + setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300); + toast(`Deleted: ${v.id}`, 'success'); + } catch(e) { + toast('Delete failed: ' + e.message, 'error'); + deleteGoBtn.disabled = false; + deleteCancelBtn.disabled = false; + closeDeleteConfirm(); + } + }); + + return wrap; +} + +async function saveMeta(voiceId, patch) { + const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ voice_id:voiceId, ...patch }) }); + if (!r.ok) { + const e = await r.json().catch(() => ({})); + throw new Error(e.detail || r.statusText); + } + return r.json(); +} + +// Close language pickers when clicking elsewhere +document.addEventListener('click', () => { + document.querySelectorAll('.flag-picker.open').forEach(fp => fp.classList.remove('open')); + document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending')); +}, { passive:true }); + +// ── TTS preview ─────────────────────────────────────────────────────────── + +function backendVoiceId(value) { + return typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || JSON.stringify(value)); +} + +function shouldFilterBackendVoices(backend) { + return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || ''); +} + +async function activeLibraryVoiceIds() { + if (!_voices.length) await loadVoiceLibrary(); + return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id)); +} + +function cleanReferenceText(text) { + return String(text || '').trim(); +} + +function selectedPreviewLibraryVoice() { + const id = $('tts-voice-select')?.value || ''; + return id ? (_voices || []).find(v => v.id === id) : null; +} + +function previewVoiceWarnings(v) { + const warnings = []; + const backend = backendById($('tts-backend-select')?.value || ''); + if (backend && backend.id && !['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend.id)) { + warnings.push(backend.id === 'nvidia_magpie' ? 'NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.' : 'This backend may follow style/model voice more than the saved WAV identity.'); + } + if (backend && backend.id === 'nvidia_zeroshot' && v.duration && (Number(v.duration) < 3 || Number(v.duration) > 10)) { + warnings.push('NVIDIA Zeroshot works best with a clear 3-10 second prompt.'); + } + if (backend && backend.id === 'nvidia_flow' && !v.transcript) { + warnings.push('NVIDIA Flow requires the exact saved reference transcript for this voice.'); + } + if (!v.transcript) warnings.push('No reference transcript is saved; cloned identity is harder to judge.'); + if (v.duration && (Number(v.duration) < 3 || Number(v.duration) > 20)) warnings.push('Reference clip length is outside the 3-20 second sweet spot.'); + if (v.needs_tts_restart) warnings.push('This voice changed since the last backend refresh; restart or clear restart flags before judging it.'); + const healthWarnings = v.health && Array.isArray(v.health.warnings) ? v.health.warnings : []; + warnings.push(...healthWarnings.slice(0, 3)); + return warnings; +} + +function updatePreviewVoiceMatchPanel() { + const panel = $('preview-match-panel'); + if (!panel) return; + const v = selectedPreviewLibraryVoice(); + if (!v) { panel.hidden = true; return; } + panel.hidden = false; + const lang = v.language || v.lang || (v.id || '').split('_')[0] || '-'; + const gender = v.gender || (v.id || '').split('_')[1] || '-'; + const db = fmtDbfs(v); + const dur = v.duration ? fmtDuration(v.duration) : '-'; + $('preview-match-title').textContent = v.id; + $('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`; + const warnings = previewVoiceWarnings(v); + $('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.'; + const transcript = cleanReferenceText(v.transcript || ''); + $('preview-match-transcript').textContent = transcript || 'No reference text saved for this voice.'; + $('preview-ref-use-text').disabled = !transcript; + $('preview-ref-synth').disabled = !transcript; + const audio = $('preview-ref-audio'); + const expected = voiceFileUrl(v); + if (audio.dataset.src !== expected) { + audio.pause(); + audio.src = expected; + audio.dataset.src = expected; + } +} + +async function synthesizeSelectedReferenceText() { + const v = selectedPreviewLibraryVoice(); + if (!v) { toast('Select a library voice first', 'error'); return; } + let text = cleanReferenceText(v.transcript || ''); + if (!text) { toast('This voice has no reference text', 'error'); return; } + if (v.needs_tts_restart) { + const ok = confirm('This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?'); + if (!ok) return; + await clearTtsRestartFlags(); + v.needs_tts_restart = false; + updatePreviewVoiceMatchPanel(); + } + const backend = $('tts-backend-select').value; + if (!backend) { toast('No available TTS backend', 'error'); return; } + const btn = $('preview-ref-synth'); + btn.disabled = true; + try { + $('preview-text-area').value = text; + const source = await createTtsAudioSource(v.id, text, backend, $('preview-playback-mode').value, $('preview-style-instruction').value.trim()); + previewBlob = source.blob; + const audio = $('preview-audio'); + audio.src = source.url; + audio.style.display = ''; + await audio.play(); + $('save-preview-mp3-btn').disabled = false; + $('save-preview-btn').disabled = source.streaming; + toast(source.streaming ? 'Reference text streaming' : 'Reference text synthesized', 'success'); + } catch(e) { toast('Reference synthesis failed: ' + e.message, 'error'); } + finally { btn.disabled = false; } +} + +$('fetch-tts-voices-btn').addEventListener('click', async () => { + $('fetch-tts-voices-btn').disabled = true; + try { + const backend = $('tts-backend-select')?.value; + if (!backend) throw new Error('No available TTS backend'); + const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); + let voices = Array.isArray(rawVoices) ? rawVoices : []; + if (shouldFilterBackendVoices(backend)) { + const activeIds = await activeLibraryVoiceIds(); + voices = voices.filter(v => activeIds.has(backendVoiceId(v))); + } + const sel = $('tts-voice-select'), prev = sel.value; + sel.innerHTML = ''; + voices.forEach(v => { + const id = backendVoiceId(v); + const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt); + }); + if(prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev; + updatePreviewVoiceMatchPanel(); + const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices'; + toast('Fetched '+voices.length+suffix,'success'); + } catch(e) { toast('Fetch failed: '+e.message,'error'); } + finally { $('fetch-tts-voices-btn').disabled = false; } +}); + +$('tts-backend-select').addEventListener('change', () => { + const sel = $('tts-voice-select'); + sel.innerHTML = ''; + updateBackendHelp(); + updatePreviewVoiceMatchPanel(); + previewBlob = null; + $('save-preview-mp3-btn').disabled = true; + $('save-preview-btn').disabled = true; +}); + +$('tts-voice-select').addEventListener('change', updatePreviewVoiceMatchPanel); +$('preview-ref-play').addEventListener('click', async () => { + updatePreviewVoiceMatchPanel(); + const audio = $('preview-ref-audio'); + try { await audio.play(); } + catch(e) { toast('Reference playback failed: ' + e.message, 'error'); } +}); +$('preview-ref-use-text').addEventListener('click', () => { + const v = selectedPreviewLibraryVoice(); + const text = cleanReferenceText(v?.transcript || ''); + if (!text) { toast('This voice has no reference text', 'error'); return; } + $('preview-text-area').value = text; + toast('Reference text copied to target text', 'success'); +}); +$('preview-ref-synth').addEventListener('click', synthesizeSelectedReferenceText); + +let _ttsStreamHealth = null; +function effectiveTtsPlaybackMode(override = 'settings') { + if (override && override !== 'settings') return override; + return _appSettings.tts_stream_mode || 'auto'; +} +async function isTtsStreamAvailable(force = false) { + if (_ttsStreamHealth && !force) return _ttsStreamHealth.ok; + try { + _ttsStreamHealth = await fetch('/api/tts-stream-health').then(r => r.json()); + return !!_ttsStreamHealth.ok; + } catch (_) { + _ttsStreamHealth = {ok:false}; + return false; + } +} +async function createTtsStreamUrl(voice, text, instruct = '') { + if (!await isTtsStreamAvailable()) throw new Error('streaming backend unavailable'); + const r = await fetch('/api/tts-stream-session', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({text,voice,instruct})}); + if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const data = await r.json(); + return data.url; +} +async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone') { + const r = await fetch('/api/tts-preview', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({text,voice,response_format:responseFormat,instruct,backend})}); + if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + return await r.blob(); +} +async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '') { + const mode = effectiveTtsPlaybackMode(modeOverride); + if (backend !== 'streaming' || mode === 'buffered') { + const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend); + return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'}; + } + try { + return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'}; + } catch (e) { + if (mode === 'streaming') throw e; + const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend); + return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'}; + } +} +let previewBlob = null; +const PREVIEW_SAMPLE_TEXT = 'Hello! This is a voice preview from TTS Voice Creator - Clone and Design.'; +$('preview-text-area').addEventListener('focus', () => { + if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = ''; +}, { once:true }); + +$('preview-btn').addEventListener('click', async () => { + const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim(); + if(!backend) { toast('No available TTS backend','error'); return; } + if(!voice) { toast('Select a TTS voice','error'); return; } + if(!text) { toast('Enter preview text','error'); return; } + $('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true; + try { + const audio = $('preview-audio'); + const source = await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct); + previewBlob = source.blob; + audio.src = source.url; + audio.style.display=''; + await audio.play(); + $('save-preview-mp3-btn').disabled = false; + $('save-preview-btn').disabled = source.streaming; + toast(source.streaming ? 'Streaming preview playing' : 'Preview playing', 'success'); + } catch(e) { toast('TTS failed: '+e.message,'error'); } + finally { $('preview-btn').disabled=false; } +}); +$('save-preview-mp3-btn').addEventListener('click', async () => { + const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim(); + if(!backend) { toast('No available TTS backend','error'); return; } + if(!voice || !text) return; + const btn = $('save-preview-mp3-btn'); + btn.disabled = true; + try { + const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (voice||'preview')+'_preview.mp3'; a.click(); + toast('MP3 saved', 'success'); + } catch(e) { toast('MP3 save failed: '+e.message,'error'); } + finally { btn.disabled = false; } +}); +$('save-preview-btn').addEventListener('click', () => { + if(!previewBlob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(previewBlob); + a.download = ($('tts-voice-select').value||'preview')+'_preview.wav'; a.click(); +}); + + +// ── STT -> TTS ─────────────────────────────────────────────────────────── + +let sttTtsSourceId = null; +let sttTtsOutputBlob = null; +let _sttBackends = []; +let sttTtsRecorder = null; +let sttTtsRecordStream = null; +let sttTtsRecordChunks = []; +let sttTtsRecordTimer = null; +let sttTtsRecordSecs = 0; + +function sttTtsSelectedSttBackend() { + return $('stt-tts-stt-backend')?.value || 'configured'; +} + +function sttBackendOptionHtml(selected = 'configured') { + if (!_sttBackends.length) return ''; + const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected); + return _sttBackends.map(b => { + const suffix = b.available ? '' : ' (unavailable)'; + const disabled = b.available ? '' : ' disabled'; + return ``; + }).join(''); +} + +function updateSttBackendHelp() { + const selected = sttTtsSelectedSttBackend(); + const b = _sttBackends.find(item => item.id === selected) || _sttBackends.find(item => item.available) || null; + const help = $('stt-tts-stt-help'); + if (!help) return; + if (!b) { help.textContent = 'No STT engine status loaded yet.'; return; } + const models = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : ''; + help.textContent = `${b.available ? 'Ready' : 'Unavailable'} at ${b.url}.${models}`; +} + +async function refreshSttBackends(selected = '') { + try { + const d = await fetch('/api/stt-backends').then(r => r.json()); + _sttBackends = (d.backends || []).filter(b => b && b.id); + } catch (_) { + _sttBackends = []; + } + const sel = $('stt-tts-stt-backend'); + if (sel) { + const prev = selected || sel.value || 'configured'; + sel.innerHTML = sttBackendOptionHtml(prev); + sel.disabled = !_sttBackends.some(b => b.available); + } + updateSttBackendHelp(); +} + +function sttTtsSelectedBackend() { + return $('stt-tts-backend-select')?.value || ''; +} + +function sttTtsDownload(blob, name) { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); +} + +async function sttTtsUploadFile(file) { + if (!file) return; + $('stt-tts-source-status').textContent = 'Uploading ' + file.name + '...'; + sttTtsSourceId = null; + sttTtsOutputBlob = null; + $('stt-tts-transcribe-btn').disabled = true; + $('stt-tts-copy-preview-btn').disabled = true; + $('stt-tts-save-mp3-btn').disabled = true; + $('stt-tts-save-wav-btn').disabled = true; + const fd = new FormData(); + fd.append('file', file); + try { + const r = await fetch('/api/upload', {method:'POST', body:fd}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + sttTtsSourceId = d.id; + const audio = $('stt-tts-source-audio'); + audio.src = '/api/audio/' + encodeURIComponent(d.id); + audio.style.display = ''; + $('stt-tts-source-status').textContent = `${d.filename || file.name} loaded (${Number(d.duration || 0).toFixed(1)} s).`; + $('stt-tts-transcribe-btn').disabled = false; + toast('Speech audio loaded', 'success'); + } catch (e) { + $('stt-tts-source-status').textContent = 'Upload failed.'; + toast('STT source upload failed: ' + e.message, 'error'); + } +} + +async function sttTtsFetchVoices() { + const backend = sttTtsSelectedBackend(); + if (!backend) throw new Error('No available TTS backend'); + const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); + let voices = Array.isArray(rawVoices) ? rawVoices : []; + if (shouldFilterBackendVoices(backend)) { + const activeIds = await activeLibraryVoiceIds(); + voices = voices.filter(v => activeIds.has(backendVoiceId(v))); + } + const sel = $('stt-tts-voice-select'), prev = sel.value; + sel.innerHTML = ''; + voices.forEach(v => { + const id = backendVoiceId(v); + const opt = document.createElement('option'); + opt.value = opt.textContent = id; + sel.appendChild(opt); + }); + if (prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev; + return voices.length; +} + +$('stt-tts-file')?.addEventListener('change', async () => { + const input = $('stt-tts-file'); + if (input.files && input.files.length) await sttTtsUploadFile(input.files[0]); + input.value = ''; +}); +$('stt-tts-refresh-stt-btn')?.addEventListener('click', async () => { + const btn = $('stt-tts-refresh-stt-btn'); + btn.disabled = true; + try { + await refreshSttBackends(sttTtsSelectedSttBackend()); + toast('STT engines refreshed', 'success'); + } finally { + btn.disabled = false; + } +}); +$('stt-tts-stt-backend')?.addEventListener('change', updateSttBackendHelp); + +function sttTtsSetRecording(on) { + $('stt-tts-rec-start').disabled = on; + $('stt-tts-rec-stop').disabled = !on; +} + +function sttTtsStopTracks() { + if (sttTtsRecordStream) sttTtsRecordStream.getTracks().forEach(t => t.stop()); + sttTtsRecordStream = null; +} + +$('stt-tts-rec-start')?.addEventListener('click', async () => { + try { + sttTtsRecordStream = await requestMicrophoneStream(); + sttTtsRecordChunks = []; + sttTtsRecordSecs = 0; + $('stt-tts-rec-time').textContent = '0:00'; + $('stt-tts-source-status').textContent = 'Recording...'; + sttTtsSetRecording(true); + sttTtsRecordTimer = setInterval(() => { + sttTtsRecordSecs++; + $('stt-tts-rec-time').textContent = Math.floor(sttTtsRecordSecs / 60) + ':' + String(sttTtsRecordSecs % 60).padStart(2, '0'); + }, 1000); + sttTtsRecorder = new MediaRecorder(sttTtsRecordStream); + sttTtsRecorder.ondataavailable = e => { if (e.data.size) sttTtsRecordChunks.push(e.data); }; + sttTtsRecorder.onstop = async () => { + clearInterval(sttTtsRecordTimer); + sttTtsRecordTimer = null; + sttTtsSetRecording(false); + sttTtsStopTracks(); + const mime = sttTtsRecorder.mimeType || 'audio/webm'; + const blob = new Blob(sttTtsRecordChunks, {type:mime}); + const ext = mime.includes('ogg') ? '.ogg' : '.webm'; + if (!blob.size) { + $('stt-tts-source-status').textContent = 'Recording was empty.'; + toast('Recording was empty', 'error'); + return; + } + await sttTtsUploadFile(new File([blob], 'stt-recording' + ext, {type:mime})); + }; + sttTtsRecorder.start(100); + toast('Recording started', 'success'); + } catch (e) { + sttTtsSetRecording(false); + sttTtsStopTracks(); + const message = await microphoneErrorMessage(e); + $('stt-tts-source-status').textContent = message; + toast(message, 'error'); + } +}); + +$('stt-tts-rec-stop')?.addEventListener('click', () => { + if (sttTtsRecorder && sttTtsRecorder.state !== 'inactive') sttTtsRecorder.stop(); +}); + +$('stt-tts-transcribe-btn')?.addEventListener('click', async () => { + if (!sttTtsSourceId) { toast('Load speech audio first', 'error'); return; } + const btn = $('stt-tts-transcribe-btn'); + btn.disabled = true; + $('stt-tts-source-status').textContent = 'Transcribing...'; + try { + const r = await fetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:sttTtsSourceId, backend:sttTtsSelectedSttBackend()})}); + if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } + const d = await r.json(); + $('stt-tts-text').value = d.text || ''; + $('stt-tts-copy-preview-btn').disabled = !(d.text || '').trim(); + const used = d.backend ? ' via ' + d.backend : ''; + $('stt-tts-source-status').textContent = 'Transcription ready' + used + '.'; + toast('Transcription ready', 'success'); + } catch (e) { + $('stt-tts-source-status').textContent = 'Transcription failed.'; + toast('STT failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +}); + +$('stt-tts-copy-preview-btn')?.addEventListener('click', () => { + const text = $('stt-tts-text').value.trim(); + if (!text) return; + $('preview-text-area').value = text; + switchTab('generation'); + toast('Copied transcription to TTS Generation', 'success'); +}); + +$('stt-tts-backend-select')?.addEventListener('change', () => { + $('stt-tts-voice-select').innerHTML = ''; + sttTtsOutputBlob = null; + $('stt-tts-save-mp3-btn').disabled = true; + $('stt-tts-save-wav-btn').disabled = true; + updateBackendHelp(); +}); + +$('stt-tts-fetch-voices-btn')?.addEventListener('click', async () => { + const btn = $('stt-tts-fetch-voices-btn'); + btn.disabled = true; + try { + const count = await sttTtsFetchVoices(); + toast('Fetched ' + count + ' voices', 'success'); + } catch (e) { + toast('Fetch failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +}); + +$('stt-tts-generate-btn')?.addEventListener('click', async () => { + const backend = sttTtsSelectedBackend(); + const voice = $('stt-tts-voice-select').value; + const text = $('stt-tts-text').value.trim(); + const instruct = $('stt-tts-style-instruction').value.trim(); + if (!backend) { toast('No available TTS backend', 'error'); return; } + if (!voice) { toast('Select a TTS voice', 'error'); return; } + if (!text) { toast('Transcribe or enter text first', 'error'); return; } + const btn = $('stt-tts-generate-btn'); + btn.disabled = true; + $('stt-tts-save-mp3-btn').disabled = true; + $('stt-tts-save-wav-btn').disabled = true; + try { + const source = await createTtsAudioSource(voice, text, backend, $('stt-tts-playback-mode').value, instruct); + sttTtsOutputBlob = source.blob; + const audio = $('stt-tts-output-audio'); + audio.src = source.url; + audio.style.display = ''; + await audio.play(); + $('stt-tts-save-mp3-btn').disabled = false; + $('stt-tts-save-wav-btn').disabled = source.streaming; + toast(source.streaming ? 'Streaming synthesized speech' : 'Synthesized speech ready', 'success'); + } catch (e) { + toast('TTS failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +}); + +$('stt-tts-save-mp3-btn')?.addEventListener('click', async () => { + const backend = sttTtsSelectedBackend(); + const voice = $('stt-tts-voice-select').value; + const text = $('stt-tts-text').value.trim(); + const instruct = $('stt-tts-style-instruction').value.trim(); + if (!backend || !voice || !text) return; + const btn = $('stt-tts-save-mp3-btn'); + btn.disabled = true; + try { + const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend); + sttTtsDownload(blob, (voice || 'stt_tts') + '_stt_tts.mp3'); + toast('MP3 saved', 'success'); + } catch (e) { + toast('MP3 save failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +}); + +$('stt-tts-save-wav-btn')?.addEventListener('click', () => { + if (!sttTtsOutputBlob) return; + sttTtsDownload(sttTtsOutputBlob, ($('stt-tts-voice-select').value || 'stt_tts') + '_stt_tts.wav'); +}); + +// ── Init ────────────────────────────────────────────────────────────────── + +initBenchmarkSampleControls(); +loadSettings().then(() => { + refreshSttBackends(); + renderIntegrationSnippets(); + if (!localStorage.getItem(SETTINGS_SEEN_KEY)) openSettings(true); +}).catch(e => status('Settings load failed: ' + e.message)); +loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message)); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..00f8367 --- /dev/null +++ b/static/index.html @@ -0,0 +1,94 @@ + + + + + + TTS Voice Creator + + + + + + + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
Loading…
+ + + + + + diff --git a/static/loader.js b/static/loader.js new file mode 100644 index 0000000..46ff0b8 --- /dev/null +++ b/static/loader.js @@ -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 = '

Failed to load section ' + id + ': ' + e.message + '

'; + } + })); + + // 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'); +})(); diff --git a/static/nav.js b/static/nav.js new file mode 100644 index 0000000..2bf5b48 --- /dev/null +++ b/static/nav.js @@ -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); + }); +})(); diff --git a/static/sections/s-clone.html b/static/sections/s-clone.html new file mode 100644 index 0000000..6d533a7 --- /dev/null +++ b/static/sections/s-clone.html @@ -0,0 +1,112 @@ +
+ 🎤 +
+

Clone a Voice

+

Upload or record 3-20 seconds of audio, trim it, then save it as a reusable voice clone.

+
+
+ + +
+
+

Step 1 — Load audio

+

Provide the audio you want to clone. Drop a file, paste a YouTube link, or record your microphone.

+
+ Drop an audio / video file here + WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM + or click to browse + +
+
+
+

YouTube / URL

+
+ + +
+
+
+
+

Microphone

+
+ + +
0:00
+
+
+ +
+ + +
+
+

Step 3 — Name the voice

+

Give the voice a unique ID in the format LANG_GENDER_Name, for example EN_F_Anna.

+
+ +
+
+
+ Build from parts  (LANG · GENDER · Name helper) +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+

Transcript (reference text)

+

Type what was spoken in the audio, or click Auto-transcribe to fill it automatically.

+
+ + +
+ +
+
+

Audio preview

+ +

No audio loaded yet. Load a file above (Step 1) or use Voice Design below.

+
+
+

Step 4 — Save to library

+
+ +
+ +
+
diff --git a/static/sections/s-connect.html b/static/sections/s-connect.html new file mode 100644 index 0000000..ed82547 --- /dev/null +++ b/static/sections/s-connect.html @@ -0,0 +1,63 @@ +
+ 🔗 +
+

Connect Your Apps

+

Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, and more.

+
+
+ +
+
+

Use voices in other apps

+

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.

+
+ + + + +
+
+
+
+

SillyTavern

+

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.

+
+ +
+
+

Open WebUI

+

Configure TTS as OpenAI-compatible audio. Use the creator proxy if you want Routing rules such as incoming voice default mapped by language.

+
+ +
+
+

Home Assistant

+

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.

+
+ +
+
+

Generic curl test

+

Quick terminal test for the voice list and speech endpoint after restarting the TTS container.

+
+ +
+
+

VoiceDesign virtual voices

+

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 vd_... voice.

+
+ +
+
+

Streaming TTS

+

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.

+
+ +
+
+
+

Important after voice changes

+

After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated active_voices folder. Then refresh the model or voice list in the target app.

+

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 faster-qwen3-tts-voicedesign container reachable from Settings.

+
+
diff --git a/static/sections/s-design.html b/static/sections/s-design.html new file mode 100644 index 0000000..afb0286 --- /dev/null +++ b/static/sections/s-design.html @@ -0,0 +1,160 @@ +
+ +
+

Design a Voice

+

Describe a voice in words and let the AI create it. No recording needed.

+
+
+ +
+
+
+ + Voice Design samples + Preview a sample or use it to fill the editor below. + +
+
Name
Sex
Language
+
Description
Sample text
Actions
+
+
+
+
+ +
+

Describe the voice

+

Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + + + + + + + + + +
DimensionExamples
GenderMale, female, neutral
AgeChild (5-12), teenager (13-18), young adult (19-35), middle-aged (36-55), elderly (55+)
PitchHigh, mid, low, slightly high, slightly low
Speaking rateFast, moderate, slow, slightly fast, slightly slow
EmotionCheerful, calm, gentle, serious, lively, composed, soothing
CharacteristicsMagnetic, crisp, husky, smooth, sweet, rich, powerful
Use caseNews broadcasting, advertisement voice-over, audiobook, animated character, voice assistant, documentary narration
+
+ +
+

Reference transcript & generate

+

This shared reference transcript is used by samples, prompt presets, generation, preview, download, and export to the Voice Clone Library.

+ +
+ + +
+
+ + + +
+

Voice Design prompt library

+
+
+ + +
+ + +
+
+
+
+ + +
+
+

Custom Voice (Qwen style control)

+

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.

+
+ Best for style control over configured target timbres. +
+ model voice + style-aware + stream-capable model +
+
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.
+
+
+ +
+
+
diff --git a/static/sections/s-routing.html b/static/sections/s-routing.html new file mode 100644 index 0000000..ce64f40 --- /dev/null +++ b/static/sections/s-routing.html @@ -0,0 +1,87 @@ +
+ +
+

App Routing

+

Map incoming requests from apps like Open WebUI or SillyTavern to specific voices and backends.

+
+
+ +
+
+

TTS voice routing

+
+ Recommended OpenAI-compatible TTS base URL: . If an app asks for the full speech endpoint instead of a base URL, use /v1/audio/speech. + Rules can turn an incoming app voice such as default into a real cloned voice before the request is sent to Qwen3-TTS. + The app name is read from request JSON fields app/client, headers such as X-TTS-App, or guessed from User-Agent/Origin. If a client cannot send that, use app * or give each app a unique incoming voice name. + For Open WebUI, set Audio → Text-to-Speech → Additional Parameters to {"app":"Open WebUI"} 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 {"app":"Home Assistant"}. Use Response splitting Punctuation for lower perceived latency. + Backend chooses whether this route uses normal Voice Clone, low-latency Streaming, or Voice Design presets such as vd_.... 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 sounds/start.wav. +
+ Do not use 0.0.0.0 in Open WebUI. + Use this machine's LAN IP, hostname, or Docker service name instead. +
+
+
+ + + + + No routes loaded. +
+
+ + + + +
+
+
+
+
+
On
App
Input voice
Language
+
Backend
Output voice
Before sound
After sound
+
+ + +
+
+ +
+
+
+

Routing log

+

Recent route tests and proxy requests. This log is kept in memory and resets when the server restarts.

+
+
+ + + +
+
+
+
+
No routing log entries yet.
+
+
+
+
diff --git a/static/sections/s-settings.html b/static/sections/s-settings.html new file mode 100644 index 0000000..7328173 --- /dev/null +++ b/static/sections/s-settings.html @@ -0,0 +1,245 @@ +
+ +
+

Settings

+

Configure backend URLs, API keys, voice folders, and playback preferences.

+
+
+ +
+
+
+
+

Settings

+

Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.

+
+
Local stack
+
+ +
+ Quick setup + 1. Check core TTS URLs. + 2. Point STT at Whisper, Parakeet, or the NVIDIA router. + 3. Save settings. +
+ +
+
+
+

Core connections

+

These are the endpoints you change most often. Qwen3 TTS, NVIDIA TTS, and STT are grouped separately.

+
+
+
+
+
+ TTS Text to Speech + Qwen3 engines: clone, design, custom, streaming +
+
+
+ + + Uploaded/cloned WAV voices. Expected: POST /v1/audio/speech. +
+
+ + + Prompt-designed voices and vd_... virtual voices. +
+
+ + + Style over configured speakers such as Ryan, Vivian, Serena. +
+
+ + + Progressive low-latency WAV playback. +
+
+
+ +
+
+ NVIDIA speech stack + router, Magpie TTS, Parakeet ASR, clone NIM +
+
+
+ + + OpenAI-compatible base URL for the NVIDIA speech router. +
+
+ + + Direct Magpie endpoint. Fixed speakers, not WAV cloning. +
+
+ + + Direct Parakeet endpoint for transcription. +
+
+ + + Magpie Zeroshot clone endpoint with audio_prompt. +
+
+ + + Magpie Flow clone endpoint with audio_prompt and transcript. +
+
+
+ +
+
+ STT Speech to Text + Whisper, Parakeet, or NVIDIA router +
+
+
+ + + Reference text recognition. Expected: POST /v1/audio/transcriptions. +
+ + +
+
+
+
+
+
+ +
+
+
+

Playback behavior

+

Small behavior switches for previews and OpenAI-compatible TTS calls.

+
+
+
+
+ + + Buffered keeps Save WAV available. Streaming starts sooner. +
+
+ + + Controls payload shape for the normal TTS API URL. +
+
+
+ +
+ + Advanced request payloads + JSON extras sent to each backend + +
+
+ + + Extra fields for the 8020 WAV voice clone/base model. +
+
+ + + Extra fields for the 8023 streaming model. +
+
+ + + Extra fields for the CustomVoice backend. +
+
+ + + Extra fields for Voice Design and virtual vd_... voices. +
+
+ + + Usually empty. Magpie accepts fixed speaker voices such as sofia, aria, jason, leo, and john. +
+
+ + + Optional multipart fields. The app supplies text, language, and audio_prompt. +
+
+ + + Optional multipart fields. The app also sends the saved reference transcript. +
+
+
+ +
+ + Voice folders + container paths and Portainer volume mounts + +
+
+ + + Contains active_voices, hidden_voices, sounds, and metadata. +
+
+ + + New cloned/exported voices are saved here. +
+
+
+ +
+ + API keys + usually empty for local containers + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
diff --git a/static/sections/s-studio.html b/static/sections/s-studio.html new file mode 100644 index 0000000..0606b61 --- /dev/null +++ b/static/sections/s-studio.html @@ -0,0 +1,36 @@ +
+ 🌐 +
+

Get Voices Online

+

Browse public voice clip sources, preview direct audio files, and import voices from the web.

+
+
+ +
+
+

Get voices

+
+ +
+ + + Not scraped yet. +
+
+
+ + + + + + +
+

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.

+
+
+
+

Click Scrape sources to fetch Aiartes VoiceAI clips, yaph/tts-samples MP3 files, and the jim-schwoebel voice dataset index.

+
+
diff --git a/static/sections/s-tryout.html b/static/sections/s-tryout.html new file mode 100644 index 0000000..1af3a03 --- /dev/null +++ b/static/sections/s-tryout.html @@ -0,0 +1,157 @@ +
+ +
+

Try It Out

+

Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.

+
+
+ + +
+
+

TTS generation playground

+

Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.

+
+
+

Generate speech

+
+
+ + +
+
+ Checking available TTS backends... +
+
+ +
+ + +
+
+
+ + +
+
+

After changing active voices, restart the TTS container so the engine reads the updated voice folder.

+ +
+ + +
+
+ + + This is sent as instruct. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware. +
+
+ + + +
+ +
+
+ + +
+
+

STT → TTS workspace

+

Upload speech audio, transcribe it with the configured STT endpoint, then synthesize the resulting text with any available TTS backend.

+
+
+

Source speech

+
+
+ +
+ + +
+ Uses Settings → Whisper/STT URL by default. +
+
+ +
+ + + + 0:00 +
+ Record or upload speech audio, then transcribe it with the selected recognition engine. +
+
+ + + No source audio loaded. +
+
+
+ + +
+
+ + +
+
+
+

Synthesize transcription

+
+
+ + +
+
+ Checking available TTS backends... +
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + + +
+ +
+
diff --git a/static/sections/s-voices.html b/static/sections/s-voices.html new file mode 100644 index 0000000..810434e --- /dev/null +++ b/static/sections/s-voices.html @@ -0,0 +1,250 @@ +
+ 🔊 +
+

My Voice Library

+

All your cloned and designed voices in one place. Play, manage, benchmark, and export them.

+
+
+ +
+
+
+

Voice library

+ +
+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+
+

1 Load or record source

+
+
+ +
+ Drop an audio / video file here + WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM + or click to browse +
+
+ +
+ + +
+
+
+
+
+ Record a fresh sample +
+ +
+
+
+
+
+ + + + + 0:00 +
+
+
+ Input level + -∞ dB +
+ +
+ + + 1.00x +
+
Best peaks: -18 to -9 dB, never red.
+
+
+
+
+ + +
+ +
+ + + Use this as the spoken script if you record. +
+
+ Quiet room + 20 cm from mic + No clipping + Natural pace +
+
+
+
+ If the microphone is blocked: +
    +
  • Chrome, Brave, Edge: click the lock/tune icon in the address bar, set Microphone to Allow, then reload.
  • +
  • Firefox: click the microphone or lock icon in the address bar, remove Blocked or choose Allow, then reload.
  • +
  • Safari: open Safari Settings, Websites, Microphone, then allow this site.
  • +
  • Requested device not found: choose or enable a microphone in your OS input settings, then reload.
  • +
  • Browsers require localhost or HTTPS for microphone access.
  • +
+
+
Load a file, paste a URL, or record a sample. Then trim, name, and save the voice.
+
+
+
+ +
+ + + +
+
+ +
+
+
+ + Select 3-20 seconds for best cloning. +
+
+
+

2 Name and save voice

+
+
+
+
+
+ +
+ + +
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
Voices
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+
Image
+
Language / Sex / Name
+
Filetype
+
Length
+
dB
+
Benchmark
+
Rating
+
Play / Pause
+
Edit
+
+
+
+ +
+
diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..c894e5c --- /dev/null +++ b/static/style.css @@ -0,0 +1,704 @@ +