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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user