Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 130 additions & 35 deletions astrbot/core/provider/sources/sensevoice_selfhosted_source.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,173 @@
"""Author: diudiu62
Date: 2025-02-24 18:04:18
LastEditTime: 2025-02-25 14:06:30
LastEditTime: 2026-08-31
LastEdit / Blame: xiewoc
"""

import asyncio
import re
from typing import cast
import os
import shutil
from typing import Optional, TYPE_CHECKING

from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess

from astrbot.core import logger
from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.utils.media_utils import MediaResolver
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core import logger

from ..entities import ProviderType
from ..provider import STTProvider
from ..register import register_provider_adapter

# 仅在类型检查时导入,避免运行时循环依赖或导入失败
if TYPE_CHECKING:
from funasr_onnx import SenseVoiceSmall

_REQUIRED_MODULES = [
"funasr", "funasr_onnx", "torch", # "torchaudio", <- we don't need this, use FFmpeg instead
"onnxruntime", "modelscope", "onnxscript"
]

# 模块级缓存,避免重复安装/导入
_sense_voice_cls = None
_postprocess_fn = None
_snapshot_download_fn = None


def _check_ffmpeg() -> None:
"""检测系统是否安装了 FFmpeg,未安装则记录错误并抛出异常阻断流程。"""
if shutil.which("ffmpeg") is None:
msg = (
"未检测到 FFmpeg!SenseVoice STT 依赖 FFmpeg 进行音频转码。"
"请安装 FFmpeg 并将其添加到系统 PATH 环境变量中。"
"参考: https://ffmpeg.org/download.html"
)
logger.error(msg)
raise RuntimeError(msg)


async def _install_dependencies():
"""异步安装依赖库"""
pip = PipInstaller(pip_install_arg="")
for item in _REQUIRED_MODULES:
try:
await pip.install(item)
except Exception as e:
logger.error(f"安装依赖 {item} 失败: {e}")
raise


def _load_sense_voice_modules():
"""延迟加载 SenseVoice 相关模块,支持自动安装重试"""
global _sense_voice_cls, _postprocess_fn, _snapshot_download_fn

if _sense_voice_cls is not None:
return _sense_voice_cls, _postprocess_fn, _snapshot_download_fn

try:
from modelscope import snapshot_download
from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess

_sense_voice_cls = SenseVoiceSmall
_postprocess_fn = rich_transcription_postprocess
_snapshot_download_fn = snapshot_download
return _sense_voice_cls, _postprocess_fn, _snapshot_download_fn

except ImportError:
logger.info("SenseVoice 依赖未安装,正在尝试自动安装...")
try:
# 注意:在已有事件循环中应使用 run_coroutine_threadsafe 或 nest_asyncio
# 此处保留原逻辑但增加提示,实际部署建议改为插件初始化钩子
loop = asyncio.get_event_loop()
if loop.is_running():
logger.warning("检测到运行中的事件循环,自动安装可能失败。建议手动安装依赖。")
asyncio.run(_install_dependencies())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): asyncio.run(_install_dependencies()) raises RuntimeError whenever SenseVoice is initialized through the normal async provider lifecycle, because initialize() already runs inside an active event loop. The exception is caught and reported as dependency-installation failure, so automatic installation never works when dependencies are missing.

Triggers: When any SenseVoice dependency is not installed before provider initialization.

Suggested fix: Await _install_dependencies() directly from an async initialization path instead of calling asyncio.run().

except Exception as e:
logger.error(f"自动安装依赖失败: {e}")
raise ImportError(
"SenseVoice 依赖安装失败,请手动执行: pip install " + " ".join(_REQUIRED_MODULES)
) from e

# 重试导入
from modelscope import snapshot_download
from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess

_sense_voice_cls = SenseVoiceSmall
_postprocess_fn = rich_transcription_postprocess
_snapshot_download_fn = snapshot_download
return _sense_voice_cls, _postprocess_fn, _snapshot_download_fn


@register_provider_adapter(
"sensevoice_stt_selfhost",
"SenseVoice 自托管语音识别 模型部署",
"SenseVoice 自托管语音识别模型部署",
provider_type=ProviderType.SPEECH_TO_TEXT,
)
class ProviderSenseVoiceSTTSelfHost(STTProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.set_model(provider_config["stt_model"])
self.model = None
self.is_emotion = provider_config.get("is_emotion", False)

self.model: Optional["SenseVoiceSmall"] = None
self.is_emotion: bool = provider_config.get("is_emotion", False)
self.model_path: str = os.path.join(get_astrbot_data_path(), "SenseVoiceSmall")

async def initialize(self) -> None:
logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...")
# ✅ 优先检测 FFmpeg,缺失时快速失败,避免浪费模型下载/加载时间
_check_ffmpeg()

logger.info("正在下载或加载 SenseVoice 模型,首次可能需要较长时间...")

SenseVoiceSmall, _, snapshot_download = _load_sense_voice_modules()

# 模型下载(同步操作放入线程池)
if not os.path.exists(os.path.join(self.model_path, "configuration.json")):
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: snapshot_download("iic/SenseVoiceSmall", local_dir=self.model_path),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The configured stt_model is ignored: the provider stores it through set_model(...), but initialization always downloads iic/SenseVoiceSmall and loads the fixed self.model_path instead of using self.model_name. Configuring another ModelScope model therefore still downloads and loads SenseVoiceSmall.

Triggers: When a provider configuration specifies an stt_model other than iic/SenseVoiceSmall.

Suggested fix: Use self.model_name for the ModelScope download and derive the local model directory consistently from the configured model.

)
Comment on lines +126 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The existence of configuration.json is treated as proof that the model download completed. If a previous download was interrupted after creating that file, initialization skips snapshot_download and passes the incomplete directory to SenseVoiceSmall, causing model loading to fail instead of resuming or repairing the download.

Triggers: When the model directory contains configuration.json from a partial or corrupted download.

Suggested fix: Validate the complete expected model file set, or let snapshot_download resume/validate the existing local directory before loading.

Suggested change
if not os.path.exists(os.path.join(self.model_path, "configuration.json")):
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: snapshot_download("iic/SenseVoiceSmall", local_dir=self.model_path),
)
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: snapshot_download("iic/SenseVoiceSmall", local_dir=self.model_path),
)


# 将模型加载放到线程池中执行
# 模型加载(CPU/GPU 密集型操作放入线程池)
self.model = await asyncio.get_running_loop().run_in_executor(
None,
lambda: SenseVoiceSmall(self.model_name, quantize=True, batch_size=16),
lambda: SenseVoiceSmall(model_dir=self.model_path, quantize=True, batch_size=16),
)

logger.info("SenseVoice 模型加载完成。")

async def get_text(self, audio_url: str) -> str:
if self.model is None:
raise RuntimeError("SenseVoice 模型未初始化,请先调用 initialize()")

# 局部变量绑定,确保类型收窄且避免 lambda 中的属性访问问题
model = self.model
_, postprocess, _ = _load_sense_voice_modules()

try:
# 使用 run_in_executor 来调用模型进行识别
loop = asyncio.get_running_loop()

async with MediaResolver(
audio_url,
media_type="audio",
default_suffix=".wav",
audio_url, media_type="audio", default_suffix=".wav"
).as_path(target_format="wav") as audio:
res = await loop.run_in_executor(
None, # 使用默认的线程池
lambda: cast(SenseVoiceSmall, self.model)(
str(audio.path), language="auto", use_itn=True
),
None,
lambda: model(str(audio.path), language="auto", use_itn=True),
)

# res = self.model(audio_url, language="auto", use_itn=True)
logger.debug(f"SenseVoice识别到的文案:{res}")
text = rich_transcription_postprocess(res[0])
logger.debug(f"SenseVoice 原始识别结果: {res}")
text = postprocess(res[0])

if self.is_emotion:
# 提取第二个匹配的值
matches = re.findall(r"<\|([^|]+)\|>", res[0])
if len(matches) >= 2:
emotion = matches[1]
text = f"(当前的情绪:{emotion}) {text}"
text = f"(当前的情绪:{matches[1]}) {text}"
else:
logger.warning("未能提取到情绪信息")
logger.warning("未能从识别结果中提取情绪标签")

return text

except Exception as e:
logger.error(f"处理音频文件时出错: {e}")
raise
logger.error(f"SenseVoice 语音识别失败: {e}", exc_info=True)
raise
Loading