mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 23:53:55 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
effd79b465 | ||
|
|
bc531652e9 | ||
|
|
1c5581b765 | ||
|
|
b486599ab8 |
@@ -75,16 +75,23 @@
|
||||
|
||||
- **硬件**:一套兼容 `xiaozhi-esp32`
|
||||
的硬件设备(具体型号请参考 [此处](https://rcnv1t9vps13.feishu.cn/wiki/DdgIw4BUgivWDPkhMj1cGIYCnRf))。
|
||||
- **服务器**:至少 4 核 CPU、8G 内存的电脑。
|
||||
- **固件编译**:请将后端服务的接口地址更新至 `xiaozhi-esp32` 项目中,再重新编译固件并烧录到设备上。
|
||||
|
||||
- **电脑或服务器**:至少 4 核 CPU、8G 内存的电脑。
|
||||
- **固件编译**:请将本后端服务的接口地址更新至 `xiaozhi-esp32` 项目中,再重新编译`xiaozhi-esp32`固件并烧录到设备上。
|
||||
|
||||
如果你没有esp32相关的硬件设备,但是非常想体验该项目,可以使用以下的项目让你的电脑、手机模拟成esp32设备。
|
||||
|
||||
- [小智安卓端](https://github.com/TOM88812/xiaozhi-android-client)
|
||||
- [小智电脑端](https://github.com/Huang-junsen/py-xiaozhi)
|
||||
|
||||
---
|
||||
|
||||
## 警告 ⚠️
|
||||
|
||||
本项目成立时间较短,还未通过网络安全测评,请勿在生产环境中使用。
|
||||
1、本项目为开源软件,本软件与对接的任何第三方API服务商(包括但不限于语音识别、大模型、语音合成等平台)均不存在商业合作关系,不为其服务质量及资金安全提供任何形式的担保。
|
||||
建议使用者优先选择持有相关业务牌照的服务商,并仔细阅读其服务协议及隐私政策。本软件不托管任何账户密钥、不参与资金流转、不承担充值资金损失风险。
|
||||
|
||||
如果您在公网环境中部署学习本项目,请务必在配置文件 `config.yaml` 中开启防护:
|
||||
2、本项目成立时间较短,还未通过网络安全测评,请勿在生产环境中使用。 如果您在公网环境中部署学习本项目,请务必在配置文件 `config.yaml` 中开启防护:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -302,7 +309,13 @@ TTS 性能排行:
|
||||
|
||||
### 7、更多问题,可联系我们反馈 💬
|
||||
|
||||

|
||||
我们的联系方式放在[百度网盘中,点击前往](https://pan.baidu.com/s/1x6USjvP1nTRsZ45XlJu65Q),提取码是`223y`。
|
||||
|
||||
网盘里有“硬件烧录QQ群”、“开源服务端交流群”、“产品建议联系人” 三张图片,请根据需要选择加入。
|
||||
|
||||
- 硬件烧录QQ群:适用于硬件烧录问题
|
||||
- 开源服务端交流群:适用于服务端问题
|
||||
- 产品建议联系人:适用于产品功能、产品设计等建议
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -278,7 +278,7 @@ _As of February 19, 2025, if my computer were located in Haizhu District, Guangz
|
||||
|
||||
### 7. For more questions, feel free to contact us for feedback 💬
|
||||
|
||||

|
||||
Our contact information is in [Baidu Netdisk](https://pan.baidu.com/s/1x6USjvP1nTRsZ45XlJu65Q),The extraction code is`223y`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ from config.settings import load_config, check_config_file
|
||||
from core.websocket_server import WebSocketServer
|
||||
from manager.http_server import WebUI
|
||||
from aiohttp import web
|
||||
from core.utils.util import get_local_ip
|
||||
from core.utils.util import get_local_ip, check_ffmpeg_installed
|
||||
|
||||
TAG = __name__
|
||||
|
||||
|
||||
async def main():
|
||||
check_config_file()
|
||||
check_ffmpeg_installed()
|
||||
logger = setup_logging()
|
||||
config = load_config()
|
||||
|
||||
|
||||
+20
-2
@@ -175,8 +175,10 @@ TTS:
|
||||
# 山引擎语音一定要购买花钱,起步价30元,就有100并发了。如果用免费的只有2个并发,会经常报tts错误
|
||||
# 购买服务后,购买免费的音色后,可能要等半小时左右,才能使用。
|
||||
# 地址:https://console.volcengine.com/speech/service/8
|
||||
api_url: https://openspeech.bytedance.com/api/v1/tts
|
||||
voice: BV001_streaming
|
||||
output_file: tmp/
|
||||
authorization: "Bearer;"
|
||||
appid: 你的火山引擎语音合成服务appid
|
||||
access_token: 你的火山引擎语音合成服务access_token
|
||||
cluster: volcano_tts
|
||||
@@ -305,7 +307,18 @@ TTS:
|
||||
# volume: 50
|
||||
# speech_rate: 0
|
||||
# pitch_rate: 0
|
||||
|
||||
# 添加 302.ai TTS 配置
|
||||
# token申请地址:https://dash.302.ai/
|
||||
TTS302AI:
|
||||
# 302AI语音合成服务,需要先在302平台创建账户充值,并获取密钥信息
|
||||
# 获取api_keyn路径:https://dash.302.ai/apis/list
|
||||
# 价格,$35/百万字符。火山原版¥450元/万字符
|
||||
type: doubao
|
||||
api_url: https://api.302ai.cn/doubao/tts_hd
|
||||
authorization: "Bearer "
|
||||
voice: "zh_female_wanwanxiaohe_moon_bigtts"
|
||||
output_file: tmp/
|
||||
access_token: "你的302API密钥"
|
||||
# 模块测试配置
|
||||
module_test:
|
||||
test_sentences: # 自定义测试语句
|
||||
@@ -327,4 +340,9 @@ music:
|
||||
- "我想听歌"
|
||||
- "我要听歌"
|
||||
- "放点音乐"
|
||||
music_dir: "./music" # 音乐文件存放路径
|
||||
music_dir: "./music" # 音乐文件存放路径,将从该目录及子目录下搜索音乐文件
|
||||
music_ext: # 音乐文件类型,p3格式效率最高
|
||||
- ".mp3"
|
||||
- ".wav"
|
||||
- ".p3"
|
||||
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒
|
||||
|
||||
+1
-1
@@ -234,7 +234,7 @@ class ConnectionHandler:
|
||||
current_text = full_text[processed_chars:] # 从未处理的位置开始
|
||||
|
||||
# 查找最后一个有效标点
|
||||
punctuations = ("。", "?", "!", ".", "?", "!", ";", ";", ":", ":", ",", ",")
|
||||
punctuations = ("。", "?", "!", "?", "!", ";", ";", ":", ":", ",")
|
||||
last_punct_pos = -1
|
||||
for punct in punctuations:
|
||||
pos = current_text.rfind(punct)
|
||||
|
||||
@@ -4,7 +4,10 @@ import random
|
||||
import difflib
|
||||
import re
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
import time
|
||||
from core.handle.sendAudioHandle import send_stt_message
|
||||
from core.utils import p3
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -33,6 +36,24 @@ def _find_best_match(potential_song, music_files):
|
||||
best_match = music_file
|
||||
return best_match
|
||||
|
||||
class MusicManager:
|
||||
def __init__(self, music_dir, music_ext):
|
||||
self.music_dir = Path(music_dir)
|
||||
self.music_ext = music_ext
|
||||
|
||||
def get_music_files(self):
|
||||
music_files = []
|
||||
for file in self.music_dir.rglob("*"):
|
||||
# 判断是否是文件
|
||||
if file.is_file():
|
||||
# 获取文件扩展名
|
||||
ext = file.suffix.lower()
|
||||
# 判断扩展名是否在列表中
|
||||
if ext in self.music_ext:
|
||||
# music_files.append(str(file.resolve())) # 添加绝对路径
|
||||
# 添加相对路径
|
||||
music_files.append(str(file.relative_to(self.music_dir)))
|
||||
return music_files
|
||||
|
||||
class MusicHandler:
|
||||
def __init__(self, config):
|
||||
@@ -45,10 +66,19 @@ class MusicHandler:
|
||||
self.music_config.get("music_dir", "./music") # 默认路径修改
|
||||
)
|
||||
self.music_related_keywords = self.music_config.get("music_commands", [])
|
||||
self.music_ext = self.music_config.get("music_ext", (".mp3", ".wav", ".p3"))
|
||||
self.refresh_time = self.music_config.get("refresh_time", 60)
|
||||
else:
|
||||
self.music_dir = os.path.abspath("./music")
|
||||
self.music_related_keywords = ["来一首歌", "唱一首歌", "播放音乐", "来点音乐", "背景音乐", "放首歌",
|
||||
"播放歌曲", "来点背景音乐", "我想听歌", "我要听歌", "放点音乐"]
|
||||
self.music_ext = (".mp3", ".wav", ".p3")
|
||||
self.refresh_time = 60
|
||||
|
||||
# 获取音乐文件列表
|
||||
self.music_files = MusicManager(self.music_dir, self.music_ext).get_music_files()
|
||||
self.scan_time = time.time()
|
||||
logger.bind(tag=TAG).debug(f"找到的音乐文件: {self.music_files}")
|
||||
|
||||
async def handle_music_command(self, conn, text):
|
||||
"""处理音乐播放指令"""
|
||||
@@ -57,12 +87,15 @@ class MusicHandler:
|
||||
|
||||
# 尝试匹配具体歌名
|
||||
if os.path.exists(self.music_dir):
|
||||
music_files = [f for f in os.listdir(self.music_dir) if f.endswith('.mp3')]
|
||||
logger.bind(tag=TAG).debug(f"找到的音乐文件: {music_files}")
|
||||
if time.time() - self.scan_time > self.refresh_time:
|
||||
# 刷新音乐文件列表
|
||||
self.music_files = MusicManager(self.music_dir, self.music_ext).get_music_files()
|
||||
self.scan_time = time.time()
|
||||
logger.bind(tag=TAG).debug(f"刷新的音乐文件: {self.music_files}")
|
||||
|
||||
potential_song = _extract_song_name(clean_text)
|
||||
if potential_song:
|
||||
best_match = _find_best_match(potential_song, music_files)
|
||||
best_match = _find_best_match(potential_song, self.music_files)
|
||||
if best_match:
|
||||
logger.bind(tag=TAG).info(f"找到最匹配的歌曲: {best_match}")
|
||||
await self.play_local_music(conn, specific_file=best_match)
|
||||
@@ -90,19 +123,29 @@ class MusicHandler:
|
||||
return
|
||||
selected_music = specific_file
|
||||
else:
|
||||
music_files = [f for f in os.listdir(self.music_dir) if f.endswith('.mp3')]
|
||||
if not music_files:
|
||||
if time.time() - self.scan_time > self.refresh_time:
|
||||
# 刷新音乐文件列表
|
||||
self.music_files = MusicManager(self.music_dir, self.music_ext).get_music_files()
|
||||
self.scan_time = time.time()
|
||||
logger.bind(tag=TAG).debug(f"刷新的音乐文件列表: {self.music_files}")
|
||||
|
||||
if not self.music_files:
|
||||
logger.bind(tag=TAG).error("未找到MP3音乐文件")
|
||||
return
|
||||
selected_music = random.choice(music_files)
|
||||
selected_music = random.choice(self.music_files)
|
||||
music_path = os.path.join(self.music_dir, selected_music)
|
||||
if not os.path.exists(music_path):
|
||||
logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}")
|
||||
return
|
||||
text = f"正在播放{selected_music}"
|
||||
await send_stt_message(conn, text)
|
||||
conn.tts_first_text = selected_music
|
||||
conn.tts_last_text = selected_music
|
||||
conn.llm_finish_task = True
|
||||
opus_packets, duration = conn.tts.wav_to_opus_data(music_path)
|
||||
|
||||
if music_path.endswith(".p3"):
|
||||
opus_packets, duration = p3.decode_opus_from_file(music_path)
|
||||
else:
|
||||
opus_packets, duration = conn.tts.wav_to_opus_data(music_path)
|
||||
conn.audio_play_queue.put((opus_packets, selected_music))
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -16,29 +16,37 @@ async def isLLMWantToFinish(last_text):
|
||||
|
||||
|
||||
async def sendAudioMessage(conn, audios, text):
|
||||
# 发送 tts.start
|
||||
# 发送句子开始消息
|
||||
if text == conn.tts_first_text:
|
||||
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
|
||||
conn.tts_start_speak_time = time.time()
|
||||
conn.tts_start_speak_time = time.perf_counter()
|
||||
await send_tts_message(conn, "sentence_start", text)
|
||||
|
||||
# 发送音频数据
|
||||
frame_duration = 60 # 初始帧持续时间(毫秒)
|
||||
start_time = time.time() # 记录开始时间
|
||||
for idx, opus_packet in enumerate(audios):
|
||||
# 初始化流控参数
|
||||
frame_duration = 60 # 毫秒
|
||||
start_time = time.perf_counter() # 使用高精度计时器
|
||||
play_position = 0 # 已播放的时长(毫秒)
|
||||
|
||||
for opus_packet in audios:
|
||||
if conn.client_abort:
|
||||
return
|
||||
|
||||
# 计算当前包的预期发送时间
|
||||
expected_time = start_time + idx * (frame_duration / 1000)
|
||||
current_time = time.time()
|
||||
# 如果未到预期时间则等待差值
|
||||
if current_time < expected_time:
|
||||
await asyncio.sleep(expected_time - current_time)
|
||||
expected_time = start_time + (play_position / 1000)
|
||||
current_time = time.perf_counter()
|
||||
|
||||
# 等待直到预期时间
|
||||
delay = expected_time - current_time
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# 发送音频包
|
||||
await conn.websocket.send(opus_packet)
|
||||
|
||||
play_position += frame_duration # 更新播放位置
|
||||
await send_tts_message(conn, "sentence_end", text)
|
||||
# 发送结束消息(如果是最后一个文本)
|
||||
if conn.llm_finish_task and text == conn.tts_last_text:
|
||||
await send_tts_message(conn, 'stop')
|
||||
await send_tts_message(conn, 'stop', None)
|
||||
if await isLLMWantToFinish(text):
|
||||
await conn.close()
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ class TTSProvider(TTSProviderBase):
|
||||
self.access_token = config.get("access_token")
|
||||
self.cluster = config.get("cluster")
|
||||
self.voice = config.get("voice")
|
||||
|
||||
self.host = "openspeech.bytedance.com"
|
||||
self.api_url = f"https://{self.host}/api/v1/tts"
|
||||
self.header = {"Authorization": f"Bearer;{self.access_token}"}
|
||||
self.api_url = config.get("api_url")
|
||||
self.authorization = config.get("authorization")
|
||||
self.header = {"Authorization": f"{self.authorization}{self.access_token}"}
|
||||
|
||||
def generate_filename(self, extension=".wav"):
|
||||
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import struct
|
||||
|
||||
def decode_opus_from_file(input_file):
|
||||
"""
|
||||
从p3文件中解码 Opus 数据,并返回一个 Opus 数据包的列表以及总时长。
|
||||
"""
|
||||
opus_datas = []
|
||||
total_frames = 0
|
||||
sample_rate = 16000 # 文件采样率
|
||||
frame_duration_ms = 60 # 帧时长
|
||||
frame_size = int(sample_rate * frame_duration_ms / 1000)
|
||||
|
||||
with open(input_file, 'rb') as f:
|
||||
while True:
|
||||
# 读取头部(4字节):[1字节类型,1字节保留,2字节长度]
|
||||
header = f.read(4)
|
||||
if not header:
|
||||
break
|
||||
|
||||
# 解包头部信息
|
||||
_, _, data_len = struct.unpack('>BBH', header)
|
||||
|
||||
# 根据头部指定的长度读取 Opus 数据
|
||||
opus_data = f.read(data_len)
|
||||
if len(opus_data) != data_len:
|
||||
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the file.")
|
||||
|
||||
opus_datas.append(opus_data)
|
||||
total_frames += 1
|
||||
|
||||
# 计算总时长
|
||||
total_duration = (total_frames * frame_duration_ms) / 1000.0
|
||||
return opus_datas, total_duration
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
import json
|
||||
import yaml
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_project_dir():
|
||||
@@ -114,3 +115,29 @@ def check_password(password):
|
||||
|
||||
# 如果满足所有条件,则返回True
|
||||
return True
|
||||
|
||||
def check_ffmpeg_installed():
|
||||
ffmpeg_installed = False
|
||||
try:
|
||||
# 执行ffmpeg -version命令,并捕获输出
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-version'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=True # 如果返回码非零则抛出异常
|
||||
)
|
||||
# 检查输出中是否包含版本信息(可选)
|
||||
output = result.stdout + result.stderr
|
||||
if 'ffmpeg version' in output.lower():
|
||||
ffmpeg_installed = True
|
||||
return False
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# 命令执行失败或未找到
|
||||
ffmpeg_installed = False
|
||||
if not ffmpeg_installed:
|
||||
error_msg = "您的电脑还没正确安装ffmpeg\n"
|
||||
error_msg += "\n建议您:\n"
|
||||
error_msg += "1、按照项目的安装文档,正确进入conda环境\n"
|
||||
error_msg += "2、查阅安装文档,如何在conda环境中安装ffmpeg\n"
|
||||
raise ValueError(error_msg)
|
||||
+21
-4
@@ -159,14 +159,31 @@ poetry run python app.py
|
||||
本项目使用`conda`管理依赖环境。如果不方便安装`conda`,需要根据实际的操作系统安装好`libopus`和`ffmpeg`。
|
||||
如果确定使用`conda`,则安装好后,开始执行以下命令。
|
||||
|
||||
重要提示!windows 用户,可以通过安装`Anaconda`来管理环境。安装好`Anaconda`后,在`开始`那里搜索`anaconda`相关的关键词,
|
||||
找到`Anaconda Prpmpt`,使用管理员身份运行它。如下图。
|
||||
|
||||

|
||||
|
||||
运行之后,如果你能看到命令行窗口前面有一个(base)字样,说明你成功进入了`conda`环境。那么你就可以执行以下命令了。
|
||||
|
||||

|
||||
|
||||
```
|
||||
conda remove -n xiaozhi-esp32-server --all -y
|
||||
conda create -n xiaozhi-esp32-server python=3.10 -y
|
||||
conda activate xiaozhi-esp32-server
|
||||
conda install conda-forge::libopus -y
|
||||
conda install conda-forge::ffmpeg -y
|
||||
|
||||
# 添加清华源通道
|
||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main
|
||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free
|
||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge
|
||||
|
||||
conda install libopus -y
|
||||
conda install ffmpeg -y
|
||||
```
|
||||
|
||||
请注意,以上命令,不是一股脑执行就成功的,你需要一步步执行,每一步执行完后,都检查一下输出的日志,查看是否成功。
|
||||
|
||||
## 2.安装本项目依赖
|
||||
|
||||
你先要下载本项目源码,源码可以通过`git clone`命令下载,如果你不熟悉`git clone`命令。
|
||||
@@ -179,7 +196,7 @@ conda install conda-forge::ffmpeg -y
|
||||
你需要把它重命名成`xiaozhi-esp32-server`,好了请记住这个目录,我们暂且称它为`项目目录`。
|
||||
|
||||
```
|
||||
# 使用dos或者终端,进入到你的项目目录,执行以下命令
|
||||
# 继续使用conda环境,进入到你的项目目录,执行以下命令
|
||||
conda activate xiaozhi-esp32-server
|
||||
pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
|
||||
pip install -r requirements.txt
|
||||
@@ -250,7 +267,7 @@ LLM:
|
||||
## 模型文件
|
||||
|
||||
本项目语音识别模型,默认使用`SenseVoiceSmall`模型,进行语音转文字。因为模型较大,需要独立下载,下载后把`model.pt`
|
||||
文件放在`model/SenseVoiceSmall`
|
||||
文件放在`models/SenseVoiceSmall`
|
||||
目录下。下面两个下载路线任选一个。
|
||||
|
||||
- 线路一:阿里魔塔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 781 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 588 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 363 KiB |
Reference in New Issue
Block a user