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 io
import logging import logging
import numpy as np
import sounddevice as sd import sounddevice as sd
import soundfile as sf import soundfile as sf
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def play_wav(wav_data: bytes) -> None: def play_wav(wav_data: bytes, volume: int = 100) -> None:
"""Play WAV audio data synchronously (blocking).
Args:
wav_data: Raw WAV file bytes.
"""
data, samplerate = sf.read(io.BytesIO(wav_data), dtype="float32") 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.play(data, samplerate)
sd.wait() sd.wait()
async def play_wav_async(wav_data: bytes) -> None: async def play_wav_async(wav_data: bytes, volume: int = 100) -> None:
"""Play WAV audio data asynchronously.
Args:
wav_data: Raw WAV file bytes.
"""
loop = asyncio.get_running_loop() 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]: def get_output_devices() -> list[dict]:
"""List available audio output devices."""
devices = sd.query_devices() devices = sd.query_devices()
result = [] result = []
for i, dev in enumerate(devices): 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. """Chinese text → Japanese Katakana converter."""
Converts Chinese text to Japanese kana (katakana) suitable for AquesTalk TTS.
Non-Chinese characters (Japanese kana, English, emoji, etc.) pass through unchanged.
Uses pypinyin for pinyin extraction and an internal pinyin→katakana mapping table.
"""
import re import re
from pypinyin import pinyin, Style from pypinyin import pinyin, Style
@@ -15,19 +10,26 @@ _CHINESE_SEGMENT_RE = re.compile(r"([\u4e00-\u9fa5]+)")
_NUMBER_RE = re.compile(r"-?\d+(\.\d+)?") _NUMBER_RE = re.compile(r"-?\d+(\.\d+)?")
_TONE_RE = re.compile(r"\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_DIGITS = ["", "", "", "", "", "", "", "", "", ""]
_CHINESE_UNITS = ["", "", "", "", ""] _CHINESE_UNITS = ["", "", "", "", ""]
_CHINESE_POINT = "" _CHINESE_POINT = ""
def _number_to_chinese(num_str: str) -> str: def _number_to_chinese(num_str: str) -> str:
"""Convert an Arabic numeral string (integer or decimal) to Chinese words."""
num_str = num_str.strip("-") num_str = num_str.strip("-")
if "." in num_str: if "." in num_str:
integer_part, decimal_part = num_str.split(".", 1) integer_part, decimal_part = num_str.split(".", 1)
else: else:
integer_part, decimal_part = num_str, "" integer_part, decimal_part = num_str, ""
result = "" result = ""
if integer_part == "0" or integer_part == "": if integer_part == "0" or integer_part == "":
result = "" result = ""
@@ -47,57 +49,33 @@ def _number_to_chinese(num_str: str) -> str:
result += _CHINESE_UNITS[4] result += _CHINESE_UNITS[4]
else: else:
result += _CHINESE_UNITS[unit_idx] result += _CHINESE_UNITS[unit_idx]
if decimal_part: if decimal_part:
result += _CHINESE_POINT result += _CHINESE_POINT
for ch in decimal_part: for ch in decimal_part:
result += _CHINESE_DIGITS[int(ch)] result += _CHINESE_DIGITS[int(ch)]
return result return result
def _strip_tone(py: str) -> str: def _strip_tone(py: str) -> str:
"""Remove tone numbers from pinyin, e.g. 'ni3' -> 'ni'."""
return _TONE_RE.sub("", py) return _TONE_RE.sub("", py)
def chinese_to_kana(text: str, convert_numbers: bool = True) -> str: 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(): if not text or not text.strip():
return "" return ""
working = text working = text
if convert_numbers: if convert_numbers:
def _replace_num(m: re.Match) -> str: def _replace_num(m: re.Match) -> str:
return _number_to_chinese(m.group(0)) return _number_to_chinese(m.group(0))
working = _NUMBER_RE.sub(_replace_num, working) working = _NUMBER_RE.sub(_replace_num, working)
if not _CHINESE_CHAR_RE.search(working): if not _CHINESE_CHAR_RE.search(working):
return text return text
segments = _CHINESE_SEGMENT_RE.split(working) segments = _CHINESE_SEGMENT_RE.split(working)
result_parts: list[str] = [] result_parts: list[str] = []
for seg in segments: for seg in segments:
if not seg: if not seg:
continue continue
if _CHINESE_CHAR_RE.match(seg[0]): if _CHINESE_CHAR_RE.match(seg[0]):
# Chinese segment: convert each character via pinyin -> katakana
py_list = pinyin(seg, style=Style.TONE3, heteronym=False) py_list = pinyin(seg, style=Style.TONE3, heteronym=False)
for py_item in py_list: for py_item in py_list:
py_raw = py_item[0] 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) kana = PINYIN2KANA.get(py_plain, py_plain)
result_parts.append(kana) result_parts.append(kana)
else: else:
# Non-Chinese segment: pass through as-is
result_parts.append(seg) result_parts.append(seg)
result = "".join(result_parts) result = "".join(result_parts)
result = re.sub(r"(?<=\S) (?=\S)", "", result) result = re.sub(r"(?<=\S) (?=\S)", "", result)
return 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. """Bilibili Live Danmaku Handler.
Connects to a Bilibili live room and puts incoming danmaku messages into an asyncio queue. 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 import asyncio
@@ -10,9 +11,11 @@ from typing import Optional
import blivedm import blivedm
from blivedm.models.web import DanmakuMessage from blivedm.models.web import DanmakuMessage
from bili_login import build_danmaku_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DANMAKU_FORMAT = "{uname}: {msg}" DANMAKU_FORMAT = "{uname}\u8bf4\u3001 {msg}"
class DanmakuHandler(blivedm.BaseHandler): class DanmakuHandler(blivedm.BaseHandler):
@@ -22,6 +25,7 @@ class DanmakuHandler(blivedm.BaseHandler):
super().__init__() super().__init__()
self._queue = queue self._queue = queue
self._message_format = message_format self._message_format = message_format
self._count = 0
def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None: def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None:
if not message.msg.strip(): if not message.msg.strip():
@@ -31,7 +35,11 @@ class DanmakuHandler(blivedm.BaseHandler):
msg=message.msg, msg=message.msg,
uid=message.uid, 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: try:
self._queue.put_nowait(text) self._queue.put_nowait(text)
except asyncio.QueueFull: except asyncio.QueueFull:
@@ -57,44 +65,33 @@ class DanmakuClient:
self, self,
room_id: int, room_id: int,
queue: asyncio.Queue, queue: asyncio.Queue,
sessdata: str = "", cookies: Optional[dict[str, str]] = None,
message_format: str = DANMAKU_FORMAT, message_format: str = DANMAKU_FORMAT,
): ):
self._room_id = room_id self._room_id = room_id
self._queue = queue self._queue = queue
self._sessdata = sessdata self._cookies = cookies
self._message_format = message_format self._message_format = message_format
self._client: Optional[blivedm.BLiveClient] = None self._client: Optional[blivedm.BLiveClient] = None
self._session: Optional["aiohttp.ClientSession"] = None
async def start(self) -> None: async def start(self) -> None:
"""Connect to the live room and start receiving danmaku."""
import http.cookies
import aiohttp import aiohttp
self._session = await build_danmaku_session(self._cookies)
session = aiohttp.ClientSession() self._client = blivedm.BLiveClient(self._room_id, session=self._session)
if self._sessdata:
cookies = http.cookies.SimpleCookie()
cookies["SESSDATA"] = self._sessdata
cookies["SESSDATA"]["domain"] = "bilibili.com"
session.cookie_jar.update_cookies(cookies)
self._client = blivedm.BLiveClient(
self._room_id,
session=session,
)
handler = DanmakuHandler(self._queue, self._message_format) handler = DanmakuHandler(self._queue, self._message_format)
self._client.set_handler(handler) self._client.set_handler(handler)
self._client.start() self._client.start()
logger.info("Connected to room %d", self._room_id) logger.info("Connected to room %d", self._room_id)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the client and clean up."""
if self._client is not None: if self._client is not None:
logger.info("Disconnecting from room %d...", self._room_id) logger.info("Disconnecting from room %d...", self._room_id)
await self._client.stop_and_close() await self._client.stop_and_close()
self._client = None self._client = None
if self._session is not None:
await self._session.close()
self._session = None
@property @property
def room_id(self) -> int: 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: Usage:
python main.py --room-id 12345 python main.py --room-id 12345
@@ -10,7 +10,6 @@ import asyncio
import logging import logging
import signal import signal
import sys import sys
from pathlib import Path
from danmaku_handler import DanmakuClient from danmaku_handler import DanmakuClient
from chinese2kana import chinese_to_kana from chinese2kana import chinese_to_kana
@@ -24,86 +23,37 @@ DEFAULT_SPEED = 100
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Bilibili Live Danmaku → Yukkuri TTS Reader")
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,
parser.add_argument( choices=["f1", "f2", "m1", "m2", "dvd", "imd1", "jgr", "r1"])
"--room-id", "-r", parser.add_argument("--speed", "-s", type=int, default=DEFAULT_SPEED)
type=int, parser.add_argument("--sessdata", type=str, default="")
required=True, parser.add_argument("--format", "-f", type=str,
help="Bilibili live room ID (from the URL)", default="{uname}\u8bf4\u3001 {msg}")
) parser.add_argument("--no-numbers", action="store_true")
parser.add_argument( parser.add_argument("--list-devices", action="store_true")
"--voice", "-v", parser.add_argument("--debug", "-d", action="store_true")
type=str,
default=DEFAULT_VOICE,
choices=["f1", "f2", "m1", "m2", "dvd", "imd1", "jgr", "r1"],
help=f"Yukkuri voice type (default: {DEFAULT_VOICE})",
)
parser.add_argument(
"--speed", "-s",
type=int,
default=DEFAULT_SPEED,
help=f"Speech speed, 50-300 (default: {DEFAULT_SPEED})",
)
parser.add_argument(
"--sessdata",
type=str,
default="",
help="Bilibili SESSDATA cookie for authenticated access (optional)",
)
parser.add_argument(
"--format", "-f",
type=str,
default="{uname}: {msg}",
help="Danmaku message format. Variables: {uname}, {msg}, {uid}",
)
parser.add_argument(
"--no-numbers",
action="store_true",
help="Do not convert Arabic numerals to Chinese words",
)
parser.add_argument(
"--list-devices",
action="store_true",
help="List available audio output devices and exit",
)
parser.add_argument(
"--debug", "-d",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args() return parser.parse_args()
async def tts_worker( async def tts_worker(queue: asyncio.Queue, bridge: TTSBridge,
queue: asyncio.Queue, convert_numbers: bool, shutdown_event: asyncio.Event) -> None:
bridge: TTSBridge,
convert_numbers: bool,
shutdown_event: asyncio.Event,
) -> None:
"""Consume danmaku messages from the queue, convert to kana, and play TTS."""
logger.info("TTS worker started.") logger.info("TTS worker started.")
while not shutdown_event.is_set(): while not shutdown_event.is_set():
try: try:
text = await asyncio.wait_for(queue.get(), timeout=1.0) text = await asyncio.wait_for(queue.get(), timeout=1.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
try: try:
kana = chinese_to_kana(text, convert_numbers=convert_numbers) 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) wav_data = await bridge.synthesize(kana)
await play_wav_async(wav_data) await play_wav_async(wav_data)
except Exception: except Exception:
logger.exception("Error processing danmaku: %s", text) logger.exception("Error processing danmaku: %s", text)
finally: finally:
queue.task_done() queue.task_done()
logger.info("TTS worker stopped.") logger.info("TTS worker stopped.")
@@ -114,37 +64,28 @@ async def main_async(args: argparse.Namespace) -> int:
for dev in devices: for dev in devices:
print(f" [{dev['index']}] {dev['name']} ({dev['channels']}ch)") print(f" [{dev['index']}] {dev['name']} ({dev['channels']}ch)")
return 0 return 0
if not (50 <= args.speed <= 300): if not (50 <= args.speed <= 300):
logger.error("Speed must be between 50 and 300.") logger.error("Speed must be between 50 and 300.")
return 1 return 1
queue: asyncio.Queue = asyncio.Queue(maxsize=256) queue: asyncio.Queue = asyncio.Queue(maxsize=256)
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
bridge = TTSBridge(voice=args.voice, speed=args.speed) bridge = TTSBridge(voice=args.voice, speed=args.speed)
danmaku_client = DanmakuClient( danmaku_client = DanmakuClient(
room_id=args.room_id, room_id=args.room_id, queue=queue,
queue=queue, cookies={"SESSDATA": args.sessdata} if args.sessdata else None,
sessdata=args.sessdata,
message_format=args.format, message_format=args.format,
) )
async def handle_signal() -> None: async def handle_signal():
"""Wait for shutdown signal."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
sig_event = asyncio.Event() sig_event = asyncio.Event()
def _handler(): def _handler():
sig_event.set() sig_event.set()
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
try: try:
loop.add_signal_handler(sig, _handler) loop.add_signal_handler(sig, _handler)
except NotImplementedError: except NotImplementedError:
pass pass
await sig_event.wait() await sig_event.wait()
logger.info("Shutdown signal received.") logger.info("Shutdown signal received.")
shutdown_event.set() shutdown_event.set()
@@ -152,38 +93,30 @@ async def main_async(args: argparse.Namespace) -> int:
try: try:
await bridge.start() await bridge.start()
await danmaku_client.start() await danmaku_client.start()
worker_task = asyncio.create_task( 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() await handle_signal()
worker_task.cancel() worker_task.cancel()
try: try:
await worker_task await worker_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception: except Exception:
logger.exception("Fatal error") logger.exception("Fatal error")
return 1 return 1
finally: finally:
await danmaku_client.stop() await danmaku_client.stop()
await bridge.stop() await bridge.stop()
return 0 return 0
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO, level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
try: try:
return asyncio.run(main_async(args)) return asyncio.run(main_async(args))
except KeyboardInterrupt: 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 Starts a long-lived Node.js subprocess running tts_bridge.js. Communication uses
stdin/stdout with temporary files for text and WAV data. stdin/stdout with temporary files for text and WAV data.
Protocol: Supports PyInstaller frozen mode: uses local node.exe and tts_bridge.js.
Python → writes kana text to temp file
Python → sends "INPUT_FILE_PATH|OUTPUT_FILE_PATH\n" to bridge's stdin
Bridge → reads text, synthesizes, writes WAV to output path
Bridge → prints "OK:OUTPUT_FILE_PATH\n" to stdout (or "ERR:message\n" on error)
""" """
import asyncio import asyncio
import os import os
import pathlib import pathlib
import sys
import tempfile import tempfile
import logging import logging
logger = logging.getLogger(__name__) 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: class TTSBridge:
"""Manages a persistent Node.js subprocess for aquestalk.js TTS synthesis.""" """Manages a persistent Node.js subprocess for aquestalk.js TTS synthesis."""
@@ -26,25 +43,18 @@ class TTSBridge:
self._voice = voice self._voice = voice
self._speed = speed self._speed = speed
self._process: asyncio.subprocess.Process | None = None self._process: asyncio.subprocess.Process | None = None
self._bridge_script = bridge_script self._bridge_script = bridge_script or _resolve_path("tts_bridge.js")
self._node_exe = _get_node_exe()
@property
def bridge_script_path(self) -> str:
if self._bridge_script:
return self._bridge_script
return str(pathlib.Path(__file__).parent / "tts_bridge.js")
async def start(self) -> None: async def start(self) -> None:
"""Launch the Node.js bridge process and wait for it to be ready.""" if not pathlib.Path(self._bridge_script).exists():
script = self.bridge_script_path raise FileNotFoundError(f"Bridge script not found: {self._bridge_script}")
if not pathlib.Path(script).exists():
raise FileNotFoundError(f"Bridge script not found: {script}")
logger.info("Starting Node.js TTS bridge (voice=%s, speed=%d)...", self._voice, self._speed) logger.info("Starting Node.js TTS bridge (voice=%s, speed=%d)...", self._voice, self._speed)
self._process = await asyncio.create_subprocess_exec( self._process = await asyncio.create_subprocess_exec(
"node", self._node_exe,
script, self._bridge_script,
"--voice", self._voice, "--voice", self._voice,
"--speed", str(self._speed), "--speed", str(self._speed),
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
@@ -64,14 +74,6 @@ class TTSBridge:
logger.info("Node.js TTS bridge ready.") logger.info("Node.js TTS bridge ready.")
async def synthesize(self, kana_text: str) -> bytes: 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: if self._process is None or self._process.stdin is None:
raise RuntimeError("Bridge not started. Call start() first.") raise RuntimeError("Bridge not started. Call start() first.")
@@ -110,7 +112,6 @@ class TTSBridge:
pass pass
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the bridge process."""
if self._process is not None: if self._process is not None:
logger.info("Stopping TTS bridge...") logger.info("Stopping TTS bridge...")
try: try:
+14 -24
View File
@@ -1,25 +1,17 @@
/** tts_bridge.js — Persistent Node.js bridge for aquestalk.js TTS synthesis. /** tts_bridge.js — Persistent Node.js bridge for aquestalk.js TTS synthesis.
Protocol (via stdin/stdout): Converts English/romaji to katakana via wanakana before synthesis.
- On startup, prints "READY" to stdout.
- Reads lines from stdin in format: INPUT_PATH|OUTPUT_PATH
- Reads kana text from INPUT_PATH, synthesizes WAV, writes to OUTPUT_PATH.
- Prints "OK:OUTPUT_PATH" on success, "ERR:message" on failure.
- Exits when stdin is closed.
Usage:
node tts_bridge.js --voice f1 --speed 100
*/ */
import { readFileSync, writeFileSync } from "fs"; import { readFileSync, writeFileSync } from "fs";
import { createInterface } from "readline"; import { createInterface } from "readline";
import { fileURLToPath, pathToFileURL } from "url"; import { fileURLToPath, pathToFileURL } from "url";
import { dirname, join } from "path"; import { dirname, join } from "path";
import wanakana from "wanakana";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// Parse CLI arguments
const args = process.argv.slice(2); const args = process.argv.slice(2);
let voice = "f1"; let voice = "f1";
let speed = 100; 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); const { load } = await import(aquestalkMod);
console.error(`[bridge] Loading aquestalk.js with voice="${voice}", speed=${speed}...`); console.error(`[bridge] Loading aquestalk.js with voice="${voice}", speed=${speed}...`);
@@ -48,6 +39,14 @@ try {
process.exit(1); 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"); process.stdout.write("READY\n");
const rl = createInterface({ const rl = createInterface({
@@ -59,28 +58,21 @@ const rl = createInterface({
rl.on("line", (line) => { rl.on("line", (line) => {
line = line.trim(); line = line.trim();
if (!line) return; if (!line) return;
const sepIdx = line.indexOf("|"); const sepIdx = line.indexOf("|");
if (sepIdx === -1) { if (sepIdx === -1) {
process.stdout.write(`ERR:INVALID_FORMAT:${line}\n`); process.stdout.write(`ERR:INVALID_FORMAT:${line}\n`);
return; return;
} }
const inputPath = line.substring(0, sepIdx); const inputPath = line.substring(0, sepIdx);
const outputPath = line.substring(sepIdx + 1); const outputPath = line.substring(sepIdx + 1);
try { try {
const kanaText = readFileSync(inputPath, "utf-8").trim(); const kanaText = readFileSync(inputPath, "utf-8").trim();
if (!kanaText) { if (!kanaText) {
process.stdout.write(`ERR:EMPTY_TEXT\n`); process.stdout.write(`ERR:EMPTY_TEXT\n`);
return; return;
} }
const wav = synthesize(kanaText);
const wav = aq.run(kanaText, speed); writeFileSync(outputPath, wav);
writeFileSync(outputPath, Buffer.from(wav));
process.stdout.write(`OK:${outputPath}\n`); process.stdout.write(`OK:${outputPath}\n`);
} catch (err) { } catch (err) {
process.stdout.write(`ERR:${err.message}\n`); process.stdout.write(`ERR:${err.message}\n`);
@@ -89,7 +81,5 @@ rl.on("line", (line) => {
rl.on("close", () => { rl.on("close", () => {
console.error("[bridge] stdin closed, shutting down."); console.error("[bridge] stdin closed, shutting down.");
aq.destroy().then(() => { aq.destroy().then(() => process.exit(0));
process.exit(0);
});
}); });