1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2026-05-09 00:34:25 +08:00

feat: 接入 edge-tts 解决文字转语音的问题 close #642

This commit is contained in:
涵曦
2026-01-13 13:15:56 +08:00
parent ce1521ba59
commit 8091a59f36
5 changed files with 158 additions and 17 deletions

View File

@@ -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(","):

View File

@@ -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点定时清理临时文件任务")

View File

@@ -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):
"""清空设备字典并取消所有定时器"""

View File

@@ -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)

View File

@@ -101,6 +101,31 @@ var vConsole = new window.VConsole();
<label for="temp_path">临时文件目录:</label>
<input id="temp_path" type="text" value="music/tmp" />
<label for="enable_auto_clean_temp">启用定时清理临时文件(每日凌晨3点):</label>
<select id="enable_auto_clean_temp">
<option value="true" selected>开启</option>
<option value="false">关闭</option>
</select>
<label for="edge_tts_voice">Edge-TTS 语音角色:</label>
<select id="edge_tts_voice">
<option value="" selected>不使用(默认)</option>
<option value="zh-CN-XiaoxiaoNeural">晓晓 (女声,温柔)</option>
<option value="zh-CN-XiaoyiNeural">晓伊 (女声,活泼)</option>
<option value="zh-CN-YunjianNeural">云健 (男声,成熟)</option>
<option value="zh-CN-YunxiNeural">云希 (男声,阳光)</option>
<option value="zh-CN-YunxiaNeural">云夏 (男声,少年)</option>
<option value="zh-CN-YunyangNeural">云扬 (男声,新闻)</option>
<option value="zh-CN-liaoning-XiaobeiNeural">晓北 (女声,东北)</option>
<option value="zh-CN-shaanxi-XiaoniNeural">晓妮 (女声,陕西)</option>
<option value="zh-HK-HiuGaaiNeural">曉佳 (女声,粤语)</option>
<option value="zh-HK-HiuMaanNeural">曉曼 (女声,粤语)</option>
<option value="zh-HK-WanLungNeural">雲龍 (男声,粤语)</option>
<option value="zh-TW-HsiaoChenNeural">曉臻 (女声,台湾)</option>
<option value="zh-TW-YunJheNeural">雲哲 (男声,台湾)</option>
<option value="zh-TW-HsiaoYuNeural">曉雨 (女声,台湾)</option>
</select>
<label for="ffmpeg_location">ffmpeg路径:</label>
<input id="ffmpeg_location" type="text" value="./ffmpeg/bin" />