diff --git a/README.md b/README.md new file mode 100644 index 0000000..55ff57e --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# bililive-touhou-tts + +Bilibili 直播弹幕 + ゆっくり TTS 语音朗读器。 + +自动连接指定 Bilibili 直播间,将弹幕实时转换为日语片假名,通过 **AquesTalk** 引擎以ゆっくり语音朗读出来。 + +## 功能 + +- 实时接收 Bilibili 直播弹幕 +- 中文 → 拼音 → 片假名自动转换 +- 英文按罗马音转片假名(wanakana) +- 可选扫码登录(获取未打码用户名) +- Web 控制面板(暗色主题) +- 支持 8 种ゆっくり音色 + 语速/音量调节 +- 即开即用,无需安装任何运行时 + +## 快速开始 + +### 方式 1:便携版(推荐) + +解压 `bililive-touhou-tts-portable.zip`,双击 `start.bat`,浏览器自动打开控制面板。 + +无需安装 Python 或 Node.js。 + +### 方式 2:源码运行 + +```bash +# 安装依赖 +pip install -r requirements.txt +cd aquestalk.js && npm install && npx tsc && cd .. +npm install + +# 启动服务 +python server.py + +# 打开浏览器 +# http://127.0.0.1:8080 +``` + +## 使用说明 + +1. 浏览器打开 `http://127.0.0.1:8080` +2. (可选)点击「Login with QR」用 Bilibili 客户端扫码登录 +3. 输入直播间房间号 +4. 选择音色、语速、音量 +5. 点击「Start」 + +弹幕会实时显示在日志区域,同时通过扬声器朗读。 + +## 命令行参数 + +``` +python server.py [--port PORT] [--host HOST] [--debug] +python main.py --room-id ROOM_ID [--voice VOICE] [--speed SPEED] +``` + +### server.py 参数 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `--port` / `-p` | `8080` | HTTP 服务端口 | +| `--host` | `127.0.0.1` | 绑定地址 | +| `--debug` / `-d` | 关闭 | 调试日志 | + +### main.py 参数(纯 CLI 模式) + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `--room-id` / `-r` | 必填 | Bilibili 直播房间号 | +| `--voice` / `-v` | `f1` | 音色 | +| `--speed` / `-s` | `100` | 语速 (50-300) | +| `--sessdata` | 空 | Bilibili cookie | +| `--list-devices` | — | 列出音频设备 | + +## 语音类型 + +| 音色 | 说明 | +|------|------| +| `f1` | 靈夢 (标准ゆっくり女声) | +| `f2` | 魔理沙 | +| `m1` | 男声1 | +| `m2` | 男声2 | +| `r1` | 机器人 | +| `dvd` | DVD | +| `imd1` | imd1 | +| `jgr` | jgr | + +## 技术架构 + +``` +Bilibili 弹幕 (blivedm) + │ + ▼ +中文→片假名 (pypinyin + 映射表) + │ + ▼ +英文→片假名 (wanakana) + │ + ▼ +AquesTalk TTS (aquestalk.js + v86 WASM 模拟) + │ + ▼ +WAV 音频播放 (sounddevice) +``` + +## 子项目 + +| 目录 | 说明 | +|------|------| +| `aquestalk.js/` | AquesTalk TTS 引擎 (WebAssembly x86 模拟) | +| `blivedm/` | Bilibili 直播弹幕 Python 库 | +| `zh-yukuuri/` | 中文→ゆっくり Web 前端 (参考) | + +## 构建便携包 + +```bash +python build.py +# 输出: dist/bililive-touhou-tts-portable.zip +``` + +## 许可 + +MIT License diff --git a/audio_player.py b/audio_player.py index 5bb040b..ac38507 100644 --- a/audio_player.py +++ b/audio_player.py @@ -4,35 +4,28 @@ import asyncio import io import logging +import numpy as np 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. - """ +def play_wav(wav_data: bytes, volume: int = 100) -> None: data, samplerate = sf.read(io.BytesIO(wav_data), dtype="float32") + if volume != 100: + gain = max(0.0, volume / 100.0) + data = np.clip(data * gain, -1.0, 1.0) 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. - """ +async def play_wav_async(wav_data: bytes, volume: int = 100) -> None: loop = asyncio.get_running_loop() - await loop.run_in_executor(None, play_wav, wav_data) + await loop.run_in_executor(None, play_wav, wav_data, volume) def get_output_devices() -> list[dict]: - """List available audio output devices.""" devices = sd.query_devices() result = [] for i, dev in enumerate(devices): diff --git a/bili_login.py b/bili_login.py new file mode 100644 index 0000000..c52bbe0 --- /dev/null +++ b/bili_login.py @@ -0,0 +1,226 @@ +"""Bilibili QR Code Login with full browser header emulation. + +Mimics a modern Chrome browser to perform QR code scan login for Bilibili. +Returns cookies (SESSDATA, bili_jct, DedeUserID, etc.) that can be injected +into the danmaku client's HTTP session. +""" + +import time +import logging +from typing import Optional + +import aiohttp + +logger = logging.getLogger(__name__) + +BROWSER_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7", + "Accept-Encoding": "gzip, deflate, br", + "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", +} + +_QR_GENERATE_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate" +_QR_POLL_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll" +_NAV_URL = "https://api.bilibili.com/x/web-interface/nav" + +STATUS_NOT_SCANNED = 86101 +STATUS_SCANNED_WAITING = 86090 +STATUS_SUCCESS = 0 +STATUS_EXPIRED = 86038 + + +class BiliLoginSession: + """Manages Bilibili QR code login with browser-like HTTP session.""" + + def __init__(self): + self._cookies: dict[str, str] = {} + self._qrcode_key: Optional[str] = None + self._is_logged_in = False + self._username: str = "" + self._uid: int = 0 + + @property + def is_logged_in(self) -> bool: + return self._is_logged_in + + @property + def username(self) -> str: + return self._username + + @property + def uid(self) -> int: + return self._uid + + @property + def cookies(self) -> dict[str, str]: + return dict(self._cookies) + + async def _create_session(self) -> aiohttp.ClientSession: + import http.cookies + h = dict(BROWSER_HEADERS) + h["Referer"] = "https://www.bilibili.com/" + h["Origin"] = "https://www.bilibili.com" + session = aiohttp.ClientSession(headers=h) + if self._cookies: + simple = http.cookies.SimpleCookie() + for name, value in self._cookies.items(): + simple[name] = value + simple[name]["domain"] = ".bilibili.com" + session.cookie_jar.update_cookies(simple) + return session + + async def generate_qrcode(self) -> dict: + session = await self._create_session() + try: + async with session.get(_QR_GENERATE_URL) as resp: + data = await resp.json() + if data.get("code") != 0: + raise RuntimeError(f"QR generate failed: {data}") + inner = data["data"] + self._qrcode_key = inner["qrcode_key"] + logger.info("QR code generated, key=%s", self._qrcode_key) + return { + "url": inner["url"], + "qrcode_key": inner["qrcode_key"], + } + finally: + await session.close() + + async def poll_login(self) -> dict: + if not self._qrcode_key: + raise RuntimeError("No QR code generated. Call generate_qrcode() first.") + + session = await self._create_session() + try: + params = {"qrcode_key": self._qrcode_key} + async with session.get(_QR_POLL_URL, params=params) as resp: + data = await resp.json() + if data.get("code") != 0: + logger.warning("QR poll API error: %s", data) + return {"status": -1, "message": f"API error: {data.get('message', 'unknown')}"} + + inner = data["data"] + sc = inner["code"] + + if sc == STATUS_SUCCESS: + self._is_logged_in = True + self._extract_cookies(session) + await self._fetch_user_info() + logger.info("QR login success, user=%s", self._username) + return { + "status": STATUS_SUCCESS, + "message": f"登录成功: {self._username}", + "cookies": dict(self._cookies), + "username": self._username, + "uid": self._uid, + } + if sc == STATUS_NOT_SCANNED: + return {"status": STATUS_NOT_SCANNED, "message": "请使用Bilibili客户端扫码"} + if sc == STATUS_SCANNED_WAITING: + return {"status": STATUS_SCANNED_WAITING, "message": "已扫码,请在手机上确认登录"} + if sc == STATUS_EXPIRED: + self._qrcode_key = None + return {"status": STATUS_EXPIRED, "message": "二维码已过期,请重新获取"} + + return {"status": sc, "message": inner.get("message", f"未知状态: {sc}")} + finally: + await session.close() + + def _extract_cookies(self, session: aiohttp.ClientSession) -> None: + jar = session.cookie_jar + for cookie in jar: + if cookie.key in ("SESSDATA", "bili_jct", "DedeUserID", "DedeUserID__ckMd5", + "sid", "buvid3", "buvid4", "b_nut"): + self._cookies[cookie.key] = cookie.value + logger.debug("Extracted cookies: %s", list(self._cookies.keys())) + + async def _fetch_user_info(self) -> None: + session = await self._create_session() + try: + async with session.get(_NAV_URL) as resp: + data = await resp.json() + if data.get("code") == 0: + inner = data["data"] + self._username = inner.get("uname", "") + self._uid = inner.get("mid", 0) + logger.info("User info: %s (uid=%d)", self._username, self._uid) + else: + logger.warning("Failed to fetch user info: %s", data) + finally: + await session.close() + + def clear(self) -> None: + self._cookies = {} + self._qrcode_key = None + self._is_logged_in = False + self._username = "" + self._uid = 0 + + async def save_cookies(self, path: str = "cookies.json") -> None: + import json + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "cookies": self._cookies, + "username": self._username, + "uid": self._uid, + }, f, ensure_ascii=False, indent=2) + logger.info("Cookies saved to %s", path) + + async def try_auto_login(self, path: str = "cookies.json") -> bool: + import json, os + if not os.path.exists(path): + return False + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + cookies = data.get("cookies", {}) + if not cookies.get("SESSDATA"): + return False + result = await self.check_login(cookies) + if result["valid"]: + logger.info("Auto-login success: %s", self._username) + return True + else: + os.remove(path) + logger.info("Cached cookies expired, removed") + return False + except Exception as e: + logger.warning("Failed to load cached cookies: %s", e) + return False + + +async def build_danmaku_session(cookies: Optional[dict[str, str]] = None) -> aiohttp.ClientSession: + """Build a browser-emulating aiohttp session for danmaku connections. + + Args: + cookies: Optional dict of cookies to inject. + + Returns: + aiohttp.ClientSession with browser headers and cookies configured. + """ + import http.cookies + + h = dict(BROWSER_HEADERS) + h["Referer"] = "https://live.bilibili.com/" + h["Origin"] = "https://live.bilibili.com" + + session = aiohttp.ClientSession(headers=h) + if cookies: + simple = http.cookies.SimpleCookie() + for key, value in cookies.items(): + simple[key] = value + simple[key]["domain"] = ".bilibili.com" + session.cookie_jar.update_cookies(simple) + + return session diff --git a/build.py b/build.py new file mode 100644 index 0000000..3c716c6 --- /dev/null +++ b/build.py @@ -0,0 +1,177 @@ +"""Build portable zip package: bililive-touhou-tts-portable.zip + +Usage: + python build.py + +Output: dist/bililive-touhou-tts-portable.zip +""" + +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).parent +DIST = ROOT / "dist" +BUILD_DIR = DIST / "portable" + + +def step(msg: str) -> None: + print(f"\n=== {msg} ===") + + +def safe_copy_tree(src: Path, dst: Path, ignore_patterns: tuple = ()) -> None: + """Copy directory tree, skipping symlinks/recursion.""" + if dst.exists(): + shutil.rmtree(dst, ignore_errors=True) + dst.mkdir(parents=True, exist_ok=True) + + to_visit = [(src, dst)] + while to_visit: + s, d = to_visit.pop() + try: + for entry in s.iterdir(): + if entry.name.startswith(".") and entry.name != ".npmignore": + continue + if entry.name in ignore_patterns: + continue + if entry.name == "__pycache__": + continue + + target = d / entry.name + + if entry.is_symlink(): + continue # skip symlinks entirely + + if entry.is_dir(): + if target.exists(): + try: + shutil.rmtree(target, ignore_errors=True) + except OSError: + continue + target.mkdir(parents=True, exist_ok=True) + to_visit.append((entry, target)) + else: + try: + shutil.copy2(entry, target) + except OSError: + pass # skip unreadable files + except OSError: + pass # skip unreadable dirs + + +def main() -> int: + DIST.mkdir(exist_ok=True) + if BUILD_DIR.exists(): + print(" Cleaning previous build...") + for item in BUILD_DIR.iterdir(): + try: + if item.is_dir(): + shutil.rmtree(item, ignore_errors=True) + else: + item.unlink(missing_ok=True) + except OSError: + pass + BUILD_DIR.mkdir(parents=True, exist_ok=True) + + # ── 1. PyInstaller ───────────────────────────────────────────────── + step("Building Python EXE with PyInstaller") + subprocess.run( + [ + sys.executable, "-m", "PyInstaller", + "--onedir", + "--name", "bililive-tts", + "--add-data", f"{ROOT / 'static'}{os.pathsep}static", + "--hidden-import", "blivedm", + "--hidden-import", "blivedm.clients", + "--hidden-import", "blivedm.clients.web", + "--hidden-import", "blivedm.clients.ws_base", + "--hidden-import", "blivedm.handlers", + "--hidden-import", "blivedm.models", + "--hidden-import", "blivedm.models.web", + "--hidden-import", "blivedm.utils", + "--hidden-import", "pypinyin", + "--hidden-import", "sounddevice", + "--hidden-import", "soundfile", + "--hidden-import", "numpy", + "--hidden-import", "aiohttp", + "--hidden-import", "Brotli", + "--hidden-import", "pure_protobuf", + "--hidden-import", "yarl", + "--hidden-import", "chinese2kana", + "--hidden-import", "pinyin2kana_data", + "--hidden-import", "audio_player", + "--hidden-import", "danmaku_handler", + "--hidden-import", "tts", + "--hidden-import", "bili_login", + "--distpath", str(BUILD_DIR / "app"), + str(ROOT / "server.py"), + ], + ) + exe = BUILD_DIR / "app" / "bililive-tts" / "bililive-tts.exe" + if not exe.exists(): + print(f"ERROR: PyInstaller failed, {exe} not found") + return 1 + print(f" OK: {exe} ({exe.stat().st_size // 1024 // 1024} MB)") + + # ── 2. Portable Node.js ──────────────────────────────────────────── + step("Copying portable Node.js") + node_src = Path(os.environ.get("NODE_PATH", r"C:\nvm4w\nodejs\node.exe")) + shutil.copy2(node_src, BUILD_DIR / "node.exe") + print(f" OK: node.exe") + + # ── 3. aquestalk.js (only what's needed) ─────────────────────────── + step("Copying aquestalk.js runtime") + aq_src = ROOT / "aquestalk.js" + aq_dst = BUILD_DIR / "aquestalk.js" + + print(" dist/ ...") + safe_copy_tree(aq_src / "dist", aq_dst / "dist") + print(" voices/ ...") + safe_copy_tree(aq_src / "voices", aq_dst / "voices") + print(" node_modules/ (symlink-safe) ...") + 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") + 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 / "package.json", BUILD_DIR / "package.json") + print(" OK") + + # ── 6. Launcher ──────────────────────────────────────────────────── + step("Creating launcher") + bat = BUILD_DIR / "start.bat" + bat.write_text( + '@echo off\r\n' + 'cd /d "%~dp0"\r\n' + 'start "" "app\\bililive-tts\\bililive-tts.exe"\r\n', + encoding="ascii", + ) + print(f" OK") + + # ── 7. Zip ───────────────────────────────────────────────────────── + step("Creating zip") + zip_path = DIST / "bililive-touhou-tts-portable.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for file in BUILD_DIR.rglob("*"): + if ".git" in file.parts or "__pycache__" in file.parts: + continue + if file.is_dir(): + continue + arcname = str(file.relative_to(BUILD_DIR)) + zf.write(file, arcname) + + size_mb = zip_path.stat().st_size / (1024 * 1024) + print(f"\n Done: {zip_path} ({size_mb:.1f} MB)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/chinese2kana.py b/chinese2kana.py index 6a38dee..8416634 100644 --- a/chinese2kana.py +++ b/chinese2kana.py @@ -1,9 +1,4 @@ -"""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. -""" +"""Chinese text → Japanese Katakana converter.""" import re from pypinyin import pinyin, Style @@ -15,19 +10,26 @@ _CHINESE_SEGMENT_RE = re.compile(r"([\u4e00-\u9fa5]+)") _NUMBER_RE = re.compile(r"-?\d+(\.\d+)?") _TONE_RE = re.compile(r"\d") +_KANA_SAFE_RE = re.compile( + r"[\u3040-\u309F\u30A0-\u30FF\uFF65-\uFF9F" + r"\u30FC\u309B\u309C" + r"\u3001\u3002\uFF01\uFF1F" + r"\u300C\u300D" + r"\u30FB\u3000" + r"a-zA-Z0-9 ]" +) + _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 = "零" @@ -47,57 +49,33 @@ def _number_to_chinese(num_str: str) -> str: 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] @@ -105,9 +83,12 @@ def chinese_to_kana(text: str, convert_numbers: bool = True) -> str: 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 + + +def filter_kana(text: str) -> str: + filtered = "".join(c for c in text if _KANA_SAFE_RE.match(c)) + return filtered.strip().lower() diff --git a/danmaku_handler.py b/danmaku_handler.py index 1a712af..7899f36 100644 --- a/danmaku_handler.py +++ b/danmaku_handler.py @@ -1,6 +1,7 @@ """Bilibili Live Danmaku Handler. Connects to a Bilibili live room and puts incoming danmaku messages into an asyncio queue. +Supports cookie-based login with browser header emulation. """ import asyncio @@ -10,9 +11,11 @@ from typing import Optional import blivedm from blivedm.models.web import DanmakuMessage +from bili_login import build_danmaku_session + logger = logging.getLogger(__name__) -DANMAKU_FORMAT = "{uname}: {msg}" +DANMAKU_FORMAT = "{uname}\u8bf4\u3001 {msg}" class DanmakuHandler(blivedm.BaseHandler): @@ -22,6 +25,7 @@ class DanmakuHandler(blivedm.BaseHandler): super().__init__() self._queue = queue self._message_format = message_format + self._count = 0 def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None: if not message.msg.strip(): @@ -31,7 +35,11 @@ class DanmakuHandler(blivedm.BaseHandler): msg=message.msg, uid=message.uid, ) - logger.info("Danmaku: %s", text) + self._count += 1 + if self._count % 20 == 1: + logger.info("Danmaku #%d: %s", self._count, text) + else: + logger.debug("Danmaku #%d: %s", self._count, text) try: self._queue.put_nowait(text) except asyncio.QueueFull: @@ -57,44 +65,33 @@ class DanmakuClient: self, room_id: int, queue: asyncio.Queue, - sessdata: str = "", + cookies: Optional[dict[str, str]] = None, message_format: str = DANMAKU_FORMAT, ): self._room_id = room_id self._queue = queue - self._sessdata = sessdata + self._cookies = cookies self._message_format = message_format self._client: Optional[blivedm.BLiveClient] = None + self._session: Optional["aiohttp.ClientSession"] = 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, - ) - + self._session = await build_danmaku_session(self._cookies) + self._client = blivedm.BLiveClient(self._room_id, session=self._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 + if self._session is not None: + await self._session.close() + self._session = None @property def room_id(self) -> int: diff --git a/main.py b/main.py index 3c48d1a..9db8a53 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -"""bililive-touhou-tts — Bilibili Live Danmaku → Yukkuri TTS Reader. +"""Bilibili Live Danmaku → Yukkuri TTS Reader. Usage: python main.py --room-id 12345 @@ -10,7 +10,6 @@ import asyncio import logging import signal import sys -from pathlib import Path from danmaku_handler import DanmakuClient from chinese2kana import chinese_to_kana @@ -24,86 +23,37 @@ 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", - ) + parser = argparse.ArgumentParser(description="Bilibili Live Danmaku → Yukkuri TTS Reader") + parser.add_argument("--room-id", "-r", type=int, required=True) + parser.add_argument("--voice", "-v", type=str, default=DEFAULT_VOICE, + choices=["f1", "f2", "m1", "m2", "dvd", "imd1", "jgr", "r1"]) + parser.add_argument("--speed", "-s", type=int, default=DEFAULT_SPEED) + parser.add_argument("--sessdata", type=str, default="") + parser.add_argument("--format", "-f", type=str, + default="{uname}\u8bf4\u3001 {msg}") + parser.add_argument("--no-numbers", action="store_true") + parser.add_argument("--list-devices", action="store_true") + parser.add_argument("--debug", "-d", action="store_true") 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.""" +async def tts_worker(queue: asyncio.Queue, bridge: TTSBridge, + convert_numbers: bool, shutdown_event: asyncio.Event) -> None: 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) - + 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.") @@ -114,37 +64,28 @@ async def main_async(args: argparse.Namespace) -> int: 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, + room_id=args.room_id, queue=queue, + cookies={"SESSDATA": args.sessdata} if args.sessdata else None, message_format=args.format, ) - async def handle_signal() -> None: - """Wait for shutdown signal.""" + async def handle_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() @@ -152,38 +93,30 @@ async def main_async(args: argparse.Namespace) -> int: try: await bridge.start() await danmaku_client.start() - worker_task = asyncio.create_task( - tts_worker(queue, bridge, not args.no_numbers, shutdown_event) - ) - + 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: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c5a221 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..49ebcd6 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "bililive-touhou-tts", + "type": "module", + "dependencies": { + "wanakana": "^5.3.1" + } +} diff --git a/server.py b/server.py new file mode 100644 index 0000000..6cfa1a3 --- /dev/null +++ b/server.py @@ -0,0 +1,278 @@ +"""Bilibili Live → Yukkuri TTS — Web UI Server.""" + +import argparse +import asyncio +import json +import logging +import os +import sys +import time +from pathlib import Path + +from aiohttp import web + +sys.path.insert(0, os.path.dirname(__file__)) + +from danmaku_handler import DanmakuClient +from chinese2kana import chinese_to_kana, filter_kana +from tts import TTSBridge +from audio_player import play_wav_async, get_output_devices +from bili_login import BiliLoginSession + +logger = logging.getLogger("bililive-tts-server") + +def _get_static_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "static" + return Path(__file__).parent / "static" + +STATIC_DIR = _get_static_dir() + + +class TTSService: + def __init__(self): + self._bridge: TTSBridge | None = None + self._danmaku_client: DanmakuClient | None = None + self._queue: asyncio.Queue | None = None + self._shutdown_event: asyncio.Event | None = None + self._worker_task: asyncio.Task | None = None + self._cookies: dict[str, str] | None = None + self.running = False + self.start_time: float = 0 + self.messages_processed = 0 + self.recent_messages: list[str] = [] + self.current_config: dict = {} + self._volume: int = 100 + + def set_cookies(self, cookies: dict[str, str] | None) -> None: + self._cookies = cookies + + async def start(self, config: dict) -> None: + if self.running: + raise RuntimeError("Already running") + room_id = config["room_id"] + voice = config.get("voice", "f1") + speed = config.get("speed", 100) + self._volume = config.get("volume", 100) + message_format = config.get("format", "{uname}\u8bf4\u3001 {msg}") + convert_numbers = config.get("convert_numbers", True) + self.current_config = config + self.recent_messages = [] + self.messages_processed = 0 + self.start_time = time.time() + self._queue = asyncio.Queue(maxsize=256) + self._shutdown_event = asyncio.Event() + self._bridge = TTSBridge(voice=voice, speed=speed) + await self._bridge.start() + self._danmaku_client = DanmakuClient( + room_id=room_id, queue=self._queue, + cookies=self._cookies, message_format=message_format, + ) + await self._danmaku_client.start() + self.running = True + self._worker_task = asyncio.create_task(self._tts_worker(convert_numbers)) + + async def stop(self) -> None: + if not self.running: + return + self.running = False + if self._shutdown_event: + self._shutdown_event.set() + if self._worker_task: + self._worker_task.cancel() + try: + await self._worker_task + except asyncio.CancelledError: + pass + if self._danmaku_client: + await self._danmaku_client.stop() + if self._bridge: + await self._bridge.stop() + self._queue = None + self._bridge = None + self._danmaku_client = None + + async def _tts_worker(self, convert_numbers: bool) -> None: + total_in = 0 + total_out = 0 + while not self._shutdown_event.is_set(): + try: + text = await asyncio.wait_for(self._queue.get(), timeout=0.5) + except asyncio.TimeoutError: + continue + total_in += 1 + try: + kana = chinese_to_kana(text, convert_numbers=convert_numbers) + kana = filter_kana(kana) + if not kana.strip(): + self._add_message(f"SKIP(empty kana): {text}") + self._queue.task_done() + continue + self._add_message(f"[{total_in}] {text}") + logger.info("TTS #%d: %s -> %s", total_in, text[:40], kana[:40]) + wav_data = await self._bridge.synthesize(kana) + await play_wav_async(wav_data, self._volume) + total_out += 1 + self.messages_processed += 1 + except asyncio.TimeoutError: + self._add_message(f"TIMEOUT: {text[:60]}") + logger.error("TTS timeout for: %s", text[:60]) + except Exception as e: + self._add_message(f"ERR: {text[:60]} | {e}") + logger.error("TTS error: %s", str(e)[:200]) + finally: + self._queue.task_done() + + def _add_message(self, msg: str) -> None: + self.recent_messages.append(msg) + if len(self.recent_messages) > 200: + self.recent_messages = self.recent_messages[-200:] + + def status(self) -> dict: + uptime = time.time() - self.start_time if self.start_time else 0 + return { + "running": self.running, + "uptime": int(uptime), + "messages_processed": self.messages_processed, + "recent_messages": self.recent_messages[-50:], + "config": self.current_config, + } + + +tts_service = TTSService() +login_session = BiliLoginSession() + + +async def index_handler(request: web.Request) -> web.Response: + return web.FileResponse(STATIC_DIR / "index.html") + + +async def api_start(request: web.Request) -> web.Response: + try: + config = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + room_id = config.get("room_id") + if not room_id: + return web.json_response({"error": "room_id is required"}, status=400) + try: + room_id = int(room_id) + except (ValueError, TypeError): + return web.json_response({"error": "room_id must be an integer"}, status=400) + config["room_id"] = room_id + config.setdefault("voice", "f1") + config.setdefault("speed", 100) + config.setdefault("volume", 100) + config.setdefault("format", "{uname}\u8bf4\u3001 {msg}") + config.setdefault("convert_numbers", True) + if tts_service.running: + try: + await tts_service.stop() + except Exception: + pass + tts_service.set_cookies(login_session.cookies if login_session.is_logged_in else None) + try: + await tts_service.start(config) + except Exception as e: + logger.exception("Failed to start TTS service") + return web.json_response({"error": str(e)}, status=500) + return web.json_response({"status": "started"}) + + +async def api_stop(request: web.Request) -> web.Response: + try: + await tts_service.stop() + except Exception as e: + logger.exception("Failed to stop TTS service") + return web.json_response({"error": str(e)}, status=500) + return web.json_response({"status": "stopped"}) + + +async def api_status(request: web.Request) -> web.Response: + s = tts_service.status() + s["login"] = { + "logged_in": login_session.is_logged_in, + "username": login_session.username, + "uid": login_session.uid, + } + return web.json_response(s) + + +async def api_devices(request: web.Request) -> web.Response: + try: + devices = get_output_devices() + return web.json_response({"devices": devices}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + +async def api_qr_generate(request: web.Request) -> web.Response: + try: + result = await login_session.generate_qrcode() + return web.json_response(result) + except Exception as e: + logger.exception("QR generate failed") + return web.json_response({"error": str(e)}, status=500) + + +async def api_qr_poll(request: web.Request) -> web.Response: + try: + result = await login_session.poll_login() + return web.json_response(result) + except Exception as e: + logger.exception("QR poll failed") + return web.json_response({"error": str(e)}, status=500) + + +async def api_login_status(request: web.Request) -> web.Response: + return web.json_response({ + "logged_in": login_session.is_logged_in, + "username": login_session.username, + "uid": login_session.uid, + }) + + +async def api_logout(request: web.Request) -> web.Response: + login_session.clear() + return web.json_response({"status": "logged_out"}) + + +def create_app() -> web.Application: + app = web.Application() + app.router.add_get("/", index_handler) + app.router.add_post("/api/start", api_start) + app.router.add_post("/api/stop", api_stop) + app.router.add_get("/api/status", api_status) + app.router.add_get("/api/devices", api_devices) + app.router.add_get("/api/qr/generate", api_qr_generate) + app.router.add_get("/api/qr/poll", api_qr_poll) + app.router.add_get("/api/login/status", api_login_status) + app.router.add_get("/api/logout", api_logout) + return app + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Bilibili Live → Yukkuri TTS Web UI") + parser.add_argument("--port", "-p", type=int, default=8080) + parser.add_argument("--host", type=str, default="127.0.0.1") + parser.add_argument("--debug", "-d", action="store_true") + return parser.parse_args() + + +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", + ) + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) + app = create_app() + print(f"\n Bilibili Live → Yukkuri TTS Web UI") + print(f" Open: http://{args.host}:{args.port}\n") + web.run_app(app, host=args.host, port=args.port, print=lambda *a: None) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..10a84b9 --- /dev/null +++ b/static/index.html @@ -0,0 +1,536 @@ + + + + + +Bilibili Live → Yukkuri TTS + + + +
+

Bilibili Live → Yukkuri TTS

+ + +
+

Login

+ +
+ + +
+

Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + Stopped +
+
+
Messages: 0
+
Uptime: 00:00
+
+
+ Ready. Login with QR (optional), set Room ID, then click Start. +
+
+
+ + + + diff --git a/tts.py b/tts.py index b2a0015..0735217 100644 --- a/tts.py +++ b/tts.py @@ -3,22 +3,39 @@ 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) +Supports PyInstaller frozen mode: uses local node.exe and tts_bridge.js. """ import asyncio import os import pathlib +import sys import tempfile import logging logger = logging.getLogger(__name__) +def _get_portable_root() -> pathlib.Path: + return pathlib.Path(sys.executable).parent.parent.parent + + +def _get_node_exe() -> str: + if getattr(sys, "frozen", False): + p = _get_portable_root() / "node.exe" + if p.exists(): + return str(p) + return "node" + + +def _resolve_path(rel: str) -> str: + if getattr(sys, "frozen", False): + p = _get_portable_root() / rel + if p.exists(): + return str(p) + return str(pathlib.Path(__file__).parent / rel) + + class TTSBridge: """Manages a persistent Node.js subprocess for aquestalk.js TTS synthesis.""" @@ -26,25 +43,18 @@ class TTSBridge: 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") + self._bridge_script = bridge_script or _resolve_path("tts_bridge.js") + self._node_exe = _get_node_exe() 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}") + if not pathlib.Path(self._bridge_script).exists(): + raise FileNotFoundError(f"Bridge script not found: {self._bridge_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, + self._node_exe, + self._bridge_script, "--voice", self._voice, "--speed", str(self._speed), stdin=asyncio.subprocess.PIPE, @@ -64,14 +74,6 @@ class TTSBridge: 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.") @@ -110,7 +112,6 @@ class TTSBridge: pass async def stop(self) -> None: - """Stop the bridge process.""" if self._process is not None: logger.info("Stopping TTS bridge...") try: diff --git a/tts_bridge.js b/tts_bridge.js index f066a4f..b69a011 100644 --- a/tts_bridge.js +++ b/tts_bridge.js @@ -1,25 +1,17 @@ /** 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 +Converts English/romaji to katakana via wanakana before synthesis. */ 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); -// Parse CLI arguments const args = process.argv.slice(2); let voice = "f1"; let speed = 100; @@ -32,8 +24,7 @@ for (let i = 0; i < args.length; i++) { } } -// Import aquestalk.js from sibling directory -const aquestalkMod = pathToFileURL(join(__dirname, "..", "aquestalk.js", "dist", "index.js")).href; +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}...`); @@ -48,6 +39,14 @@ try { process.exit(1); } +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); + return Buffer.from(wav); +} + process.stdout.write("READY\n"); const rl = createInterface({ @@ -59,28 +58,21 @@ const rl = createInterface({ 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)); - + const wav = synthesize(kanaText); + writeFileSync(outputPath, wav); process.stdout.write(`OK:${outputPath}\n`); } catch (err) { process.stdout.write(`ERR:${err.message}\n`); @@ -89,7 +81,5 @@ rl.on("line", (line) => { rl.on("close", () => { console.error("[bridge] stdin closed, shutting down."); - aq.destroy().then(() => { - process.exit(0); - }); + aq.destroy().then(() => process.exit(0)); });