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"