mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 01:53:53 +08:00
update: 音频流式优化
This commit is contained in:
@@ -151,20 +151,6 @@ class AudioFlowController:
|
||||
)
|
||||
|
||||
|
||||
async def simulate_device_consumption(flow_controller: AudioFlowController, frame_count: int):
|
||||
"""
|
||||
模拟设备消费音频帧的过程
|
||||
实际应用中应该根据设备反馈来更新消费情况
|
||||
|
||||
Args:
|
||||
flow_controller: 流控制器实例
|
||||
frame_count: 消费的帧数
|
||||
"""
|
||||
# 模拟设备播放延迟(60ms per frame)
|
||||
await asyncio.sleep(frame_count * 0.06)
|
||||
flow_controller.update_device_consumption(frame_count)
|
||||
|
||||
|
||||
# 流控配置常量
|
||||
class FlowControlConfig:
|
||||
"""流控配置常量"""
|
||||
|
||||
@@ -5,9 +5,8 @@ Opus编码工具类
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import numpy as np
|
||||
from typing import List, Optional, Callable, Any
|
||||
from typing import Optional, Callable, Any
|
||||
from opuslib_next import Encoder
|
||||
from opuslib_next import constants
|
||||
|
||||
@@ -56,53 +55,6 @@ class OpusEncoderUtils:
|
||||
self.encoder.reset_state()
|
||||
self.buffer = np.array([], dtype=np.int16)
|
||||
|
||||
def encode_pcm_to_opus(self, pcm_data: bytes, end_of_stream: bool) -> List[bytes]:
|
||||
"""
|
||||
将PCM数据编码为Opus格式
|
||||
|
||||
Args:
|
||||
pcm_data: PCM字节数据
|
||||
end_of_stream: 是否为流的结束
|
||||
|
||||
Returns:
|
||||
Opus数据包列表
|
||||
"""
|
||||
# 将字节数据转换为short数组
|
||||
new_samples = self._convert_bytes_to_shorts(pcm_data)
|
||||
|
||||
# 校验PCM数据
|
||||
self._validate_pcm_data(new_samples)
|
||||
|
||||
# 将新数据追加到缓冲区
|
||||
self.buffer = np.append(self.buffer, new_samples)
|
||||
|
||||
opus_packets = []
|
||||
offset = 0
|
||||
|
||||
# 处理所有完整帧
|
||||
while offset <= len(self.buffer) - self.total_frame_size:
|
||||
frame = self.buffer[offset : offset + self.total_frame_size]
|
||||
output = self._encode(frame)
|
||||
if output:
|
||||
opus_packets.append(output)
|
||||
offset += self.total_frame_size
|
||||
|
||||
# 保留未处理的样本
|
||||
self.buffer = self.buffer[offset:]
|
||||
|
||||
# 流结束时处理剩余数据
|
||||
if end_of_stream and len(self.buffer) > 0:
|
||||
# 创建最后一帧并用0填充
|
||||
last_frame = np.zeros(self.total_frame_size, dtype=np.int16)
|
||||
last_frame[: len(self.buffer)] = self.buffer
|
||||
|
||||
output = self._encode(last_frame)
|
||||
if output:
|
||||
opus_packets.append(output)
|
||||
self.buffer = np.array([], dtype=np.int16)
|
||||
|
||||
return opus_packets
|
||||
|
||||
def encode_pcm_to_opus_stream(self, pcm_data: bytes, end_of_stream: bool, callback: Callable[[Any], Any]):
|
||||
"""
|
||||
将PCM数据编码为Opus格式,以流式方式进行处理
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import io
|
||||
import struct
|
||||
from typing import Callable, Any
|
||||
|
||||
def decode_opus_from_file(input_file):
|
||||
"""
|
||||
从p3文件中解码 Opus 数据,并返回一个 Opus 数据包的列表。
|
||||
"""
|
||||
opus_datas = []
|
||||
|
||||
with open(input_file, 'rb') as f:
|
||||
while True:
|
||||
# 读取头部(4字节):[1字节类型,1字节保留,2字节长度]
|
||||
header = f.read(4)
|
||||
if not header:
|
||||
break
|
||||
|
||||
# 解包头部信息
|
||||
_, _, data_len = struct.unpack('>BBH', header)
|
||||
|
||||
# 根据头部指定的长度读取 Opus 数据
|
||||
opus_data = f.read(data_len)
|
||||
if len(opus_data) != data_len:
|
||||
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the file.")
|
||||
|
||||
opus_datas.append(opus_data)
|
||||
|
||||
return opus_datas
|
||||
|
||||
def decode_opus_from_file_stream(input_file, callback: Callable[[Any], Any]):
|
||||
"""
|
||||
@@ -47,25 +24,6 @@ def decode_opus_from_file_stream(input_file, callback: Callable[[Any], Any]):
|
||||
|
||||
callback(opus_data)
|
||||
|
||||
def decode_opus_from_bytes(input_bytes):
|
||||
"""
|
||||
从p3二进制数据中解码 Opus 数据,并返回一个 Opus 数据包的列表。
|
||||
"""
|
||||
import io
|
||||
opus_datas = []
|
||||
|
||||
f = io.BytesIO(input_bytes)
|
||||
while True:
|
||||
header = f.read(4)
|
||||
if not header:
|
||||
break
|
||||
_, _, data_len = struct.unpack('>BBH', header)
|
||||
opus_data = f.read(data_len)
|
||||
if len(opus_data) != data_len:
|
||||
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the bytes.")
|
||||
opus_datas.append(opus_data)
|
||||
|
||||
return opus_datas
|
||||
|
||||
def decode_opus_from_bytes_stream(input_bytes, callback: Callable[[Any], Any]):
|
||||
"""
|
||||
|
||||
@@ -3,10 +3,8 @@ import socket
|
||||
import subprocess
|
||||
import re
|
||||
import os
|
||||
import wave
|
||||
from io import BytesIO
|
||||
from typing import Callable, Any
|
||||
|
||||
from core.utils import p3
|
||||
import numpy as np
|
||||
import requests
|
||||
@@ -213,23 +211,6 @@ def extract_json_from_string(input_string):
|
||||
return None
|
||||
|
||||
|
||||
def audio_to_data(audio_file_path, is_opus=True):
|
||||
# 获取文件后缀名
|
||||
file_type = os.path.splitext(audio_file_path)[1]
|
||||
if file_type:
|
||||
file_type = file_type.lstrip(".")
|
||||
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
|
||||
audio = AudioSegment.from_file(
|
||||
audio_file_path, format=file_type, parameters=["-nostdin"]
|
||||
)
|
||||
|
||||
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
|
||||
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
|
||||
|
||||
# 获取原始PCM数据(16位小端)
|
||||
raw_data = audio.raw_data
|
||||
return pcm_to_data(raw_data, is_opus)
|
||||
|
||||
def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any], Any]=None) -> None:
|
||||
# 获取文件后缀名
|
||||
file_type = os.path.splitext(audio_file_path)[1]
|
||||
@@ -245,25 +226,9 @@ def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any]
|
||||
|
||||
# 获取原始PCM数据(16位小端)
|
||||
raw_data = audio.raw_data
|
||||
pcm_to_data_stream(raw_data, is_opus,callback)
|
||||
pcm_to_data_stream(raw_data, is_opus, callback)
|
||||
|
||||
|
||||
def audio_bytes_to_data(audio_bytes, file_type, is_opus=True):
|
||||
"""
|
||||
直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3
|
||||
"""
|
||||
if file_type == "p3":
|
||||
# 直接用p3解码
|
||||
return p3.decode_opus_from_bytes(audio_bytes)
|
||||
else:
|
||||
# 其他格式用pydub
|
||||
audio = AudioSegment.from_file(
|
||||
BytesIO(audio_bytes), format=file_type, parameters=["-nostdin"]
|
||||
)
|
||||
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
|
||||
raw_data = audio.raw_data
|
||||
return pcm_to_data(raw_data, is_opus)
|
||||
|
||||
def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]) -> None:
|
||||
"""
|
||||
直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3
|
||||
@@ -281,37 +246,7 @@ def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callab
|
||||
pcm_to_data_stream(raw_data, is_opus, callback)
|
||||
|
||||
|
||||
def pcm_to_data(raw_data, is_opus=True):
|
||||
# 初始化Opus编码器
|
||||
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
|
||||
|
||||
# 编码参数
|
||||
frame_duration = 60 # 60ms per frame
|
||||
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
|
||||
|
||||
datas = []
|
||||
# 按帧处理所有音频数据(包括最后一帧可能补零)
|
||||
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
|
||||
# 获取当前帧的二进制数据
|
||||
chunk = raw_data[i : i + frame_size * 2]
|
||||
|
||||
# 如果最后一帧不足,补零
|
||||
if len(chunk) < frame_size * 2:
|
||||
chunk += b"\x00" * (frame_size * 2 - len(chunk))
|
||||
|
||||
if is_opus:
|
||||
# 转换为numpy数组处理
|
||||
np_frame = np.frombuffer(chunk, dtype=np.int16)
|
||||
# 编码Opus数据
|
||||
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
|
||||
else:
|
||||
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
||||
|
||||
datas.append(frame_data)
|
||||
|
||||
return datas
|
||||
|
||||
def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any]=None):
|
||||
def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] = None):
|
||||
# 初始化Opus编码器
|
||||
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
|
||||
|
||||
@@ -338,32 +273,6 @@ def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any]=No
|
||||
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
||||
callback(frame_data)
|
||||
|
||||
def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1):
|
||||
"""
|
||||
将opus帧列表解码为wav字节流
|
||||
"""
|
||||
decoder = opuslib_next.Decoder(sample_rate, channels)
|
||||
pcm_datas = []
|
||||
|
||||
frame_duration = 60 # ms
|
||||
frame_size = int(sample_rate * frame_duration / 1000) # 960
|
||||
|
||||
for opus_frame in opus_datas:
|
||||
# 解码为PCM(返回bytes,2字节/采样点)
|
||||
pcm = decoder.decode(opus_frame, frame_size)
|
||||
pcm_datas.append(pcm)
|
||||
|
||||
pcm_bytes = b"".join(pcm_datas)
|
||||
|
||||
# 写入wav字节流
|
||||
wav_buffer = BytesIO()
|
||||
with wave.open(wav_buffer, "wb") as wf:
|
||||
wf.setnchannels(channels)
|
||||
wf.setsampwidth(2) # 16bit
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm_bytes)
|
||||
return wav_buffer.getvalue()
|
||||
|
||||
|
||||
def check_vad_update(before_config, new_config):
|
||||
if (
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
import time
|
||||
import hashlib
|
||||
import portalocker
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class FileLock:
|
||||
def __init__(self, file, timeout=5):
|
||||
self.file = file
|
||||
self.timeout = timeout
|
||||
self.start_time = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start_time = time.time()
|
||||
while True:
|
||||
try:
|
||||
portalocker.lock(self.file, portalocker.LOCK_EX | portalocker.LOCK_NB)
|
||||
return self.file
|
||||
except portalocker.LockException:
|
||||
if time.time() - self.start_time > self.timeout:
|
||||
raise TimeoutError("获取文件锁超时")
|
||||
time.sleep(0.1)
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
portalocker.unlock(self.file)
|
||||
|
||||
|
||||
class WakeupWordsConfig:
|
||||
def __init__(self):
|
||||
self.config_file = "data/.wakeup_words.yaml"
|
||||
self.assets_dir = "config/assets/wakeup_words"
|
||||
self._ensure_directories()
|
||||
self._config_cache = None
|
||||
self._last_load_time = 0
|
||||
self._cache_ttl = 1 # 缓存有效期(秒)
|
||||
self._lock_timeout = 5 # 文件锁超时时间(秒)
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""确保必要的目录存在"""
|
||||
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
|
||||
os.makedirs(self.assets_dir, exist_ok=True)
|
||||
|
||||
def _load_config(self) -> Dict:
|
||||
"""加载配置文件,使用缓存机制"""
|
||||
current_time = time.time()
|
||||
|
||||
# 如果缓存有效,直接返回缓存
|
||||
if (
|
||||
self._config_cache is not None
|
||||
and current_time - self._last_load_time < self._cache_ttl
|
||||
):
|
||||
return self._config_cache
|
||||
|
||||
try:
|
||||
with open(self.config_file, "a+") as f:
|
||||
with FileLock(f, timeout=self._lock_timeout):
|
||||
f.seek(0)
|
||||
content = f.read()
|
||||
config = yaml.safe_load(content) if content else {}
|
||||
self._config_cache = config
|
||||
self._last_load_time = current_time
|
||||
return config
|
||||
except (TimeoutError, IOError) as e:
|
||||
print(f"加载配置文件失败: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"加载配置文件时发生未知错误: {e}")
|
||||
return {}
|
||||
|
||||
def _save_config(self, config: Dict):
|
||||
"""保存配置到文件,使用文件锁保护"""
|
||||
try:
|
||||
with open(self.config_file, "w") as f:
|
||||
with FileLock(f, timeout=self._lock_timeout):
|
||||
yaml.dump(config, f, allow_unicode=True)
|
||||
self._config_cache = config
|
||||
self._last_load_time = time.time()
|
||||
except (TimeoutError, IOError) as e:
|
||||
print(f"保存配置文件失败: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"保存配置文件时发生未知错误: {e}")
|
||||
raise
|
||||
|
||||
def get_wakeup_response(self, voice: str) -> Dict:
|
||||
voice = hashlib.md5(voice.encode()).hexdigest()
|
||||
"""获取唤醒词回复配置"""
|
||||
config = self._load_config()
|
||||
|
||||
if not config or voice not in config:
|
||||
return None
|
||||
|
||||
# 检查文件大小
|
||||
file_path = config[voice]["file_path"]
|
||||
if not os.path.exists(file_path) or os.stat(file_path).st_size < (15 * 1024):
|
||||
return None
|
||||
|
||||
return config[voice]
|
||||
|
||||
def update_wakeup_response(self, voice: str, file_path: str, text: str):
|
||||
"""更新唤醒词回复配置"""
|
||||
try:
|
||||
# 过滤表情符号
|
||||
filtered_text = re.sub(r'[\U0001F600-\U0001F64F\U0001F900-\U0001F9FF]', '', text)
|
||||
|
||||
config = self._load_config()
|
||||
voice_hash = hashlib.md5(voice.encode()).hexdigest()
|
||||
config[voice_hash] = {
|
||||
"voice": voice,
|
||||
"file_path": file_path,
|
||||
"time": time.time(),
|
||||
"text": filtered_text,
|
||||
}
|
||||
self._save_config(config)
|
||||
except Exception as e:
|
||||
print(f"更新唤醒词回复配置失败: {e}")
|
||||
raise
|
||||
|
||||
def generate_file_path(self, voice: str) -> str:
|
||||
"""生成音频文件路径,使用voice的哈希值作为文件名"""
|
||||
try:
|
||||
# 生成voice的哈希值
|
||||
voice_hash = hashlib.md5(voice.encode()).hexdigest()
|
||||
file_path = os.path.join(self.assets_dir, f"{voice_hash}.wav")
|
||||
|
||||
# 如果文件已存在,先删除
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
print(f"删除已存在的音频文件失败: {e}")
|
||||
raise
|
||||
|
||||
return file_path
|
||||
except Exception as e:
|
||||
print(f"生成音频文件路径失败: {e}")
|
||||
raise
|
||||
Reference in New Issue
Block a user