feat: 服务端AEC功能

ref: 入口解码为pcm音频数据,vad和asr可直接使用
This commit is contained in:
Sakura-RanChen
2026-07-06 16:21:24 +08:00
parent 209fd3fbc8
commit 3d204fb181
21 changed files with 312 additions and 220 deletions
@@ -213,7 +213,7 @@ class ASRProvider(ASRProviderBase):
return None
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if self._is_token_expired():
@@ -7,7 +7,6 @@ import hashlib
import asyncio
import requests
import websockets
import opuslib_next
from urllib import parse
from datetime import datetime
from config.logger import setup_logging
@@ -74,7 +73,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM
self.config = config
self.text = ""
self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None
self.forward_task = None
self.is_processing = False
@@ -129,9 +127,9 @@ class ASRProvider(ASRProviderBase):
async def open_audio_channels(self, conn):
await super().open_audio_channels(conn)
async def receive_audio(self, conn, audio, audio_have_voice):
async def receive_audio(self, conn, pcm_frame, audio_have_voice):
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
await super().receive_audio(conn, pcm_frame, audio_have_voice)
# 只在有声音且没有连接时建立连接(排除正在停止的情况)
if audio_have_voice and not self.is_processing and not self.asr_ws:
@@ -144,7 +142,6 @@ class ASRProvider(ASRProviderBase):
if self.asr_ws and self.is_processing and self.server_ready:
try:
pcm_frame = self.decoder.decode(audio, 960)
await self.asr_ws.send(pcm_frame)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}")
@@ -232,10 +229,9 @@ class ASRProvider(ASRProviderBase):
# 发送缓存音频
if conn.asr_audio:
for cached_audio in conn.asr_audio[-10:]:
for cached_pcm in conn.asr_audio[-10:]:
try:
pcm_frame = self.decoder.decode(cached_audio, 960)
await self.asr_ws.send(pcm_frame)
await self.asr_ws.send(cached_pcm)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}")
break
@@ -328,7 +324,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
async def speech_to_text(self, opus_data, session_id, artifacts=None):
"""获取识别结果"""
result = self.text
self.text = ""
@@ -337,10 +333,3 @@ class ASRProvider(ASRProviderBase):
async def close(self):
"""关闭资源"""
await self._cleanup()
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Aliyun decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Aliyun decoder资源时出错: {e}")
@@ -2,7 +2,6 @@ import json
import uuid
import asyncio
import websockets
import opuslib_next
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
@@ -22,7 +21,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM
self.config = config
self.text = ""
self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None
self.forward_task = None
self.is_processing = False
@@ -55,9 +53,9 @@ class ASRProvider(ASRProviderBase):
async def open_audio_channels(self, conn):
await super().open_audio_channels(conn)
async def receive_audio(self, conn, audio, audio_have_voice):
async def receive_audio(self, conn, pcm_frame, audio_have_voice):
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
await super().receive_audio(conn, pcm_frame, audio_have_voice)
# 只在有声音且没有连接时建立连接
if audio_have_voice and not self.is_processing and not self.asr_ws:
@@ -71,8 +69,6 @@ class ASRProvider(ASRProviderBase):
# 发送音频数据
if self.asr_ws and self.is_processing and self.server_ready:
try:
pcm_frame = self.decoder.decode(audio, 960)
# 直接发送PCM音频数据(二进制)
await self.asr_ws.send(pcm_frame)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}")
@@ -179,10 +175,9 @@ class ASRProvider(ASRProviderBase):
# 发送缓存音频
if conn.asr_audio:
for cached_audio in conn.asr_audio[-10:]:
for cached_pcm in conn.asr_audio[-10:]:
try:
pcm_frame = self.decoder.decode(cached_audio, 960)
await self.asr_ws.send(pcm_frame)
await self.asr_ws.send(cached_pcm)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}")
break
@@ -312,7 +307,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
async def speech_to_text(self, opus_data, session_id, artifacts=None):
"""获取识别结果"""
result = self.text
self.text = ""
@@ -320,11 +315,4 @@ class ASRProvider(ASRProviderBase):
async def close(self):
"""关闭资源"""
await self._cleanup()
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Aliyun BL decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Aliyun BL decoder资源时出错: {e}")
await self._cleanup()
@@ -30,7 +30,7 @@ class ASRProvider(ASRProviderBase):
os.makedirs(self.output_dir, exist_ok=True)
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if not opus_data:
+13 -58
View File
@@ -10,7 +10,6 @@ import asyncio
import tempfile
import traceback
import threading
import opuslib_next
from abc import ABC, abstractmethod
from config.logger import setup_logging
@@ -59,13 +58,13 @@ class ASRProviderBase(ABC):
continue
# 接收音频
async def receive_audio(self, conn: "ConnectionHandler", audio, audio_have_voice):
async def receive_audio(self, conn: "ConnectionHandler", pcm_frame, audio_have_voice):
if conn.client_listen_mode == "manual":
# 手动模式:缓存音频用于ASR识别
conn.asr_audio.append(audio)
conn.asr_audio.append(pcm_frame)
else:
# 自动/实时模式:使用VAD检测
conn.asr_audio.append(audio)
conn.asr_audio.append(pcm_frame)
# 如果没有语音,且之前也没有声音,缓存部分音频
if not audio_have_voice and not conn.client_have_voice:
@@ -74,24 +73,21 @@ class ASRProviderBase(ABC):
# 自动模式下通过VAD检测到语音停止时触发识别
if conn.asr.interface_type != InterfaceType.STREAM and conn.client_voice_stop:
asr_audio_task = conn.asr_audio.copy()
# 直接使用asr_audio中的PCM数据
pcm_bytes = b"".join(conn.asr_audio)
# 检查是否有足够的音频数据(每帧1920字节,15帧约28800字节)
if len(pcm_bytes) > 1920 * 15:
await self.handle_voice_stop(conn, [pcm_bytes])
conn.reset_audio_states()
if len(asr_audio_task) > 15:
await self.handle_voice_stop(conn, asr_audio_task)
# 处理语音停止
async def handle_voice_stop(self, conn: "ConnectionHandler", asr_audio_task: List[bytes]):
"""并行处理ASR和声纹识别"""
try:
total_start_time = time.monotonic()
# 准备音频数据
if conn.audio_format == "pcm":
pcm_data = asr_audio_task
else:
pcm_data = self.decode_opus(asr_audio_task)
# 数据已经是PCM直接使用
pcm_data = asr_audio_task
combined_pcm_data = b"".join(pcm_data)
# 预先准备WAV数据
@@ -101,7 +97,7 @@ class ASRProviderBase(ABC):
# 定义ASR任务
asr_task = self.speech_to_text_wrapper(
asr_audio_task, conn.session_id, conn.audio_format
asr_audio_task, conn.session_id
)
if conn.voiceprint_provider and wav_data:
@@ -270,15 +266,11 @@ class ASRProviderBase(ABC):
return file_path
async def speech_to_text_wrapper(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, pcm_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
file_path = None
temp_path = None
try:
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
free_space = shutil.disk_usage(self.output_dir).free
@@ -304,7 +296,7 @@ class ASRProviderBase(ABC):
)
text, _ = await self.speech_to_text(
opus_data, session_id, audio_format, artifacts
pcm_data, session_id, artifacts
)
return text, file_path
except OSError as e:
@@ -332,50 +324,13 @@ class ASRProviderBase(ABC):
self,
opus_data: List[bytes],
session_id: str,
audio_format="opus",
artifacts: Optional[AudioArtifacts] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本
:param opus_data: 输入的Opus音频数据
:param session_id: 会话ID
:param audio_format: 音频格式,默认"opus"
:param artifacts: 音频工件,包含PCM数据、文件路径等
:return: 识别结果文本和文件路径(如果有)
"""
pass
@staticmethod
def decode_opus(opus_data: List[bytes]) -> List[bytes]:
"""将Opus音频数据解码为PCM数据"""
decoder = None
try:
decoder = opuslib_next.Decoder(16000, 1)
pcm_data = []
buffer_size = 960 # 每次处理960个采样点 (60ms at 16kHz)
for i, opus_packet in enumerate(opus_data):
try:
if not opus_packet or len(opus_packet) == 0:
continue
pcm_frame = decoder.decode(opus_packet, buffer_size)
if pcm_frame and len(pcm_frame) > 0:
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).warning(f"Opus解码错误,跳过数据包 {i}: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"音频处理错误,数据包 {i}: {e}")
return pcm_data
except Exception as e:
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}")
return []
finally:
if decoder is not None:
try:
del decoder
except Exception as e:
logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}")
@@ -232,7 +232,7 @@ class ASRProvider(ASRProviderBase):
yield data[offset:data_len], True
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
@@ -3,7 +3,6 @@ import gzip
import uuid
import asyncio
import websockets
import opuslib_next
from core.providers.asr.base import ASRProviderBase
from config.logger import setup_logging
from core.providers.asr.dto.dto import InterfaceType
@@ -22,7 +21,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM
self.config = config
self.text = ""
self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None
self.forward_task = None
self.is_processing = False # 添加处理状态标志
@@ -68,10 +66,10 @@ class ASRProvider(ASRProviderBase):
async def open_audio_channels(self, conn):
await super().open_audio_channels(conn)
async def receive_audio(self, conn: "ConnectionHandler", audio, audio_have_voice):
async def receive_audio(self, conn: "ConnectionHandler", pcm_frame, audio_have_voice):
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
await super().receive_audio(conn, pcm_frame, audio_have_voice)
# 如果本次有声音,且之前没有建立连接
if audio_have_voice and self.asr_ws is None and not self.is_processing:
try:
@@ -123,10 +121,9 @@ class ASRProvider(ASRProviderBase):
# 发送缓存的音频数据
if conn.asr_audio and len(conn.asr_audio) > 0:
for cached_audio in conn.asr_audio[-10:]:
for cached_pcm in conn.asr_audio[-10:]:
try:
pcm_frame = self.decoder.decode(cached_audio, 960)
payload = gzip.compress(pcm_frame)
payload = gzip.compress(cached_pcm)
audio_request = bytearray(
self.generate_audio_default_header()
)
@@ -151,7 +148,6 @@ class ASRProvider(ASRProviderBase):
# 发送当前音频数据
if self.asr_ws and self.is_processing and not self._is_stopping:
try:
pcm_frame = self.decoder.decode(audio, 960)
payload = gzip.compress(pcm_frame)
audio_request = bytearray(self.generate_audio_default_header())
audio_request.extend(len(payload).to_bytes(4, "big"))
@@ -414,7 +410,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"原始响应数据: {res.hex()}")
raise
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
async def speech_to_text(self, opus_data, session_id, artifacts=None):
result = self.text
self.text = "" # 清空text
return result, None
@@ -433,11 +429,3 @@ class ASRProvider(ASRProviderBase):
self.forward_task = None
self.is_processing = False
# 显式释放decoder资源
if hasattr(self, "decoder") and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Doubao decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Doubao decoder资源时出错: {e}")
@@ -65,7 +65,7 @@ class ASRProvider(ASRProviderBase):
)
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑"""
retry_count = 0
@@ -101,7 +101,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug(f"Sent end message: {end_message}")
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""
Convert speech data to text using FunASR.
@@ -24,7 +24,7 @@ class ASRProvider(ASRProviderBase):
def requires_file(self) -> bool:
return True
async def speech_to_text(self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None) -> Tuple[Optional[str], Optional[str]]:
async def speech_to_text(self, opus_data: List[bytes], session_id: str, artifacts=None) -> Tuple[Optional[str], Optional[str]]:
file_path = None
try:
if artifacts is None:
@@ -41,7 +41,7 @@ class ASRProvider(ASRProviderBase):
return True
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
temp_file_path = None
@@ -124,7 +124,7 @@ class ASRProvider(ASRProviderBase):
return True
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑"""
file_path = None
@@ -32,7 +32,7 @@ class ASRProvider(ASRProviderBase):
os.makedirs(self.output_dir, exist_ok=True)
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if not opus_data:
@@ -44,7 +44,7 @@ class ASRProvider(ASRProviderBase):
raise
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
self, opus_data: List[bytes], session_id: str, artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
try:
@@ -4,7 +4,6 @@ import base64
import hashlib
import asyncio
import websockets
import opuslib_next
import gc
from time import mktime
from datetime import datetime
@@ -33,7 +32,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM
self.config = config
self.text = ""
self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None
self.forward_task = None
self.is_processing = False
@@ -100,9 +98,9 @@ class ASRProvider(ASRProviderBase):
async def open_audio_channels(self, conn: "ConnectionHandler"):
await super().open_audio_channels(conn)
async def receive_audio(self, conn: "ConnectionHandler", audio, audio_have_voice):
async def receive_audio(self, conn: "ConnectionHandler", pcm_frame, audio_have_voice):
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
await super().receive_audio(conn, pcm_frame, audio_have_voice)
# 如果本次有声音,且之前没有建立连接
if audio_have_voice and self.asr_ws is None and not self.is_processing:
@@ -116,7 +114,6 @@ class ASRProvider(ASRProviderBase):
# 发送当前音频数据
if self.asr_ws and self.is_processing and self.server_ready:
try:
pcm_frame = self.decoder.decode(audio, 960)
await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}")
@@ -148,19 +145,15 @@ class ASRProvider(ASRProviderBase):
# 发送首帧音频
if conn.asr_audio and len(conn.asr_audio) > 0:
first_audio = conn.asr_audio[-1] if conn.asr_audio else b""
pcm_frame = (
self.decoder.decode(first_audio, 960) if first_audio else b""
)
await self._send_audio_frame(pcm_frame, STATUS_FIRST_FRAME)
first_pcm = conn.asr_audio[-1] if conn.asr_audio else b""
await self._send_audio_frame(first_pcm, STATUS_FIRST_FRAME)
self.server_ready = True
logger.bind(tag=TAG).info("已发送首帧,开始识别")
# 发送缓存的音频数据
for cached_audio in conn.asr_audio[-10:]:
for cached_pcm in conn.asr_audio[-10:]:
try:
pcm_frame = self.decoder.decode(cached_audio, 960)
await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME)
await self._send_audio_frame(cached_pcm, STATUS_CONTINUE_FRAME)
except Exception as e:
logger.bind(tag=TAG).info(f"发送缓存音频数据时发生错误: {e}")
break
@@ -322,7 +315,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
async def speech_to_text(self, opus_data, session_id, artifacts=None):
"""获取识别结果"""
result = self.text
self.text = ""
@@ -342,12 +335,3 @@ class ASRProvider(ASRProviderBase):
self.forward_task = None
self.is_processing = False
# 显式释放decoder资源
if hasattr(self, "decoder") and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Xunfei decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Xunfei decoder资源时出错: {e}")
@@ -1,7 +1,6 @@
import time
import os
import numpy as np
import opuslib_next
import onnxruntime
from config.logger import setup_logging
from core.providers.vad.base import VADProviderBase
@@ -39,8 +38,6 @@ class VADProvider(VADProviderBase):
def _init_connection_state(self, conn):
"""为连接初始化独立的 VAD 状态"""
if not hasattr(conn, "_vad_opus_decoder"):
conn._vad_opus_decoder = opuslib_next.Decoder(16000, 1)
if not hasattr(conn, "_vad_state"):
conn._vad_state = np.zeros((2, 1, 128), dtype=np.float32)
if not hasattr(conn, "_vad_context"):
@@ -48,14 +45,14 @@ class VADProvider(VADProviderBase):
def release_conn_resources(self, conn):
"""释放连接的 VAD 资源(连接关闭时调用)"""
for attr in ("_vad_opus_decoder", "_vad_state", "_vad_context"):
for attr in ("_vad_state", "_vad_context"):
if hasattr(conn, attr):
try:
delattr(conn, attr)
except Exception:
pass
def is_vad(self, conn, opus_packet):
def is_vad(self, conn, pcm_frame):
# 手动模式:直接返回True,不进行实时VAD检测,所有音频都缓存
if conn.client_listen_mode == "manual":
return True
@@ -63,7 +60,7 @@ class VADProvider(VADProviderBase):
try:
self._init_connection_state(conn)
pcm_frame = conn._vad_opus_decoder.decode(opus_packet, 960)
# pcm_frame已经是处理后的PCM数据
conn.client_audio_buffer.extend(pcm_frame)
client_have_voice = False
@@ -115,7 +112,5 @@ class VADProvider(VADProviderBase):
conn.vad_last_voice_time = time.time() * 1000
return client_have_voice
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).info(f"解码错误: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing audio packet: {e}")