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)
327 lines
12 KiB
JavaScript
327 lines
12 KiB
JavaScript
/** 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: "ボ" },
|
||
tʃ: { a: "チャ", i: "チ", u: "チュ", e: "チェ", o: "チョ" },
|
||
dʒ: { 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));
|
||
});
|