commit 9b277fe2e0bc385a66ad7c0a8bde0bcbb6f89468 Author: Chun_Qiu Date: Sat Aug 8 11:26:11 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deb7d4d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/audio_player.py b/audio_player.py new file mode 100644 index 0000000..5bb040b --- /dev/null +++ b/audio_player.py @@ -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 diff --git a/chinese2kana.py b/chinese2kana.py new file mode 100644 index 0000000..6a38dee --- /dev/null +++ b/chinese2kana.py @@ -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 diff --git a/danmaku_handler.py b/danmaku_handler.py new file mode 100644 index 0000000..1a712af --- /dev/null +++ b/danmaku_handler.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..3c48d1a --- /dev/null +++ b/main.py @@ -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()) diff --git a/pinyin2kana_data.py b/pinyin2kana_data.py new file mode 100644 index 0000000..0ec935f --- /dev/null +++ b/pinyin2kana_data.py @@ -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": "ルー", "lü": "リュー", "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": "ヌー", "nü": "ニュー", "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": "ズオ", +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78506c2 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tts.py b/tts.py new file mode 100644 index 0000000..b2a0015 --- /dev/null +++ b/tts.py @@ -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 diff --git a/tts_bridge.js b/tts_bridge.js new file mode 100644 index 0000000..f066a4f --- /dev/null +++ b/tts_bridge.js @@ -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); + }); +});