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:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "bililive-touhou-tts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"wanakana": "^5.3.1"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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 → 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>
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user