Files
bililive-touhou-tts/tts_bridge.js
T
chun_qiu 6ae7249583 fix: collapse AquesTalk1-unsupported punctuation (!「」・ ) to 、 pause
Probe results: AquesTalk1 rejects U+FF01 !, U+300C/D 「」, U+30FB ・,
U+3000 full-width space with error 105; only 、。?〜ー are safe.
User templates containing ! (e.g. 卧槽!、是{uname}!...) now render
correctly: synthesize() final pass replaces any char outside the safe
kana/punctuation set with a 、 pause.

Verified: custom template full pipeline -> WAV 103316 bytes, no 105.
Rebuild portable zip (80.8 MB)
2026-08-08 16:53:54 +08:00

327 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** tts_bridge.js — Persistent Node.js bridge for aquestalk.js TTS synthesis.
English word → katakana pipeline (never skips):
1. english-to-kana dictionary (49K words, MIT, vendored english-kana-matcher.js)
2. phonemize (MIT) G2P → IPA → rule-based katakana transcription
3. letter-by-letter romanized spelling (last resort)
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";
import { createRequire } from "module";
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);
// ── phonemize (G2P, MIT) via CJS entry (avoids JSON import issue in Node ESM) ──
const require = createRequire(import.meta.url);
let phonemize = null;
try {
phonemize = require("phonemize").phonemize;
console.error("[bridge] phonemize G2P loaded.");
} catch (err) {
console.error(`[bridge] phonemize unavailable (${err.message}), using letter fallback only.`);
}
// ── 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);
}
// ── IPA → Katakana transcription (rule-based) ─────────────────────────
const KANA = {
k: { a: "カ", i: "キ", u: "ク", e: "ケ", o: "コ" },
g: { a: "ガ", i: "ギ", u: "グ", e: "ゲ", o: "ゴ" },
s: { a: "サ", i: "シ", u: "ス", e: "セ", o: "ソ" },
z: { a: "ザ", i: "ジ", u: "ズ", e: "ゼ", o: "ゾ" },
t: { a: "タ", i: "チ", u: "トゥ", e: "テ", o: "ト" },
d: { a: "ダ", i: "ジ", u: "ドゥ", e: "デ", o: "ド" },
n: { a: "ナ", i: "ニ", u: "ヌ", e: "ネ", o: "" },
h: { a: "ハ", i: "ヒ", u: "フ", e: "ヘ", o: "ホ" },
b: { a: "バ", i: "ビ", u: "ブ", e: "ベ", o: "ボ" },
p: { a: "パ", i: "ピ", u: "プ", e: "ペ", o: "ポ" },
m: { a: "マ", i: "ミ", u: "ム", e: "メ", o: "モ" },
r: { a: "ラ", i: "リ", u: "ル", e: "レ", o: "ロ" },
f: { a: "ファ", i: "フィ", u: "フ", e: "フェ", o: "フォ" },
v: { a: "バ", i: "ビ", u: "ブ", e: "ベ", o: "ボ" },
: { a: "チャ", i: "チ", u: "チュ", e: "チェ", o: "チョ" },
: { a: "ジャ", i: "ジ", u: "ジュ", e: "ジェ", o: "ジョ" },
ʃ: { a: "シャ", i: "シ", u: "シュ", e: "シェ", o: "ショ" },
ʒ: { a: "ジャ", i: "ジ", u: "ジュ", e: "ジェ", o: "ジョ" },
θ: { a: "サ", i: "シ", u: "ス", e: "セ", o: "ソ" },
ð: { a: "ザ", i: "ジ", u: "ズ", e: "ゼ", o: "ゾ" },
w: { a: "ワ", i: "ウィ", u: "ウ", e: "ウェ", o: "ウォ" },
j: { a: "ヤ", i: "イ", u: "ユ", e: "イェ", o: "ヨ" },
ŋ: { a: "ンガ", i: "ンギ", u: "ング", e: "ンゲ", o: "ンゴ" },
};
const CONS_ROW = {
"p": "p", "b": "b", "t": "t", "d": "d", "k": "k", "ɡ": "g", "g": "g",
"f": "f", "v": "v", "s": "s", "z": "z", "ʃ": "ʃ", "ʒ": "ʒ",
"h": "h", "tʃ": "tʃ", "dʒ": "dʒ", "θ": "θ", "ð": "ð",
"m": "m", "n": "n", "ŋ": "ŋ", "l": "r", "ɫ": "r", "ɹ": "r",
"r": "r", "j": "j", "w": "w", "ɾ": "r",
};
const VOWEL_KANA = { a: "ア", i: "イ", u: "ウ", e: "エ", o: "オ" };
const DIPH_KANA = { ai: "アイ", au: "アウ", oi: "オイ" };
function vClass(v) {
switch (v) {
case "ə": case "ɚ": case "ɝ": case "ɑ": case "ʌ": case "ɒ": case "æ": case "ɜ":
return { vowel: "a", long: false, diph: null };
case "ɪ": return { vowel: "i", long: false, diph: null };
case "i": return { vowel: "i", long: true, diph: null };
case "ʊ": return { vowel: "u", long: false, diph: null };
case "u": return { vowel: "u", long: true, diph: null };
case "ɛ": case "e": return { vowel: "e", long: false, diph: null };
case "eɪ": return { vowel: "e", long: true, diph: null };
case "ɔ": return { vowel: "o", long: false, diph: null };
case "o": case "oʊ": return { vowel: "o", long: true, diph: null };
case "aɪ": return { vowel: "a", long: false, diph: "ai" };
case "aʊ": return { vowel: "a", long: false, diph: "au" };
case "ɔɪ": return { vowel: "o", long: false, diph: "oi" };
default: return null;
}
}
const SONORANT = new Set(["m", "n", "ŋ", "l", "ɫ", "ɹ", "r", "j", "w", "ɾ"]);
function ipaToKana(ipaStr) {
const tokens = [];
let i = 0;
while (i < ipaStr.length) {
const two = ipaStr.slice(i, i + 2);
if (CONS_ROW[two] || vClass(two)) { tokens.push(two); i += 2; continue; }
const one = ipaStr[i];
if (CONS_ROW[one] || vClass(one)) { tokens.push(one); i += 1; continue; }
if (one === " " || one === "ː") { tokens.push(one); i += 1; continue; }
i += 1;
}
let out = "";
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t === " ") { out += " "; continue; }
if (t === "ː") { out += "ー"; continue; }
const vc = vClass(t);
if (vc) {
out += vc.diph ? DIPH_KANA[vc.diph] : (VOWEL_KANA[vc.vowel] + (vc.long ? "ー" : ""));
continue;
}
const row = CONS_ROW[t];
if (!row) continue;
const next = tokens[i + 1];
const nextVc = next !== undefined ? vClass(next) : null;
if (nextVc) {
if (nextVc.diph) {
out += KANA[row][nextVc.vowel] + DIPH_KANA[nextVc.diph].slice(1);
} else {
out += KANA[row][nextVc.vowel] + (nextVc.long ? "ー" : "");
}
i++;
} else if (next !== undefined) {
out += KANA[row]["u"];
} else {
out += (t === "n" || t === "ŋ") ? "ン" : KANA[row]["u"];
}
}
return out;
}
// ── Letter-by-letter fallback (last resort, AquesTalk1-safe) ──────────
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": "ナイン",
};
function kanaForChar(ch) {
return LETTER_KANA[ch.toLowerCase()] || DIGIT_KANA[ch] || null;
}
function spellWord(word) {
let out = "";
for (const ch of word) {
const k = kanaForChar(ch);
out += k ? k : "";
}
return out;
}
// ── English punctuation → Japanese pause equivalents ──────────────────
// Characters AquesTalk1 can safely pronounce (kana + limited punctuation).
// Anything else (!「」・  etc.) is collapsed to a 、 pause in synthesize().
const AQTK_SAFE_RE = /[\u3040-\u309F\u30A0-\u30FF\uFF65-\uFF9F\u3001\u3002\uFF1F\u301C\u30FC\u309B\u309C]/;
const PUNCT_MAP = {
",": "、", ".": "。", "!": "、", "?": "",
"": "、", "。": "。", "": "、", "": "",
" ": "、", // AquesTalk truncates on space; use 、 pause instead
};
function wordToKana(word) {
const clean = word.replace(/'/g, "").toLowerCase();
if (!clean) return "";
// 1. dictionary
const hit = lookupKana(clean);
if (hit) return hit;
// 2. phonemize G2P → IPA → katakana
if (phonemize && /^[a-z]+$/.test(clean)) {
try {
const ipa = phonemize(clean, { stripStress: true });
if (ipa && /[^a-z]/.test(ipa)) {
const kana = ipaToKana(ipa);
if (kana) return kana;
}
} catch (err) {
// fall through to letter spelling
}
}
// 3. letter-by-letter spelling (never skip)
return spellWord(clean);
}
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("、"); // word gap: AquesTalk truncates on space, use 、 instead
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);
}
// Collapse consecutive pauses; strip any residual spaces (AquesTalk truncates on space)
result = result.replace(/ +/g, "、").replace(/、+/g, "、").replace(/^、|、$/g, "");
// Replace any remaining chars AquesTalk1 cannot pronounce with a 、 pause
// (covers !「」・  etc. that slipped through from user templates)
let cleaned = "";
for (const ch of result) {
cleaned += AQTK_SAFE_RE.test(ch) ? ch : "、";
}
result = cleaned.replace(/、+/g, "、").replace(/^、|、$/g, "");
console.error(`[bridge] SYNTH IN: ${trimmed}`);
console.error(`[bridge] SYNTH OUT: ${result}`);
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));
});