feat: web UI + QR login + portable build

- Add web UI panel (dark theme) at / with REST API
- Add Bilibili QR code scan login with browser header emulation
- Add wanakana for English -> katakana conversion
- Add volume control (0%-200%)
- Add message format with '说、' pause marker
- PyInstaller --onedir build for portable exe
- Rate-limit danmaku logging (1/20)
- Fix aiohttp.access log spam
- Add README.md
This commit is contained in:
2026-08-08 13:22:58 +08:00
parent 9b277fe2e0
commit fd5c732d8a
13 changed files with 1470 additions and 206 deletions
+123
View File
@@ -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
+7 -14
View File
@@ -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):
+226
View File
@@ -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
+177
View File
@@ -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())
+15 -34
View File
@@ -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()
+18 -21
View File
@@ -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:
+19 -86
View File
@@ -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:
+22
View File
@@ -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"
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "bililive-touhou-tts",
"type": "module",
"dependencies": {
"wanakana": "^5.3.1"
}
}
+278
View File
@@ -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())
+536
View File
@@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bilibili Live → Yukkuri TTS</title>
<style>
:root {
--bg: #1a1a2e;
--panel: #16213e;
--border: #0f3460;
--accent: #e94560;
--accent-hover: #ff6b81;
--green: #4ade80;
--text: #e2e8f0;
--text-dim: #94a3b8;
--input-bg: #0f1629;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex; justify-content: center; min-height: 100vh;
padding: 24px;
}
.container {
width: 100%; max-width: 680px;
display: flex; flex-direction: column; gap: 16px;
}
h1 {
font-size: 1.4rem; font-weight: 700;
text-align: center; color: var(--accent);
padding-bottom: 8px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.card h2 {
font-size: 0.95rem; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 1px;
margin-bottom: 14px;
}
.form-row {
display: flex; gap: 12px; margin-bottom: 12px;
flex-wrap: wrap;
}
.form-group {
display: flex; flex-direction: column; gap: 4px;
flex: 1; min-width: 140px;
}
.form-group label {
font-size: 0.8rem; color: var(--text-dim);
}
.form-group input, .form-group select {
padding: 8px 12px; border-radius: 6px;
border: 1px solid var(--border);
background: var(--input-bg); color: var(--text);
font-size: 0.9rem; outline: none;
}
.form-group input:focus, .form-group select:focus {
border-color: var(--accent);
}
input[type="range"] {
accent-color: var(--accent);
border: none; background: transparent; padding: 0;
}
.btn-row {
display: flex; gap: 10px; margin-top: 4px;
}
.btn {
padding: 10px 24px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-start {
background: var(--green); color: #1a1a2e;
flex: 1;
}
.btn-start:hover { opacity: 0.9; }
.btn-stop {
background: var(--accent); color: white;
flex: 1;
}
.btn-stop:hover { background: var(--accent-hover); }
.btn:disabled {
opacity: 0.4; cursor: not-allowed;
}
.btn-login {
background: transparent; color: var(--text);
border: 1px solid var(--border);
padding: 6px 16px; font-size: 0.85rem; cursor: pointer;
border-radius: 6px; transition: background 0.15s;
}
.btn-login:hover { background: var(--input-bg); }
.btn-login.ready {
border-color: var(--green); color: var(--green);
}
/* QR Container */
.login-area {
display: flex; align-items: flex-start; gap: 20px;
margin-top: 4px; flex-wrap: wrap;
}
.qr-box {
background: white; border-radius: 8px;
padding: 12px; width: 180px; height: 180px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
position: relative; overflow: hidden;
}
.qr-box img {
width: 156px; height: 156px; image-rendering: pixelated;
}
.qr-box .qr-placeholder {
display: flex; flex-direction: column; align-items: center;
justify-content: center; color: #94a3b8; font-size: 0.82rem;
text-align: center; gap: 8px;
}
.qr-box .qr-placeholder svg {
width: 48px; height: 48px; opacity: 0.3;
}
.qr-overlay {
position: absolute; inset: 0;
background: rgba(15,22,41,0.92);
display: flex; align-items: center; justify-content: center;
border-radius: 8px; flex-direction: column; gap: 6px;
font-size: 0.85rem; text-align: center;
}
.qr-overlay .btn-refresh {
margin-top: 8px; padding: 6px 18px; border-radius: 6px;
border: 1px solid var(--accent); background: transparent;
color: var(--accent); font-size: 0.82rem; cursor: pointer;
transition: background 0.15s;
}
.qr-overlay .btn-refresh:hover { background: var(--accent); color: white; }
.qr-info {
display: flex; flex-direction: column; gap: 6px;
flex: 1; min-width: 180px;
}
.qr-info .status-text {
font-size: 0.88rem; line-height: 1.5;
}
.qr-info .user-info {
display: flex; align-items: center; gap: 10px;
font-size: 0.9rem; color: var(--green);
}
.qr-info .user-info .avatar {
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--green);
}
.logged-in-badge {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; font-size: 0.85rem;
}
.logged-in-badge .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--green); flex-shrink: 0;
}
/* Status bar */
.status-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; font-size: 0.85rem;
}
.status-dot {
width: 10px; height: 10px; border-radius: 50%;
display: inline-block;
}
.dot-off { background: #64748b; }
.dot-on { background: var(--green); animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.stats {
display: flex; gap: 20px; flex-wrap: wrap;
font-size: 0.82rem; color: var(--text-dim);
}
.stats span { color: var(--text); font-weight: 600; }
#log {
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
height: 320px; overflow-y: auto;
padding: 12px;
font-family: "Consolas", "Courier New", monospace;
font-size: 0.82rem; line-height: 1.5;
white-space: pre-wrap; word-break: break-all;
}
#log .msg { color: var(--text-dim); }
#log .msg:last-child { color: var(--text); }
/* spinner */
.spinner {
width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.2);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.8s linear infinite; margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<h1>Bilibili Live &rarr; Yukkuri TTS</h1>
<!-- Login Card -->
<div class="card" id="login-card">
<h2>Login</h2>
<div class="login-area">
<div class="qr-box" id="qr-box">
<div class="qr-placeholder" id="qr-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<rect x="6" y="6" width="2" height="2"/><rect x="10" y="6" width="2" height="2"/>
<rect x="14" y="6" width="2" height="2"/><rect x="16" y="6" width="2" height="2"/>
<rect x="6" y="10" width="2" height="2"/>
<rect x="16" y="10" width="2" height="2"/>
<rect x="6" y="14" width="2" height="2"/>
<rect x="10" y="14" width="2" height="2"/>
<rect x="6" y="16" width="2" height="2"/>
<rect x="14" y="16" width="2" height="2"/>
</svg>
<span>Click "Login"<br/>to get QR code</span>
</div>
<img id="qr-img" src="" alt="QR Code" style="display:none">
<div id="qr-overlay" style="display:none"></div>
</div>
<div class="qr-info">
<div id="login-status" class="status-text">
Not logged in — anonymous danmaku may have censored usernames.
</div>
<div>
<button class="btn btn-login" id="btn-login" onclick="startLogin()">Login with QR</button>
<button class="btn btn-login" id="btn-logout" onclick="doLogout()" style="display:none">Logout</button>
</div>
</div>
</div>
</div>
<!-- Config Card -->
<div class="card">
<h2>Configuration</h2>
<div class="form-row">
<div class="form-group">
<label for="room-id">Room ID</label>
<input type="number" id="room-id" placeholder="e.g. 12235923" value="">
</div>
<div class="form-group" style="max-width:120px">
<label for="voice">Voice</label>
<select id="voice">
<option value="f1">f1 (Reimu)</option>
<option value="f2">f2 (Marisa)</option>
<option value="m1">m1 (Male 1)</option>
<option value="m2">m2 (Male 2)</option>
<option value="r1">r1 (Robot)</option>
<option value="dvd">dvd</option>
<option value="imd1">imd1</option>
<option value="jgr">jgr</option>
</select>
</div>
<div class="form-group" style="max-width:140px">
<label for="speed">Speed (<span id="speed-val">100</span>)</label>
<input type="range" id="speed" min="50" max="300" value="100"
oninput="document.getElementById('speed-val').textContent=this.value">
</div>
<div class="form-group" style="max-width:140px">
<label for="volume">Volume (<span id="volume-val">100</span>%)</label>
<input type="range" id="volume" min="0" max="200" value="100"
oninput="document.getElementById('volume-val').textContent=this.value">
</div>
</div>
<div class="btn-row">
<button class="btn btn-start" id="btn-start" onclick="startTTS()">Start</button>
<button class="btn btn-stop" id="btn-stop" onclick="stopTTS()" disabled>Stop</button>
</div>
</div>
<!-- Status Card -->
<div class="card">
<div class="status-bar">
<span class="status-dot dot-off" id="status-dot"></span>
<span id="status-text">Stopped</span>
</div>
<div class="stats">
<div>Messages: <span id="stat-msgs">0</span></div>
<div>Uptime: <span id="stat-uptime">00:00</span></div>
</div>
<div id="log" style="margin-top:12px">
<span class="msg">Ready. Login with QR (optional), set Room ID, then click Start.</span>
</div>
</div>
</div>
<script>
let pollTimer = null;
let qrPollTimer = null;
let running = false;
let loginPollKey = null;
function fmtUptime(sec) {
const m = Math.floor(sec / 60), s = sec % 60;
return String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
// ── QR Login ────────────────────────────────────────────────────────────
async function startLogin() {
document.getElementById('btn-login').disabled = true;
document.getElementById('login-status').innerHTML = '<div class="spinner"></div> Generating...';
try {
const resp = await fetch('/api/qr/generate');
const data = await resp.json();
if (data.error) {
document.getElementById('login-status').textContent = 'Error: ' + data.error;
document.getElementById('btn-login').disabled = false;
return;
}
document.getElementById('qr-placeholder').style.display = 'none';
document.getElementById('qr-img').src = 'https://api.qrserver.com/v1/create-qr-code/?size=156x156&data=' + encodeURIComponent(data.url);
document.getElementById('qr-img').style.display = 'block';
document.getElementById('qr-overlay').style.display = 'none';
document.getElementById('login-status').textContent = '请使用Bilibili客户端扫码';
document.getElementById('btn-login').disabled = false;
document.getElementById('btn-login').textContent = 'Refresh QR';
loginPollKey = data.qrcode_key;
if (qrPollTimer) clearInterval(qrPollTimer);
qrPollTimer = setInterval(pollLoginStatus, 2000);
} catch (e) {
document.getElementById('login-status').textContent = 'Network error: ' + e.message;
document.getElementById('btn-login').disabled = false;
}
}
async function pollLoginStatus() {
if (!loginPollKey) return;
try {
const resp = await fetch('/api/qr/poll?key=' + loginPollKey);
const data = await resp.json();
const st = parseInt(data.status);
if (st === 0) {
// SUCCESS
clearInterval(qrPollTimer); qrPollTimer = null;
loginPollKey = null;
document.getElementById('qr-img').style.display = 'none';
document.getElementById('qr-placeholder').style.display = 'none';
showOverlay(false, '', '');
document.getElementById('login-status').innerHTML =
'<div class="user-info"><span class="status-dot dot-on"></span> Login: ' + escapeHtml(data.username || 'Unknown') + '</div>';
document.getElementById('btn-login').style.display = 'none';
document.getElementById('btn-logout').style.display = 'inline-block';
document.getElementById('btn-login').disabled = false;
document.getElementById('btn-login').textContent = 'Login with QR';
updateLoginStatusCard(true, data.username || '');
} else if (st === 86090) {
// Scanned, waiting
document.getElementById('login-status').textContent = '已扫码,请在手机上确认登录';
showOverlay(false, '', '');
} else if (st === 86101) {
// Not scanned
document.getElementById('login-status').textContent = '请使用Bilibili客户端扫码';
showOverlay(false, '', '');
} else if (st === 86038) {
// Expired
clearInterval(qrPollTimer); qrPollTimer = null;
loginPollKey = null;
showOverlay(true, 'expired', '二维码已过期');
document.getElementById('login-status').textContent = '二维码已过期,请点击刷新';
document.getElementById('btn-login').textContent = 'Refresh QR';
} else {
document.getElementById('login-status').textContent = data.message || ('Status: ' + st);
}
} catch (e) {
// ignore network errors during polling
}
}
function showOverlay(show, cls, text) {
const overlay = document.getElementById('qr-overlay');
if (show) {
overlay.style.display = 'flex';
overlay.innerHTML = '<span>' + text + '</span>' +
'<button class="btn-refresh" onclick="startLogin()">刷新二维码</button>';
} else {
overlay.style.display = 'none';
overlay.innerHTML = '';
}
}
async function doLogout() {
try {
await fetch('/api/logout');
} catch(e) {}
loginPollKey = null;
if (qrPollTimer) { clearInterval(qrPollTimer); qrPollTimer = null; }
document.getElementById('qr-img').style.display = 'none';
document.getElementById('qr-img').src = '';
document.getElementById('qr-placeholder').style.display = 'flex';
document.getElementById('qr-overlay').style.display = 'none';
document.getElementById('login-status').textContent = 'Not logged in — anonymous danmaku may have censored usernames.';
document.getElementById('btn-login').style.display = 'inline-block';
document.getElementById('btn-logout').style.display = 'none';
document.getElementById('btn-login').textContent = 'Login with QR';
}
function updateLoginStatusCard(loggedIn, username) {
// Visual indicator in login card already handled above.
}
// ── TTS Control ──────────────────────────────────────────────────────────
async function startTTS() {
const roomId = document.getElementById('room-id').value;
if (!roomId) { alert('Please enter a Room ID'); return; }
const config = {
room_id: parseInt(roomId),
voice: document.getElementById('voice').value,
speed: parseInt(document.getElementById('speed').value),
volume: parseInt(document.getElementById('volume').value),
};
try {
const resp = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await resp.json();
if (data.error) { alert('Error: ' + data.error); return; }
running = true;
updateButtons();
startPolling();
} catch (e) {
alert('Failed to connect: ' + e.message);
}
}
async function stopTTS() {
try {
await fetch('/api/stop', { method: 'POST' });
} catch(e) {}
running = false;
updateButtons();
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
document.getElementById('status-dot').className = 'status-dot dot-off';
document.getElementById('status-text').textContent = 'Stopped';
}
function updateButtons() {
document.getElementById('btn-start').disabled = running;
document.getElementById('btn-stop').disabled = !running;
for (const el of ['room-id', 'voice', 'speed', 'volume']) {
document.getElementById(el).disabled = running;
}
document.getElementById('btn-login').disabled = running;
document.getElementById('btn-logout').disabled = running;
}
async function pollStatus() {
try {
const resp = await fetch('/api/status');
const s = await resp.json();
if (!s.running && running) {
running = false;
updateButtons();
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
const dot = document.getElementById('status-dot');
const txt = document.getElementById('status-text');
if (s.running) {
dot.className = 'status-dot dot-on';
txt.textContent = 'Running';
} else {
dot.className = 'status-dot dot-off';
txt.textContent = 'Stopped';
}
document.getElementById('stat-msgs').textContent = s.messages_processed;
document.getElementById('stat-uptime').textContent = fmtUptime(s.uptime);
const log = document.getElementById('log');
const msgs = s.recent_messages || [];
log.innerHTML = msgs.length > 0
? msgs.map(m => '<span class="msg">' + escapeHtml(m) + '</span>').join('\n')
: '<span class="msg">Waiting for danmaku...</span>';
log.scrollTop = log.scrollHeight;
} catch(e) {
// Server may have stopped
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollStatus, 400);
pollStatus();
}
// ── Init: check existing login ──────────────────────────────────────────
async function checkExistingLogin() {
try {
const resp = await fetch('/api/login/status');
const data = await resp.json();
if (data.logged_in) {
document.getElementById('login-status').innerHTML =
'<div class="user-info"><span class="status-dot dot-on"></span> Login: ' + escapeHtml(data.username) + '</div>';
document.getElementById('btn-login').style.display = 'none';
document.getElementById('btn-logout').style.display = 'inline-block';
document.getElementById('qr-placeholder').style.display = 'none';
}
} catch(e) {}
}
checkExistingLogin();
</script>
</body>
</html>
+28 -27
View File
@@ -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:
+14 -24
View File
@@ -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));
});