- Vendor english-kana-matcher.js (english-to-kana, MIT, 49216 words) - Rewrite bridge: dictionary lookup + letter-by-letter fallback for OOV words - Convert English punctuation (,.!?) to Japanese pauses (、。) - Fix Chinese fullwidth comma being stripped by filter_kana - Preserve decimal points in numbers (3.5 -> スリーファイブ) - main.py now applies filter_kana (matches web UI behavior) - Remove wanakana dependency; rebuild portable zip (82.5 MB)
173 lines
5.4 KiB
JavaScript
173 lines
5.4 KiB
JavaScript
/** 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));
|
||
});
|