Files
bililive-touhou-tts/tts_bridge.js
T
chun_qiu cb22e074af fix: OOV english words now transcribed via phonemize G2P + IPA
- Add phonemize (MIT) rule-based G2P as second fallback tier
- IPA phoneme -> katakana CV-syllable transcription table
- proposal -> プラポーザル, bilibili -> バイリーバイリー (no more letter-spelling)
- Letter spelling kept as last resort only
- Bridge logs SYNTH IN/OUT to stderr; tts.py drains stderr to Python logs
  so conversion results are visible in the server log
- Rebuild portable zip (83.8 MB)
2026-08-08 15:21:00 +08:00

311 lines
11 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 ──────────────────
const PUNCT_MAP = {
",": "、", ".": "。", "!": "", "?": "",
"": "、", "。": "。", "": "", "": "",
};
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(" "); // 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);
}
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));
});