feat: replace wanakana with english-to-kana 49K dictionary

- 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)
This commit is contained in:
2026-08-08 15:06:34 +08:00
parent fd5c732d8a
commit 0c0e63161d
8 changed files with 152 additions and 40 deletions
+47 -2
View File
@@ -8,7 +8,7 @@ Bilibili 直播弹幕 + ゆっくり TTS 语音朗读器。
- 实时接收 Bilibili 直播弹幕
- 中文 → 拼音 → 片假名自动转换
- 英文按罗马音转片假名(wanakana
- 英文转片假名(english-to-kana 49K 词库 + 字母拼读兜底
- 可选扫码登录(获取未打码用户名)
- Web 控制面板(暗色主题)
- 支持 8 种ゆっくり音色 + 语速/音量调节
@@ -94,7 +94,7 @@ Bilibili 弹幕 (blivedm)
中文→片假名 (pypinyin + 映射表)
英文→片假名 (wanakana)
英文→片假名 (english-to-kana 49K 词库 + 字母拼读兜底)
AquesTalk TTS (aquestalk.js + v86 WASM 模拟)
@@ -121,3 +121,48 @@ python build.py
## 许可
MIT License
```
.
-==- +
::. %*%#*:
--.. #***:*+********-. .-+*#%%%%%%%##*+-: ..::-- -.
.*---%#=.=*+*+*+******+#@%%#%%%#%%%#%#%#%%%%#%#%%#%@%- .:-+###**+***+:+**:.-#+=
::+.-+++#+####****+**%#%%#%%#%%#%%#%#%%#%%#%#%%%%#%%#%#%%@@%#+**+****+**+**=-+#*::-+:
*-. #+.=+*+***%%%%%%#%%#%%#%#%#%#%#%%#%%#%#%#%#%#%%#%#%%%@@%%##*+***+**++*:=**#-.-+.
*###**::*%%%#%#%#%#%#%%#%%#%%#%%#%%%#%#%#%%#%#%#%%#%#%%#%#%@@@@@%#*+*+**+**=-::-*#. =
+-#=%##%#%#%#%#%%%#%#%#%#%#%#%%#%%#%#%%%#%#%%%%#%%#%%%#%%#%#%#%@@@@@#########=+*+*+=:+.
+---:%#%%%#%%#%%#%#%%%#%%#%%#%#%%#%#%%#%#%%%#%#%%#%%#%#%#%%#%#%%#%@%@@@######*++##--==
=-##%%#%#%#%%#%%#%#%#%#%%#%%%#%#%%#%#%#%#%#%#%#%#%#%#%%%#%%%%#%#%#%@%@@%##**+++%#==-
.%%%#%#%%%#%%#%#%%%#%%%%#%#%#%%#%#%%%#%%%#%%%#%%#%%#%#%#%@##%%#%%%%%%@%#+%##*+#: :-:+
:==#%#%%#%%%%#%%#%#%%#%@#%%%%%#%%#%%%%%#%#%#%#%#%%#%%%%%%%%@%%#%%##+-=-=-=-%#*++##**=:+
===--=--+%%%#%@%#%%#%%@@%%%#%@%%%%%#%@%#%#%%%#%@%%#%#%#%@%#%@**=--=--=+#%@@@%*+#%%=-%*
:%#%#@%%%%==-----+*%%@@%@#%%%@%%@@#%%#@@%%%#%@%%#@%%%#*+-----=##@%#%@@@%%@%@%@%%%*-+:%@:
%%#%@%%@@#%@@%%@%@%%@%@@%%%#%@@%@%%#%#@@@%%%%%@%@%@@%%%%@%@@@@%@@@%%%@@@%@@@@@%-+%##%@%*
*%#%@@%@@#@@%@%@@@@%%@@@@%%%%@@@@#==%@%@%@@%%#%%@@@@@%%%%%%%@%@@@%@%%#%@@@@%@%@@#######%@.
.%%%%@%@@%%@%@%%@%@%@@@%@%#@@%@%@#=-:*%@%@@%#%+%%%@%@%@%@@#%@@@%@@@@@%%%%%@@@@@%@####*+*+*%.
=%#@@@@@%%@@@%@@@@+@@%@#-#%+@@@@#-=:..*@@@%@=%*=+%@@@@@@%@%%%@@@%@@%@@%%#%@%@%@@@###*+*####%*-*
*%#@%@%@%@%@%@%@%-#%@%*+=##-@%@#==:....+@@@%-#*.:=@%@@=-%@@=*@%@@@%@@@@@%%%@@@@%@%##**+*--:++*-
##@@@%%#@@@@%@@*==*@====-%--+@*-=:... ..-%@*=.#%-=*#%%+=-+@*-=%@%@@@%@@%@@%%@%@@@%*+**#+-*%*+.
*%@%@- @%@%@@@+==#+ =####%-....=-.........+-:.# :######%=*+%.-+@@@%@@@@@@%@@@@@%@%*####+=%%.
=%%%. .@@@-#@=-=*:####*##%... ..... ... ......%####*##*%..*::.:@%@@*%%#%@@@%@%@@@#-:==+@@@@.
:%# -@--%%%--:- %******#........... .... ...*********+ +..+ *.=.-++-%@@@@@@@%@@%@%#%@@%@:
: :-#-==#:::-.=%#%%%%*.. ... .. .......... #######+ .:.:..=*=+#+#%==*%@%@%@@@@%@@@@@@@@:
#***%-:---+.-................... .. .......---=+--=:-:-**+###%=*=*@@@@@%@@@@%@%@%@@:
.#***%.:...:.......... .. .... ................:.:.:.:.-****###=-==%@%@@@%@%@@@@@@%@.
.#*+##...:...:............. ...... .. .................-*+*####=-=-@@@%@@@@@@%@%@@@%.
#***#............. .-... .........* ... ..............:**+###*=-=*%@@@%@@%@@@@@@@%%
.#+**%.................#@**+========-..................:****##*=-+%@%@@@%@@@%@%@%@@%
:=+=*##:......... ... ...*++========*..... .............:+-+*+#*=#@@@@@%@@@%@@@@@@*%*
#.=*--*...... .... .... .:#====-==-#.. ..... .........=-*=#*#%+%@%@%@@@@%@@@@%@%@-@=
-+ %@%-.. .................*+===*=....... .... .. ...=.+=-+#+=@%%@@%@@%@@@%@@@@*:%:
:%%@%@@-..... .. ... ... ......... .. .................@%+@@@@%#%@%@#%@@@%=%@%@-:#
:%%@@@%%#.. .......... ....... ........ ... .. ... . ==%#@%@%%%%%@@%-@@%@#:@@@% :*
:%%%@%.%%@#:... .. ......... .... .. .... .........+- -%%%@@@%#%@%@:-@#-@+ %@%= =.
:%%@@: %%-%@@*:..... ... ................... .. :+. =#@@%=#*%%@@- =@:.@: #@#
:%@@- %#.-@@@@#%*:....... .. .. .... .. ...:=+. #%@% -+-#@%= *- .#. *%.
+%@: #: @%@- *- .=*+=-.... ... ...-++=. .%@# . .%@= : = *.
*@: + %# =@- %:
#. .*
```
+4 -2
View File
@@ -135,13 +135,15 @@ def main() -> int:
safe_copy_tree(aq_src / "node_modules", aq_dst / "node_modules",
ignore_patterns=("bililive-touhou-tts",))
# ── 4. kuroshiro node_modules ──────────────────────────────────────
step("Copying kuroshiro node_modules")
# ── 4. node_modules (if any) ───────────────────────────────────────
if (ROOT / "node_modules").exists():
step("Copying node_modules")
safe_copy_tree(ROOT / "node_modules", BUILD_DIR / "node_modules")
# ── 5. Bridge + config ─────────────────────────────────────────────
step("Copying bridge files")
shutil.copy2(ROOT / "tts_bridge.js", BUILD_DIR / "tts_bridge.js")
shutil.copy2(ROOT / "english-kana-matcher.js", BUILD_DIR / "english-kana-matcher.js")
shutil.copy2(ROOT / "package.json", BUILD_DIR / "package.json")
print(" OK")
+2 -2
View File
@@ -16,7 +16,7 @@ _KANA_SAFE_RE = re.compile(
r"\u3001\u3002\uFF01\uFF1F"
r"\u300C\u300D"
r"\u30FB\u3000"
r"a-zA-Z0-9 ]"
r"a-zA-Z0-9 ,.!?\uFF0C]"
)
_CHINESE_DIGITS = ["", "", "", "", "", "", "", "", "", ""]
@@ -85,7 +85,7 @@ def chinese_to_kana(text: str, convert_numbers: bool = True) -> str:
else:
result_parts.append(seg)
result = "".join(result_parts)
result = re.sub(r"(?<=\S) (?=\S)", "", result)
result = re.sub(r"(?<=[^\x00-\x7F]) (?=[^\x00-\x7F])", "", result)
return result
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -12,7 +12,7 @@ import signal
import sys
from danmaku_handler import DanmakuClient
from chinese2kana import chinese_to_kana
from chinese2kana import chinese_to_kana, filter_kana
from tts import TTSBridge
from audio_player import play_wav_async, get_output_devices
@@ -47,6 +47,7 @@ async def tts_worker(queue: asyncio.Queue, bridge: TTSBridge,
continue
try:
kana = chinese_to_kana(text, convert_numbers=convert_numbers)
kana = filter_kana(kana)
logger.info("Speaking: %s -> %s", text, kana)
wav_data = await bridge.synthesize(kana)
await play_wav_async(wav_data)
-22
View File
@@ -1,22 +0,0 @@
{
"name": "bililive-touhou-tts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bililive-touhou-tts",
"dependencies": {
"wanakana": "^5.3.1"
}
},
"node_modules/wanakana": {
"version": "5.3.1",
"resolved": "https://registry.npmmirror.com/wanakana/-/wanakana-5.3.1.tgz",
"integrity": "sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
}
}
}
+1 -3
View File
@@ -1,7 +1,5 @@
{
"name": "bililive-touhou-tts",
"type": "module",
"dependencies": {
"wanakana": "^5.3.1"
}
"dependencies": {}
}
+94 -7
View File
@@ -1,17 +1,27 @@
/** tts_bridge.js — Persistent Node.js bridge for aquestalk.js TTS synthesis.
Converts English/romaji to katakana via wanakana before 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";
import wanakana from "wanakana";
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;
@@ -24,9 +34,6 @@ for (let i = 0; i < args.length; i++) {
}
}
const aquestalkMod = pathToFileURL(join(__dirname, "aquestalk.js", "dist", "index.js")).href;
const { load } = await import(aquestalkMod);
console.error(`[bridge] Loading aquestalk.js with voice="${voice}", speed=${speed}...`);
let aq;
@@ -39,11 +46,91 @@ try {
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");
const finalText = wanakana.toKatakana(trimmed, { convertLongVowelMark: true });
const wav = aq.run(finalText, speed);
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);
}