From 29d0dc06378a005395456f900af2c78a66ba33b4 Mon Sep 17 00:00:00 2001 From: Renato Pfeiler Date: Fri, 23 Oct 2026 15:52:46 +0200 Subject: [PATCH] Qwen3-TTS auf der DGX: Startskript, Deploy-Config, Node-Client - dgx/start-qwen3-tts.sh startet CustomVoice (:8011) oder Base/Klon (:8012) via vllm-omni im Image vllm-node-030-omni - dgx/qwen3_tts_spark.yaml: V1-Runner (MRV2 scheitert am vLLM-dev-Build) und 0.08/0.06 Speicher statt 2x 0.3 - SpeechSynthesis_Qwen.js als Ersatz fuer das Azure-Skript: Stimmenauswahl inkl. Klonstimmen, -Pausen, Musik, Ausgabe als WAV/G.722/MP3 Co-Authored-By: Claude Opus 5.5 --- SpeechSynthesis_Qwen.js | 218 +++++++++++++++++++++++++++++++++++++++ dgx/qwen3_tts_spark.yaml | 163 +++++++++++++++++++++++++++++ dgx/start-qwen3-tts.sh | 34 ++++++ 3 files changed, 415 insertions(+) create mode 100644 SpeechSynthesis_Qwen.js create mode 100644 dgx/qwen3_tts_spark.yaml create mode 100755 dgx/start-qwen3-tts.sh diff --git a/SpeechSynthesis_Qwen.js b/SpeechSynthesis_Qwen.js new file mode 100644 index 0000000..2e5a5fb --- /dev/null +++ b/SpeechSynthesis_Qwen.js @@ -0,0 +1,218 @@ +(async function () { + "use strict"; + // Qwen3-TTS (vllm-omni auf der DGX) statt Azure. Gleicher Ablauf wie + // SpeechSynthesis_SSML.js, fragt aber nacheinander ab: + // 1. Stimme -> Klonstimmen aus voice_clone/ oder feste Qwen-Stimmen + // 2. Text -> bzw. fuer Pausen + // (Qwen3-TTS kann kein SSML, andere Tags werden entfernt) + // 3. Datei -> Endung bestimmt das Format (Enter = tts_asterisk.wav): + // .wav WAV 8 kHz mono (Asterisk) + // .g722 G.722 16 kHz roh (Asterisk) + // .mp3 MP3 (WhatsApp, E-Mail) + // + // Aufruf (Musik wie bisher): + // node SpeechSynthesis_Qwen.js -> background.wav, falls vorhanden + // node SpeechSynthesis_Qwen.js meine-musik.mp3 -> diese Musik + // node SpeechSynthesis_Qwen.js --no-music -> ohne Musik (auch: none, -n) + // + // Neue Klonstimme: Aufnahme (max. 30 s) als voice_clone/.wav ablegen, + // exaktes Transkript als voice_clone/.txt daneben -> erscheint in der Liste. + // + // Ohne Rueckfragen (z. B. in Skripten, Text dann per stdin): + // QWEN_TTS_VOICE= QWEN_TTS_OUT= + // QWEN_TTS_REF_AUDIO= [QWEN_TTS_REF_TEXT=] (Klon ausserhalb voice_clone/) + // QWEN_TTS_INSTRUCT= QWEN_TTS_URL= + const readline = require("readline"); + const { execFile } = require("child_process"); + const fs = require("fs"); + const path = require("path"); + + const host = "http://10.146.97.103"; + const sampleRate = 24000; + const rawFile = "tts_raw.wav"; + + const arg = process.argv[2]; + const noMusic = ["--no-music", "none", "-n"].includes(arg); + const musicFile = noMusic ? null : (arg || "background.wav"); + const musicVolume = 0.15; + const introMs = 2000; // Musik-Vorlauf vor der Sprache + + // Klonstimmen: Audiodatei mit gleichnamiger .txt in voice_clone/ ("alex_ref.wav" -> "alex") + const cloneDir = path.join(__dirname, "voice_clone"); + const txtFor = (f) => f.replace(/\.[^./]+$/, "") + ".txt"; + const clones = (fs.existsSync(cloneDir) ? fs.readdirSync(cloneDir) : []) + .filter((f) => /\.(wav|mp3|m4a|ogg|flac)$/i.test(f) && fs.existsSync(path.join(cloneDir, txtFor(f)))) + .map((f) => ({ name: f.replace(/(_ref)?\.[^.]+$/, ""), file: path.join(cloneDir, f) })); + // Feste Stimmen des CustomVoice-Modells (per QWEN_TTS_VOICE auch: ono_anna, sohee, eric, dylan) + const builtins = ["serena", "vivian", "ryan", "aiden", "uncle_fu"]; + const voices = [...clones, ...builtins.map((name) => ({ name }))]; + + function outArgs(file) { + if (/\.g722$/i.test(file)) return ["-ac", "1", "-ar", "16000", "-c:a", "g722", "-f", "g722", file]; + if (/\.mp3$/i.test(file)) return ["-ac", "1", "-ar", "44100", "-c:a", "libmp3lame", "-b:a", "96k", file]; + return ["-acodec", "pcm_s16le", "-ar", "8000", "-ac", "1", file]; + } + + function ffmpeg(args) { + return new Promise((resolve, reject) => { + execFile("ffmpeg", ["-y", "-loglevel", "error", ...args], (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(); + }); + }); + } + + // "Hallo Welt" -> [{text:"Hallo"}, {pauseMs:1000}, {text:"Welt"}] + function parseInput(input) { + const parts = []; + const re = //gi; + let last = 0, m; + const pushText = (t) => { + t = t.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + if (t) parts.push({ text: t }); + }; + while ((m = re.exec(input)) !== null) { + pushText(input.slice(last, m.index)); + parts.push({ pauseMs: Math.round(parseFloat(m[1]) * (m[2].toLowerCase() === "s" ? 1000 : 1)) }); + last = re.lastIndex; + } + pushText(input.slice(last)); + return parts; + } + + // Aufnahme (beliebiges Format) -> 24 kHz mono WAV als data-URL fuer ref_audio + async function loadRefAudio(file) { + const refWav = "tts_ref.wav"; + await ffmpeg(["-i", file, "-ac", "1", "-ar", String(sampleRate), refWav]); + const dataUrl = "data:audio/wav;base64," + fs.readFileSync(refWav).toString("base64"); + fs.unlinkSync(refWav); + return dataUrl; + } + + // Zeilenweise lesen statt rl.question: funktioniert auch mit per Pipe uebergebenem Text + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const lines = rl[Symbol.asyncIterator](); + async function ask(question) { + process.stdout.write(question); + const { value, done } = await lines.next(); + return done ? "" : value.trim(); + } + + // 1. Stimme + let voice; + if (process.env.QWEN_TTS_REF_AUDIO) { + voice = { name: path.basename(process.env.QWEN_TTS_REF_AUDIO), file: process.env.QWEN_TTS_REF_AUDIO }; + } else { + let choice = process.env.QWEN_TTS_VOICE; + if (!choice) { + console.log("Stimmen:"); + voices.forEach((v, i) => console.log(` ${i + 1}) ${v.name}${v.file ? "" : " (feste Qwen-Stimme)"}`)); + choice = (await ask("Stimme [1]: ")) || "1"; + } + const n = Number(choice); + voice = Number.isInteger(n) ? voices[n - 1] + : voices.find((v) => v.name === choice.toLowerCase()) || { name: choice.toLowerCase() }; + if (!voice) { + console.error(`Ungueltige Auswahl: ${choice}`); + rl.close(); + return; + } + } + const refTextFile = voice.file && (process.env.QWEN_TTS_REF_TEXT || + [txtFor(voice.file), path.join(path.dirname(voice.file), "referenztext.txt")].find((f) => fs.existsSync(f))); + const apiUrl = process.env.QWEN_TTS_URL || `${host}:${voice.file ? 8012 : 8011}/v1/audio/speech`; + const instructions = process.env.QWEN_TTS_INSTRUCT || ""; + + // 2. Text, 3. Datei + const userInput = await ask("Text ( fuer Pausen):\n> "); + const parts = parseInput(userInput); + if (!parts.some((p) => p.text)) { + console.error("Kein Text eingegeben."); + rl.close(); + return; + } + const outFile = process.env.QWEN_TTS_OUT || + (await ask("Datei [tts_asterisk.wav] (.wav / .g722 / .mp3): ")) || "tts_asterisk.wav"; + rl.close(); + + let clone = null; // { audio, text } im Klon-Modus + + async function synthesize(text, file) { + const body = { input: text, language: "German", response_format: "wav", sample_rate: sampleRate }; + if (clone) { + // Base-Modell kennt keine instructions + Object.assign(body, { task_type: "Base", ref_audio: clone.audio, ref_text: clone.text }); + } else { + body.voice = voice.name; + if (instructions) body.instructions = instructions; + } + const res = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + fs.writeFileSync(file, Buffer.from(await res.arrayBuffer())); + } + + const started = Date.now(); + console.log(`Synthesizing (Qwen3-TTS, ${voice.file ? `Klon: ${voice.name}` : `Stimme: ${voice.name}`})...`); + const segFiles = []; + const ffArgs = []; + const labels = []; + try { + if (voice.file) { + if (!refTextFile) throw new Error(`Kein Transkript zu ${voice.file} gefunden (.txt daneben oder QWEN_TTS_REF_TEXT)`); + clone = { + audio: await loadRefAudio(voice.file), + text: fs.readFileSync(refTextFile, "utf8").trim() + }; + } + for (const p of parts) { + if (p.text) { + const f = `tts_seg_${segFiles.length}.wav`; + await synthesize(p.text, f); + segFiles.push(f); + ffArgs.push("-i", f); + } else { + ffArgs.push("-f", "lavfi", "-t", String(p.pauseMs / 1000), + "-i", `anullsrc=r=${sampleRate}:cl=mono`); + } + labels.push(`[${labels.length}:a]`); + } + await ffmpeg([...ffArgs, "-filter_complex", + `${labels.join("")}concat=n=${labels.length}:v=0:a=1`, rawFile]); + } catch (err) { + console.error("TTS failed:", err.message); + return; + } finally { + segFiles.forEach((f) => fs.existsSync(f) && fs.unlinkSync(f)); + } + + const hasMusic = !noMusic && fs.existsSync(musicFile); + if (!noMusic && !hasMusic) { + console.warn(`Hinweis: "${musicFile}" nicht gefunden -> ohne Musik.`); + } + console.log( + "Qwen3-TTS done → converting" + + (hasMusic ? ` (mit Musik: ${musicFile})...` : " (ohne Musik)...") + ); + + try { + if (hasMusic) { + await ffmpeg(["-i", rawFile, "-stream_loop", "-1", "-i", musicFile, "-filter_complex", + `[0:a]adelay=${introMs}:all=1[sp];` + + `[1:a]volume=${musicVolume}[bg];` + + `[sp][bg]amix=inputs=2:duration=first:dropout_transition=0,` + + `areverse,afade=t=in:d=2,areverse`, + ...outArgs(outFile)]); + } else { + await ffmpeg(["-i", rawFile, ...outArgs(outFile)]); + } + } catch (err) { + console.error("FFmpeg error:", err.message); + return; + } + console.log(`Fertig: ${outFile} (${((Date.now() - started) / 1000).toFixed(1)} s)`); + fs.unlinkSync(rawFile); +})(); diff --git a/dgx/qwen3_tts_spark.yaml b/dgx/qwen3_tts_spark.yaml new file mode 100644 index 0000000..8648287 --- /dev/null +++ b/dgx/qwen3_tts_spark.yaml @@ -0,0 +1,163 @@ +# DGX-Spark-Variante von vllm_omni/deploy/qwen3_tts.yaml (vllm-omni 0.30.0): +# * model_runner v1 statt v2 -- MRV2 importiert RoutedExpertsTensors, das im +# vLLM-dev-Build des Images (vllm-node-030-omni) fehlt -> ImportError. +# * gpu_memory_utilization 0.08 / 0.06 statt 2x 0.3 (Unified Memory ist geteilt). +# * max_num_seqs 8 statt 64. +# +# Qwen3-TTS deploy: talker → code2wav via shared-memory chunk streaming. +# Verified on 1x H100. +# +# Runner selection: +# Model Runner V2 (MRV2) is EXPERIMENTAL and is the default runner on CUDA +# for this model. Set `model_runner: v1` to opt out. The `platforms:` +# sections at the end of this file keep V1 on NPU, XPU, ROCm and MUSA. +# See docs/configuration/stage_configs.md ("Qwen3-TTS with Model Runner V2"). +# +# Fields omitted from a stage fall back to StageDeployConfig defaults (see +# vllm_omni/config/stage_config.py). Notable choices for this model: +# * Stage 0 (talker) sets max_num_batched_tokens=512 for async-chunk +# latency tuning (not correctness) — small per-step batches keep +# first-chunk latency low, and the MRV2 profiles rely on that bound. +# * Stage 1 (code2wav) sets max_num_batched_tokens=65536 for correctness: +# codec prefill length (Q * num_frames) exceeds the 32k default. +# * Stage 0 does not set enforce_eager — talker runs cudagraph by default. +# Stage 1 defaults to false so Code2Wav can use its inner cudagraph; +# set true to disable that path in eager mode. +model_runner: v1 +async_chunk: true + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + # Must match the decoder sliding attention window. + codec_chunk_frames: 25 + codec_left_context_frames: 72 + # Emit only the first audio chunk early, then return to codec_chunk_frames. + initial_codec_chunk_frames: 1 + # Chunk ramp (opt-in): gradual size increase for first N chunks to + # eliminate the IC→steady cliff (1→25). Uncomment to enable. + # Each entry is the size of that chunk index; indices past the table + # fall back to codec_chunk_frames. When enabled, ramp overrides + # initial_codec_chunk_frames (including the per-request API field). + # Example: + # codec_chunk_ramp: [4, 4, 8, 16, 25] + # Adaptive chunk ramp (opt-in, takes precedence over fixed codec_chunk_ramp): + # Chunk 0 uses dynamic IC; chunk 1+ sizes from live buffer-depth feedback. + # Self-tunes across hardware (NPU/NVIDIA) and load conditions. + # codec_chunk_adaptive: true + # codec_chunk_min_frames: 2 + # Code2Wav decode + transfer margin + # codec_chunk_safety_margin_ms: 50 + # EWMA->delta divisor (delta=max(delta_min, int(ewma/divisor))); higher=gentler ramp + # codec_chunk_ramp_divisor: 15 + # min chunk-size growth per emit + # codec_chunk_ramp_delta_min: 2 + # Decoder CUDA Graph sizing: + # * async_chunk=true derives all stateful ICL/xvec shapes from + # codec_chunk_ramp when enabled, otherwise from + # initial_codec_chunk_frames, codec_chunk_frames, and sliding_window; + # decode_cudagraph_capture_sizes is ignored in this mode. + # * async_chunk=false captures stateless shapes 150 and + # decode_chunk_frames + decode_left_context_frames (325 by default). + # Extra stateless shapes may be added (union, not intersection) with: + # decode_cudagraph_capture_sizes: [97, 400] + # Graph batch size defaults to 1. Add larger buckets only when needed: + # decode_cudagraph_batch_sizes: [1, 2, 4] + # Limit eager batches to reduce temporary stateful cache allocations; CUDA Graph batching uses the buckets above. + decode_batch_max_size: 4 + +stages: + - stage_id: 0 + max_num_seqs: 8 + gpu_memory_utilization: 0.08 + trust_remote_code: true + # Omni prefix cache (reuse of talker codec rows on shared prefixes). Off + # by default; set true when text repeats across requests (same voice or + # shared ref_audio). The talker opts out of hidden-state caching and + # defers codes.audio writes, so per-step cost is small. Stage 1 (codec + # decoder) is not a supported prefix-cache stage: keep it false there. + # See docs/design/feature/prefix_caching.md for the stage matrix. + enable_prefix_caching: false + async_scheduling: true + # Bound per-step prefill work (validated MRV2 basic profile). Small + # per-step batches keep first-chunk latency low. + max_num_batched_tokens: 512 + max_model_len: 4096 + devices: "0" + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.9 + top_k: 50 + max_tokens: 4096 + # Official qwen-tts generates the talker with min_new_tokens=2; without + # it, checkpoints that sample codec EOS on the first frame return empty + # audio (HTTP 400). See #4962. + min_tokens: 2 + repetition_penalty: 1.05 + # Suppress codebook-0 silence tokens for the first N decode frames of a + # request (#4966). Applies to every request on this stage, so intended + # for x-vector-only (Base voice-clone) deploys. 0 = off. + silence_ban_frames: 0 + subtalker_sampling_params: + do_sample: true + temperature: 0.9 + top_k: 50 + top_p: 1.0 + + - stage_id: 1 + # Decoder compute dtype on GPU/NPU; 310P requires FP16 and overrides this. + dtype: bfloat16 + # Tuned for Base voice clone; CustomVoice / VoiceDesign are TTFA-optimal at 1. + max_num_seqs: 8 + gpu_memory_utilization: 0.06 + enforce_eager: false + trust_remote_code: true + enable_prefix_caching: false + async_scheduling: true + # Must be divisible by num_code_groups and cover (left_context + chunk). + # Prefill length is Q * num_frames (e.g. 16 * 2148 = 34368); keep + # headroom past 32k. + max_num_batched_tokens: 65536 + # async_chunk appends windows per step; max_model_len must cover the + # accumulated flat codec stream. + max_model_len: 65536 + devices: "0" + input_connectors: + from_stage_0: connector_of_shared_memory + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + repetition_penalty: 1.0 + +platforms: + rocm: + # MRV2 is not qualified on ROCm; keep the legacy V1 runner. + model_runner: v1 + stages: + - stage_id: 1 + # MIOpen Conv1d cannot be invoked while vLLM captures the outer + # Code2Wav model graph. Keep CUDA's graph-enabled default, but run + # Stage 1 eager on ROCm so decoder convolutions execute normally. + enforce_eager: true + npu: + model_runner: v1 + stages: + - stage_id: 0 + enforce_eager: false + compilation_config: + cudagraph_mode: PIECEWISE + - stage_id: 1 + compilation_config: + cudagraph_mode: PIECEWISE + xpu: + model_runner: v1 + musa: + model_runner: v1 diff --git a/dgx/start-qwen3-tts.sh b/dgx/start-qwen3-tts.sh new file mode 100755 index 0000000..44f4f32 --- /dev/null +++ b/dgx/start-qwen3-tts.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Qwen3-TTS 1.7B auf der DGX Spark (admin@10.146.97.103) via vllm-omni. +# OpenAI-kompatibel: POST http://10.146.97.103:/v1/audio/speech +# +# ./start-qwen3-tts.sh -> CustomVoice (9 feste Stimmen) :8011 +# ./start-qwen3-tts.sh base -> Base (Voice-Cloning, ref_audio) :8012 +# +# Liegt auf der DGX unter ~/qwen3-tts/ zusammen mit qwen3_tts_spark.yaml +# (eigene Deploy-Config: V1-Runner + 0.08/0.06 Speicher, siehe Kopf der YAML). +set -eu +CFG_DIR=/home/admin/qwen3-tts +IMAGE=vllm-node-030-omni # vllm-omni 0.30.0 + librosa/soundfile, bringt qwen3_tts mit + +case "${1:-customvoice}" in + customvoice) NAME=qwen3-tts-customvoice; PORT=8011; MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice ;; + base) NAME=qwen3-tts-base; PORT=8012; MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-Base ;; + *) echo "Aufruf: $0 [customvoice|base]" >&2; exit 1 ;; +esac + +if docker ps --format '{{.Names}}' | grep -qx "$NAME"; then + echo "$NAME laeuft bereits"; exit 0 +fi +docker rm "$NAME" >/dev/null 2>&1 || true + +docker run -d --name "$NAME" --network host --ipc host --gpus all \ + -v /home/admin/.cache/huggingface:/root/.cache/huggingface \ + -v "$CFG_DIR":/cfg:ro \ + -e VLLM_DISABLE_COMPILE_CACHE=1 \ + -e HF_HUB_OFFLINE=1 \ + "$IMAGE" \ + vllm-omni serve "$MODEL" --omni --port "$PORT" \ + --deploy-config /cfg/qwen3_tts_spark.yaml + +echo "gestartet: docker logs -f $NAME"