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, <break>-Pausen, Musik, Ausgabe als WAV/G.722/MP3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
097c9e7141
commit
29d0dc0637
3 files changed
+415
No files matched your search
@@ -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 -> <break time="500ms"/> bzw. <break time="1s"/> 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/<name>.wav ablegen,
|
||||
// exaktes Transkript als voice_clone/<name>.txt daneben -> erscheint in der Liste.
|
||||
//
|
||||
// Ohne Rueckfragen (z. B. in Skripten, Text dann per stdin):
|
||||
// QWEN_TTS_VOICE=<name oder nummer> QWEN_TTS_OUT=<datei>
|
||||
// QWEN_TTS_REF_AUDIO=<aufnahme> [QWEN_TTS_REF_TEXT=<transkript.txt>] (Klon ausserhalb voice_clone/)
|
||||
// QWEN_TTS_INSTRUCT=<Stilanweisung, nur feste Stimmen> QWEN_TTS_URL=<api>
|
||||
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 <break time="1s"/> Welt" -> [{text:"Hallo"}, {pauseMs:1000}, {text:"Welt"}]
|
||||
function parseInput(input) {
|
||||
const parts = [];
|
||||
const re = /<break\s+time\s*=\s*["']?([\d.]+)\s*(ms|s)["']?\s*\/?>/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 (<break time=\"500ms\"/> 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);
|
||||
})();
|
||||
Reference in new issue
Block a user