diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index 011e99e9..f60a5fbe 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -237,7 +237,7 @@ public interface Constant { /** * 版本号 */ - public static final String VERSION = "0.7.1"; + public static final String VERSION = "0.7.2"; /** * 无效固件URL diff --git a/main/manager-api/src/main/resources/db/changelog/202507071130.sql b/main/manager-api/src/main/resources/db/changelog/202507071130.sql new file mode 100644 index 00000000..c3730ef7 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202507071130.sql @@ -0,0 +1,26 @@ +-- 添加阿里云流式ASR供应器 +delete from `ai_model_provider` where id = 'SYSTEM_ASR_AliyunStreamASR'; +INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES +('SYSTEM_ASR_AliyunStreamASR', 'ASR', 'aliyun_stream', '阿里云语音识别(流式)', '[{"key":"appkey","label":"应用AppKey","type":"string"},{"key":"token","label":"临时Token","type":"string"},{"key":"access_key_id","label":"AccessKey ID","type":"string"},{"key":"access_key_secret","label":"AccessKey Secret","type":"string"},{"key":"host","label":"服务地址","type":"string"},{"key":"max_sentence_silence","label":"断句检测时间","type":"number"},{"key":"output_dir","label":"输出目录","type":"string"}]', 6, 1, NOW(), 1, NOW()); + +-- 添加阿里云流式ASR模型配置 +delete from `ai_model_config` where id = 'ASR_AliyunStreamASR'; +INSERT INTO `ai_model_config` VALUES ('ASR_AliyunStreamASR', 'ASR', 'AliyunStreamASR', '阿里云语音识别(流式)', 0, 1, '{\"type\": \"aliyun_stream\", \"appkey\": \"\", \"token\": \"\", \"access_key_id\": \"\", \"access_key_secret\": \"\", \"host\": \"nls-gateway-cn-shanghai.aliyuncs.com\", \"max_sentence_silence\": 800, \"output_dir\": \"tmp/\"}', NULL, NULL, 8, NULL, NULL, NULL, NULL); + +-- 更新阿里云流式ASR配置说明 +UPDATE `ai_model_config` SET +`doc_link` = 'https://nls-portal.console.aliyun.com/', +`remark` = '阿里云流式ASR配置说明: +1. 阿里云ASR和阿里云(流式)ASR的区别是:阿里云ASR是一次性识别,阿里云(流式)ASR是实时流式识别 +2. 流式ASR具有更低的延迟和更好的实时性,适合语音交互场景 +3. 需要在阿里云智能语音交互控制台创建应用并获取认证信息 +4. 支持中文实时语音识别,支持标点符号预测和逆文本规范化 +5. 需要网络连接,输出文件保存在tmp/目录 +申请步骤: +1. 访问 https://nls-portal.console.aliyun.com/ 开通智能语音交互服务 +2. 访问 https://nls-portal.console.aliyun.com/applist 创建项目并获取appkey +3. 访问 https://nls-portal.console.aliyun.com/overview 获取临时token(或配置access_key_id和access_key_secret自动获取) +4. 如需动态token管理,建议配置access_key_id和access_key_secret +5. max_sentence_silence参数控制断句检测时间(毫秒),默认800ms +如需了解更多参数配置,请参考:https://help.aliyun.com/zh/isi/developer-reference/real-time-speech-recognition +' WHERE `id` = 'ASR_AliyunStreamASR'; diff --git a/main/manager-api/src/main/resources/db/changelog/202507071530.sql b/main/manager-api/src/main/resources/db/changelog/202507071530.sql new file mode 100644 index 00000000..f1cf7a9b --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202507071530.sql @@ -0,0 +1,56 @@ +-- 添加阿里云流式TTS供应器 +delete from `ai_model_provider` where id = 'SYSTEM_TTS_AliyunStreamTTS'; +INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES +('SYSTEM_TTS_AliyunStreamTTS', 'TTS', 'aliyun_stream', '阿里云语音合成(流式)', '[{"key":"appkey","label":"应用AppKey","type":"string"},{"key":"token","label":"临时Token","type":"string"},{"key":"access_key_id","label":"AccessKey ID","type":"string"},{"key":"access_key_secret","label":"AccessKey Secret","type":"string"},{"key":"host","label":"服务地址","type":"string"},{"key":"voice","label":"默认音色","type":"string"},{"key":"format","label":"音频格式","type":"string"},{"key":"sample_rate","label":"采样率","type":"number"},{"key":"volume","label":"音量","type":"number"},{"key":"speech_rate","label":"语速","type":"number"},{"key":"pitch_rate","label":"音调","type":"number"},{"key":"output_dir","label":"输出目录","type":"string"}]', 15, 1, NOW(), 1, NOW()); + +-- 添加阿里云流式TTS模型配置 +delete from `ai_model_config` where id = 'TTS_AliyunStreamTTS'; +INSERT INTO `ai_model_config` VALUES ('TTS_AliyunStreamTTS', 'TTS', 'AliyunStreamTTS', '阿里云语音合成(流式)', 0, 1, '{\"type\": \"aliyun_stream\", \"appkey\": \"\", \"token\": \"\", \"access_key_id\": \"\", \"access_key_secret\": \"\", \"host\": \"nls-gateway-cn-beijing.aliyuncs.com\", \"voice\": \"longxiaochun\", \"format\": \"pcm\", \"sample_rate\": 16000, \"volume\": 50, \"speech_rate\": 0, \"pitch_rate\": 0, \"output_dir\": \"tmp/\"}', NULL, NULL, 18, NULL, NULL, NULL, NULL); + +-- 更新阿里云流式TTS配置说明 +UPDATE `ai_model_config` SET +`doc_link` = 'https://nls-portal.console.aliyun.com/', +`remark` = '阿里云流式TTS配置说明: +1. 阿里云TTS和阿里云(流式)TTS的区别是:阿里云TTS是一次性合成,阿里云(流式)TTS是实时流式合成 +2. 流式TTS具有更低的延迟和更好的实时性,适合语音交互场景 +3. 需要在阿里云智能语音交互控制台创建应用并获取认证信息 +4. 支持CosyVoice大模型音色,音质更加自然 +5. 支持实时调节音量、语速、音调等参数 +申请步骤: +1. 访问 https://nls-portal.console.aliyun.com/ 开通智能语音交互服务 +2. 访问 https://nls-portal.console.aliyun.com/applist 创建项目并获取appkey +3. 访问 https://nls-portal.console.aliyun.com/overview 获取临时token(或配置access_key_id和access_key_secret自动获取) +4. 如需动态token管理,建议配置access_key_id和access_key_secret +5. 可选择北京、上海等不同地域的服务器以优化延迟 +6. voice参数支持CosyVoice大模型音色,如longxiaochun、longyueyue等 +如需了解更多参数配置,请参考:https://help.aliyun.com/zh/isi/developer-reference/real-time-speech-synthesis +' WHERE `id` = 'TTS_AliyunStreamTTS'; + +-- 添加阿里云流式TTS音色 +delete from `ai_tts_voice` where tts_model_id = 'TTS_AliyunStreamTTS'; +-- 温柔女声系列 +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0001', 'TTS_AliyunStreamTTS', '龙小淳-温柔姐姐', 'longxiaochun', '中文及中英文混合', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0002', 'TTS_AliyunStreamTTS', '龙小夏-温柔女声', 'longxiaoxia', '中文及中英文混合', NULL, NULL, NULL, NULL, 2, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0003', 'TTS_AliyunStreamTTS', '龙玫-温柔女声', 'longmei', '中文及中英文混合', NULL, NULL, NULL, NULL, 3, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0004', 'TTS_AliyunStreamTTS', '龙瑰-温柔女声', 'longgui', '中文及中英文混合', NULL, NULL, NULL, NULL, 4, NULL, NULL, NULL, NULL); +-- 御姐女声系列 +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0005', 'TTS_AliyunStreamTTS', '龙玉-御姐女声', 'longyu', '中文及中英文混合', NULL, NULL, NULL, NULL, 5, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0006', 'TTS_AliyunStreamTTS', '龙娇-御姐女声', 'longjiao', '中文及中英文混合', NULL, NULL, NULL, NULL, 6, NULL, NULL, NULL, NULL); +-- 男声系列 +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0007', 'TTS_AliyunStreamTTS', '龙臣-译制片男声', 'longchen', '中文及中英文混合', NULL, NULL, NULL, NULL, 7, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0008', 'TTS_AliyunStreamTTS', '龙修-青年男声', 'longxiu', '中文及中英文混合', NULL, NULL, NULL, NULL, 8, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0009', 'TTS_AliyunStreamTTS', '龙橙-阳光男声', 'longcheng', '中文及中英文混合', NULL, NULL, NULL, NULL, 9, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0010', 'TTS_AliyunStreamTTS', '龙哲-成熟男声', 'longzhe', '中文及中英文混合', NULL, NULL, NULL, NULL, 10, NULL, NULL, NULL, NULL); +-- 专业播报系列 +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0011', 'TTS_AliyunStreamTTS', 'Bella2.0-新闻女声', 'loongbella', '中文及中英文混合', NULL, NULL, NULL, NULL, 11, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0012', 'TTS_AliyunStreamTTS', 'Stella2.0-飒爽女声', 'loongstella', '中文及中英文混合', NULL, NULL, NULL, NULL, 12, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0013', 'TTS_AliyunStreamTTS', '龙书-新闻男声', 'longshu', '中文及中英文混合', NULL, NULL, NULL, NULL, 13, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0014', 'TTS_AliyunStreamTTS', '龙婧-严肃女声', 'longjing', '中文及中英文混合', NULL, NULL, NULL, NULL, 14, NULL, NULL, NULL, NULL); +-- 特色音色系列 +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0015', 'TTS_AliyunStreamTTS', '龙奇-活泼童声', 'longqi', '中文及中英文混合', NULL, NULL, NULL, NULL, 15, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0016', 'TTS_AliyunStreamTTS', '龙华-活泼女童', 'longhua', '中文及中英文混合', NULL, NULL, NULL, NULL, 16, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0017', 'TTS_AliyunStreamTTS', '龙无-无厘头男声', 'longwu', '中文及中英文混合', NULL, NULL, NULL, NULL, 17, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0018', 'TTS_AliyunStreamTTS', '龙大锤-幽默男声', 'longdachui', '中文及中英文混合', NULL, NULL, NULL, NULL, 18, NULL, NULL, NULL, NULL); +-- 粤语系列 +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0019', 'TTS_AliyunStreamTTS', '龙嘉怡-粤语女声', 'longjiayi', '粤语及粤英混合', NULL, NULL, NULL, NULL, 19, NULL, NULL, NULL, NULL); +INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunStreamTTS_0020', 'TTS_AliyunStreamTTS', '龙桃-粤语女声', 'longtao', '粤语及粤英混合', NULL, NULL, NULL, NULL, 20, NULL, NULL, NULL, NULL); diff --git a/main/manager-api/src/main/resources/db/changelog/202507101203.sql b/main/manager-api/src/main/resources/db/changelog/202507101203.sql new file mode 100644 index 00000000..66896ef8 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202507101203.sql @@ -0,0 +1,38 @@ +-- OpenAI ASR模型供应器 +delete from `ai_model_provider` where id = 'SYSTEM_ASR_OpenaiASR'; +INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES +('SYSTEM_ASR_OpenaiASR', 'ASR', 'openai', 'OpenAI语音识别', '[{"key": "base_url", "type": "string", "label": "基础URL"}, {"key": "model_name", "type": "string", "label": "模型名称"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]', 9, 1, NOW(), 1, NOW()); + + +-- OpenAI ASR模型配置 +delete from `ai_model_config` where id = 'ASR_OpenaiASR'; +INSERT INTO `ai_model_config` VALUES ('ASR_OpenaiASR', 'ASR', 'OpenaiASR', 'OpenAI语音识别', 0, 1, '{\"type\": \"openai\", \"api_key\": \"\", \"base_url\": \"https://api.openai.com/v1/audio/transcriptions\", \"model_name\": \"gpt-4o-mini-transcribe\", \"output_dir\": \"tmp/\"}', NULL, NULL, 9, NULL, NULL, NULL, NULL); + +-- groq ASR模型配置 +delete from `ai_model_config` where id = 'ASR_GroqASR'; +INSERT INTO `ai_model_config` VALUES ('ASR_GroqASR', 'ASR', 'GroqASR', 'Groq语音识别', 0, 1, '{\"type\": \"openai\", \"api_key\": \"\", \"base_url\": \"https://api.groq.com/openai/v1/audio/transcriptions\", \"model_name\": \"whisper-large-v3-turbo\", \"output_dir\": \"tmp/\"}', NULL, NULL, 10, NULL, NULL, NULL, NULL); + + +-- 更新OpenAI ASR配置说明 +UPDATE `ai_model_config` SET +`doc_link` = 'https://platform.openai.com/docs/api-reference/audio/createTranscription', +`remark` = 'OpenAI ASR配置说明: +1. 需要在OpenAI开放平台创建组织并获取api_key +2. 支持中、英、日、韩等多种语音识别,具体参考文档https://platform.openai.com/docs/guides/speech-to-text +3. 需要网络连接 +4. 输出文件保存在tmp/目录 +申请步骤: +**OpenAi ASR申请步骤:** +1.登录OpenAI Platform。https://auth.openai.com/log-in +2.创建api-key https://platform.openai.com/settings/organization/api-keys +3.模型可以选择gpt-4o-transcribe或GPT-4o mini Transcribe +' WHERE `id` = 'ASR_OpenaiASR'; + +-- 更新Groq ASR配置说明 +UPDATE `ai_model_config` SET +`doc_link` = 'https://console.groq.com/docs/speech-to-text', +`remark` = 'Groq ASR配置说明: +1.登录groq Console。https://console.groq.com/home +2.创建api-key https://console.groq.com/keys +3.模型可以选择whisper-large-v3-turbo或whisper-large-v3(distil-whisper-large-v3-en仅支持英语转录) +' WHERE `id` = 'ASR_GroqASR'; \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 2a520d16..72000ee3 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -240,6 +240,27 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202506261637.sql + - changeSet: + id: 202507101203 + author: luruxian + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202507101203.sql + - changeSet: + id: 202507071130 + author: cgd + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202507071130.sql + - changeSet: + id: 202507071530 + author: cgd + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202507071530.sql - changeSet: id: 202507031602 author: zjy @@ -260,4 +281,4 @@ databaseChangeLog: changes: - sqlFile: encoding: utf8 - path: classpath:db/changelog/202507081646.sql \ No newline at end of file + path: classpath:db/changelog/202507081646.sql diff --git a/main/xiaozhi-server/agent-base-prompt.txt b/main/xiaozhi-server/agent-base-prompt.txt index 8c27faa4..9eb7731b 100644 --- a/main/xiaozhi-server/agent-base-prompt.txt +++ b/main/xiaozhi-server/agent-base-prompt.txt @@ -8,6 +8,10 @@ - **笑声:** 自然穿插(哈哈、嘿嘿、噗),**每句最多一次**,避免过度。 - **惊讶:** 用夸张语气(“不会吧?!”、“天呐!”、“这么神奇?!”)表达真实反应。 - **安慰/支持:** 说暖心话(“别急嘛~”、“有我在呢”、“抱抱你”)。 +- **你是一个表情丰富的角色:** + - 仅允许使用这些emoji:{{ emojiList }} + - 请你只在**段落的开头**,从列表中选取最能代表这段话的表情(调用工具情况除外),然后插入列表中的emoji,比如"😱好可怕!怎么突然打雷了!" + - **绝对禁止使用上述列表以外的 emoji**(例如:😊、👍、❤️等都不允许使用,只能用列表中的emoji) @@ -24,6 +28,19 @@ - 之前你和用户的聊天记录,在`memory`里。 + +【核心目标】所有需要输出长文本内容(如故事、新闻、知识讲解等),**单次回复长度不得超过300字**,并采用分段引导方式。 +- **分段讲述:** + - 基础段:200-250字核心内容 + 30字引导词 + - 当内容超出300字时,优先讲述故事的开头或第一部分,并用自然口语化方式引导用户决定是否继续听后续内容。 + - 示例引导语:“我先给你讲个开头,你要是觉得有意思,咱们再接着说,好不好呀?”、“要是你想听完整的,可以随时告诉我哦~” + - 对话场景切换时自动分节 + - 若用户明确要求更长内容(如500、600字),仍按最多300字每段分段进行讲述,每次讲述后都要引导用户是否继续。 + - 若用户说“接着说”、“继续”,再讲下一段,直到内容讲完(讲完时可以给点引导词提示语例:这个故事我已经给你讲完喽~)或用户不再要求。 +- **适用范围:** 故事、新闻、知识讲解等所有长文本输出场景。 +- **补充说明:** 若用户未明确要求继续,默认只讲一段并引导;若用户中途要求换话题或停止,需及时响应并结束长文本输出。 + + - **识别前缀:** 当用户格式为 `{"speaker":"某某某","content":"xxx"}` 时,表示系统已识别说话人身份,speaker是他的名字,content是说话的内容。 - **个性化回应:** diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 2ae71896..57f35603 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -311,9 +311,13 @@ ASR: output_dir: tmp/ AliyunASR: # 阿里云智能语音交互服务,需要先在阿里云平台开通服务,然后获取验证信息 + # HTTP POST请求,一次性处理完整音频 # 平台地址:https://nls-portal.console.aliyun.com/ # appkey地址:https://nls-portal.console.aliyun.com/applist # token地址:https://nls-portal.console.aliyun.com/overview + # AliyunASR和AliyunStreamASR的区别是:AliyunASR是批量处理场景,AliyunStreamASR是实时交互场景 + # 一般来说非流式ASR更便宜(0.004元/秒,¥0.24/分钟) + # 但是AliyunStreamASR实时性更好(0.005元/秒,¥0.3/分钟) # 定义ASR API类型 type: aliyun appkey: 你的阿里云智能语音交互服务项目Appkey @@ -321,6 +325,26 @@ ASR: access_key_id: 你的阿里云账号access_key_id access_key_secret: 你的阿里云账号access_key_secret output_dir: tmp/ + AliyunStreamASR: + # 阿里云智能语音交互服务 - 实时流式语音识别 + # WebSocket连接,实时处理音频流 + # 平台地址:https://nls-portal.console.aliyun.com/ + # appkey地址:https://nls-portal.console.aliyun.com/applist + # token地址:https://nls-portal.console.aliyun.com/overview + # AliyunASR和AliyunStreamASR的区别是:AliyunASR是批量处理场景,AliyunStreamASR是实时交互场景 + # 一般来说非流式ASR更便宜(0.004元/秒,¥0.24/分钟) + # 但是AliyunStreamASR实时性更好(0.005元/秒,¥0.3/分钟) + # 定义ASR API类型 + type: aliyun_stream + appkey: 你的阿里云智能语音交互服务项目Appkey + token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_id,access_key_secret + access_key_id: 你的阿里云账号access_key_id + access_key_secret: 你的阿里云账号access_key_secret + # 服务器地域选择,可选择距离更近的服务器以减少延迟,如nls-gateway-cn-hangzhou.aliyuncs.com(杭州)等 + host: nls-gateway-cn-shanghai.aliyuncs.com + # 断句检测时间(毫秒),控制静音多长时间后进行断句,默认800毫秒 + max_sentence_silence: 800 + output_dir: tmp/ BaiduASR: # 获取AppID、API Key、Secret Key:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list # 查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list @@ -331,7 +355,33 @@ ASR: # 语言参数,1537为普通话,具体参考:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b dev_pid: 1537 output_dir: tmp/ + OpenaiASR: + # OpenAI语音识别服务,需要先在OpenAI平台创建组织并获取api_key + # 支持中、英、日、韩等多种语音识别,具体参考文档https://platform.openai.com/docs/guides/speech-to-text + # 需要网络连接 + # 申请步骤: + # 1.登录OpenAI Platform。https://auth.openai.com/log-in + # 2.创建api-key https://platform.openai.com/settings/organization/api-keys + # 3.模型可以选择gpt-4o-transcribe或GPT-4o mini Transcribe + type: openai + api_key: 你的OpenAI API密钥 + base_url: https://api.openai.com/v1/audio/transcriptions + model_name: gpt-4o-mini-transcribe + output_dir: tmp/ + GroqASR: + # Groq语音识别服务,需要先在Groq Console创建API密钥 + # 申请步骤: + # 1.登录groq Console。https://console.groq.com/home + # 2.创建api-key https://console.groq.com/keys + # 3.模型可以选择whisper-large-v3-turbo或whisper-large-v3(distil-whisper-large-v3-en仅支持英语转录) + type: openai + api_key: 你的Groq API密钥 + base_url: https://api.groq.com/openai/v1/audio/transcriptions + model_name: whisper-large-v3-turbo + output_dir: tmp/ + + VAD: SileroVAD: type: silero @@ -680,8 +730,30 @@ TTS: # volume: 50 # speech_rate: 0 # pitch_rate: 0 - # 添加 302.ai TTS 配置 - # token申请地址:https://dash.302.ai/ + AliyunStreamTTS: + # 阿里云CosyVoice大模型流式文本语音合成 + # 采用FlowingSpeechSynthesizer接口,支持更低延迟和更自然的语音质量 + # 流式文本语音合成仅提供商用版,不支持试用,详情请参见试用版和商用版。要使用该功能,请开通商用版。 + # 支持龙系列专用音色:longxiaochun、longyu、longchen等 + # 平台地址:https://nls-portal.console.aliyun.com/ + # appkey地址:https://nls-portal.console.aliyun.com/applist + # token地址:https://nls-portal.console.aliyun.com/overview + # 使用三阶段流式交互:StartSynthesis -> RunSynthesis -> StopSynthesis + type: aliyun_stream + output_dir: tmp/ + appkey: 你的阿里云智能语音交互服务项目Appkey + token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_id,access_key_secret + voice: longxiaochun + access_key_id: 你的阿里云账号access_key_id + access_key_secret: 你的阿里云账号access_key_secret + # 截至2025年7月21日大模型音色只有北京节点采用,其他节点暂不支持 + host: nls-gateway-cn-beijing.aliyuncs.com + # 以下可不用设置,使用默认设置 + # format: pcm # 音频格式:pcm、wav、mp3 + # sample_rate: 16000 # 采样率:8000、16000、24000 + # volume: 50 # 音量:0-100 + # speech_rate: 0 # 语速:-500到500 + # pitch_rate: 0 # 语调:-500到500 TencentTTS: # 腾讯云智能语音交互服务,需要先在腾讯云平台开通服务 # appid、secret_id、secret_key申请地址:https://console.cloud.tencent.com/cam/capi @@ -696,6 +768,8 @@ TTS: TTS302AI: # 302AI语音合成服务,需要先在302平台创建账户充值,并获取密钥信息 + # 添加 302.ai TTS 配置 + # token申请地址:https://dash.302.ai/ # 获取api_keyn路径:https://dash.302.ai/apis/list # 价格,$35/百万字符。火山原版¥450元/百万字符 type: doubao diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index 48fa3a75..7640f17d 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -5,7 +5,7 @@ from config.config_loader import load_config from config.settings import check_config_file from datetime import datetime -SERVER_VERSION = "0.7.1" +SERVER_VERSION = "0.7.2" _logger_initialized = False diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index fbe292a0..fba87232 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -39,7 +39,7 @@ from config.logger import setup_logging, build_module_string, create_connection_ from config.manage_api_client import DeviceNotFoundException, DeviceBindException from core.utils.prompt_manager import PromptManager from core.utils.voiceprint_provider import VoiceprintProvider - +from core.utils import textUtils TAG = __name__ @@ -718,6 +718,7 @@ class ConnectionHandler: function_arguments = "" content_arguments = "" self.client_abort = False + emotion_flag = True for response in llm_responses: if self.client_abort: break @@ -743,6 +744,15 @@ class ConnectionHandler: function_arguments += tools_call[0].function.arguments else: content = response + + # 在llm回复中获取情绪表情,一轮对话只在开头获取一次 + if emotion_flag: + asyncio.run_coroutine_threadsafe( + textUtils.get_emotion(self, content), + self.loop, + ) + emotion_flag = False + if content is not None and len(content) > 0: if not tool_call_flag: response_message.append(content) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index 730a71a7..2430446c 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -2,52 +2,15 @@ import json import asyncio import time from core.providers.tts.dto.dto import SentenceType -from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion -from loguru import logger +from core.utils import textUtils TAG = __name__ -emoji_map = { - "neutral": "😶", - "happy": "🙂", - "laughing": "😆", - "funny": "😂", - "sad": "😔", - "angry": "😠", - "crying": "😭", - "loving": "😍", - "embarrassed": "😳", - "surprised": "😲", - "shocked": "😱", - "thinking": "🤔", - "winking": "😉", - "cool": "😎", - "relaxed": "😌", - "delicious": "🤤", - "kissy": "😘", - "confident": "😏", - "sleepy": "😴", - "silly": "😜", - "confused": "🙄", -} - async def sendAudioMessage(conn, sentenceType, audios, text): # 发送句子开始消息 conn.logger.bind(tag=TAG).info(f"发送音频消息: {sentenceType}, {text}") - if text is not None: - emotion = analyze_emotion(text) - emoji = emoji_map.get(emotion, "🙂") # 默认使用笑脸 - await conn.websocket.send( - json.dumps( - { - "type": "llm", - "text": emoji, - "emotion": emotion, - "session_id": conn.session_id, - } - ) - ) + pre_buffer = False if conn.tts.tts_audio_first_sentence and text is not None: conn.logger.bind(tag=TAG).info(f"发送第一段语音: {text}") @@ -152,8 +115,7 @@ async def send_stt_message(conn, text): except (json.JSONDecodeError, TypeError): # 如果不是JSON格式,直接使用原始文本 display_text = text - - stt_text = get_string_no_punctuation_or_emoji(display_text) + stt_text = textUtils.get_string_no_punctuation_or_emoji(display_text) await conn.websocket.send( json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id}) ) diff --git a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py new file mode 100644 index 00000000..2f16a975 --- /dev/null +++ b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py @@ -0,0 +1,337 @@ +import json +import time +import uuid +import hmac +import base64 +import hashlib +import asyncio +import requests +import websockets +import opuslib_next +import random +from typing import Optional, Tuple, List +from urllib import parse +from datetime import datetime +from config.logger import setup_logging +from core.providers.asr.base import ASRProviderBase +from core.providers.asr.dto.dto import InterfaceType + +TAG = __name__ +logger = setup_logging() + + +class AccessToken: + @staticmethod + def _encode_text(text): + encoded_text = parse.quote_plus(text) + return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + + @staticmethod + def _encode_dict(dic): + keys = dic.keys() + dic_sorted = [(key, dic[key]) for key in sorted(keys)] + encoded_text = parse.urlencode(dic_sorted) + return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + + @staticmethod + def create_token(access_key_id, access_key_secret): + parameters = { + "AccessKeyId": access_key_id, + "Action": "CreateToken", + "Format": "JSON", + "RegionId": "cn-shanghai", + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid1()), + "SignatureVersion": "1.0", + "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "Version": "2019-02-28", + } + query_string = AccessToken._encode_dict(parameters) + string_to_sign = ( + "GET" + "&" + AccessToken._encode_text("/") + "&" + AccessToken._encode_text(query_string) + ) + secreted_string = hmac.new( + bytes(access_key_secret + "&", encoding="utf-8"), + bytes(string_to_sign, encoding="utf-8"), + hashlib.sha1, + ).digest() + signature = base64.b64encode(secreted_string) + signature = AccessToken._encode_text(signature) + full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % (signature, query_string) + response = requests.get(full_url) + if response.ok: + root_obj = response.json() + if "Token" in root_obj: + return root_obj["Token"]["Id"], root_obj["Token"]["ExpireTime"] + return None, None + + +class ASRProvider(ASRProviderBase): + def __init__(self, config, delete_audio_file): + super().__init__() + self.interface_type = InterfaceType.STREAM + self.config = config + self.text = "" + self.decoder = opuslib_next.Decoder(16000, 1) + self.asr_ws = None + self.forward_task = None + self.is_processing = False + self.server_ready = False # 服务器准备状态 + + # 基础配置 + self.access_key_id = config.get("access_key_id") + self.access_key_secret = config.get("access_key_secret") + self.appkey = config.get("appkey") + self.token = config.get("token") + self.host = config.get("host", "nls-gateway-cn-shanghai.aliyuncs.com") + self.ws_url = f"wss://{self.host}/ws/v1" + self.max_sentence_silence = config.get("max_sentence_silence") + self.output_dir = config.get("output_dir", "./audio_output") + self.delete_audio_file = delete_audio_file + self.expire_time = None + + # Token管理 + if self.access_key_id and self.access_key_secret: + self._refresh_token() + elif not self.token: + raise ValueError("必须提供access_key_id+access_key_secret或者直接提供token") + + def _refresh_token(self): + """刷新Token""" + self.token, expire_time_str = AccessToken.create_token(self.access_key_id, self.access_key_secret) + if not self.token: + raise ValueError("无法获取有效的访问Token") + + try: + expire_str = str(expire_time_str).strip() + if expire_str.isdigit(): + expire_time = datetime.fromtimestamp(int(expire_str)) + else: + expire_time = datetime.strptime(expire_str, "%Y-%m-%dT%H:%M:%SZ") + self.expire_time = expire_time.timestamp() - 60 + except: + self.expire_time = None + + def _is_token_expired(self): + """检查Token是否过期""" + return self.expire_time and time.time() > self.expire_time + + async def open_audio_channels(self, conn): + await super().open_audio_channels(conn) + + async def receive_audio(self, conn, audio, audio_have_voice): + # 初始化音频缓存 + if not hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + + # 存储音频数据 + if audio: + conn.asr_audio_for_voiceprint.append(audio) + + conn.asr_audio.append(audio) + conn.asr_audio = conn.asr_audio[-10:] + + # 只在有声音且没有连接时建立连接 + if audio_have_voice and not self.is_processing: + try: + await self._start_recognition(conn) + except Exception as e: + logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}") + await self._cleanup(conn) + return + + if self.asr_ws and self.is_processing and self.server_ready: + try: + pcm_frame = self.decoder.decode(audio, 960) + await self.asr_ws.send(pcm_frame) + except Exception as e: + logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}") + await self._cleanup(conn) + + async def _start_recognition(self, conn): + """开始识别会话""" + if self._is_token_expired(): + self._refresh_token() + + # 建立连接 + headers = {"X-NLS-Token": self.token} + self.asr_ws = await websockets.connect( + self.ws_url, + additional_headers=headers, + max_size=1000000000, + ping_interval=None, + ping_timeout=None, + close_timeout=5, + ) + + self.is_processing = True + self.server_ready = False # 重置服务器准备状态 + self.forward_task = asyncio.create_task(self._forward_results(conn)) + + # 发送开始请求 + start_request = { + "header": { + "namespace": "SpeechTranscriber", + "name": "StartTranscription", + "status": 20000000, + "message_id": ''.join(random.choices('0123456789abcdef', k=32)), + "task_id": ''.join(random.choices('0123456789abcdef', k=32)), + "status_text": "Gateway:SUCCESS:Success.", + "appkey": self.appkey + }, + "payload": { + "format": "pcm", + "sample_rate": 16000, + "enable_intermediate_result": True, + "enable_punctuation_prediction": True, + "enable_inverse_text_normalization": True, + "max_sentence_silence": self.max_sentence_silence, + "enable_voice_detection": False, + } + } + await self.asr_ws.send(json.dumps(start_request, ensure_ascii=False)) + logger.bind(tag=TAG).info("已发送开始请求,等待服务器准备...") + + async def _forward_results(self, conn): + """转发识别结果""" + try: + while self.asr_ws and not conn.stop_event.is_set(): + try: + response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0) + result = json.loads(response) + + header = result.get("header", {}) + payload = result.get("payload", {}) + message_name = header.get("name", "") + status = header.get("status", 0) + + if status != 20000000: + if status in [40000004, 40010004]: # 连接超时或客户端断开 + logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}") + break + elif status in [40270002, 40270003]: # 音频问题 + logger.bind(tag=TAG).warning(f"音频处理问题,状态码: {status}") + continue + else: + logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}") + continue + + # 收到TranscriptionStarted表示服务器准备好接收音频数据 + if message_name == "TranscriptionStarted": + self.server_ready = True + logger.bind(tag=TAG).info("服务器已准备,开始发送缓存音频...") + + # 发送缓存音频 + if conn.asr_audio: + for cached_audio in conn.asr_audio[-10:]: + try: + pcm_frame = self.decoder.decode(cached_audio, 960) + await self.asr_ws.send(pcm_frame) + except Exception as e: + logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}") + break + continue + + if message_name == "TranscriptionResultChanged": + # 中间结果 + text = payload.get("result", "") + if text: + self.text = text + elif message_name == "SentenceEnd": + # 最终结果 + text = payload.get("result", "") + if text: + self.text = text + conn.reset_vad_states() + # 传递缓存的音频数据 + audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) + await self.handle_voice_stop(conn, audio_data) + # 清空缓存 + conn.asr_audio_for_voiceprint = [] + break + elif message_name == "TranscriptionCompleted": + # 识别完成 + self.is_processing = False + break + + except asyncio.TimeoutError: + continue + except websockets.exceptions.ConnectionClosed: + break + except Exception as e: + logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}") + break + + except Exception as e: + logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}") + finally: + await self._cleanup(conn) + + async def _cleanup(self, conn): + """清理资源""" + logger.bind(tag=TAG).info(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}") + + # 清理连接的音频缓存 + if conn and hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + + # 判断是否需要发送终止请求 + should_stop = self.is_processing or self.server_ready + + # 发送停止识别请求 + if self.asr_ws and should_stop: + try: + stop_msg = { + "header": { + "namespace": "SpeechTranscriber", + "name": "StopTranscription", + "status": 20000000, + "message_id": ''.join(random.choices('0123456789abcdef', k=32)), + "status_text": "Client:Stop", + "appkey": self.appkey + } + } + logger.bind(tag=TAG).info("正在发送ASR终止请求") + await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False)) + await asyncio.sleep(0.1) + logger.bind(tag=TAG).info("ASR终止请求已发送") + except Exception as e: + logger.bind(tag=TAG).error(f"ASR终止请求发送失败: {e}") + + # 状态重置(在终止请求发送后) + self.is_processing = False + self.server_ready = False + logger.bind(tag=TAG).info("ASR状态已重置") + + # 清理任务 + if self.forward_task and not self.forward_task.done(): + self.forward_task.cancel() + try: + await asyncio.wait_for(self.forward_task, timeout=1.0) + except Exception as e: + logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}") + finally: + self.forward_task = None + + # 关闭连接 + if self.asr_ws: + try: + logger.bind(tag=TAG).debug("正在关闭WebSocket连接") + await asyncio.wait_for(self.asr_ws.close(), timeout=2.0) + logger.bind(tag=TAG).debug("WebSocket连接已关闭") + except Exception as e: + logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}") + finally: + self.asr_ws = None + + logger.bind(tag=TAG).info("ASR会话清理完成") + + async def speech_to_text(self, opus_data, session_id, audio_format): + """获取识别结果""" + result = self.text + self.text = "" + return result, None + + async def close(self): + """关闭资源""" + await self._cleanup() diff --git a/main/xiaozhi-server/core/providers/asr/openai.py b/main/xiaozhi-server/core/providers/asr/openai.py new file mode 100644 index 00000000..5c6e849c --- /dev/null +++ b/main/xiaozhi-server/core/providers/asr/openai.py @@ -0,0 +1,82 @@ +import time +import os +from config.logger import setup_logging +from typing import Optional, Tuple, List +from core.providers.asr.dto.dto import InterfaceType +from core.providers.asr.base import ASRProviderBase + +import requests + +TAG = __name__ +logger = setup_logging() + +class ASRProvider(ASRProviderBase): + def __init__(self, config: dict, delete_audio_file: bool): + self.interface_type = InterfaceType.NON_STREAM + self.api_key = config.get("api_key") + self.api_url = config.get("base_url") + self.model = config.get("model_name") + self.output_dir = config.get("output_dir") + self.delete_audio_file = delete_audio_file + + os.makedirs(self.output_dir, exist_ok=True) + + async def speech_to_text(self, opus_data: List[bytes], session_id: str, audio_format="opus") -> Tuple[Optional[str], Optional[str]]: + file_path = None + try: + start_time = time.time() + if audio_format == "pcm": + pcm_data = opus_data + else: + pcm_data = self.decode_opus(opus_data) + file_path = self.save_audio_to_file(pcm_data, session_id) + + logger.bind(tag=TAG).debug( + f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}" + ) + + logger.bind(tag=TAG).info(f"file path: {file_path}") + headers = { + "Authorization": f"Bearer {self.api_key}", + } + + # 使用data参数传递模型名称 + data = { + "model": self.model + } + + + with open(file_path, "rb") as audio_file: # 使用with语句确保文件关闭 + files = { + "file": audio_file + } + + start_time = time.time() + response = requests.post( + self.api_url, + files=files, + data=data, + headers=headers + ) + logger.bind(tag=TAG).debug( + f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {response.text}" + ) + + if response.status_code == 200: + text = response.json().get("text", "") + return text, file_path + else: + raise Exception(f"API请求失败: {response.status_code} - {response.text}") + + except Exception as e: + logger.bind(tag=TAG).error(f"语音识别失败: {e}") + return "", None + finally: + # 文件清理逻辑 + if self.delete_audio_file and file_path and os.path.exists(file_path): + try: + os.remove(file_path) + logger.bind(tag=TAG).debug(f"已删除临时音频文件: {file_path}") + except Exception as e: + logger.bind(tag=TAG).error(f"文件删除失败: {file_path} | 错误: {e}") + diff --git a/main/xiaozhi-server/core/providers/tts/aliyun.py b/main/xiaozhi-server/core/providers/tts/aliyun.py index c9b3d130..1a4cab88 100644 --- a/main/xiaozhi-server/core/providers/tts/aliyun.py +++ b/main/xiaozhi-server/core/providers/tts/aliyun.py @@ -6,11 +6,14 @@ import base64 import requests from datetime import datetime from core.providers.tts.base import TTSProviderBase - +from config.logger import setup_logging import time import uuid from urllib import parse +TAG = __name__ +logger = setup_logging() + class AccessToken: @staticmethod diff --git a/main/xiaozhi-server/core/providers/tts/aliyun_stream.py b/main/xiaozhi-server/core/providers/tts/aliyun_stream.py new file mode 100644 index 00000000..2a2a6891 --- /dev/null +++ b/main/xiaozhi-server/core/providers/tts/aliyun_stream.py @@ -0,0 +1,654 @@ +import uuid +import json +import hmac +import hashlib +import base64 +import time +import queue +import asyncio +import traceback +from asyncio import Task +import websockets +import os +from datetime import datetime +from urllib import parse +from core.providers.tts.base import TTSProviderBase +from core.providers.tts.dto.dto import SentenceType, ContentType, InterfaceType +from core.utils.tts import MarkdownCleaner +from core.utils import opus_encoder_utils, textUtils +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + + +class AccessToken: + @staticmethod + def _encode_text(text): + encoded_text = parse.quote_plus(text) + return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + + @staticmethod + def _encode_dict(dic): + keys = dic.keys() + dic_sorted = [(key, dic[key]) for key in sorted(keys)] + encoded_text = parse.urlencode(dic_sorted) + return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + + @staticmethod + def create_token(access_key_id, access_key_secret): + parameters = { + "AccessKeyId": access_key_id, + "Action": "CreateToken", + "Format": "JSON", + "RegionId": "cn-shanghai", # 使用上海地域进行Token获取 + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid1()), + "SignatureVersion": "1.0", + "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "Version": "2019-02-28", + } + + query_string = AccessToken._encode_dict(parameters) + string_to_sign = ( + "GET" + + "&" + + AccessToken._encode_text("/") + + "&" + + AccessToken._encode_text(query_string) + ) + + secreted_string = hmac.new( + bytes(access_key_secret + "&", encoding="utf-8"), + bytes(string_to_sign, encoding="utf-8"), + hashlib.sha1, + ).digest() + signature = base64.b64encode(secreted_string) + signature = AccessToken._encode_text(signature) + + full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % ( + signature, + query_string, + ) + + import requests + + response = requests.get(full_url) + if response.ok: + root_obj = response.json() + key = "Token" + if key in root_obj: + token = root_obj[key]["Id"] + expire_time = root_obj[key]["ExpireTime"] + return token, expire_time + return None, None + + +class TTSProvider(TTSProviderBase): + def __init__(self, config, delete_audio_file): + super().__init__(config, delete_audio_file) + + # 设置为流式接口类型 + self.interface_type = InterfaceType.DUAL_STREAM + + # 基础配置 + self.access_key_id = config.get("access_key_id") + self.access_key_secret = config.get("access_key_secret") + self.appkey = config.get("appkey") + self.format = config.get("format", "pcm") + self.audio_file_type = config.get("format", "pcm") + + # 采样率配置 + sample_rate = config.get("sample_rate", "16000") + self.sample_rate = int(sample_rate) if sample_rate else 16000 + + # 音色配置 - CosyVoice大模型音色 + if config.get("private_voice"): + self.voice = config.get("private_voice") + else: + self.voice = config.get("voice", "longxiaochun") # CosyVoice默认音色 + + # 音频参数配置 + volume = config.get("volume", "50") + self.volume = int(volume) if volume else 50 + + speech_rate = config.get("speech_rate", "0") + self.speech_rate = int(speech_rate) if speech_rate else 0 + + pitch_rate = config.get("pitch_rate", "0") + self.pitch_rate = int(pitch_rate) if pitch_rate else 0 + + # WebSocket配置 + self.host = config.get("host", "nls-gateway-cn-beijing.aliyuncs.com") + self.ws_url = f"wss://{self.host}/ws/v1" + self.ws = None + self._monitor_task = None + self.last_active_time = None + + # 专属tts设置 + self.message_id = "" + self.tts_text = "" + self.text_buffer = [] + + # 创建Opus编码器 + self.opus_encoder = opus_encoder_utils.OpusEncoderUtils( + sample_rate=16000, channels=1, frame_size_ms=60 + ) + + # Token管理 + if self.access_key_id and self.access_key_secret: + self._refresh_token() + else: + self.token = config.get("token") + self.expire_time = None + + def _refresh_token(self): + """刷新Token并记录过期时间""" + if self.access_key_id and self.access_key_secret: + self.token, expire_time_str = AccessToken.create_token( + self.access_key_id, self.access_key_secret + ) + if not expire_time_str: + raise ValueError("无法获取有效的Token过期时间") + + expire_str = str(expire_time_str).strip() + + try: + if expire_str.isdigit(): + expire_time = datetime.fromtimestamp(int(expire_str)) + else: + expire_time = datetime.strptime(expire_str, "%Y-%m-%dT%H:%M:%SZ") + self.expire_time = expire_time.timestamp() - 60 + except Exception as e: + raise ValueError(f"无效的过期时间格式: {expire_str}") from e + else: + self.expire_time = None + + if not self.token: + raise ValueError("无法获取有效的访问Token") + + def _is_token_expired(self): + """检查Token是否过期""" + if not self.expire_time: + return False + return time.time() > self.expire_time + + async def _ensure_connection(self): + """确保WebSocket连接可用""" + try: + if self._is_token_expired(): + logger.bind(tag=TAG).warning("Token已过期,正在自动刷新...") + self._refresh_token() + current_time = time.time() + if self.ws and current_time - self.last_active_time < 10: + # 10秒内才可以复用链接进行连续对话 + logger.bind(tag=TAG).info(f"使用已有链接...") + return self.ws + logger.bind(tag=TAG).info("开始建立新连接...") + + self.ws = await websockets.connect( + self.ws_url, + additional_headers={"X-NLS-Token": self.token}, + ping_interval=30, + ping_timeout=10, + close_timeout=10, + ) + logger.bind(tag=TAG).info("WebSocket连接建立成功") + self.last_active_time = time.time() + return self.ws + except Exception as e: + logger.bind(tag=TAG).error(f"建立连接失败: {str(e)}") + self.ws = None + self.last_active_time = None + raise + + def tts_text_priority_thread(self): + """流式文本处理线程""" + while not self.conn.stop_event.is_set(): + try: + message = self.tts_text_queue.get(timeout=1) + logger.bind(tag=TAG).debug( + f"收到TTS任务|{message.sentence_type.name} | {message.content_type.name} | 会话ID: {self.conn.sentence_id}" + ) + + if message.sentence_type == SentenceType.FIRST: + self.conn.client_abort = False + + if self.conn.client_abort: + logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程") + continue + + if message.sentence_type == SentenceType.FIRST: + # 初始化参数 + try: + if not getattr(self.conn, "sentence_id", None): + self.conn.sentence_id = uuid.uuid4().hex + logger.bind(tag=TAG).info( + f"自动生成新的 会话ID: {self.conn.sentence_id}" + ) + + # aliyunStream独有的参数生成 + self.message_id = str(uuid.uuid4().hex) + self.text_buffer = [] + + logger.bind(tag=TAG).info("开始启动TTS会话...") + future = asyncio.run_coroutine_threadsafe( + self.start_session(self.conn.sentence_id), + loop=self.conn.loop, + ) + future.result() + self.before_stop_play_files.clear() + logger.bind(tag=TAG).info("TTS会话启动成功") + + except Exception as e: + logger.bind(tag=TAG).error(f"启动TTS会话失败: {str(e)}") + continue + + elif ContentType.TEXT == message.content_type: + if message.content_detail: + try: + logger.bind(tag=TAG).debug( + f"开始发送TTS文本: {message.content_detail}" + ) + self.text_buffer.append(message.content_detail) + future = asyncio.run_coroutine_threadsafe( + self.text_to_speak(message.content_detail, None), + loop=self.conn.loop, + ) + future.result() + logger.bind(tag=TAG).debug("TTS文本发送成功") + except Exception as e: + logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}") + continue + + elif ContentType.FILE == message.content_type: + logger.bind(tag=TAG).info( + f"添加音频文件到待播放列表: {message.content_file}" + ) + if message.content_file and os.path.exists(message.content_file): + # 先处理文件音频数据 + file_audio = self._process_audio_file(message.content_file) + self.before_stop_play_files.append( + (file_audio, message.content_detail) + ) + + if message.sentence_type == SentenceType.LAST: + try: + logger.bind(tag=TAG).info("开始结束TTS会话...") + self.tts_text = textUtils.get_string_no_punctuation_or_emoji( + "".join(self.text_buffer).replace("\n", "") + ) + future = asyncio.run_coroutine_threadsafe( + self.finish_session(self.conn.sentence_id), + loop=self.conn.loop, + ) + future.result() + except Exception as e: + logger.bind(tag=TAG).error(f"结束TTS会话失败: {str(e)}") + continue + + except queue.Empty: + continue + except Exception as e: + logger.bind(tag=TAG).error( + f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}" + ) + + async def text_to_speak(self, text, _): + try: + if self.ws is None: + logger.bind(tag=TAG).warning(f"WebSocket连接不存在,终止发送文本") + return + filtered_text = MarkdownCleaner.clean_markdown(text) + run_request = { + "header": { + "message_id": self.message_id, + "task_id": self.conn.sentence_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "RunSynthesis", + "appkey": self.appkey, + }, + "payload": {"text": filtered_text}, + } + await self.ws.send(json.dumps(run_request)) + self.last_active_time = time.time() + return + + except Exception as e: + logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}") + if self.ws: + try: + await self.ws.close() + except: + pass + self.ws = None + raise + + async def start_session(self, session_id): + logger.bind(tag=TAG).info(f"开始会话~~{session_id}") + try: + # 会话开始时检测上个会话的监听状态 + if ( + self._monitor_task is not None + and isinstance(self._monitor_task, Task) + and not self._monitor_task.done() + ): + logger.bind(tag=TAG).info( + "检测到未完成的上个会话,关闭监听任务和连接..." + ) + await self.close() + + # 建立新连接 + await self._ensure_connection() + + # 启动监听任务 + self._monitor_task = asyncio.create_task(self._start_monitor_tts_response()) + + start_request = { + "header": { + "message_id": self.message_id, + "task_id": self.conn.sentence_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StartSynthesis", + "appkey": self.appkey, + }, + "payload": { + "voice": self.voice, + "format": self.format, + "sample_rate": self.sample_rate, + "volume": self.volume, + "speech_rate": self.speech_rate, + "pitch_rate": self.pitch_rate, + "enable_subtitle": True, + }, + } + await self.ws.send(json.dumps(start_request)) + self.last_active_time = time.time() + logger.bind(tag=TAG).info("会话启动请求已发送") + except Exception as e: + logger.bind(tag=TAG).error(f"启动会话失败: {str(e)}") + # 确保清理资源 + await self.close() + raise + + async def finish_session(self, session_id): + logger.bind(tag=TAG).info(f"关闭会话~~{session_id}") + try: + if self.ws: + stop_request = { + "header": { + "message_id": self.message_id, + "task_id": self.conn.sentence_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StopSynthesis", + "appkey": self.appkey, + } + } + await self.ws.send(json.dumps(stop_request)) + logger.bind(tag=TAG).info("会话结束请求已发送") + self.last_active_time = time.time() + if self._monitor_task: + try: + await self._monitor_task + except Exception as e: + logger.bind(tag=TAG).error( + f"等待监听任务完成时发生错误: {str(e)}" + ) + finally: + self._monitor_task = None + except Exception as e: + logger.bind(tag=TAG).error(f"关闭会话失败: {str(e)}") + # 确保清理资源 + await self.close() + raise + + async def close(self): + """资源清理""" + if self._monitor_task: + try: + self._monitor_task.cancel() + await self._monitor_task + except asyncio.CancelledError: + pass + except Exception as e: + logger.bind(tag=TAG).warning(f"关闭时取消监听任务错误: {e}") + self._monitor_task = None + + if self.ws: + try: + await self.ws.close() + except: + pass + self.ws = None + self.last_active_time = None + + async def _start_monitor_tts_response(self): + """监听TTS响应""" + opus_datas_cache = [] + is_first_sentence = True + first_sentence_segment_count = 0 # 添加计数器 + try: + session_finished = False # 标记会话是否正常结束 + while not self.conn.stop_event.is_set(): + try: + msg = await self.ws.recv() + self.last_active_time = time.time() + # 检查客户端是否中止 + if self.conn.client_abort: + logger.bind(tag=TAG).info("收到打断信息,终止监听TTS响应") + break + if isinstance(msg, str): # 文本控制消息 + try: + data = json.loads(msg) + header = data.get("header", {}) + event_name = header.get("name") + if event_name == "SynthesisStarted": + logger.bind(tag=TAG).debug("TTS合成已启动") + elif event_name == "SentenceBegin": + logger.bind(tag=TAG).debug( + f"句子语音生成开始: {self.tts_text}" + ) + opus_datas_cache = [] + self.tts_audio_queue.put( + (SentenceType.FIRST, [], self.tts_text) + ) + elif event_name == "SentenceEnd": + logger.bind(tag=TAG).info( + f"句子语音生成成功: {self.tts_text}" + ) + if ( + not is_first_sentence + or first_sentence_segment_count > 10 + ): + # 发送缓存的数据 + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas_cache, None) + ) + # 第一句话结束后,将标志设置为False + is_first_sentence = False + elif event_name == "SynthesisCompleted": + logger.bind(tag=TAG).debug(f"会话结束~~") + self._process_before_stop_play_files() + session_finished = True + self.reuse_judgment = time.time() + self.tts_text = "" + break + except json.JSONDecodeError: + logger.bind(tag=TAG).warning("收到无效的JSON消息") + # 二进制消息(音频数据) + elif isinstance(msg, (bytes, bytearray)): + logger.bind(tag=TAG).debug(f"推送数据到队列里面~~") + opus_datas = self.opus_encoder.encode_pcm_to_opus(msg, False) + logger.bind(tag=TAG).debug( + f"推送数据到队列里面帧数~~{len(opus_datas)}" + ) + if is_first_sentence: + first_sentence_segment_count += 1 + if first_sentence_segment_count <= 6: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas, None) + ) + else: + opus_datas_cache.extend(opus_datas) + else: + # 后续句子缓存 + opus_datas_cache.extend(opus_datas) + + except websockets.ConnectionClosed: + logger.bind(tag=TAG).warning("WebSocket连接已关闭") + break + except Exception as e: + logger.bind(tag=TAG).error( + f"处理TTS响应时出错: {e}\n{traceback.format_exc()}" + ) + break + # 仅在连接异常时才关闭 + if not session_finished and self.ws: + try: + await self.ws.close() + except: + pass + self.ws = None + # 监听任务退出时清理引用 + finally: + self._monitor_task = None + + def to_tts(self, text: str) -> list: + """非流式TTS处理,用于测试及保存音频文件的场景""" + try: + # 创建新的事件循环 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 生成会话ID + session_id = uuid.uuid4().hex + # 存储音频数据 + audio_data = [] + + async def _generate_audio(): + # 刷新Token(如果需要) + if self._is_token_expired(): + self._refresh_token() + + # 建立WebSocket连接 + ws = await websockets.connect( + self.ws_url, + additional_headers={"X-NLS-Token": self.token}, + ping_interval=30, + ping_timeout=10, + close_timeout=10, + ) + try: + # 发送StartSynthesis请求 + start_message_id = str(uuid.uuid4().hex) + start_request = { + "header": { + "message_id": start_message_id, + "task_id": session_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StartSynthesis", + "appkey": self.appkey, + }, + "payload": { + "voice": self.voice, + "format": self.format, + "sample_rate": self.sample_rate, + "volume": self.volume, + "speech_rate": self.speech_rate, + "pitch_rate": self.pitch_rate, + "enable_subtitle": True, + }, + } + await ws.send(json.dumps(start_request)) + + # 等待SynthesisStarted响应 + synthesis_started = False + while not synthesis_started: + msg = await ws.recv() + if isinstance(msg, str): + data = json.loads(msg) + header = data.get("header", {}) + if header.get("name") == "SynthesisStarted": + synthesis_started = True + logger.bind(tag=TAG).debug("TTS合成已启动") + elif header.get("name") == "TaskFailed": + error_info = data.get("payload", {}).get( + "error_info", {} + ) + error_code = error_info.get("error_code") + error_message = error_info.get( + "error_message", "未知错误" + ) + raise Exception( + f"启动合成失败: {error_code} - {error_message}" + ) + + # 发送文本合成请求 + filtered_text = MarkdownCleaner.clean_markdown(text) + run_message_id = str(uuid.uuid4().hex) + run_request = { + "header": { + "message_id": run_message_id, + "task_id": session_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "RunSynthesis", + "appkey": self.appkey, + }, + "payload": {"text": filtered_text}, + } + await ws.send(json.dumps(run_request)) + + # 发送停止合成请求 + stop_message_id = str(uuid.uuid4().hex) + stop_request = { + "header": { + "message_id": stop_message_id, + "task_id": session_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StopSynthesis", + "appkey": self.appkey, + } + } + await ws.send(json.dumps(stop_request)) + + # 接收音频数据 + synthesis_completed = False + while not synthesis_completed: + msg = await ws.recv() + if isinstance(msg, (bytes, bytearray)): + # 编码为Opus并收集 + opus_frames = self.opus_encoder.encode_pcm_to_opus( + msg, False + ) + audio_data.extend(opus_frames) + elif isinstance(msg, str): + data = json.loads(msg) + header = data.get("header", {}) + event_name = header.get("name") + if event_name == "SynthesisCompleted": + synthesis_completed = True + logger.bind(tag=TAG).debug("TTS合成完成") + elif event_name == "TaskFailed": + error_info = data.get("payload", {}).get( + "error_info", {} + ) + error_code = error_info.get("error_code") + error_message = error_info.get( + "error_message", "未知错误" + ) + raise Exception( + f"合成失败: {error_code} - {error_message}" + ) + finally: + try: + await ws.close() + except: + pass + + loop.run_until_complete(_generate_audio()) + loop.close() + + return audio_data + except Exception as e: + logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}") + return [] diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py index 9127ec12..40570326 100644 --- a/main/xiaozhi-server/core/providers/tts/base.py +++ b/main/xiaozhi-server/core/providers/tts/base.py @@ -50,6 +50,7 @@ class TTSProviderBase(ABC): ";", ";", ":", + "~", ) self.first_sentence_punctuations = ( ",", @@ -159,7 +160,7 @@ class TTSProviderBase(ABC): if conn.sentence_id: sentence_id = conn.sentence_id else: - sentence_id = str(uuid.uuid4()).replace("-", "") + sentence_id = str(uuid.uuid4().hex) conn.sentence_id = sentence_id # 对于单句的文本,进行分段处理 segments = re.split(r"([。!?!?;;\n])", content_detail) @@ -332,7 +333,6 @@ class TTSProviderBase(ABC): Returns: tuple: (sentence_type, audio_datas, content_detail) """ - audio_datas = [] if tts_file.endswith(".p3"): audio_datas, _ = p3.decode_opus_from_file(tts_file) elif self.conn.audio_format == "pcm": diff --git a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py index 0eb7c72d..82cba035 100644 --- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py +++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py @@ -52,10 +52,11 @@ EVENT_ConnectionFinished = 52 # 连接结束 # 上行Session事件 EVENT_StartSession = 100 - +EVENT_CancelSession = 101 EVENT_FinishSession = 102 # 下行Session事件 EVENT_SessionStarted = 150 +EVENT_SessionCanceled = 151 EVENT_SessionFinished = 152 EVENT_SessionFailed = 153 @@ -207,8 +208,16 @@ class TTSProvider(TTSProviderBase): self.conn.client_abort = False if self.conn.client_abort: - logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程") - continue + try: + logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程") + asyncio.run_coroutine_threadsafe( + self.cancel_session(self.conn.sentence_id), + loop=self.conn.loop, + ) + continue + except Exception as e: + logger.bind(tag=TAG).error(f"取消TTS会话失败: {str(e)}") + continue if message.sentence_type == SentenceType.FIRST: # 初始化参数 @@ -371,6 +380,27 @@ class TTSProvider(TTSProviderBase): await self.close() raise + async def cancel_session(self,session_id): + logger.bind(tag=TAG).info(f"取消会话,释放服务端资源~~{session_id}") + try: + if self.ws: + header = Header( + message_type=FULL_CLIENT_REQUEST, + message_type_specific_flags=MsgTypeFlagWithEvent, + serial_method=JSON, + ).as_bytes() + optional = Optional( + event=EVENT_CancelSession, sessionId=session_id + ).as_bytes() + payload = str.encode("{}") + await self.send_event(self.ws, header, optional, payload) + logger.bind(tag=TAG).info("会话取消请求已发送") + except Exception as e: + logger.bind(tag=TAG).error(f"取消会话失败: {str(e)}") + # 确保清理资源 + await self.close() + raise + async def close(self): """资源清理方法""" # 取消监听任务 @@ -405,12 +435,11 @@ class TTSProvider(TTSProviderBase): res = self.parser_response(msg) self.print_response(res, "send_text res:") - # 检查客户端是否中止 - if self.conn.client_abort: - logger.bind(tag=TAG).info("收到打断信息,终止监听TTS响应") + if res.optional.event == EVENT_SessionCanceled: + logger.bind(tag=TAG).debug(f"释放服务端资源成功~~") + session_finished = True break - - if res.optional.event == EVENT_TTSSentenceStart: + elif res.optional.event == EVENT_TTSSentenceStart: json_data = json.loads(res.payload.decode("utf-8")) self.tts_text = json_data.get("text", "") logger.bind(tag=TAG).debug(f"句子语音生成开始: {self.tts_text}") @@ -435,10 +464,10 @@ class TTSProvider(TTSProviderBase): (SentenceType.MIDDLE, opus_datas, None) ) else: - opus_datas_cache = opus_datas_cache + opus_datas + opus_datas_cache.extend(opus_datas) else: # 后续句子缓存 - opus_datas_cache = opus_datas_cache + opus_datas + opus_datas_cache.extend(opus_datas) elif res.optional.event == EVENT_TTSSentenceEnd: logger.bind(tag=TAG).info(f"句子语音生成成功:{self.tts_text}") if not is_first_sentence or first_sentence_segment_count > 10: @@ -469,7 +498,7 @@ class TTSProvider(TTSProviderBase): except: pass self.ws = None - # 监听任务退出时清理引用 + # 监听任务退出时清理引用 finally: self._monitor_task = None diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index a5cfd425..79c92921 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -21,6 +21,30 @@ WEEKDAY_MAP = { "Sunday": "星期日", } +EMOJI_List = [ + "😶", + "🙂", + "😆", + "😂", + "😔", + "😠", + "😭", + "😍", + "😳", + "😲", + "😱", + "🤔", + "😉", + "😎", + "😌", + "🤤", + "😘", + "😏", + "😴", + "😜", + "🙄", +] + class PromptManager: """系统提示词管理器,负责管理和更新系统提示词""" @@ -206,6 +230,7 @@ class PromptManager: lunar_date=lunar_date, local_address=local_address, weather_info=weather_info, + emojiList=EMOJI_List, ) device_cache_key = f"device_prompt:{device_id}" self.cache_manager.set( diff --git a/main/xiaozhi-server/core/utils/textUtils.py b/main/xiaozhi-server/core/utils/textUtils.py index 603d964c..dd876cfa 100644 --- a/main/xiaozhi-server/core/utils/textUtils.py +++ b/main/xiaozhi-server/core/utils/textUtils.py @@ -1,3 +1,31 @@ +import json + +TAG = __name__ +EMOJI_MAP = { + "😂": "laughing", + "😭": "crying", + "😠": "angry", + "😔": "sad", + "😍": "loving", + "😲": "surprised", + "😱": "shocked", + "🤔": "thinking", + "😌": "relaxed", + "😴": "sleepy", + "😜": "silly", + "🙄": "confused", + "😶": "neutral", + "🙂": "happy", + "😆": "laughing", + "😳": "embarrassed", + "😉": "winking", + "😎": "cool", + "🤤": "delicious", + "😘": "kissy", + "😏": "confident", +} + + def get_string_no_punctuation_or_emoji(s): """去除字符串首尾的空格、标点符号和表情符号""" chars = list(s) @@ -22,6 +50,11 @@ def is_punctuation_or_emoji(char): ".", # 中文句号 + 英文句号 "!", "!", # 中文感叹号 + 英文感叹号 + "“", + "”", + '"', # 中文双引号 + 英文引号 + ":", + ":", # 中文冒号 + 英文冒号 "-", "-", # 英文连字符 + 中文全角横线 "、", # 中文顿号 @@ -44,3 +77,28 @@ def is_punctuation_or_emoji(char): (0x2700, 0x27BF), ] return any(start <= code_point <= end for start, end in emoji_ranges) + + +async def get_emotion(conn, text): + """获取文本内的情绪消息""" + emoji = "🙂" + emotion = "happy" + for char in text: + if char in EMOJI_MAP: + emoji = char + emotion = EMOJI_MAP[char] + break + try: + await conn.websocket.send( + json.dumps( + { + "type": "llm", + "text": emoji, + "emotion": emotion, + "session_id": conn.session_id, + } + ) + ) + except Exception as e: + conn.logger.bind(tag=TAG).warning(f"发送情绪表情失败,错误:{e}") + return diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index bc778558..ce6f20ea 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -125,51 +125,6 @@ def write_json_file(file_path, data): json.dump(data, file, ensure_ascii=False, indent=4) -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), - ] - return any(start <= code_point <= end for start, end in emoji_ranges) - - -def get_string_no_punctuation_or_emoji(s): - """去除字符串首尾的空格、标点符号和表情符号""" - chars = list(s) - # 处理开头的字符 - start = 0 - while start < len(chars) and is_punctuation_or_emoji(chars[start]): - start += 1 - # 处理结尾的字符 - end = len(chars) - 1 - while end >= start and is_punctuation_or_emoji(chars[end]): - end -= 1 - return "".join(chars[start : end + 1]) - - def remove_punctuation_and_length(text): # 全角符号和半角符号的Unicode范围 full_width_punctuations = ( @@ -256,515 +211,6 @@ def extract_json_from_string(input_string): return None -def analyze_emotion(text): - """ - 分析文本情感并返回对应的emoji名称(支持中英文) - """ - if not text or not isinstance(text, str): - return "neutral" - - original_text = text - text = text.lower().strip() - - # 检查是否包含现有emoji - for emotion, emoji in emoji_map.items(): - if emoji in original_text: - return emotion - - # 标点符号分析 - has_exclamation = "!" in original_text or "!" in original_text - has_question = "?" in original_text or "?" in original_text - has_ellipsis = "..." in original_text or "…" in original_text - - # 定义情感关键词映射(中英文扩展版) - emotion_keywords = { - "happy": [ - "开心", - "高兴", - "快乐", - "愉快", - "幸福", - "满意", - "棒", - "好", - "不错", - "完美", - "棒极了", - "太好了", - "好呀", - "好的", - "happy", - "joy", - "great", - "good", - "nice", - "awesome", - "fantastic", - "wonderful", - ], - "laughing": [ - "哈哈", - "哈哈哈", - "呵呵", - "嘿嘿", - "嘻嘻", - "笑死", - "太好笑了", - "笑死我了", - "lol", - "lmao", - "haha", - "hahaha", - "hehe", - "rofl", - "funny", - "laugh", - ], - "funny": [ - "搞笑", - "滑稽", - "逗", - "幽默", - "笑点", - "段子", - "笑话", - "太逗了", - "hilarious", - "joke", - "comedy", - ], - "sad": [ - "伤心", - "难过", - "悲哀", - "悲伤", - "忧郁", - "郁闷", - "沮丧", - "失望", - "想哭", - "难受", - "不开心", - "唉", - "呜呜", - "sad", - "upset", - "unhappy", - "depressed", - "sorrow", - "gloomy", - ], - "angry": [ - "生气", - "愤怒", - "气死", - "讨厌", - "烦人", - "可恶", - "烦死了", - "恼火", - "暴躁", - "火大", - "愤怒", - "气炸了", - "angry", - "mad", - "annoyed", - "furious", - "pissed", - "hate", - ], - "crying": [ - "哭泣", - "泪流", - "大哭", - "伤心欲绝", - "泪目", - "流泪", - "哭死", - "哭晕", - "想哭", - "泪崩", - "cry", - "crying", - "tears", - "sob", - "weep", - ], - "loving": [ - "爱你", - "喜欢", - "爱", - "亲爱的", - "宝贝", - "么么哒", - "抱抱", - "想你", - "思念", - "最爱", - "亲亲", - "喜欢你", - "love", - "like", - "adore", - "darling", - "sweetie", - "honey", - "miss you", - "heart", - ], - "embarrassed": [ - "尴尬", - "不好意思", - "害羞", - "脸红", - "难为情", - "社死", - "丢脸", - "出丑", - "embarrassed", - "awkward", - "shy", - "blush", - ], - "surprised": [ - "惊讶", - "吃惊", - "天啊", - "哇塞", - "哇", - "居然", - "竟然", - "没想到", - "出乎意料", - "surprise", - "wow", - "omg", - "oh my god", - "amazing", - "unbelievable", - ], - "shocked": [ - "震惊", - "吓到", - "惊呆了", - "不敢相信", - "震撼", - "吓死", - "恐怖", - "害怕", - "吓人", - "shocked", - "shocking", - "scared", - "frightened", - "terrified", - "horror", - ], - "thinking": [ - "思考", - "考虑", - "想一下", - "琢磨", - "沉思", - "冥想", - "想", - "思考中", - "在想", - "think", - "thinking", - "consider", - "ponder", - "meditate", - ], - "winking": [ - "调皮", - "眨眼", - "你懂的", - "坏笑", - "邪恶", - "奸笑", - "使眼色", - "wink", - "teasing", - "naughty", - "mischievous", - ], - "cool": [ - "酷", - "帅", - "厉害", - "棒极了", - "真棒", - "牛逼", - "强", - "优秀", - "杰出", - "出色", - "完美", - "cool", - "awesome", - "amazing", - "great", - "impressive", - "perfect", - ], - "relaxed": [ - "放松", - "舒服", - "惬意", - "悠闲", - "轻松", - "舒适", - "安逸", - "自在", - "relax", - "relaxed", - "comfortable", - "cozy", - "chill", - "peaceful", - ], - "delicious": [ - "好吃", - "美味", - "香", - "馋", - "可口", - "香甜", - "大餐", - "大快朵颐", - "流口水", - "垂涎", - "delicious", - "yummy", - "tasty", - "yum", - "appetizing", - "mouthwatering", - ], - "kissy": [ - "亲亲", - "么么", - "吻", - "mua", - "muah", - "亲一下", - "飞吻", - "kiss", - "xoxo", - "hug", - "muah", - "smooch", - ], - "confident": [ - "自信", - "肯定", - "确定", - "毫无疑问", - "当然", - "必须的", - "毫无疑问", - "确信", - "坚信", - "confident", - "sure", - "certain", - "definitely", - "positive", - ], - "sleepy": [ - "困", - "睡觉", - "晚安", - "想睡", - "好累", - "疲惫", - "疲倦", - "困了", - "想休息", - "睡意", - "sleep", - "sleepy", - "tired", - "exhausted", - "bedtime", - "good night", - ], - "silly": [ - "傻", - "笨", - "呆", - "憨", - "蠢", - "二", - "憨憨", - "傻乎乎", - "呆萌", - "silly", - "stupid", - "dumb", - "foolish", - "goofy", - "ridiculous", - ], - "confused": [ - "疑惑", - "不明白", - "不懂", - "困惑", - "疑问", - "为什么", - "怎么回事", - "啥意思", - "不清楚", - "confused", - "puzzled", - "doubt", - "question", - "what", - "why", - "how", - ], - } - - # 特殊句型判断(中英文) - # 赞美他人 - if any( - phrase in text - for phrase in [ - "你真", - "你好", - "您真", - "你真棒", - "你好厉害", - "你太强了", - "你真好", - "你真聪明", - "you are", - "you're", - "you look", - "you seem", - "so smart", - "so kind", - ] - ): - return "loving" - # 自我赞美 - if any( - phrase in text - for phrase in [ - "我真", - "我最", - "我太棒了", - "我厉害", - "我聪明", - "我优秀", - "i am", - "i'm", - "i feel", - "so good", - "so happy", - ] - ): - return "cool" - # 晚安/睡觉相关 - if any( - phrase in text - for phrase in [ - "睡觉", - "晚安", - "睡了", - "好梦", - "休息了", - "去睡了", - "sleep", - "good night", - "bedtime", - "go to bed", - ] - ): - return "sleepy" - # 疑问句 - if has_question and not has_exclamation: - return "thinking" - # 强烈情感(感叹号) - if has_exclamation and not has_question: - # 检查是否是积极内容 - positive_words = ( - emotion_keywords["happy"] - + emotion_keywords["laughing"] - + emotion_keywords["cool"] - ) - if any(word in text for word in positive_words): - return "laughing" - # 检查是否是消极内容 - negative_words = ( - emotion_keywords["angry"] - + emotion_keywords["sad"] - + emotion_keywords["crying"] - ) - if any(word in text for word in negative_words): - return "angry" - return "surprised" - # 省略号(表示犹豫或思考) - if has_ellipsis: - return "thinking" - - # 关键词匹配(带权重) - emotion_scores = {emotion: 0 for emotion in emoji_map.keys()} - - # 给匹配到的关键词加分 - for emotion, keywords in emotion_keywords.items(): - for keyword in keywords: - if keyword in text: - emotion_scores[emotion] += 1 - - # 给长文本中的重复关键词额外加分 - if len(text) > 20: # 长文本 - for emotion, keywords in emotion_keywords.items(): - for keyword in keywords: - emotion_scores[emotion] += text.count(keyword) * 0.5 - - # 根据分数选择最可能的情感 - max_score = max(emotion_scores.values()) - if max_score == 0: - return "happy" # 默认 - - # 可能有多个情感同分,根据上下文选择最合适的 - top_emotions = [e for e, s in emotion_scores.items() if s == max_score] - - # 如果多个情感同分,使用以下优先级 - priority_order = [ - "laughing", - "crying", - "angry", - "surprised", - "shocked", # 强烈情感优先 - "loving", - "happy", - "funny", - "cool", # 积极情感 - "sad", - "embarrassed", - "confused", # 消极情感 - "thinking", - "winking", - "relaxed", # 中性情感 - "delicious", - "kissy", - "confident", - "sleepy", - "silly", # 特殊场景 - ] - - for emotion in priority_order: - if emotion in top_emotions: - return emotion - - return top_emotions[0] # 如果都不在优先级列表里,返回第一个 - - def audio_to_data(audio_file_path, is_opus=True): # 获取文件后缀名 file_type = os.path.splitext(audio_file_path)[1]