/** tts_bridge.js — Persistent Node.js bridge for aquestalk.js TTS synthesis. Converts English words to katakana via vendored english-to-kana dictionary (english-kana-matcher.js, 49K words, MIT). Out-of-dictionary words fall back to letter-by-letter romanized spelling so no word is ever skipped. English punctuation (,.!?) is converted to Japanese pauses (、。). */ import { readFileSync, writeFileSync } from "fs"; import { createInterface } from "readline"; import { fileURLToPath, pathToFileURL } from "url"; import { dirname, join } from "path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ── vendored english-to-kana dictionary (auto-generated, MIT) ────────── const matcherMod = pathToFileURL(join(__dirname, "english-kana-matcher.js")).href; const { lookupKana } = await import(matcherMod); // ── aquestalk.js ────────────────────────────────────────────────────── const aquestalkMod = pathToFileURL(join(__dirname, "aquestalk.js", "dist", "index.js")).href; const { load } = await import(aquestalkMod); const args = process.argv.slice(2); let voice = "f1"; let speed = 100; for (let i = 0; i < args.length; i++) { if (args[i] === "--voice" && i + 1 < args.length) { voice = args[++i]; } else if (args[i] === "--speed" && i + 1 < args.length) { speed = parseInt(args[++i], 10); } } console.error(`[bridge] Loading aquestalk.js with voice="${voice}", speed=${speed}...`); let aq; try { aq = await load(voice); console.error("[bridge] aquestalk.js loaded successfully."); } catch (err) { console.error("[bridge] Failed to load aquestalk.js:", err.message); process.stdout.write("ERR:FAILED_TO_LOAD\n"); process.exit(1); } // Letter-by-letter fallback readings (AquesTalk1-safe, no ヴ) const LETTER_KANA = { a: "エー", b: "ビー", c: "シー", d: "ディー", e: "イー", f: "エフ", g: "ジー", h: "エイチ", i: "アイ", j: "ジェー", k: "ケー", l: "エル", m: "エム", n: "エヌ", o: "オー", p: "ピー", q: "キュー", r: "アール", s: "エス", t: "ティー", u: "ユー", v: "ブイ", w: "ダブリュー", x: "エックス", y: "ワイ", z: "ゼット", }; const DIGIT_KANA = { "0": "ゼロ", "1": "ワン", "2": "ツー", "3": "スリー", "4": "フォー", "5": "ファイブ", "6": "シックス", "7": "セブン", "8": "エイト", "9": "ナイン", }; // English punctuation → Japanese pause equivalents const PUNCT_MAP = { ",": "、", ".": "。", "!": "!", "?": "?", ",": "、", "。": "。", "!": "!", "?": "?", }; function kanaForChar(ch) { return LETTER_KANA[ch.toLowerCase()] || DIGIT_KANA[ch] || null; } function wordToKana(word) { const clean = word.replace(/'/g, "").toLowerCase(); const hit = lookupKana(clean); if (hit) return hit; // OOV fallback: spell out letter by letter (never skip) let out = ""; for (const ch of clean) { const k = kanaForChar(ch); out += k ? k : ""; } return out; } function convertEnglishSegment(text) { // Protect decimal points between digits (3.5) from being treated as periods const protectedText = text.replace(/(\d)\.(\d)/g, "$1\u30FB$2"); const words = protectedText.split(/([\s]+|[.,!?,。!?]+)/).filter((s) => s.length > 0); const parts = []; for (const token of words) { if (/^\s+$/.test(token)) { parts.push(" "); // keep word gap for AquesTalk pause continue; } if (PUNCT_MAP[token] !== undefined) { parts.push(PUNCT_MAP[token]); continue; } parts.push(wordToKana(token)); } return parts.join("").replace(/ +/g, " "); } function synthesize(kanaText) { const trimmed = kanaText.trim(); if (!trimmed) throw new Error("EMPTY_TEXT"); let result = ""; let englishBuf = ""; for (const ch of trimmed) { if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") || ch === "'" || ch === "," || ch === "." || ch === "!" || ch === "?") { englishBuf += ch; } else { if (englishBuf) { result += convertEnglishSegment(englishBuf); englishBuf = ""; } // Full-width punctuation that slipped through (e.g. ,) → Japanese pause result += PUNCT_MAP[ch] !== undefined ? PUNCT_MAP[ch] : ch; } } if (englishBuf) { result += convertEnglishSegment(englishBuf); } const wav = aq.run(result, speed); return Buffer.from(wav); } process.stdout.write("READY\n"); const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false, }); rl.on("line", (line) => { line = line.trim(); if (!line) return; const sepIdx = line.indexOf("|"); if (sepIdx === -1) { process.stdout.write(`ERR:INVALID_FORMAT:${line}\n`); return; } const inputPath = line.substring(0, sepIdx); const outputPath = line.substring(sepIdx + 1); try { const kanaText = readFileSync(inputPath, "utf-8").trim(); if (!kanaText) { process.stdout.write(`ERR:EMPTY_TEXT\n`); return; } const wav = synthesize(kanaText); writeFileSync(outputPath, wav); process.stdout.write(`OK:${outputPath}\n`); } catch (err) { process.stdout.write(`ERR:${err.message}\n`); } }); rl.on("close", () => { console.error("[bridge] stdin closed, shutting down."); aq.destroy().then(() => process.exit(0)); });