(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); })();