Merge pull request 'feat/awake-optimizing' (#2) from feat/awake-optimizing into main

Reviewed-on: #2
This commit is contained in:
陈阳 2026-01-04 23:55:47 +08:00
commit 2163c03794
9 changed files with 154 additions and 43 deletions

Binary file not shown.

View File

@ -26,15 +26,15 @@
}, },
"items": [], "items": [],
"source": { "source": {
"automatic_player_status": true, "automatic_player_status": false,
"automatic_player_url": "http://127.0.0.1:6000", "automatic_player_url": "http://127.0.0.1:6000",
"liveRoom": { "liveRoom": {
"enabled": true, "enabled": false,
"url": "" "url": ""
}, },
"record": { "record": {
"device": "", "device": "",
"enabled": true "enabled": false
}, },
"wake_word": "\u5c0f\u6a44\u6984", "wake_word": "\u5c0f\u6a44\u6984",
"wake_word_enabled": true, "wake_word_enabled": true,

View File

@ -66,6 +66,22 @@ modules = {
} }
def get_public_base():
"""
方案A返回大屏可访问的后端基址
优先用 config.json server.public_base
否则退回 cfg.fay_url:5000你原来的
"""
try:
config_util.load_config()
pb = config_util.config.get("server", {}).get("public_base", "")
if pb:
return pb.rstrip("/")
except Exception:
pass
return f"http://{cfg.fay_url}:5000"
#大语言模型回复 #大语言模型回复
def handle_chat_message(msg, username='User', observation=''): def handle_chat_message(msg, username='User', observation=''):
text = '' text = ''
@ -431,6 +447,15 @@ class FeiFei:
can_auto_play = False can_auto_play = False
self.speaking = True self.speaking = True
# ✅ 方案A把本次音频的 http url 推给大屏web端播放
http_audio = f"{get_public_base()}/audio/{os.path.basename(file_url)}"
if wsa_server.get_web_instance().is_connected(interact.data.get("user")):
wsa_server.get_web_instance().add_cmd({
"Username": interact.data.get("user"),
"audioUrl": http_audio
})
#推送远程音频 #推送远程音频
MyThread(target=self.__send_remote_device_audio, args=[file_url, interact]).start() MyThread(target=self.__send_remote_device_audio, args=[file_url, interact]).start()
@ -454,8 +479,8 @@ class FeiFei:
threading.Timer(audio_length, self.send_play_end_msg, [interact]).start() threading.Timer(audio_length, self.send_play_end_msg, [interact]).start()
#面板播放 #面板播放
if config_util.config["interact"]["playSound"]: # if config_util.config["interact"]["playSound"]:
self.__play_sound(file_url, audio_length, interact) # self.__play_sound(file_url, audio_length, interact)
except Exception as e: except Exception as e:
print(e) print(e)

View File

@ -16,11 +16,68 @@ import tempfile
import wave import wave
from core import fay_core from core import fay_core
from core import interact from core import interact
# ===== 新增:用于前置唤醒词句首容错 =====
import re
import unicodedata
# 启动时间 (秒) # 启动时间 (秒)
_ATTACK = 0.2 _ATTACK = 0.08 # ↓ 改小:让系统更早进入拾音,避免“唤醒词前半截被吃掉”
# 释放时间 (秒) # 释放时间 (秒)
_RELEASE = 0.7 _RELEASE = 0.55 # ↓ 略微缩短,避免一句话被切成两段
# ===== 新增:前置唤醒词句首规范化与匹配 =====
_PUNCS = ",。!?!?,.、:;“”\"'()[]【】<>《》-—…" # 常见中文标点
_FILLER_PREFIX = ("", "", "", "", "", "", "那个", "就是", "然后") # 常见句首语气词ASR 很容易加)
def _norm_head(s: str) -> str:
"""只做句首容错:去不可见/空白/句首标点/句首语气词,不改变正文结构。"""
if not s:
return ""
s = unicodedata.normalize("NFKC", s).strip()
# 去掉开头空白
s = re.sub(r"^\s+", "", s)
# 去掉开头标点(可重复)
s = re.sub(r"^[{}]+".format(re.escape(_PUNCS)), "", s)
# 去掉句首常见语气词(允许多次叠加)
changed = True
while changed:
changed = False
for fp in _FILLER_PREFIX:
if s.startswith(fp):
s = s[len(fp):]
s = re.sub(r"^\s+", "", s)
s = re.sub(r"^[{}]+".format(re.escape(_PUNCS)), "", s)
changed = True
break
return s
def _front_wake_match(text: str, wake_words):
"""
前置唤醒词匹配严格前置
- 唤醒词必须在规范化后的最前面
- 不允许句中唤醒
"""
t = _norm_head(text)
for w in wake_words:
w = w.strip()
if not w:
continue
# 允许:唤醒词后面紧跟空格/标点/语气助词
# 例:"小橄榄,帮我..." "小橄榄啊 帮我..."
if t.startswith(w):
rest = t[len(w):] # 去掉唤醒词,得到真正的问题
# 去掉紧随其后的标点 / 空格 / 语气助词
rest = rest.lstrip(" \t\r\n" + _PUNCS)
rest = re.sub(r"^(啊|呀|呢|吧|哈|哎|诶|欸)\s*", "", rest)
rest = rest.lstrip(" \t\r\n" + _PUNCS)
return True, w, rest
return False, None, ""
class Recorder: class Recorder:
@ -141,35 +198,45 @@ class Recorder:
self.timer.cancel() # 取消之前的计时器任务 self.timer.cancel() # 取消之前的计时器任务
self.timer = threading.Timer(60, self.reset_wakeup_status) # 重设计时器为60秒 self.timer = threading.Timer(60, self.reset_wakeup_status) # 重设计时器为60秒
self.timer.start() self.timer.start()
#前置唤醒词模式 # 前置唤醒词模式(严格前置,但句首做容错)
elif cfg.config['source']['wake_word_type'] == 'front': elif cfg.config['source']['wake_word_type'] == 'front':
wake_word = cfg.config['source']['wake_word'] # 读取配置的唤醒词(支持多个)
wake_word_list = wake_word.split(',') wake_word = cfg.config['source']['wake_word']
wake_up = False wake_word_list = [w.strip() for w in wake_word.split(',') if w.strip()]
for word in wake_word_list:
if text.startswith(word): matched, wake_up_word, question = _front_wake_match(text, wake_word_list)
wake_up_word = word
wake_up = True if matched:
break
if wake_up:
util.printInfo(1, self.username, "唤醒成功!") util.printInfo(1, self.username, "唤醒成功!")
if wsa_server.get_web_instance().is_connected(self.username): if wsa_server.get_web_instance().is_connected(self.username):
wsa_server.get_web_instance().add_cmd({"panelMsg": "唤醒成功!", "Username" : self.username , 'robot': f'http://{cfg.fay_url}:5000/robot/Listening.jpg'}) wsa_server.get_web_instance().add_cmd({"panelMsg": "唤醒成功!", "Username": self.username,
'robot': f'http://{cfg.fay_url}:5000/robot/Listening.jpg'})
if wsa_server.get_instance().is_connected(self.username): if wsa_server.get_instance().is_connected(self.username):
content = {'Topic': 'Unreal', 'Data': {'Key': 'log', 'Value': "唤醒成功!"}, 'Username' : self.username, 'robot': f'http://{cfg.fay_url}:5000/robot/Listening.jpg'} content = {'Topic': 'Unreal', 'Data': {'Key': 'log', 'Value': "唤醒成功!"},
'Username': self.username,
'robot': f'http://{cfg.fay_url}:5000/robot/Listening.jpg'}
wsa_server.get_instance().add_cmd(content) wsa_server.get_instance().add_cmd(content)
#去除唤醒词后语句
question = text#[len(wake_up_word):].lstrip() # 在识别到【前置唤醒词】后,发送“去掉唤醒词后的问题”
self.on_speaking(question) if question:
self.on_speaking(question)
else:
intt = interact.Interact("auto_play", 2, {'user': self.username, 'text': "在呢,你说?"})
self.__fay.on_interact(intt)
self.processing = False self.processing = False
else: else:
util.printInfo(1, self.username, "[!] 待唤醒!") util.printInfo(1, self.username, "[!] 待唤醒!")
if wsa_server.get_web_instance().is_connected(self.username): if wsa_server.get_web_instance().is_connected(self.username):
wsa_server.get_web_instance().add_cmd({"panelMsg": "[!] 待唤醒!", "Username" : self.username , 'robot': f'http://{cfg.fay_url}:5000/robot/Normal.jpg'}) wsa_server.get_web_instance().add_cmd({"panelMsg": "[!] 待唤醒!", "Username": self.username,
'robot': f'http://{cfg.fay_url}:5000/robot/Normal.jpg'})
if wsa_server.get_instance().is_connected(self.username): if wsa_server.get_instance().is_connected(self.username):
content = {'Topic': 'Unreal', 'Data': {'Key': 'log', 'Value': "[!] 待唤醒!"}, 'Username' : self.username, 'robot': f'http://{cfg.fay_url}:5000/robot/Normal.jpg'} content = {'Topic': 'Unreal', 'Data': {'Key': 'log', 'Value': "[!] 待唤醒!"},
'Username': self.username,
'robot': f'http://{cfg.fay_url}:5000/robot/Normal.jpg'}
wsa_server.get_instance().add_cmd(content) wsa_server.get_instance().add_cmd(content)
self.processing = False
#非唤醒模式 #非唤醒模式
else: else:
@ -220,12 +287,8 @@ class Recorder:
continue continue
#是否可以拾音,不可以就掉弃录音 #是否可以拾音,不可以就掉弃录音
can_listen = True can_listen = True
#没有开唤醒,但面板或数字人正在播音时不能拾音 if self.__fay.speaking == True:
if cfg.config['source']['wake_word_enabled'] == False and self.__fay.speaking == True: # 只要数字人/面板在播放TTS就禁拾音避免把自己的声音识别成用户输入
can_listen = False
#普通唤醒模式已经激活,并且面板或数字人正在输出声音时不能拾音
if cfg.config['source']['wake_word_enabled'] == True and cfg.config['source']['wake_word_type'] == 'common' and self.wakeup_matched == True and self.__fay.speaking == True:
can_listen = False can_listen = False
if can_listen == False:#掉弃录音 if can_listen == False:#掉弃录音
@ -234,7 +297,7 @@ class Recorder:
#计算音量是否满足激活拾音 #计算音量是否满足激活拾音
level = audioop.rms(data, 2) level = audioop.rms(data, 2)
if len(self.__history_data) >= 10:#保存激活前的音频,以免信息掉失 if len(self.__history_data) >= 20:#保存激活前的音频,以免信息掉失
self.__history_data.pop(0) self.__history_data.pop(0)
if len(self.__history_level) >= 500: if len(self.__history_level) >= 500:
self.__history_level.pop(0) self.__history_level.pop(0)
@ -242,12 +305,19 @@ class Recorder:
self.__history_level.append(level) self.__history_level.append(level)
percentage = level / self.__MAX_LEVEL percentage = level / self.__MAX_LEVEL
history_percentage = self.__get_history_percentage(30) history_percentage = self.__get_history_percentage(30)
# ===== 改进:阈值平滑变化,避免断句导致唤醒词被截断 =====
up_alpha = 0.01 # 环境变吵:慢慢升
down_alpha = 0.05 # 环境变安静:也不要瞬间掉
if history_percentage > self.__dynamic_threshold: if history_percentage > self.__dynamic_threshold:
self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * 0.0025 self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * up_alpha
elif history_percentage < self.__dynamic_threshold: else:
self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * 1 self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * down_alpha
# 给阈值一个下限,防止过度灵敏
self.__dynamic_threshold = max(self.__dynamic_threshold, 0.02)
#激活拾音 #激活拾音
if percentage > self.__dynamic_threshold: if percentage > self.__dynamic_threshold:
last_speaking_time = time.time() last_speaking_time = time.time()

BIN
fay.db

Binary file not shown.

View File

@ -529,7 +529,10 @@ def chat1():
def serve_audio(filename): def serve_audio(filename):
audio_file = os.path.join(os.getcwd(), "samples", filename) audio_file = os.path.join(os.getcwd(), "samples", filename)
if os.path.exists(audio_file): if os.path.exists(audio_file):
return send_file(audio_file) resp = send_file(audio_file)
# ✅ 推荐:避免浏览器缓存旧音频(大屏轮播/重复播放时更稳)
resp.headers["Cache-Control"] = "no-store"
return resp
else: else:
return jsonify({'error': '文件未找到'}), 404 return jsonify({'error': '文件未找到'}), 404

View File

@ -150,6 +150,19 @@ class FayInterface {
handleIncomingMessage(data) { handleIncomingMessage(data) {
const vueInstance = this.vueInstance; const vueInstance = this.vueInstance;
if (data.panelReply !== undefined) {
vueInstance.panelReply = data.panelReply.content;
// 发送消息给父窗口,并指定目标 origin必须是父组件的域名
if (window.parent) {
window.parent.postMessage(
{type: 'panelReply', data: data.panelReply.content},
'*' // 父组件的域名
);
}
}
// console.log('Incoming message:', data); // console.log('Incoming message:', data);
if (data.liveState !== undefined) { if (data.liveState !== undefined) {
vueInstance.liveState = data.liveState; vueInstance.liveState = data.liveState;

2
qa.csv
View File

@ -1,4 +1,4 @@
你好,你好,我是小橄榄!有什么我可以帮助你的吗 你好,你好我是小橄榄!有什么我可以帮助你的吗
我们现在在哪里,我们现在在冕宁元升农业的展览厅,可以参观游览我们先进的油橄榄产业园区哦! 我们现在在哪里,我们现在在冕宁元升农业的展览厅,可以参观游览我们先进的油橄榄产业园区哦!
介绍一下基地,元升集团油橄榄种植基地是中国目前最大的油橄榄种植庄园目前整个庄园面积已接近30000亩。 介绍一下基地,元升集团油橄榄种植基地是中国目前最大的油橄榄种植庄园目前整个庄园面积已接近30000亩。
介绍一下元升集团,2011年冕宁元升农业董事长林春福跟随周恩来总理的脚步在冕宁地区开启了油橄榄庄园打造之路。经过十年的发展冕宁元升农业目前已成为国家林业局示范基地、国家林业重点龙头企业、四川省第一种植庄园、四川省脱贫标杆企业获得各种荣誉奖项200余项。 介绍一下元升集团,2011年冕宁元升农业董事长林春福跟随周恩来总理的脚步在冕宁地区开启了油橄榄庄园打造之路。经过十年的发展冕宁元升农业目前已成为国家林业局示范基地、国家林业重点龙头企业、四川省第一种植庄园、四川省脱贫标杆企业获得各种荣誉奖项200余项。

1 你好,你好,我是小橄榄!有什么我可以帮助你的吗 你好 你好,我是小橄榄!有什么我可以帮助你的吗
2 我们现在在哪里,我们现在在冕宁元升农业的展览厅,可以参观游览我们先进的油橄榄产业园区哦! 我们现在在哪里 我们现在在冕宁元升农业的展览厅,可以参观游览我们先进的油橄榄产业园区哦!
3 介绍一下基地,元升集团油橄榄种植基地是中国目前最大的油橄榄种植庄园,目前整个庄园面积已接近30000亩。 介绍一下基地 元升集团油橄榄种植基地是中国目前最大的油橄榄种植庄园,目前整个庄园面积已接近30000亩。
4 介绍一下元升集团,2011年,冕宁元升农业董事长林春福跟随周恩来总理的脚步,在冕宁地区开启了油橄榄庄园打造之路。经过十年的发展,冕宁元升农业目前已成为国家林业局示范基地、国家林业重点龙头企业、四川省第一种植庄园、四川省脱贫标杆企业,获得各种荣誉奖项200余项。 介绍一下元升集团 2011年,冕宁元升农业董事长林春福跟随周恩来总理的脚步,在冕宁地区开启了油橄榄庄园打造之路。经过十年的发展,冕宁元升农业目前已成为国家林业局示范基地、国家林业重点龙头企业、四川省第一种植庄园、四川省脱贫标杆企业,获得各种荣誉奖项200余项。

View File

@ -52,11 +52,11 @@ lingju_api_authcode=
#gpt 服务密钥(NLP多选1) https://openai.com/ #gpt 服务密钥(NLP多选1) https://openai.com/
#免费key只支持gpt 3.5 若想使用其他model可到 https://api.zyai.online/register/?aff_code=MyCI 下购买申请。 #免费key只支持gpt 3.5 若想使用其他model可到 https://api.zyai.online/register/?aff_code=MyCI 下购买申请。
gpt_api_key=sk-4Spva89SGSikpacz3a70Dd081cA84c9a8dEd345f19C9BdFc gpt_api_key=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
#gpt base url 如https://api.openai.com/v1、https://rwkv.ai-creator.net/chntuned/v1、https://api.fastgpt.in/api/v1、https://api.moonshot.cn/v1 #gpt base url 如https://api.openai.com/v1、https://rwkv.ai-creator.net/chntuned/v1、https://api.fastgpt.in/api/v1、https://api.moonshot.cn/v1
gpt_base_url=https://api.zyai.online/v1 gpt_base_url=https://openrouter.ai/api/v1
#gpt model engine 如gpt-3.5-turbo、moonshot-v1-8k #gpt model engine 如gpt-3.5-turbo、moonshot-v1-8k
gpt_model_engine=gpt-3.5-turbo gpt_model_engine=qwen/qwen3-4b:free
#gpt(fastgpt)代理(可为空填写例子127.0.0.1:7890) #gpt(fastgpt)代理(可为空填写例子127.0.0.1:7890)
proxy_config= proxy_config=