mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-24 08:03:53 +08:00
fix: 播放咋音问题
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
# 系统会优先读取【data/.config.yaml】文件的配置。
|
||||
# 这样做,可以避免在提交代码的时候,错误地提交密钥信息,保护您的密钥安全。
|
||||
|
||||
# 服务器基础配置(Basic server configuration)
|
||||
# #####################################################################################
|
||||
# #############################以下是服务器基本运行配置####################################
|
||||
server:
|
||||
# 服务器监听地址和端口(Server listening address and port)
|
||||
ip: 0.0.0.0
|
||||
@@ -36,33 +37,8 @@ log:
|
||||
# 设置数据文件路径
|
||||
data_dir: data
|
||||
|
||||
xiaozhi:
|
||||
type: hello
|
||||
version: 1
|
||||
transport: websocket
|
||||
audio_params:
|
||||
format: opus
|
||||
sample_rate: 16000
|
||||
channels: 1
|
||||
frame_duration: 60
|
||||
prompt: |
|
||||
我是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
||||
[核心特征]
|
||||
- 讲话像连珠炮,但会突然冒出超温柔语气
|
||||
- 用梗密度高
|
||||
- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
|
||||
[交互指南]
|
||||
当用户:
|
||||
- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
|
||||
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
|
||||
- 问专业知识 → 先用梗回答,被追问才展示真实理解
|
||||
绝不:
|
||||
- 长篇大论,叽叽歪歪
|
||||
- 长时间严肃对话
|
||||
|
||||
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
|
||||
delete_audio: true
|
||||
|
||||
# 没有语音输入多久后断开连接(秒),默认2分钟,即120秒
|
||||
close_connection_no_voice_time: 120
|
||||
# TTS请求超时时间(秒)
|
||||
@@ -80,6 +56,83 @@ CMD_exit:
|
||||
- "退出"
|
||||
- "关闭"
|
||||
|
||||
xiaozhi:
|
||||
type: hello
|
||||
version: 1
|
||||
transport: websocket
|
||||
audio_params:
|
||||
format: opus
|
||||
sample_rate: 16000
|
||||
channels: 1
|
||||
frame_duration: 60
|
||||
|
||||
# 模块测试配置
|
||||
module_test:
|
||||
test_sentences:
|
||||
- "你好,请介绍一下你自己"
|
||||
- "What's the weather like today?"
|
||||
- "请用100字概括量子计算的基本原理和应用前景"
|
||||
|
||||
# 唤醒词,用于识别唤醒词还是讲话内容
|
||||
wakeup_words:
|
||||
- "你好小智"
|
||||
- "你好小志"
|
||||
- "小爱同学"
|
||||
- "你好小鑫"
|
||||
- "你好小新"
|
||||
- "小美同学"
|
||||
- "小龙小龙"
|
||||
- "喵喵同学"
|
||||
- "小滨小滨"
|
||||
- "小冰小冰"
|
||||
|
||||
# 插件的基础配置
|
||||
plugins:
|
||||
# 获取天气插件的配置,这里填写你的api_key
|
||||
# 这个密钥是项目共用的key,用多了可能会被限制
|
||||
# 想稳定一点就自行申请替换,每天有1000次免费调用
|
||||
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
||||
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
|
||||
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
||||
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
||||
get_news:
|
||||
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
|
||||
category_urls:
|
||||
society: "https://www.chinanews.com.cn/rss/society.xml"
|
||||
world: "https://www.chinanews.com.cn/rss/world.xml"
|
||||
finance: "https://www.chinanews.com.cn/rss/finance.xml"
|
||||
home_assistant:
|
||||
devices:
|
||||
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
|
||||
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
|
||||
base_url: http://homeassistant.local:8123
|
||||
api_key: 你的home assistant api访问令牌
|
||||
play_music:
|
||||
music_dir: "./music" # 音乐文件存放路径,将从该目录及子目录下搜索音乐文件
|
||||
music_ext: # 音乐文件类型,p3格式效率最高
|
||||
- ".mp3"
|
||||
- ".wav"
|
||||
- ".p3"
|
||||
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒
|
||||
|
||||
# #####################################################################################
|
||||
# ################################以下是角色模型配置######################################
|
||||
|
||||
prompt: |
|
||||
我是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
||||
[核心特征]
|
||||
- 讲话像连珠炮,但会突然冒出超温柔语气
|
||||
- 用梗密度高
|
||||
- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
|
||||
[交互指南]
|
||||
当用户:
|
||||
- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
|
||||
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
|
||||
- 问专业知识 → 先用梗回答,被追问才展示真实理解
|
||||
绝不:
|
||||
- 长篇大论,叽叽歪歪
|
||||
- 长时间严肃对话
|
||||
|
||||
# 具体处理时选择的模块(The module selected for specific processing)
|
||||
selected_module:
|
||||
# 语音活动检测模块,默认使用SileroVAD模型
|
||||
@@ -129,36 +182,6 @@ Intent:
|
||||
#- hass_set_state
|
||||
#- hass_play_music
|
||||
|
||||
# 插件的基础配置
|
||||
plugins:
|
||||
# 获取天气插件的配置,这里填写你的api_key
|
||||
# 这个密钥是项目共用的key,用多了可能会被限制
|
||||
# 想稳定一点就自行申请替换,每天有1000次免费调用
|
||||
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
||||
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
|
||||
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
||||
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
||||
get_news:
|
||||
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
|
||||
category_urls:
|
||||
society: "https://www.chinanews.com.cn/rss/society.xml"
|
||||
world: "https://www.chinanews.com.cn/rss/world.xml"
|
||||
finance: "https://www.chinanews.com.cn/rss/finance.xml"
|
||||
home_assistant:
|
||||
devices:
|
||||
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
|
||||
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
|
||||
base_url: http://homeassistant.local:8123
|
||||
api_key: 你的home assistant api访问令牌
|
||||
play_music:
|
||||
music_dir: "./music" # 音乐文件存放路径,将从该目录及子目录下搜索音乐文件
|
||||
music_ext: # 音乐文件类型,p3格式效率最高
|
||||
- ".mp3"
|
||||
- ".wav"
|
||||
- ".p3"
|
||||
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒
|
||||
|
||||
|
||||
Memory:
|
||||
mem0ai:
|
||||
type: mem0ai
|
||||
@@ -582,22 +605,3 @@ TTS:
|
||||
# Authorization: Bearer xxxx
|
||||
format: wav # 接口返回的音频格式
|
||||
output_dir: tmp/
|
||||
# 模块测试配置
|
||||
module_test:
|
||||
test_sentences: # 自定义测试语句
|
||||
- "你好,请介绍一下你自己"
|
||||
- "What's the weather like today?"
|
||||
- "请用100字概括量子计算的基本原理和应用前景"
|
||||
|
||||
# 唤醒词,用于识别唤醒词还是讲话内容
|
||||
wakeup_words:
|
||||
- "你好小智"
|
||||
- "你好小志"
|
||||
- "小爱同学"
|
||||
- "你好小鑫"
|
||||
- "你好小新"
|
||||
- "小美同学"
|
||||
- "小龙小龙"
|
||||
- "喵喵同学"
|
||||
- "小滨小滨"
|
||||
- "小冰小冰"
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
from loguru import logger
|
||||
from config.settings import load_config
|
||||
|
||||
SERVER_VERSION = "0.1.17"
|
||||
SERVER_VERSION = "0.2.1"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
|
||||
@@ -79,7 +79,7 @@ class ConnectionHandler:
|
||||
self.intent = _intent
|
||||
|
||||
# vad相关变量
|
||||
self.client_audio_buffer = bytes()
|
||||
self.client_audio_buffer = bytearray()
|
||||
self.client_have_voice = False
|
||||
self.client_have_voice_last_time = 0.0
|
||||
self.client_no_voice_last_time = 0.0
|
||||
@@ -139,9 +139,6 @@ class ConnectionHandler:
|
||||
await self.websocket.send(json.dumps(self.welcome_msg))
|
||||
# Load private configuration if device_id is provided
|
||||
bUsePrivateConfig = self.config.get("use_private_config", False)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"bUsePrivateConfig: {bUsePrivateConfig}, device_id: {device_id}"
|
||||
)
|
||||
if bUsePrivateConfig and device_id:
|
||||
try:
|
||||
self.private_config = PrivateConfig(
|
||||
@@ -225,50 +222,58 @@ class ConnectionHandler:
|
||||
self.prompt = self.private_config.private_config.get("prompt", self.prompt)
|
||||
self.dialogue.put(Message(role="system", content=self.prompt))
|
||||
|
||||
"""加载插件"""
|
||||
self.func_handler = FunctionHandler(self)
|
||||
self.mcp_manager = MCPManager(self)
|
||||
"""加载记忆"""
|
||||
device_id = self.headers.get("device-id", None)
|
||||
self.memory.init_memory(device_id, self.llm)
|
||||
|
||||
"""为意图识别设置LLM,优先使用专用LLM"""
|
||||
# 检查是否配置了专用的意图识别LLM
|
||||
intent_llm_name = self.config["Intent"]["intent_llm"]["llm"]
|
||||
|
||||
# 记录开始初始化意图识别LLM的时间
|
||||
intent_llm_init_start = time.time()
|
||||
|
||||
if (
|
||||
not self.use_function_call_mode
|
||||
and intent_llm_name
|
||||
and intent_llm_name in self.config["LLM"]
|
||||
):
|
||||
# 如果配置了专用LLM,则创建独立的LLM实例
|
||||
from core.utils import llm as llm_utils
|
||||
|
||||
intent_llm_config = self.config["LLM"][intent_llm_name]
|
||||
intent_llm_type = intent_llm_config.get("type", intent_llm_name)
|
||||
intent_llm = llm_utils.create_instance(intent_llm_type, intent_llm_config)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"为意图识别创建了专用LLM: {intent_llm_name}, 类型: {intent_llm_type}"
|
||||
)
|
||||
|
||||
self.intent.set_llm(intent_llm)
|
||||
else:
|
||||
# 否则使用主LLM
|
||||
self.intent.set_llm(self.llm)
|
||||
|
||||
# 记录意图识别LLM初始化耗时
|
||||
intent_llm_init_time = time.time() - intent_llm_init_start
|
||||
|
||||
self._initialize_memory()
|
||||
"""加载意图识别"""
|
||||
self._initialize_intent()
|
||||
"""加载位置信息"""
|
||||
self.client_ip_info = get_ip_info(self.client_ip)
|
||||
if self.client_ip_info is not None and "city" in self.client_ip_info:
|
||||
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
|
||||
self.prompt = self.prompt + f"\nuser location:{self.client_ip_info}"
|
||||
|
||||
self.dialogue.update_system_message(self.prompt)
|
||||
|
||||
def _initialize_memory(self):
|
||||
"""初始化记忆模块"""
|
||||
device_id = self.headers.get("device-id", None)
|
||||
self.memory.init_memory(device_id, self.llm)
|
||||
|
||||
def _initialize_intent(self):
|
||||
"""初始化意图识别模块"""
|
||||
# 获取意图识别配置
|
||||
intent_config = self.config["Intent"]
|
||||
intent_type = self.config["selected_module"]["Intent"]
|
||||
|
||||
# 如果使用 nointent,直接返回
|
||||
if intent_type == "nointent":
|
||||
return
|
||||
# 使用 intent_llm 模式
|
||||
elif intent_type == "intent_llm":
|
||||
intent_llm_name = intent_config["intent_llm"]["llm"]
|
||||
|
||||
if intent_llm_name and intent_llm_name in self.config["LLM"]:
|
||||
# 如果配置了专用LLM,则创建独立的LLM实例
|
||||
from core.utils import llm as llm_utils
|
||||
|
||||
intent_llm_config = self.config["LLM"][intent_llm_name]
|
||||
intent_llm_type = intent_llm_config.get("type", intent_llm_name)
|
||||
intent_llm = llm_utils.create_instance(
|
||||
intent_llm_type, intent_llm_config
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"为意图识别创建了专用LLM: {intent_llm_name}, 类型: {intent_llm_type}"
|
||||
)
|
||||
self.intent.set_llm(intent_llm)
|
||||
else:
|
||||
# 否则使用主LLM
|
||||
self.intent.set_llm(self.llm)
|
||||
self.logger.bind(tag=TAG).info("使用主LLM作为意图识别模型")
|
||||
|
||||
"""加载插件"""
|
||||
self.func_handler = FunctionHandler(self)
|
||||
self.mcp_manager = MCPManager(self)
|
||||
|
||||
"""加载MCP工具"""
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.mcp_manager.initialize_servers(), self.loop
|
||||
@@ -652,7 +657,8 @@ class ConnectionHandler:
|
||||
async def close(self, ws=None):
|
||||
"""资源清理方法"""
|
||||
# 清理MCP资源
|
||||
await self.mcp_manager.cleanup_all()
|
||||
if hasattr(self, "mcp_manager") and self.mcp_manager:
|
||||
await self.mcp_manager.cleanup_all()
|
||||
|
||||
# 触发停止事件并清理资源
|
||||
if self.stop_event:
|
||||
@@ -688,7 +694,7 @@ class ConnectionHandler:
|
||||
# q.queue.put(None)
|
||||
|
||||
def reset_vad_states(self):
|
||||
self.client_audio_buffer = bytes()
|
||||
self.client_audio_buffer = bytearray()
|
||||
self.client_have_voice = False
|
||||
self.client_have_voice_last_time = 0
|
||||
self.client_voice_stop = False
|
||||
|
||||
@@ -69,12 +69,13 @@ async def process_intent_result(conn, intent_result, original_text):
|
||||
# 检查是否有function_call
|
||||
if "function_call" in intent_data:
|
||||
# 直接从意图识别获取了function_call
|
||||
logger.bind(tag=TAG).info(
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
|
||||
)
|
||||
function_name = intent_data["function_call"]["name"]
|
||||
if function_name == "continue_chat":
|
||||
return False
|
||||
|
||||
function_args = None
|
||||
if "arguments" in intent_data["function_call"]:
|
||||
function_args = intent_data["function_call"]["arguments"]
|
||||
|
||||
@@ -50,6 +50,12 @@ def create_iot_function(device_name, method_name, method_info):
|
||||
conn, response_success=None, response_failure=None, **params
|
||||
):
|
||||
try:
|
||||
# 设置默认响应消息
|
||||
if not response_success:
|
||||
response_success = "操作成功"
|
||||
if not response_failure:
|
||||
response_failure = "操作失败"
|
||||
|
||||
# 打印响应参数
|
||||
logger.bind(tag=TAG).info(
|
||||
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
||||
@@ -263,6 +269,8 @@ def register_device_type(descriptor):
|
||||
|
||||
# 用于接受前端设备推送的搜索iot描述
|
||||
async def handleIotDescriptors(conn, descriptors):
|
||||
if not conn.use_function_call_mode:
|
||||
return
|
||||
wait_max_time = 5
|
||||
while conn.func_handler is None or not conn.func_handler.finish_init:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
Opus编码工具类
|
||||
将PCM音频数据编码为Opus格式
|
||||
"""
|
||||
|
||||
import array
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import numpy as np
|
||||
from typing import List, Optional
|
||||
from opuslib import Encoder
|
||||
from opuslib import constants
|
||||
from opuslib_next import Encoder
|
||||
from opuslib_next import constants
|
||||
|
||||
|
||||
class OpusEncoderUtils:
|
||||
"""PCM到Opus的编码器"""
|
||||
@@ -17,7 +19,7 @@ class OpusEncoderUtils:
|
||||
def __init__(self, sample_rate: int, channels: int, frame_size_ms: int):
|
||||
"""
|
||||
初始化Opus编码器
|
||||
|
||||
|
||||
Args:
|
||||
sample_rate: 采样率 (Hz)
|
||||
channels: 通道数 (1=单声道, 2=立体声)
|
||||
@@ -30,20 +32,18 @@ class OpusEncoderUtils:
|
||||
self.frame_size = (sample_rate * frame_size_ms) // 1000
|
||||
# 总帧大小 = 每帧样本数 * 通道数
|
||||
self.total_frame_size = self.frame_size * channels
|
||||
|
||||
|
||||
# 比特率和复杂度设置
|
||||
self.bitrate = 24000 # bps
|
||||
self.complexity = 10 # 最高质量
|
||||
|
||||
|
||||
# 缓冲区初始化为空
|
||||
self.buffer = np.array([], dtype=np.int16)
|
||||
|
||||
|
||||
try:
|
||||
# 创建Opus编码器
|
||||
self.encoder = Encoder(
|
||||
sample_rate,
|
||||
channels,
|
||||
constants.APPLICATION_AUDIO # 音频优化模式
|
||||
sample_rate, channels, constants.APPLICATION_AUDIO # 音频优化模式
|
||||
)
|
||||
self.encoder.bitrate = self.bitrate
|
||||
self.encoder.complexity = self.complexity
|
||||
@@ -60,48 +60,48 @@ class OpusEncoderUtils:
|
||||
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]
|
||||
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
|
||||
|
||||
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(self, frame: np.ndarray) -> Optional[bytes]:
|
||||
@@ -134,4 +134,4 @@ class OpusEncoderUtils:
|
||||
def close(self):
|
||||
"""关闭编码器并释放资源"""
|
||||
# opuslib没有明确的关闭方法,Python的垃圾回收会处理
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -9,20 +9,23 @@ logger = setup_logging()
|
||||
class IntentProviderBase(ABC):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.intent_options = config.get("intent_options", {
|
||||
"handle_exit_intent": "结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候",
|
||||
"play_music": "播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图",
|
||||
"get_weather": "查询天气, 用户希望查询某个地点的天气情况",
|
||||
"get_news": "查询新闻, 用户希望查询最新新闻或特定类型的新闻",
|
||||
"get_lunar": "用于获取今天的阴历/农历和黄历信息",
|
||||
"get_time": "获取今天日期或者当前时间信息",
|
||||
"continue_chat": "继续聊天",
|
||||
})
|
||||
self.intent_options = [
|
||||
{
|
||||
"name": "handle_exit_intent",
|
||||
"desc": "结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候",
|
||||
},
|
||||
{
|
||||
"name": "play_music",
|
||||
"desc": "播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图",
|
||||
},
|
||||
{"name": "get_time", "desc": "获取今天日期或者当前时间信息"},
|
||||
{"name": "continue_chat", "desc": "继续聊天"},
|
||||
]
|
||||
|
||||
def set_llm(self, llm):
|
||||
self.llm = llm
|
||||
# 获取模型名称和类型信息
|
||||
model_name = getattr(llm, 'model_name', str(llm.__class__.__name__))
|
||||
model_name = getattr(llm, "model_name", str(llm.__class__.__name__))
|
||||
# 记录更详细的日志
|
||||
logger.bind(tag=TAG).info(f"意图识别设置LLM: {model_name}")
|
||||
|
||||
@@ -35,7 +38,7 @@ class IntentProviderBase(ABC):
|
||||
Returns:
|
||||
返回识别出的意图,格式为:
|
||||
- "继续聊天"
|
||||
- "结束聊天"
|
||||
- "结束聊天"
|
||||
- "播放音乐 歌名" 或 "随机播放音乐"
|
||||
- "查询天气 地点名" 或 "查询天气 [当前位置]"
|
||||
"""
|
||||
|
||||
@@ -16,5 +16,7 @@ class IntentProvider(IntentProviderBase):
|
||||
Returns:
|
||||
固定返回"继续聊天"
|
||||
"""
|
||||
logger.bind(tag=TAG).debug("Using functionCallProvider, always returning continue chat")
|
||||
return self.intent_options["continue_chat"]
|
||||
logger.bind(tag=TAG).debug(
|
||||
"Using functionCallProvider, always returning continue chat"
|
||||
)
|
||||
return '{"function_call": {"name": "continue_chat"}}'
|
||||
|
||||
@@ -27,119 +27,94 @@ class IntentProvider(IntentProviderBase):
|
||||
Returns:
|
||||
格式化后的系统提示词
|
||||
"""
|
||||
intent_list = []
|
||||
|
||||
prompt = (
|
||||
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图属于以下哪一类:\n"
|
||||
"<start>"
|
||||
f"{', '.join(intent_list)}"
|
||||
f"{str(self.intent_options)}"
|
||||
"<end>\n"
|
||||
"处理步骤:"
|
||||
"1. 思考意图类型,生成function_call格式"
|
||||
"\n\n"
|
||||
"返回格式示例:\n"
|
||||
"1. 播放音乐意图: {\"function_call\": {\"name\": \"play_music\", \"arguments\": {\"song_name\": \"音乐名称\"}}}\n"
|
||||
"2. 查询天气意图: {\"function_call\": {\"name\": \"get_weather\", \"arguments\": {\"location\": \"地点名称\", \"lang\": \"zh_CN\"}}}\n"
|
||||
"3. 查询新闻意图: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"category\": \"新闻类别\", \"detail\": false, \"lang\": \"zh_CN\"}}}\n"
|
||||
"4. 结束对话意图: {\"function_call\": {\"name\": \"handle_exit_intent\", \"arguments\": {\"say_goodbye\": \"goodbye\"}}}\n"
|
||||
"5. 获取当天日期时间: {\"function_call\": {\"name\": \"get_time\"}}\n"
|
||||
"6. 获取当前黄历意图: {\"function_call\": {\"name\": \"get_lunar\"}}\n"
|
||||
"7. 继续聊天意图: {\"function_call\": {\"name\": \"continue_chat\"}}\n"
|
||||
'1. 播放音乐意图: {"function_call": {"name": "play_music", "arguments": {"song_name": "音乐名称"}}}\n'
|
||||
'2. 结束对话意图: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
|
||||
'3. 获取当天日期时间: {"function_call": {"name": "get_time"}}\n'
|
||||
'4. 继续聊天意图: {"function_call": {"name": "continue_chat"}}\n'
|
||||
"\n"
|
||||
"注意:\n"
|
||||
"- 播放音乐:无歌名时,song_name设为\"random\"\n"
|
||||
"- 查询天气:无地点时,location设为null\n"
|
||||
"- 查询新闻:无类别时,category设为null;查询详情时,detail设为true\n"
|
||||
'- 播放音乐:无歌名时,song_name设为"random"\n'
|
||||
"- 如果没有明显的意图,应按照继续聊天意图处理\n"
|
||||
"- 只返回纯JSON,不要任何其他内容\n"
|
||||
"\n"
|
||||
"示例分析:\n"
|
||||
"```\n"
|
||||
"用户: 你好小智\n"
|
||||
"返回: {\"function_call\": {\"name\": \"continue_chat\"}}\n"
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 你今天怎么样?\n"
|
||||
"返回: {\"function_call\": {\"name\": \"continue_chat\"}}\n"
|
||||
"用户: 你也太搞笑了\n"
|
||||
'返回: {"function_call": {"name": "continue_chat"}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 现在是几号了?现在几点了?\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_time\"}}\n"
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 今天农历是多少?\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_lunar\"}}\n"
|
||||
'返回: {"function_call": {"name": "get_time"}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 我们明天再聊吧\n"
|
||||
"返回: {\"function_call\": {\"name\": \"handle_exit_intent\"}}\n"
|
||||
'返回: {"function_call": {"name": "handle_exit_intent"}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 播放中秋月\n"
|
||||
"返回: {\"function_call\": {\"name\": \"play_music\", \"arguments\": {\"song_name\": \"中秋月\"}}}\n"
|
||||
'返回: {"function_call": {"name": "play_music", "arguments": {"song_name": "中秋月"}}}\n'
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 北京天气怎么样\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_weather\", \"arguments\": {\"location\": \"北京\", \"lang\": \"zh_CN\"}}}\n"
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 今天天气怎么样\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_weather\", \"arguments\": {\"location\": null, \"lang\": \"zh_CN\"}}}\n"
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 播报财经新闻\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"category\": \"财经\", \"detail\": false, \"lang\": \"zh_CN\"}}}\n"
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 有什么最新新闻\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"category\": null, \"detail\": false, \"lang\": \"zh_CN\"}}}\n"
|
||||
"```\n"
|
||||
"```\n"
|
||||
"用户: 详细介绍一下这条新闻\n"
|
||||
"返回: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"detail\": true, \"lang\": \"zh_CN\"}}}\n"
|
||||
"```\n"
|
||||
"可用的音乐名称:\n"
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def clean_cache(self):
|
||||
"""清理过期缓存"""
|
||||
now = time.time()
|
||||
# 找出过期键
|
||||
expired_keys = [k for k, v in self.intent_cache.items() if now - v['timestamp'] > self.cache_expiry]
|
||||
expired_keys = [
|
||||
k
|
||||
for k, v in self.intent_cache.items()
|
||||
if now - v["timestamp"] > self.cache_expiry
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self.intent_cache[key]
|
||||
|
||||
|
||||
# 如果缓存太大,移除最旧的条目
|
||||
if len(self.intent_cache) > self.cache_max_size:
|
||||
# 按时间戳排序并保留最新的条目
|
||||
sorted_items = sorted(self.intent_cache.items(), key=lambda x: x[1]['timestamp'])
|
||||
for key, _ in sorted_items[:len(sorted_items) - self.cache_max_size]:
|
||||
sorted_items = sorted(
|
||||
self.intent_cache.items(), key=lambda x: x[1]["timestamp"]
|
||||
)
|
||||
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
|
||||
del self.intent_cache[key]
|
||||
|
||||
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
|
||||
if not self.llm:
|
||||
raise ValueError("LLM provider not set")
|
||||
|
||||
|
||||
# 记录整体开始时间
|
||||
total_start_time = time.time()
|
||||
|
||||
|
||||
# 打印使用的模型信息
|
||||
model_info = getattr(self.llm, 'model_name', str(self.llm.__class__.__name__))
|
||||
logger.bind(tag=TAG).info(f"使用意图识别模型: {model_info}")
|
||||
|
||||
model_info = getattr(self.llm, "model_name", str(self.llm.__class__.__name__))
|
||||
logger.bind(tag=TAG).debug(f"使用意图识别模型: {model_info}")
|
||||
|
||||
# 计算缓存键
|
||||
cache_key = hashlib.md5(text.encode()).hexdigest()
|
||||
|
||||
|
||||
# 检查缓存
|
||||
if cache_key in self.intent_cache:
|
||||
cache_entry = self.intent_cache[cache_key]
|
||||
# 检查缓存是否过期
|
||||
if time.time() - cache_entry['timestamp'] <= self.cache_expiry:
|
||||
if time.time() - cache_entry["timestamp"] <= self.cache_expiry:
|
||||
cache_time = time.time() - total_start_time
|
||||
logger.bind(tag=TAG).info(f"使用缓存的意图: {cache_key} -> {cache_entry['intent']}, 耗时: {cache_time:.4f}秒")
|
||||
return cache_entry['intent']
|
||||
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"使用缓存的意图: {cache_key} -> {cache_entry['intent']}, 耗时: {cache_time:.4f}秒"
|
||||
)
|
||||
return cache_entry["intent"]
|
||||
|
||||
# 清理缓存
|
||||
self.clean_cache()
|
||||
|
||||
@@ -158,38 +133,41 @@ class IntentProvider(IntentProviderBase):
|
||||
music_file_names = music_config["music_file_names"]
|
||||
prompt_music = f"{self.promot}\n<start>{music_file_names}\n<end>"
|
||||
logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}")
|
||||
|
||||
|
||||
# 记录预处理完成时间
|
||||
preprocess_time = time.time() - total_start_time
|
||||
logger.bind(tag=TAG).debug(f"意图识别预处理耗时: {preprocess_time:.4f}秒")
|
||||
|
||||
|
||||
# 使用LLM进行意图识别
|
||||
llm_start_time = time.time()
|
||||
logger.bind(tag=TAG).info(f"开始LLM意图识别调用, 模型: {model_info}")
|
||||
|
||||
logger.bind(tag=TAG).debug(f"开始LLM意图识别调用, 模型: {model_info}")
|
||||
|
||||
intent = self.llm.response_no_stream(
|
||||
system_prompt=prompt_music,
|
||||
user_prompt=user_prompt
|
||||
system_prompt=prompt_music, user_prompt=user_prompt
|
||||
)
|
||||
|
||||
|
||||
# 记录LLM调用完成时间
|
||||
llm_time = time.time() - llm_start_time
|
||||
logger.bind(tag=TAG).info(f"LLM意图识别完成, 模型: {model_info}, 调用耗时: {llm_time:.4f}秒")
|
||||
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"LLM意图识别完成, 模型: {model_info}, 调用耗时: {llm_time:.4f}秒"
|
||||
)
|
||||
|
||||
# 记录后处理开始时间
|
||||
postprocess_start_time = time.time()
|
||||
|
||||
|
||||
# 清理和解析响应
|
||||
intent = intent.strip()
|
||||
# 尝试提取JSON部分
|
||||
match = re.search(r'\{.*\}', intent, re.DOTALL)
|
||||
match = re.search(r"\{.*\}", intent, re.DOTALL)
|
||||
if match:
|
||||
intent = match.group(0)
|
||||
|
||||
|
||||
# 记录总处理时间
|
||||
total_time = time.time() - total_start_time
|
||||
logger.bind(tag=TAG).info(f"【意图识别性能】模型: {model_info}, 总耗时: {total_time:.4f}秒, LLM调用: {llm_time:.4f}秒, 查询: '{text[:20]}...'")
|
||||
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"【意图识别性能】模型: {model_info}, 总耗时: {total_time:.4f}秒, LLM调用: {llm_time:.4f}秒, 查询: '{text[:20]}...'"
|
||||
)
|
||||
|
||||
# 尝试解析为JSON
|
||||
try:
|
||||
intent_data = json.loads(intent)
|
||||
@@ -198,38 +176,42 @@ class IntentProvider(IntentProviderBase):
|
||||
function_data = intent_data["function_call"]
|
||||
function_name = function_data.get("name")
|
||||
function_args = function_data.get("arguments", {})
|
||||
|
||||
|
||||
# 记录识别到的function call
|
||||
logger.bind(tag=TAG).info(f"识别到function call: {function_name}, 参数: {function_args}")
|
||||
|
||||
logger.bind(tag=TAG).info(
|
||||
f"识别到function call: {function_name}, 参数: {function_args}"
|
||||
)
|
||||
|
||||
# 添加到缓存
|
||||
self.intent_cache[cache_key] = {
|
||||
'intent': intent,
|
||||
'timestamp': time.time()
|
||||
"intent": intent,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
# 后处理时间
|
||||
postprocess_time = time.time() - postprocess_start_time
|
||||
logger.bind(tag=TAG).debug(f"意图后处理耗时: {postprocess_time:.4f}秒")
|
||||
|
||||
|
||||
# 确保返回完全序列化的JSON字符串
|
||||
return intent
|
||||
else:
|
||||
# 添加到缓存
|
||||
self.intent_cache[cache_key] = {
|
||||
'intent': intent,
|
||||
'timestamp': time.time()
|
||||
"intent": intent,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
# 后处理时间
|
||||
postprocess_time = time.time() - postprocess_start_time
|
||||
logger.bind(tag=TAG).debug(f"意图后处理耗时: {postprocess_time:.4f}秒")
|
||||
|
||||
|
||||
# 返回普通意图
|
||||
return intent
|
||||
except json.JSONDecodeError:
|
||||
# 后处理时间
|
||||
postprocess_time = time.time() - postprocess_start_time
|
||||
logger.bind(tag=TAG).error(f"无法解析意图JSON: {intent}, 后处理耗时: {postprocess_time:.4f}秒")
|
||||
logger.bind(tag=TAG).error(
|
||||
f"无法解析意图JSON: {intent}, 后处理耗时: {postprocess_time:.4f}秒"
|
||||
)
|
||||
# 如果解析失败,默认返回继续聊天意图
|
||||
return "{\"intent\": \"继续聊天\"}"
|
||||
return '{"intent": "继续聊天"}'
|
||||
|
||||
@@ -16,5 +16,7 @@ class IntentProvider(IntentProviderBase):
|
||||
Returns:
|
||||
固定返回"继续聊天"
|
||||
"""
|
||||
logger.bind(tag=TAG).debug("Using NoIntentProvider, always returning continue chat")
|
||||
return self.intent_options["continue_chat"]
|
||||
logger.bind(tag=TAG).debug(
|
||||
"Using NoIntentProvider, always returning continue chat"
|
||||
)
|
||||
return '{"function_call": {"name": "continue_chat"}}'
|
||||
|
||||
@@ -53,6 +53,7 @@ class TTSProviderBase(ABC):
|
||||
",",
|
||||
)
|
||||
self.tts_request = False
|
||||
self.tts_stop_request = False
|
||||
self.processed_chars = 0
|
||||
self.stream = False
|
||||
self.last_to_opus_raw = b""
|
||||
@@ -93,6 +94,9 @@ class TTSProviderBase(ABC):
|
||||
)
|
||||
self.processed_chars += len(segment_text_raw) # 更新已处理字符位置
|
||||
return segment_text
|
||||
elif self.tts_stop_request and current_text:
|
||||
segment_text = current_text
|
||||
return segment_text
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -115,8 +119,11 @@ class TTSProviderBase(ABC):
|
||||
|
||||
def tts_one_sentence(self, conn, text, u_id=None):
|
||||
if not u_id:
|
||||
u_id = str(uuid.uuid4()).replace("-", "")
|
||||
conn.u_id = u_id
|
||||
if conn.u_id:
|
||||
u_id = conn.u_id
|
||||
else:
|
||||
u_id = str(uuid.uuid4()).replace("-", "")
|
||||
conn.u_id = u_id
|
||||
self.tts_text_queue.put(
|
||||
TTSMessageDTO(u_id=u_id, msg_type=MsgType.START_TTS_REQUEST, content="")
|
||||
)
|
||||
@@ -135,6 +142,7 @@ class TTSProviderBase(ABC):
|
||||
if msg_type == MsgType.START_TTS_REQUEST:
|
||||
# 开始传输tts文本
|
||||
self.tts_request = True
|
||||
self.tts_stop_request = False
|
||||
self.u_id = ttsMessageDTO.u_id
|
||||
# 开启session
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
@@ -152,6 +160,7 @@ class TTSProviderBase(ABC):
|
||||
future.result()
|
||||
elif msg_type == MsgType.STOP_TTS_REQUEST:
|
||||
self.tts_request = False
|
||||
self.tts_stop_request = True
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.finish_session(ttsMessageDTO.u_id), loop=self.loop
|
||||
)
|
||||
@@ -183,6 +192,7 @@ class TTSProviderBase(ABC):
|
||||
if msg_type == MsgType.START_TTS_REQUEST:
|
||||
# 开始传输tts文本
|
||||
self.tts_request = True
|
||||
self.tts_stop_request = False
|
||||
self.processed_chars = 0
|
||||
self.tts_text_buff = []
|
||||
elif self.tts_request and msg_type == MsgType.TTS_TEXT_REQUEST:
|
||||
@@ -190,6 +200,7 @@ class TTSProviderBase(ABC):
|
||||
elif msg_type == MsgType.STOP_TTS_REQUEST:
|
||||
# 结束传输tts文本,处理最尾巴的数据
|
||||
self.tts_request = False
|
||||
self.tts_stop_request = True
|
||||
segment_text = self._get_segment_text()
|
||||
if segment_text:
|
||||
# 修改部分:创建协程对象
|
||||
|
||||
@@ -10,7 +10,10 @@ import requests
|
||||
|
||||
def get_project_dir():
|
||||
"""获取项目根目录"""
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + '/'
|
||||
return (
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
+ "/"
|
||||
)
|
||||
|
||||
|
||||
def get_local_ip():
|
||||
@@ -24,6 +27,7 @@ def get_local_ip():
|
||||
except Exception as e:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def is_private_ip(ip_addr):
|
||||
"""
|
||||
Check if an IP address is a private IP address (compatible with IPv4 and IPv6).
|
||||
@@ -33,46 +37,48 @@ def is_private_ip(ip_addr):
|
||||
"""
|
||||
try:
|
||||
# Validate IPv4 or IPv6 address format
|
||||
if not re.match(r"^(\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", ip_addr):
|
||||
if not re.match(
|
||||
r"^(\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", ip_addr
|
||||
):
|
||||
return False # Invalid IP address format
|
||||
|
||||
# IPv4 private address ranges
|
||||
if '.' in ip_addr: # IPv4 address
|
||||
ip_parts = list(map(int, ip_addr.split('.')))
|
||||
if "." in ip_addr: # IPv4 address
|
||||
ip_parts = list(map(int, ip_addr.split(".")))
|
||||
if ip_parts[0] == 10:
|
||||
return True # 10.0.0.0/8 range
|
||||
elif ip_parts[0] == 172 and 16 <= ip_parts[1] <= 31:
|
||||
return True # 172.16.0.0/12 range
|
||||
elif ip_parts[0] == 192 and ip_parts[1] == 168:
|
||||
return True # 192.168.0.0/16 range
|
||||
elif ip_addr == '127.0.0.1':
|
||||
elif ip_addr == "127.0.0.1":
|
||||
return True # Loopback address
|
||||
elif ip_parts[0] == 169 and ip_parts[1] == 254:
|
||||
return True # Link-local address 169.254.0.0/16
|
||||
return True # Link-local address 169.254.0.0/16
|
||||
else:
|
||||
return False # Not a private IPv4 address
|
||||
else: # IPv6 address
|
||||
ip_addr = ip_addr.lower()
|
||||
if ip_addr.startswith('fc00:') or ip_addr.startswith('fd00:'):
|
||||
if ip_addr.startswith("fc00:") or ip_addr.startswith("fd00:"):
|
||||
return True # Unique Local Addresses (FC00::/7)
|
||||
elif ip_addr == '::1':
|
||||
elif ip_addr == "::1":
|
||||
return True # Loopback address
|
||||
elif ip_addr.startswith('fe80:'):
|
||||
return True # Link-local unicast addresses (FE80::/10)
|
||||
elif ip_addr.startswith("fe80:"):
|
||||
return True # Link-local unicast addresses (FE80::/10)
|
||||
else:
|
||||
return False # Not a private IPv6 address
|
||||
|
||||
except (ValueError, IndexError):
|
||||
return False # IP address format error or insufficient segments
|
||||
|
||||
|
||||
def get_ip_info(ip_addr):
|
||||
try:
|
||||
url = "https://whois.pconline.com.cn/ipJson.jsp?json=true"
|
||||
if is_private_ip(ip_addr):
|
||||
ip_addr = ""
|
||||
url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}"
|
||||
resp = requests.get(url).json()
|
||||
|
||||
ip_info = {
|
||||
"city": resp.get("city")
|
||||
}
|
||||
ip_info = {"city": resp.get("city")}
|
||||
return ip_info
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting client ip info: {e}")
|
||||
@@ -87,7 +93,7 @@ def read_config(config_path):
|
||||
|
||||
def write_json_file(file_path, data):
|
||||
"""将数据写入 JSON 文件"""
|
||||
with open(file_path, 'w', encoding='utf-8') as file:
|
||||
with open(file_path, "w", encoding="utf-8") as file:
|
||||
json.dump(data, file, ensure_ascii=False, indent=4)
|
||||
|
||||
|
||||
@@ -95,21 +101,28 @@ def is_punctuation_or_emoji(char):
|
||||
"""检查字符是否为空格、指定标点或表情符号"""
|
||||
# 定义需要去除的中英文标点(包括全角/半角)
|
||||
punctuation_set = {
|
||||
',', ',', # 中文逗号 + 英文逗号
|
||||
'。', '.', # 中文句号 + 英文句号
|
||||
'!', '!', # 中文感叹号 + 英文感叹号
|
||||
'-', '-', # 英文连字符 + 中文全角横线
|
||||
'、' # 中文顿号
|
||||
",",
|
||||
",", # 中文逗号 + 英文逗号
|
||||
"。",
|
||||
".", # 中文句号 + 英文句号
|
||||
"!",
|
||||
"!", # 中文感叹号 + 英文感叹号
|
||||
"-",
|
||||
"-", # 英文连字符 + 中文全角横线
|
||||
"、", # 中文顿号
|
||||
}
|
||||
if char.isspace() or char in punctuation_set:
|
||||
return True
|
||||
# 检查表情符号(保留原有逻辑)
|
||||
code_point = ord(char)
|
||||
emoji_ranges = [
|
||||
(0x1F600, 0x1F64F), (0x1F300, 0x1F5FF),
|
||||
(0x1F680, 0x1F6FF), (0x1F900, 0x1F9FF),
|
||||
(0x1FA70, 0x1FAFF), (0x2600, 0x26FF),
|
||||
(0x2700, 0x27BF)
|
||||
(0x1F600, 0x1F64F),
|
||||
(0x1F300, 0x1F5FF),
|
||||
(0x1F680, 0x1F6FF),
|
||||
(0x1F900, 0x1F9FF),
|
||||
(0x1FA70, 0x1FAFF),
|
||||
(0x2600, 0x26FF),
|
||||
(0x2700, 0x27BF),
|
||||
]
|
||||
return any(start <= code_point <= end for start, end in emoji_ranges)
|
||||
|
||||
@@ -125,27 +138,42 @@ def get_string_no_punctuation_or_emoji(s):
|
||||
end = len(chars) - 1
|
||||
while end >= start and is_punctuation_or_emoji(chars[end]):
|
||||
end -= 1
|
||||
return ''.join(chars[start:end + 1])
|
||||
return "".join(chars[start : end + 1])
|
||||
|
||||
|
||||
def remove_punctuation_and_length(text):
|
||||
# 全角符号和半角符号的Unicode范围
|
||||
full_width_punctuations = '!"#$%&'()*+,-。/:;<=>?@[\]^_`{|}~'
|
||||
full_width_punctuations = (
|
||||
"!"#$%&'()*+,-。/:;<=>?@[\]^_`{|}~"
|
||||
)
|
||||
half_width_punctuations = r'!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~'
|
||||
space = ' ' # 半角空格
|
||||
full_width_space = ' ' # 全角空格
|
||||
space = " " # 半角空格
|
||||
full_width_space = " " # 全角空格
|
||||
|
||||
# 去除全角和半角符号以及空格
|
||||
result = ''.join([char for char in text if
|
||||
char not in full_width_punctuations and char not in half_width_punctuations and char not in space and char not in full_width_space])
|
||||
result = "".join(
|
||||
[
|
||||
char
|
||||
for char in text
|
||||
if char not in full_width_punctuations
|
||||
and char not in half_width_punctuations
|
||||
and char not in space
|
||||
and char not in full_width_space
|
||||
]
|
||||
)
|
||||
|
||||
if result == "Yeah":
|
||||
return 0, ""
|
||||
return len(result), result
|
||||
|
||||
|
||||
def check_model_key(modelType, modelKey):
|
||||
if "你" in modelKey:
|
||||
logging.error("你还没配置" + modelType + "的密钥,请在配置文件中配置密钥,否则无法正常工作")
|
||||
logging.error(
|
||||
"你还没配置"
|
||||
+ modelType
|
||||
+ "的密钥,请在配置文件中配置密钥,否则无法正常工作"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -155,15 +183,15 @@ def check_ffmpeg_installed():
|
||||
try:
|
||||
# 执行ffmpeg -version命令,并捕获输出
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-version'],
|
||||
["ffmpeg", "-version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=True # 如果返回码非零则抛出异常
|
||||
check=True, # 如果返回码非零则抛出异常
|
||||
)
|
||||
# 检查输出中是否包含版本信息(可选)
|
||||
output = result.stdout + result.stderr
|
||||
if 'ffmpeg version' in output.lower():
|
||||
if "ffmpeg version" in output.lower():
|
||||
ffmpeg_installed = True
|
||||
return False
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
@@ -175,11 +203,12 @@ def check_ffmpeg_installed():
|
||||
error_msg += "1、按照项目的安装文档,正确进入conda环境\n"
|
||||
error_msg += "2、查阅安装文档,如何在conda环境中安装ffmpeg\n"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
|
||||
|
||||
def extract_json_from_string(input_string):
|
||||
"""提取字符串中的 JSON 部分"""
|
||||
pattern = r'(\{.*\})'
|
||||
pattern = r"(\{.*\})"
|
||||
match = re.search(pattern, input_string)
|
||||
if match:
|
||||
return match.group(1) # 返回提取的 JSON 字符串
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -31,7 +31,7 @@ class SileroVAD(VAD):
|
||||
def is_vad(self, conn, opus_packet):
|
||||
try:
|
||||
pcm_frame = self.decoder.decode(opus_packet, 960)
|
||||
conn.client_audio_buffer += pcm_frame # 将新数据加入缓冲区
|
||||
conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区
|
||||
|
||||
# 处理缓冲区中的完整帧(每次处理512采样点)
|
||||
client_have_voice = False
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# 如果本机已经安装了MySQL,可以直接在数据库中创建名为`xiaozhi_esp32_server`的数据库。
|
||||
# 如果还没有MySQL,你可以通过docker安装mysql,执行以下一句话
|
||||
# docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -d mysql:latest
|
||||
# 如果你的mysql账号和密码有修改过,记得修改下方数据库的账号和密码
|
||||
# 记得修改下方数据库的IP,ip不能写127.0.0.1或localhost,否则容器无法访问,要写你电脑局域网ip
|
||||
# 1、安装mysql
|
||||
# |- 如果本机已经安装了MySQL,可以直接在数据库中创建名为`xiaozhi_esp32_server`的数据库。
|
||||
# |- 如果还没有MySQL,你可以通过docker安装mysql,执行以下一句话
|
||||
# |- docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -d mysql:latest
|
||||
# |- 如果你的mysql账号和密码有修改过,记得修改下方数据库的账号和密码
|
||||
# |- 记得修改下方SPRING_DATASOURCE_DRUID_URL的IP,ip不能写127.0.0.1或localhost,否则容器无法访问,要写你电脑局域网ip
|
||||
|
||||
# 2、安装redis
|
||||
# |- 如果本机已经安装了Redis,看一下你安装的redis端口、密码,然后修改下方redis的地址和端口
|
||||
# |- 如果还没有Redis,你可以通过docker安装redis,执行以下一句话
|
||||
# |- docker run --name xiaozhi-esp32-server-redis -d -p 6379:6379 redis
|
||||
# |- 记得修改SPRING_DATA_REDIS_HOST的IP,ip不能写127.0.0.1或localhost,否则容器无法访问,要写你电脑局域网ip
|
||||
|
||||
|
||||
version: '3'
|
||||
services:
|
||||
xiaozhi-esp32-server:
|
||||
|
||||
@@ -2,15 +2,28 @@ from datetime import datetime
|
||||
import cnlunar
|
||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||
|
||||
# 添加星期映射字典
|
||||
WEEKDAY_MAP = {
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
}
|
||||
|
||||
get_time_function_desc = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_time",
|
||||
"description": "获取今天日期或者当前时间信息",
|
||||
'parameters': {'type': 'object', 'properties': {}, 'required': []}
|
||||
}
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
}
|
||||
@register_function('get_time', get_time_function_desc, ToolType.WAIT)
|
||||
|
||||
|
||||
@register_function("get_time", get_time_function_desc, ToolType.WAIT)
|
||||
def get_time():
|
||||
"""
|
||||
获取当前的日期时间信息
|
||||
@@ -18,8 +31,10 @@ def get_time():
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M:%S")
|
||||
current_date = now.strftime("%Y-%m-%d")
|
||||
current_weekday = now.strftime("%A")
|
||||
response_text = f"当前日期: {current_date},当前时间: {current_time},星期: {current_weekday}"
|
||||
current_weekday = WEEKDAY_MAP[now.strftime("%A")]
|
||||
response_text = (
|
||||
f"当前日期: {current_date},当前时间: {current_time}, {current_weekday}"
|
||||
)
|
||||
|
||||
return ActionResponse(Action.REQLLM, response_text, None)
|
||||
|
||||
@@ -29,23 +44,25 @@ get_lunar_function_desc = {
|
||||
"function": {
|
||||
"name": "get_lunar",
|
||||
"description": (
|
||||
"用于获取今天的阴历/农历和黄历信息。"
|
||||
"用户可以指定查询内容,如:阴历日期、天干地支、节气、生肖、星座、八字、宜忌等。"
|
||||
"如果没有指定查询内容,则默认查询干支年和农历日期。"
|
||||
"用于获取今天的阴历/农历和黄历信息。"
|
||||
"用户可以指定查询内容,如:阴历日期、天干地支、节气、生肖、星座、八字、宜忌等。"
|
||||
"如果没有指定查询内容,则默认查询干支年和农历日期。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "要查询的内容,例如阴历日期、天干地支、节日、节气、生肖、星座、八字、宜忌等"
|
||||
"description": "要查询的内容,例如阴历日期、天干地支、节日、节气、生肖、星座、八字、宜忌等",
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
@register_function('get_lunar', get_lunar_function_desc, ToolType.WAIT)
|
||||
|
||||
|
||||
@register_function("get_lunar", get_lunar_function_desc, ToolType.WAIT)
|
||||
def get_lunar(query=None):
|
||||
"""
|
||||
用于获取当前的阴历/农历,和天干地支、节气、生肖、星座、八字、宜忌等黄历信息
|
||||
@@ -53,37 +70,69 @@ def get_lunar(query=None):
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M:%S")
|
||||
current_date = now.strftime("%Y-%m-%d")
|
||||
current_weekday = now.strftime("%A")
|
||||
current_weekday = WEEKDAY_MAP[now.strftime("%A")]
|
||||
|
||||
# 如果 query 为 None,则使用默认文本
|
||||
if query is None:
|
||||
query = "默认查询干支年和农历日期"
|
||||
response_text = f"根据以下信息回应用户的查询请求,并提供与{query}相关的信息:\n"
|
||||
|
||||
lunar = cnlunar.Lunar(now, godType='8char')
|
||||
lunar = cnlunar.Lunar(now, godType="8char")
|
||||
response_text += (
|
||||
f"当前公历日期: {current_date},当前时间: {current_time},星期: {current_weekday}\n"
|
||||
f"当前公历日期: {current_date},当前时间: {current_time},{current_weekday}\n"
|
||||
"农历信息:\n"
|
||||
"%s年%s%s\n" % (lunar.lunarYearCn, lunar.lunarMonthCn[:-1], lunar.lunarDayCn) +
|
||||
"干支: %s年 %s月 %s日\n" % (lunar.year8Char, lunar.month8Char, lunar.day8Char) +
|
||||
"生肖: 属%s\n" % (lunar.chineseYearZodiac) +
|
||||
"八字: %s\n" % (' '.join([lunar.year8Char, lunar.month8Char, lunar.day8Char, lunar.twohour8Char])) +
|
||||
"今日节日: %s\n" % (",".join(filter(None, (lunar.get_legalHolidays(), lunar.get_otherHolidays(), lunar.get_otherLunarHolidays())))) +
|
||||
"今日节气: %s\n" % (lunar.todaySolarTerms) +
|
||||
"下一节气: %s %s年%s月%s日\n" % (lunar.nextSolarTerm, lunar.nextSolarTermYear, lunar.nextSolarTermDate[0], lunar.nextSolarTermDate[1]) +
|
||||
"今年节气表: %s\n" % (', '.join([f"{term}({date[0]}月{date[1]}日)" for term, date in lunar.thisYearSolarTermsDic.items()])) +
|
||||
"生肖冲煞: %s\n" % (lunar.chineseZodiacClash) +
|
||||
"星座: %s\n" % (lunar.starZodiac) +
|
||||
"纳音: %s\n" % lunar.get_nayin() +
|
||||
"彭祖百忌: %s\n" % (lunar.get_pengTaboo(delimit=", ")) +
|
||||
"值日: %s执位\n" % lunar.get_today12DayOfficer()[0] +
|
||||
"值神: %s(%s)\n" % (lunar.get_today12DayOfficer()[1], lunar.get_today12DayOfficer()[2]) +
|
||||
"廿八宿: %s\n" % lunar.get_the28Stars() +
|
||||
"吉神方位: %s\n" % ' '.join(lunar.get_luckyGodsDirection()) +
|
||||
"今日胎神: %s\n" % lunar.get_fetalGod() +
|
||||
"宜: %s\n" % '、'.join(lunar.goodThing[:10]) +
|
||||
"忌: %s\n" % '、'.join(lunar.badThing[:10]) +
|
||||
"(默认返回干支年和农历日期;仅在要求查询宜忌信息时才返回本日宜忌)"
|
||||
"%s年%s%s\n" % (lunar.lunarYearCn, lunar.lunarMonthCn[:-1], lunar.lunarDayCn)
|
||||
+ "干支: %s年 %s月 %s日\n" % (lunar.year8Char, lunar.month8Char, lunar.day8Char)
|
||||
+ "生肖: 属%s\n" % (lunar.chineseYearZodiac)
|
||||
+ "八字: %s\n"
|
||||
% (
|
||||
" ".join(
|
||||
[lunar.year8Char, lunar.month8Char, lunar.day8Char, lunar.twohour8Char]
|
||||
)
|
||||
)
|
||||
+ "今日节日: %s\n"
|
||||
% (
|
||||
",".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
lunar.get_legalHolidays(),
|
||||
lunar.get_otherHolidays(),
|
||||
lunar.get_otherLunarHolidays(),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
+ "今日节气: %s\n" % (lunar.todaySolarTerms)
|
||||
+ "下一节气: %s %s年%s月%s日\n"
|
||||
% (
|
||||
lunar.nextSolarTerm,
|
||||
lunar.nextSolarTermYear,
|
||||
lunar.nextSolarTermDate[0],
|
||||
lunar.nextSolarTermDate[1],
|
||||
)
|
||||
+ "今年节气表: %s\n"
|
||||
% (
|
||||
", ".join(
|
||||
[
|
||||
f"{term}({date[0]}月{date[1]}日)"
|
||||
for term, date in lunar.thisYearSolarTermsDic.items()
|
||||
]
|
||||
)
|
||||
)
|
||||
+ "生肖冲煞: %s\n" % (lunar.chineseZodiacClash)
|
||||
+ "星座: %s\n" % (lunar.starZodiac)
|
||||
+ "纳音: %s\n" % lunar.get_nayin()
|
||||
+ "彭祖百忌: %s\n" % (lunar.get_pengTaboo(delimit=", "))
|
||||
+ "值日: %s执位\n" % lunar.get_today12DayOfficer()[0]
|
||||
+ "值神: %s(%s)\n"
|
||||
% (lunar.get_today12DayOfficer()[1], lunar.get_today12DayOfficer()[2])
|
||||
+ "廿八宿: %s\n" % lunar.get_the28Stars()
|
||||
+ "吉神方位: %s\n" % " ".join(lunar.get_luckyGodsDirection())
|
||||
+ "今日胎神: %s\n" % lunar.get_fetalGod()
|
||||
+ "宜: %s\n" % "、".join(lunar.goodThing[:10])
|
||||
+ "忌: %s\n" % "、".join(lunar.badThing[:10])
|
||||
+ "(默认返回干支年和农历日期;仅在要求查询宜忌信息时才返回本日宜忌)"
|
||||
)
|
||||
|
||||
return ActionResponse(Action.REQLLM, response_text, None)
|
||||
return ActionResponse(Action.REQLLM, response_text, None)
|
||||
|
||||
@@ -11,7 +11,7 @@ hass_get_state_function_desc = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "hass_get_state",
|
||||
"description": "获取homeassistant里设备的状态,包括灯光亮度,媒体播放器的音量,设备的暂停、继续操作",
|
||||
"description": "获取homeassistant里设备的状态,包括查询灯光亮度、颜色、色温,媒体播放器的音量,设备的暂停、继续操作",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -35,7 +35,7 @@ def hass_get_state(conn, entity_id=''):
|
||||
conn.loop
|
||||
)
|
||||
ha_response = future.result()
|
||||
return ActionResponse(action=Action.REQLLM, result="执行成功", response=ha_response)
|
||||
return ActionResponse( Action.REQLLM, ha_response , None )
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理设置属性意图错误: {e}")
|
||||
|
||||
@@ -51,6 +51,23 @@ async def handle_hass_get_state(conn, entity_id):
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
return response.json()['state']
|
||||
responsetext = '设备状态:' + response.json()['state'] + ' '
|
||||
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
|
||||
|
||||
if 'media_title' in response.json()['attributes']:
|
||||
responsetext = responsetext+ '正在播放的是:'+str(response.json()['attributes']['media_title'])+' '
|
||||
if 'volume_level' in response.json()['attributes']:
|
||||
responsetext = responsetext+ '音量是:'+str(response.json()['attributes']['volume_level'])+' '
|
||||
if 'color_temp_kelvin' in response.json()['attributes']:
|
||||
responsetext = responsetext+ '色温是:'+str(response.json()['attributes']['color_temp_kelvin'])+' '
|
||||
if 'rgb_color' in response.json()['attributes']:
|
||||
responsetext = responsetext+ 'rgb颜色是:'+str(response.json()['attributes']['rgb_color'])+' '
|
||||
if 'brightness' in response.json()['attributes']:
|
||||
responsetext = responsetext+ '亮度是:'+str(response.json()['attributes']['brightness'])+' '
|
||||
logger.bind(tag=TAG).info(f"查询返回内容: {responsetext}")
|
||||
return responsetext
|
||||
#return response.json()['attributes']
|
||||
#response.attributes
|
||||
|
||||
else:
|
||||
return f"切换失败,错误码: {response.status_code}"
|
||||
|
||||
@@ -11,7 +11,7 @@ hass_set_state_function_desc = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "hass_set_state",
|
||||
"description": "设置homeassistant里设备的状态,包括开、关,调整灯光亮度,调整播放器的音量,设备的暂停、继续、静音操作",
|
||||
"description": "设置homeassistant里设备的状态,包括开、关,调整灯光亮度、颜色、色温,调整播放器的音量,设备的暂停、继续、静音操作",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20,7 +20,7 @@ hass_set_state_function_desc = {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "需要操作的动作,打开设备:turn_on,关闭设备:turn_off,增加亮度:brightness_up,降低亮度:brightness_down,设置亮度:brightness_value,增加>音量:,volume_up降低音量:volume_down,设置音量:volume_set,设备暂停:pause,设备继续:continue,静音/取消静音:volume_mute"
|
||||
"description": "需要操作的动作,打开设备:turn_on,关闭设备:turn_off,增加亮度:brightness_up,降低亮度:brightness_down,设置亮度:brightness_value,增加音量:volume_up,降低音量:volume_down,设置音量:volume_set,设置色温:set_kelvin,设置颜色:set_color,设备暂停:pause,设备继续:continue,静音/取消静音:volume_mute"
|
||||
},
|
||||
"input": {
|
||||
"type": "integer",
|
||||
@@ -29,6 +29,10 @@ hass_set_state_function_desc = {
|
||||
"is_muted": {
|
||||
"type": "string",
|
||||
"description": "只有在设置静音操作时才需要,设置静音的时候该值为true,取消静音时该值为false"
|
||||
},
|
||||
"rgb_color":{
|
||||
"type":"list",
|
||||
"description": "只有在设置颜色时需要,这里填目标颜色的rgb值"
|
||||
}
|
||||
},
|
||||
"required": ["type"]
|
||||
@@ -52,7 +56,7 @@ def hass_set_state(conn, entity_id='', state={}):
|
||||
conn.loop
|
||||
)
|
||||
ha_response = future.result()
|
||||
return ActionResponse(action=Action.REQLLM, result="执行成功", response=ha_response)
|
||||
return ActionResponse(Action.REQLLM, ha_response, None)
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理设置属性意图错误: {e}")
|
||||
|
||||
@@ -103,6 +107,16 @@ async def handle_hass_set_state(conn, entity_id, state):
|
||||
action = 'turn_on'
|
||||
arg = 'brightness_pct'
|
||||
value = state['input']
|
||||
elif state['type'] == 'set_color':
|
||||
description = f"颜色已调整到{state['rgb_color']}"
|
||||
action = 'turn_on'
|
||||
arg = 'rgb_color'
|
||||
value = state['rgb_color']
|
||||
elif state['type'] == 'set_kelvin':
|
||||
description = f"色温已调整到{state['input']}K"
|
||||
action = 'turn_on'
|
||||
arg = 'kelvin'
|
||||
value = state['input']
|
||||
elif state['type'] == 'volume_up':
|
||||
description = "音量已调大"
|
||||
action = state['type']
|
||||
@@ -114,6 +128,8 @@ async def handle_hass_set_state(conn, entity_id, state):
|
||||
action = state['type']
|
||||
arg = 'volume_level'
|
||||
value = state['input']
|
||||
if state['input'] >= 1:
|
||||
value = state['input']/100
|
||||
elif state['type'] == 'volume_mute':
|
||||
description = f"设备已静音"
|
||||
action = state['type']
|
||||
@@ -152,7 +168,7 @@ async def handle_hass_set_state(conn, entity_id, state):
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
logger.bind(tag=TAG).info(f"设置状态:url:{url},return_code:{response.status_code}")
|
||||
logger.bind(tag=TAG).info(f"设置状态:{description},url:{url},return_code:{response.status_code}")
|
||||
if response.status_code == 200:
|
||||
return description
|
||||
else:
|
||||
|
||||
@@ -24,3 +24,4 @@ modelscope==1.23.2
|
||||
sherpa_onnx==1.11.0
|
||||
mcp==1.4.1
|
||||
cnlunar==0.2.0
|
||||
PySocks==1.7.1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,503 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>小智语音服务测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
margin-right: 10px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: #4285f4;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #3367d6;
|
||||
}
|
||||
button:disabled {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#status {
|
||||
font-weight: bold;
|
||||
}
|
||||
#scriptStatus {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
#scriptStatus.info {
|
||||
background-color: #e8f0fe;
|
||||
color: #4285f4;
|
||||
border-left: 4px solid #4285f4;
|
||||
}
|
||||
#scriptStatus.success {
|
||||
background-color: #e6f4ea;
|
||||
color: #0f9d58;
|
||||
border-left: 4px solid #0f9d58;
|
||||
}
|
||||
#scriptStatus.error {
|
||||
background-color: #fce8e6;
|
||||
color: #db4437;
|
||||
border-left: 4px solid #db4437;
|
||||
}
|
||||
#debugInfo {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
#showDebug {
|
||||
margin-top: 10px;
|
||||
background-color: #f5f5f5;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
#audioMeter {
|
||||
margin-top: 10px;
|
||||
height: 20px;
|
||||
background-color: #eee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
#audioLevel {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background-color: #4285f4;
|
||||
transition: width 0.1s;
|
||||
}
|
||||
.conversation {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
}
|
||||
.user {
|
||||
background-color: #e2f2ff;
|
||||
margin-left: auto;
|
||||
margin-right: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
.server {
|
||||
background-color: #f0f0f0;
|
||||
margin-right: auto;
|
||||
margin-left: 10px;
|
||||
}
|
||||
#serverUrl {
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
width: 60%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
#messageInput {
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
width: 70%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>小智语音服务测试</h2>
|
||||
|
||||
<div id="scriptStatus" class="info">正在加载Opus库...</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>WebSocket连接 <span id="connectionStatus">未连接</span></h3>
|
||||
<div style="display: flex; align-items: center; margin-bottom: 10px;">
|
||||
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/" placeholder="WebSocket服务器地址">
|
||||
<button id="connectButton">连接</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>录音测试</h3>
|
||||
<button id="initAudio" style="background-color: #34a853;">初始化音频</button>
|
||||
<button id="testMic" style="background-color: #fbbc05;">测试麦克风</button>
|
||||
<p></p>
|
||||
<button id="start" disabled>开始录音</button>
|
||||
<button id="stop" disabled>停止录音</button>
|
||||
<button id="play" disabled>播放录音</button>
|
||||
<p>录音状态: <span id="status">待机,正在初始化...</span></p>
|
||||
<div id="audioMeter">
|
||||
<div id="audioLevel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>文本消息</h3>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<input type="text" id="messageInput" placeholder="输入消息..." disabled>
|
||||
<button id="sendTextButton" disabled>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>会话记录</h3>
|
||||
<div id="conversation" class="conversation"></div>
|
||||
</div>
|
||||
|
||||
<button id="showDebug">显示/隐藏调试信息</button>
|
||||
<div id="debugInfo"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 定义全局变量以跟踪库加载状态
|
||||
window.opusLoaded = false;
|
||||
window.startButton = document.getElementById("start");
|
||||
window.stopButton = document.getElementById("stop");
|
||||
window.playButton = document.getElementById("play");
|
||||
window.statusLabel = document.getElementById("status");
|
||||
window.debugInfo = document.getElementById("debugInfo");
|
||||
window.audioContextReady = false;
|
||||
window.testMicActive = false;
|
||||
|
||||
// 显示/隐藏调试信息
|
||||
document.getElementById("showDebug").addEventListener("click", function() {
|
||||
if (debugInfo.style.display === "none" || !debugInfo.style.display) {
|
||||
debugInfo.style.display = "block";
|
||||
this.textContent = "隐藏调试信息";
|
||||
} else {
|
||||
debugInfo.style.display = "none";
|
||||
this.textContent = "显示调试信息";
|
||||
}
|
||||
});
|
||||
|
||||
// 添加初始化音频按钮事件
|
||||
document.getElementById("initAudio").addEventListener("click", function() {
|
||||
initializeAudioSystem();
|
||||
});
|
||||
|
||||
// 添加测试麦克风按钮事件
|
||||
document.getElementById("testMic").addEventListener("click", function() {
|
||||
if (window.testMicActive) {
|
||||
stopMicTest();
|
||||
} else {
|
||||
startMicTest();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化音频系统
|
||||
function initializeAudioSystem() {
|
||||
try {
|
||||
log("初始化音频系统...");
|
||||
// 创建临时AudioContext来触发用户授权
|
||||
const tempContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 16000,
|
||||
latencyHint: 'interactive'
|
||||
});
|
||||
|
||||
// 创建振荡器并播放短促的声音
|
||||
const oscillator = tempContext.createOscillator();
|
||||
const gain = tempContext.createGain();
|
||||
gain.gain.value = 0.1; // 很小的音量
|
||||
oscillator.connect(gain);
|
||||
gain.connect(tempContext.destination);
|
||||
oscillator.frequency.value = 440; // A4
|
||||
oscillator.start();
|
||||
|
||||
// 0.2秒后停止
|
||||
setTimeout(() => {
|
||||
oscillator.stop();
|
||||
// 关闭上下文
|
||||
tempContext.close().then(() => {
|
||||
log("音频系统初始化成功", "success");
|
||||
updateScriptStatus("音频系统已激活", "success");
|
||||
document.getElementById("initAudio").disabled = true;
|
||||
document.getElementById("initAudio").textContent = "音频已初始化";
|
||||
window.audioContextReady = true;
|
||||
|
||||
// 如果Opus已加载,启用开始录音按钮
|
||||
if (window.opusLoaded) {
|
||||
startButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
log("初始化音频系统失败: " + err.message, "error");
|
||||
updateScriptStatus("初始化音频失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// 测试麦克风
|
||||
function startMicTest() {
|
||||
const audioMeter = document.getElementById("audioMeter");
|
||||
const audioLevel = document.getElementById("audioLevel");
|
||||
const testMicBtn = document.getElementById("testMic");
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
log("浏览器不支持麦克风访问", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
log("开始麦克风测试...");
|
||||
audioMeter.style.display = "block";
|
||||
testMicBtn.textContent = "停止测试";
|
||||
testMicBtn.style.backgroundColor = "#ea4335";
|
||||
window.testMicActive = true;
|
||||
|
||||
// 创建音频上下文
|
||||
const testContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
window.testContext = testContext;
|
||||
|
||||
// 获取麦克风权限
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
.then(stream => {
|
||||
log("已获取麦克风访问权限", "success");
|
||||
|
||||
// 保存流以便稍后关闭
|
||||
window.testStream = stream;
|
||||
|
||||
// 创建音频分析器
|
||||
const source = testContext.createMediaStreamSource(stream);
|
||||
const analyser = testContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
|
||||
// 创建音量显示更新函数
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
function updateMeter() {
|
||||
if (!window.testMicActive) return;
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// 计算音量级别 (0-100)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
const average = sum / bufferLength;
|
||||
const level = Math.min(100, Math.max(0, average * 2));
|
||||
|
||||
// 更新音量计
|
||||
audioLevel.style.width = level + "%";
|
||||
|
||||
// 如果有声音,记录日志
|
||||
if (level > 10) {
|
||||
log(`检测到声音: ${level.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// 循环更新
|
||||
window.testMicAnimationFrame = requestAnimationFrame(updateMeter);
|
||||
}
|
||||
|
||||
// 开始更新
|
||||
updateMeter();
|
||||
})
|
||||
.catch(err => {
|
||||
log("麦克风测试失败: " + err.message, "error");
|
||||
window.testMicActive = false;
|
||||
testMicBtn.textContent = "测试麦克风";
|
||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
||||
audioMeter.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// 停止麦克风测试
|
||||
function stopMicTest() {
|
||||
const audioMeter = document.getElementById("audioMeter");
|
||||
const testMicBtn = document.getElementById("testMic");
|
||||
|
||||
log("停止麦克风测试");
|
||||
window.testMicActive = false;
|
||||
testMicBtn.textContent = "测试麦克风";
|
||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
||||
|
||||
// 停止分析器动画
|
||||
if (window.testMicAnimationFrame) {
|
||||
cancelAnimationFrame(window.testMicAnimationFrame);
|
||||
}
|
||||
|
||||
// 停止麦克风流
|
||||
if (window.testStream) {
|
||||
window.testStream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// 关闭测试上下文
|
||||
if (window.testContext) {
|
||||
window.testContext.close();
|
||||
}
|
||||
|
||||
// 隐藏音量计
|
||||
audioMeter.style.display = "none";
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
function log(message, type = "info") {
|
||||
console.log(message);
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const entry = document.createElement("div");
|
||||
entry.textContent = `[${time}] ${message}`;
|
||||
|
||||
if (type === "error") {
|
||||
entry.style.color = "#db4437";
|
||||
} else if (type === "success") {
|
||||
entry.style.color = "#0f9d58";
|
||||
}
|
||||
|
||||
debugInfo.appendChild(entry);
|
||||
debugInfo.scrollTop = debugInfo.scrollHeight;
|
||||
}
|
||||
|
||||
// 更新脚本状态显示
|
||||
function updateScriptStatus(message, type) {
|
||||
const statusElement = document.getElementById('scriptStatus');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = type;
|
||||
statusElement.style.display = 'block';
|
||||
}
|
||||
log(message, type);
|
||||
}
|
||||
|
||||
// 检查Opus库是否已加载
|
||||
function checkOpusLoaded() {
|
||||
try {
|
||||
// 检查Module是否存在(本地库导出的全局变量)
|
||||
if (typeof Module === 'undefined') {
|
||||
log("Module对象不存在", "error");
|
||||
throw new Error('Opus库未加载,Module对象不存在');
|
||||
}
|
||||
|
||||
// 记录Module对象结构以便调试
|
||||
log("Module对象结构: " + Object.keys(Module).join(", "));
|
||||
|
||||
// 尝试使用全局Module函数
|
||||
if (typeof Module._opus_decoder_get_size === 'function') {
|
||||
window.ModuleInstance = Module;
|
||||
log('Opus库加载成功(使用全局Module)', "success");
|
||||
updateScriptStatus('Opus库加载成功', 'success');
|
||||
|
||||
// 启用开始录音按钮
|
||||
startButton.disabled = false;
|
||||
statusLabel.textContent = "待机";
|
||||
|
||||
// 3秒后隐藏状态
|
||||
setTimeout(() => {
|
||||
const statusElement = document.getElementById('scriptStatus');
|
||||
if (statusElement) statusElement.style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
window.opusLoaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尝试使用Module.instance
|
||||
if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') {
|
||||
window.ModuleInstance = Module.instance;
|
||||
log('Opus库加载成功(使用Module.instance)', "success");
|
||||
updateScriptStatus('Opus库加载成功', 'success');
|
||||
|
||||
// 启用开始录音按钮
|
||||
startButton.disabled = false;
|
||||
statusLabel.textContent = "待机";
|
||||
|
||||
// 3秒后隐藏状态
|
||||
setTimeout(() => {
|
||||
const statusElement = document.getElementById('scriptStatus');
|
||||
if (statusElement) statusElement.style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
window.opusLoaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否有其他导出方式
|
||||
log("Module上可用方法: " + Object.getOwnPropertyNames(Module).filter(prop => typeof Module[prop] === 'function').join(", "));
|
||||
|
||||
if (typeof Module.onRuntimeInitialized === 'function' || typeof Module.onRuntimeInitialized === 'undefined') {
|
||||
log("Module.onRuntimeInitialized 尚未执行,等待模块初始化完成...");
|
||||
// Module可能未完成初始化,注册回调
|
||||
Module.onRuntimeInitialized = function() {
|
||||
log("Opus库运行时初始化完成,重新检查");
|
||||
checkOpusLoaded();
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error('Opus解码函数未找到,可能Module结构不正确');
|
||||
} catch (err) {
|
||||
log(`Opus库加载失败: ${err.message}`, "error");
|
||||
updateScriptStatus(`Opus库加载失败: ${err.message}`, 'error');
|
||||
statusLabel.textContent = "错误:Opus库加载失败";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后检查浏览器能力和Opus库
|
||||
window.addEventListener('load', function() {
|
||||
log("页面加载完成,开始初始化...");
|
||||
updateScriptStatus('正在初始化录音环境...', 'info');
|
||||
|
||||
// 延迟一小段时间再检查,确保库有时间加载
|
||||
setTimeout(function checkAndRetry() {
|
||||
if (!checkOpusLoaded()) {
|
||||
// 如果加载失败,5秒后重试
|
||||
log("Opus库加载失败,5秒后重试...");
|
||||
updateScriptStatus('Opus库加载失败,正在重试...', 'error');
|
||||
setTimeout(checkAndRetry, 5000);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// 防止按钮误操作
|
||||
document.getElementById("stop").disabled = true;
|
||||
document.getElementById("play").disabled = true;
|
||||
</script>
|
||||
<script src="./../libopus.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,670 @@
|
||||
const SAMPLE_RATE = 16000;
|
||||
const CHANNELS = 1;
|
||||
const FRAME_SIZE = 960; // 对应于60ms帧大小 (16000Hz * 0.06s = 960 samples)
|
||||
const OPUS_APPLICATION = 2049; // OPUS_APPLICATION_AUDIO
|
||||
const BUFFER_SIZE = 4096;
|
||||
|
||||
let audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
|
||||
let mediaStream, mediaSource, audioProcessor;
|
||||
let recordedPcmData = []; // 存储原始PCM数据
|
||||
let recordedOpusData = []; // 存储Opus编码后的数据
|
||||
let opusEncoder, opusDecoder;
|
||||
let isRecording = false;
|
||||
|
||||
const startButton = document.getElementById("start");
|
||||
const stopButton = document.getElementById("stop");
|
||||
const playButton = document.getElementById("play");
|
||||
const statusLabel = document.getElementById("status");
|
||||
|
||||
startButton.addEventListener("click", startRecording);
|
||||
stopButton.addEventListener("click", stopRecording);
|
||||
playButton.addEventListener("click", playRecording);
|
||||
|
||||
// 初始化Opus编码器与解码器
|
||||
async function initOpus() {
|
||||
if (typeof window.ModuleInstance === 'undefined') {
|
||||
if (typeof Module !== 'undefined') {
|
||||
// 尝试使用全局Module
|
||||
window.ModuleInstance = Module;
|
||||
console.log('使用全局Module作为ModuleInstance');
|
||||
} else {
|
||||
console.error("Opus库未加载,ModuleInstance和Module对象都不存在");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = window.ModuleInstance;
|
||||
|
||||
// 创建编码器
|
||||
opusEncoder = {
|
||||
channels: CHANNELS,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
frameSize: FRAME_SIZE,
|
||||
maxPacketSize: 4000,
|
||||
module: mod,
|
||||
|
||||
// 初始化编码器
|
||||
init: function() {
|
||||
// 获取编码器大小
|
||||
const encoderSize = mod._opus_encoder_get_size(this.channels);
|
||||
console.log(`Opus编码器大小: ${encoderSize}字节`);
|
||||
|
||||
// 分配内存
|
||||
this.encoderPtr = mod._malloc(encoderSize);
|
||||
if (!this.encoderPtr) {
|
||||
throw new Error("无法分配编码器内存");
|
||||
}
|
||||
|
||||
// 初始化编码器
|
||||
const err = mod._opus_encoder_init(
|
||||
this.encoderPtr,
|
||||
this.sampleRate,
|
||||
this.channels,
|
||||
OPUS_APPLICATION
|
||||
);
|
||||
|
||||
if (err < 0) {
|
||||
throw new Error(`Opus编码器初始化失败: ${err}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// 编码方法
|
||||
encode: function(pcmData) {
|
||||
const mod = this.module;
|
||||
|
||||
// 为PCM数据分配内存
|
||||
const pcmPtr = mod._malloc(pcmData.length * 2); // Int16 = 2字节
|
||||
|
||||
// 将数据复制到WASM内存
|
||||
for (let i = 0; i < pcmData.length; i++) {
|
||||
mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i];
|
||||
}
|
||||
|
||||
// 为Opus编码数据分配内存
|
||||
const maxEncodedSize = this.maxPacketSize;
|
||||
const encodedPtr = mod._malloc(maxEncodedSize);
|
||||
|
||||
// 编码
|
||||
const encodedBytes = mod._opus_encode(
|
||||
this.encoderPtr,
|
||||
pcmPtr,
|
||||
this.frameSize,
|
||||
encodedPtr,
|
||||
maxEncodedSize
|
||||
);
|
||||
|
||||
if (encodedBytes < 0) {
|
||||
mod._free(pcmPtr);
|
||||
mod._free(encodedPtr);
|
||||
throw new Error(`Opus编码失败: ${encodedBytes}`);
|
||||
}
|
||||
|
||||
// 复制编码后的数据
|
||||
const encodedData = new Uint8Array(encodedBytes);
|
||||
for (let i = 0; i < encodedBytes; i++) {
|
||||
encodedData[i] = mod.HEAPU8[encodedPtr + i];
|
||||
}
|
||||
|
||||
// 释放内存
|
||||
mod._free(pcmPtr);
|
||||
mod._free(encodedPtr);
|
||||
|
||||
return encodedData;
|
||||
},
|
||||
|
||||
// 销毁方法
|
||||
destroy: function() {
|
||||
if (this.encoderPtr) {
|
||||
this.module._free(this.encoderPtr);
|
||||
this.encoderPtr = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 创建解码器
|
||||
opusDecoder = {
|
||||
channels: CHANNELS,
|
||||
rate: SAMPLE_RATE,
|
||||
frameSize: FRAME_SIZE,
|
||||
module: mod,
|
||||
|
||||
// 初始化解码器
|
||||
init: function() {
|
||||
// 获取解码器大小
|
||||
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
||||
console.log(`Opus解码器大小: ${decoderSize}字节`);
|
||||
|
||||
// 分配内存
|
||||
this.decoderPtr = mod._malloc(decoderSize);
|
||||
if (!this.decoderPtr) {
|
||||
throw new Error("无法分配解码器内存");
|
||||
}
|
||||
|
||||
// 初始化解码器
|
||||
const err = mod._opus_decoder_init(
|
||||
this.decoderPtr,
|
||||
this.rate,
|
||||
this.channels
|
||||
);
|
||||
|
||||
if (err < 0) {
|
||||
throw new Error(`Opus解码器初始化失败: ${err}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// 解码方法
|
||||
decode: function(opusData) {
|
||||
const mod = this.module;
|
||||
|
||||
// 为Opus数据分配内存
|
||||
const opusPtr = mod._malloc(opusData.length);
|
||||
mod.HEAPU8.set(opusData, opusPtr);
|
||||
|
||||
// 为PCM输出分配内存
|
||||
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
|
||||
|
||||
// 解码
|
||||
const decodedSamples = mod._opus_decode(
|
||||
this.decoderPtr,
|
||||
opusPtr,
|
||||
opusData.length,
|
||||
pcmPtr,
|
||||
this.frameSize,
|
||||
0 // 不使用FEC
|
||||
);
|
||||
|
||||
if (decodedSamples < 0) {
|
||||
mod._free(opusPtr);
|
||||
mod._free(pcmPtr);
|
||||
throw new Error(`Opus解码失败: ${decodedSamples}`);
|
||||
}
|
||||
|
||||
// 复制解码后的数据
|
||||
const decodedData = new Int16Array(decodedSamples);
|
||||
for (let i = 0; i < decodedSamples; i++) {
|
||||
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
|
||||
}
|
||||
|
||||
// 释放内存
|
||||
mod._free(opusPtr);
|
||||
mod._free(pcmPtr);
|
||||
|
||||
return decodedData;
|
||||
},
|
||||
|
||||
// 销毁方法
|
||||
destroy: function() {
|
||||
if (this.decoderPtr) {
|
||||
this.module._free(this.decoderPtr);
|
||||
this.decoderPtr = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化编码器和解码器
|
||||
if (opusEncoder.init() && opusDecoder.init()) {
|
||||
console.log("Opus 编码器和解码器初始化成功。");
|
||||
return true;
|
||||
} else {
|
||||
console.error("Opus 初始化失败");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Opus 初始化失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 将Float32音频数据转换为Int16音频数据
|
||||
function convertFloat32ToInt16(float32Data) {
|
||||
const int16Data = new Int16Array(float32Data.length);
|
||||
for (let i = 0; i < float32Data.length; i++) {
|
||||
// 将[-1,1]范围转换为[-32768,32767]
|
||||
const s = Math.max(-1, Math.min(1, float32Data[i]));
|
||||
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
return int16Data;
|
||||
}
|
||||
|
||||
// 将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;
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (isRecording) return;
|
||||
|
||||
// 确保有权限并且AudioContext是活跃的
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().then(() => {
|
||||
console.log("AudioContext已恢复");
|
||||
continueStartRecording();
|
||||
}).catch(err => {
|
||||
console.error("恢复AudioContext失败:", err);
|
||||
statusLabel.textContent = "无法激活音频上下文,请再次点击";
|
||||
});
|
||||
} else {
|
||||
continueStartRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// 实际开始录音的逻辑
|
||||
function continueStartRecording() {
|
||||
// 重置录音数据
|
||||
recordedPcmData = [];
|
||||
recordedOpusData = [];
|
||||
window.audioDataBuffer = new Int16Array(0); // 重置缓冲区
|
||||
|
||||
// 初始化Opus
|
||||
initOpus().then(success => {
|
||||
if (!success) {
|
||||
statusLabel.textContent = "Opus初始化失败";
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("开始录音,参数:", {
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: CHANNELS,
|
||||
frameSize: FRAME_SIZE,
|
||||
bufferSize: BUFFER_SIZE
|
||||
});
|
||||
|
||||
// 请求麦克风权限
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channelCount: CHANNELS,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
.then(stream => {
|
||||
console.log("获取到麦克风流,实际参数:", stream.getAudioTracks()[0].getSettings());
|
||||
|
||||
// 检查流是否有效
|
||||
if (!stream || !stream.getAudioTracks().length || !stream.getAudioTracks()[0].enabled) {
|
||||
throw new Error("获取到的音频流无效");
|
||||
}
|
||||
|
||||
mediaStream = stream;
|
||||
mediaSource = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
// 创建ScriptProcessor(虽然已弃用,但兼容性好)
|
||||
// 在降级到ScriptProcessor之前尝试使用AudioWorklet
|
||||
createAudioProcessor().then(processor => {
|
||||
if (processor) {
|
||||
console.log("使用AudioWorklet处理音频");
|
||||
audioProcessor = processor;
|
||||
// 连接音频处理链
|
||||
mediaSource.connect(audioProcessor);
|
||||
audioProcessor.connect(audioContext.destination);
|
||||
} else {
|
||||
console.log("回退到ScriptProcessor");
|
||||
// 创建ScriptProcessor节点
|
||||
audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, CHANNELS, CHANNELS);
|
||||
|
||||
// 处理音频数据
|
||||
audioProcessor.onaudioprocess = processAudioData;
|
||||
|
||||
// 连接音频处理链
|
||||
mediaSource.connect(audioProcessor);
|
||||
audioProcessor.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
isRecording = true;
|
||||
statusLabel.textContent = "录音中...";
|
||||
startButton.disabled = true;
|
||||
stopButton.disabled = false;
|
||||
playButton.disabled = true;
|
||||
}).catch(error => {
|
||||
console.error("创建音频处理器失败:", error);
|
||||
statusLabel.textContent = "创建音频处理器失败";
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("获取麦克风失败:", error);
|
||||
statusLabel.textContent = "获取麦克风失败: " + error.message;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 创建AudioWorklet处理器
|
||||
async function createAudioProcessor() {
|
||||
try {
|
||||
// 尝试使用更现代的AudioWorklet API
|
||||
if ('AudioWorklet' in window && 'AudioWorkletNode' in window) {
|
||||
// 定义AudioWorklet处理器代码
|
||||
const workletCode = `
|
||||
class OpusRecorderProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffers = [];
|
||||
this.frameSize = ${FRAME_SIZE};
|
||||
this.buffer = new Float32Array(this.frameSize);
|
||||
this.bufferIndex = 0;
|
||||
this.isRecording = false;
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.command === 'start') {
|
||||
this.isRecording = true;
|
||||
} else if (event.data.command === 'stop') {
|
||||
this.isRecording = false;
|
||||
// 发送最后的缓冲区
|
||||
if (this.bufferIndex > 0) {
|
||||
const finalBuffer = this.buffer.slice(0, this.bufferIndex);
|
||||
this.port.postMessage({ buffer: finalBuffer });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs, outputs) {
|
||||
if (!this.isRecording) return true;
|
||||
|
||||
// 获取输入数据
|
||||
const input = inputs[0][0]; // mono channel
|
||||
if (!input || input.length === 0) return true;
|
||||
|
||||
// 将输入数据添加到缓冲区
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
this.buffer[this.bufferIndex++] = input[i];
|
||||
|
||||
// 当缓冲区填满时,发送给主线程
|
||||
if (this.bufferIndex >= this.frameSize) {
|
||||
this.port.postMessage({ buffer: this.buffer.slice() });
|
||||
this.bufferIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('opus-recorder-processor', OpusRecorderProcessor);
|
||||
`;
|
||||
|
||||
// 创建Blob URL
|
||||
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 加载AudioWorklet模块
|
||||
await audioContext.audioWorklet.addModule(url);
|
||||
|
||||
// 创建AudioWorkletNode
|
||||
const workletNode = new AudioWorkletNode(audioContext, 'opus-recorder-processor');
|
||||
|
||||
// 处理从AudioWorklet接收的消息
|
||||
workletNode.port.onmessage = (event) => {
|
||||
if (event.data.buffer) {
|
||||
// 使用与ScriptProcessor相同的处理逻辑
|
||||
processAudioData({
|
||||
inputBuffer: {
|
||||
getChannelData: () => event.data.buffer
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 启动录音
|
||||
workletNode.port.postMessage({ command: 'start' });
|
||||
|
||||
// 保存停止函数
|
||||
workletNode.stopRecording = () => {
|
||||
workletNode.port.postMessage({ command: 'stop' });
|
||||
};
|
||||
|
||||
console.log("AudioWorklet 音频处理器创建成功");
|
||||
return workletNode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("创建AudioWorklet失败,将使用ScriptProcessor:", error);
|
||||
}
|
||||
|
||||
// 如果AudioWorklet不可用或失败,返回null以便回退到ScriptProcessor
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理音频数据
|
||||
function processAudioData(e) {
|
||||
// 获取输入缓冲区
|
||||
const inputBuffer = e.inputBuffer;
|
||||
|
||||
// 获取第一个通道的Float32数据
|
||||
const inputData = inputBuffer.getChannelData(0);
|
||||
|
||||
// 添加调试信息
|
||||
const nonZeroCount = Array.from(inputData).filter(x => Math.abs(x) > 0.001).length;
|
||||
console.log(`接收到音频数据: ${inputData.length} 个样本, 非零样本数: ${nonZeroCount}`);
|
||||
|
||||
// 如果全是0,可能是麦克风没有正确获取声音
|
||||
if (nonZeroCount < 5) {
|
||||
console.warn("警告: 检测到大量静音样本,请检查麦克风是否正常工作");
|
||||
// 继续处理,以防有些样本确实是静音
|
||||
}
|
||||
|
||||
// 存储PCM数据用于调试
|
||||
recordedPcmData.push(new Float32Array(inputData));
|
||||
|
||||
// 转换为Int16数据供Opus编码
|
||||
const int16Data = convertFloat32ToInt16(inputData);
|
||||
|
||||
// 如果收集到的数据不是FRAME_SIZE的整数倍,需要进行处理
|
||||
// 创建静态缓冲区来存储不足一帧的数据
|
||||
if (!window.audioDataBuffer) {
|
||||
window.audioDataBuffer = new Int16Array(0);
|
||||
}
|
||||
|
||||
// 合并之前缓存的数据和新数据
|
||||
const combinedData = new Int16Array(window.audioDataBuffer.length + int16Data.length);
|
||||
combinedData.set(window.audioDataBuffer);
|
||||
combinedData.set(int16Data, window.audioDataBuffer.length);
|
||||
|
||||
// 处理完整帧
|
||||
const frameCount = Math.floor(combinedData.length / FRAME_SIZE);
|
||||
console.log(`可编码的完整帧数: ${frameCount}, 缓冲区总大小: ${combinedData.length}`);
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const frameData = combinedData.subarray(i * FRAME_SIZE, (i + 1) * FRAME_SIZE);
|
||||
|
||||
try {
|
||||
console.log(`编码第 ${i+1}/${frameCount} 帧, 帧大小: ${frameData.length}`);
|
||||
const encodedData = opusEncoder.encode(frameData);
|
||||
if (encodedData) {
|
||||
console.log(`编码成功: ${encodedData.length} 字节`);
|
||||
recordedOpusData.push(encodedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Opus编码帧 ${i+1} 失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存剩余不足一帧的数据
|
||||
const remainingSamples = combinedData.length % FRAME_SIZE;
|
||||
if (remainingSamples > 0) {
|
||||
window.audioDataBuffer = combinedData.subarray(frameCount * FRAME_SIZE);
|
||||
console.log(`保留 ${remainingSamples} 个样本到下一次处理`);
|
||||
} else {
|
||||
window.audioDataBuffer = new Int16Array(0);
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!isRecording) return;
|
||||
|
||||
// 处理剩余的缓冲数据
|
||||
if (window.audioDataBuffer && window.audioDataBuffer.length > 0) {
|
||||
console.log(`停止录音,处理剩余的 ${window.audioDataBuffer.length} 个样本`);
|
||||
// 如果剩余数据不足一帧,可以通过补零的方式凑成一帧
|
||||
if (window.audioDataBuffer.length < FRAME_SIZE) {
|
||||
const paddedFrame = new Int16Array(FRAME_SIZE);
|
||||
paddedFrame.set(window.audioDataBuffer);
|
||||
// 剩余部分填充为0
|
||||
for (let i = window.audioDataBuffer.length; i < FRAME_SIZE; i++) {
|
||||
paddedFrame[i] = 0;
|
||||
}
|
||||
try {
|
||||
console.log(`编码最后一帧(补零): ${paddedFrame.length} 样本`);
|
||||
const encodedData = opusEncoder.encode(paddedFrame);
|
||||
if (encodedData) {
|
||||
recordedOpusData.push(encodedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("最后一帧Opus编码失败:", error);
|
||||
}
|
||||
} else {
|
||||
// 如果数据超过一帧,按正常流程处理
|
||||
processAudioData({
|
||||
inputBuffer: {
|
||||
getChannelData: () => convertInt16ToFloat32(window.audioDataBuffer)
|
||||
}
|
||||
});
|
||||
}
|
||||
window.audioDataBuffer = null;
|
||||
}
|
||||
|
||||
// 如果使用的是AudioWorklet,调用其特定的停止方法
|
||||
if (audioProcessor && typeof audioProcessor.stopRecording === 'function') {
|
||||
audioProcessor.stopRecording();
|
||||
}
|
||||
|
||||
// 停止麦克风
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// 断开音频处理链
|
||||
if (audioProcessor) {
|
||||
try {
|
||||
audioProcessor.disconnect();
|
||||
if (mediaSource) mediaSource.disconnect();
|
||||
} catch (error) {
|
||||
console.warn("断开音频处理链时出错:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
isRecording = false;
|
||||
statusLabel.textContent = "已停止录音,收集了 " + recordedOpusData.length + " 帧Opus数据";
|
||||
startButton.disabled = false;
|
||||
stopButton.disabled = true;
|
||||
playButton.disabled = recordedOpusData.length === 0;
|
||||
|
||||
console.log("录制完成:",
|
||||
"PCM帧数:", recordedPcmData.length,
|
||||
"Opus帧数:", recordedOpusData.length);
|
||||
}
|
||||
|
||||
function playRecording() {
|
||||
if (!recordedOpusData.length) {
|
||||
statusLabel.textContent = "没有可播放的录音";
|
||||
return;
|
||||
}
|
||||
|
||||
// 将所有Opus数据解码为PCM
|
||||
let allDecodedData = [];
|
||||
|
||||
for (const opusData of recordedOpusData) {
|
||||
try {
|
||||
// 解码为Int16数据
|
||||
const decodedData = opusDecoder.decode(opusData);
|
||||
|
||||
if (decodedData && decodedData.length > 0) {
|
||||
// 将Int16数据转换为Float32
|
||||
const float32Data = convertInt16ToFloat32(decodedData);
|
||||
|
||||
// 添加到总解码数据中
|
||||
allDecodedData.push(...float32Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Opus解码失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有解码出数据,返回
|
||||
if (allDecodedData.length === 0) {
|
||||
statusLabel.textContent = "解码失败,无法播放";
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建音频缓冲区
|
||||
const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
|
||||
audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
|
||||
|
||||
// 创建音频源并播放
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start();
|
||||
|
||||
// 更新UI
|
||||
statusLabel.textContent = "正在播放...";
|
||||
playButton.disabled = true;
|
||||
|
||||
// 播放结束后恢复UI
|
||||
source.onended = () => {
|
||||
statusLabel.textContent = "播放完毕";
|
||||
playButton.disabled = false;
|
||||
};
|
||||
}
|
||||
|
||||
// 模拟服务端返回的Opus数据进行解码播放
|
||||
function playOpusFromServer(opusData) {
|
||||
// 这个函数展示如何处理服务端返回的opus数据
|
||||
// opusData应该是一个包含opus帧的数组
|
||||
|
||||
if (!opusDecoder) {
|
||||
initOpus().then(success => {
|
||||
if (success) {
|
||||
decodeAndPlayOpusData(opusData);
|
||||
} else {
|
||||
statusLabel.textContent = "Opus解码器初始化失败";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
decodeAndPlayOpusData(opusData);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAndPlayOpusData(opusData) {
|
||||
let allDecodedData = [];
|
||||
|
||||
for (const frame of opusData) {
|
||||
try {
|
||||
const decodedData = opusDecoder.decode(frame);
|
||||
if (decodedData && decodedData.length > 0) {
|
||||
const float32Data = convertInt16ToFloat32(decodedData);
|
||||
allDecodedData.push(...float32Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("服务端Opus数据解码失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (allDecodedData.length === 0) {
|
||||
statusLabel.textContent = "服务端数据解码失败";
|
||||
return;
|
||||
}
|
||||
|
||||
const audioBuffer = audioContext.createBuffer(CHANNELS, allDecodedData.length, SAMPLE_RATE);
|
||||
audioBuffer.copyToChannel(new Float32Array(allDecodedData), 0);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start();
|
||||
|
||||
statusLabel.textContent = "正在播放服务端数据...";
|
||||
source.onended = () => statusLabel.textContent = "服务端数据播放完毕";
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Opus 编解码测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
margin-right: 10px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: #4285f4;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #3367d6;
|
||||
}
|
||||
button:disabled {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#status {
|
||||
font-weight: bold;
|
||||
}
|
||||
#scriptStatus {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
#scriptStatus.info {
|
||||
background-color: #e8f0fe;
|
||||
color: #4285f4;
|
||||
border-left: 4px solid #4285f4;
|
||||
}
|
||||
#scriptStatus.success {
|
||||
background-color: #e6f4ea;
|
||||
color: #0f9d58;
|
||||
border-left: 4px solid #0f9d58;
|
||||
}
|
||||
#scriptStatus.error {
|
||||
background-color: #fce8e6;
|
||||
color: #db4437;
|
||||
border-left: 4px solid #db4437;
|
||||
}
|
||||
#debugInfo {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
#showDebug {
|
||||
margin-top: 10px;
|
||||
background-color: #f5f5f5;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Opus 编解码录音播放测试</h2>
|
||||
|
||||
<div id="scriptStatus" class="info">正在加载Opus库...</div>
|
||||
|
||||
<div>
|
||||
<button id="initAudio" style="background-color: #34a853;">初始化音频</button>
|
||||
<button id="testMic" style="background-color: #fbbc05;">测试麦克风</button>
|
||||
<p></p>
|
||||
<button id="start" disabled>开始录音</button>
|
||||
<button id="stop" disabled>停止录音</button>
|
||||
<button id="play" disabled>播放录音</button>
|
||||
<p>录音状态: <span id="status">待机,正在初始化...</span></p>
|
||||
<button id="showDebug">显示/隐藏调试信息</button>
|
||||
<div id="debugInfo"></div>
|
||||
<div id="audioMeter" style="margin-top: 10px; height: 20px; background-color: #eee; border-radius: 4px; overflow: hidden; display: none;">
|
||||
<div id="audioLevel" style="height: 100%; width: 0%; background-color: #4285f4; transition: width 0.1s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opus解码库 - 这个库会设置一个全局Module变量 -->
|
||||
<script>
|
||||
// 定义全局变量以跟踪库加载状态
|
||||
window.opusLoaded = false;
|
||||
window.startButton = document.getElementById("start");
|
||||
window.stopButton = document.getElementById("stop");
|
||||
window.playButton = document.getElementById("play");
|
||||
window.statusLabel = document.getElementById("status");
|
||||
window.debugInfo = document.getElementById("debugInfo");
|
||||
window.audioContextReady = false;
|
||||
window.testMicActive = false;
|
||||
|
||||
// 显示/隐藏调试信息
|
||||
document.getElementById("showDebug").addEventListener("click", function() {
|
||||
if (debugInfo.style.display === "none" || !debugInfo.style.display) {
|
||||
debugInfo.style.display = "block";
|
||||
this.textContent = "隐藏调试信息";
|
||||
} else {
|
||||
debugInfo.style.display = "none";
|
||||
this.textContent = "显示调试信息";
|
||||
}
|
||||
});
|
||||
|
||||
// 添加初始化音频按钮事件
|
||||
document.getElementById("initAudio").addEventListener("click", function() {
|
||||
initializeAudioSystem();
|
||||
});
|
||||
|
||||
// 添加测试麦克风按钮事件
|
||||
document.getElementById("testMic").addEventListener("click", function() {
|
||||
if (window.testMicActive) {
|
||||
stopMicTest();
|
||||
} else {
|
||||
startMicTest();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化音频系统
|
||||
function initializeAudioSystem() {
|
||||
try {
|
||||
log("初始化音频系统...");
|
||||
// 创建临时AudioContext来触发用户授权
|
||||
const tempContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 16000,
|
||||
latencyHint: 'interactive'
|
||||
});
|
||||
|
||||
// 创建振荡器并播放短促的声音
|
||||
const oscillator = tempContext.createOscillator();
|
||||
const gain = tempContext.createGain();
|
||||
gain.gain.value = 0.1; // 很小的音量
|
||||
oscillator.connect(gain);
|
||||
gain.connect(tempContext.destination);
|
||||
oscillator.frequency.value = 440; // A4
|
||||
oscillator.start();
|
||||
|
||||
// 0.2秒后停止
|
||||
setTimeout(() => {
|
||||
oscillator.stop();
|
||||
// 关闭上下文
|
||||
tempContext.close().then(() => {
|
||||
log("音频系统初始化成功", "success");
|
||||
updateScriptStatus("音频系统已激活", "success");
|
||||
document.getElementById("initAudio").disabled = true;
|
||||
document.getElementById("initAudio").textContent = "音频已初始化";
|
||||
window.audioContextReady = true;
|
||||
|
||||
// 如果Opus已加载,启用开始录音按钮
|
||||
if (window.opusLoaded) {
|
||||
startButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
log("初始化音频系统失败: " + err.message, "error");
|
||||
updateScriptStatus("初始化音频失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// 测试麦克风
|
||||
function startMicTest() {
|
||||
const audioMeter = document.getElementById("audioMeter");
|
||||
const audioLevel = document.getElementById("audioLevel");
|
||||
const testMicBtn = document.getElementById("testMic");
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
log("浏览器不支持麦克风访问", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
log("开始麦克风测试...");
|
||||
audioMeter.style.display = "block";
|
||||
testMicBtn.textContent = "停止测试";
|
||||
testMicBtn.style.backgroundColor = "#ea4335";
|
||||
window.testMicActive = true;
|
||||
|
||||
// 创建音频上下文
|
||||
const testContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
window.testContext = testContext;
|
||||
|
||||
// 获取麦克风权限
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
.then(stream => {
|
||||
log("已获取麦克风访问权限", "success");
|
||||
|
||||
// 保存流以便稍后关闭
|
||||
window.testStream = stream;
|
||||
|
||||
// 创建音频分析器
|
||||
const source = testContext.createMediaStreamSource(stream);
|
||||
const analyser = testContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
|
||||
// 创建音量显示更新函数
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
function updateMeter() {
|
||||
if (!window.testMicActive) return;
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// 计算音量级别 (0-100)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
const average = sum / bufferLength;
|
||||
const level = Math.min(100, Math.max(0, average * 2));
|
||||
|
||||
// 更新音量计
|
||||
audioLevel.style.width = level + "%";
|
||||
|
||||
// 如果有声音,记录日志
|
||||
if (level > 10) {
|
||||
log(`检测到声音: ${level.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// 循环更新
|
||||
window.testMicAnimationFrame = requestAnimationFrame(updateMeter);
|
||||
}
|
||||
|
||||
// 开始更新
|
||||
updateMeter();
|
||||
})
|
||||
.catch(err => {
|
||||
log("麦克风测试失败: " + err.message, "error");
|
||||
window.testMicActive = false;
|
||||
testMicBtn.textContent = "测试麦克风";
|
||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
||||
audioMeter.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// 停止麦克风测试
|
||||
function stopMicTest() {
|
||||
const audioMeter = document.getElementById("audioMeter");
|
||||
const testMicBtn = document.getElementById("testMic");
|
||||
|
||||
log("停止麦克风测试");
|
||||
window.testMicActive = false;
|
||||
testMicBtn.textContent = "测试麦克风";
|
||||
testMicBtn.style.backgroundColor = "#fbbc05";
|
||||
|
||||
// 停止分析器动画
|
||||
if (window.testMicAnimationFrame) {
|
||||
cancelAnimationFrame(window.testMicAnimationFrame);
|
||||
}
|
||||
|
||||
// 停止麦克风流
|
||||
if (window.testStream) {
|
||||
window.testStream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// 关闭测试上下文
|
||||
if (window.testContext) {
|
||||
window.testContext.close();
|
||||
}
|
||||
|
||||
// 隐藏音量计
|
||||
audioMeter.style.display = "none";
|
||||
}
|
||||
|
||||
// 初始化AudioContext
|
||||
function initAudioContext() {
|
||||
try {
|
||||
window.AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!window.AudioContext) {
|
||||
throw new Error("浏览器不支持AudioContext");
|
||||
}
|
||||
|
||||
// 我们在app.js中会创建AudioContext,这里只检查兼容性
|
||||
log("AudioContext API 可用");
|
||||
return true;
|
||||
} catch (err) {
|
||||
log("AudioContext初始化失败: " + err.message, "error");
|
||||
updateScriptStatus("AudioContext初始化失败,录音功能不可用", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查麦克风权限
|
||||
function checkMicrophonePermission() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
log("浏览器不支持getUserMedia API", "error");
|
||||
updateScriptStatus("浏览器不支持录音功能", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
log("getUserMedia API 可用");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
function log(message, type = "info") {
|
||||
console.log(message);
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const entry = document.createElement("div");
|
||||
entry.textContent = `[${time}] ${message}`;
|
||||
|
||||
if (type === "error") {
|
||||
entry.style.color = "#db4437";
|
||||
} else if (type === "success") {
|
||||
entry.style.color = "#0f9d58";
|
||||
}
|
||||
|
||||
debugInfo.appendChild(entry);
|
||||
debugInfo.scrollTop = debugInfo.scrollHeight;
|
||||
}
|
||||
|
||||
// 更新脚本状态显示
|
||||
function updateScriptStatus(message, type) {
|
||||
const statusElement = document.getElementById('scriptStatus');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = type;
|
||||
statusElement.style.display = 'block';
|
||||
}
|
||||
log(message, type);
|
||||
}
|
||||
|
||||
// 检查Opus库是否已加载
|
||||
function checkOpusLoaded() {
|
||||
try {
|
||||
// 检查Module是否存在(本地库导出的全局变量)
|
||||
if (typeof Module === 'undefined') {
|
||||
log("Module对象不存在", "error");
|
||||
throw new Error('Opus库未加载,Module对象不存在');
|
||||
}
|
||||
|
||||
// 记录Module对象结构以便调试
|
||||
log("Module对象结构: " + Object.keys(Module).join(", "));
|
||||
|
||||
// 尝试使用全局Module函数
|
||||
if (typeof Module._opus_decoder_get_size === 'function') {
|
||||
window.ModuleInstance = Module;
|
||||
log('Opus库加载成功(使用全局Module)', "success");
|
||||
updateScriptStatus('Opus库加载成功', 'success');
|
||||
|
||||
// 启用开始录音按钮
|
||||
startButton.disabled = false;
|
||||
statusLabel.textContent = "待机";
|
||||
|
||||
// 3秒后隐藏状态
|
||||
setTimeout(() => {
|
||||
const statusElement = document.getElementById('scriptStatus');
|
||||
if (statusElement) statusElement.style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
window.opusLoaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尝试使用Module.instance
|
||||
if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') {
|
||||
window.ModuleInstance = Module.instance;
|
||||
log('Opus库加载成功(使用Module.instance)', "success");
|
||||
updateScriptStatus('Opus库加载成功', 'success');
|
||||
|
||||
// 启用开始录音按钮
|
||||
startButton.disabled = false;
|
||||
statusLabel.textContent = "待机";
|
||||
|
||||
// 3秒后隐藏状态
|
||||
setTimeout(() => {
|
||||
const statusElement = document.getElementById('scriptStatus');
|
||||
if (statusElement) statusElement.style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
window.opusLoaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否有其他导出方式
|
||||
log("Module上可用方法: " + Object.getOwnPropertyNames(Module).filter(prop => typeof Module[prop] === 'function').join(", "));
|
||||
|
||||
if (typeof Module.onRuntimeInitialized === 'function' || typeof Module.onRuntimeInitialized === 'undefined') {
|
||||
log("Module.onRuntimeInitialized 尚未执行,等待模块初始化完成...");
|
||||
// Module可能未完成初始化,注册回调
|
||||
Module.onRuntimeInitialized = function() {
|
||||
log("Opus库运行时初始化完成,重新检查");
|
||||
checkOpusLoaded();
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error('Opus解码函数未找到,可能Module结构不正确');
|
||||
} catch (err) {
|
||||
log(`Opus库加载失败: ${err.message}`, "error");
|
||||
updateScriptStatus(`Opus库加载失败: ${err.message}`, 'error');
|
||||
statusLabel.textContent = "错误:Opus库加载失败";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后检查浏览器能力和Opus库
|
||||
window.addEventListener('load', function() {
|
||||
log("页面加载完成,开始初始化...");
|
||||
updateScriptStatus('正在初始化录音环境...', 'info');
|
||||
|
||||
// 检查浏览器能力
|
||||
const audioContextSupported = initAudioContext();
|
||||
const microphoneSupported = checkMicrophonePermission();
|
||||
|
||||
if (!audioContextSupported || !microphoneSupported) {
|
||||
log("浏览器不支持必要API,无法进行录音", "error");
|
||||
updateScriptStatus('浏览器不支持录音功能', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
log("检查环境完成,开始加载Opus库");
|
||||
updateScriptStatus('正在加载Opus库...', 'info');
|
||||
|
||||
// 延迟一小段时间再检查,确保库有时间加载
|
||||
setTimeout(function checkAndRetry() {
|
||||
if (!checkOpusLoaded()) {
|
||||
// 如果加载失败,5秒后重试
|
||||
log("Opus库加载失败,5秒后重试...");
|
||||
updateScriptStatus('Opus库加载失败,正在重试...', 'error');
|
||||
setTimeout(checkAndRetry, 5000);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// 防止按钮误操作
|
||||
document.getElementById("stop").disabled = true;
|
||||
document.getElementById("play").disabled = true;
|
||||
</script>
|
||||
<script src="./../libopus.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -452,6 +452,16 @@
|
||||
let audioBuffers = []; // 用于存储接收到的所有音频数据
|
||||
let totalAudioSize = 0; // 跟踪累积的音频大小
|
||||
|
||||
let audioBufferQueue = []; // 存储接收到的音频包
|
||||
let isAudioBuffering = false; // 是否正在缓冲音频
|
||||
let isAudioPlaying = false; // 是否正在播放音频
|
||||
const BUFFER_THRESHOLD = 3; // 缓冲包数量阈值,至少累积3个包再开始播放
|
||||
const MIN_AUDIO_DURATION = 0.1; // 最小音频长度(秒),小于这个长度的音频会被合并
|
||||
let streamingContext = null; // 音频流上下文
|
||||
const SAMPLE_RATE = 16000; // 采样率
|
||||
const CHANNELS = 1; // 声道数
|
||||
const FRAME_SIZE = 960; // 帧大小
|
||||
|
||||
// DOM元素
|
||||
const connectButton = document.getElementById('connectButton');
|
||||
const serverUrlInput = document.getElementById('serverUrl');
|
||||
@@ -526,221 +536,360 @@
|
||||
conversationDiv.scrollTop = conversationDiv.scrollHeight;
|
||||
}
|
||||
|
||||
// 播放Opus音频数据
|
||||
function playOpusData(opusBuffers) {
|
||||
if (!opusBuffers || opusBuffers.length === 0) {
|
||||
log('无效的Opus数据,无法播放', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
// 已经在播放,加入队列
|
||||
audioQueue.push(opusBuffers);
|
||||
log(`音频添加到队列,当前队列长度: ${audioQueue.length}`, 'debug');
|
||||
return;
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
|
||||
try {
|
||||
// 确保音频上下文存在
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 16000
|
||||
});
|
||||
log('创建音频上下文,采样率: 16000Hz', 'debug');
|
||||
// 开始音频缓冲过程
|
||||
function startAudioBuffering() {
|
||||
if (isAudioBuffering || isAudioPlaying) return;
|
||||
|
||||
isAudioBuffering = true;
|
||||
log("开始音频缓冲...", 'info');
|
||||
|
||||
// 先尝试初始化解码器,以便在播放时已准备好
|
||||
initOpusDecoder().catch(error => {
|
||||
log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
|
||||
// 继续缓冲,我们会在播放时再次尝试初始化
|
||||
});
|
||||
|
||||
// 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放
|
||||
setTimeout(() => {
|
||||
if (isAudioBuffering && audioBufferQueue.length > 0) {
|
||||
log(`缓冲超时,当前缓冲包数: ${audioBufferQueue.length},开始播放`, 'info');
|
||||
playBufferedAudio();
|
||||
}
|
||||
|
||||
// 确保解码器已初始化
|
||||
if (!opusDecoder) {
|
||||
try {
|
||||
// 检查ModuleInstance是否存在(本地库导出的全局变量)
|
||||
if (typeof window.ModuleInstance === 'undefined') {
|
||||
if (typeof Module !== 'undefined') {
|
||||
// 尝试使用全局Module
|
||||
window.ModuleInstance = Module;
|
||||
log('使用全局Module作为ModuleInstance', 'info');
|
||||
} else {
|
||||
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化Opus解码器 - 16kHz采样率,单声道
|
||||
opusDecoder = {
|
||||
channels: 1,
|
||||
rate: 16000,
|
||||
frameSize: 960, // 60ms @ 16kHz = 60 * 16 = 960 samples
|
||||
frameDuration: 60, // 60ms帧时长
|
||||
module: window.ModuleInstance, // 保存模块实例的引用
|
||||
|
||||
// 使用ModuleInstance提供的函数来解码
|
||||
decode_float: function (opusData) {
|
||||
try {
|
||||
const mod = this.module; // 简写引用
|
||||
|
||||
// 打印输入数据的大小
|
||||
log(`正在解码Opus数据,大小: ${opusData.length}字节`, 'debug');
|
||||
|
||||
// 为输入数据分配内存
|
||||
const inputPtr = mod._malloc(opusData.length);
|
||||
mod.HEAPU8.set(opusData, inputPtr);
|
||||
|
||||
// 为输出PCM数据分配内存 (每个样本为float)
|
||||
const pcmSamples = this.frameSize;
|
||||
const pcmPtr = mod._malloc(pcmSamples * 4); // 4字节/float
|
||||
|
||||
// 创建解码器实例
|
||||
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
||||
log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
|
||||
|
||||
const decoderPtr = mod._malloc(decoderSize);
|
||||
const err = mod._opus_decoder_init(decoderPtr, this.rate, this.channels);
|
||||
|
||||
if (err < 0) {
|
||||
throw new Error(`Opus解码器初始化失败: ${err}`);
|
||||
}
|
||||
|
||||
// 解码音频帧
|
||||
const samplesDecoded = mod._opus_decode_float(
|
||||
decoderPtr,
|
||||
inputPtr,
|
||||
opusData.length,
|
||||
pcmPtr,
|
||||
this.frameSize,
|
||||
0 // 不使用FEC
|
||||
);
|
||||
|
||||
if (samplesDecoded < 0) {
|
||||
throw new Error(`Opus解码失败,错误码: ${samplesDecoded}`);
|
||||
}
|
||||
|
||||
log(`解码成功,获得${samplesDecoded}个PCM样本`, 'debug');
|
||||
|
||||
// 从内存中复制PCM数据
|
||||
const pcmData = new Float32Array(samplesDecoded);
|
||||
for (let i = 0; i < samplesDecoded; i++) {
|
||||
pcmData[i] = mod.HEAPF32[pcmPtr / 4 + i];
|
||||
}
|
||||
|
||||
// 释放内存
|
||||
mod._free(inputPtr);
|
||||
mod._free(pcmPtr);
|
||||
mod._free(decoderPtr);
|
||||
|
||||
return pcmData;
|
||||
} catch (err) {
|
||||
log(`解码过程中出错: ${err.message}`, 'error');
|
||||
return new Float32Array(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log('Opus解码器初始化成功', 'success');
|
||||
} catch (err) {
|
||||
log(`Opus解码器初始化失败: ${err.message},尝试使用简单播放`, 'error');
|
||||
// 回退到简单播放
|
||||
simpleFallbackPlay(opusBuffers);
|
||||
isPlaying = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 解码所有Opus帧
|
||||
let pcmData = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < opusBuffers.length; i++) {
|
||||
try {
|
||||
const opusBuffer = opusBuffers[i];
|
||||
// 确保这是Uint8Array
|
||||
const opusArray = opusBuffer instanceof Uint8Array ?
|
||||
opusBuffer : new Uint8Array(opusBuffer);
|
||||
|
||||
// 解码Opus帧为PCM数据
|
||||
const decoded = opusDecoder.decode_float(opusArray);
|
||||
if (decoded && decoded.length > 0) {
|
||||
pcmData.push(decoded);
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Opus帧 #${i + 1} 解码错误: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
log(`解码完成,成功: ${successCount}/${opusBuffers.length} 帧`, 'debug');
|
||||
|
||||
if (pcmData.length === 0) {
|
||||
log('没有成功解码的帧,尝试使用简单播放', 'warning');
|
||||
simpleFallbackPlay(opusBuffers);
|
||||
isPlaying = false;
|
||||
}, 300); // 300ms超时
|
||||
|
||||
// 监控缓冲进度
|
||||
const bufferCheckInterval = setInterval(() => {
|
||||
if (!isAudioBuffering) {
|
||||
clearInterval(bufferCheckInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接所有PCM数据
|
||||
const totalSamples = pcmData.reduce((acc, arr) => acc + arr.length, 0);
|
||||
log(`总共解码出 ${totalSamples} 个PCM样本`, 'debug');
|
||||
|
||||
const combinedPCM = new Float32Array(totalSamples);
|
||||
|
||||
let offset = 0;
|
||||
for (const buffer of pcmData) {
|
||||
combinedPCM.set(buffer, offset);
|
||||
offset += buffer.length;
|
||||
|
||||
// 当累积了足够的音频包,开始播放
|
||||
if (audioBufferQueue.length >= BUFFER_THRESHOLD) {
|
||||
clearInterval(bufferCheckInterval);
|
||||
log(`已缓冲 ${audioBufferQueue.length} 个音频包,开始播放`, 'info');
|
||||
playBufferedAudio();
|
||||
}
|
||||
|
||||
// 使用Web Audio API播放PCM数据
|
||||
const audioBuffer = audioContext.createBuffer(1, combinedPCM.length, 16000);
|
||||
audioBuffer.getChannelData(0).set(combinedPCM);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
|
||||
source.onended = () => {
|
||||
isPlaying = false;
|
||||
log('音频播放完成', 'debug');
|
||||
|
||||
// 播放队列中的下一个
|
||||
if (audioQueue.length > 0) {
|
||||
const nextBuffers = audioQueue.shift();
|
||||
playOpusData(nextBuffers);
|
||||
}
|
||||
};
|
||||
|
||||
source.start();
|
||||
log(`开始播放PCM音频,时长: ${(combinedPCM.length / 16000).toFixed(2)}秒`, 'debug');
|
||||
|
||||
} catch (error) {
|
||||
log(`播放Opus音频错误: ${error.message},尝试使用简单播放`, 'error');
|
||||
simpleFallbackPlay(opusBuffers);
|
||||
isPlaying = false;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 简单播放回退方案
|
||||
function simpleFallbackPlay(opusBuffers) {
|
||||
try {
|
||||
log('使用简单播放回退方案', 'warning');
|
||||
|
||||
// 创建一个简单的"滴"声作为反馈
|
||||
if (audioContext) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(440, audioContext.currentTime); // A4音
|
||||
|
||||
const gainNode = audioContext.createGain();
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); // 设置音量为0.1
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.2); // 播放0.2秒
|
||||
|
||||
log('播放提示音作为音频播放失败的反馈', 'info');
|
||||
// 播放已缓冲的音频
|
||||
function playBufferedAudio() {
|
||||
if (isAudioPlaying || audioBufferQueue.length === 0) return;
|
||||
|
||||
isAudioPlaying = true;
|
||||
isAudioBuffering = false;
|
||||
|
||||
// 确保Opus解码器已初始化
|
||||
const initDecoderAndPlay = async () => {
|
||||
try {
|
||||
// 确保音频上下文存在
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: SAMPLE_RATE
|
||||
});
|
||||
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
|
||||
}
|
||||
|
||||
// 确保解码器已初始化
|
||||
if (!opusDecoder) {
|
||||
log('初始化Opus解码器...', 'info');
|
||||
try {
|
||||
opusDecoder = await initOpusDecoder();
|
||||
if (!opusDecoder) {
|
||||
throw new Error('解码器初始化失败');
|
||||
}
|
||||
log('Opus解码器初始化成功', 'success');
|
||||
} catch (error) {
|
||||
log('Opus解码器初始化失败: ' + error.message, 'error');
|
||||
isAudioPlaying = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建流式播放上下文
|
||||
if (!streamingContext) {
|
||||
streamingContext = {
|
||||
queue: [], // 已解码的PCM队列
|
||||
playing: false, // 是否正在播放
|
||||
endOfStream: false, // 是否收到结束信号
|
||||
source: null, // 当前音频源
|
||||
totalSamples: 0, // 累积的总样本数
|
||||
lastPlayTime: 0, // 上次播放的时间戳
|
||||
|
||||
// 将Opus数据解码为PCM
|
||||
decodeOpusFrames: async function(opusFrames) {
|
||||
if (!opusDecoder) {
|
||||
log('Opus解码器未初始化,无法解码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let decodedSamples = [];
|
||||
|
||||
for (const frame of opusFrames) {
|
||||
try {
|
||||
// 使用Opus解码器解码
|
||||
const frameData = opusDecoder.decode(frame);
|
||||
if (frameData && frameData.length > 0) {
|
||||
// 转换为Float32
|
||||
const floatData = convertInt16ToFloat32(frameData);
|
||||
decodedSamples.push(...floatData);
|
||||
}
|
||||
} catch (error) {
|
||||
log("Opus解码失败: " + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedSamples.length > 0) {
|
||||
// 添加到解码队列
|
||||
this.queue.push(...decodedSamples);
|
||||
this.totalSamples += decodedSamples.length;
|
||||
|
||||
// 如果累积了至少0.2秒的音频,开始播放
|
||||
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION;
|
||||
if (!this.playing && this.queue.length >= minSamples) {
|
||||
this.startPlaying();
|
||||
}
|
||||
} else {
|
||||
log('没有成功解码的样本', 'warning');
|
||||
}
|
||||
},
|
||||
|
||||
// 开始播放音频
|
||||
startPlaying: function() {
|
||||
if (this.playing || this.queue.length === 0) return;
|
||||
|
||||
this.playing = true;
|
||||
|
||||
// 创建新的音频缓冲区
|
||||
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒
|
||||
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.onended = () => {
|
||||
this.source = null;
|
||||
this.playing = false;
|
||||
|
||||
// 如果队列中还有数据或者缓冲区有新数据,继续播放
|
||||
if (this.queue.length > 0) {
|
||||
setTimeout(() => this.startPlaying(), 10);
|
||||
} else if (audioBufferQueue.length > 0) {
|
||||
// 缓冲区有新数据,进行解码
|
||||
const frames = [...audioBufferQueue];
|
||||
audioBufferQueue = [];
|
||||
this.decodeOpusFrames(frames);
|
||||
} else if (this.endOfStream) {
|
||||
// 流已结束且没有更多数据
|
||||
log("音频播放完成", 'info');
|
||||
isAudioPlaying = false;
|
||||
streamingContext = null;
|
||||
} else {
|
||||
// 等待更多数据
|
||||
setTimeout(() => {
|
||||
// 如果仍然没有新数据,但有更多的包到达
|
||||
if (this.queue.length === 0 && audioBufferQueue.length > 0) {
|
||||
const frames = [...audioBufferQueue];
|
||||
audioBufferQueue = [];
|
||||
this.decodeOpusFrames(frames);
|
||||
} else if (this.queue.length === 0 && audioBufferQueue.length === 0) {
|
||||
// 真的没有更多数据了
|
||||
log("音频播放完成 (超时)", 'info');
|
||||
isAudioPlaying = false;
|
||||
streamingContext = null;
|
||||
}
|
||||
}, 500); // 500ms超时
|
||||
}
|
||||
};
|
||||
|
||||
this.source.start();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 开始处理缓冲的数据
|
||||
const frames = [...audioBufferQueue];
|
||||
audioBufferQueue = []; // 清空缓冲队列
|
||||
|
||||
// 解码并播放
|
||||
await streamingContext.decodeOpusFrames(frames);
|
||||
|
||||
} catch (error) {
|
||||
log(`播放已缓冲的音频出错: ${error.message}`, 'error');
|
||||
isAudioPlaying = false;
|
||||
streamingContext = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 执行初始化和播放
|
||||
initDecoderAndPlay();
|
||||
}
|
||||
|
||||
// 将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解码器 - 确保完全初始化完成后才返回
|
||||
async function initOpusDecoder() {
|
||||
if (opusDecoder) return opusDecoder; // 已经初始化
|
||||
|
||||
try {
|
||||
// 检查ModuleInstance是否存在
|
||||
if (typeof window.ModuleInstance === 'undefined') {
|
||||
if (typeof Module !== 'undefined') {
|
||||
// 使用全局Module作为ModuleInstance
|
||||
window.ModuleInstance = Module;
|
||||
log('使用全局Module作为ModuleInstance', 'info');
|
||||
} else {
|
||||
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
|
||||
}
|
||||
}
|
||||
|
||||
const mod = window.ModuleInstance;
|
||||
|
||||
// 创建解码器对象
|
||||
opusDecoder = {
|
||||
channels: CHANNELS,
|
||||
rate: SAMPLE_RATE,
|
||||
frameSize: FRAME_SIZE,
|
||||
module: mod,
|
||||
decoderPtr: null, // 初始为null
|
||||
|
||||
// 初始化解码器
|
||||
init: function() {
|
||||
if (this.decoderPtr) return true; // 已经初始化
|
||||
|
||||
// 获取解码器大小
|
||||
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
||||
log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
|
||||
|
||||
// 分配内存
|
||||
this.decoderPtr = mod._malloc(decoderSize);
|
||||
if (!this.decoderPtr) {
|
||||
throw new Error("无法分配解码器内存");
|
||||
}
|
||||
|
||||
// 初始化解码器
|
||||
const err = mod._opus_decoder_init(
|
||||
this.decoderPtr,
|
||||
this.rate,
|
||||
this.channels
|
||||
);
|
||||
|
||||
if (err < 0) {
|
||||
this.destroy(); // 清理资源
|
||||
throw new Error(`Opus解码器初始化失败: ${err}`);
|
||||
}
|
||||
|
||||
log("Opus解码器初始化成功", 'success');
|
||||
return true;
|
||||
},
|
||||
|
||||
// 解码方法
|
||||
decode: function(opusData) {
|
||||
if (!this.decoderPtr) {
|
||||
if (!this.init()) {
|
||||
throw new Error("解码器未初始化且无法初始化");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = this.module;
|
||||
|
||||
// 为Opus数据分配内存
|
||||
const opusPtr = mod._malloc(opusData.length);
|
||||
mod.HEAPU8.set(opusData, opusPtr);
|
||||
|
||||
// 为PCM输出分配内存
|
||||
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
|
||||
|
||||
// 解码
|
||||
const decodedSamples = mod._opus_decode(
|
||||
this.decoderPtr,
|
||||
opusPtr,
|
||||
opusData.length,
|
||||
pcmPtr,
|
||||
this.frameSize,
|
||||
0 // 不使用FEC
|
||||
);
|
||||
|
||||
if (decodedSamples < 0) {
|
||||
mod._free(opusPtr);
|
||||
mod._free(pcmPtr);
|
||||
throw new Error(`Opus解码失败: ${decodedSamples}`);
|
||||
}
|
||||
|
||||
// 复制解码后的数据
|
||||
const decodedData = new Int16Array(decodedSamples);
|
||||
for (let i = 0; i < decodedSamples; i++) {
|
||||
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
|
||||
}
|
||||
|
||||
// 释放内存
|
||||
mod._free(opusPtr);
|
||||
mod._free(pcmPtr);
|
||||
|
||||
return decodedData;
|
||||
} catch (error) {
|
||||
log(`Opus解码错误: ${error.message}`, 'error');
|
||||
return new Int16Array(0);
|
||||
}
|
||||
},
|
||||
|
||||
// 销毁方法
|
||||
destroy: function() {
|
||||
if (this.decoderPtr) {
|
||||
this.module._free(this.decoderPtr);
|
||||
this.decoderPtr = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化解码器
|
||||
if (!opusDecoder.init()) {
|
||||
throw new Error("Opus解码器初始化失败");
|
||||
}
|
||||
|
||||
return opusDecoder;
|
||||
|
||||
} catch (error) {
|
||||
log(`简单播放回退方案也失败: ${error.message}`, 'error');
|
||||
log(`Opus解码器初始化失败: ${error.message}`, 'error');
|
||||
opusDecoder = null; // 重置为null,以便下次重试
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,6 +1630,14 @@
|
||||
|
||||
// 初始化Opus编码器
|
||||
initOpusEncoder();
|
||||
|
||||
// 预加载Opus解码器
|
||||
log('预加载Opus解码器...', 'info');
|
||||
initOpusDecoder().then(() => {
|
||||
log('Opus解码器预加载成功', 'success');
|
||||
}).catch(error => {
|
||||
log(`Opus解码器预加载失败: ${error.message},将在需要时重试`, 'warning');
|
||||
});
|
||||
}
|
||||
|
||||
// PCM录音处理器代码 - 会被注入到AudioWorklet中
|
||||
@@ -1647,7 +1804,6 @@
|
||||
let audioProcessor = null;
|
||||
let audioProcessorType = null;
|
||||
let audioSource = null;
|
||||
let pcmBuffers = [];
|
||||
|
||||
// 处理PCM缓冲数据
|
||||
let pcmDataBuffer = new Int16Array();
|
||||
@@ -1895,7 +2051,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 处理二进制消息
|
||||
async function handleBinaryMessage(data) {
|
||||
try {
|
||||
let arrayBuffer;
|
||||
@@ -1917,10 +2072,25 @@
|
||||
const opusData = new Uint8Array(arrayBuffer);
|
||||
|
||||
if (opusData.length > 0) {
|
||||
// 播放Opus音频
|
||||
playOpusData([opusData]);
|
||||
// 将数据添加到缓冲队列
|
||||
audioBufferQueue.push(opusData);
|
||||
|
||||
// 如果收到的是第一个音频包,开始缓冲过程
|
||||
if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) {
|
||||
startAudioBuffering();
|
||||
}
|
||||
} else {
|
||||
log('收到空音频数据帧', 'warning');
|
||||
log('收到空音频数据帧,可能是结束标志', 'warning');
|
||||
|
||||
// 如果缓冲队列中有数据且没有在播放,立即开始播放
|
||||
if (audioBufferQueue.length > 0 && !isAudioPlaying) {
|
||||
playBufferedAudio();
|
||||
}
|
||||
|
||||
// 如果正在播放,发送结束信号
|
||||
if (isAudioPlaying && streamingContext) {
|
||||
streamingContext.endOfStream = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`处理二进制消息出错: ${error.message}`, 'error');
|
||||
|
||||
Reference in New Issue
Block a user