Update voice library UI: convert table layout to card grid, change network to dgx_net
This commit is contained in:
parent
8f06971aaf
commit
6583a9fc80
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 738 KiB |
BIN
claude studio/TTS-Voice-Creator-05-25-2026_08_14_PM.jpg
Normal file
BIN
claude studio/TTS-Voice-Creator-05-25-2026_08_14_PM.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@ -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
|
||||
|
||||
10
docker/qwen3-tts-streaming/Dockerfile
Normal file
10
docker/qwen3-tts-streaming/Dockerfile
Normal 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
217
docs/forum-playbook.md
Normal 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.
|
||||
235
docs/tutorial-video-script.md
Normal file
235
docs/tutorial-video-script.md
Normal 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
|
||||
109
static/style.css
109
static/style.css
@ -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)); }
|
||||
|
||||
47
support/faster-qwen3-tts-config/customvoice_voices.json
Normal file
47
support/faster-qwen3-tts-config/customvoice_voices.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
233
support/faster-qwen3-tts-config/run_customvoice_server.py
Normal file
233
support/faster-qwen3-tts-config/run_customvoice_server.py
Normal 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()
|
||||
244
support/faster-qwen3-tts-config/run_voicedesign_server.py
Normal file
244
support/faster-qwen3-tts-config/run_voicedesign_server.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user