feat: Bilibili Live Danmaku -> Yukkuri TTS Reader

- Python main entry with CLI (--room-id, --voice, --speed)
- blivedm integration for real-time danmaku receiving
- Chinese-to-Japanese katakana conversion (pypinyin + mapping table)
- Node.js persistent bridge for aquestalk.js TTS synthesis
- Audio playback via sounddevice
- Message queue for sequential TTS playback
This commit is contained in:
2026-08-08 11:26:11 +08:00
commit 9b277fe2e0
9 changed files with 816 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
dist/
build/
*.egg
# Virtual environments
venv/
.venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Temp files
tmp/
temp/
*.tmp
# Node.js
node_modules/
+46
View File
@@ -0,0 +1,46 @@
"""Audio playback for WAV files."""
import asyncio
import io
import logging
import sounddevice as sd
import soundfile as sf
logger = logging.getLogger(__name__)
def play_wav(wav_data: bytes) -> None:
"""Play WAV audio data synchronously (blocking).
Args:
wav_data: Raw WAV file bytes.
"""
data, samplerate = sf.read(io.BytesIO(wav_data), dtype="float32")
sd.play(data, samplerate)
sd.wait()
async def play_wav_async(wav_data: bytes) -> None:
"""Play WAV audio data asynchronously.
Args:
wav_data: Raw WAV file bytes.
"""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, play_wav, wav_data)
def get_output_devices() -> list[dict]:
"""List available audio output devices."""
devices = sd.query_devices()
result = []
for i, dev in enumerate(devices):
if dev["max_output_channels"] > 0:
result.append({
"index": i,
"name": dev["name"],
"channels": dev["max_output_channels"],
"default_samplerate": dev["default_samplerate"],
})
return result
+113
View File
@@ -0,0 +1,113 @@
"""Chinese text → Japanese Katakana converter.
Converts Chinese text to Japanese kana (katakana) suitable for AquesTalk TTS.
Non-Chinese characters (Japanese kana, English, emoji, etc.) pass through unchanged.
Uses pypinyin for pinyin extraction and an internal pinyin→katakana mapping table.
"""
import re
from pypinyin import pinyin, Style
from pinyin2kana_data import PINYIN2KANA
_CHINESE_CHAR_RE = re.compile(r"[\u4e00-\u9fa5]")
_CHINESE_SEGMENT_RE = re.compile(r"([\u4e00-\u9fa5]+)")
_NUMBER_RE = re.compile(r"-?\d+(\.\d+)?")
_TONE_RE = re.compile(r"\d")
_CHINESE_DIGITS = ["", "", "", "", "", "", "", "", "", ""]
_CHINESE_UNITS = ["", "", "", "", ""]
_CHINESE_POINT = ""
def _number_to_chinese(num_str: str) -> str:
"""Convert an Arabic numeral string (integer or decimal) to Chinese words."""
num_str = num_str.strip("-")
if "." in num_str:
integer_part, decimal_part = num_str.split(".", 1)
else:
integer_part, decimal_part = num_str, ""
result = ""
if integer_part == "0" or integer_part == "":
result = ""
else:
digits = [int(ch) for ch in integer_part]
n = len(digits)
for i, d in enumerate(digits):
pos = n - i - 1
if d == 0:
if i < n - 1 and digits[i + 1] != 0:
result += _CHINESE_DIGITS[0]
else:
result += _CHINESE_DIGITS[d]
unit_idx = pos % 4
wan = pos // 4
if wan > 0 and unit_idx == 0:
result += _CHINESE_UNITS[4]
else:
result += _CHINESE_UNITS[unit_idx]
if decimal_part:
result += _CHINESE_POINT
for ch in decimal_part:
result += _CHINESE_DIGITS[int(ch)]
return result
def _strip_tone(py: str) -> str:
"""Remove tone numbers from pinyin, e.g. 'ni3' -> 'ni'."""
return _TONE_RE.sub("", py)
def chinese_to_kana(text: str, convert_numbers: bool = True) -> str:
"""Convert text containing Chinese characters to Japanese katakana.
Text is split into Chinese and non-Chinese segments. Chinese segments
are converted character-by-character: pinyin -> katakana. Non-Chinese
segments pass through unchanged.
Args:
text: Input text (may contain Chinese, Japanese, English, etc.)
convert_numbers: If True, convert Arabic numerals to Chinese words first.
Returns:
Katakana string suitable for AquesTalk synthesis.
"""
if not text or not text.strip():
return ""
working = text
if convert_numbers:
def _replace_num(m: re.Match) -> str:
return _number_to_chinese(m.group(0))
working = _NUMBER_RE.sub(_replace_num, working)
if not _CHINESE_CHAR_RE.search(working):
return text
segments = _CHINESE_SEGMENT_RE.split(working)
result_parts: list[str] = []
for seg in segments:
if not seg:
continue
if _CHINESE_CHAR_RE.match(seg[0]):
# Chinese segment: convert each character via pinyin -> katakana
py_list = pinyin(seg, style=Style.TONE3, heteronym=False)
for py_item in py_list:
py_raw = py_item[0]
py_plain = _strip_tone(py_raw)
kana = PINYIN2KANA.get(py_plain, py_plain)
result_parts.append(kana)
else:
# Non-Chinese segment: pass through as-is
result_parts.append(seg)
result = "".join(result_parts)
result = re.sub(r"(?<=\S) (?=\S)", "", result)
return result
+101
View File
@@ -0,0 +1,101 @@
"""Bilibili Live Danmaku Handler.
Connects to a Bilibili live room and puts incoming danmaku messages into an asyncio queue.
"""
import asyncio
import logging
from typing import Optional
import blivedm
from blivedm.models.web import DanmakuMessage
logger = logging.getLogger(__name__)
DANMAKU_FORMAT = "{uname}: {msg}"
class DanmakuHandler(blivedm.BaseHandler):
"""Handles incoming danmaku from a Bilibili live room."""
def __init__(self, queue: asyncio.Queue, message_format: str = DANMAKU_FORMAT):
super().__init__()
self._queue = queue
self._message_format = message_format
def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None:
if not message.msg.strip():
return
text = self._message_format.format(
uname=message.uname,
msg=message.msg,
uid=message.uid,
)
logger.info("Danmaku: %s", text)
try:
self._queue.put_nowait(text)
except asyncio.QueueFull:
logger.warning("Danmaku queue full, dropping: %s", text)
def _on_gift(self, client: blivedm.BLiveClient, message) -> None:
logger.debug("Gift: %s x%d from %s", message.gift_name, message.num, message.uname)
def _on_interact_word_v2(self, client: blivedm.BLiveClient, message) -> None:
logger.debug("Interact: %s (type=%d)", message.uname, message.msg_type)
def on_client_stopped(self, client, exception: Optional[Exception]) -> None:
if exception is not None:
logger.error("Client stopped with exception: %s", exception)
else:
logger.info("Client stopped normally.")
class DanmakuClient:
"""Manages the BLiveClient connection for a single room."""
def __init__(
self,
room_id: int,
queue: asyncio.Queue,
sessdata: str = "",
message_format: str = DANMAKU_FORMAT,
):
self._room_id = room_id
self._queue = queue
self._sessdata = sessdata
self._message_format = message_format
self._client: Optional[blivedm.BLiveClient] = None
async def start(self) -> None:
"""Connect to the live room and start receiving danmaku."""
import http.cookies
import aiohttp
session = aiohttp.ClientSession()
if self._sessdata:
cookies = http.cookies.SimpleCookie()
cookies["SESSDATA"] = self._sessdata
cookies["SESSDATA"]["domain"] = "bilibili.com"
session.cookie_jar.update_cookies(cookies)
self._client = blivedm.BLiveClient(
self._room_id,
session=session,
)
handler = DanmakuHandler(self._queue, self._message_format)
self._client.set_handler(handler)
self._client.start()
logger.info("Connected to room %d", self._room_id)
async def stop(self) -> None:
"""Stop the client and clean up."""
if self._client is not None:
logger.info("Disconnecting from room %d...", self._room_id)
await self._client.stop_and_close()
self._client = None
@property
def room_id(self) -> int:
return self._room_id
+194
View File
@@ -0,0 +1,194 @@
"""bililive-touhou-tts — Bilibili Live Danmaku → Yukkuri TTS Reader.
Usage:
python main.py --room-id 12345
python main.py --room-id 12345 --voice f2 --speed 120
"""
import argparse
import asyncio
import logging
import signal
import sys
from pathlib import Path
from danmaku_handler import DanmakuClient
from chinese2kana import chinese_to_kana
from tts import TTSBridge
from audio_player import play_wav_async, get_output_devices
logger = logging.getLogger("bililive-tts")
DEFAULT_VOICE = "f1"
DEFAULT_SPEED = 100
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bilibili Live Danmaku → Yukkuri TTS Reader"
)
parser.add_argument(
"--room-id", "-r",
type=int,
required=True,
help="Bilibili live room ID (from the URL)",
)
parser.add_argument(
"--voice", "-v",
type=str,
default=DEFAULT_VOICE,
choices=["f1", "f2", "m1", "m2", "dvd", "imd1", "jgr", "r1"],
help=f"Yukkuri voice type (default: {DEFAULT_VOICE})",
)
parser.add_argument(
"--speed", "-s",
type=int,
default=DEFAULT_SPEED,
help=f"Speech speed, 50-300 (default: {DEFAULT_SPEED})",
)
parser.add_argument(
"--sessdata",
type=str,
default="",
help="Bilibili SESSDATA cookie for authenticated access (optional)",
)
parser.add_argument(
"--format", "-f",
type=str,
default="{uname}: {msg}",
help="Danmaku message format. Variables: {uname}, {msg}, {uid}",
)
parser.add_argument(
"--no-numbers",
action="store_true",
help="Do not convert Arabic numerals to Chinese words",
)
parser.add_argument(
"--list-devices",
action="store_true",
help="List available audio output devices and exit",
)
parser.add_argument(
"--debug", "-d",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args()
async def tts_worker(
queue: asyncio.Queue,
bridge: TTSBridge,
convert_numbers: bool,
shutdown_event: asyncio.Event,
) -> None:
"""Consume danmaku messages from the queue, convert to kana, and play TTS."""
logger.info("TTS worker started.")
while not shutdown_event.is_set():
try:
text = await asyncio.wait_for(queue.get(), timeout=1.0)
except asyncio.TimeoutError:
continue
try:
kana = chinese_to_kana(text, convert_numbers=convert_numbers)
logger.info("Speaking: %s%s", text, kana)
wav_data = await bridge.synthesize(kana)
await play_wav_async(wav_data)
except Exception:
logger.exception("Error processing danmaku: %s", text)
finally:
queue.task_done()
logger.info("TTS worker stopped.")
async def main_async(args: argparse.Namespace) -> int:
if args.list_devices:
devices = get_output_devices()
print("Available audio output devices:")
for dev in devices:
print(f" [{dev['index']}] {dev['name']} ({dev['channels']}ch)")
return 0
if not (50 <= args.speed <= 300):
logger.error("Speed must be between 50 and 300.")
return 1
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
shutdown_event = asyncio.Event()
bridge = TTSBridge(voice=args.voice, speed=args.speed)
danmaku_client = DanmakuClient(
room_id=args.room_id,
queue=queue,
sessdata=args.sessdata,
message_format=args.format,
)
async def handle_signal() -> None:
"""Wait for shutdown signal."""
loop = asyncio.get_running_loop()
sig_event = asyncio.Event()
def _handler():
sig_event.set()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _handler)
except NotImplementedError:
pass
await sig_event.wait()
logger.info("Shutdown signal received.")
shutdown_event.set()
try:
await bridge.start()
await danmaku_client.start()
worker_task = asyncio.create_task(
tts_worker(queue, bridge, not args.no_numbers, shutdown_event)
)
await handle_signal()
worker_task.cancel()
try:
await worker_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Fatal error")
return 1
finally:
await danmaku_client.stop()
await bridge.stop()
return 0
def main() -> int:
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
try:
return asyncio.run(main_async(args))
except KeyboardInterrupt:
return 0
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
# Pinyin to Katakana mapping derived from pinyin-to-kana
# Source: https://cdn.jsdelivr.net/npm/pinyin-to-kana@1.0.0/mapping.tsv
PINYIN2KANA: dict[str, str] = {
"a": "アー", "ai": "アイ", "an": "アン", "ang": "アン", "ao": "アオ",
"ba": "バー", "bai": "バイ", "ban": "バン", "bang": "バン", "bao": "バオ",
"bei": "ベイ", "ben": "ベン", "beng": "ボン", "bi": "ビー", "bian": "ビエン",
"biao": "ビアオ", "bie": "ビエ", "bin": "ビン", "bing": "ビン", "bo": "ボー",
"bu": "ブー",
"ca": "ツァー", "cai": "ツァイ", "can": "ツァン", "cang": "ツァン", "cao": "ツァオ",
"ce": "ツォー", "cen": "ツェン", "ceng": "ツォン",
"cha": "チャー", "chai": "チャイ", "chan": "チャン", "chang": "チャン", "chao": "チャオ",
"che": "チョー", "chen": "チェン", "cheng": "チョン", "chi": "チー", "chong": "チョン",
"chou": "チョウ", "chu": "チュー", "chua": "チュワ", "chuai": "チュワイ", "chuan": "チュワン",
"chuang": "チュアン", "chui": "チュイ", "chun": "チュン", "chuo": "チュオ",
"ci": "ツー", "cong": "ツォン", "cou": "ツォウ", "cu": "ツー", "cuan": "ツワン",
"cui": "ツイ", "cun": "ツン", "cuo": "ツオ",
"da": "ダー", "dai": "ダイ", "dan": "ダン", "dang": "ダン", "dao": "ダオ",
"de": "ドー", "dei": "デイ", "den": "デン", "deng": "ドン",
"di": "ディー", "dian": "ディエン", "diao": "ディアオ", "die": "ディエ",
"ding": "ディン", "diu": "ディウ", "dong": "ドン", "dou": "ドウ",
"du": "ドゥー", "duan": "ドワン", "dui": "ドゥイ", "dun": "ドゥン", "duo": "ドゥオ",
"e": "オー", "ei": "エイ", "en": "エン", "eng": "オン", "er": "アル",
"fa": "ファー", "fan": "ファン", "fang": "ファン", "fei": "フェイ", "fen": "フェン",
"feng": "フォン", "fo": "フォー", "fou": "フォウ", "fu": "フー",
"ga": "ガー", "gai": "ガイ", "gan": "ガン", "gang": "ガン", "gao": "ガオ",
"ge": "ゴー", "gei": "ゲイ", "gen": "ゲン", "geng": "ゴン",
"gong": "ゴン", "gou": "ゴウ", "gu": "グー", "gua": "グワ", "guai": "グワイ",
"guan": "グワン", "guang": "グアン", "gui": "グイ", "gun": "グン", "guo": "グオ",
"ha": "ハー", "hai": "ハイ", "han": "ハン", "hang": "ハン", "hao": "ハオ",
"he": "ホー", "hei": "ヘイ", "hen": "ヘン", "heng": "ホン",
"hong": "ホン", "hou": "ホウ", "hu": "フー", "hua": "ホワ", "huai": "ホワイ",
"huan": "ホワン", "huang": "ホアン", "hui": "フイ", "hun": "フン", "huo": "フオ",
"ji": "ジー", "jia": "ジア", "jian": "ジエン", "jiang": "ジアン", "jiao": "ジアオ",
"jie": "ジエ", "jin": "ジン", "jing": "ジン", "jiong": "ジオン", "jiu": "ジウ",
"ju": "ジュー", "juan": "ジュエン", "jue": "ジュエ", "jun": "ジュン",
"ka": "カー", "kai": "カイ", "kan": "カン", "kang": "カン", "kao": "カオ",
"ke": "コー", "kei": "ケイ", "ken": "ケン", "keng": "コン",
"kong": "コン", "kou": "コウ", "ku": "クー", "kua": "クワ", "kuai": "クワイ",
"kuan": "クワン", "kuang": "クアン", "kui": "クイ", "kun": "クン", "kuo": "クオ",
"la": "ラー", "lai": "ライ", "lan": "ラン", "lang": "ラン", "lao": "ラオ",
"le": "ロー", "lei": "レイ", "leng": "ロン",
"li": "リー", "lia": "リア", "lian": "リエン", "liang": "リアン", "liao": "リアオ",
"lie": "リエ", "lin": "リン", "ling": "リン", "liu": "リウ",
"lo": "ロー", "long": "ロン", "lou": "ロウ",
"lu": "ルー", "": "リュー", "luan": "ルワン", "lüe": "リュエ", "lun": "ルン", "luo": "ルオ",
"lv": "リュー", "lve": "リュエ",
"ma": "マー", "mai": "マイ", "man": "マン", "mang": "マン", "mao": "マオ",
"me": "", "mei": "メイ", "men": "メン", "meng": "モン",
"mi": "ミー", "mian": "ミエン", "miao": "ミアオ", "mie": "ミエ",
"min": "ミン", "ming": "ミン", "miu": "ミウ",
"mo": "モー", "mou": "モウ", "mu": "ムー",
"na": "ナー", "nai": "ナイ", "nan": "ナン", "nang": "ナン", "nao": "ナオ",
"ne": "ノー", "nei": "ネイ", "nen": "ネン", "neng": "ノン",
"ni": "ニー", "nian": "ニエン", "niang": "ニアン", "niao": "ニアオ", "nie": "ニエ",
"nin": "ニン", "ning": "ニン", "niu": "ニウ",
"nong": "ノン", "nou": "ノウ",
"nu": "ヌー", "": "ニュー", "nuan": "ヌワン", "nüe": "ニュエ", "nuo": "ヌオ",
"nv": "ニュー", "nve": "ニュエ",
"o": "オー", "ou": "オウ",
"pa": "パー", "pai": "パイ", "pan": "パン", "pang": "パン", "pao": "パオ",
"pei": "ペイ", "pen": "ペン", "peng": "ポン",
"pi": "ピー", "pian": "ピエン", "piao": "ピアオ", "pie": "ピエ",
"pin": "ピン", "ping": "ピン", "po": "ポー", "pou": "ポウ", "pu": "プー",
"qi": "チー", "qia": "チア", "qian": "チエン", "qiang": "チアン", "qiao": "チアオ",
"qie": "チエ", "qin": "チン", "qing": "チン", "qiong": "チオン", "qiu": "チウ",
"qu": "チュー", "quan": "チュエン", "que": "チュエ", "qun": "チュン",
"ran": "ラン", "rang": "ラン", "rao": "ラオ", "re": "ロー", "ren": "レン",
"reng": "ロン", "ri": "リー",
"rong": "ロン", "rou": "ロウ", "ru": "ルー", "ruan": "ルワン",
"rui": "ルイ", "run": "ルン", "ruo": "ルオ",
"sa": "サー", "sai": "サイ", "san": "サン", "sang": "サン", "sao": "サオ",
"se": "ソー", "sen": "セン", "seng": "ソン",
"sha": "シャー", "shai": "シャイ", "shan": "シャン", "shang": "シャン", "shao": "シャオ",
"she": "ショー", "shei": "シェイ", "shen": "シェン", "sheng": "ション",
"shi": "シー", "shou": "ショウ", "shu": "シュー", "shua": "シュワ", "shuai": "シュワイ",
"shuan": "シュワン", "shuang": "シュアン", "shui": "シュイ", "shun": "シュン", "shuo": "シュオ",
"si": "スー", "song": "ソン", "sou": "ソウ", "su": "スー", "suan": "スワン",
"sui": "スイ", "sun": "スン", "suo": "スオ",
"ta": "ター", "tai": "タイ", "tan": "タン", "tang": "タン", "tao": "タオ",
"te": "トー", "teng": "トン",
"ti": "ティー", "tian": "ティエン", "tiao": "ティアオ", "tie": "ティエ",
"ting": "ティン", "tong": "トン", "tou": "トウ",
"tu": "トゥー", "tuan": "トワン", "tui": "トゥイ", "tun": "トゥン", "tuo": "トゥオ",
"wa": "ワー", "wai": "ワイ", "wan": "ワン", "wang": "ワン",
"wei": "ウェイ", "wen": "ウェン", "weng": "ウォン", "wo": "ウオ", "wu": "ウー",
"xi": "シー", "xia": "シア", "xian": "シエン", "xiang": "シアン", "xiao": "シアオ",
"xie": "シエ", "xin": "シン", "xing": "シン", "xiong": "シオン", "xiu": "シウ",
"xu": "シュー", "xuan": "シュエン", "xue": "シュエ", "xun": "シュン",
"ya": "ヤー", "yan": "イエン", "yang": "ヤン", "yao": "ヤオ",
"ye": "イエ", "yi": "イー", "yin": "イン", "ying": "イン",
"yo": "ヨー", "yong": "ヨン", "you": "ヨウ",
"yu": "ユー", "yuan": "ユエン", "yue": "ユエ", "yun": "ユン",
"za": "ザー", "zai": "ザイ", "zan": "ザン", "zang": "ザン", "zao": "ザオ",
"ze": "ゾー", "zei": "ゼイ", "zen": "ゼン", "zeng": "ゾン",
"zha": "ジャー", "zhai": "ジャイ", "zhan": "ジャン", "zhang": "ジャン", "zhao": "ジャオ",
"zhe": "ジョー", "zhei": "ジェイ", "zhen": "ジェン", "zheng": "ジョン",
"zhi": "ジー", "zhong": "ジョン", "zhou": "ジョウ",
"zhu": "ジュー", "zhua": "ジュワ", "zhuai": "ジュワイ", "zhuan": "ジュワン",
"zhuang": "ジュアン", "zhui": "ジュイ", "zhun": "ジュン", "zhuo": "ジュオ",
"zi": "ズー", "zong": "ゾン", "zou": "ゾウ",
"zu": "ズー", "zuan": "ズワン", "zui": "ズイ", "zun": "ズン", "zuo": "ズオ",
}
+5
View File
@@ -0,0 +1,5 @@
aiohttp>=3.9.0
pypinyin>=0.51.0
sounddevice>=0.5.0
soundfile>=0.12.0
blivedm @ git+https://github.com/xfgryujk/blivedm.git@master
+128
View File
@@ -0,0 +1,128 @@
"""Node.js TTS Bridge — Persistent Process Manager.
Starts a long-lived Node.js subprocess running tts_bridge.js. Communication uses
stdin/stdout with temporary files for text and WAV data.
Protocol:
Python → writes kana text to temp file
Python → sends "INPUT_FILE_PATH|OUTPUT_FILE_PATH\n" to bridge's stdin
Bridge → reads text, synthesizes, writes WAV to output path
Bridge → prints "OK:OUTPUT_FILE_PATH\n" to stdout (or "ERR:message\n" on error)
"""
import asyncio
import os
import pathlib
import tempfile
import logging
logger = logging.getLogger(__name__)
class TTSBridge:
"""Manages a persistent Node.js subprocess for aquestalk.js TTS synthesis."""
def __init__(self, voice: str = "f1", speed: int = 100, bridge_script: str | None = None):
self._voice = voice
self._speed = speed
self._process: asyncio.subprocess.Process | None = None
self._bridge_script = bridge_script
@property
def bridge_script_path(self) -> str:
if self._bridge_script:
return self._bridge_script
return str(pathlib.Path(__file__).parent / "tts_bridge.js")
async def start(self) -> None:
"""Launch the Node.js bridge process and wait for it to be ready."""
script = self.bridge_script_path
if not pathlib.Path(script).exists():
raise FileNotFoundError(f"Bridge script not found: {script}")
logger.info("Starting Node.js TTS bridge (voice=%s, speed=%d)...", self._voice, self._speed)
self._process = await asyncio.create_subprocess_exec(
"node",
script,
"--voice", self._voice,
"--speed", str(self._speed),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=60)
line_str = line.decode("utf-8").strip()
if "READY" not in line_str:
stderr_data = await self._process.stderr.read()
raise RuntimeError(
f"Bridge failed to start. Got: {line_str}. Stderr: {stderr_data.decode('utf-8', errors='replace')}"
)
logger.info("Node.js TTS bridge ready.")
async def synthesize(self, kana_text: str) -> bytes:
"""Synthesize kana text to WAV audio.
Args:
kana_text: Japanese kana text to synthesize.
Returns:
Raw WAV audio bytes.
"""
if self._process is None or self._process.stdin is None:
raise RuntimeError("Bridge not started. Call start() first.")
tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="tts_"))
try:
input_path = tmpdir / "input.txt"
output_path = tmpdir / "output.wav"
input_path.write_text(kana_text, encoding="utf-8")
cmd_line = f"{input_path}|{output_path}\n"
self._process.stdin.write(cmd_line.encode("utf-8"))
await self._process.stdin.drain()
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=120)
line_str = line.decode("utf-8").strip()
if line_str.startswith("ERR:"):
raise RuntimeError(f"TTS synthesis error: {line_str[4:]}")
if not line_str.startswith("OK:") or line_str[3:] != str(output_path):
raise RuntimeError(f"Unexpected bridge response: {line_str}")
wav_data = output_path.read_bytes()
return wav_data
finally:
for f in tmpdir.iterdir():
try:
f.unlink()
except OSError:
pass
try:
tmpdir.rmdir()
except OSError:
pass
async def stop(self) -> None:
"""Stop the bridge process."""
if self._process is not None:
logger.info("Stopping TTS bridge...")
try:
if self._process.stdin is not None:
self._process.stdin.close()
except OSError:
pass
try:
self._process.terminate()
await asyncio.wait_for(self._process.wait(), timeout=5)
except asyncio.TimeoutError:
self._process.kill()
await self._process.wait()
logger.info("TTS bridge stopped.")
self._process = None
+95
View File
@@ -0,0 +1,95 @@
/** tts_bridge.js — Persistent Node.js bridge for aquestalk.js TTS synthesis.
Protocol (via stdin/stdout):
- On startup, prints "READY" to stdout.
- Reads lines from stdin in format: INPUT_PATH|OUTPUT_PATH
- Reads kana text from INPUT_PATH, synthesizes WAV, writes to OUTPUT_PATH.
- Prints "OK:OUTPUT_PATH" on success, "ERR:message" on failure.
- Exits when stdin is closed.
Usage:
node tts_bridge.js --voice f1 --speed 100
*/
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);
// Parse CLI arguments
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);
}
}
// Import aquestalk.js from sibling directory
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;
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);
}
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 = aq.run(kanaText, speed);
writeFileSync(outputPath, Buffer.from(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);
});
});