修复直播测试与 TTS 预热

This commit is contained in:
2026-08-17 15:44:23 +08:00
parent f623959fd4
commit da8a82bfd5
8 changed files with 718 additions and 676 deletions
+6
View File
@@ -2,6 +2,7 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/ .venv/
.venv-tts/
runtime/ runtime/
config/.venv_python_path.txt config/.venv_python_path.txt
@@ -21,6 +22,10 @@ data/queue_state.json
data/song_requests.json data/song_requests.json
data/tts_state.json data/tts_state.json
data/bilibili_credentials.json data/bilibili_credentials.json
data/admin_audit.log
data/admin_auth.json
data/statistics.sqlite3*
data/users.json
web/music_cover.jpg web/music_cover.jpg
# IDE # IDE
@@ -30,3 +35,4 @@ web/music_cover.jpg
# Large binaries (kept local only) # Large binaries (kept local only)
vendor/mpv/mpv.exe vendor/mpv/mpv.exe
vendor/mpv/mpv.7z vendor/mpv/mpv.7z
vendor/tts-model/
+303 -25
View File
@@ -61,6 +61,10 @@ from pathlib import Path
from typing import Any from typing import Any
MIHOYO_SDK_REGISTRY_SUBKEY = r"Software\miHoYoSDK" MIHOYO_SDK_REGISTRY_SUBKEY = r"Software\miHoYoSDK"
MANUAL_LIVE_TEST_DURATION_SECONDS = 30 * 60
BILIBILI_LIVE_STATUS_POLL_TIMEOUT_SECONDS = 45.0
BILIBILI_LIVE_STATUS_POLL_INTERVAL_SECONDS = 2.0
BILIBILI_LIVE_STATUS_REFRESH_SECONDS = 60.0
def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]: def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]:
@@ -511,6 +515,7 @@ class Config:
tts_all["faster-qwen3-tts"].setdefault("chunk_size", 8) tts_all["faster-qwen3-tts"].setdefault("chunk_size", 8)
tts_all["faster-qwen3-tts"].setdefault("append_silence", True) tts_all["faster-qwen3-tts"].setdefault("append_silence", True)
tts_all["faster-qwen3-tts"].setdefault("streaming", True) tts_all["faster-qwen3-tts"].setdefault("streaming", True)
tts_all["faster-qwen3-tts"].setdefault("startup_timeout_seconds", 900)
self.data.setdefault("frontend", {}) self.data.setdefault("frontend", {})
if legacy_frontend: if legacy_frontend:
self.data["frontend"].update(legacy_frontend) self.data["frontend"].update(legacy_frontend)
@@ -2450,6 +2455,10 @@ class FasterQwen3TTSEngine:
self.cpu_threads = max(1, min(8, int(self.cfg.get("cpu_threads", 4) or 4))) self.cpu_threads = max(1, min(8, int(self.cfg.get("cpu_threads", 4) or 4)))
self.cpu_affinity_count = max(0, int(self.cfg.get("cpu_affinity_count", 8) or 0)) self.cpu_affinity_count = max(0, int(self.cfg.get("cpu_affinity_count", 8) or 0))
self.process_priority = str(self.cfg.get("process_priority", "below_normal") or "below_normal") self.process_priority = str(self.cfg.get("process_priority", "below_normal") or "below_normal")
self.startup_timeout_seconds = max(
60,
int(self.cfg.get("startup_timeout_seconds", 900) or 900),
)
self.logger = logger self.logger = logger
self._parent = parent self._parent = parent
self._worker_ready = False self._worker_ready = False
@@ -2473,6 +2482,7 @@ class FasterQwen3TTSEngine:
}, },
logger, logger,
synthesis_timeout_seconds=120, synthesis_timeout_seconds=120,
startup_timeout_seconds=self.startup_timeout_seconds,
) )
if bool(self.cfg.get("streaming", False)): if bool(self.cfg.get("streaming", False)):
self.logger.warning("[FasterQwenTTS] 独立 worker 仅使用非流式模式,已忽略 streaming=true") self.logger.warning("[FasterQwenTTS] 独立 worker 仅使用非流式模式,已忽略 streaming=true")
@@ -2720,6 +2730,7 @@ class Broadcaster:
self._tts_sequence = 0 self._tts_sequence = 0
self._tts_started = False self._tts_started = False
self._tts_warmup_done = asyncio.Event() self._tts_warmup_done = asyncio.Event()
self._tts_warmup_task: asyncio.Task | None = None
self._broadcast_channels: dict[str, dict[str, str]] = {} self._broadcast_channels: dict[str, dict[str, str]] = {}
self._stop = False self._stop = False
# 弹幕会话已登录标志: 启动时ping一下 nav API, -101 表示 SESSDATA 失效, 关闭弹幕发送 # 弹幕会话已登录标志: 启动时ping一下 nav API, -101 表示 SESSDATA 失效, 关闭弹幕发送
@@ -2762,9 +2773,14 @@ class Broadcaster:
self.logger.warning("[TTS] 配置热更新时无法调度旧 worker 关闭") self.logger.warning("[TTS] 配置热更新时无法调度旧 worker 关闭")
self.logger.info(f"[配置] 播报配置已热更新: danmu={self.enable_danmu}, tts={self.config.tts_provider}") self.logger.info(f"[配置] 播报配置已热更新: danmu={self.enable_danmu}, tts={self.config.tts_provider}")
async def _warmup_tts_in_background(self, tts: TTSEngine):
try:
await tts.warmup()
finally:
self._tts_warmup_done.set()
async def start(self): async def start(self):
if self._tts_started: if self._tts_started:
await self._tts_warmup_done.wait()
return return
self._tts_started = True self._tts_started = True
if not self.tts.enabled: if not self.tts.enabled:
@@ -2777,10 +2793,16 @@ class Broadcaster:
for task in (synth_task, play_task): for task in (synth_task, play_task):
task.add_done_callback(self._tts_worker_tasks.discard) task.add_done_callback(self._tts_worker_tasks.discard)
try: if bool(self._tts_queue_cfg.get("warmup_on_start", True)):
if bool(self._tts_queue_cfg.get("warmup_on_start", True)): warmup_task = asyncio.create_task(
await self.tts.warmup() self._warmup_tts_in_background(self.tts),
finally: name="TTS后台预热",
)
self._tts_warmup_task = warmup_task
self._tts_worker_tasks.add(warmup_task)
warmup_task.add_done_callback(self._tts_worker_tasks.discard)
self.logger.info("[TTS队列] 后台预热已启动,不阻塞弹幕监听")
else:
self._tts_warmup_done.set() self._tts_warmup_done.set()
self.logger.info( self.logger.info(
f"[TTS队列] 流水线启动, pending={self._tts_pending.maxsize}, " f"[TTS队列] 流水线启动, pending={self._tts_pending.maxsize}, "
@@ -5285,6 +5307,11 @@ class CommandHandler:
) )
def _is_live_time(self) -> bool: def _is_live_time(self) -> bool:
if self.system and (
self.system.manual_live_override_active()
or self.system.bilibili_live_active()
):
return True
return is_within_live_time(self.config.system_cfg) return is_within_live_time(self.config.system_cfg)
def _rule_matches(self, rule: dict, text: str) -> bool: def _rule_matches(self, rule: dict, text: str) -> bool:
@@ -6867,6 +6894,23 @@ def get_real_room_id(room_id: int, cookie: str = "") -> int:
return real_room_id return real_room_id
def get_bilibili_live_status(room_id: int, cookie: str = "") -> int:
"""返回 B站房间直播状态:0=未开播,1=直播中,其他值按接口原样返回。"""
data = _request_bilibili_json(
f"https://api.live.bilibili.com/room/v1/Room/get_info?room_id={int(room_id)}",
cookie,
f"https://live.bilibili.com/{int(room_id)}",
)
if data.get("code") != 0:
raise RuntimeError(
f"查询直播状态失败: code={data.get('code')} message={data.get('message', '')}"
)
try:
return int((data.get("data") or {}).get("live_status", -1))
except (TypeError, ValueError) as exc:
raise RuntimeError("直播状态接口未返回有效 live_status") from exc
def get_danmu_server(room_id: int, cookie: str = "") -> dict: def get_danmu_server(room_id: int, cookie: str = "") -> dict:
"""使用登录 Cookie 获取弹幕服务器和专用 tokentoken 不得回退为 SESSDATA。""" """使用登录 Cookie 获取弹幕服务器和专用 tokentoken 不得回退为 SESSDATA。"""
url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={int(room_id)}" url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={int(room_id)}"
@@ -7241,6 +7285,9 @@ class SystemScheduler:
self._live_session_started_at: datetime | None = None self._live_session_started_at: datetime | None = None
self._startup_compensation_task: asyncio.Task | None = None self._startup_compensation_task: asyncio.Task | None = None
self._startup_compensation_window_key: str | None = None self._startup_compensation_window_key: str | None = None
self._last_live_action_error = ""
self._last_bilibili_live_status: int | None = None
self._last_live_status_refresh_monotonic = 0.0
@property @property
def cfg(self) -> dict: def cfg(self) -> dict:
@@ -7452,6 +7499,89 @@ class SystemScheduler:
user32.EnumWindows(EnumWindowsProc(callback), 0) user32.EnumWindows(EnumWindowsProc(callback), 0)
return hwnd_found[0] if hwnd_found else None return hwnd_found[0] if hwnd_found else None
@property
def last_live_action_error(self) -> str:
return self._last_live_action_error
@staticmethod
def _force_foreground_window(hwnd: int) -> bool:
"""在同一登录会话中临时合并输入线程,可靠地前置目标窗口。"""
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
current_thread = int(kernel32.GetCurrentThreadId())
foreground = int(user32.GetForegroundWindow() or 0)
target_pid = ctypes.wintypes.DWORD()
target_thread = int(user32.GetWindowThreadProcessId(hwnd, ctypes.byref(target_pid)) or 0)
foreground_thread = 0
if foreground:
foreground_pid = ctypes.wintypes.DWORD()
foreground_thread = int(
user32.GetWindowThreadProcessId(foreground, ctypes.byref(foreground_pid)) or 0
)
attached: list[int] = []
try:
for thread_id in {target_thread, foreground_thread}:
if thread_id and thread_id != current_thread:
if user32.AttachThreadInput(current_thread, thread_id, True):
attached.append(thread_id)
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
else:
user32.ShowWindow(hwnd, 5) # SW_SHOW
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
user32.SetActiveWindow(hwnd)
user32.SetFocus(hwnd)
finally:
for thread_id in reversed(attached):
user32.AttachThreadInput(current_thread, thread_id, False)
return int(user32.GetForegroundWindow() or 0) == int(hwnd)
async def _get_bilibili_live_status(self, *, record_error: bool = True) -> int | None:
try:
status = await asyncio.to_thread(
get_bilibili_live_status,
int(self.config.room_id),
self.config.bilibili_cookie,
)
self._last_bilibili_live_status = status
if self.system:
self.system.set_bilibili_live_status(status)
return status
except Exception as exc:
message = f"查询 B站直播状态失败: {exc}"
if record_error:
self._last_live_action_error = message
self.logger.warning(f"[系统定时] {message}")
else:
self.logger.debug(f"[系统定时] {message}")
return None
async def _wait_for_bilibili_live_status(
self,
expected_status: int,
*,
timeout_seconds: float = BILIBILI_LIVE_STATUS_POLL_TIMEOUT_SECONDS,
) -> bool:
effective_timeout = max(1.0, float(timeout_seconds))
deadline = time.monotonic() + effective_timeout
while True:
status = await self._get_bilibili_live_status()
if status == expected_status:
self._last_live_action_error = ""
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
state_name = "开播" if expected_status == 1 else "关播"
self._last_live_action_error = (
f"已点击直播姬,但 B站在 {int(effective_timeout)} 秒内未确认{state_name}"
f"live_status={status}"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
await asyncio.sleep(min(BILIBILI_LIVE_STATUS_POLL_INTERVAL_SECONDS, remaining))
async def _click_bilibili_live( async def _click_bilibili_live(
self, self,
*, *,
@@ -7460,57 +7590,137 @@ class SystemScheduler:
action_name: str, action_name: str,
confirm_enter: bool = False, confirm_enter: bool = False,
) -> bool: ) -> bool:
"""直播姬窗口前置,在指定比例坐标点击;可选按 Enter 确认弹窗""" """确认直播姬获得前台后,在恢复后的窗口坐标点击。"""
self._last_live_action_error = ""
if os.name != "nt": if os.name != "nt":
self.logger.warning(f"[系统定时] 当前系统不支持自动{action_name}") self._last_live_action_error = f"当前系统不支持自动{action_name}"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False return False
keyword = self.cfg.get("bilibili_push_window_keyword", "直播姬") keyword = self.cfg.get("bilibili_push_window_keyword", "直播姬")
hwnd = self._find_window_by_keyword(keyword) hwnd = self._find_window_by_keyword(keyword)
if not hwnd: if not hwnd:
self.logger.warning(f"[系统定时] 未找到直播姬窗口,关键字: {keyword}") self._last_live_action_error = f"未找到直播姬窗口,关键字: {keyword}"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False return False
user32 = ctypes.windll.user32 user32 = ctypes.windll.user32
rect = ctypes.wintypes.RECT() previous_dpi_context = None
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)): set_thread_dpi_context = getattr(user32, "SetThreadDpiAwarenessContext", None)
self.logger.warning("[系统定时] 获取直播姬窗口位置失败")
return False
x_ratio = float(self.cfg.get(x_ratio_key, 0.741))
y_ratio = float(self.cfg.get(y_ratio_key, 0.907))
x = int(rect.left + max(0.0, min(1.0, x_ratio)) * (rect.right - rect.left))
y = int(rect.top + max(0.0, min(1.0, y_ratio)) * (rect.bottom - rect.top))
try: try:
user32.ShowWindow(hwnd, 9) # SW_RESTORE if set_thread_dpi_context:
user32.SetForegroundWindow(hwnd) set_thread_dpi_context.restype = ctypes.c_void_p
previous_dpi_context = set_thread_dpi_context(ctypes.c_void_p(-4))
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
else:
user32.ShowWindow(hwnd, 5) # SW_SHOW
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
user32.SetCursorPos(x, y) if not self._force_foreground_window(hwnd):
await asyncio.sleep(0.3)
if int(user32.GetForegroundWindow() or 0) != int(hwnd):
self._last_live_action_error = "直播姬窗口无法获得前台,已取消点击以避免误操作其他窗口"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
rect = ctypes.wintypes.RECT()
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
self._last_live_action_error = "获取直播姬窗口位置失败"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
width = int(rect.right - rect.left)
height = int(rect.bottom - rect.top)
if width < 200 or height < 150:
self._last_live_action_error = (
f"直播姬窗口尺寸异常: rect=({rect.left},{rect.top},{rect.right},{rect.bottom})"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
x_ratio = float(self.cfg.get(x_ratio_key, 0.741))
y_ratio = float(self.cfg.get(y_ratio_key, 0.907))
x = int(rect.left + max(0.0, min(1.0, x_ratio)) * width)
y = int(rect.top + max(0.0, min(1.0, y_ratio)) * height)
virtual_left = int(user32.GetSystemMetrics(76))
virtual_top = int(user32.GetSystemMetrics(77))
virtual_right = virtual_left + int(user32.GetSystemMetrics(78))
virtual_bottom = virtual_top + int(user32.GetSystemMetrics(79))
if not (virtual_left <= x < virtual_right and virtual_top <= y < virtual_bottom):
self._last_live_action_error = (
f"直播姬点击坐标超出屏幕: x={x}, y={y}, "
f"screen=({virtual_left},{virtual_top},{virtual_right},{virtual_bottom})"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
previous_cursor = ctypes.wintypes.POINT()
has_previous_cursor = bool(user32.GetCursorPos(ctypes.byref(previous_cursor)))
if not user32.SetCursorPos(x, y):
self._last_live_action_error = (
f"无法移动鼠标到直播姬按钮位置: x={x}, y={y}"
"请通过桌面上可见的 run.bat 控制台启动服务后重试"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if int(user32.GetForegroundWindow() or 0) != int(hwnd):
self._last_live_action_error = "点击前直播姬失去前台,已取消操作"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
if has_previous_cursor:
user32.SetCursorPos(previous_cursor.x, previous_cursor.y)
return False
user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP
if has_previous_cursor:
user32.SetCursorPos(previous_cursor.x, previous_cursor.y)
if confirm_enter: if confirm_enter:
await asyncio.sleep(0.8) await asyncio.sleep(0.8)
user32.keybd_event(0x0D, 0, 0, 0) # VK_RETURN key down user32.keybd_event(0x0D, 0, 0, 0) # VK_RETURN key down
user32.keybd_event(0x0D, 0, 0x0002, 0) # key up user32.keybd_event(0x0D, 0, 0x0002, 0) # key up
self.logger.info(f"[系统定时] 已点击直播姬{action_name}: hwnd={hwnd}, x={x}, y={y}") self.logger.info(
f"[系统定时] 已点击直播姬{action_name}: hwnd={hwnd}, x={x}, y={y}, "
f"rect=({rect.left},{rect.top},{rect.right},{rect.bottom})"
)
return True return True
except Exception as e: except Exception as e:
self.logger.warning(f"[系统定时] 点击直播姬{action_name}失败: {e}") self._last_live_action_error = f"点击直播姬{action_name}失败: {e}"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False return False
finally:
if set_thread_dpi_context and previous_dpi_context:
try:
set_thread_dpi_context(ctypes.c_void_p(previous_dpi_context))
except Exception:
pass
async def push_bilibili_live(self) -> bool: async def push_bilibili_live(self) -> bool:
return await self._click_bilibili_live( self._last_live_action_error = ""
if await self._get_bilibili_live_status() == 1:
self.logger.info("[系统定时] B站已处于开播状态,跳过重复点击")
return True
clicked = await self._click_bilibili_live(
x_ratio_key="bilibili_push_click_x_ratio", x_ratio_key="bilibili_push_click_x_ratio",
y_ratio_key="bilibili_push_click_y_ratio", y_ratio_key="bilibili_push_click_y_ratio",
action_name="开启推流按钮", action_name="开启推流按钮",
) )
if not clicked:
return False
return await self._wait_for_bilibili_live_status(1)
async def stop_bilibili_live(self) -> bool: async def stop_bilibili_live(self) -> bool:
return await self._click_bilibili_live( self._last_live_action_error = ""
if await self._get_bilibili_live_status() == 0:
self.logger.info("[系统定时] B站已处于未开播状态,跳过重复关播点击")
return True
clicked = await self._click_bilibili_live(
x_ratio_key="bilibili_stop_push_click_x_ratio", x_ratio_key="bilibili_stop_push_click_x_ratio",
y_ratio_key="bilibili_stop_push_click_y_ratio", y_ratio_key="bilibili_stop_push_click_y_ratio",
action_name="关闭推流按钮", action_name="关闭推流按钮",
confirm_enter=bool(self.cfg.get("bilibili_stop_push_confirm_enter", True)), confirm_enter=bool(self.cfg.get("bilibili_stop_push_confirm_enter", True)),
) )
if not clicked:
return False
return await self._wait_for_bilibili_live_status(0)
def _schedule_reboot_after_stop(self, stopped_at: datetime) -> bool: def _schedule_reboot_after_stop(self, stopped_at: datetime) -> bool:
if not self._is_enabled("reboot_after_stop_enabled"): if not self._is_enabled("reboot_after_stop_enabled"):
@@ -7677,6 +7887,13 @@ class SystemScheduler:
now = datetime.now().replace(second=0, microsecond=0) now = datetime.now().replace(second=0, microsecond=0)
today = date.today().isoformat() today = date.today().isoformat()
try: try:
monotonic_now = time.monotonic()
if (
monotonic_now - self._last_live_status_refresh_monotonic
>= BILIBILI_LIVE_STATUS_REFRESH_SECONDS
):
self._last_live_status_refresh_monotonic = monotonic_now
await self._get_bilibili_live_status(record_error=False)
for event, when in self._live_occurrences(now): for event, when in self._live_occurrences(now):
key = self._event_key(event, when) key = self._event_key(event, when)
if key in self._triggered_events or not self._event_due(now, when): if key in self._triggered_events or not self._event_due(now, when):
@@ -7744,6 +7961,11 @@ class QueueSystem:
self.stats_store = stats_store self.stats_store = stats_store
self.health = ServiceRegistry(stats_store) self.health = ServiceRegistry(stats_store)
self.live_client = None self.live_client = None
self._manual_live_override_until_monotonic = 0.0
self._manual_live_override_until_wall = 0.0
self._bilibili_live_status: int | None = None
self._bilibili_live_status_updated_monotonic = 0.0
self._bilibili_live_status_updated_wall = 0.0
self.health.set("主程序", ServiceRegistry.STARTING, "初始化") self.health.set("主程序", ServiceRegistry.STARTING, "初始化")
self.health.set("配置", ServiceRegistry.RUNNING, f"revision={config.revision}") self.health.set("配置", ServiceRegistry.RUNNING, f"revision={config.revision}")
self.user_mgr = UserManager( self.user_mgr = UserManager(
@@ -7792,6 +8014,54 @@ class QueueSystem:
self.handler.login_monitor = self.login_monitor self.handler.login_monitor = self.login_monitor
self.health.set("主程序", ServiceRegistry.RUNNING, "初始化完成") self.health.set("主程序", ServiceRegistry.RUNNING, "初始化完成")
def enable_manual_live_override(
self,
duration_seconds: int = MANUAL_LIVE_TEST_DURATION_SECONDS,
) -> float:
duration = max(60, min(int(duration_seconds), 6 * 60 * 60))
self._manual_live_override_until_monotonic = time.monotonic() + duration
self._manual_live_override_until_wall = time.time() + duration
self.logger.info(f"[直播测试] 已临时开放弹幕指令 {duration // 60} 分钟")
return self._manual_live_override_until_wall
def clear_manual_live_override(self) -> None:
was_active = self.manual_live_override_active()
self._manual_live_override_until_monotonic = 0.0
self._manual_live_override_until_wall = 0.0
if was_active:
self.logger.info("[直播测试] 已关闭手动直播指令时段")
def manual_live_override_active(self) -> bool:
if self._manual_live_override_until_monotonic <= time.monotonic():
self._manual_live_override_until_monotonic = 0.0
self._manual_live_override_until_wall = 0.0
return False
return True
def manual_live_override_snapshot(self) -> dict[str, Any]:
active = self.manual_live_override_active()
return {
"active": active,
"until": self._manual_live_override_until_wall if active else 0.0,
}
def set_bilibili_live_status(self, status: int) -> None:
self._bilibili_live_status = int(status)
self._bilibili_live_status_updated_monotonic = time.monotonic()
self._bilibili_live_status_updated_wall = time.time()
def bilibili_live_active(self, max_age_seconds: float = 120.0) -> bool:
status = getattr(self, "_bilibili_live_status", None)
updated = float(getattr(self, "_bilibili_live_status_updated_monotonic", 0.0) or 0.0)
return status == 1 and time.monotonic() - updated <= max(1.0, max_age_seconds)
def bilibili_live_status_snapshot(self) -> dict[str, Any]:
return {
"status": getattr(self, "_bilibili_live_status", None),
"updated_at": float(getattr(self, "_bilibili_live_status_updated_wall", 0.0) or 0.0),
"active": self.bilibili_live_active(),
}
def apply_config(self): def apply_config(self):
self.config.apply_runtime_settings() self.config.apply_runtime_settings()
self.user_mgr.config = self.config self.user_mgr.config = self.config
@@ -9223,6 +9493,8 @@ class WebServer:
"active_song_request": song_state.get("active"), "active_song_request": song_state.get("active"),
"recent_danmu": list(getattr(self.system.handler, "recent_danmu", [])[-25:]), "recent_danmu": list(getattr(self.system.handler, "recent_danmu", [])[-25:]),
"recent_gifts": list(getattr(self.system.handler, "recent_gifts", [])[-25:]), "recent_gifts": list(getattr(self.system.handler, "recent_gifts", [])[-25:]),
"manual_live_test": self.system.manual_live_override_snapshot(),
"bilibili_live": self.system.bilibili_live_status_snapshot(),
}) })
return state return state
@@ -10284,13 +10556,19 @@ class WebServer:
elif action == "test_bilibili_push": elif action == "test_bilibili_push":
ok = await self.scheduler.push_bilibili_live() ok = await self.scheduler.push_bilibili_live()
if not ok: if not ok:
return web.json_response({"success": False, "error": "未找到直播姬窗口或点击失败"}, status=400) error = self.scheduler.last_live_action_error or "直播姬开播测试失败"
return web.json_response({"success": False, "error": error}, status=400)
manual_until = self.system.enable_manual_live_override()
audit_target = "bilibili_push" audit_target = "bilibili_push"
audit_detail = f"live confirmed manual_until={manual_until:.0f}"
elif action == "test_bilibili_stop_push": elif action == "test_bilibili_stop_push":
ok = await self.scheduler.stop_bilibili_live() ok = await self.scheduler.stop_bilibili_live()
if not ok: if not ok:
return web.json_response({"success": False, "error": "未找到直播姬窗口或关闭推流点击失败"}, status=400) error = self.scheduler.last_live_action_error or "直播姬关播测试失败"
return web.json_response({"success": False, "error": error}, status=400)
self.system.clear_manual_live_override()
audit_target = "bilibili_stop_push" audit_target = "bilibili_stop_push"
audit_detail = "offline confirmed"
elif action == "save_system_schedule_config": elif action == "save_system_schedule_config":
data = await request.json() data = await request.json()
self.config.reload() self.config.reload()
+112 -18
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import io import io
import multiprocessing import multiprocessing
import os import os
import sys
import threading import threading
import time import time
import traceback import traceback
@@ -13,13 +14,14 @@ from typing import Any, Callable
MAX_NEW_TOKENS = 384 MAX_NEW_TOKENS = 384
DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0 DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0
DEFAULT_STARTUP_TIMEOUT_SECONDS = 480.0 DEFAULT_STARTUP_TIMEOUT_SECONDS = 900.0
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环 # 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。 # 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0 STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
DEFAULT_CPU_THREADS = 4 DEFAULT_CPU_THREADS = 4
DEFAULT_CPU_AFFINITY_COUNT = 8 DEFAULT_CPU_AFFINITY_COUNT = 8
DEFAULT_PROCESS_PRIORITY = "below_normal" DEFAULT_PROCESS_PRIORITY = "below_normal"
STARTUP_PROGRESS_POLL_SECONDS = 1.0
class FasterQwenWorkerError(RuntimeError): class FasterQwenWorkerError(RuntimeError):
@@ -122,15 +124,26 @@ def _apply_worker_process_limits(settings: dict[str, Any]) -> dict[str, Any]:
} }
def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]: def _load_runtime(
settings: dict[str, Any],
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
def report(stage: str) -> None:
if progress:
progress(stage)
device = str(settings.get("device") or "cuda") device = str(settings.get("device") or "cuda")
if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ: if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ:
os.environ["CUDA_VISIBLE_DEVICES"] = "" os.environ["CUDA_VISIBLE_DEVICES"] = ""
cpu_threads = _configure_worker_environment(settings) cpu_threads = _configure_worker_environment(settings)
report("torch_import_started")
import torch import torch
report("torch_import_complete")
_configure_torch_threads(torch, cpu_threads) _configure_torch_threads(torch, cpu_threads)
report("tts_library_import_started")
from faster_qwen3_tts import FasterQwen3TTS from faster_qwen3_tts import FasterQwen3TTS
report("tts_library_import_complete")
if device == "cpu": if device == "cpu":
torch.cuda.is_available = lambda: False torch.cuda.is_available = lambda: False
@@ -138,10 +151,12 @@ def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
load_kwargs: dict[str, Any] = {} load_kwargs: dict[str, Any] = {}
if device == "cpu": if device == "cpu":
load_kwargs["device"] = "cpu" load_kwargs["device"] = "cpu"
report("from_pretrained_started")
model = FasterQwen3TTS.from_pretrained( model = FasterQwen3TTS.from_pretrained(
str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"), str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"),
**load_kwargs, **load_kwargs,
) )
report("from_pretrained_complete")
# voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有 # voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有
# create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在 # create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在
@@ -193,21 +208,63 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None: def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
def report(stage: str, **details: Any) -> None:
try:
connection.send({
"type": "progress",
"stage": stage,
"pid": os.getpid(),
**details,
})
except Exception:
pass
try: try:
report("process_started", executable=sys.executable, prefix=sys.prefix)
cpu_threads = _configure_worker_environment(settings) cpu_threads = _configure_worker_environment(settings)
process_limits = _apply_worker_process_limits(settings) report("environment_ready", cpu_threads=cpu_threads)
load_started = time.monotonic() load_started = time.monotonic()
runtime = _load_runtime(settings) report("model_loading", model_name=str(settings.get("model_name_or_path") or ""))
runtime = _load_runtime(settings, progress=report)
load_ms = int((time.monotonic() - load_started) * 1000) load_ms = int((time.monotonic() - load_started) * 1000)
report("model_loaded", load_ms=load_ms)
warmup_started = time.monotonic() warmup_started = time.monotonic()
graph_warmup_ms = 0
report("warmup_started")
warmup = getattr(runtime["model"], "warmup", None)
if callable(warmup):
graph_warmup_started = time.monotonic()
warmup()
graph_warmup_ms = int((time.monotonic() - graph_warmup_started) * 1000)
report("graph_warmup_complete", graph_warmup_ms=graph_warmup_ms)
# model.warmup() 只捕获模型内部 CUDA Graph,不会初始化参考音频和
# voice-clone 生成路径。若把这一步留到首条播报,后台会在近一分钟内
# 看起来毫无响应,因此在降低进程优先级之前完成一次真实短句合成。
voice_clone_warmup_started = time.monotonic()
report("voice_clone_warmup_started")
_synthesize_wav(runtime, "系统启动") _synthesize_wav(runtime, "系统启动")
voice_clone_warmup_ms = int((time.monotonic() - voice_clone_warmup_started) * 1000)
report("voice_clone_warmup_complete", voice_clone_warmup_ms=voice_clone_warmup_ms)
warmup_ms = int((time.monotonic() - warmup_started) * 1000) warmup_ms = int((time.monotonic() - warmup_started) * 1000)
report(
"warmup_complete",
warmup_ms=warmup_ms,
graph_warmup_ms=graph_warmup_ms,
voice_clone_warmup_ms=voice_clone_warmup_ms,
)
# 加载和 CUDA Graph 捕获需要完整 CPU 调度能力。仅在预热完成后降低
# worker 优先级和亲和性,避免繁忙直播环境下启动时间被放大到超时。
process_limits = _apply_worker_process_limits(settings)
connection.send({ connection.send({
"type": "ready", "type": "ready",
"pid": os.getpid(), "pid": os.getpid(),
"load_ms": load_ms, "load_ms": load_ms,
"warmup_ms": warmup_ms, "warmup_ms": warmup_ms,
"graph_warmup_ms": graph_warmup_ms,
"voice_clone_warmup_ms": voice_clone_warmup_ms,
"max_new_tokens": MAX_NEW_TOKENS, "max_new_tokens": MAX_NEW_TOKENS,
"cpu_threads": cpu_threads, "cpu_threads": cpu_threads,
**process_limits, **process_limits,
@@ -278,7 +335,7 @@ class FasterQwenWorkerClient:
self.settings = dict(settings) self.settings = dict(settings)
self.logger = logger self.logger = logger
self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds)) self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds))
self.startup_timeout_seconds = max(10.0, float(startup_timeout_seconds)) self.startup_timeout_seconds = max(0.1, float(startup_timeout_seconds))
self._context = context or multiprocessing.get_context("spawn") self._context = context or multiprocessing.get_context("spawn")
self._process_target = process_target or faster_qwen_worker_main self._process_target = process_target or faster_qwen_worker_main
self._lock = threading.RLock() self._lock = threading.RLock()
@@ -330,21 +387,55 @@ class FasterQwenWorkerClient:
self._worker_pid = int(getattr(process, "pid", 0) or 0) self._worker_pid = int(getattr(process, "pid", 0) or 0)
self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid) self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid)
if not parent_connection.poll(self.startup_timeout_seconds): started = time.monotonic()
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS deadline = started + self.startup_timeout_seconds
self._terminate_worker_locked("startup_timeout", graceful=False) last_stage = "process_spawned"
raise FasterQwenWorkerError( message: Any = None
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s" while True:
) remaining = deadline - time.monotonic()
try: if remaining <= 0:
message = parent_connection.recv() self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
except (EOFError, OSError) as exc: self._terminate_worker_locked("startup_timeout", graceful=False)
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS raise FasterQwenWorkerError(
self._terminate_worker_locked("startup_connection_closed", graceful=False) f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s "
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during startup") from exc f"(last_stage={last_stage})"
)
if not parent_connection.poll(min(STARTUP_PROGRESS_POLL_SECONDS, remaining)):
if not process.is_alive():
exit_code = getattr(process, "exitcode", None)
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_process_exited", graceful=False)
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker exited during startup "
f"(exit_code={exit_code}, last_stage={last_stage})"
)
continue
try:
message = parent_connection.recv()
except (EOFError, OSError) as exc:
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_connection_closed", graceful=False)
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker connection closed during startup "
f"(last_stage={last_stage})"
) from exc
if isinstance(message, dict) and message.get("type") == "progress":
last_stage = str(message.get("stage") or "unknown")
elapsed_ms = int((time.monotonic() - started) * 1000)
self._log(
"info",
"[FasterQwenTTS] worker 启动进度: %s, elapsed=%sms",
last_stage,
elapsed_ms,
)
continue
break
if not isinstance(message, dict) or message.get("type") != "ready": if not isinstance(message, dict) or message.get("type") != "ready":
if isinstance(message, dict): if isinstance(message, dict):
error = str(message.get("error") or message.get("error_type") or "unknown startup error") error = str(message.get("error") or message.get("error_type") or "unknown startup error")
worker_traceback = str(message.get("traceback") or "").strip()
if worker_traceback:
self._log("error", "[FasterQwenTTS] worker 启动异常:\n%s", worker_traceback)
else: else:
error = "invalid startup response" error = "invalid startup response"
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
@@ -353,10 +444,13 @@ class FasterQwenWorkerClient:
self._next_start_after = 0.0 self._next_start_after = 0.0
self._log( self._log(
"info", "info",
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms max_new_tokens=%s", "[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms "
"graph=%sms voice_clone=%sms max_new_tokens=%s",
self.worker_pid, self.worker_pid,
message.get("load_ms"), message.get("load_ms"),
message.get("warmup_ms"), message.get("warmup_ms"),
message.get("graph_warmup_ms"),
message.get("voice_clone_warmup_ms"),
message.get("max_new_tokens"), message.get("max_new_tokens"),
) )
self._log( self._log(
+2 -1
View File
@@ -59,7 +59,8 @@
"streaming": false, "streaming": false,
"cpu_threads": 3, "cpu_threads": 3,
"cpu_affinity_count": 6, "cpu_affinity_count": 6,
"process_priority": "below_normal" "process_priority": "below_normal",
"startup_timeout_seconds": 900
} }
}, },
"enable_danmu_reply": true, "enable_danmu_reply": true,
+86
View File
@@ -9,6 +9,7 @@ from app.faster_qwen_worker import (
MAX_NEW_TOKENS, MAX_NEW_TOKENS,
FasterQwenWorkerClient, FasterQwenWorkerClient,
FasterQwenWorkerTimeout, FasterQwenWorkerTimeout,
faster_qwen_worker_main,
_generation_kwargs, _generation_kwargs,
_configure_worker_environment, _configure_worker_environment,
) )
@@ -91,6 +92,14 @@ def _ready(pid):
} }
def _progress(stage):
return {
"type": "progress",
"stage": stage,
"pid": 4000,
}
def _probe_worker_main(connection, _settings): def _probe_worker_main(connection, _settings):
connection.send({ connection.send({
"type": "ready", "type": "ready",
@@ -153,6 +162,8 @@ class FasterQwenWorkerTests(unittest.TestCase):
def test_client_returns_worker_audio(self): def test_client_returns_worker_audio(self):
connection = _FakeConnection([ connection = _FakeConnection([
_progress("model_loading"),
_progress("warmup_started"),
_ready(4100), _ready(4100),
{ {
"type": "result", "type": "result",
@@ -181,6 +192,81 @@ class FasterQwenWorkerTests(unittest.TestCase):
self.assertEqual(len(context.processes), 1) self.assertEqual(len(context.processes), 1)
client.close() client.close()
def test_client_default_startup_timeout_allows_slow_model_load(self):
client = FasterQwenWorkerClient({})
self.assertEqual(client.startup_timeout_seconds, 900.0)
client.close()
def test_worker_applies_process_limits_after_model_warmup(self):
order = []
class FakeModel:
def warmup(self):
order.append("warmup")
class WorkerConnection:
def __init__(self):
self.sent = []
self.closed = False
def send(self, message):
self.sent.append(message)
def recv(self):
return {"command": "stop"}
def close(self):
self.closed = True
connection = WorkerConnection()
with (
patch("app.faster_qwen_worker._configure_worker_environment", return_value=3),
patch(
"app.faster_qwen_worker._load_runtime",
side_effect=lambda _settings, progress=None: (
progress("from_pretrained_started") if progress else None,
order.append("load"),
{"model": FakeModel()},
)[-1],
),
patch(
"app.faster_qwen_worker._apply_worker_process_limits",
side_effect=lambda _settings: order.append("limits") or {
"cpu_affinity_count": 6,
"process_priority": "below_normal",
},
),
patch(
"app.faster_qwen_worker._synthesize_wav",
side_effect=lambda _runtime, _text: order.append("voice_clone") or b"wav",
),
):
faster_qwen_worker_main(connection, {})
self.assertEqual(order, ["load", "warmup", "voice_clone", "limits"])
progress_stages = [
item.get("stage")
for item in connection.sent
if item.get("type") == "progress"
]
self.assertEqual(
progress_stages,
[
"process_started",
"environment_ready",
"model_loading",
"from_pretrained_started",
"model_loaded",
"warmup_started",
"graph_warmup_complete",
"voice_clone_warmup_started",
"voice_clone_warmup_complete",
"warmup_complete",
],
)
self.assertEqual(connection.sent[-1]["type"], "ready")
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self): def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
first_connection = _FakeConnection([_ready(4200)]) first_connection = _FakeConnection([_ready(4200)])
replacement_connection = _FakeConnection([_ready(4300)]) replacement_connection = _FakeConnection([_ready(4300)])
+165 -1
View File
@@ -1,10 +1,12 @@
import asyncio import asyncio
import ctypes
import logging import logging
import unittest import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from app.danmu_queue import SystemScheduler from app.danmu_queue import CommandHandler, QueueSystem, SystemScheduler
class _FakeConfig: class _FakeConfig:
@@ -155,5 +157,167 @@ class StartupLiveCompensationTests(unittest.IsolatedAsyncioTestCase):
) )
class LivehimePushVerificationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.logger = logging.getLogger("test-livehime-push-verification")
self.scheduler = SystemScheduler(
_FakeConfig({
"bilibili_push_window_keyword": "直播姬",
"bilibili_push_click_x_ratio": 0.787,
"bilibili_push_click_y_ratio": 0.927,
"bilibili_stop_push_click_x_ratio": 0.787,
"bilibili_stop_push_click_y_ratio": 0.927,
}),
self.logger,
)
async def test_push_skips_click_when_bilibili_is_already_live(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=1)
self.scheduler._click_bilibili_live = AsyncMock()
self.assertTrue(await self.scheduler.push_bilibili_live())
self.scheduler._click_bilibili_live.assert_not_awaited()
async def test_push_clicks_then_waits_for_confirmed_live_status(self):
self.scheduler._get_bilibili_live_status = AsyncMock(side_effect=[0, 0, 1])
self.scheduler._click_bilibili_live = AsyncMock(return_value=True)
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
result = await self.scheduler.push_bilibili_live()
self.assertTrue(result)
self.scheduler._click_bilibili_live.assert_awaited_once()
async def test_stop_skips_click_when_bilibili_is_already_offline(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
self.scheduler._click_bilibili_live = AsyncMock()
self.assertTrue(await self.scheduler.stop_bilibili_live())
self.scheduler._click_bilibili_live.assert_not_awaited()
async def test_live_status_timeout_returns_specific_error(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
result = await self.scheduler._wait_for_bilibili_live_status(
1,
timeout_seconds=0.01,
)
self.assertFalse(result)
self.assertIn("未确认开播", self.scheduler.last_live_action_error)
@patch("app.danmu_queue.os.name", "nt")
async def test_click_restores_and_focuses_before_reading_physical_window_rect(self):
hwnd = 12345
user32 = MagicMock()
call_order = []
user32.IsIconic.return_value = True
user32.GetForegroundWindow.return_value = hwnd
user32.GetSystemMetrics.side_effect = lambda index: {
76: 0,
77: 0,
78: 2560,
79: 1440,
}[index]
user32.SetCursorPos.return_value = True
user32.GetCursorPos.return_value = False
user32.SetThreadDpiAwarenessContext.return_value = None
def show_window(_hwnd, _command):
call_order.append("restore")
return True
def get_window_rect(_hwnd, pointer):
call_order.append("rect")
rect = ctypes.cast(pointer, ctypes.POINTER(ctypes.wintypes.RECT)).contents
rect.left = -11
rect.top = -11
rect.right = 2571
rect.bottom = 1379
return True
user32.ShowWindow.side_effect = show_window
user32.GetWindowRect.side_effect = get_window_rect
self.scheduler._find_window_by_keyword = MagicMock(return_value=hwnd)
self.scheduler._force_foreground_window = MagicMock(return_value=True)
with (
patch("app.danmu_queue.ctypes.windll.user32", user32),
patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock),
):
result = await self.scheduler._click_bilibili_live(
x_ratio_key="bilibili_push_click_x_ratio",
y_ratio_key="bilibili_push_click_y_ratio",
action_name="开启推流按钮",
)
self.assertTrue(result)
self.assertLess(call_order.index("restore"), call_order.index("rect"))
user32.SetCursorPos.assert_called_once_with(2021, 1277)
self.assertEqual(user32.mouse_event.call_count, 2)
class ManualLiveOverrideTests(unittest.TestCase):
def setUp(self):
self.system = QueueSystem.__new__(QueueSystem)
self.system.logger = logging.getLogger("test-manual-live-override")
self.system._manual_live_override_until_monotonic = 0.0
self.system._manual_live_override_until_wall = 0.0
self.system._bilibili_live_status = None
self.system._bilibili_live_status_updated_monotonic = 0.0
self.system._bilibili_live_status_updated_wall = 0.0
def test_manual_live_override_allows_commands_outside_schedule(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with patch("app.danmu_queue.time.monotonic", return_value=100.0):
self.system.enable_manual_live_override(300)
self.assertTrue(handler._is_live_time())
snapshot = self.system.manual_live_override_snapshot()
self.assertTrue(snapshot["active"])
self.assertGreater(snapshot["until"], 0)
def test_clear_manual_live_override_restores_schedule_gate(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with (
patch("app.danmu_queue.time.monotonic", return_value=100.0),
patch("app.danmu_queue.datetime") as mocked_datetime,
):
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
mocked_datetime.strptime = datetime.strptime
self.system.enable_manual_live_override(300)
self.system.clear_manual_live_override()
self.assertFalse(handler._is_live_time())
def test_confirmed_bilibili_live_status_allows_commands_outside_schedule(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with (
patch("app.danmu_queue.time.monotonic", return_value=100.0),
patch("app.danmu_queue.datetime") as mocked_datetime,
):
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
mocked_datetime.strptime = datetime.strptime
self.system.set_bilibili_live_status(1)
self.assertTrue(handler._is_live_time())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+44
View File
@@ -2,6 +2,7 @@ import asyncio
import logging import logging
import time import time
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import numpy as np import numpy as np
@@ -144,6 +145,49 @@ class PipelineOverlapTests(unittest.IsolatedAsyncioTestCase):
await asyncio.gather(synth_worker, play_worker, return_exceptions=True) await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
class BackgroundWarmupTests(unittest.IsolatedAsyncioTestCase):
async def test_start_returns_before_tts_warmup_finishes(self):
warmup_started = asyncio.Event()
release_warmup = asyncio.Event()
class FakeTTS:
enabled = True
async def warmup(self):
warmup_started.set()
await release_warmup.wait()
async def idle_worker():
await asyncio.Event().wait()
broadcaster = Broadcaster.__new__(Broadcaster)
broadcaster._tts_started = False
broadcaster._tts_warmup_done = asyncio.Event()
broadcaster._tts_warmup_task = None
broadcaster._tts_worker_tasks = set()
broadcaster._tts_queue_cfg = {"warmup_on_start": True}
broadcaster._tts_pending = SimpleNamespace(maxsize=8)
broadcaster._tts_playback_queue = SimpleNamespace(maxsize=2)
broadcaster._tts_synthesis_loop = idle_worker
broadcaster._tts_playback_loop = idle_worker
broadcaster.tts = FakeTTS()
broadcaster.logger = logging.getLogger("tts-background-warmup-test")
await asyncio.wait_for(broadcaster.start(), timeout=0.2)
await asyncio.wait_for(warmup_started.wait(), timeout=0.2)
self.assertFalse(broadcaster._tts_warmup_done.is_set())
self.assertIsNotNone(broadcaster._tts_warmup_task)
release_warmup.set()
await asyncio.wait_for(broadcaster._tts_warmup_done.wait(), timeout=0.2)
tasks = list(broadcaster._tts_worker_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
class TTSExpiryPolicyTests(unittest.TestCase): class TTSExpiryPolicyTests(unittest.TestCase):
def test_default_expiry_windows_match_configured_policy(self): def test_default_expiry_windows_match_configured_policy(self):
broadcaster = Broadcaster.__new__(Broadcaster) broadcaster = Broadcaster.__new__(Broadcaster)
File diff suppressed because one or more lines are too long