diff --git a/cache_data/input.wav b/cache_data/input.wav index 79f0c22..62a3d53 100644 Binary files a/cache_data/input.wav and b/cache_data/input.wav differ diff --git a/config.json b/config.json index 9e1d5d0..8b54049 100644 --- a/config.json +++ b/config.json @@ -26,15 +26,15 @@ }, "items": [], "source": { - "automatic_player_status": true, + "automatic_player_status": false, "automatic_player_url": "http://127.0.0.1:6000", "liveRoom": { - "enabled": true, + "enabled": false, "url": "" }, "record": { "device": "", - "enabled": true + "enabled": false }, "wake_word": "\u5c0f\u6a44\u6984", "wake_word_enabled": true, diff --git a/core/fay_core.py b/core/fay_core.py index ec4874b..0c942fc 100644 --- a/core/fay_core.py +++ b/core/fay_core.py @@ -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=''): text = '' @@ -431,6 +447,15 @@ class FeiFei: can_auto_play = False 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() @@ -454,8 +479,8 @@ class FeiFei: threading.Timer(audio_length, self.send_play_end_msg, [interact]).start() #面板播放 - if config_util.config["interact"]["playSound"]: - self.__play_sound(file_url, audio_length, interact) + # if config_util.config["interact"]["playSound"]: + # self.__play_sound(file_url, audio_length, interact) except Exception as e: print(e) diff --git a/core/recorder.py b/core/recorder.py index a6a56a2..f8a6a64 100644 --- a/core/recorder.py +++ b/core/recorder.py @@ -16,11 +16,68 @@ import tempfile import wave from core import fay_core 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: @@ -141,35 +198,45 @@ class Recorder: self.timer.cancel() # 取消之前的计时器任务 self.timer = threading.Timer(60, self.reset_wakeup_status) # 重设计时器为60秒 self.timer.start() - - #前置唤醒词模式 - elif cfg.config['source']['wake_word_type'] == 'front': - wake_word = cfg.config['source']['wake_word'] - wake_word_list = wake_word.split(',') - wake_up = False - for word in wake_word_list: - if text.startswith(word): - wake_up_word = word - wake_up = True - break - if wake_up: + + # 前置唤醒词模式(严格前置,但句首做容错) + elif cfg.config['source']['wake_word_type'] == 'front': + # 读取配置的唤醒词(支持多个) + wake_word = cfg.config['source']['wake_word'] + wake_word_list = [w.strip() for w in wake_word.split(',') if w.strip()] + + matched, wake_up_word, question = _front_wake_match(text, wake_word_list) + + if matched: util.printInfo(1, 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): - 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) - #去除唤醒词后语句 - 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 else: util.printInfo(1, 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): - 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) + self.processing = False #非唤醒模式 else: @@ -220,12 +287,8 @@ class Recorder: continue #是否可以拾音,不可以就掉弃录音 can_listen = True - #没有开唤醒,但面板或数字人正在播音时不能拾音 - if cfg.config['source']['wake_word_enabled'] == False and self.__fay.speaking == True: - 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: + if self.__fay.speaking == True: + # 只要数字人/面板在播放TTS,就禁拾音,避免把自己的声音识别成用户输入 can_listen = False if can_listen == False:#掉弃录音 @@ -234,7 +297,7 @@ class Recorder: #计算音量是否满足激活拾音 level = audioop.rms(data, 2) - if len(self.__history_data) >= 10:#保存激活前的音频,以免信息掉失 + if len(self.__history_data) >= 20:#保存激活前的音频,以免信息掉失 self.__history_data.pop(0) if len(self.__history_level) >= 500: self.__history_level.pop(0) @@ -242,12 +305,19 @@ class Recorder: self.__history_level.append(level) percentage = level / self.__MAX_LEVEL history_percentage = self.__get_history_percentage(30) + + # ===== 改进:阈值平滑变化,避免断句导致唤醒词被截断 ===== + up_alpha = 0.01 # 环境变吵:慢慢升 + down_alpha = 0.05 # 环境变安静:也不要瞬间掉 + if history_percentage > self.__dynamic_threshold: - self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * 0.0025 - elif history_percentage < self.__dynamic_threshold: - self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * 1 - - + self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * up_alpha + else: + self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * down_alpha + + # 给阈值一个下限,防止过度灵敏 + self.__dynamic_threshold = max(self.__dynamic_threshold, 0.02) + #激活拾音 if percentage > self.__dynamic_threshold: last_speaking_time = time.time() diff --git a/fay.db b/fay.db index 8b3a6a4..0830d50 100644 Binary files a/fay.db and b/fay.db differ diff --git a/gui/flask_server.py b/gui/flask_server.py index e762bcb..1b45b91 100644 --- a/gui/flask_server.py +++ b/gui/flask_server.py @@ -529,7 +529,10 @@ def chat1(): def serve_audio(filename): audio_file = os.path.join(os.getcwd(), "samples", filename) 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: return jsonify({'error': '文件未找到'}), 404 diff --git a/gui/static/js/chat1.js b/gui/static/js/chat1.js index 90588e9..0d41f26 100644 --- a/gui/static/js/chat1.js +++ b/gui/static/js/chat1.js @@ -150,6 +150,19 @@ class FayInterface { handleIncomingMessage(data) { 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); if (data.liveState !== undefined) { vueInstance.liveState = data.liveState; diff --git a/qa.csv b/qa.csv index 2fbd4bb..b824f0e 100644 --- a/qa.csv +++ b/qa.csv @@ -1,4 +1,4 @@ -你好,你好,我是小橄榄!有什么我可以帮助你的吗 +你好,你好,我是小橄榄!有什么我可以帮助你的吗 我们现在在哪里,我们现在在冕宁元升农业的展览厅,可以参观游览我们先进的油橄榄产业园区哦! 介绍一下基地,元升集团油橄榄种植基地是中国目前最大的油橄榄种植庄园,目前整个庄园面积已接近30000亩。 介绍一下元升集团,2011年,冕宁元升农业董事长林春福跟随周恩来总理的脚步,在冕宁地区开启了油橄榄庄园打造之路。经过十年的发展,冕宁元升农业目前已成为国家林业局示范基地、国家林业重点龙头企业、四川省第一种植庄园、四川省脱贫标杆企业,获得各种荣誉奖项200余项。 diff --git a/system.conf b/system.conf index c1fbc84..47a38db 100644 --- a/system.conf +++ b/system.conf @@ -52,11 +52,11 @@ lingju_api_authcode= #gpt 服务密钥(NLP多选1) https://openai.com/ #免费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.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 +gpt_model_engine=qwen/qwen3-4b:free #gpt(fastgpt)代理(可为空,填写例子:127.0.0.1:7890) proxy_config=