From 5761ae44f24e389bdc6ebe1ee621000dc2075b33 Mon Sep 17 00:00:00 2001 From: John Smith Date: Tue, 27 Feb 2024 23:18:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0TTS=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blcsdk/requirements.txt | 2 + plugins/text-to-speech/config.py | 33 ++++++ plugins/text-to-speech/listener.py | 113 +++++++++++++++++++++ plugins/text-to-speech/main.py | 92 +++++++++++++++++ plugins/text-to-speech/requirements.txt | 1 + plugins/text-to-speech/tts.py | 127 ++++++++++++++++++++++++ 6 files changed, 368 insertions(+) create mode 100644 blcsdk/requirements.txt create mode 100644 plugins/text-to-speech/config.py create mode 100644 plugins/text-to-speech/listener.py create mode 100644 plugins/text-to-speech/main.py create mode 100644 plugins/text-to-speech/requirements.txt create mode 100644 plugins/text-to-speech/tts.py diff --git a/blcsdk/requirements.txt b/blcsdk/requirements.txt new file mode 100644 index 0000000..8513e46 --- /dev/null +++ b/blcsdk/requirements.txt @@ -0,0 +1,2 @@ +aiohttp~=3.9.0 +pyinstaller~=5.13.2 diff --git a/plugins/text-to-speech/config.py b/plugins/text-to-speech/config.py new file mode 100644 index 0000000..f7f2aeb --- /dev/null +++ b/plugins/text-to-speech/config.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +import os +from typing import * + +BASE_PATH = os.path.realpath(os.getcwd()) +LOG_PATH = os.path.join(BASE_PATH, 'log') + +_config: Optional['AppConfig'] = None + + +def init(): + global _config + _config = AppConfig() + # TODO 读配置文件 + + +def get_config(): + return _config + + +class AppConfig: + def __init__(self): + self.tts_voice_id: Optional[str] = None + self.tts_rate = 250 + self.tts_volume = 1.0 + + self.max_tts_queue_size = 5 + + self.template_text = '{author_name} 说:{content}' + self.template_free_gift = '{author_name} 赠送了{num}个{gift_name},总价{total_coin}银瓜子' + self.template_paid_gift = '{author_name} 赠送了{num}个{gift_name},总价{price}元' + self.template_member = '{author_name} 购买了{num}{unit} {guard_name}' + self.template_super_chat = '{author_name} 发送了{price}元的醒目留言:{content}' diff --git a/plugins/text-to-speech/listener.py b/plugins/text-to-speech/listener.py new file mode 100644 index 0000000..d24aff8 --- /dev/null +++ b/plugins/text-to-speech/listener.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +import __main__ +import logging +import os +import sys +from typing import * + +import blcsdk +import blcsdk.models as sdk_models +import config +import tts + +logger = logging.getLogger('text-to-speech.' + __name__) + +_msg_handler: Optional['MsgHandler'] = None + + +def init(): + global _msg_handler + _msg_handler = MsgHandler() + blcsdk.set_msg_handler(_msg_handler) + + +def shut_down(): + blcsdk.set_msg_handler(None) + + +class MsgHandler(blcsdk.BaseHandler): + def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]): + logger.info('blivechat disconnected') + __main__.start_shut_down() + + def _on_open_plugin_admin_ui( + self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData + ): + if sys.platform == 'win32': + # TODO 浏览配置文件 + os.startfile(config.LOG_PATH) + else: + logger.info('Log path is "%s"', config.LOG_PATH) + + def _on_add_text(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddTextMsg, extra: sdk_models.ExtraData): + if extra.is_from_plugin: + return + cfg = config.get_config() + if cfg.template_text == '': + return + + text = cfg.template_text.format( + author_name=message.author_name, + content=message.content, + ) + tts.say(text) + + def _on_add_gift(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddGiftMsg, extra: sdk_models.ExtraData): + if extra.is_from_plugin: + return + cfg = config.get_config() + is_paid_gift = message.total_coin != 0 + template = cfg.template_paid_gift if is_paid_gift else cfg.template_free_gift + if template == '': + return + + text = template.format( + author_name=message.author_name, + num=message.num, + gift_name=message.gift_name, + price=message.total_coin / 1000, + total_coin=message.total_coin if is_paid_gift else message.total_free_coin, + ) + tts.say(text, tts.Priority.HIGH if is_paid_gift else tts.Priority.NORMAL) + + def _on_add_member( + self, client: blcsdk.BlcPluginClient, message: sdk_models.AddMemberMsg, extra: sdk_models.ExtraData + ): + if extra.is_from_plugin: + return + cfg = config.get_config() + if cfg.template_member == '': + return + + if message.privilege_type == sdk_models.GuardLevel.LV1: + guard_name = '舰长' + elif message.privilege_type == sdk_models.GuardLevel.LV2: + guard_name = '提督' + elif message.privilege_type == sdk_models.GuardLevel.LV3: + guard_name = '总督' + else: + guard_name = '未知舰队等级' + + text = cfg.template_member.format( + author_name=message.author_name, + num=message.num, + unit=message.unit, + guard_name=guard_name, + ) + tts.say(text, tts.Priority.HIGH) + + def _on_add_super_chat( + self, client: blcsdk.BlcPluginClient, message: sdk_models.AddSuperChatMsg, extra: sdk_models.ExtraData + ): + if extra.is_from_plugin: + return + cfg = config.get_config() + if cfg.template_super_chat == '': + return + + text = cfg.template_super_chat.format( + author_name=message.author_name, + price=message.price, + content=message.content, + ) + tts.say(text, tts.Priority.HIGH) diff --git a/plugins/text-to-speech/main.py b/plugins/text-to-speech/main.py new file mode 100644 index 0000000..f2ef8ec --- /dev/null +++ b/plugins/text-to-speech/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import asyncio +import logging.handlers +import os +import signal +import sys +from typing import * + +import blcsdk +import config +import listener +import tts + +logger = logging.getLogger('text-to-speech') + +shut_down_event: Optional[asyncio.Event] = None + + +async def main(): + try: + if not await init(): + return 1 + await run() + finally: + await shut_down() + return 0 + + +async def init(): + init_signal_handlers() + + init_logging() + config.init() + + await blcsdk.init() + if not blcsdk.is_sdk_version_compatible(): + raise RuntimeError('SDK version is not compatible') + + if not tts.init(): + return False + listener.init() + return True + + +def init_signal_handlers(): + global shut_down_event + shut_down_event = asyncio.Event() + + signums = (signal.SIGINT, signal.SIGTERM) + try: + loop = asyncio.get_running_loop() + for signum in signums: + loop.add_signal_handler(signum, start_shut_down) + except NotImplementedError: + # 不太安全,但Windows只能用这个 + for signum in signums: + signal.signal(signum, start_shut_down) + + +def start_shut_down(*_args): + shut_down_event.set() + + +def init_logging(): + filename = os.path.join(config.LOG_PATH, 'text-to-speech.log') + stream_handler = logging.StreamHandler() + file_handler = logging.handlers.TimedRotatingFileHandler( + filename, encoding='utf-8', when='midnight', backupCount=7, delay=True + ) + logging.basicConfig( + format='{asctime} {levelname} [{name}]: {message}', + style='{', + level=logging.INFO, + # level=logging.DEBUG, + handlers=[stream_handler, file_handler], + ) + + +async def run(): + logger.info('Running event loop') + await shut_down_event.wait() + logger.info('Start to shut down') + + +async def shut_down(): + listener.shut_down() + await blcsdk.shut_down() + + +if __name__ == '__main__': + sys.exit(asyncio.run(main())) diff --git a/plugins/text-to-speech/requirements.txt b/plugins/text-to-speech/requirements.txt new file mode 100644 index 0000000..424d51a --- /dev/null +++ b/plugins/text-to-speech/requirements.txt @@ -0,0 +1 @@ +pyttsx3==2.90 diff --git a/plugins/text-to-speech/tts.py b/plugins/text-to-speech/tts.py new file mode 100644 index 0000000..6253975 --- /dev/null +++ b/plugins/text-to-speech/tts.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +import dataclasses +import enum +import logging +import queue +import threading +from typing import * + +import pyttsx3.voice + +import config + +logger = logging.getLogger('text-to-speech.' + __name__) + +_tts: Optional['Tts'] = None + + +class Priority(enum.IntEnum): + HIGH = 0 + NORMAL = 1 + + +@dataclasses.dataclass +class TtsTask: + priority: Priority + text: str + + +def init(): + global _tts + _tts = Tts() + return _tts.init() + + +def say(text, priority: Priority = Priority.NORMAL): + logger.debug('%s', text) + task = TtsTask(priority=priority, text=text) + res = _tts.push_task(task) + if not res: + if task.priority == Priority.HIGH: + logger.info('Dropped high priority task: %s', task.text) + else: + logger.debug('Dropped task: %s', task.text) + return res + + +class Tts: + def __init__(self): + self._worker_thread = threading.Thread(target=self._worker_thread_func, daemon=True) + # COM组件必须在使用它的线程里初始化,否则使用时会有问题 + self._engine: Optional[pyttsx3.Engine] = None + self._thread_init_event = threading.Event() + + cfg = config.get_config() + self._task_queues: List[queue.Queue['TtsTask']] = [ + queue.Queue(cfg.max_tts_queue_size) for _ in range(len(Priority)) + ] + """任务队列,索引是优先级""" + + def init(self): + self._worker_thread.start() + res = self._thread_init_event.wait(10) + if not res: + logger.error('Initializing TTS engine timed out') + return res + + def _init_in_worker_thread(self): + logger.info('Initializing TTS engine') + self._engine = pyttsx3.init() + + voices = cast(List[pyttsx3.voice.Voice], self._engine.getProperty('voices')) + logger.info('Available voices:\n%s', '\n'.join(map(str, voices))) + + cfg = config.get_config() + if cfg.tts_voice_id is not None: + self._engine.setProperty('voice', cfg.tts_voice_id) + self._engine.setProperty('rate', cfg.tts_rate) + self._engine.setProperty('volume', cfg.tts_volume) + + self._thread_init_event.set() + + # TODO 自己实现队列,合并礼物消息 + def push_task(self, task: TtsTask): + q = self._task_queues[task.priority] + try: + q.put_nowait(task) + return True + except queue.Full: + pass + + if task.priority != Priority.HIGH: + return False + + # 高优先级的尝试降级,挤掉低优先级的任务 + q = self._task_queues[Priority.NORMAL] + while True: + try: + q.put_nowait(task) + break + except queue.Full: + try: + task = q.get_nowait() + if task.priority == Priority.HIGH: + logger.info('Dropped high priority task: %s', task.text) + else: + logger.debug('Dropped task: %s', task.text) + except queue.Empty: + pass + return True + + def _pop_task(self) -> TtsTask: + while True: + # 按优先级遍历,轮询等待任务 + for q in self._task_queues: + try: + return q.get(timeout=0.1) + except queue.Empty: + pass + + def _worker_thread_func(self): + self._init_in_worker_thread() + + logger.info('Running TTS worker') + while True: + task = self._pop_task() + self._engine.say(task.text) + self._engine.runAndWait()