diff --git a/claude studio/Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling page.html b/claude studio/Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling page.html
new file mode 100644
index 0000000..0e30a9b
--- /dev/null
+++ b/claude studio/Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling page.html
@@ -0,0 +1,772 @@
+
Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling pageMy voices
14 voices, ready to speak.
Listen, tweak, route, or send to your favourite TTS apps. Use the left rail to jump to a section.
Hidden voices stay in the library but the TTS server doesn't see them.
\ No newline at end of file
diff --git a/claude studio/Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling page.png b/claude studio/Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling page.png
new file mode 100644
index 0000000..0c2efd9
Binary files /dev/null and b/claude studio/Studio Notebook _ V1 sibling _ collapsible sub-nav _ single scrolling page.png differ
diff --git a/claude studio/TTS-Voice-Creator-05-25-2026_08_14_PM.jpg b/claude studio/TTS-Voice-Creator-05-25-2026_08_14_PM.jpg
new file mode 100644
index 0000000..b01b7b0
Binary files /dev/null and b/claude studio/TTS-Voice-Creator-05-25-2026_08_14_PM.jpg differ
diff --git a/docker-compose.yml b/docker-compose.yml
index fa5ac86..7b2192d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/docker/qwen3-tts-streaming/Dockerfile b/docker/qwen3-tts-streaming/Dockerfile
new file mode 100644
index 0000000..b880f9a
--- /dev/null
+++ b/docker/qwen3-tts-streaming/Dockerfile
@@ -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}"]
diff --git a/docs/forum-playbook.md b/docs/forum-playbook.md
new file mode 100644
index 0000000..0610513
--- /dev/null
+++ b/docs/forum-playbook.md
@@ -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.
diff --git a/docs/tutorial-video-script.md b/docs/tutorial-video-script.md
new file mode 100644
index 0000000..3d35dfb
--- /dev/null
+++ b/docs/tutorial-video-script.md
@@ -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
diff --git a/static/style.css b/static/style.css
index c894e5c..377714d 100644
--- a/static/style.css
+++ b/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)); }
diff --git a/support/faster-qwen3-tts-config/customvoice_voices.json b/support/faster-qwen3-tts-config/customvoice_voices.json
new file mode 100644
index 0000000..8e5ddc5
--- /dev/null
+++ b/support/faster-qwen3-tts-config/customvoice_voices.json
@@ -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": ""
+ }
+}
diff --git a/support/faster-qwen3-tts-config/run_customvoice_server.py b/support/faster-qwen3-tts-config/run_customvoice_server.py
new file mode 100644
index 0000000..1d5d5f5
--- /dev/null
+++ b/support/faster-qwen3-tts-config/run_customvoice_server.py
@@ -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()
diff --git a/support/faster-qwen3-tts-config/run_voicedesign_server.py b/support/faster-qwen3-tts-config/run_voicedesign_server.py
new file mode 100644
index 0000000..47e98f2
--- /dev/null
+++ b/support/faster-qwen3-tts-config/run_voicedesign_server.py
@@ -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()