diff --git a/README.md b/README.md index 9a0eb41..f5725b0 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,41 @@ python main.py --room-id ROOM_ID [--voice VOICE] [--speed SPEED] | `imd1` | imd1 | | `jgr` | jgr | +## 事件语音规则 + +除弹幕外,礼物、盲盒、进房、上舰、醒目留言等事件也可以自定义语音。规则保存在 `event_rules.json`(首次运行自动生成),可在 Web UI 的 "Event Voice Rules" 面板编辑。 + +模板为空字符串 = 该事件静音。`enabled` 控制整类事件开关,`skip_free_gift` 跳过银瓜子免费礼物。 + +### 事件与变量 + +| 事件 | 变量 | +|------|------| +| `danmaku` 弹幕 | `{uname}` `{msg}` | +| `gift` 普通礼物 | `{viewer_name}` `{gift_name}` `{gift_num}` `{gift_price}` `{gift_coin_type}` | +| `blind_box` 盲盒礼物 | `{viewer_name}` `{gift_name}` `{gift_num}` `{blind_box_gift}` | +| `enter_room` 进房 | `{viewer_name}` `{user_uid}` | +| `follow` 关注 | `{viewer_name}` `{user_uid}` | +| `like` 点赞 | `{viewer_name}` `{user_uid}` | +| `interact_other` 其他互动 | `{viewer_name}` `{user_uid}` `{interact_type}`(分享/特别关注/互粉) | +| `buy_guard` 上舰 | `{ships_viewer_name}` `{ships_level}`(舰长/提督/总督) `{ships_num}` `{ships_price}` | +| `guard_toast` 上舰提示 | `{ships_viewer_name}` `{ships_level}` `{guard_days}` `{toast_msg}` | +| `super_chat` 醒目留言 | `{viewer_name}` `{super_chat_content}` `{super_chat_price}` `{super_chat_duration}` | + +示例模板: + +```json +{ + "templates": { + "gift": "感谢由{viewer_name}投喂的{gift_name}", + "blind_box": "感谢由{viewer_name}投喂的{gift_name}爆出了{blind_box_gift}", + "enter_room": "欢迎{viewer_name}进入直播间", + "buy_guard": "恭喜{ships_viewer_name}成为尊贵的{ships_level}", + "super_chat": "{viewer_name}的醒目留言:{super_chat_content}" + } +} +``` + ## 技术架构 ``` @@ -94,7 +129,7 @@ Bilibili 弹幕 (blivedm) 中文→片假名 (pypinyin + 映射表) │ ▼ -英文→片假名 (english-to-kana 49K 词库 + 字母拼读兜底) +英文→片假名 (english-to-kana 49K 词库 + phonemize G2P 兜底) │ ▼ AquesTalk TTS (aquestalk.js + v86 WASM 模拟) diff --git a/build.py b/build.py index db6078c..3f47963 100644 --- a/build.py +++ b/build.py @@ -145,6 +145,8 @@ def main() -> int: shutil.copy2(ROOT / "tts_bridge.js", BUILD_DIR / "tts_bridge.js") shutil.copy2(ROOT / "english-kana-matcher.js", BUILD_DIR / "english-kana-matcher.js") shutil.copy2(ROOT / "package.json", BUILD_DIR / "package.json") + if (ROOT / "event_rules.json").exists(): + shutil.copy2(ROOT / "event_rules.json", BUILD_DIR / "event_rules.json") print(" OK") # ── 6. Launcher ──────────────────────────────────────────────────── diff --git a/danmaku_handler.py b/danmaku_handler.py index 7899f36..c1c22ca 100644 --- a/danmaku_handler.py +++ b/danmaku_handler.py @@ -1,7 +1,8 @@ """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. +Connects to a Bilibili live room, receives danmaku/gift/guard/super-chat/ +interact events, and puts user-customizable speech text into an asyncio queue. +Rules come from event_rules.json (see event_rules.py). """ import asyncio @@ -9,24 +10,55 @@ import logging from typing import Optional import blivedm -from blivedm.models.web import DanmakuMessage +from blivedm.models.web import DanmakuMessage, GiftMessage, GuardBuyMessage, \ + SuperChatMessage, InteractWordV2Message, UserToastV2Message from bili_login import build_danmaku_session +from event_rules import ( + DEFAULT_RULES, GUARD_LEVEL_NAMES, event_text, is_enabled, load_rules, +) logger = logging.getLogger(__name__) DANMAKU_FORMAT = "{uname}\u8bf4\u3001 {msg}" +INTERACT_TYPE_NAMES = { + 3: "\u5206\u4eab", 4: "\u7279\u522b\u5173\u6ce8", 5: "\u4e92\u7c89", +} + class DanmakuHandler(blivedm.BaseHandler): - """Handles incoming danmaku from a Bilibili live room.""" + """Handles incoming danmaku and live events from a Bilibili live room.""" - def __init__(self, queue: asyncio.Queue, message_format: str = DANMAKU_FORMAT): + _CMD_CALLBACK_DICT = blivedm.BaseHandler._CMD_CALLBACK_DICT.copy() + + def __init__(self, queue: asyncio.Queue, rules: dict | None = None, + message_format: str = DANMAKU_FORMAT): super().__init__() self._queue = queue + self._rules = rules or dict(DEFAULT_RULES) self._message_format = message_format self._count = 0 + # ── custom SEND_GIFT callback to capture blind-gift info ────────── + def __gift_callback(self, client: blivedm.BLiveClient, command: dict): + message = GiftMessage.from_command(command["data"]) + blind = command.get("data", {}).get("blind_gift") or {} + blind_name = blind.get("blind_gift_name", "") if isinstance(blind, dict) else "" + self._on_gift_raw(client, message, blind_name) + + _CMD_CALLBACK_DICT["SEND_GIFT"] = __gift_callback + + # ── helpers ──────────────────────────────────────────────────────── + def _put(self, text: str) -> None: + if not text: + return + try: + self._queue.put_nowait(text) + except asyncio.QueueFull: + logger.warning("Danmaku queue full, dropping: %s", text) + + # ── danmaku ──────────────────────────────────────────────────────── def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None: if not message.msg.strip(): return @@ -40,17 +72,91 @@ class DanmakuHandler(blivedm.BaseHandler): 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: - logger.warning("Danmaku queue full, dropping: %s", text) + self._put(text) - def _on_gift(self, client: blivedm.BLiveClient, message) -> None: - logger.debug("Gift: %s x%d from %s", message.gift_name, message.num, message.uname) + # ── gift ─────────────────────────────────────────────────────────── + def _on_gift_raw(self, client: blivedm.BLiveClient, message: GiftMessage, + blind_box_gift: str) -> None: + if self._rules.get("skip_free_gift") and message.coin_type == "silver": + logger.debug("Skip free gift: %s x%d from %s", + message.gift_name, message.num, message.uname) + return - def _on_interact_word_v2(self, client: blivedm.BLiveClient, message) -> None: - logger.debug("Interact: %s (type=%d)", message.uname, message.msg_type) + if blind_box_gift: + text = event_text(self._rules, "blind_box", + viewer_name=message.uname, + gift_name=message.gift_name, + gift_num=message.num, + blind_box_gift=blind_box_gift) + else: + text = event_text(self._rules, "gift", + viewer_name=message.uname, + gift_name=message.gift_name, + gift_num=message.num, + gift_price=message.price, + gift_coin_type=message.coin_type) + logger.info("Gift: %s x%d from %s (blind=%s)", + message.gift_name, message.num, message.uname, blind_box_gift or "-") + self._put(text) + def _on_gift(self, client: blivedm.BLiveClient, message: GiftMessage) -> None: + # fallback in case _CMD_CALLBACK_DICT override is bypassed + self._on_gift_raw(client, message, "") + + # ── buy guard ────────────────────────────────────────────────────── + def _on_buy_guard(self, client: blivedm.BLiveClient, message: GuardBuyMessage) -> None: + text = event_text(self._rules, "buy_guard", + ships_viewer_name=message.username, + ships_level=GUARD_LEVEL_NAMES.get(message.guard_level, str(message.guard_level)), + ships_num=message.num, + ships_price=message.price) + logger.info("Buy guard: %s level=%d", message.username, message.guard_level) + self._put(text) + + # ── guard toast ──────────────────────────────────────────────────── + def _on_user_toast_v2(self, client: blivedm.BLiveClient, message: UserToastV2Message) -> None: + text = event_text(self._rules, "guard_toast", + ships_viewer_name=message.username, + ships_level=GUARD_LEVEL_NAMES.get(message.guard_level, str(message.guard_level)), + guard_days=message.num, + toast_msg=message.toast_msg) + logger.info("Guard toast: %s level=%d", message.username, message.guard_level) + self._put(text) + + # ── super chat ───────────────────────────────────────────────────── + def _on_super_chat(self, client: blivedm.BLiveClient, message: SuperChatMessage) -> None: + text = event_text(self._rules, "super_chat", + viewer_name=message.uname, + super_chat_content=message.message, + super_chat_price=message.price, + super_chat_duration=message.time) + logger.info("Super chat: %s (%d yuan): %s", message.uname, message.price, message.message) + self._put(text) + + # ── interact (enter room / follow / like / share) ────────────────── + def _on_interact_word_v2(self, client: blivedm.BLiveClient, message: InteractWordV2Message) -> None: + msg_type = message.msg_type + if msg_type == 1: + text = event_text(self._rules, "enter_room", + viewer_name=message.username, + user_uid=message.uid) + elif msg_type == 2: + text = event_text(self._rules, "follow", + viewer_name=message.username, + user_uid=message.uid) + elif msg_type == 6: + text = event_text(self._rules, "like", + viewer_name=message.username, + user_uid=message.uid) + else: + text = event_text(self._rules, "interact_other", + viewer_name=message.username, + user_uid=message.uid, + interact_type=INTERACT_TYPE_NAMES.get(msg_type, str(msg_type))) + logger.debug("Interact: %s (type=%d)", message.username, msg_type) + self._put(text) + + # ── client stopped ───────────────────────────────────────────────── def on_client_stopped(self, client, exception: Optional[Exception]) -> None: if exception is not None: logger.error("Client stopped with exception: %s", exception) @@ -66,11 +172,13 @@ class DanmakuClient: room_id: int, queue: asyncio.Queue, cookies: Optional[dict[str, str]] = None, + rules: dict | None = None, message_format: str = DANMAKU_FORMAT, ): self._room_id = room_id self._queue = queue self._cookies = cookies + self._rules = rules or dict(DEFAULT_RULES) self._message_format = message_format self._client: Optional[blivedm.BLiveClient] = None self._session: Optional["aiohttp.ClientSession"] = None @@ -79,7 +187,7 @@ class DanmakuClient: import aiohttp 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) + handler = DanmakuHandler(self._queue, self._rules, self._message_format) self._client.set_handler(handler) self._client.start() logger.info("Connected to room %d", self._room_id) diff --git a/event_rules.json b/event_rules.json new file mode 100644 index 0000000..84f5933 --- /dev/null +++ b/event_rules.json @@ -0,0 +1,27 @@ +{ + "enabled": { + "danmaku": true, + "gift": true, + "blind_box": true, + "enter_room": true, + "follow": false, + "like": false, + "interact_other": false, + "buy_guard": true, + "guard_toast": false, + "super_chat": true + }, + "templates": { + "danmaku": "{uname}说、 {msg}", + "gift": "感谢由{viewer_name}投喂的{gift_name}", + "blind_box": "感谢由{viewer_name}投喂的{gift_name}爆出了{blind_box_gift}", + "enter_room": "欢迎{viewer_name}进入直播间", + "follow": "", + "like": "", + "interact_other": "", + "buy_guard": "恭喜{ships_viewer_name}成为尊贵的{ships_level}", + "guard_toast": "", + "super_chat": "{viewer_name}的醒目留言:{super_chat_content}" + }, + "skip_free_gift": true +} \ No newline at end of file diff --git a/event_rules.py b/event_rules.py new file mode 100644 index 0000000..f823bd6 --- /dev/null +++ b/event_rules.py @@ -0,0 +1,125 @@ +"""Event voice rules: load/save event_rules.json and render templates. + +Each live event (danmaku, gift, blind box, enter room, follow, like, +interact, buy guard, guard toast, super chat) can be voiced using a +user-customizable template. Empty template = event is not spoken. +""" + +import json +import logging +import pathlib +import re +import sys + +logger = logging.getLogger(__name__) + +DEFAULT_RULES = { + "enabled": { + "danmaku": True, + "gift": True, + "blind_box": True, + "enter_room": True, + "follow": False, + "like": False, + "interact_other": False, + "buy_guard": True, + "guard_toast": False, + "super_chat": True, + }, + "templates": { + "danmaku": "{uname}\u8bf4\u3001 {msg}", + "gift": "\u611f\u8c22\u7531{viewer_name}\u6295\u5582\u7684{gift_name}", + "blind_box": "\u611f\u8c22\u7531{viewer_name}\u6295\u5582\u7684{gift_name}\u7206\u51fa\u4e86{blind_box_gift}", + "enter_room": "\u6b22\u8fce{viewer_name}\u8fdb\u5165\u76f4\u64ad\u95f4", + "follow": "", + "like": "", + "interact_other": "", + "buy_guard": "\u606d\u559c{ships_viewer_name}\u6210\u4e3a\u5c0a\u8d35\u7684{ships_level}", + "guard_toast": "", + "super_chat": "{viewer_name}\u7684\u9192\u76ee\u7559\u8a00\uff1a{super_chat_content}", + }, + "skip_free_gift": True, +} + +ALL_EVENTS = [ + "danmaku", "gift", "blind_box", "enter_room", "follow", "like", + "interact_other", "buy_guard", "guard_toast", "super_chat", +] + +_TEMPLATE_RE = re.compile(r"\{(\w+)\}") + + +def _base_dir() -> pathlib.Path: + if getattr(sys, "frozen", False): + return pathlib.Path(sys.executable).parent.parent.parent + return pathlib.Path(__file__).parent + + +def rules_path() -> pathlib.Path: + return _base_dir() / "event_rules.json" + + +def load_rules(path: str | None = None) -> dict: + """Load rules from file; create default file if missing.""" + p = pathlib.Path(path) if path else rules_path() + if not p.exists(): + try: + p.write_text(json.dumps(DEFAULT_RULES, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info("Created default event_rules.json at %s", p) + except OSError: + logger.warning("Cannot write default rules to %s", p) + return json.loads(json.dumps(DEFAULT_RULES)) + + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + logger.error("Failed to load rules from %s: %s", p, e) + return json.loads(json.dumps(DEFAULT_RULES)) + + # merge with defaults to fill missing keys + merged = json.loads(json.dumps(DEFAULT_RULES)) + for section in ("enabled", "templates"): + if isinstance(data.get(section), dict): + merged[section].update({k: v for k, v in data[section].items() if k in merged[section]}) + if isinstance(data.get("skip_free_gift"), bool): + merged["skip_free_gift"] = data["skip_free_gift"] + return merged + + +def save_rules(rules: dict, path: str | None = None) -> None: + p = pathlib.Path(path) if path else rules_path() + p.write_text(json.dumps(rules, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info("Saved event rules to %s", p) + + +def render_template(template: str, **vars_dict) -> str: + """Render a template with given variables. Missing variables become empty. + Unknown variables are kept as-is (so users can see typos in the UI log).""" + if not template: + return "" + + def _replace(m: re.Match) -> str: + name = m.group(1) + return str(vars_dict.get(name, "")) if name in vars_dict else m.group(0) + + return _TEMPLATE_RE.sub(_replace, template) + + +def is_enabled(rules: dict, event: str) -> bool: + return bool(rules.get("enabled", {}).get(event, False)) + + +def get_template(rules: dict, event: str) -> str: + return str(rules.get("templates", {}).get(event, "")) + + +def event_text(rules: dict, event: str, **vars_dict) -> str: + """Build the speech text for an event, or '' if disabled/empty template.""" + if not is_enabled(rules, event): + return "" + text = render_template(get_template(rules, event), **vars_dict) + return text.strip() + + +# ── guard level mapping ───────────────────────────────────────────────── +GUARD_LEVEL_NAMES = {1: "\u603b\u7763", 2: "\u63d0\u7763", 3: "\u8230\u957f"} diff --git a/main.py b/main.py index 1123024..52022b4 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ import sys from danmaku_handler import DanmakuClient from chinese2kana import chinese_to_kana, filter_kana +from event_rules import load_rules from tts import TTSBridge from audio_player import play_wav_async, get_output_devices @@ -71,9 +72,11 @@ async def main_async(args: argparse.Namespace) -> int: queue: asyncio.Queue = asyncio.Queue(maxsize=256) shutdown_event = asyncio.Event() bridge = TTSBridge(voice=args.voice, speed=args.speed) + rules = load_rules() danmaku_client = DanmakuClient( room_id=args.room_id, queue=queue, cookies={"SESSDATA": args.sessdata} if args.sessdata else None, + rules=rules, message_format=args.format, ) diff --git a/server.py b/server.py index 8e71c9b..4fea7d4 100644 --- a/server.py +++ b/server.py @@ -18,6 +18,7 @@ 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 +from event_rules import DEFAULT_RULES, load_rules, save_rules, rules_path logger = logging.getLogger("bililive-tts-server") @@ -27,6 +28,7 @@ def _get_static_dir() -> Path: return Path(__file__).parent / "static" STATIC_DIR = _get_static_dir() +event_rules = load_rules() class TTSService: @@ -68,7 +70,8 @@ class TTSService: await self._bridge.start() self._danmaku_client = DanmakuClient( room_id=room_id, queue=self._queue, - cookies=self._cookies, message_format=message_format, + cookies=self._cookies, rules=event_rules, + message_format=message_format, ) await self._danmaku_client.start() self.running = True @@ -240,6 +243,42 @@ async def api_logout(request: web.Request) -> web.Response: return web.json_response({"status": "logged_out"}) +async def api_rules_get(request: web.Request) -> web.Response: + return web.json_response(event_rules) + + +async def api_rules_post(request: web.Request) -> web.Response: + global event_rules + try: + new_rules = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + from event_rules import ALL_EVENTS, DEFAULT_RULES + merged = { + "enabled": dict(DEFAULT_RULES["enabled"]), + "templates": dict(DEFAULT_RULES["templates"]), + "skip_free_gift": True, + } + if isinstance(new_rules.get("enabled"), dict): + for k in ALL_EVENTS: + if isinstance(new_rules["enabled"].get(k), bool): + merged["enabled"][k] = new_rules["enabled"][k] + if isinstance(new_rules.get("templates"), dict): + for k in ALL_EVENTS: + if isinstance(new_rules["templates"].get(k), str): + merged["templates"][k] = new_rules["templates"][k] + if isinstance(new_rules.get("skip_free_gift"), bool): + merged["skip_free_gift"] = new_rules["skip_free_gift"] + + try: + save_rules(merged) + except OSError as e: + return web.json_response({"error": f"Failed to save rules: {e}"}, status=500) + event_rules = merged + return web.json_response({"status": "saved", "rules": event_rules}) + + def create_app() -> web.Application: app = web.Application() app.router.add_get("/", index_handler) @@ -251,6 +290,8 @@ def create_app() -> web.Application: 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) + app.router.add_get("/api/rules", api_rules_get) + app.router.add_post("/api/rules", api_rules_post) return app diff --git a/static/index.html b/static/index.html index 10a84b9..8f29dda 100644 --- a/static/index.html +++ b/static/index.html @@ -202,6 +202,27 @@ animation: spin 0.8s linear infinite; margin: 0 auto; } @keyframes spin { to { transform: rotate(360deg); } } + + /* Event rules */ + .rule-row { + display: flex; gap: 10px; align-items: center; + margin-bottom: 8px; + } + .rule-row .rule-name { + width: 110px; font-size: 0.82rem; color: var(--text-dim); + flex-shrink: 0; + } + .rule-row input[type="checkbox"] { + accent-color: var(--accent); width: 15px; height: 15px; + flex-shrink: 0; + } + .rule-row input[type="text"] { + flex: 1; padding: 6px 10px; border-radius: 6px; + border: 1px solid var(--border); + background: var(--input-bg); color: var(--text); + font-size: 0.85rem; outline: none; + } + .rule-row input[type="text"]:focus { border-color: var(--accent); }
@@ -280,6 +301,22 @@ + +