feat: 开放小爱音箱接入小智 AI 演示源代码
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import logging
|
||||
import queue
|
||||
import numpy as np
|
||||
import pyaudio
|
||||
import opuslib
|
||||
from xiaozhi.services.protocols.typing import AudioConfig
|
||||
import time
|
||||
import sys
|
||||
|
||||
from xiaozhi.services.audio.stream import MyAudio
|
||||
from xiaozhi.xiaoai import XiaoAi
|
||||
|
||||
logger = logging.getLogger("AudioCodec")
|
||||
|
||||
|
||||
class AudioCodec:
|
||||
"""音频编解码器类,处理音频的录制和播放"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化音频编解码器"""
|
||||
self.audio = None
|
||||
self.input_stream = None
|
||||
self.output_stream = None
|
||||
self.opus_encoder = None
|
||||
self.opus_decoder = None
|
||||
self.audio_decode_queue = queue.Queue()
|
||||
self._is_closing = False
|
||||
|
||||
self._initialize_audio()
|
||||
|
||||
def _initialize_audio(self):
|
||||
"""初始化音频设备和编解码器"""
|
||||
try:
|
||||
self.audio = MyAudio() if XiaoAi.mode == "xiaoai" else pyaudio.PyAudio()
|
||||
|
||||
# 初始化音频输入流
|
||||
self.input_stream = self.audio.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=AudioConfig.CHANNELS,
|
||||
rate=AudioConfig.SAMPLE_RATE,
|
||||
input=True,
|
||||
frames_per_buffer=AudioConfig.FRAME_SIZE,
|
||||
)
|
||||
|
||||
# 初始化音频输出流
|
||||
self.output_stream = self.audio.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=AudioConfig.CHANNELS,
|
||||
rate=AudioConfig.SAMPLE_RATE,
|
||||
output=True,
|
||||
frames_per_buffer=AudioConfig.FRAME_SIZE,
|
||||
)
|
||||
|
||||
# 初始化Opus编码器
|
||||
self.opus_encoder = opuslib.Encoder(
|
||||
fs=AudioConfig.SAMPLE_RATE,
|
||||
channels=AudioConfig.CHANNELS,
|
||||
application=opuslib.APPLICATION_AUDIO,
|
||||
)
|
||||
|
||||
# 初始化Opus解码器
|
||||
self.opus_decoder = opuslib.Decoder(
|
||||
fs=AudioConfig.SAMPLE_RATE, channels=AudioConfig.CHANNELS
|
||||
)
|
||||
|
||||
logger.info("音频设备和编解码器初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化音频设备失败: {e}")
|
||||
raise
|
||||
|
||||
def read_audio(self):
|
||||
"""读取音频输入数据并编码"""
|
||||
try:
|
||||
data = self.input_stream.read(
|
||||
AudioConfig.FRAME_SIZE, exception_on_overflow=False
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
return self.opus_encoder.encode(data, AudioConfig.FRAME_SIZE)
|
||||
except Exception as e:
|
||||
logger.error(f"读取音频输入时出错: {e}")
|
||||
return None
|
||||
|
||||
def write_audio(self, opus_data):
|
||||
"""将编码的音频数据添加到播放队列"""
|
||||
self.audio_decode_queue.put(opus_data)
|
||||
|
||||
def play_audio(self):
|
||||
"""处理并播放队列中的音频数据"""
|
||||
try:
|
||||
# 批量处理多个音频包以减少处理延迟
|
||||
batch_size = min(10, self.audio_decode_queue.qsize())
|
||||
if batch_size == 0:
|
||||
return False
|
||||
|
||||
# 创建缓冲区存储解码后的数据
|
||||
buffer = bytearray()
|
||||
|
||||
for _ in range(batch_size):
|
||||
if self.audio_decode_queue.empty():
|
||||
break
|
||||
|
||||
opus_data = self.audio_decode_queue.get_nowait()
|
||||
try:
|
||||
pcm_data = self.opus_decoder.decode(
|
||||
opus_data, AudioConfig.FRAME_SIZE, decode_fec=False
|
||||
)
|
||||
buffer.extend(pcm_data)
|
||||
except Exception as e:
|
||||
logger.error(f"解码音频数据时出错: {e}")
|
||||
|
||||
# 只有在有数据时才处理和播放
|
||||
if len(buffer) > 0:
|
||||
# 转换为numpy数组
|
||||
pcm_array = np.frombuffer(buffer, dtype=np.int16)
|
||||
|
||||
# 播放音频
|
||||
try:
|
||||
if self.output_stream and self.output_stream.is_active():
|
||||
self.output_stream.write(pcm_array.tobytes())
|
||||
return True
|
||||
else:
|
||||
# MAC 特定:如果流不活跃,尝试重新初始化
|
||||
self._reinitialize_output_stream()
|
||||
if self.output_stream and self.output_stream.is_active():
|
||||
self.output_stream.write(pcm_array.tobytes())
|
||||
return True
|
||||
except OSError as e:
|
||||
if "Stream closed" in str(e) or "Internal PortAudio error" in str(
|
||||
e
|
||||
):
|
||||
logger.error(f"播放音频时出错: {e}")
|
||||
self._reinitialize_output_stream()
|
||||
else:
|
||||
logger.error(f"播放音频时出错: {e}")
|
||||
except queue.Empty:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"播放音频时出错: {e}")
|
||||
self._reinitialize_output_stream()
|
||||
|
||||
return False
|
||||
|
||||
def has_pending_audio(self):
|
||||
"""检查是否还有待播放的音频数据"""
|
||||
return not self.audio_decode_queue.empty()
|
||||
|
||||
def wait_for_audio_complete(self, timeout=5.0):
|
||||
# 等待音频队列清空
|
||||
attempt = 0
|
||||
max_attempts = 15
|
||||
while not self.audio_decode_queue.empty() and attempt < max_attempts:
|
||||
time.sleep(0.1)
|
||||
attempt += 1
|
||||
|
||||
# 在关闭前清空任何剩余数据
|
||||
while not self.audio_decode_queue.empty():
|
||||
try:
|
||||
self.audio_decode_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def clear_audio_queue(self):
|
||||
"""清空音频队列"""
|
||||
while not self.audio_decode_queue.empty():
|
||||
try:
|
||||
self.audio_decode_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def start_streams(self):
|
||||
"""启动音频流"""
|
||||
if not self.input_stream.is_active():
|
||||
self.input_stream.start_stream()
|
||||
if not self.output_stream.is_active():
|
||||
self.output_stream.start_stream()
|
||||
|
||||
def stop_streams(self):
|
||||
"""停止音频流"""
|
||||
if self.input_stream and self.input_stream.is_active():
|
||||
self.input_stream.stop_stream()
|
||||
if self.output_stream and self.output_stream.is_active():
|
||||
self.output_stream.stop_stream()
|
||||
|
||||
def _reinitialize_output_stream(self):
|
||||
"""重新初始化音频输出流"""
|
||||
if self._is_closing: # 如果正在关闭,不要重新初始化
|
||||
return
|
||||
|
||||
try:
|
||||
if self.output_stream:
|
||||
try:
|
||||
if self.output_stream.is_active():
|
||||
self.output_stream.stop_stream()
|
||||
self.output_stream.close()
|
||||
except Exception as e:
|
||||
# logger.warning(f"关闭旧输出流时出错: {e}")
|
||||
pass
|
||||
|
||||
# 在 MAC 上添加短暂延迟
|
||||
if sys.platform in ("darwin", "linux"):
|
||||
time.sleep(0.1)
|
||||
|
||||
self.output_stream = self.audio.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=AudioConfig.CHANNELS,
|
||||
rate=AudioConfig.SAMPLE_RATE,
|
||||
output=True,
|
||||
frames_per_buffer=AudioConfig.FRAME_SIZE,
|
||||
)
|
||||
logger.info("音频输出流重新初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"重新初始化音频输出流失败: {e}")
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""关闭音频编解码器,确保资源正确释放"""
|
||||
if self._is_closing: # 防止重复关闭
|
||||
return
|
||||
|
||||
self._is_closing = True
|
||||
logger.info("开始关闭音频编解码器...")
|
||||
|
||||
try:
|
||||
# 等待并清理剩余音频数据
|
||||
self.wait_for_audio_complete()
|
||||
|
||||
# 关闭输入流
|
||||
if self.input_stream:
|
||||
logger.debug("正在关闭输入流...")
|
||||
try:
|
||||
if self.input_stream.is_active():
|
||||
self.input_stream.stop_stream()
|
||||
self.input_stream.close()
|
||||
except Exception as e:
|
||||
logger.error(f"关闭输入流时出错: {e}")
|
||||
self.input_stream = None
|
||||
|
||||
# 关闭输出流
|
||||
if self.output_stream:
|
||||
logger.debug("正在关闭输出流...")
|
||||
try:
|
||||
if self.output_stream.is_active():
|
||||
self.output_stream.stop_stream()
|
||||
self.output_stream.close()
|
||||
except Exception as e:
|
||||
logger.error(f"关闭输出流时出错: {e}")
|
||||
self.output_stream = None
|
||||
|
||||
# 关闭 PyAudio 实例
|
||||
if self.audio:
|
||||
logger.debug("正在终止 PyAudio...")
|
||||
try:
|
||||
self.audio.terminate()
|
||||
except Exception as e:
|
||||
logger.error(f"终止 PyAudio 时出错: {e}")
|
||||
self.audio = None
|
||||
|
||||
# 清理编解码器
|
||||
self.opus_encoder = None
|
||||
self.opus_decoder = None
|
||||
|
||||
logger.info("音频编解码器关闭完成")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭音频编解码器时发生错误: {e}")
|
||||
finally:
|
||||
self._is_closing = False
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保资源被释放"""
|
||||
self.close()
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("Opus")
|
||||
|
||||
|
||||
def setup_opus():
|
||||
libs_dir = ""
|
||||
lib_path = ""
|
||||
|
||||
logger.info("正在加载 Opus 动态库,请耐心等待...")
|
||||
|
||||
if sys.platform == "win32":
|
||||
libs_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
lib_path = os.path.join(libs_dir, "opus.dll")
|
||||
elif sys.platform == "darwin":
|
||||
result = subprocess.check_output(
|
||||
"brew list opus | grep libopus.dylib", shell=True
|
||||
)
|
||||
lib_path = result.decode("utf-8").strip()
|
||||
if not lib_path.endswith("libopus.dylib"):
|
||||
raise RuntimeError("请先安装 Opus: brew install opus")
|
||||
libs_dir = os.path.dirname(lib_path)
|
||||
else:
|
||||
raise RuntimeError(f"暂不支持 {sys.platform} 平台")
|
||||
|
||||
import ctypes.util
|
||||
|
||||
original_find_library = ctypes.util.find_library
|
||||
|
||||
def patched_find_library(name):
|
||||
if name == "opus":
|
||||
return lib_path
|
||||
return original_find_library(name)
|
||||
|
||||
ctypes.util.find_library = patched_find_library
|
||||
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
os.add_dll_directory(libs_dir)
|
||||
|
||||
os.environ["PATH"] = libs_dir + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
ctypes.CDLL(lib_path)
|
||||
@@ -0,0 +1,290 @@
|
||||
from typing import ClassVar, Optional, Callable, Any
|
||||
from threading import Lock
|
||||
from collections import deque
|
||||
|
||||
|
||||
class GlobalStream:
|
||||
"""
|
||||
全局音频缓冲区,用于存储和分发音频数据
|
||||
|
||||
用来将小爱音箱的音频输入输出流,适配到小智 AI 的音频输入输出流
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = Lock()
|
||||
|
||||
# 默认缓冲区大小(以字节为单位)
|
||||
DEFAULT_BUFFER_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
def __new__(cls):
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super(GlobalStream, cls).__new__(cls)
|
||||
cls._instance._initialize()
|
||||
return cls._instance
|
||||
|
||||
def _initialize(self):
|
||||
"""初始化实例变量"""
|
||||
self._max_buffer_size = self.DEFAULT_BUFFER_SIZE
|
||||
self._input_buffer = deque(maxlen=self._max_buffer_size)
|
||||
self._is_input_active = False
|
||||
self._is_output_active = False
|
||||
self._input_readers = {} # 跟踪每个流的读取位置
|
||||
self._reader_counter = 0 # 为每个读取器分配唯一ID
|
||||
self._buffer_overflow_count = 0 # 记录缓冲区溢出次数
|
||||
self.on_output_data = None # 输入数据回调函数
|
||||
|
||||
def set_buffer_size(self, frames: int) -> None:
|
||||
if frames <= 0:
|
||||
raise ValueError("缓冲区大小必须大于0")
|
||||
|
||||
# 创建新的有限长度缓冲区
|
||||
new_input_buffer = deque(self._input_buffer, maxlen=frames * 2)
|
||||
|
||||
# 替换旧缓冲区
|
||||
self._input_buffer = new_input_buffer
|
||||
self._max_buffer_size = frames * 2
|
||||
|
||||
# 重置读取位置,因为缓冲区可能已经改变
|
||||
for reader_id in self._input_readers:
|
||||
self._input_readers[reader_id] = 0
|
||||
|
||||
def register_reader(self) -> int:
|
||||
"""注册一个新的读取器并返回其ID"""
|
||||
reader_id = self._reader_counter
|
||||
self._input_readers[reader_id] = 0 # 初始位置为0
|
||||
self._reader_counter += 1
|
||||
return reader_id
|
||||
|
||||
def unregister_reader(self, reader_id: int) -> None:
|
||||
"""注销一个读取器"""
|
||||
if reader_id in self._input_readers:
|
||||
del self._input_readers[reader_id]
|
||||
|
||||
def read(self, reader_id: int, num_frames: int) -> bytes:
|
||||
num_frames = num_frames * 2
|
||||
if not self._is_input_active:
|
||||
return bytes(num_frames)
|
||||
|
||||
if reader_id not in self._input_readers:
|
||||
return bytes(num_frames)
|
||||
|
||||
# 将输入缓冲区转换为列表以便随机访问
|
||||
buffer_list = list(self._input_buffer)
|
||||
current_pos = self._input_readers[reader_id]
|
||||
|
||||
# 如果当前位置超出缓冲区大小,返回空字节
|
||||
if current_pos >= len(buffer_list):
|
||||
return bytes(num_frames)
|
||||
|
||||
# 读取数据
|
||||
end_pos = min(current_pos + num_frames, len(buffer_list))
|
||||
data = bytes(buffer_list[current_pos:end_pos])
|
||||
|
||||
# 更新读取位置
|
||||
self._input_readers[reader_id] = end_pos
|
||||
|
||||
# 如果数据不足,用零填充
|
||||
if len(data) < num_frames:
|
||||
data += bytes(num_frames - len(data))
|
||||
|
||||
return data
|
||||
|
||||
# 转发输出音频流
|
||||
def write(self, frames: bytes) -> None:
|
||||
"""写入数据到输出缓冲区"""
|
||||
if not self._is_output_active:
|
||||
return
|
||||
|
||||
if self.on_output_data:
|
||||
self.on_output_data(frames)
|
||||
|
||||
# 添加输入音频流
|
||||
def add_input_data(self, data: bytes) -> None:
|
||||
if not self._is_input_active:
|
||||
return
|
||||
|
||||
"""添加输入数据到全局缓冲区"""
|
||||
# 检查是否会溢出
|
||||
if len(self._input_buffer) + len(data) > self._max_buffer_size:
|
||||
self._buffer_overflow_count += 1
|
||||
|
||||
for b in data:
|
||||
self._input_buffer.append(b)
|
||||
|
||||
# 如果有读取器的位置已经超出了缓冲区大小,需要调整
|
||||
if len(self._input_buffer) >= self._max_buffer_size:
|
||||
for reader_id in self._input_readers:
|
||||
if self._input_readers[reader_id] > len(self._input_buffer) // 2:
|
||||
# 将读取位置重置到缓冲区中间,避免读取器永远跟不上
|
||||
self._input_readers[reader_id] = len(self._input_buffer) // 2
|
||||
|
||||
def start_input(self) -> None:
|
||||
"""启动输入流"""
|
||||
self._is_input_active = True
|
||||
|
||||
def stop_input(self) -> None:
|
||||
"""停止输入流"""
|
||||
self._is_input_active = False
|
||||
self.clear()
|
||||
|
||||
def start_output(self) -> None:
|
||||
"""启动输出流"""
|
||||
self._is_output_active = True
|
||||
|
||||
def stop_output(self) -> None:
|
||||
"""停止输出流"""
|
||||
self._is_output_active = False
|
||||
|
||||
def is_input_active(self) -> bool:
|
||||
"""检查输入流是否活跃"""
|
||||
return self._is_input_active
|
||||
|
||||
def is_output_active(self) -> bool:
|
||||
"""检查输出流是否活跃"""
|
||||
return self._is_output_active
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空缓冲区"""
|
||||
self._input_buffer.clear()
|
||||
for reader_id in self._input_readers:
|
||||
self._input_readers[reader_id] = 0
|
||||
|
||||
|
||||
class MyStream:
|
||||
"""音频流类,用于读写音频数据"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rate: int,
|
||||
channels: int,
|
||||
format: int,
|
||||
input: bool = False,
|
||||
output: bool = False,
|
||||
frames_per_buffer: int = 1024,
|
||||
start: bool = True,
|
||||
) -> None:
|
||||
self._rate = rate
|
||||
self._channels = channels
|
||||
self._format = format
|
||||
self._frames_per_buffer = frames_per_buffer
|
||||
self._is_input = input
|
||||
self._is_output = output
|
||||
self._is_active = False
|
||||
self._is_closed = False
|
||||
|
||||
# 获取全局音频缓冲区
|
||||
self._global_buffer = GlobalStream()
|
||||
|
||||
# 注册读取器ID
|
||||
self._reader_id = self._global_buffer.register_reader() if input else None
|
||||
|
||||
# 如果需要,启动流
|
||||
if start:
|
||||
self.start_stream()
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭流"""
|
||||
if not self._is_closed:
|
||||
self.stop_stream()
|
||||
if self._is_input and self._reader_id is not None:
|
||||
self._global_buffer.unregister_reader(self._reader_id)
|
||||
self._is_closed = True
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""检查流是否活跃"""
|
||||
return self._is_active
|
||||
|
||||
def start_stream(self) -> None:
|
||||
"""启动流"""
|
||||
if not self._is_active and not self._is_closed:
|
||||
self._is_active = True
|
||||
if self._is_input:
|
||||
self._global_buffer.start_input()
|
||||
if self._is_output:
|
||||
self._global_buffer.start_output()
|
||||
|
||||
def stop_stream(self) -> None:
|
||||
"""停止流"""
|
||||
if self._is_active:
|
||||
self._is_active = False
|
||||
if self._is_input:
|
||||
self._global_buffer.stop_input()
|
||||
if self._is_output:
|
||||
self._global_buffer.stop_output()
|
||||
|
||||
def read(self, num_frames: int, exception_on_overflow=False) -> bytes:
|
||||
"""从输入流读取数据"""
|
||||
if (
|
||||
not self._is_input
|
||||
or self._is_closed
|
||||
or not self._is_active
|
||||
or self._reader_id is None
|
||||
):
|
||||
return bytes(num_frames)
|
||||
|
||||
return self._global_buffer.read(self._reader_id, num_frames)
|
||||
|
||||
def write(self, frames: bytes) -> None:
|
||||
"""写入数据到输出流"""
|
||||
if not self._is_output or self._is_closed or not self._is_active:
|
||||
return
|
||||
|
||||
self._global_buffer.write(frames)
|
||||
|
||||
|
||||
class MyAudio:
|
||||
"""PyAudio替代品,用于创建和管理音频流"""
|
||||
|
||||
Stream: ClassVar[type] = MyStream
|
||||
|
||||
def __init__(self, buffer_size: int = GlobalStream.DEFAULT_BUFFER_SIZE) -> None:
|
||||
# 初始化全局音频缓冲区
|
||||
self._global_buffer = GlobalStream()
|
||||
# 设置缓冲区大小
|
||||
if buffer_size != GlobalStream.DEFAULT_BUFFER_SIZE:
|
||||
self._global_buffer.set_buffer_size(buffer_size)
|
||||
self._is_terminated = False
|
||||
|
||||
def open(
|
||||
self,
|
||||
rate: int,
|
||||
channels: int,
|
||||
format: int,
|
||||
input: bool = False,
|
||||
output: bool = False,
|
||||
input_device_index: Optional[int] = None,
|
||||
output_device_index: Optional[int] = None,
|
||||
frames_per_buffer: int = 1024,
|
||||
start: bool = True,
|
||||
input_host_api_specific_stream_info: Optional[Any] = None,
|
||||
output_host_api_specific_stream_info: Optional[Any] = None,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
) -> MyStream:
|
||||
"""打开一个新的音频流"""
|
||||
if self._is_terminated:
|
||||
raise RuntimeError("MyAudio instance has been terminated")
|
||||
|
||||
# 启动全局输入/输出流(如果需要)
|
||||
if input:
|
||||
self._global_buffer.start_input()
|
||||
if output:
|
||||
self._global_buffer.start_output()
|
||||
|
||||
# 创建并返回一个新的流实例
|
||||
return MyStream(
|
||||
rate=rate,
|
||||
channels=channels,
|
||||
format=format,
|
||||
input=input,
|
||||
output=output,
|
||||
frames_per_buffer=frames_per_buffer,
|
||||
start=start,
|
||||
)
|
||||
|
||||
def terminate(self) -> None:
|
||||
"""终止MyAudio实例"""
|
||||
if not self._is_terminated:
|
||||
self._global_buffer.stop_input()
|
||||
self._global_buffer.stop_output()
|
||||
self._is_terminated = True
|
||||
Reference in New Issue
Block a user