From 8091a59f368a3818dd3c73dd1317e8905ebc835f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=B5=E6=9B=A6?= Date: Tue, 13 Jan 2026 13:15:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20edge-tts=20?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=96=87=E5=AD=97=E8=BD=AC=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=20close=20#642?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaomusic/config.py | 6 ++ xiaomusic/crontab.py | 32 +++++++++ xiaomusic/device_player.py | 93 +++++++++++++++++++++++---- xiaomusic/music_url.py | 19 +++++- xiaomusic/static/default/setting.html | 25 +++++++ 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/xiaomusic/config.py b/xiaomusic/config.py index c85ed96..02d259d 100644 --- a/xiaomusic/config.py +++ b/xiaomusic/config.py @@ -228,6 +228,12 @@ class Config: web_music_proxy: bool = ( os.getenv("XIAOMUSIC_WEB_MUSIC_PROXY", "true").lower() == "true" ) + # edge-tts 语音角色 + edge_tts_voice: str = os.getenv("XIAOMUSIC_EDGE_TTS_VOICE", "") + # 是否启用定时清理临时文件 + enable_auto_clean_temp: bool = ( + os.getenv("XIAOMUSIC_ENABLE_AUTO_CLEAN_TEMP", "true").lower() == "true" + ) def append_keyword(self, keys, action): for key in keys.split(","): diff --git a/xiaomusic/crontab.py b/xiaomusic/crontab.py index 7cf2027..21c03f6 100644 --- a/xiaomusic/crontab.py +++ b/xiaomusic/crontab.py @@ -1,4 +1,5 @@ import json +import os from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.base import BaseTrigger @@ -218,3 +219,34 @@ class Crontab: self.log.info("crontab reload_config ok") except Exception as e: self.log.exception(f"Execption {e}") + + # 添加定时清理临时文件任务 + if xiaomusic.config.enable_auto_clean_temp: + + async def clean_temp_job(): + try: + temp_dir = xiaomusic.config.temp_dir + if not os.path.exists(temp_dir): + self.log.info(f"临时目录不存在: {temp_dir}") + return + + files = os.listdir(temp_dir) + deleted_count = 0 + for filename in files: + try: + file_path = os.path.join(temp_dir, filename) + if os.path.isfile(file_path): + os.remove(file_path) + deleted_count += 1 + self.log.debug(f"已删除临时文件: {file_path}") + except Exception as e: + self.log.warning(f"删除文件失败 {file_path}: {e}") + + self.log.info( + f"定时清理临时文件完成,共删除 {deleted_count} 个文件" + ) + except Exception as e: + self.log.exception(f"清理临时文件异常: {e}") + + self.add_job("0 3 * * *", clean_temp_job) + self.log.info("已添加每日凌晨3点定时清理临时文件任务") diff --git a/xiaomusic/device_player.py b/xiaomusic/device_player.py index 6d7be5e..81bce4a 100644 --- a/xiaomusic/device_player.py +++ b/xiaomusic/device_player.py @@ -77,6 +77,8 @@ class XiaoMusicDevice: # 添加歌曲定时器 self._add_song_timer = None + # TTS 播放定时器 + self._tts_timer = None @property def did(self): @@ -599,24 +601,82 @@ class XiaoMusicDevice: async def text_to_speech(self, value): """文字转语音""" try: - # 有 tts command 优先使用 tts command 说话 - if self.hardware in TTS_COMMAND: - tts_cmd = TTS_COMMAND[self.hardware] - self.log.info("Call MiIOService tts.") - value = value.replace(" ", ",") # 不能有空格 - await miio_command( - self.auth_manager.miio_service, - self.did, - f"{tts_cmd} {value}", - ) + # 检查是否配置了 edge-tts 语音角色 + if self.config.edge_tts_voice: + await self._text_to_speech_edge_tts(value) else: - self.log.debug("Call MiNAService tts.") - await self.auth_manager.mina_service.text_to_speech( - self.device_id, value - ) + # 使用原有的 TTS 逻辑 + # 有 tts command 优先使用 tts command 说话 + if self.hardware in TTS_COMMAND: + tts_cmd = TTS_COMMAND[self.hardware] + self.log.info("Call MiIOService tts.") + value = value.replace(" ", ",") # 不能有空格 + await miio_command( + self.auth_manager.miio_service, + self.did, + f"{tts_cmd} {value}", + ) + else: + self.log.debug("Call MiNAService tts.") + await self.auth_manager.mina_service.text_to_speech( + self.device_id, value + ) except Exception as e: self.log.exception(f"Execption {e}") + async def _text_to_speech_edge_tts(self, value): + """使用 edge-tts 进行文字转语音""" + from xiaomusic.utils.music_utils import get_local_music_duration + from xiaomusic.utils.network_utils import text_to_mp3 + + try: + # 取消之前的 TTS 定时器 + if self._tts_timer: + self._tts_timer.cancel() + self._tts_timer = None + self.log.info("已取消之前的 TTS 定时器") + + # 使用 edge-tts 生成 MP3 文件 + self.log.info( + f"使用 edge-tts 生成语音: {value}, voice: {self.config.edge_tts_voice}" + ) + mp3_path = await text_to_mp3( + text=value, + save_dir=self.config.temp_dir, + voice=self.config.edge_tts_voice, + ) + self.log.info(f"edge-tts 生成的文件路径: {mp3_path}") + + # 生成播放 URL + url = self.xiaomusic.music_url_handler._get_file_url(mp3_path) + self.log.info(f"TTS 播放 URL: {url}") + + # 播放 TTS 音频 + await self.group_player_play(url) + + # 获取 MP3 时长 + duration = await get_local_music_duration(mp3_path, self.config) + self.log.info(f"TTS 音频时长: {duration} 秒") + + # 创建定时器,时长到后停止 + if duration > 0: + + async def _tts_timeout(): + await asyncio.sleep(duration) + try: + self.log.info("TTS 播放定时器时间到") + if self._tts_timer: + self._tts_timer = None + await self.stop(arg1="notts") + except Exception as e: + self.log.error(f"TTS 定时器异常: {e}") + + self._tts_timer = asyncio.create_task(_tts_timeout()) + self.log.info(f"已设置 TTS 定时器,{duration} 秒后停止") + + except Exception as e: + self.log.exception(f"edge-tts 播放失败: {e}") + async def group_player_play(self, url, name=""): """同一组设备播放""" device_id_list = self.xiaomusic.get_group_device_id_list(self.group_name) @@ -841,6 +901,11 @@ class XiaoMusicDevice: self._stop_timer = None self.log.info("cancel_all_timer _stop_timer.cancel") + if self._tts_timer: + self._tts_timer.cancel() + self._tts_timer = None + self.log.info("cancel_all_timer _tts_timer.cancel") + @classmethod def dict_clear(cls, d): """清空设备字典并取消所有定时器""" diff --git a/xiaomusic/music_url.py b/xiaomusic/music_url.py index af201c0..d53a793 100644 --- a/xiaomusic/music_url.py +++ b/xiaomusic/music_url.py @@ -138,6 +138,21 @@ class MusicUrlHandler: str: 本地音乐播放URL """ filename = self.music_library.get_filename(name) + self.log.info( + f"_get_local_music_url local music. name:{name}, filename:{filename}" + ) + return self._get_file_url(filename) + + def _get_file_url(self, filepath): + """根据文件路径生成可访问的URL + + Args: + filepath: 文件的完整路径 + + Returns: + str: 文件访问URL + """ + filename = filepath # 处理文件路径 if filename.startswith(self.config.music_path): @@ -146,9 +161,7 @@ class MusicUrlHandler: if filename.startswith("/"): filename = filename[1:] - self.log.info( - f"_get_local_music_url local music. name:{name}, filename:{filename}" - ) + self.log.info(f"_get_file_url filepath:{filepath}, filename:{filename}") # 构造URL encoded_name = urllib.parse.quote(filename) diff --git a/xiaomusic/static/default/setting.html b/xiaomusic/static/default/setting.html index 4c75cc2..c551d88 100644 --- a/xiaomusic/static/default/setting.html +++ b/xiaomusic/static/default/setting.html @@ -101,6 +101,31 @@ var vConsole = new window.VConsole(); + + + + + +