feat: Bilibili Live Danmaku -> Yukkuri TTS Reader
- Python main entry with CLI (--room-id, --voice, --speed) - blivedm integration for real-time danmaku receiving - Chinese-to-Japanese katakana conversion (pypinyin + mapping table) - Node.js persistent bridge for aquestalk.js TTS synthesis - Audio playback via sounddevice - Message queue for sequential TTS playback
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temp files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Audio playback for WAV files."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
|
||||
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.
|
||||
"""
|
||||
data, samplerate = sf.read(io.BytesIO(wav_data), dtype="float32")
|
||||
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.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, play_wav, wav_data)
|
||||
|
||||
|
||||
def get_output_devices() -> list[dict]:
|
||||
"""List available audio output devices."""
|
||||
devices = sd.query_devices()
|
||||
result = []
|
||||
for i, dev in enumerate(devices):
|
||||
if dev["max_output_channels"] > 0:
|
||||
result.append({
|
||||
"index": i,
|
||||
"name": dev["name"],
|
||||
"channels": dev["max_output_channels"],
|
||||
"default_samplerate": dev["default_samplerate"],
|
||||
})
|
||||
return result
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""Chinese text → Japanese Katakana converter.
|
||||
|
||||
Converts Chinese text to Japanese kana (katakana) suitable for AquesTalk TTS.
|
||||
Non-Chinese characters (Japanese kana, English, emoji, etc.) pass through unchanged.
|
||||
Uses pypinyin for pinyin extraction and an internal pinyin→katakana mapping table.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pypinyin import pinyin, Style
|
||||
|
||||
from pinyin2kana_data import PINYIN2KANA
|
||||
|
||||
_CHINESE_CHAR_RE = re.compile(r"[\u4e00-\u9fa5]")
|
||||
_CHINESE_SEGMENT_RE = re.compile(r"([\u4e00-\u9fa5]+)")
|
||||
_NUMBER_RE = re.compile(r"-?\d+(\.\d+)?")
|
||||
_TONE_RE = re.compile(r"\d")
|
||||
|
||||
_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 = "零"
|
||||
else:
|
||||
digits = [int(ch) for ch in integer_part]
|
||||
n = len(digits)
|
||||
for i, d in enumerate(digits):
|
||||
pos = n - i - 1
|
||||
if d == 0:
|
||||
if i < n - 1 and digits[i + 1] != 0:
|
||||
result += _CHINESE_DIGITS[0]
|
||||
else:
|
||||
result += _CHINESE_DIGITS[d]
|
||||
unit_idx = pos % 4
|
||||
wan = pos // 4
|
||||
if wan > 0 and unit_idx == 0:
|
||||
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]
|
||||
py_plain = _strip_tone(py_raw)
|
||||
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
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bilibili Live Danmaku Handler.
|
||||
|
||||
Connects to a Bilibili live room and puts incoming danmaku messages into an asyncio queue.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import blivedm
|
||||
from blivedm.models.web import DanmakuMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DANMAKU_FORMAT = "{uname}: {msg}"
|
||||
|
||||
|
||||
class DanmakuHandler(blivedm.BaseHandler):
|
||||
"""Handles incoming danmaku from a Bilibili live room."""
|
||||
|
||||
def __init__(self, queue: asyncio.Queue, message_format: str = DANMAKU_FORMAT):
|
||||
super().__init__()
|
||||
self._queue = queue
|
||||
self._message_format = message_format
|
||||
|
||||
def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None:
|
||||
if not message.msg.strip():
|
||||
return
|
||||
text = self._message_format.format(
|
||||
uname=message.uname,
|
||||
msg=message.msg,
|
||||
uid=message.uid,
|
||||
)
|
||||
logger.info("Danmaku: %s", text)
|
||||
try:
|
||||
self._queue.put_nowait(text)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Danmaku queue full, dropping: %s", 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)
|
||||
|
||||
def _on_interact_word_v2(self, client: blivedm.BLiveClient, message) -> None:
|
||||
logger.debug("Interact: %s (type=%d)", message.uname, message.msg_type)
|
||||
|
||||
def on_client_stopped(self, client, exception: Optional[Exception]) -> None:
|
||||
if exception is not None:
|
||||
logger.error("Client stopped with exception: %s", exception)
|
||||
else:
|
||||
logger.info("Client stopped normally.")
|
||||
|
||||
|
||||
class DanmakuClient:
|
||||
"""Manages the BLiveClient connection for a single room."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
room_id: int,
|
||||
queue: asyncio.Queue,
|
||||
sessdata: str = "",
|
||||
message_format: str = DANMAKU_FORMAT,
|
||||
):
|
||||
self._room_id = room_id
|
||||
self._queue = queue
|
||||
self._sessdata = sessdata
|
||||
self._message_format = message_format
|
||||
self._client: Optional[blivedm.BLiveClient] = 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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def room_id(self) -> int:
|
||||
return self._room_id
|
||||
@@ -0,0 +1,194 @@
|
||||
"""bililive-touhou-tts — Bilibili Live Danmaku → Yukkuri TTS Reader.
|
||||
|
||||
Usage:
|
||||
python main.py --room-id 12345
|
||||
python main.py --room-id 12345 --voice f2 --speed 120
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from danmaku_handler import DanmakuClient
|
||||
from chinese2kana import chinese_to_kana
|
||||
from tts import TTSBridge
|
||||
from audio_player import play_wav_async, get_output_devices
|
||||
|
||||
logger = logging.getLogger("bililive-tts")
|
||||
|
||||
DEFAULT_VOICE = "f1"
|
||||
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",
|
||||
)
|
||||
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."""
|
||||
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)
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> int:
|
||||
if args.list_devices:
|
||||
devices = get_output_devices()
|
||||
print("Available audio output devices:")
|
||||
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,
|
||||
message_format=args.format,
|
||||
)
|
||||
|
||||
async def handle_signal() -> None:
|
||||
"""Wait for shutdown 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()
|
||||
|
||||
try:
|
||||
await bridge.start()
|
||||
await danmaku_client.start()
|
||||
|
||||
worker_task = asyncio.create_task(
|
||||
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:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
# Pinyin to Katakana mapping derived from pinyin-to-kana
|
||||
# Source: https://cdn.jsdelivr.net/npm/pinyin-to-kana@1.0.0/mapping.tsv
|
||||
|
||||
PINYIN2KANA: dict[str, str] = {
|
||||
"a": "アー", "ai": "アイ", "an": "アン", "ang": "アン", "ao": "アオ",
|
||||
"ba": "バー", "bai": "バイ", "ban": "バン", "bang": "バン", "bao": "バオ",
|
||||
"bei": "ベイ", "ben": "ベン", "beng": "ボン", "bi": "ビー", "bian": "ビエン",
|
||||
"biao": "ビアオ", "bie": "ビエ", "bin": "ビン", "bing": "ビン", "bo": "ボー",
|
||||
"bu": "ブー",
|
||||
"ca": "ツァー", "cai": "ツァイ", "can": "ツァン", "cang": "ツァン", "cao": "ツァオ",
|
||||
"ce": "ツォー", "cen": "ツェン", "ceng": "ツォン",
|
||||
"cha": "チャー", "chai": "チャイ", "chan": "チャン", "chang": "チャン", "chao": "チャオ",
|
||||
"che": "チョー", "chen": "チェン", "cheng": "チョン", "chi": "チー", "chong": "チョン",
|
||||
"chou": "チョウ", "chu": "チュー", "chua": "チュワ", "chuai": "チュワイ", "chuan": "チュワン",
|
||||
"chuang": "チュアン", "chui": "チュイ", "chun": "チュン", "chuo": "チュオ",
|
||||
"ci": "ツー", "cong": "ツォン", "cou": "ツォウ", "cu": "ツー", "cuan": "ツワン",
|
||||
"cui": "ツイ", "cun": "ツン", "cuo": "ツオ",
|
||||
"da": "ダー", "dai": "ダイ", "dan": "ダン", "dang": "ダン", "dao": "ダオ",
|
||||
"de": "ドー", "dei": "デイ", "den": "デン", "deng": "ドン",
|
||||
"di": "ディー", "dian": "ディエン", "diao": "ディアオ", "die": "ディエ",
|
||||
"ding": "ディン", "diu": "ディウ", "dong": "ドン", "dou": "ドウ",
|
||||
"du": "ドゥー", "duan": "ドワン", "dui": "ドゥイ", "dun": "ドゥン", "duo": "ドゥオ",
|
||||
"e": "オー", "ei": "エイ", "en": "エン", "eng": "オン", "er": "アル",
|
||||
"fa": "ファー", "fan": "ファン", "fang": "ファン", "fei": "フェイ", "fen": "フェン",
|
||||
"feng": "フォン", "fo": "フォー", "fou": "フォウ", "fu": "フー",
|
||||
"ga": "ガー", "gai": "ガイ", "gan": "ガン", "gang": "ガン", "gao": "ガオ",
|
||||
"ge": "ゴー", "gei": "ゲイ", "gen": "ゲン", "geng": "ゴン",
|
||||
"gong": "ゴン", "gou": "ゴウ", "gu": "グー", "gua": "グワ", "guai": "グワイ",
|
||||
"guan": "グワン", "guang": "グアン", "gui": "グイ", "gun": "グン", "guo": "グオ",
|
||||
"ha": "ハー", "hai": "ハイ", "han": "ハン", "hang": "ハン", "hao": "ハオ",
|
||||
"he": "ホー", "hei": "ヘイ", "hen": "ヘン", "heng": "ホン",
|
||||
"hong": "ホン", "hou": "ホウ", "hu": "フー", "hua": "ホワ", "huai": "ホワイ",
|
||||
"huan": "ホワン", "huang": "ホアン", "hui": "フイ", "hun": "フン", "huo": "フオ",
|
||||
"ji": "ジー", "jia": "ジア", "jian": "ジエン", "jiang": "ジアン", "jiao": "ジアオ",
|
||||
"jie": "ジエ", "jin": "ジン", "jing": "ジン", "jiong": "ジオン", "jiu": "ジウ",
|
||||
"ju": "ジュー", "juan": "ジュエン", "jue": "ジュエ", "jun": "ジュン",
|
||||
"ka": "カー", "kai": "カイ", "kan": "カン", "kang": "カン", "kao": "カオ",
|
||||
"ke": "コー", "kei": "ケイ", "ken": "ケン", "keng": "コン",
|
||||
"kong": "コン", "kou": "コウ", "ku": "クー", "kua": "クワ", "kuai": "クワイ",
|
||||
"kuan": "クワン", "kuang": "クアン", "kui": "クイ", "kun": "クン", "kuo": "クオ",
|
||||
"la": "ラー", "lai": "ライ", "lan": "ラン", "lang": "ラン", "lao": "ラオ",
|
||||
"le": "ロー", "lei": "レイ", "leng": "ロン",
|
||||
"li": "リー", "lia": "リア", "lian": "リエン", "liang": "リアン", "liao": "リアオ",
|
||||
"lie": "リエ", "lin": "リン", "ling": "リン", "liu": "リウ",
|
||||
"lo": "ロー", "long": "ロン", "lou": "ロウ",
|
||||
"lu": "ルー", "lü": "リュー", "luan": "ルワン", "lüe": "リュエ", "lun": "ルン", "luo": "ルオ",
|
||||
"lv": "リュー", "lve": "リュエ",
|
||||
"ma": "マー", "mai": "マイ", "man": "マン", "mang": "マン", "mao": "マオ",
|
||||
"me": "マ", "mei": "メイ", "men": "メン", "meng": "モン",
|
||||
"mi": "ミー", "mian": "ミエン", "miao": "ミアオ", "mie": "ミエ",
|
||||
"min": "ミン", "ming": "ミン", "miu": "ミウ",
|
||||
"mo": "モー", "mou": "モウ", "mu": "ムー",
|
||||
"na": "ナー", "nai": "ナイ", "nan": "ナン", "nang": "ナン", "nao": "ナオ",
|
||||
"ne": "ノー", "nei": "ネイ", "nen": "ネン", "neng": "ノン",
|
||||
"ni": "ニー", "nian": "ニエン", "niang": "ニアン", "niao": "ニアオ", "nie": "ニエ",
|
||||
"nin": "ニン", "ning": "ニン", "niu": "ニウ",
|
||||
"nong": "ノン", "nou": "ノウ",
|
||||
"nu": "ヌー", "nü": "ニュー", "nuan": "ヌワン", "nüe": "ニュエ", "nuo": "ヌオ",
|
||||
"nv": "ニュー", "nve": "ニュエ",
|
||||
"o": "オー", "ou": "オウ",
|
||||
"pa": "パー", "pai": "パイ", "pan": "パン", "pang": "パン", "pao": "パオ",
|
||||
"pei": "ペイ", "pen": "ペン", "peng": "ポン",
|
||||
"pi": "ピー", "pian": "ピエン", "piao": "ピアオ", "pie": "ピエ",
|
||||
"pin": "ピン", "ping": "ピン", "po": "ポー", "pou": "ポウ", "pu": "プー",
|
||||
"qi": "チー", "qia": "チア", "qian": "チエン", "qiang": "チアン", "qiao": "チアオ",
|
||||
"qie": "チエ", "qin": "チン", "qing": "チン", "qiong": "チオン", "qiu": "チウ",
|
||||
"qu": "チュー", "quan": "チュエン", "que": "チュエ", "qun": "チュン",
|
||||
"ran": "ラン", "rang": "ラン", "rao": "ラオ", "re": "ロー", "ren": "レン",
|
||||
"reng": "ロン", "ri": "リー",
|
||||
"rong": "ロン", "rou": "ロウ", "ru": "ルー", "ruan": "ルワン",
|
||||
"rui": "ルイ", "run": "ルン", "ruo": "ルオ",
|
||||
"sa": "サー", "sai": "サイ", "san": "サン", "sang": "サン", "sao": "サオ",
|
||||
"se": "ソー", "sen": "セン", "seng": "ソン",
|
||||
"sha": "シャー", "shai": "シャイ", "shan": "シャン", "shang": "シャン", "shao": "シャオ",
|
||||
"she": "ショー", "shei": "シェイ", "shen": "シェン", "sheng": "ション",
|
||||
"shi": "シー", "shou": "ショウ", "shu": "シュー", "shua": "シュワ", "shuai": "シュワイ",
|
||||
"shuan": "シュワン", "shuang": "シュアン", "shui": "シュイ", "shun": "シュン", "shuo": "シュオ",
|
||||
"si": "スー", "song": "ソン", "sou": "ソウ", "su": "スー", "suan": "スワン",
|
||||
"sui": "スイ", "sun": "スン", "suo": "スオ",
|
||||
"ta": "ター", "tai": "タイ", "tan": "タン", "tang": "タン", "tao": "タオ",
|
||||
"te": "トー", "teng": "トン",
|
||||
"ti": "ティー", "tian": "ティエン", "tiao": "ティアオ", "tie": "ティエ",
|
||||
"ting": "ティン", "tong": "トン", "tou": "トウ",
|
||||
"tu": "トゥー", "tuan": "トワン", "tui": "トゥイ", "tun": "トゥン", "tuo": "トゥオ",
|
||||
"wa": "ワー", "wai": "ワイ", "wan": "ワン", "wang": "ワン",
|
||||
"wei": "ウェイ", "wen": "ウェン", "weng": "ウォン", "wo": "ウオ", "wu": "ウー",
|
||||
"xi": "シー", "xia": "シア", "xian": "シエン", "xiang": "シアン", "xiao": "シアオ",
|
||||
"xie": "シエ", "xin": "シン", "xing": "シン", "xiong": "シオン", "xiu": "シウ",
|
||||
"xu": "シュー", "xuan": "シュエン", "xue": "シュエ", "xun": "シュン",
|
||||
"ya": "ヤー", "yan": "イエン", "yang": "ヤン", "yao": "ヤオ",
|
||||
"ye": "イエ", "yi": "イー", "yin": "イン", "ying": "イン",
|
||||
"yo": "ヨー", "yong": "ヨン", "you": "ヨウ",
|
||||
"yu": "ユー", "yuan": "ユエン", "yue": "ユエ", "yun": "ユン",
|
||||
"za": "ザー", "zai": "ザイ", "zan": "ザン", "zang": "ザン", "zao": "ザオ",
|
||||
"ze": "ゾー", "zei": "ゼイ", "zen": "ゼン", "zeng": "ゾン",
|
||||
"zha": "ジャー", "zhai": "ジャイ", "zhan": "ジャン", "zhang": "ジャン", "zhao": "ジャオ",
|
||||
"zhe": "ジョー", "zhei": "ジェイ", "zhen": "ジェン", "zheng": "ジョン",
|
||||
"zhi": "ジー", "zhong": "ジョン", "zhou": "ジョウ",
|
||||
"zhu": "ジュー", "zhua": "ジュワ", "zhuai": "ジュワイ", "zhuan": "ジュワン",
|
||||
"zhuang": "ジュアン", "zhui": "ジュイ", "zhun": "ジュン", "zhuo": "ジュオ",
|
||||
"zi": "ズー", "zong": "ゾン", "zou": "ゾウ",
|
||||
"zu": "ズー", "zuan": "ズワン", "zui": "ズイ", "zun": "ズン", "zuo": "ズオ",
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
aiohttp>=3.9.0
|
||||
pypinyin>=0.51.0
|
||||
sounddevice>=0.5.0
|
||||
soundfile>=0.12.0
|
||||
blivedm @ git+https://github.com/xfgryujk/blivedm.git@master
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Node.js TTS Bridge — Persistent Process Manager.
|
||||
|
||||
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)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TTSBridge:
|
||||
"""Manages a persistent Node.js subprocess for aquestalk.js TTS synthesis."""
|
||||
|
||||
def __init__(self, voice: str = "f1", speed: int = 100, bridge_script: str | None = None):
|
||||
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")
|
||||
|
||||
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}")
|
||||
|
||||
logger.info("Starting Node.js TTS bridge (voice=%s, speed=%d)...", self._voice, self._speed)
|
||||
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
"node",
|
||||
script,
|
||||
"--voice", self._voice,
|
||||
"--speed", str(self._speed),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=60)
|
||||
line_str = line.decode("utf-8").strip()
|
||||
|
||||
if "READY" not in line_str:
|
||||
stderr_data = await self._process.stderr.read()
|
||||
raise RuntimeError(
|
||||
f"Bridge failed to start. Got: {line_str}. Stderr: {stderr_data.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
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.")
|
||||
|
||||
tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="tts_"))
|
||||
try:
|
||||
input_path = tmpdir / "input.txt"
|
||||
output_path = tmpdir / "output.wav"
|
||||
|
||||
input_path.write_text(kana_text, encoding="utf-8")
|
||||
|
||||
cmd_line = f"{input_path}|{output_path}\n"
|
||||
self._process.stdin.write(cmd_line.encode("utf-8"))
|
||||
await self._process.stdin.drain()
|
||||
|
||||
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=120)
|
||||
line_str = line.decode("utf-8").strip()
|
||||
|
||||
if line_str.startswith("ERR:"):
|
||||
raise RuntimeError(f"TTS synthesis error: {line_str[4:]}")
|
||||
|
||||
if not line_str.startswith("OK:") or line_str[3:] != str(output_path):
|
||||
raise RuntimeError(f"Unexpected bridge response: {line_str}")
|
||||
|
||||
wav_data = output_path.read_bytes()
|
||||
return wav_data
|
||||
|
||||
finally:
|
||||
for f in tmpdir.iterdir():
|
||||
try:
|
||||
f.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
tmpdir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the bridge process."""
|
||||
if self._process is not None:
|
||||
logger.info("Stopping TTS bridge...")
|
||||
try:
|
||||
if self._process.stdin is not None:
|
||||
self._process.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self._process.terminate()
|
||||
await asyncio.wait_for(self._process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
self._process.kill()
|
||||
await self._process.wait()
|
||||
logger.info("TTS bridge stopped.")
|
||||
self._process = None
|
||||
@@ -0,0 +1,95 @@
|
||||
/** 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
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { createInterface } from "readline";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
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;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--voice" && i + 1 < args.length) {
|
||||
voice = args[++i];
|
||||
} else if (args[i] === "--speed" && i + 1 < args.length) {
|
||||
speed = parseInt(args[++i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Import aquestalk.js from sibling directory
|
||||
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}...`);
|
||||
|
||||
let aq;
|
||||
try {
|
||||
aq = await load(voice);
|
||||
console.error("[bridge] aquestalk.js loaded successfully.");
|
||||
} catch (err) {
|
||||
console.error("[bridge] Failed to load aquestalk.js:", err.message);
|
||||
process.stdout.write("ERR:FAILED_TO_LOAD\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write("READY\n");
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
process.stdout.write(`OK:${outputPath}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(`ERR:${err.message}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
console.error("[bridge] stdin closed, shutting down.");
|
||||
aq.destroy().then(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user