Merge branch 'main' into py_wakeup_audio

This commit is contained in:
hrz
2025-08-30 18:15:09 +08:00
committed by GitHub
17 changed files with 285 additions and 182 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
</tr> </tr>
<tr> <tr>
<td> <td>
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank"> <a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
<picture> <picture>
<img alt="自定义音色" src="docs/images/demo6.png" /> <img alt="自定义音色" src="docs/images/demo6.png" />
</picture> </picture>
+1 -1
View File
@@ -93,7 +93,7 @@ Want to see the usage effects? Click the videos below 🎥
</tr> </tr>
<tr> <tr>
<td> <td>
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank"> <a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
<picture> <picture>
<img alt="Custom voice timbre" src="docs/images/demo6.png" /> <img alt="Custom voice timbre" src="docs/images/demo6.png" />
</picture> </picture>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 111 KiB

+1 -1
View File
@@ -75,7 +75,7 @@ TTS:
sample_rate: 24000 # 采样率 [websocket默认24000http默认0 自动选择] sample_rate: 24000 # 采样率 [websocket默认24000http默认0 自动选择]
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢 speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小 volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
save_path: ./streaming_tts.wav # 服务器生成的语音文件保存路径 save_path: # 保存路径
``` ```
### 3.启动xiaozhi服务 ### 3.启动xiaozhi服务
```py ```py
@@ -3,7 +3,7 @@
class="center-dialog" > class="center-dialog" >
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
修改模型 {{ modelData.duplicateMode ? '创建副本' : '修改模型' }}
</div> </div>
<button class="custom-close-btn" @click="dialogVisible = false"> <button class="custom-close-btn" @click="dialogVisible = false">
@@ -190,6 +190,11 @@ export default {
Api.model.getModelConfig(this.modelData.id, ({ data }) => { Api.model.getModelConfig(this.modelData.id, ({ data }) => {
if (data.code === 0 && data.data) { if (data.code === 0 && data.data) {
const model = data.data; const model = data.data;
if (this.modelData.duplicateMode) {
model.modelName = this.modelData.modelName + '_副本';
model.modelCode = this.modelData.modelCode + '_副本';
}
this.pendingProviderType = model.configJson.type; this.pendingProviderType = model.configJson.type;
this.pendingModelData = model; this.pendingModelData = model;
+30 -7
View File
@@ -79,11 +79,14 @@
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center" width="150px"> <el-table-column label="操作" align="center" width="180px">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn"> <el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
修改 修改
</el-button> </el-button>
<el-button type="text" size="mini" @click="duplicateModel(scope.row)" class="edit-btn">
创建副本
</el-button>
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn"> <el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
删除 删除
</el-button> </el-button>
@@ -277,6 +280,11 @@ export default {
this.editModelData = JSON.parse(JSON.stringify(model)); this.editModelData = JSON.parse(JSON.stringify(model));
this.editDialogVisible = true; this.editDialogVisible = true;
}, },
duplicateModel(model) {
this.editModelData = JSON.parse(JSON.stringify(model));
this.editModelData.duplicateMode = true;
this.editDialogVisible = true;
},
// 删除单个模型 // 删除单个模型
deleteModel(model) { deleteModel(model) {
this.$confirm('确定要删除该模型吗?', '提示', { this.$confirm('确定要删除该模型吗?', '提示', {
@@ -313,19 +321,34 @@ export default {
const modelType = this.activeTab; const modelType = this.activeTab;
const id = formData.id; const id = formData.id;
Api.model.updateModel( if (this.editModelData.duplicateMode) {
{ modelType, provideCode, id, formData }, Api.model.addModel({modelType, provideCode, formData},
({ data }) => { ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success('保存成功'); this.$message.success('创建副本成功');
this.loadData(); this.loadData();
this.editDialogVisible = false; this.editDialogVisible = false;
} else { } else {
this.$message.error(data.msg || '保存失败'); this.$message.error(data.msg || '创建副本失败');
} }
done && done(); // 调用done回调关闭加载状态 done && done(); // 调用done回调关闭加载状态
} })
); }
else {
Api.model.updateModel(
{ modelType, provideCode, id, formData },
({ data }) => {
if (data.code === 0) {
this.$message.success('保存成功');
this.loadData();
this.editDialogVisible = false;
} else {
this.$message.error(data.msg || '保存失败');
}
done && done(); // 调用done回调关闭加载状态
}
);
}
}, },
selectAll() { selectAll() {
if (this.isAllSelected) { if (this.isAllSelected) {
+1 -3
View File
@@ -59,8 +59,6 @@ log:
delete_audio: true delete_audio: true
# 没有语音输入多久后断开连接(秒),默认2分钟,即120秒 # 没有语音输入多久后断开连接(秒),默认2分钟,即120秒
close_connection_no_voice_time: 120 close_connection_no_voice_time: 120
# TTS请求超时时间(秒)
tts_timeout: 10
# 开场是否回复唤醒词 # 开场是否回复唤醒词
enable_greeting: true enable_greeting: true
# 说完话是否开启提示音 # 说完话是否开启提示音
@@ -899,7 +897,7 @@ TTS:
sample_rate: 24000 # 采样率 [websocket默认24000http默认0 自动选择] sample_rate: 24000 # 采样率 [websocket默认24000http默认0 自动选择]
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢 speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小 volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
save_path: ./streaming_tts.wav # 服务器生成的语音文件保存路径 save_path: # 保存路径
IndexStreamTTS: IndexStreamTTS:
# 基于Index-TTS-vLLM项目的TTS接口服务 # 基于Index-TTS-vLLM项目的TTS接口服务
# 参照教程:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md # 参照教程:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
-4
View File
@@ -140,10 +140,6 @@ class ConnectionHandler:
self.func_handler = None self.func_handler = None
self.cmd_exit = self.config["exit_commands"] self.cmd_exit = self.config["exit_commands"]
self.max_cmd_length = 0
for cmd in self.cmd_exit:
if len(cmd) > self.max_cmd_length:
self.max_cmd_length = len(cmd)
# 是否在聊天结束后关闭连接 # 是否在聊天结束后关闭连接
self.close_after_chat = False self.close_after_chat = False
@@ -1,8 +1,10 @@
from config.logger import setup_logging from config.logger import setup_logging
from http import HTTPStatus from http import HTTPStatus
import dashscope
from dashscope import Application from dashscope import Application
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key from core.utils.util import check_model_key
import time
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -15,6 +17,7 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url") self.base_url = config.get("base_url")
self.is_No_prompt = config.get("is_no_prompt") self.is_No_prompt = config.get("is_no_prompt")
self.memory_id = config.get("ali_memory_id") self.memory_id = config.get("ali_memory_id")
self.streaming_chunk_size = config.get("streaming_chunk_size", 3) # 每次流式返回的字符数
check_model_key("AliBLLLM", self.api_key) check_model_key("AliBLLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
@@ -32,6 +35,8 @@ class LLMProvider(LLMProviderBase):
"app_id": self.app_id, "app_id": self.app_id,
"session_id": session_id, "session_id": session_id,
"messages": dialogue, "messages": dialogue,
# 开启SDK原生流式
"stream": True,
} }
if self.memory_id != False: if self.memory_id != False:
# 百练memory需要prompt参数 # 百练memory需要prompt参数
@@ -42,25 +47,63 @@ class LLMProvider(LLMProviderBase):
f"【阿里百练API服务】处理后的prompt: {prompt}" f"【阿里百练API服务】处理后的prompt: {prompt}"
) )
# 可选地设置自定义API基地址(若配置为兼容模式URL则忽略)
if self.base_url and ("/api/" in self.base_url):
dashscope.base_http_api_url = self.base_url
responses = Application.call(**call_params) responses = Application.call(**call_params)
if responses.status_code != HTTPStatus.OK:
logger.bind(tag=TAG).error( # 流式处理(SDK在stream=True时返回可迭代对象;否则返回单次响应对象)
f"code={responses.status_code}, " logger.bind(tag=TAG).debug(
f"message={responses.message}, " f"【阿里百练API服务】构造参数: {dict(call_params, api_key='***')}"
f"请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code" )
)
yield "【阿里百练API服务响应异常】" last_text = ""
else: try:
logger.bind(tag=TAG).debug( for resp in responses:
f"【阿里百练API服务】构造参数: {call_params}" if resp.status_code != HTTPStatus.OK:
) logger.bind(tag=TAG).error(
yield responses.output.text f"code={resp.status_code}, message={resp.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
)
continue
current_text = getattr(getattr(resp, "output", None), "text", None)
if current_text is None:
continue
# SDK流式为增量覆盖,计算差量输出
if len(current_text) >= len(last_text):
delta = current_text[len(last_text):]
else:
# 避免偶发回退
delta = current_text
if delta:
yield delta
last_text = current_text
except TypeError:
# 非流式回落(一次性返回)
if responses.status_code != HTTPStatus.OK:
logger.bind(tag=TAG).error(
f"code={responses.status_code}, message={responses.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
)
yield "【阿里百练API服务响应异常】"
else:
full_text = getattr(getattr(responses, "output", None), "text", "")
logger.bind(tag=TAG).info(
f"【阿里百练API服务】完整响应长度: {len(full_text)}"
)
for i in range(0, len(full_text), self.streaming_chunk_size):
chunk = full_text[i:i + self.streaming_chunk_size]
if chunk:
yield chunk
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}") logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
yield "【LLM服务响应异常】" yield "【LLM服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).error( # 阿里百练当前未支持原生的 function call。为保持兼容,这里回退到普通文本流式输出。
f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别" # 上层会按 (content, tool_calls) 的形式消费,这里始终返回 (token, None)
logger.bind(tag=TAG).warning(
"阿里百练未实现原生 function call,已回退为纯文本流式输出"
) )
for token in self.response(session_id, dialogue):
yield token, None
@@ -18,8 +18,8 @@ class ServerMCPExecutor(ToolExecutor):
"""初始化MCP管理器""" """初始化MCP管理器"""
if not self._initialized: if not self._initialized:
self.mcp_manager = ServerMCPManager(self.conn) self.mcp_manager = ServerMCPManager(self.conn)
await self.mcp_manager.initialize_servers()
self._initialized = True self._initialized = True
await self.mcp_manager.initialize_servers()
async def execute( async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any] self, conn, tool_name: str, arguments: Dict[str, Any]
@@ -68,6 +68,9 @@ class ServerMCPManager:
# 输出当前支持的服务端MCP工具列表 # 输出当前支持的服务端MCP工具列表
if hasattr(self.conn, "func_handler") and self.conn.func_handler: if hasattr(self.conn, "func_handler") and self.conn.func_handler:
# 刷新工具缓存以确保服务端MCP工具被正确加载
if hasattr(self.conn.func_handler, "tool_manager"):
self.conn.func_handler.tool_manager.refresh_tools()
self.conn.func_handler.current_support_functions() self.conn.func_handler.current_support_functions()
def get_all_tools(self) -> List[Dict[str, Any]]: def get_all_tools(self) -> List[Dict[str, Any]]:
@@ -33,7 +33,6 @@ class TTSProviderBase(ABC):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
self.interface_type = InterfaceType.NON_STREAM self.interface_type = InterfaceType.NON_STREAM
self.conn = None self.conn = None
self.tts_timeout = 10
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
self.audio_file_type = "wav" self.audio_file_type = "wav"
self.output_file = config.get("output_dir", "tmp/") self.output_file = config.get("output_dir", "tmp/")
@@ -192,7 +191,6 @@ class TTSProviderBase(ABC):
async def open_audio_channels(self, conn): async def open_audio_channels(self, conn):
self.conn = conn self.conn = conn
self.tts_timeout = conn.config.get("tts_timeout", 10)
# tts 消化线程 # tts 消化线程
self.tts_priority_thread = threading.Thread( self.tts_priority_thread = threading.Thread(
target=self.tts_text_priority_thread, daemon=True target=self.tts_text_priority_thread, daemon=True
@@ -8,6 +8,7 @@ import wave
import websockets import websockets
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
from config.logger import setup_logging from config.logger import setup_logging
from datetime import datetime
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -18,11 +19,12 @@ class TTSProvider(TTSProviderBase):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming") self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
self.protocol = config.get("protocol", "websocket") self.protocol = config.get("protocol", "websocket")
if config.get("private_voice"): if config.get("private_voice"):
self.spk_id = int(config.get("private_voice")) self.spk_id = int(config.get("private_voice"))
else: else:
self.spk_id = int(config.get("spk_id", "0")) self.spk_id = int(config.get("spk_id", "0"))
sample_rate = config.get("sample_rate", 24000) sample_rate = config.get("sample_rate", 24000)
self.sample_rate = float(sample_rate) if sample_rate else 24000 self.sample_rate = float(sample_rate) if sample_rate else 24000
@@ -32,7 +34,21 @@ class TTSProvider(TTSProviderBase):
volume = config.get("volume", 1.0) volume = config.get("volume", 1.0)
self.volume = float(volume) if volume else 1.0 self.volume = float(volume) if volume else 1.0
self.save_path = config.get("save_path", "./streaming_tts.wav") self.delete_audio_file = config.get("delete_audio", True)
if not self.delete_audio_file:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
save_path = config.get("save_path")
if save_path:
if not save_path.endswith('.wav'):
save_path = f"{save_path}_{timestamp}.wav"
else:
other_path = save_path[:-4]
save_path = f"{other_path}_{timestamp}.wav"
self.save_path = save_path
else:
self.save_path = f"./streaming_tts_{timestamp}.wav"
else:
self.save_path = None
async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1, async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1,
bits_per_sample: int = 16) -> bytes: bits_per_sample: int = 16) -> bytes:
@@ -151,6 +167,12 @@ class TTSProvider(TTSProviderBase):
# 接收结束响应避免服务抛出异常 # 接收结束响应避免服务抛出异常
await ws.recv() await ws.recv()
# 根据配置决定是否保存文件
if not self.delete_audio_file and self.save_path:
with open(self.save_path, "wb") as f:
f.write(wav_data)
logger.bind(tag=TAG).info(f"音频文件已保存到: {self.save_path}")
# 返回或保存音频数据 # 返回或保存音频数据
if output_file: if output_file:
with open(output_file, "wb") as file_to_save: with open(output_file, "wb") as file_to_save:
@@ -159,4 +181,4 @@ class TTSProvider(TTSProviderBase):
return wav_data return wav_data
except Exception as e: except Exception as e:
raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}") raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}")
@@ -29,12 +29,7 @@ hass_get_state_function_desc = {
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL) @register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
def hass_get_state(conn, entity_id=""): def hass_get_state(conn, entity_id=""):
try: try:
ha_response = handle_hass_get_state(conn, entity_id)
future = asyncio.run_coroutine_threadsafe(
handle_hass_get_state(conn, entity_id), conn.loop
)
# 添加10秒超时
ha_response = future.result(timeout=10)
return ActionResponse(Action.REQLLM, ha_response, None) return ActionResponse(Action.REQLLM, ha_response, None)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.bind(tag=TAG).error("获取Home Assistant状态超时") logger.bind(tag=TAG).error("获取Home Assistant状态超时")
@@ -45,13 +40,13 @@ def hass_get_state(conn, entity_id=""):
return ActionResponse(Action.ERROR, error_msg, None) return ActionResponse(Action.ERROR, error_msg, None)
async def handle_hass_get_state(conn, entity_id): def handle_hass_get_state(conn, entity_id):
ha_config = initialize_hass_handler(conn) ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key") api_key = ha_config.get("api_key")
base_url = ha_config.get("base_url") base_url = ha_config.get("base_url")
url = f"{base_url}/api/states/{entity_id}" url = f"{base_url}/api/states/{entity_id}"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers, timeout=5)
if response.status_code == 200: if response.status_code == 200:
responsetext = "设备状态:" + response.json()["state"] + " " responsetext = "设备状态:" + response.json()["state"] + " "
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}") logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
@@ -54,11 +54,7 @@ def hass_set_state(conn, entity_id="", state=None):
if state is None: if state is None:
state = {} state = {}
try: try:
future = asyncio.run_coroutine_threadsafe( ha_response = handle_hass_set_state(conn, entity_id, state)
handle_hass_set_state(conn, entity_id, state), conn.loop
)
# 添加10秒超时
ha_response = future.result(timeout=10)
return ActionResponse(Action.REQLLM, ha_response, None) return ActionResponse(Action.REQLLM, ha_response, None)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.bind(tag=TAG).error("设置Home Assistant状态超时") logger.bind(tag=TAG).error("设置Home Assistant状态超时")
@@ -69,7 +65,7 @@ def hass_set_state(conn, entity_id="", state=None):
return ActionResponse(Action.ERROR, error_msg, None) return ActionResponse(Action.ERROR, error_msg, None)
async def handle_hass_set_state(conn, entity_id, state): def handle_hass_set_state(conn, entity_id, state):
ha_config = initialize_hass_handler(conn) ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key") api_key = ha_config.get("api_key")
base_url = ha_config.get("base_url") base_url = ha_config.get("base_url")
@@ -169,7 +165,7 @@ async def handle_hass_set_state(conn, entity_id, state):
data = {"entity_id": entity_id, arg: value} data = {"entity_id": entity_id, arg: value}
url = f"{base_url}/api/services/{domain}/{action}" url = f"{base_url}/api/services/{domain}/{action}"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
response = requests.post(url, headers=headers, json=data) response = requests.post(url, headers=headers, json=data, timeout=5) # 设置5秒超时
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"设置状态:{description},url:{url},return_code:{response.status_code}" f"设置状态:{description},url:{url},return_code:{response.status_code}"
) )
@@ -0,0 +1,149 @@
import BlockingQueue from './utils/BlockingQueue.js';
import { log } from './utils/logger.js';
// 音频流播放上下文类
export class StreamingContext {
constructor(opusDecoder, audioContext, sampleRate, channels, minAudioDuration) {
this.opusDecoder = opusDecoder;
this.audioContext = audioContext;
// 音频参数
this.sampleRate = sampleRate;
this.channels = channels;
this.minAudioDuration = minAudioDuration;
// 初始化队列和状态
this.queue = []; // 已解码的PCM队列。正在播放
this.activeQueue = new BlockingQueue(); // 已解码的PCM队列。准备播放
this.pendingAudioBufferQueue = []; // 待处理的缓存队列
this.audioBufferQueue = new BlockingQueue(); // 缓存队列
this.playing = false; // 是否正在播放
this.endOfStream = false; // 是否收到结束信号
this.source = null; // 当前音频源
this.totalSamples = 0; // 累积的总样本数
this.lastPlayTime = 0; // 上次播放的时间戳
}
// 缓存音频数组
pushAudioBuffer(item) {
this.audioBufferQueue.enqueue(...item);
}
// 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题
async getPendingAudioBufferQueue() {
// 原子交换 + 清空
[this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()];
}
// 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题
async getQueue(minSamples) {
let TepArray = [];
const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1;
// 原子交换 + 清空
[TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()];
this.queue.push(...TepArray);
}
// 将Int16音频数据转换为Float32音频数据
convertInt16ToFloat32(int16Data) {
const float32Data = new Float32Array(int16Data.length);
for (let i = 0; i < int16Data.length; i++) {
// 将[-32768,32767]范围转换为[-1,1]
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
}
return float32Data;
}
// 将Opus数据解码为PCM
async decodeOpusFrames() {
if (!this.opusDecoder) {
log('Opus解码器未初始化,无法解码', 'error');
return;
} else {
log('Opus解码器启动', 'info');
}
while (true) {
let decodedSamples = [];
for (const frame of this.pendingAudioBufferQueue) {
try {
// 使用Opus解码器解码
const frameData = this.opusDecoder.decode(frame);
if (frameData && frameData.length > 0) {
// 转换为Float32
const floatData = this.convertInt16ToFloat32(frameData);
// 使用循环替代展开运算符
for (let i = 0; i < floatData.length; i++) {
decodedSamples.push(floatData[i]);
}
}
} catch (error) {
log("Opus解码失败: " + error.message, 'error');
}
}
if (decodedSamples.length > 0) {
// 使用循环替代展开运算符
for (let i = 0; i < decodedSamples.length; i++) {
this.activeQueue.enqueue(decodedSamples[i]);
}
this.totalSamples += decodedSamples.length;
} else {
log('没有成功解码的样本', 'warning');
}
await this.getPendingAudioBufferQueue();
}
}
// 开始播放音频
async startPlaying() {
while (true) {
// 如果累积了至少0.3秒的音频,开始播放
const minSamples = this.sampleRate * this.minAudioDuration * 3;
if (!this.playing && this.queue.length < minSamples) {
await this.getQueue(minSamples);
}
this.playing = true;
while (this.playing && this.queue.length) {
// 创建新的音频缓冲区
const minPlaySamples = Math.min(this.queue.length, this.sampleRate);
const currentSamples = this.queue.splice(0, minPlaySamples);
const audioBuffer = this.audioContext.createBuffer(this.channels, currentSamples.length, this.sampleRate);
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
// 创建音频源
this.source = this.audioContext.createBufferSource();
this.source.buffer = audioBuffer;
// 创建增益节点用于平滑过渡
const gainNode = this.audioContext.createGain();
// 应用淡入淡出效果避免爆音
const fadeDuration = 0.02; // 20毫秒
gainNode.gain.setValueAtTime(0, this.audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, this.audioContext.currentTime + fadeDuration);
const duration = audioBuffer.duration;
if (duration > fadeDuration * 2) {
gainNode.gain.setValueAtTime(1, this.audioContext.currentTime + duration - fadeDuration);
gainNode.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + duration);
}
// 连接节点并开始播放
this.source.connect(gainNode);
gainNode.connect(this.audioContext.destination);
this.lastPlayTime = this.audioContext.currentTime;
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / this.sampleRate).toFixed(2)}`, 'info');
this.source.start();
}
await this.getQueue(minSamples);
}
}
}
// 创建streamingContext实例的工厂函数
export function createStreamingContext(opusDecoder, audioContext, sampleRate, channels, minAudioDuration) {
return new StreamingContext(opusDecoder, audioContext, sampleRate, channels, minAudioDuration);
}
+3 -128
View File
@@ -181,6 +181,7 @@
import { checkOpusLoaded, initOpusEncoder } from './js/opus.js'; import { checkOpusLoaded, initOpusEncoder } from './js/opus.js';
import { addMessage } from './js/document.js' import { addMessage } from './js/document.js'
import BlockingQueue from './js/utils/BlockingQueue.js' import BlockingQueue from './js/utils/BlockingQueue.js'
import { createStreamingContext } from './js/StreamingContext.js'
// 需要加载的脚本列表 - 移除Opus依赖 // 需要加载的脚本列表 - 移除Opus依赖
const scriptFiles = []; const scriptFiles = [];
@@ -336,125 +337,7 @@
// 创建流式播放上下文 // 创建流式播放上下文
if (!streamingContext) { if (!streamingContext) {
streamingContext = { streamingContext = createStreamingContext(opusDecoder, audioContext, SAMPLE_RATE, CHANNELS, MIN_AUDIO_DURATION);
queue: [], // 已解码的PCM队列。正在播放
activeQueue: new BlockingQueue(), // 已解码的PCM队列。准备播放
pendingAudioBufferQueue: [], // 待处理的缓存队列
audioBufferQueue: new BlockingQueue(), // 缓存队列
playing: false, // 是否正在播放
endOfStream: false, // 是否收到结束信号
source: null, // 当前音频源
totalSamples: 0, // 累积的总样本数
lastPlayTime: 0, // 上次播放的时间戳
// 缓存音频数组
pushAudioBuffer: function (item) {
this.audioBufferQueue.enqueue(...item)
},
// 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题
getPendingAudioBufferQueue: async function () {
// 原子交换 + 清空
[this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()];
},
// 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题
getQueue: async function (minSamples) {
let TepArray = []
const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1;
// 原子交换 + 清空
[TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()];
this.queue.push(...TepArray)
},
// 将Opus数据解码为PCM
decodeOpusFrames: async function () {
if (!opusDecoder) {
log('Opus解码器未初始化,无法解码', 'error');
return;
} else {
log('Opus解码器启动', 'info');
}
while (true) {
let decodedSamples = [];
for (const frame of this.pendingAudioBufferQueue) {
try {
// 使用Opus解码器解码
const frameData = opusDecoder.decode(frame);
if (frameData && frameData.length > 0) {
// 转换为Float32
const floatData = convertInt16ToFloat32(frameData);
// 使用循环替代展开运算符
for (let i = 0; i < floatData.length; i++) {
decodedSamples.push(floatData[i]);
}
}
} catch (error) {
log("Opus解码失败: " + error.message, 'error');
}
}
if (decodedSamples.length > 0) {
// 使用循环替代展开运算符
for (let i = 0; i < decodedSamples.length; i++) {
this.activeQueue.enqueue(decodedSamples[i]);
}
this.totalSamples += decodedSamples.length;
} else {
log('没有成功解码的样本', 'warning');
}
await this.getPendingAudioBufferQueue();
}
},
// 开始播放音频
startPlaying: async function () {
while (true) {
// 如果累积了至少0.3秒的音频,开始播放
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION * 3;
if (!this.playing && this.queue.length < minSamples) {
await this.getQueue(minSamples)
}
this.playing = true;
while (this.playing && this.queue.length) {
// 创建新的音频缓冲区
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE);
const currentSamples = this.queue.splice(0, minPlaySamples);
const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
// 创建音频源
this.source = audioContext.createBufferSource();
this.source.buffer = audioBuffer;
// 创建增益节点用于平滑过渡
const gainNode = audioContext.createGain();
// 应用淡入淡出效果避免爆音
const fadeDuration = 0.02; // 20毫秒
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
const duration = audioBuffer.duration;
if (duration > fadeDuration * 2) {
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
}
// 连接节点并开始播放
this.source.connect(gainNode);
gainNode.connect(audioContext.destination);
this.lastPlayTime = audioContext.currentTime;
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)}`, 'info');
this.source.start();
}
await this.getQueue(minSamples)
}
}
};
} }
streamingContext.decodeOpusFrames(); streamingContext.decodeOpusFrames();
@@ -467,15 +350,7 @@
} }
} }
// 将Int16音频数据转换为Float32音频数据
function convertInt16ToFloat32(int16Data) {
const float32Data = new Float32Array(int16Data.length);
for (let i = 0; i < int16Data.length; i++) {
// 将[-32768,32767]范围转换为[-1,1]
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
}
return float32Data;
}
// 初始化Opus解码器 - 确保完全初始化完成后才返回 // 初始化Opus解码器 - 确保完全初始化完成后才返回
async function initOpusDecoder() { async function initOpusDecoder() {