Files
ai-agent-admin/backend-fastapi/ai_platform/services/speech_service.py
T
2026-06-08 18:14:59 +08:00

471 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
语音识别服务
支持:
- 阿里云百炼 DashScope ASR(推荐)
- OpenAI Whisper API
"""
import logging
import os
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional
import dashscope
from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from ai_platform.models import LLMProvider
logger = logging.getLogger(__name__)
class SpeechService:
"""
语音识别服务
支持多种语音识别后端:
- 阿里云百炼 DashScope(默认,推荐)
- OpenAI Whisper API
"""
def __init__(self, db: Optional[AsyncSession] = None):
self._db = db
# 延迟解析:优先从数据库获取,初始化时先从 settings 读取作为兜底(settings 已自动从环境变量读取)
self.dashscope_api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
self._resolved = False
async def _resolve_dashscope_api_key(self):
"""
异步解析 DashScope API Key
优先级:
1. 数据库中 qwen 类型提供商的 API Key
2. settings / 环境变量中的 DASHSCOPE_API_KEY(兜底)
"""
if self._resolved:
return
self._resolved = True
if not self._db:
return
try:
result = await self._db.execute(
select(LLMProvider).where(
LLMProvider.provider_type == 'qwen',
LLMProvider.is_active == True,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if provider and provider.api_key:
self.dashscope_api_key = provider.api_key
logger.info(f"Speech: Using API key from database provider: {provider.name}")
except Exception as e:
logger.debug(f"Speech: Failed to get provider from database: {e}")
def transcribe(
self,
audio_file,
language: str = 'zh',
provider: str = 'dashscope',
) -> dict:
"""
语音转文字
Args:
audio_file: 音频文件(Django UploadedFile 或文件路径或 bytes
language: 语言代码,如 'zh', 'en'
provider: 服务提供商,'dashscope''openai'
Returns:
{
'success': bool,
'text': str,
'duration': float,
'error': str,
}
"""
if provider == 'dashscope':
return self._transcribe_dashscope(audio_file, language)
elif provider == 'openai':
return self._transcribe_openai(audio_file, language)
else:
return {
'success': False,
'text': '',
'error': f'不支持的提供商: {provider}',
}
def _transcribe_dashscope(self, audio_file, language: str) -> dict:
"""
使用阿里云百炼 DashScope 进行语音识别
使用 fun-asr-realtime 模型,支持多种音频格式
"""
try:
if not self.dashscope_api_key:
return {
'success': False,
'text': '',
'error': '未配置 DASHSCOPE_API_KEY',
}
# 设置 API Key
dashscope.api_key = self.dashscope_api_key
dashscope.base_websocket_api_url = 'wss://dashscope.aliyuncs.com/api-ws/v1/inference'
# 获取音频数据和格式
audio_data, audio_format, sample_rate = self._prepare_audio(audio_file)
if not audio_data:
return {
'success': False,
'text': '',
'error': '无法读取音频文件',
}
# 使用同步方式收集识别结果
result_text = []
error_message = []
completed = threading.Event()
class SyncCallback(RecognitionCallback):
def on_complete(self) -> None:
completed.set()
def on_error(self, result: RecognitionResult) -> None:
error_message.append(result.message if hasattr(result, 'message') else str(result))
completed.set()
def on_event(self, result: RecognitionResult) -> None:
sentence = result.get_sentence()
if 'text' in sentence and RecognitionResult.is_sentence_end(sentence):
result_text.append(sentence['text'])
callback = SyncCallback()
# 创建识别实例
recognition = Recognition(
model='paraformer-realtime-v2', # 使用 paraformer 模型,效果更好
format=audio_format,
sample_rate=sample_rate,
callback=callback,
)
# 开始识别
recognition.start()
# 分块发送音频数据
chunk_size = 3200 # 每次发送 3200 字节
offset = 0
while offset < len(audio_data):
chunk = audio_data[offset:offset + chunk_size]
recognition.send_audio_frame(chunk)
offset += chunk_size
time.sleep(0.05) # 稍微延迟,模拟实时流
# 停止识别
recognition.stop()
# 等待完成(最多 30 秒)
completed.wait(timeout=30)
if error_message:
return {
'success': False,
'text': '',
'error': error_message[0],
}
final_text = ''.join(result_text)
return {
'success': True,
'text': final_text,
'duration': len(audio_data) / (sample_rate * 2), # 估算时长
'error': '',
}
except Exception as e:
logger.exception(f'阿里云语音识别失败: {e}')
return {
'success': False,
'text': '',
'error': str(e),
}
def _prepare_audio(self, audio_file) -> tuple:
"""
准备音频数据
前端已直接录制 WAV 格式(16kHz, 16bit, 单声道),无需转换
阿里云 DashScope ASR 支持的格式:pcm, wav, mp3, opus, speex, aac, amr
Returns:
(audio_data: bytes, format: str, sample_rate: int)
"""
audio_data = None
audio_format = 'wav'
sample_rate = 16000
if isinstance(audio_file, bytes):
audio_data = audio_file
elif hasattr(audio_file, 'read'):
# Django UploadedFile
audio_data = audio_file.read()
file_name = getattr(audio_file, 'name', 'audio.wav').lower()
# 根据文件名判断格式
if file_name.endswith('.mp3'):
audio_format = 'mp3'
elif file_name.endswith('.pcm'):
audio_format = 'pcm'
elif file_name.endswith('.opus'):
audio_format = 'opus'
else:
audio_format = 'wav'
elif isinstance(audio_file, (str, Path)):
# 文件路径
file_path = Path(audio_file)
with open(file_path, 'rb') as f:
audio_data = f.read()
suffix = file_path.suffix.lower()
if suffix == '.mp3':
audio_format = 'mp3'
elif suffix == '.pcm':
audio_format = 'pcm'
elif suffix == '.opus':
audio_format = 'opus'
else:
audio_format = 'wav'
return audio_data, audio_format, sample_rate
def _transcribe_openai(self, audio_file, language: str, provider_config: dict = None) -> dict:
"""使用 OpenAI Whisper API 进行语音识别"""
try:
import openai
if not provider_config:
return {
'success': False,
'text': '',
'error': '未配置 OpenAI 提供商',
}
# 创建客户端
client = openai.OpenAI(
api_key=provider_config.get('api_key', ''),
base_url=provider_config.get('api_base') or None,
)
# 处理文件
if isinstance(audio_file, bytes):
# bytes 数据,写入临时文件
with tempfile.NamedTemporaryFile(
suffix='.wav',
delete=False,
) as tmp:
tmp.write(audio_file)
tmp_path = tmp.name
try:
with open(tmp_path, 'rb') as f:
response = client.audio.transcriptions.create(
model='whisper-1',
file=f,
language=language,
response_format='verbose_json',
)
finally:
Path(tmp_path).unlink(missing_ok=True)
elif hasattr(audio_file, 'read'):
# UploadedFile
file_content = audio_file.read()
file_name = getattr(audio_file, 'name', 'audio.webm')
# 写入临时文件
with tempfile.NamedTemporaryFile(
suffix=Path(file_name).suffix or '.webm',
delete=False,
) as tmp:
tmp.write(file_content)
tmp_path = tmp.name
try:
with open(tmp_path, 'rb') as f:
response = client.audio.transcriptions.create(
model='whisper-1',
file=f,
language=language,
response_format='verbose_json',
)
finally:
Path(tmp_path).unlink(missing_ok=True)
else:
with open(audio_file, 'rb') as f:
response = client.audio.transcriptions.create(
model='whisper-1',
file=f,
language=language,
response_format='verbose_json',
)
return {
'success': True,
'text': response.text,
'duration': getattr(response, 'duration', 0),
'error': '',
}
except Exception as e:
logger.exception(f'OpenAI 语音识别失败: {e}')
return {
'success': False,
'text': '',
'error': str(e),
}
def text_to_speech(
self,
text: str,
voice: str = 'sambert-zhichu-v1',
provider: str = 'dashscope',
) -> dict:
"""
文字转语音(TTS
Args:
text: 要转换的文字
voice: 声音类型
provider: 服务提供商
Returns:
{
'success': bool,
'audio_data': bytes,
'content_type': str,
'error': str,
}
"""
if provider == 'dashscope':
return self._tts_dashscope(text, voice)
elif provider == 'openai':
return self._tts_openai(text, voice)
else:
return {
'success': False,
'audio_data': b'',
'error': f'不支持的提供商: {provider}',
}
def _tts_dashscope(self, text: str, voice: str) -> dict:
"""使用阿里云百炼 TTS"""
try:
from dashscope.audio.tts import SpeechSynthesizer
if not self.dashscope_api_key:
return {
'success': False,
'audio_data': b'',
'error': '未配置 DASHSCOPE_API_KEY',
}
dashscope.api_key = self.dashscope_api_key
# 调用 TTS
result = SpeechSynthesizer.call(
model=voice, # sambert-zhichu-v1, sambert-zhimiao-emo-v1 等
text=text,
sample_rate=16000,
format='wav',
)
if result.get_audio_data():
return {
'success': True,
'audio_data': result.get_audio_data(),
'content_type': 'audio/wav',
'error': '',
}
else:
return {
'success': False,
'audio_data': b'',
'error': '语音合成失败',
}
except Exception as e:
logger.exception(f'阿里云 TTS 失败: {e}')
return {
'success': False,
'audio_data': b'',
'error': str(e),
}
def _tts_openai(self, text: str, voice: str, provider_config: dict = None) -> dict:
"""使用 OpenAI TTS API"""
try:
import openai
if not provider_config:
return {
'success': False,
'audio_data': b'',
'error': '未配置 OpenAI 提供商',
}
client = openai.OpenAI(
api_key=provider_config.get('api_key', ''),
base_url=provider_config.get('api_base') or None,
)
response = client.audio.speech.create(
model='tts-1',
voice=voice, # alloy, echo, fable, onyx, nova, shimmer
input=text,
)
return {
'success': True,
'audio_data': response.content,
'content_type': 'audio/mpeg',
'error': '',
}
except Exception as e:
logger.exception(f'OpenAI TTS 失败: {e}')
return {
'success': False,
'audio_data': b'',
'error': str(e),
}
async def get_openai_provider_config(self) -> dict:
"""异步获取OpenAI提供商配置"""
if not self._db:
return {}
result = await self._db.execute(
select(LLMProvider).where(
LLMProvider.provider_type == 'openai',
LLMProvider.is_active == True,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
return {}
return {
'api_key': provider.api_key,
'api_base': provider.api_base,
}