Update voice library UI: convert table layout to card grid, change network to dgx_net

This commit is contained in:
mARTin-B78 2026-05-25 20:31:27 +02:00
parent 8f06971aaf
commit 6583a9fc80
11 changed files with 1808 additions and 63 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -25,11 +25,11 @@ services:
- "host.docker.internal:host-gateway"
networks:
- tts_net
- dgx_net
volumes:
tts-voice-creator-clone-and-design-2:
networks:
tts_net:
dgx_net:
external: true

View File

@ -0,0 +1,10 @@
FROM faster-qwen3-tts-dgx-spark:v4
ENV PYTHONUNBUFFERED=1 \
QWEN_TTS_MODEL=/models/Qwen3-TTS \
QWEN_TTS_VOICES=/config/voices.json \
QWEN_TTS_MAX_SEQ_LEN=4096
EXPOSE 8000
CMD ["/bin/bash", "-lc", "python3 /config/generate_voices.py && python3 /config/run_server.py --model ${QWEN_TTS_MODEL} --voices ${QWEN_TTS_VOICES} --port 8000 --max-seq-len ${QWEN_TTS_MAX_SEQ_LEN}"]

217
docs/forum-playbook.md Normal file
View File

@ -0,0 +1,217 @@
# TTS Voice Creator + Qwen3-TTS Setup Playbook
This playbook is for people who want a friendly voice-cloning and TTS-routing setup without living in a terminal.
## What This Gives You
You get a browser app called **TTS Voice Creator - Clone and Design** that can:
- Record or import voice samples.
- Trim, normalize, transcribe, and save cloned voices.
- Create prompt-based Voice Design voices.
- Route simple app voices such as `default` to different real voices by language.
- Act as an OpenAI-compatible TTS endpoint for Open WebUI, SillyTavern, Home Assistant, and curl.
The usual Open WebUI setup becomes:
```text
TTS API base URL: http://YOUR_HOST:7860/v1
Model: tts-1
Voice: default
```
Then the Routing tab decides what `default` means.
## The Pieces
### Docker
Docker runs each part in its own container. That keeps the app, Qwen3-TTS, Voice Design, and optional tools separated and easier to update.
### Portainer
Portainer is optional. It is a browser dashboard for Docker. If your target user dislikes command lines, Portainer is worth installing because it gives them buttons for logs, restarts, and container status.
### TTS Voice Creator
This is the voice library and routing interface. It stores reference voices, metadata, transcripts, ratings, active/hidden state, and routing rules.
### Qwen3-TTS Voice Clone
This is the normal production TTS path. It uses reference audio from exported cloned voices.
Use this when:
- You recorded a real person or character voice.
- You want Open WebUI to speak using saved voices.
- You want the most predictable day-to-day TTS endpoint.
### Qwen3-TTS Voice Design
This creates a voice from a written description. No reference recording is required.
Use this when:
- You are designing a new narrator or character.
- You want to prototype voices before recording anyone.
- You want virtual voices through the app proxy.
### Qwen3-TTS Custom Voice
This is for Qwen CustomVoice workflows with named speakers and optional instructions.
Use this when:
- You already maintain custom speaker IDs.
- You want to expose Qwen custom speakers as OpenAI-compatible voices.
- You need a separate custom voice server from the cloned voice library.
## Guided Installation
Start the installer:
```bash
python3 guided_installer.py
```
On desktop systems with Python file association, you can often double-click `guided_installer.py`.
The installer checks:
- Docker command
- Docker daemon
- Docker Compose plugin
- Portainer
- TTS Voice Creator
- Qwen3-TTS clone server
- Qwen3-TTS Voice Design server
- Qwen3-TTS Custom Voice server
It then asks for:
- Voice library folder
- Model folder
- App port
- Docker network name
- Qwen Docker image
- Which Qwen model servers to install
- Ports for each model server
- Model locations for each server
- Optional external TTS endpoint
## Recommended Ports
```text
7860 TTS Voice Creator
8020 Qwen3-TTS Voice Clone
8021 Qwen3-TTS Voice Design
8022 Qwen3-TTS Custom Voice
9000 Portainer HTTP
9443 Portainer HTTPS
```
## Installation Flow
1. Open the guided installer.
2. Click **Re-check this computer**.
3. If Docker is missing, open the Docker install guide from the installer.
4. Optionally click **Install Portainer**.
5. Choose which Qwen model servers to install.
6. Pick where the large model folders live.
7. Pick the ports.
8. Click **Generate compose file**.
9. Click **Install / update selected stack**.
10. Open `http://YOUR_HOST:7860`.
## First App Setup
Open the Settings gear in TTS Voice Creator:
```text
TTS API URL: http://host.docker.internal:8020
Voice Design URL: http://host.docker.internal:8021
Voice scan directory: /voices
Active voices dir: /voices/active_voices
```
If the app and TTS servers are on the same Docker network, container names can also be used from inside Docker.
## Recording A Voice
1. Open **Voice Clone**.
2. Click **Add new voice**.
3. Choose the read-sample language.
4. Click **Check level**.
5. Adjust mic gain until peaks are roughly `-18` to `-9 dB`.
6. Click **Record**.
7. Stop after 10 to 20 seconds.
8. Auto trim if needed.
9. Use or edit the transcript.
10. Save the voice.
After saving voices, restart the Qwen3-TTS clone container so it scans `active_voices`.
## Open WebUI Without Routing
Use this when you always want one explicit voice.
```text
Provider: OpenAI-compatible
Base URL: http://YOUR_HOST:8020/v1
API key: dummy
Model: tts-1
Voice: EN_F_YourVoice
```
## Open WebUI With Routing
Use this when Open WebUI sends `default`, and TTS Voice Creator decides which real voice to use.
```text
Provider: OpenAI-compatible
Base URL: http://YOUR_HOST:7860/v1
API key: dummy
Model: tts-1
Voice: default
```
Then add routes:
```text
Open WebUI + default + EN -> EN_F_YourEnglishVoice
Open WebUI + default + DE -> DE_M_YourGermanVoice
Open WebUI + default + FR -> FR_F_YourFrenchVoice
```
Now English text, German text, and French text can each use a different voice while Open WebUI still only knows about `default`.
## Troubleshooting
### Microphone Is Blocked
Browsers usually require `localhost` or HTTPS for microphone access.
- Chrome, Brave, Edge: click the lock/tune icon in the address bar and allow Microphone.
- Firefox: click the microphone or lock icon and remove the block.
- Safari: Safari Settings, Websites, Microphone, allow this site.
### Voice Does Not Appear In Qwen3-TTS
Restart the Qwen3-TTS clone container. Most TTS servers scan voice folders at startup.
### Open WebUI Gets No Audio
Check:
- Base URL ends in `/v1`.
- Voice exists in `/v1/audio/voices`.
- API key is set to `dummy` if Open WebUI requires a key.
- The TTS container logs do not show model-load errors.
### Routing Picks The Wrong Language
The router uses lightweight language detection. If a sentence is short or mixed-language, add a fallback `*` route or send a more explicit text sample.
## Why This Setup Is Nice
It separates voice management from the TTS engine. The app becomes the place where non-technical users can record, clean up, label, activate, route, and test voices, while Docker keeps the heavier services running in the background.

View File

@ -0,0 +1,235 @@
# Tutorial Video Script: TTS Voice Creator + Qwen3-TTS
## Video Goal
Show the value first, then walk through setup with the guided installer.
Target length: 12 to 18 minutes.
## Cast
- Presenter
- Screen recording
- Optional second device or narrow browser window for mobile view
## Scene 1: Hook
**Visual:** Open TTS Voice Creator on the Voice Clone tab.
**Presenter:**
Today I am going to show a complete local TTS workflow: record a voice, save it as a Qwen3-TTS voice, route Open WebUI's `default` voice by language, and install the whole stack with a guided installer instead of command-line guesswork.
By the end, Open WebUI can say English text with one voice and German text with another, while Open WebUI itself only asks for `default`.
## Scene 2: Feature Overview
**Visual:** Click through the main tabs.
**Presenter:**
The app has four main areas.
Voice Clone is where you record or import voice samples. You can trim them, normalize loudness, transcribe the reference text, and save them as active voices.
Voice Design is for creating voices from written descriptions. This is useful when you do not have a recording yet.
Routing lets an external app send a simple voice name like `default`, and this app maps that to the right output voice by app and language.
Use in apps gives copyable setup examples for Open WebUI, SillyTavern, Home Assistant, and curl.
## Scene 3: Record A Voice
**Visual:** Voice Clone, Add new voice.
**Presenter:**
Let us start with recording. I click Add new voice. On the right, I have the microphone recorder.
First I choose the read-sample language. The sample text changes automatically, and I can edit it if I want. It is saved per language.
Before recording, I click Check level. Now the input meter is live. I adjust mic gain until peaks land around minus eighteen to minus nine dB and never hit red.
Now I record the sample, stop, and the waveform appears.
## Scene 4: Trim And Save
**Visual:** Use waveform, Auto trim, transcript, save fields.
**Presenter:**
The app can auto trim the useful speech area. I can also adjust start and end manually.
Next I make sure the transcript matches exactly what was spoken. This matters for voice cloning quality.
Then I choose language, gender, and a voice ID. When I save, the app writes the WAV and the reference transcript into the active voices folder.
After adding voices, restart the Qwen3-TTS clone container so it rescans active voices.
## Scene 5: Routing Demo
**Visual:** Routing tab.
**Presenter:**
Here is the routing system. This is especially helpful for Open WebUI.
Instead of teaching Open WebUI every voice, I can let Open WebUI ask for `default`.
Then I add rules:
Open WebUI, default, English goes to my English voice.
Open WebUI, default, German goes to my German voice.
Now the creator app receives the TTS request, detects the language, swaps the voice, and forwards the request to Qwen3-TTS.
## Scene 6: Open WebUI Setup
**Visual:** Use in apps tab, Open WebUI card.
**Presenter:**
For Open WebUI with routing, use the creator app as the OpenAI-compatible TTS base URL.
The base URL is:
`http://YOUR_HOST:7860/v1`
The model can be `tts-1`.
The voice is `default`.
Without routing, point Open WebUI directly to Qwen3-TTS:
`http://YOUR_HOST:8020/v1`
and use a real voice ID.
## Scene 7: Mobile View
**Visual:** Narrow browser or phone view.
**Presenter:**
The interface also works on mobile. The voice library becomes card-like, the Add Voice controls stack cleanly, and the recording sample has larger touch-friendly controls.
This is useful when you want to fix a route, enable a voice, or check a sample from another device.
## Scene 8: Guided Installer Introduction
**Visual:** Launch `guided_installer.py`.
**Presenter:**
Now let us look at setup. The target user does not need to memorize Docker commands.
The guided installer checks this computer for Docker, Docker Compose, Portainer, this app, and the Qwen3-TTS services.
It explains what each component is for and gives buttons for the common actions.
## Scene 9: Installer Basics
**Visual:** Installer Basics tab.
**Presenter:**
On the Basics tab, I choose the voice library folder, the model folder, the app port, the Docker network, and the Qwen TTS image.
The voice folder stores the saved reference voices.
The model folder stores the large Qwen model files.
Portainer is optional, but for non-command-line users it is very helpful.
## Scene 10: Qwen Model Choices
**Visual:** Installer Qwen Models tab.
**Presenter:**
There are three Qwen-related servers.
Voice Clone is the normal production server for recorded voices. Most people should install this.
Voice Design creates voices from text descriptions. Install this if you want prompt-based voices.
Custom Voice is for Qwen CustomVoice speaker workflows, where named speakers are configured separately.
Each service can have its own model location and port.
The recommended ports are 8020 for clone, 8021 for Voice Design, and 8022 for Custom Voice.
## Scene 11: Generate And Install
**Visual:** Click Generate compose file, then install selected stack.
**Presenter:**
When the choices look right, I click Generate compose file. This writes a Docker Compose file with the selected services.
Then I click Install or update selected stack. The installer creates the Docker network and runs Docker Compose.
All output appears in the Log tab, so if something fails, there is a visible error instead of a disappearing terminal window.
## Scene 12: Portainer
**Visual:** Portainer button and Portainer UI if available.
**Presenter:**
If Portainer is selected, the installer can install Portainer too. Portainer gives a browser dashboard for container status, logs, restarts, and updates.
This is usually the friendliest maintenance screen for someone who does not like command lines.
## Scene 13: Final End-To-End Test
**Visual:** Open WebUI or curl/audio preview.
**Presenter:**
Now we test the whole chain.
Open WebUI sends text to `http://YOUR_HOST:7860/v1`.
The creator app receives `voice: default`.
The Routing tab maps that to a real voice based on language.
Qwen3-TTS generates the audio.
Open WebUI plays it back.
## Scene 14: Closing
**Presenter:**
That is the full workflow: install with a guided setup, record and manage voices in the browser, route simple app voice names to real multilingual voices, and keep everything local through Docker.
For advanced users, there is also a CLI, but the main path is now friendly for people who prefer buttons and a browser.
## B-Roll Checklist
- Voice library with active voices.
- Add new voice panel.
- Microphone level meter.
- Read sample language selector.
- Waveform trim.
- Routing tab with `default` rules.
- Use in apps Open WebUI snippet.
- Guided installer status check.
- Installer Qwen model selection.
- Portainer container list.
- Mobile/narrow viewport.
## Suggested Chapter Titles
1. What This Solves
2. Record A Voice
3. Save And Activate
4. Route Open WebUI Voices
5. Mobile Editing
6. Guided Installer
7. Choosing Qwen Models
8. Open WebUI Setup
9. Final Test

View File

@ -362,22 +362,21 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.library-filter-note { border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.05); color: var(--subtext); padding: 8px 10px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 8px 0; }
.library-filter-note strong { color: var(--text); }
.library-filter-note button { flex: 0 0 auto; }
.library-control-panel { display: grid; grid-template-columns: minmax(220px,.9fr) minmax(280px,1.2fr) repeat(4, minmax(120px,.45fr)) auto; gap: 9px; align-items: end; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: rgba(37,99,235,.035); }
.library-control-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; align-items: end; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: rgba(37,99,235,.035); }
.library-control-panel .field { min-width: 0; }
.library-control-panel input, .library-control-panel select { width: 100%; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 10px; }
.library-list-title { display:flex; justify-content:space-between; align-items:center; gap:12px; padding: 8px 2px 0; color: var(--subtext); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 800; }
.library-list-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 8px 2px 8px; color: var(--subtext); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 800; }
.library-list-title strong { color: var(--text); font-size: 13px; }
.library-action-row { display: grid; grid-template-columns: 132px minmax(360px,1.7fr) 60px 70px 86px 112px 132px minmax(130px,.55fr) 54px; gap: 8px; align-items: center; margin: 4px 0 8px; }
.voice-action-group { min-width: 0; display:flex; gap: 8px; align-items:center; }
.voice-action-group button { width:100%; min-width:0; min-height:34px; padding: 7px 10px; white-space:nowrap; }
.action-add { grid-column: 1 / 2; }
.action-refresh { grid-column: 2 / 3; justify-content:flex-start; }
.library-action-row { display: grid; grid-template-columns: auto 1fr auto auto; gap: 8px; align-items: center; margin: 8px 0 12px; }
.voice-action-group { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.voice-action-group button { min-height: 34px; padding: 7px 12px; white-space: nowrap; }
.action-add { grid-column: 1; }
.action-refresh { grid-column: 2; justify-content: flex-start; }
.action-refresh button { flex: 0 0 auto; }
.action-db { grid-column: 5 / 6; }
.action-benchmark { grid-column: 6 / 7; }
.action-copy { grid-column: 7 / 8; justify-content:flex-start; }
.action-copy button { width:auto; flex:0 0 auto; min-width:0; font-size:12px; }
.action-show-disabled { grid-column: 8 / 10; justify-self:end; align-self:center; min-height:34px; padding: 0 4px; margin:0; }
.action-db { display: none; }
.action-benchmark { display: none; }
.action-copy { display: none; }
.action-show-disabled { grid-column: 4; justify-self: end; align-self: center; min-height: 34px; padding: 0 4px; margin: 0; }
.benchmark-confirm { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 0 132px; padding: 10px 12px; max-width: 760px; border: 1px solid rgba(217,119,6,.45); border-radius: 7px; background: rgba(217,119,6,.07); color: var(--text); box-shadow: var(--shadow); }
.benchmark-confirm[hidden] { display: none; }
.benchmark-confirm strong { color: var(--yellow); font-size: 13px; }
@ -387,28 +386,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.disabled-info { background: rgba(37,99,235,.06); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 15px; font-size: 13px; color: var(--subtext); line-height: 1.6; }
.disabled-info strong { color: var(--text); }
/* Voice list rows */
.vl-grid { display: grid; grid-template-columns: 132px minmax(360px,1.7fr) 60px 70px 86px 112px 132px minmax(130px,.55fr) 54px; gap: 8px; align-items: center; }
.vl-header { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); position: sticky; top: 0; z-index: 5; }
.vl-header div { white-space: nowrap; overflow: hidden; font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .07em; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 3px; transition: color .15s; padding: 2px 0; }
.vl-header div:hover { color: var(--text); }
.vl-header div.sort-asc::after { content: ' ▲'; font-size: 11px; color: var(--accent); font-weight: 900; }
.vl-header div.sort-desc::after { content: ' ▼'; font-size: 11px; color: var(--accent); font-weight: 900; }
.vl-header div.sort-asc, .vl-header div.sort-desc { color: var(--accent); }
.vl-header div.no-sort { cursor: default; }
.vl-header div.no-sort:hover { color: var(--subtext); }
.vl-row { position: relative; min-height: 136px; padding: 12px 10px 10px 132px; overflow: visible; flex: 0 0 auto; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); transition: border-color .15s, background .15s; display: flex; flex-direction: column; gap: 8px; }
.vl-row:hover { border-color: var(--accent); background: rgba(37,99,235,.03); }
/* Voice list rows - Card grid layout */
.vl-grid { display: grid; grid-template-columns: repeat(2, minmax(300px, 1fr)); gap: 14px; }
.vl-header { display: none; }
.vl-row { position: relative; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); transition: border-color .15s, background .15s, box-shadow .15s; display: flex; flex-direction: column; gap: 10px; }
.vl-row:hover { border-color: var(--accent); background: rgba(37,99,235,.02); box-shadow: 0 4px 12px rgba(37,99,235,.12); }
.vl-row.vr-disabled { opacity: .42; }
.vl-row.edit-open { gap: 10px; border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,.15) inset; }
.vr-main-row { display: grid; grid-template-columns: minmax(360px,1.7fr) 60px 70px 86px 112px 132px minmax(130px,.55fr) 54px; grid-template-areas: "identity type length db bench rating play edit"; align-items: center; gap: 8px; min-width: 0; }
.vr-photo { position:absolute; left:12px; top:12px; width: 104px; height: 104px; aspect-ratio: 1/1; border-radius: 7px; overflow: hidden; cursor: pointer; background: var(--border); flex-shrink: 0; }
.vl-row.edit-open { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
.vr-main-row { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.vr-photo { width: 100%; height: 160px; border-radius: 8px; overflow: hidden; background: var(--border); position: relative; }
.vr-photo img { width: 100%; height: 100%; object-fit: cover; }
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 34px; color: var(--subtext); }
.vr-photo:hover::after { content: '+'; position: absolute; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; font-size: 20px; color: #fff; font-weight: bold; }
.vr-photo input[type=file] { display: none; }
.vr-identity { grid-area: identity; display:grid; grid-template-columns: 58px 38px minmax(0,1fr); gap:16px; align-items:center; min-width:0; padding-left: 18px; }
.vr-flag { position: relative; cursor: pointer; user-select: none; display: flex; flex-direction: column; align-items: center; gap: 1px; }
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 40px; color: var(--subtext); }
.vr-photo:hover::after { content: ''; }
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; }
.vr-flag { cursor: pointer; user-select: none; display: flex; flex-direction: column; align-items: center; gap: 1px; flex: 0 0 auto; }
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
.vr-flag .flag-code { font-size: 10px; font-family: monospace; font-weight: 700; color: var(--subtext); letter-spacing: .04em; }
.vr-flag:hover .flag-emoji { transform: scale(1.18); }
@ -418,40 +409,34 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.flag-opt .fo-code { font-size: 10px; font-family: monospace; font-weight: 700; color: var(--subtext); }
.flag-opt:hover { background: var(--border); }
.flag-opt.active { background: rgba(37,99,235,.15); }
.vr-gender { grid-area: auto; text-align: center; cursor: pointer; user-select: none; line-height: 1; display:flex; align-items:center; justify-content:center; }
.vr-gender span { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:50%; background:rgba(37,99,235,.08); border:1px solid rgba(37,99,235,.22); font-size:28px; font-weight:800; transition: transform .15s, background .15s, border-color .15s; }
.vr-gender span:hover { transform: scale(1.12); background:rgba(37,99,235,.15); border-color:var(--accent); }
.vr-gender { text-align: center; cursor: pointer; user-select: none; line-height: 1; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; }
.vr-gender span { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(37,99,235,.08); border: 1px solid rgba(37,99,235,.22); font-size: 28px; font-weight: 800; transition: transform .15s, background .15s, border-color .15s; }
.vr-gender span:hover { transform: scale(1.12); background: rgba(37,99,235,.15); border-color: var(--accent); }
.g-f { color: #e91e8c; }
.g-m { color: #0ea5e9; }
.g-n { color: #8b5cf6; }
.vr-name { display: flex; align-items: center; gap: 4px; min-width: 0; }
.vr-name { display: flex; align-items: center; gap: 4px; min-width: 0; flex: 1; }
.vr-name-text { font-weight: 700; color: var(--accent); font-family: monospace; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; cursor: text; }
.vr-name-input { flex: 1; background: var(--bg); border: 1px solid var(--accent); color: var(--text); border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: monospace; min-width: 0; }
.icon-btn { background: none; border: none; padding: 3px 5px; font-size: 14px; color: var(--subtext); border-radius: 4px; cursor: pointer; flex-shrink: 0; }
.icon-btn:hover { background: var(--border); color: var(--text); }
.rename-confirm { display: none; gap: 3px; }
.rename-confirm.show { display: flex; }
.vr-type { grid-area:type; font-family: monospace; font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-length { grid-area:length; font-family: monospace; font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-db { grid-area:db; display: flex; align-items: center; gap: 4px; font-family: monospace; font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-bench { grid-area:bench; display: grid; grid-template-columns: minmax(0,1fr) 26px; gap: 4px; align-items: center; font-family: monospace; font-size: 12px; color: var(--subtext); }
.vr-bench-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vr-bench.bench-ok { color: var(--green); }
.vr-bench.bench-warn { color: var(--yellow); }
.vr-bench.bench-bad { color: var(--red); }
.normalize-voice-btn { width: 24px; height: 24px; padding: 0; border-radius: 6px; background: rgba(37,99,235,.08); border: 1px solid var(--border); color: var(--accent); font-size: 13px; line-height: 1; }
.normalize-voice-btn:hover { border-color: var(--accent); background: rgba(37,99,235,.18); filter: none; }
.vr-edit { grid-area:edit; display: flex; justify-content: flex-end; }
.vr-type { display: none; }
.vr-length { display: none; }
.vr-db { display: none; }
.vr-bench { display: none; }
.vr-edit { display: flex; justify-content: flex-end; }
.edit-audio-btn { width: 38px; height: 34px; border-radius: 7px; padding: 0; background: rgba(37,99,235,.10); border: 1px solid var(--border); color: var(--accent); font-size: 22px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.edit-audio-btn:hover { border-color: var(--accent); background: rgba(37,99,235,.18); filter: none; }
.benchmark-one-btn { width: 26px; height: 26px; border-radius: 6px; padding: 0; font-size: 12px; }
.vr-rating { grid-area:rating; display: flex; gap: 2px; font-size: 18px; justify-content:flex-start; align-items:center; min-height: 31px; }
.vr-rating { display: flex; gap: 2px; font-size: 18px; justify-content: flex-start; align-items: center; min-height: 31px; }
.star { cursor: pointer; color: var(--border); transition: color .1s; }
.star.on { color: var(--yellow); }
.vr-rating:hover .star { color: var(--yellow); }
.vr-rating:hover .star ~ .star { color: var(--border); }
.vr-play { grid-area:play; display:flex; align-items:center; justify-content:flex-end; }
.vr-play-group { display:flex; gap:8px; align-items:center; justify-content:flex-end; }
.vr-play { display: flex; align-items: center; justify-content: flex-start; }
.vr-play-group { display: flex; gap: 8px; align-items: center; justify-content: flex-start; }
.vr-play button { width: 40px; height: 40px; border-radius: 50%; background: var(--accent); color: var(--on-accent); font-size: 13px; display: flex; align-items: center; justify-content: center; padding: 0; }
.vr-play-synth button { background: var(--teal); }
.vr-toggle { display: flex; justify-content: center; }
@ -466,19 +451,19 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.delete-btn:hover { background: rgba(220,38,38,.12); border-color: var(--red); color: var(--red); filter: none; }
.delete-confirm { position: absolute; right: 38px; top: 16px; z-index: 20; display: none; align-items: center; gap: 8px; min-width: 270px; max-width: min(380px, calc(100vw - 80px)); padding: 8px 9px; border: 1px solid rgba(220,38,38,.45); border-radius: 7px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.delete-confirm strong { font-size: 12px; color: var(--red); white-space: nowrap; }
.delete-confirm span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; color:var(--subtext); }
.delete-confirm button { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 12px; white-space:nowrap; }
.delete-confirm span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--subtext); }
.delete-confirm button { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.delete-confirm .delete-confirm-go { background: rgba(220,38,38,.12); border-color: rgba(220,38,38,.45); color: var(--red); }
.vl-row.delete-pending { border-color: rgba(220,38,38,.65); }
.vl-row.delete-pending .delete-confirm { display:flex; }
.vl-row.delete-pending .delete-confirm { display: flex; }
.vl-row.delete-pending .delete-btn { background: rgba(220,38,38,.12); border-color: var(--red); color: var(--red); }
.vr-detail-row { display:grid; grid-template-columns: minmax(320px,1.35fr) minmax(260px,1fr) 126px 42px; padding-left: 10px; align-items:start; gap: 10px; border-top: 1px solid rgba(37,99,235,.12); padding-top: 8px; }
.vr-detail-active { display:flex; flex-direction:column; align-items:flex-end; gap:3px; min-height:31px; }
.vr-detail-active .vr-active-tools { justify-content:flex-end; width:100%; }
.vr-detail-delete { display:flex; align-items:start; justify-content:flex-end; min-height:31px; padding-top:18px; }
.vr-active-tools { display:flex; gap:10px; align-items:center; }
.vr-detail-row { display: none; }
.vr-detail-active { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; min-height: 31px; }
.vr-detail-active .vr-active-tools { justify-content: flex-end; width: 100%; }
.vr-detail-delete { display: flex; align-items: start; justify-content: flex-end; min-height: 31px; padding-top: 18px; }
.vr-active-tools { display: flex; gap: 10px; align-items: center; }
.vr-ref, .vr-note { display: flex; flex-direction: column; align-items: stretch; gap: 2px; }
.vr-ref .vr-inline, .vr-note .vr-inline { display:flex; gap:6px; align-items:center; min-width:0; }
.vr-ref .vr-inline, .vr-note .vr-inline { display: flex; gap: 6px; align-items: center; min-width: 0; }
.vr-ref input { width: 100%; min-width: 0; background: transparent; border: none; border-bottom: 1px solid transparent; color: var(--subtext); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; }
.vr-ref input:focus { border-bottom-color: var(--accent); color: var(--text); }
.vr-ref input::placeholder { color: var(--border); font-style: italic; }
@ -487,8 +472,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vr-note input { width: 100%; background: transparent; border: none; border-bottom: 1px solid var(--border); color: var(--text); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; }
.vr-note input:focus { border-bottom-color: var(--accent); }
.vr-note input::placeholder { color: var(--border); }
.vr-row-subtle { color: var(--subtext); font-size: 11px; font-family: monospace; white-space: nowrap; overflow:hidden; text-overflow:ellipsis; }
.vr-optimizer { grid-column: 1 / -1; display: none; padding: 12px; border-top: 1px solid var(--border); margin-top: 6px; background: rgba(37,99,235,.04); border-radius: 6px; margin-left: 0; margin-top: 10px; width: 100%; box-sizing: border-box; }
.vr-row-subtle { color: var(--subtext); font-size: 11px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-optimizer { grid-column: auto; display: none; padding: 12px; border-top: 1px solid var(--border); margin: 6px 0 0; background: rgba(37,99,235,.04); border-radius: 6px; width: 100%; box-sizing: border-box; }
.vl-row.edit-open .vr-optimizer { display: block; }
.optimizer-grid { display: grid; grid-template-columns: minmax(280px,1.2fr) minmax(280px,1fr); gap: 12px; align-items: start; }
.opt-group { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; }
@ -686,6 +671,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.vl-grid { grid-template-columns: 1fr; }
.getvoices-source-editor, .getvoices-toolbar, .getvoices-summary { grid-template-columns: 1fr; }
.getvoices-source-buttons { flex-direction: row; min-width: 0; }
.design-sample-grid { grid-template-columns: 1fr; }
@ -695,6 +681,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
@media (max-width: 760px) {
#sidebar { display: none; }
#main-content { padding: 0 12px 60px; }
.vl-grid { grid-template-columns: 1fr; }
}
@media (max-width: 1100px) {
.library-control-panel { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }

View File

@ -0,0 +1,47 @@
{
"Ryan": {
"speaker": "Ryan",
"language": "English",
"instruct": ""
},
"Aiden": {
"speaker": "Aiden",
"language": "English",
"instruct": ""
},
"Vivian": {
"speaker": "Vivian",
"language": "Chinese",
"instruct": ""
},
"Serena": {
"speaker": "Serena",
"language": "Chinese",
"instruct": ""
},
"Uncle_Fu": {
"speaker": "Uncle_Fu",
"language": "Chinese",
"instruct": ""
},
"Dylan": {
"speaker": "Dylan",
"language": "Chinese",
"instruct": ""
},
"Eric": {
"speaker": "Eric",
"language": "Chinese",
"instruct": ""
},
"Ono_Anna": {
"speaker": "Ono_Anna",
"language": "Japanese",
"instruct": ""
},
"Sohee": {
"speaker": "Sohee",
"language": "Korean",
"instruct": ""
}
}

View File

@ -0,0 +1,233 @@
"""
OpenAI-compatible TTS server for Qwen3-TTS CustomVoice models.
Voices are defined in customvoice_voices.json as:
{ "voice_id": { "speaker": "Ryan", "language": "English", "instruct": "" } }
The request body may also include "language" and "instruct" fields to override
the configured defaults for a single generation.
"""
import argparse
import asyncio
import json
import logging
import queue
import sys
import threading
from typing import Optional
import numpy as np
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel
sys.path.append("/app")
from faster_qwen3_tts.model import FasterQwen3TTS
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
app = FastAPI()
tts_model: FasterQwen3TTS = None
voices: dict = {}
default_voice: str = None
SAMPLE_RATE = 24000
DEFAULT_MAX_NEW_TOKENS = 2048
_model_lock = threading.Lock()
class SpeechRequest(BaseModel):
model: str = "tts-1"
input: str
voice: str = "Ryan"
response_format: str = "wav"
speed: float = 1.0
language: Optional[str] = None
instruct: Optional[str] = None
max_new_tokens: Optional[int] = None
def _to_pcm16(audio: np.ndarray) -> bytes:
return (audio * 32767).clip(-32768, 32767).astype(np.int16).tobytes()
def _wav_header(sample_rate: int) -> bytes:
import struct
return struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF", 0xFFFFFFFF, b"WAVE",
b"fmt ", 16, 1, 1,
sample_rate, sample_rate * 2, 2, 16,
b"data", 0xFFFFFFFF,
)
def _to_mp3_bytes(audio: np.ndarray, sr: int) -> bytes:
import io
from pydub import AudioSegment
pcm = _to_pcm16(audio)
seg = AudioSegment(pcm, frame_rate=sr, sample_width=2, channels=1)
buf = io.BytesIO()
seg.export(buf, format="mp3")
return buf.getvalue()
def resolve_voice(name: str) -> dict:
cfg = voices.get(name)
if cfg:
return cfg
if default_voice and default_voice in voices:
logger.warning("Voice %r not found, falling back to %r", name, default_voice)
return voices[default_voice]
raise HTTPException(status_code=404, detail=f"Voice {name!r} not found")
def _request_generation_params(req: SpeechRequest, voice_cfg: dict) -> dict:
return {
"text": req.input,
"speaker": voice_cfg.get("speaker") or req.voice,
"language": req.language or voice_cfg.get("language", "Auto"),
"instruct": req.instruct if req.instruct is not None else voice_cfg.get("instruct") or None,
"max_new_tokens": req.max_new_tokens or int(voice_cfg.get("max_new_tokens", DEFAULT_MAX_NEW_TOKENS)),
}
async def _stream_chunks(params: dict):
q: queue.Queue = queue.Queue()
done = object()
def producer():
try:
with _model_lock:
for chunk, _sr, _timing in tts_model.generate_custom_voice_streaming(**params):
q.put(chunk)
except Exception as exc:
q.put(exc)
finally:
q.put(done)
threading.Thread(target=producer, daemon=True).start()
loop = asyncio.get_event_loop()
while True:
item = await loop.run_in_executor(None, q.get)
if item is done:
break
if isinstance(item, Exception):
raise item
yield _to_pcm16(item)
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": tts_model is not None}
@app.post("/v1/audio/speech")
async def create_speech(req: SpeechRequest):
if tts_model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
if not req.input.strip():
raise HTTPException(status_code=400, detail="'input' text is empty")
voice_cfg = resolve_voice(req.voice)
params = _request_generation_params(req, voice_cfg)
fmt = req.response_format.lower()
content_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
if fmt not in content_types:
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
if fmt == "mp3":
loop = asyncio.get_event_loop()
def generate():
with _model_lock:
return tts_model.generate_custom_voice(**params)
audio_arrays, sr = await loop.run_in_executor(None, generate)
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
async def audio_stream():
if fmt == "wav":
yield _wav_header(SAMPLE_RATE)
async for raw in _stream_chunks(params):
yield raw
return StreamingResponse(audio_stream(), media_type=content_types[fmt])
_voice_list = None
_models_response = None
def _build_voice_list():
global _voice_list, _models_response
_voice_list = [{"id": v, "object": "model", "created": 1686935002, "owned_by": "qwen"} for v in voices]
_models_response = {"object": "list", "data": _voice_list}
@app.get("/v1/models")
async def list_models():
return _models_response
@app.get("/v1/audio/voices")
async def list_audio_voices():
return _models_response
@app.get("/v1/audio/models")
async def list_audio_models():
return _models_response
@app.get("/speakers")
async def get_speakers():
return list(voices.keys())
@app.options("/{path:path}")
async def options_handler(path: str):
return JSONResponse(content={"status": "ok"})
def main():
global tts_model, voices, default_voice, SAMPLE_RATE, DEFAULT_MAX_NEW_TOKENS
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="/models/Qwen3-TTS-CustomVoice")
parser.add_argument("--voices", default="/config/customvoice_voices.json")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--device", default="cuda")
parser.add_argument("--max-seq-len", type=int, default=2048)
args = parser.parse_args()
DEFAULT_MAX_NEW_TOKENS = args.max_seq_len
with open(args.voices) as f:
voices = json.load(f)
default_voice = next(iter(voices), None)
_build_voice_list()
import torch
logger.info("Loading CustomVoice model %s ...", args.model)
tts_model = FasterQwen3TTS.from_pretrained(
args.model,
device=args.device,
dtype=torch.bfloat16,
attn_implementation="sdpa",
max_seq_len=args.max_seq_len,
)
SAMPLE_RATE = tts_model.sample_rate
logger.info("Model ready. Sample rate: %d Hz", SAMPLE_RATE)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,244 @@
"""
OpenAI-compatible TTS server for Qwen3-TTS-12Hz-1.7B-VoiceDesign.
Voices are defined in voicedesign_voices.json as:
{ "voice_id": { "instruct": "...", "language": "..." } }
No ref_audio needed the instruct text fully describes the voice.
"""
import json
import logging
import queue
import threading
import asyncio
import argparse
import numpy as np
import sys
from typing import Optional
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response, StreamingResponse, JSONResponse
from pydantic import BaseModel
sys.path.append("/app")
from faster_qwen3_tts.model import FasterQwen3TTS
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
app = FastAPI()
tts_model: FasterQwen3TTS = None
voices: dict = {}
default_voice: str = None
SAMPLE_RATE = 24000
DEFAULT_MAX_NEW_TOKENS = 2048
_model_lock = threading.Lock()
# ---------------------------------------------------------------------------
# Request schema (OpenAI TTS compatible)
# ---------------------------------------------------------------------------
class SpeechRequest(BaseModel):
model: str = "tts-1"
input: str
voice: str = "vd_british_male"
response_format: str = "wav"
speed: float = 1.0
language: Optional[str] = None
instruct: Optional[str] = None
max_new_tokens: Optional[int] = None
# ---------------------------------------------------------------------------
# Audio helpers
# ---------------------------------------------------------------------------
def _to_pcm16(audio: np.ndarray) -> bytes:
return (audio * 32767).clip(-32768, 32767).astype(np.int16).tobytes()
def _wav_header(sample_rate: int) -> bytes:
import struct
return struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF", 0xFFFFFFFF, b"WAVE",
b"fmt ", 16, 1, 1,
sample_rate, sample_rate * 2, 2, 16,
b"data", 0xFFFFFFFF,
)
def _to_mp3_bytes(audio: np.ndarray, sr: int) -> bytes:
from pydub import AudioSegment
import io
pcm = _to_pcm16(audio)
seg = AudioSegment(pcm, frame_rate=sr, sample_width=2, channels=1)
buf = io.BytesIO()
seg.export(buf, format="mp3")
return buf.getvalue()
def resolve_voice(name: str) -> dict:
cfg = voices.get(name)
if cfg:
return cfg
if default_voice and default_voice in voices:
logger.warning("Voice %r not found, falling back to %r", name, default_voice)
return voices[default_voice]
raise HTTPException(status_code=404, detail=f"Voice {name!r} not found")
# ---------------------------------------------------------------------------
# Generation helpers
# ---------------------------------------------------------------------------
def _request_generation_params(req: SpeechRequest, voice_cfg: dict) -> dict:
instruct = req.instruct if req.instruct is not None else voice_cfg.get("instruct", "")
language = req.language or voice_cfg.get("language", "English")
return {
"text": req.input,
"instruct": instruct,
"language": language,
"max_new_tokens": req.max_new_tokens or int(voice_cfg.get("max_new_tokens", DEFAULT_MAX_NEW_TOKENS)),
}
async def _stream_chunks(params: dict):
q: queue.Queue = queue.Queue()
_DONE = object()
def producer():
try:
with _model_lock:
for chunk, _sr, _timing in tts_model.generate_voice_design_streaming(**params):
q.put(chunk)
except Exception as exc:
q.put(exc)
finally:
q.put(_DONE)
threading.Thread(target=producer, daemon=True).start()
loop = asyncio.get_event_loop()
while True:
item = await loop.run_in_executor(None, q.get)
if item is _DONE:
break
if isinstance(item, Exception):
raise item
yield _to_pcm16(item)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": tts_model is not None}
@app.post("/v1/audio/speech")
async def create_speech(req: SpeechRequest):
if tts_model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
if not req.input.strip():
raise HTTPException(status_code=400, detail="'input' text is empty")
voice_cfg = resolve_voice(req.voice)
params = _request_generation_params(req, voice_cfg)
fmt = req.response_format.lower()
_CONTENT_TYPES = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
if fmt not in _CONTENT_TYPES:
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
if fmt == "mp3":
loop = asyncio.get_event_loop()
def _gen():
with _model_lock:
return tts_model.generate_voice_design(**params)
audio_arrays, sr = await loop.run_in_executor(None, _gen)
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
async def audio_stream():
if fmt == "wav":
yield _wav_header(SAMPLE_RATE)
async for raw in _stream_chunks(params):
yield raw
return StreamingResponse(audio_stream(), media_type=_CONTENT_TYPES[fmt])
_voice_list = None
_models_response = None
def _build_voice_list():
global _voice_list, _models_response
_voice_list = [{"id": v, "object": "model", "created": 1686935002, "owned_by": "qwen"} for v in voices]
_models_response = {"object": "list", "data": _voice_list}
@app.get("/v1/models")
async def list_models():
return _models_response
@app.get("/v1/audio/voices")
async def list_audio_voices():
return _models_response
@app.get("/v1/audio/models")
async def list_audio_models():
return _models_response
@app.get("/speakers")
async def get_speakers():
return list(voices.keys())
@app.options("/{path:path}")
async def options_handler(path: str):
return JSONResponse(content={"status": "ok"})
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
global tts_model, voices, default_voice, SAMPLE_RATE, DEFAULT_MAX_NEW_TOKENS
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="/models/Qwen3-TTS-VoiceDesign")
parser.add_argument("--voices", default="/config/voicedesign_voices.json")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--device", default="cuda")
parser.add_argument("--max-seq-len", type=int, default=2048)
args = parser.parse_args()
DEFAULT_MAX_NEW_TOKENS = args.max_seq_len
with open(args.voices) as f:
voices = json.load(f)
default_voice = next(iter(voices), None)
_build_voice_list()
import torch
logger.info("Loading VoiceDesign model %s", args.model)
tts_model = FasterQwen3TTS.from_pretrained(
args.model,
device=args.device,
dtype=torch.bfloat16,
attn_implementation="sdpa",
max_seq_len=args.max_seq_len,
)
SAMPLE_RATE = tts_model.sample_rate
logger.info("Model ready. Sample rate: %d Hz", SAMPLE_RATE)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()