-
灵感来源-星黎
对话、语气、情绪、打断、声纹识别、记忆、MCP、视觉、幻觉、图灵测试 .....
-
开源项目-小智
虾哥开源:https://xiaozhi.me/ https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb
无硬件版:https://github.com/huangjunsen0406/py-xiaozhi?tab=readme-ov-file
华南理工大学-刘思源教授团队主导:<https://github.com/xinnan-tech/xiaozhi-esp32-server>
3. ## 整体架构
`- 🎙 ASR: 使用 FunASR 进行自动语音识别,将用户的语音转换为文本。
- 🎚 VAD: 使用 silero-vad 进行语音活动检测,以确保只处理有效的语音片段。
- 🧠 LLM: 使用 deepseek 作为大语言模型来处理用户输入并生成响应,极具性价比。
- 🔊 TTS: 使用 edge-tts Kokoro-82M ChatTTS MacOS say进行文本到语音的转换,将生成的文本响应转换为自然流畅的语音。`
-
声音的基础知识
声音的本质是一种能量波,由振动而产生的能量波,通过传输介质传输出去。我们听到的声音都是时间连续的,我们称为这种信号叫模拟信号。模拟信号需要转化为数字信号才能在计算机中使用。 4.1 ### PCM PCM(Pulse Code Modulation)即脉冲编码调制技术。PCM技术就是把声音从模拟信号转化为数字信号的技术,即对声音进行采样、量化的过程,经过PCM处理后的数据,是最原始的音频数据。
-
采样
- 采样频率:单位时间内对模拟信号的采样次数,它用赫兹(Hz)来表示。采样频率越高,声音的还原就越真实越自然,当然数据量就越大。采样频率一般共分为
22.05KHz、44.1KHz、48KHz三个等级。
- 采样频率:单位时间内对模拟信号的采样次数,它用赫兹(Hz)来表示。采样频率越高,声音的还原就越真实越自然,当然数据量就越大。采样频率一般共分为
-
量化:
- 量化精度(位深):用多少个二进制位来表示这个幅度等级。例如:16-bit的量化精度,意味着有 65,536 个可能的幅度等级。
- 量化误差:由于四舍五入产生的原始值与量化值之间的微小差异,在回放时会表现为本底噪声。位深越高,量化误差越小,动态范围越广。
-
编码
- 将离散的幅度值转换为二进制码。
-
码率/比特率
- 是指每秒传输的比特数,单位是bps(bit per second)。
- 在音频编码中,比特率表示编码器每秒产生的比特数。比特率越高,通常音质越好,但文件体积也越大;比特率越低,音质可能越差,但文件体积越小。
- 在Opus编码中,我们可以设置一个目标比特率,编码器会尝试以这个比特率来编码音频。Opus编码器支持从6kbps到510kbps的比特率。
-
原始PCM大小
- PCM数据速率 (bps) = 采样率 × 位深 × 声道数
- CD音质举例:
44,100 × 16 × 2 = 1,411,200 bps (约1.4 Mbps) - 一首5分钟的CD品质歌曲,其原始PCM数据体积约为
1.4 Mbps × 300秒 / 8 ≈ 52.9 MB
4.2. ### OPUS
Opus 是一款完全开放、免版税且功能强大的音频编解码器,Opus 可以处理各种音频应用,包括 IP 语音、视频会议、游戏内聊天,甚至远程现场音乐表演。它能够处理从低比特率窄带语音到极高品质立体声音乐的各种音频。
-
帧
- 帧:是 OPUS 编码和解码的基本数据单元。
- 帧大小:一帧所代表的时间长度。OPUS 的帧大小非常灵活,可以是 2.5ms, 5ms, 10ms, 20ms, 40ms, 60ms。
-
采样点数
- 采样点数 = 采样率 * 帧大小(秒)
- 例如:在 16kHz 采样率、60ms 帧大小下,一帧包含
16000 * 0.06 = 960个采样点。
-
目标码率
- 编码器在压缩音频或视频信号时,期望达到的平均数据速率。单位是bps(bit per second)
- 码率越高,压缩的越少,音质损失越少。反之,码率越低,压缩的越多,音质损失越多。
- 文件大小 ≈ 目标码率 × 持续时间
-
语音活动检测
-
VAD
-
模型耗时:单个音频块(30毫秒以上)在单个CPU线程上处理时间不到1毫秒
-
speech_prob的含义
- 数值范围:0.0 到 1.0 之间的浮点数
- 数值含义:值越接近1.0,表示该音频片段包含语音的可能性越大;值越接近0.0,表示该音频片段更可能是静音或噪声
-
声音时长:512 / 16000 = 0.032秒 = 32毫秒
-
张量Tensor:类型转换、归一化、自动微分`
-
如果之前有声音,但本次没有声音,且与上次有声音的时间差已经超过了静默阈值,则认为已经说完一句话
def is_vad(self, conn, opus_packet):
try:
pcm_frame = self.decoder.decode(opus_packet, 960)
conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区
# 处理缓冲区中的完整帧(每次处理512采样点)
client_have_voice = False
while len(conn.client_audio_buffer) >= 512 * 2:
# 提取前512个采样点(1024字节)
chunk = conn.client_audio_buffer[: 512 * 2]
conn.client_audio_buffer = conn.client_audio_buffer[512 * 2 :]
# 转换为模型需要的张量格式
audio_int16 = np.frombuffer(chunk, dtype=np.int16)
audio_float32 = audio_int16.astype(np.float32) / 32768.0
audio_tensor = torch.from_numpy(audio_float32)
# 检测语音活动,在推理阶段,不需要计算梯度
with torch.no_grad():
speech_prob = self.model(audio_tensor, 16000).item()
# 双阈值判断
if speech_prob >= self.vad_threshold:
is_voice = True
elif speech_prob <= self.vad_threshold_low:
is_voice = False
else:
is_voice = conn.last_is_voice
# 声音没低于最低值则延续前一个状态,判断为有声音
conn.last_is_voice = is_voice
# 更新滑动窗口
conn.client_voice_window.append(is_voice)
client_have_voice = (
conn.client_voice_window.count(True) >= self.frame_window_threshold
)
# 如果之前有声音,但本次没有声音,且与上次有声音的时间差已经超过了静默阈值,则认为已经说完一句话
if conn.client_have_voice and not client_have_voice:
stop_duration = time.time() * 1000 - conn.last_activity_time
if stop_duration >= self.silence_threshold_ms:
conn.client_voice_stop = True
if client_have_voice:
conn.client_have_voice = True
conn.last_activity_time = time.time() * 1000
return client_have_voice
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).info(f"解码错误: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing audio packet: {e}")
6. ## 声纹识别
6.1. ### 3D-Speaker
def identify_voiceprint(
self, speaker_ids: List[str], audio_bytes: bytes
) -> Tuple[str, float]:
"""
识别声纹
Args:
speaker_ids: 候选说话人ID列表
audio_bytes: 音频字节数据
Returns:
Tuple[str, float]: (识别出的说话人ID, 相似度分数)
"""
start_time = time.time()
logger.info(f"开始声纹识别流程,候选说话人数量: {len(speaker_ids)}")
audio_path = None
try:
# 简化音频验证
if len(audio_bytes) < 1000:
logger.warning("音频文件过小")
return "", 0.0
# 处理音频文件
audio_process_start = time.time()
audio_path = audio_processor.ensure_16k_wav(audio_bytes)
audio_process_time = time.time() - audio_process_start
logger.debug(f"音频文件处理完成,耗时: {audio_process_time:.3f}秒")
# 提取声纹特征
extract_start = time.time()
logger.debug("开始提取声纹特征...")
test_emb = self.extract_voiceprint(audio_path)
extract_time = time.time() - extract_start
logger.debug(f"声纹特征提取完成,耗时: {extract_time:.3f}秒")
# 获取候选声纹特征
db_query_start = time.time()
logger.debug("开始查询数据库获取候选声纹特征...")
voiceprints = voiceprint_db.get_voiceprints(speaker_ids)
db_query_time = time.time() - db_query_start
logger.debug(
f"数据库查询完成,获取到{len(voiceprints)}个声纹特征,耗时: {db_query_time:.3f}秒"
)
if not voiceprints:
logger.info("未找到候选说话人声纹")
return "", 0.0
# 计算相似度
similarity_start = time.time()
logger.debug("开始计算相似度...")
similarities = {}
for name, emb in voiceprints.items():
similarity = self.calculate_similarity(test_emb, emb)
similarities[name] = similarity
similarity_time = time.time() - similarity_start
logger.debug(
f"相似度计算完成,共计算{len(similarities)}个,耗时: {similarity_time:.3f}秒"
)
# 找到最佳匹配
if not similarities:
return "", 0.0
match_name = max(similarities, key=similarities.get)
match_score = similarities[match_name]
# 检查是否超过阈值
if match_score < self.similarity_threshold:
logger.info(
f"未识别到说话人,最高分: {match_score:.4f},阈值: {self.similarity_threshold}"
)
total_time = time.time() - start_time
logger.info(f"声纹识别流程完成,总耗时: {total_time:.3f}秒")
return "", match_score
total_time = time.time() - start_time
logger.info(
f"识别到说话人: {match_name}, 分数: {match_score:.4f}, 总耗时: {total_time:.3f}秒"
)
return match_name, match_score
except Exception as e:
total_time = time.time() - start_time
logger.error(f"声纹识别异常,总耗时: {total_time:.3f}秒,错误: {e}")
return "", 0.0
finally:
# 清理临时文件
cleanup_start = time.time()
if audio_path:
audio_processor.cleanup_temp_file(audio_path)
cleanup_time = time.time() - cleanup_start
logger.debug(f"临时文件清理完成,耗时: {cleanup_time:.3f}秒")
7. ## 语音识别
-
记忆
8.1. ### 短期记忆
-
上下文记忆
-
本地记忆
- 根据对话内容生成总结
8.2. ### 长期记忆
8.3. ### mem0
官方文档:github.com/mem0ai/mem0 docs.mem0.ai/quickstart
MCP工具:server.smithery.ai/@big-omega/…
其他记忆:github.com/letta-ai/le… github.com/NevaMind-AI…
向量信息存储在本地/tmp/qdrant
//原始输入
result = m.add("我的名字叫张三,我有一个朋友叫李四,我们都在小米工作,李四的学长王五21年毕业于清华大学,最近学长刚提了一台小米su7", user_id="test")
print(result)
//信息提取-提示词
'{"facts" : ["名字叫张三", "有一个朋友叫李四", "都在小米工作", "李四的学长王五21年毕业于清华大学", "学长刚提了一台小米su7"]}'
//结合旧的记忆,增删改记忆-LLM
{
"memory" : [ {
"id" : "0",
"text" : "名字叫张三",
"event" : "ADD"
}, {
"id" : "1",
"text" : "有一个朋友叫李四",
"event" : "ADD"
}, {
"id" : "2",
"text" : "都在小米工作",
"event" : "ADD"
}, {
"id" : "3",
"text" : "李四的学长王五21年毕业于清华大学",
"event" : "ADD"
}, {
"id" : "4",
"text" : "学长刚提了一台小米su7",
"event" : "ADD"
} ]
}
图数据库neo4j
官方平台:neo4j.com/
官方文档:neo4j.ac.cn/docs/
cypher语法:neo4j.com/docs/cypher…
本地部署:brew install neo4j brew services start neo4j
服务启动: neo4j restart --verbose
本地可视化: http://localhost:7474 用户名: neo4j 密码:12345678
//原始输入
result = m.add("我的名字叫张三,我有一个朋友叫李四,我们都在小米工作,李四的学长王五21年毕业于清华大学,最近学长刚提了一台小米su7", user_id="test")
print(result)
//提取实体-llm
{'entities': [{'entity': '张三', 'entity_type': 'Person'},
{'entity': '李四', 'entity_type': 'Person'},
{'entity': '小米', 'entity_type': 'Organization'},
{'entity': '王五', 'entity_type': 'Person'},
{'entity': '清华大学', 'entity_type': 'Organization'},
{'entity': '小米su7', 'entity_type': 'Product'}]}
//构建关系-llm
{'content': None, 'tool_calls': [{'arguments': {'entities': [
{'destination': '张三', 'relationship': 'name', 'source': 'user_id: test'},
{'destination': '李四', 'relationship': 'friend', 'source': 'user_id: test'},
{'destination': '小米', 'relationship': 'work_at', 'source': 'user_id: test'}, {'destination': '小米', 'relationship': 'work_at', 'source': '李四'},
{'destination': '清华大学', 'relationship': 'graduate_of', 'source': '王五'}, {'destination': '小米su7', 'relationship': 'purchase', 'source': '王五'}]},
'name': 'establish_relationships'}]}
with concurrent.futures.ThreadPoolExecutor() as executor:
future1 = executor.submit(self._add_to_vector_store, messages, processed_metadata, effective_filters, infer)
future2 = executor.submit(self._add_to_graph, messages, effective_filters)
concurrent.futures.wait([future1, future2])
vector_store_result = future1.result()
graph_result = future2.result()
if self.enable_graph:
return {
"results": vector_store_result,
"relations": graph_result,
}
return {"results": vector_store_result}
8.4. ### 相关提示词
-
MCP
-
mcp工具:smithery.ai/
-
MCP,全称是Model Context Protocol,模型上下文协议,由Claude母公司Anthropic于2024年11月正式 提出。MCP是一种技术协议,一种智能 体Agent开发过程中共同约定的一种规范,它标准化了应用程序向 LLM 提供上下文的方式。MCP 提供了一种将 AI 模型连接到不同数据源和工具的标准化方式。
-
MCP解决的最大痛点就是Agent开发中调用外部工具的技术门槛过高、外部函数重复编写的问题。
-
MCP也是通过Function calling来实现的,如果模型本身没有Function calling功能很难实现MCP。(可通过系统提示词间接实现)
-
大语言模型
deepseek
api接口:api-docs.deepseek.com/
流式输出:当检测到标点符号的时候截断进行文本转语音
def _get_segment_text(self):
# 合并当前全部文本并处理未分割部分
full_text = "".join(self.tts_text_buff)
current_text = full_text[self.processed_chars :] # 从未处理的位置开始
last_punct_pos = -1
# 根据是否是第一句话选择不同的标点符号集合
punctuations_to_use = (
self.first_sentence_punctuations
if self.is_first_sentence
else self.punctuations
)
for punct in punctuations_to_use:
pos = current_text.rfind(punct)
if (pos != -1 and last_punct_pos == -1) or (
pos != -1 and pos < last_punct_pos
):
last_punct_pos = pos
if last_punct_pos != -1:
segment_text_raw = current_text[: last_punct_pos + 1]
segment_text = textUtils.get_string_no_punctuation_or_emoji(
segment_text_raw
)
self.processed_chars += len(segment_text_raw) # 更新已处理字符位置
# 如果是第一句话,在找到第一个逗号后,将标志设置为False
if self.is_first_sentence:
self.is_first_sentence = False
return segment_text
elif self.tts_stop_request and current_text:
segment_text = current_text
self.is_first_sentence = True # 重置标志
return segment_text
else:
return None
11. ## 语音合成
EdgeTTS
特点:免费、本地部署
minimax
体验平台:www.minimaxi.com/audio/text-…
接入文档:platform.minimaxi.com/docs/guides…
在线调试:platform.minimaxi.com/examination…
火山
官方平台: www.volcengine.com/
开通模型: console.volcengine.com/speech/serv…
资源ID固定为:volc.service_type.10029(大模型语音合成及混音)
-
视觉模型
智普
视觉:底层调用摄像头拍照,图片分析/视频分析
def capture_photo(camera_index=0, output_path="captured_image.jpg", warmup_frames=5, delay=0.1):
"""
拍摄照片并保存
Args:
camera_index: 摄像头索引
output_path: 输出文件路径
warmup_frames: 预热帧数,用于摄像头自动曝光调整
delay: 帧间延迟时间
"""
cap = cv2.VideoCapture(camera_index)
if not cap.isOpened():
logger.error(f"无法打开摄像头 {camera_index}")
return False
try:
# 预热摄像头,让自动曝光和白平衡调整
for i in range(warmup_frames):
ret, frame = cap.read()
if not ret:
logger.warning(f"预热帧 {i + 1} 读取失败")
time.sleep(delay) # 给摄像头一点时间调整
# 实际拍摄
ret, frame = cap.read()
if ret:
cv2.imwrite(output_path, frame)
logger.info(f"照片已保存至 {output_path}")
return True
else:
logger.error("拍照失败,无法读取画面")
return False
except Exception as e:
logger.error(f"拍照过程出错: {e}")
return False
finally:
cap.release()
-
服务部署
部署流程https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97
mysql用户名root密码123456
本地启动mysql:brew services start mysql
建立连接:jdbc:mysql://localhost:3306/xiaozhi_esp32_server
本地连接:mysql -u root -p
本地启动redis:brew services start redis
redis 无密码登录
小智登录账号fangfang 密码Yff19980221
服务器密钥e3d942c8-5ce6-44ee-9586-6d62f228cdfd
启动java后端项目manger-api,jdk21
启动前端项目 manger-web
npm install
npm run serve
App running at:
- Local: http://localhost:8001/
- Network: http://10.189.87.253:8001/
启动python项目xiaozhi-server python app.py
启动测试页面 test.html
http://10.189.87.253:8002/xiaozhi/ota/
ws://10.189.87.253:8000/xiaozhi/v1/
http://10.189.87.253/
启动mcp服务-mcp-endpoint-server
250826 15:04:47[0.0.6][src.server]-INFO-智控台MCP参数配置: http://10.189.87.253:8004/mcp_endpoint/health?key=d0971a409005449cb407f8c97ebd2d39
250826 15:04:47[0.0.6][src.server]-INFO-单模块部署MCP接入点: ws://10.189.87.253:8004/mcp_endpoint/mcp/?token=Z9C/GBN7RMXqY62Oa9BSFoxbWGDKVkvyjHUOYb/Ueik%3D
启动mcp工具-mcp-calculator
export MCP_ENDPOINT=ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc
python mcp_pipe.py calculator.py
启动声纹识别服务
voiceprint-api
250827 11:34:48[0.0.4][app.core.logger]-INFO-日志系统初始化完成,级别: INFO
250827 11:34:48[0.0.4][app.core.logger]-INFO-🚀 开始: 开发环境服务启动,监听地址: 0.0.0.0:8005
250827 11:34:48[0.0.4][app.core.logger]-INFO-API文档: http://0.0.0.0:8005/voiceprint/docs
250827 11:34:48[0.0.4][app.core.logger]-INFO-============================================================
250827 11:34:48[0.0.4][app.core.logger]-INFO-声纹接口地址: http://10.189.87.253:8005/voiceprint/health?key=31ab25cd-fbf0-4168-adca-10bc6ccdcf39
250827 11:34:48[0.0.4][app.core.logger]-INFO-============================================================
启动视觉模块
250827 14:58:01[0.7.5-00000000000000][__main__]-INFO-视觉分析接口是 http://10.189.87.253:8003/mcp/vision/explain
250827 14:58:01[0.7.5-00000000000000][__main__]-INFO-Websocket地址是 ws://10.189.87.253:8000/xiaozhi/v1/
250827 14:58:01[0.7.5-00000000000000][__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
250827 14:58:01[0.7.5-00000000000000][__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
xiaozhi-py
# 使用 Homebrew 安装
brew install portaudio opus ffmpeg gfortran
brew upgrade tcl-tk
# 安装 Xcode 命令行工具(如未安装)
xcode-select --install
启动图数据库neo4j
sudo chown -R $(whoami) /opt/homebrew/var/neo4j
sudo chmod -R 755 /opt/homebrew/var/neo4j
neo4j restart --verbose