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 page
My 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.
01

All voices

A
Anna
EN_F_Anna
M
Max
DE_M_Max
C
Calvin
EN_M_Calvin
M
Mei
ZH_F_Mei
B
Brooke
EN_F_Brooke
L
Luc
FR_M_Luc
A
Atlas
EN_M_Atlas
L
Lucía
ES_F_Lucia
02

Cloned voices

A
Anna
🇬🇧 EN · 12.4s
M
Max
🇩🇪 DE · 9.8s
M
Mei
🇨🇳 ZH · 8.2s
B
Brooke
🇬🇧 EN · 14.0s
A
Atlas
🇬🇧 EN · 11.6s
L
Lucía
🇪🇸 ES · 10.1s
03

Designed voices

C
Calvin
vd_Calvin
L
Luc
vd_Luc
S
Sage
vd_Sage
M
Marco
vd_Marco
P
Pace
vd_Pace
04

Favorites · 5★

A
Anna
★★★★★
C
Calvin
★★★★★
B
Brooke
★★★★★
A
Atlas
★★★★★
D
Drew
★★★★★
05

Hidden voices

Hidden voices stay in the library but the TTS server doesn't see them.
H
Hina
hidden_voices/
D
Drew
hidden_voices/
M
Marco
hidden_voices/
I
Iris
hidden_voices/
P
Pace
hidden_voices/
06

Library tools

Sample sentence (for preview & benchmark)
Row play button
\ 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()