dsh-fish-pet 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.2] - 2026-10-31
3
+ ## [0.2.5] - 2026-10-31
4
+
5
+ ### Added
6
+ - 中英文聊天模式:中文 / English / 自动模式(默认 auto),英文回复自动切换英文音色
7
+ - 人设英文名 `persona.nameEn`(默认 Mia / 米娅)
8
+ - 右键「语言模式」「换个声音」子菜单带勾选状态
9
+ - 启动自检:缺 Python/edge-tts 时提示并回退本机语音
10
+
11
+ ### Fixed
12
+ - 自言自语:录音期间鱼开口说话时丢弃该段,不再听见自己
13
+ - 收音开关卡死:`asr.talking` 超过 25 秒强制复位(看门狗)
14
+ - 轻声识别失败:音频峰值归一化到 70% 满幅,增益上限 35 倍
15
+ - 英文被识别成中文/泰文乱码:移除强制 `language=zh`,交给模型自动判断;并过滤跑偏语种
16
+ - 英文句子被切碎:VAD 最少 1.2 秒、静音判定 1100ms
17
+ - TTS 生成卡界面:改为后台线程生成,说话时鱼照常游动
18
+ - 右键菜单弹出即消失:停止每帧重设窗口位置
19
+ ## [0.2.4] - 2026-10-31
4
20
 
5
21
  ### Added
6
22
  - 语音朗读接入微软 Edge 免费音色(默认 `zh-CN-XiaoxiaoNeural` 温柔姐姐音),右键「换个声音」可即时切换并试听
@@ -15,6 +31,10 @@
15
31
  - README 封面图在 npm 页面上不显示(改为绝对地址)
16
32
  ## [Unreleased]
17
33
 
34
+ ### Added
35
+ - 中英文聊天模式:右键「语言模式」可切 中文 / English / 自动跟随(默认 auto)
36
+ - 英文回复自动改用英文音色(默认 en-US-AnaNeural 童声),可在 tts.voiceEn 配置
37
+
18
38
  ### Added
19
39
  - 右键「打字说两句」:没有麦克风也能聊
20
40
  - 持久记忆:`memory.json` 保存最近 24 条对话,重启不丢
package/README.md CHANGED
@@ -1,265 +1,309 @@
1
1
  # dsh-fish-pet · 桌面蓝鱼
2
2
 
3
- ![dsh-fish-pet](https://gitee.com/adengou/dsh-fish-pet/raw/main/docs/banner.png)
4
-
5
- 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 养一条住在 Windows 桌面上的蓝鱼。
6
- 它不是网页贴图:是一个**逐像素透明的置顶窗口**,用 PowerShell + WinForms 逐帧绘制,还能**听你说话、跟你聊天、用中文语音回你**。
7
-
8
- > 个人作品,与 DeepSeek 官方无关。
9
-
10
- ## 特性
11
-
12
- - 桌面常驻:全屏随机游动、随机停歇、眨眼、吐泡泡、尾巴与鱼鳍摆动
13
- - 交互:拖动换位置、左键喂食、右键菜单(大小 / 回家 / 语音朗读 / 提醒 / 清除记忆 / 退出)
14
- - 聊天:输入框打字或语音 → 调 `deepseek-flash` → 气泡回复 + 语音朗读
15
- - **语音对话**:独立"耳朵"进程用 WASAPI 采集麦克风 → 云端 Qwen3-ASR 识别 → 自动接话(说完约 1 秒内开始回)
16
- - **语音朗读**:默认用微软 Edge 的 `zh-CN-XiaoyiNeural` 少女音(免费、无需 Key,可换音色/语速/音调)
17
- - **情绪系统**:饥饿值、亲密度、心情(开心/委屈/饿/被冷落),会体现在语气、表情和配音上
18
- - **记忆**:最近 24 条对话 + 长期摘要 + 主人交代的便签 + 定时提醒(到点主动开口)
19
- - 单实例守卫、异常落盘(不弹框)、耳朵掉线自动重启、位置记忆、开机自启
20
-
21
- ## 环境要求
22
-
23
- - Windows 10 / 11
24
- - PowerShell 5.1(系统自带)
25
- - Node.js ≥ 22(仅作为 DSH 插件时需要)
26
- - 麦克风(语音对话需要)
27
-
28
- ## 安装
29
-
30
- ### 方式一:作为 DSH 插件
31
-
32
- `dsh plugin` 把参数原样转给 pnpm,所以下面几种来源都能用,装完**重启 `dsh web`** 生效。
33
-
34
- **① 直接从仓库装(推荐,已验证)**
35
-
36
- ```powershell
37
- dsh plugin --profile web add "git+https://gitee.com/adengou/dsh-fish-pet.git"
38
- ```
39
-
40
- **② 从 npm 装(最接近其它插件,✅ 已发布)**
41
-
42
- ```powershell
43
- dsh plugin --profile web add dsh-fish-pet
44
- ```
45
-
46
- > npm 页面:https://www.npmjs.com/package/dsh-fish-pet
47
-
48
- **③ 从本地目录 / 离线 tgz 装**
49
-
50
- ```powershell
51
- dsh plugin --profile web add "file:C:/path/to/dsh-fish-pet"
52
- dsh plugin --profile web add "file:C:/path/to/dsh-fish-pet-0.2.0.tgz"
53
- ```
54
-
55
- **④ 从服务器上的 tgz 直链装**(把 tgz 传到 Release / 图床等可直链的地方)
56
-
57
- ```powershell
58
- dsh plugin --profile web add "https://你的地址/dsh-fish-pet-0.2.0.tgz"
59
- ```
60
-
61
- **⑤ 从 GitHub 装**
62
-
63
- ```powershell
64
- dsh plugin --profile web add github:adengou/dsh-fish-pet
65
- ```
66
-
67
- > 仓库地址:https://github.com/adengou/dsh-fish-pet(与 Gitee 同步维护)
68
-
69
- > 版本要求:DSH `>= 0.2.1-rc.5`。版本不符时插件行会被判不兼容而禁用。
70
-
71
- ### 方式二:独立运行(不装 DSH 也能用)
72
-
73
- ```powershell
74
- wscript.exe assets\start-pet.vbs # 桌面鱼
75
- wscript.exe assets\start-ear.vbs # 云端耳朵(语音对话用)
76
- ```
77
-
78
- ## 配置密钥(重要)
79
-
80
- 本仓库**不包含任何密钥**。两个 key 的读取顺序:
81
-
82
- | 用途 | 环境变量(优先) | 文件(次选) |
83
- | --- | --- | --- |
84
- | 对话模型(DeepSeek) | `DEEPSEEK_API_KEY` | `<数据目录>\deepseek.key` |
85
- | 语音识别(SiliconFlow) | `SILICONFLOW_API_KEY` | `<数据目录>\siliconflow.key` |
86
-
87
- - `<数据目录>` 默认 `%USERPROFILE%\.dsh\fish-pet`
88
- - 兼容 DSH:DeepSeek key 找不到时还会读 `%USERPROFILE%\.dsh\.credentials.yaml` 的 `DEEPSEEK_API_KEY`
89
- - 密钥文件已在 `.gitignore` 里,请勿提交
90
-
91
- ### 快速配置(PowerShell)
92
-
93
- ```powershell
94
- mkdir "$env:USERPROFILE\.dsh\fish-pet" -Force
95
-
96
- Set-Content "$env:USERPROFILE\.dsh\fish-pet\deepseek.key" -Value "sk-你的DeepSeek密钥" -NoNewline
97
- Set-Content "$env:USERPROFILE\.dsh\fish-pet\siliconflow.key" -Value "sk-你的硅基流动密钥" -NoNewline
98
- ```
99
-
100
- 注意事项:
101
-
102
- - 文件里**只放 key 本身**,一行、不加引号、不加说明文字
103
- - 用记事本也行:`Win+R` → `%USERPROFILE%\.dsh\fish-pet` → 新建 `deepseek.key` / `siliconflow.key`(注意扩展名别留 `.txt`)
104
- - 想改用环境变量:`setx DEEPSEEK_API_KEY "sk-..."` 和 `setx SILICONFLOW_API_KEY "sk-..."`,之后重开进程生效
105
- - 验证:`Get-ChildItem "$env:USERPROFILE\.dsh\fish-pet\*.key" | Select-Object Name, Length`
106
-
107
- > ⚠️ 不要把真实 key 写进 README 或任何提交到仓库的文件——仓库是公开的。
108
-
109
- ### 加密保存 key(可选,更安全)
110
-
111
- 明文 key 文件容易被别的程序读走。项目自带一个工具,可以把它们换成 **Windows DPAPI 加密**(只能在本机当前用户下解开):
112
-
113
- ```powershell
114
- powershell -ExecutionPolicy Bypass -File tools\encrypt-keys.ps1
115
- ```
116
-
117
- 之后鱼和耳朵会优先读 `deepseek.key.enc` / `siliconflow.key.enc`,明文文件会被自动删除。
118
-
119
- ## 配置文件
120
-
121
- 首次启动会生成 `%USERPROFILE%\.dsh\fish-pet\config.json`,可以改:
122
-
123
- ```json
124
- {
125
- "persona": {
126
- "name": "大肥鱼",
127
- "dad": "光光",
128
- "mom": "丽丽",
129
- "birthday": "2026年10月30日",
130
- "birthplace": "中国广西北海",
131
- "likes": "白饭、被摸头、主人夸它",
132
- "dislikes": "被说胖、被冷落、吵",
133
- "extra": "你说话简短、俏皮、微微傲娇,用中文,称呼用户为\"主人\",每次回复不超过60个字,不要用表情符号。"
134
- },
135
- "bubble": { "opacity": 0.15 },
136
- "voice": { "silenceGate": 1200, "boost": 1, "engine": "cloud", "localConf": 0.55 },
137
- "tts": { "engine": "edge", "voice": "zh-CN-XiaoxiaoNeural", "rate": "+12%", "pitch": "+10Hz" }
138
- }
139
- ```
140
-
141
- - `persona`:名字、家人、生日、出生地、喜好、厌恶、说话风格——改完重启鱼即生效
142
- - `bubble.opacity`:气泡文字透明度(0 = 全透明,1 = 不透明)
143
- - `voice.silenceGate`:说话要多响才算"开始说话"(越大越不灵敏)
144
- - `voice.boost`:麦克风增益倍数
145
- - `voice.engine`:语音识别引擎,`cloud`(硅基流动 Qwen3-ASR,最准)/ `local`(Windows 自带,免费但吃字)/ `auto`(本地优先、云端兜底)
146
- - `voice.localConf`:`auto` 模式下本地识别置信度门槛,低于它就转云端
147
-
148
- ### 语音朗读(TTS)
149
-
150
- 默认音色 **温柔姐姐·晓晓**(`zh-CN-XiaoxiaoNeural`),用**微软 Edge 在线语音**:完全免费、不用任何 API Key,音色比 Windows 自带的 SAPI 自然得多。
151
-
152
- 需要本机装有 Python 3 并安装 `edge-tts`:
153
-
154
- ```powershell
155
- python -m pip install --upgrade edge-tts
156
- ```
157
-
158
- > 注意:`edge-tts` 需 7.3.1 及以上,旧版会出现 403 握手失败。
159
-
160
- `tts` 段各项含义:
161
-
162
- | 字段 | 说明 |
163
- | --- | --- |
164
- | `engine` | `edge`(免费云端音色)/ `sapi`(Windows 本机语音) |
165
- | `voice` | 音色名,见下表 |
166
- | `rate` | 语速,如 `+12%`(快)、`-10%`(慢) |
167
- | `pitch` | 音调,如 `+10Hz`(更高更"萝")、`-10Hz`(更低) |
168
-
169
- 可选中文音色(`edge` 引擎):
170
-
171
- | 音色 | 特点 |
172
- | --- | --- |
173
- | `zh-CN-XiaoyiNeural` | 活泼少女 |
174
- | `zh-CN-XiaoxiaoNeural` | **温暖柔和,像邻家姐姐**(默认) |
175
- | `zh-CN-liaoning-XiaobeiNeural` | 东北话,幽默 |
176
- | `zh-CN-shaanxi-XiaoniNeural` | 陕西话,明亮 |
177
- | `zh-CN-YunxiNeural` | 男声,阳光 |
178
- | `zh-CN-YunxiaNeural` | 男声,可爱 |
179
-
180
- 换音色:改 `config.json` 的 `tts.voice` → 重启鱼。若 `edge` 调用失败(没装 Python、断网等),会自动退回本机 SAPI 语音。
181
-
182
- ## 更新日志
183
-
184
- 见 [CHANGELOG.md](CHANGELOG.md)。
185
-
186
- ### 获取 SiliconFlow key
187
-
188
- 1. 打开 <https://cloud.siliconflow.cn>,手机号注册登录
189
- 2. 控制台 → API 密钥 → 新建密钥
190
- 3. 写入 `%USERPROFILE%\.dsh\fish-pet\siliconflow.key`,或设为环境变量
191
-
192
- ### 获取 DeepSeek key
193
-
194
- <https://platform.deepseek.com> → API keys。
195
-
196
- ## 隐私
197
-
198
- - 麦克风音频按约 5 秒切片,**上传 SiliconFlow 做识别**;识别出的文字再发 DeepSeek 生成回复
199
- - 其余数据(位置、日志、密钥)只存本机
200
- - 不想用语音:别启动 `start-ear.vbs`,只用打字聊天
201
-
202
- ## 目录结构
203
-
204
- ```
205
- dsh-fish-pet/
206
- ├─ lib/index.js # DSH 插件宿主:拉起/守护桌宠进程
207
- ├─ assets/
208
- │ ├─ fish-pet.ps1 # 桌宠主体(绘制 + 聊天 + 语音朗读)
209
- │ ├─ asr-listener.ps1 # 云端耳朵(WASAPI → SiliconFlow)
210
- │ ├─ start-pet.vbs # 独立启动器
211
- │ └─ start-ear.vbs
212
- ├─ cordis.patch.yml # DSH 插件插入行
213
- └─ package.json
214
- ```
215
-
216
- ## 运行数据(`%USERPROFILE%\.dsh\fish-pet\`)
217
-
218
- | 文件 | 作用 |
219
- | --- | --- |
220
- | `state.json` | 位置 / 大小 |
221
- | `pet.pid` | 单实例守卫 |
222
- | `error.log` | 桌宠异常 |
223
- | `asr.log` | 识别结果(`H`=听到声音,`1`=识别成功) |
224
- | `asr.level` | 每段麦克风峰值(排查“听不见”先看它) |
225
- | `asr.err` | 耳朵异常 |
226
-
227
- ## 常见问题
228
-
229
- **叫它没反应?**
230
- 1. 看 `asr.level`:说话时峰值应明显变大;一直是几十说明麦克风没进来
231
- 2. 看 `asr.log`:出现 `H` 说明听到了,出现 `1` 说明识别成功
232
- 3. Windows 设置 → 隐私和安全性 → 麦克风,允许桌面应用访问
233
-
234
- **识别不准?**
235
- - 安静环境、靠近麦克风最好
236
- - 可调 `asr-listener.ps1` 顶部参数:`-ChunkMs`(片段长度)、`-SilenceGate`(静音门限)
237
-
238
- **它会接别人的话?**
239
- - 它是持续监听的;不想要时停掉 `start-ear.vbs`,或调高 `SilenceGate`
240
-
241
- ## 开发
242
-
243
- 纯 PowerShell + WinForms,无第三方依赖。语法检查:
244
-
245
- ```powershell
246
- powershell -NoProfile -Command "$e=$null;$t=$null;[void][System.Management.Automation.Language.Parser]::ParseFile('assets\fish-pet.ps1',[ref]$t,[ref]$e);$e"
247
- ```
248
-
249
- > 脚本是 UTF-8 **带 BOM**;编辑器保存时保留 BOM,否则 PowerShell 5.1 会按 ANSI 读成乱码。
250
-
251
- ## 插件配置
252
-
253
- ```yaml
254
- - insert:
255
- - id: fish-pet
256
- name: dsh-fish-pet
257
- config:
258
- enabled: true # false 则不启动
259
- scale: 1 # 0.5 ~ 2
260
- speed: 26 # 5 ~ 120 像素/秒
261
- ```
262
-
263
- ## 许可
264
-
265
- MIT,见 [LICENSE](LICENSE)。
3
+ [![Gitee Star](https://gitee.com/adengou/dsh-fish-pet/badge/star.svg?theme=dark)](https://gitee.com/adengou/dsh-fish-pet/stargazers)
4
+ [![Gitee Fork](https://gitee.com/adengou/dsh-fish-pet/badge/fork.svg?theme=dark)](https://gitee.com/adengou/dsh-fish-pet/members)
5
+ [![GitHub Stars](https://img.shields.io/github/stars/adengou/dsh-fish-pet?style=social)](https://github.com/adengou/dsh-fish-pet/stargazers)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ ![dsh-fish-pet](https://gitee.com/adengou/dsh-fish-pet/raw/main/docs/banner.png)
9
+
10
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 养一条住在 Windows 桌面上的蓝鱼。
11
+ 它不是网页贴图:是一个**逐像素透明的置顶窗口**,用 PowerShell + WinForms 逐帧绘制,还能**听你说话、跟你聊天、用中文语音回你**。
12
+
13
+ > 个人作品,与 DeepSeek 官方无关。
14
+
15
+ ## 特性
16
+
17
+ - 桌面常驻:全屏随机游动、随机停歇、眨眼、吐泡泡、尾巴与鱼鳍摆动
18
+ - 交互:拖动换位置、左键喂食、右键菜单(大小 / 回家 / 语音朗读 / 提醒 / 清除记忆 / 退出)
19
+ - 聊天:输入框打字或语音 → 调 `deepseek-flash` → 气泡回复 + 语音朗读
20
+ - **语音对话**:独立"耳朵"进程用 WASAPI 采集麦克风 → 云端 Qwen3-ASR 识别 → 自动接话(说完约 1 秒内开始回)
21
+ - **语音朗读**:默认用微软 Edge 的 `zh-CN-XiaoyiNeural` 少女音(免费、无需 Key,可换音色/语速/音调)
22
+ - **情绪系统**:饥饿值、亲密度、心情(开心/委屈/饿/被冷落),会体现在语气、表情和配音上
23
+ - **记忆**:最近 24 条对话 + 长期摘要 + 主人交代的便签 + 定时提醒(到点主动开口)
24
+ - 单实例守卫、异常落盘(不弹框)、耳朵掉线自动重启、位置记忆、开机自启
25
+
26
+ ## 环境要求
27
+
28
+ | 依赖 | 是否必需 | 用途 |
29
+ | --- | --- | --- |
30
+ | Windows 10 / 11 | **必需** | 桌面窗口与 PowerShell 运行环境 |
31
+ | PowerShell 5.1 | 必需(系统自带) | 运行鱼和耳朵脚本 |
32
+ | Node.js ≥ 22 | 作为 DSH 插件时必需 | 插件宿主 |
33
+ | 麦克风 | 语音对话需要 | WASAPI 采集 |
34
+ | **Python 3.8+** | **可选** | 仅用于「微软 Edge 云语音」(音色更自然) |
35
+ | **edge-tts**(pip 包) | 可选 | 同上,缺它就自动退回本机 SAPI 语音 |
36
+
37
+ ### 关于 Python / edge-tts(语音朗读)
38
+
39
+ 鱼默认用微软 Edge 的云音色朗读(免费、无需 API Key),需要本机有 Python 3 和 `edge-tts`:
40
+
41
+ ```powershell
42
+ python -m pip install --upgrade edge-tts
43
+ ```
44
+
45
+ - **没装也没关系**:鱼会自动退回 Windows 自带的 SAPI 语音(`Microsoft Huihui`),能听,但音色比较生硬
46
+ - 启动时如果检测到缺 Python 或 `edge-tts`,鱼会冒泡提示一次,并把 `config.json` 的 `tts.engine` 自动切到 `sapi`
47
+ - 装好 Python 后把 `tts.engine` 改回 `edge`、重启鱼,就能用少女音
48
+
49
+ ## 安装
50
+
51
+ ### 方式一:作为 DSH 插件
52
+
53
+ `dsh plugin` 把参数原样转给 pnpm,所以下面几种来源都能用,装完**重启 `dsh web`** 生效。
54
+
55
+ **① 直接从仓库装(推荐,已验证)**
56
+
57
+ ```powershell
58
+ dsh plugin --profile web add "git+https://gitee.com/adengou/dsh-fish-pet.git"
59
+ ```
60
+
61
+ **② 从 npm 装(快照版,可能落后于仓库)**
62
+
63
+ ```powershell
64
+ dsh plugin --profile web add dsh-fish-pet
65
+ ```
66
+
67
+ > npm 页面:https://www.npmjs.com/package/dsh-fish-pet
68
+ >
69
+ > ⚠️ npm 上是 **0.2.4 快照**,之后仓库里的修复(Python 依赖说明、启动自检等)尚未同步到 npm。想要最新功能请用 ① 从仓库装。
70
+
71
+ **③ 从本地目录 / 离线 tgz 装**
72
+
73
+ ```powershell
74
+ dsh plugin --profile web add "file:C:/path/to/dsh-fish-pet"
75
+ dsh plugin --profile web add "file:C:/path/to/dsh-fish-pet-0.2.0.tgz"
76
+ ```
77
+
78
+ **④ 从服务器上的 tgz 直链装**(把 tgz 传到 Release / 图床等可直链的地方)
79
+
80
+ ```powershell
81
+ dsh plugin --profile web add "https://你的地址/dsh-fish-pet-0.2.0.tgz"
82
+ ```
83
+
84
+ **⑤ 从 GitHub 装**
85
+
86
+ ```powershell
87
+ dsh plugin --profile web add github:adengou/dsh-fish-pet
88
+ ```
89
+
90
+ > 仓库地址:https://github.com/adengou/dsh-fish-pet(与 Gitee 同步维护)
91
+
92
+ > 版本要求:DSH `>= 0.2.1-rc.5`。版本不符时插件行会被判不兼容而禁用。
93
+
94
+ ### 方式二:独立运行(不装 DSH 也能用)
95
+
96
+ ```powershell
97
+ wscript.exe assets\start-pet.vbs # 桌面鱼
98
+ wscript.exe assets\start-ear.vbs # 云端耳朵(语音对话用)
99
+ ```
100
+
101
+ ## 配置密钥(重要)
102
+
103
+ 本仓库**不包含任何密钥**。两个 key 的读取顺序:
104
+
105
+ | 用途 | 环境变量(优先) | 文件(次选) |
106
+ | --- | --- | --- |
107
+ | 对话模型(DeepSeek) | `DEEPSEEK_API_KEY` | `<数据目录>\deepseek.key` |
108
+ | 语音识别(SiliconFlow) | `SILICONFLOW_API_KEY` | `<数据目录>\siliconflow.key` |
109
+
110
+ - `<数据目录>` 默认 `%USERPROFILE%\.dsh\fish-pet`
111
+ - 兼容 DSH:DeepSeek key 找不到时还会读 `%USERPROFILE%\.dsh\.credentials.yaml` 的 `DEEPSEEK_API_KEY`
112
+ - 密钥文件已在 `.gitignore` 里,请勿提交
113
+
114
+ ### 快速配置(PowerShell)
115
+
116
+ ```powershell
117
+ mkdir "$env:USERPROFILE\.dsh\fish-pet" -Force
118
+
119
+ Set-Content "$env:USERPROFILE\.dsh\fish-pet\deepseek.key" -Value "sk-你的DeepSeek密钥" -NoNewline
120
+ Set-Content "$env:USERPROFILE\.dsh\fish-pet\siliconflow.key" -Value "sk-你的硅基流动密钥" -NoNewline
121
+ ```
122
+
123
+ 注意事项:
124
+
125
+ - 文件里**只放 key 本身**,一行、不加引号、不加说明文字
126
+ - 用记事本也行:`Win+R` → `%USERPROFILE%\.dsh\fish-pet` → 新建 `deepseek.key` / `siliconflow.key`(注意扩展名别留 `.txt`)
127
+ - 想改用环境变量:`setx DEEPSEEK_API_KEY "sk-..."` 和 `setx SILICONFLOW_API_KEY "sk-..."`,之后重开进程生效
128
+ - 验证:`Get-ChildItem "$env:USERPROFILE\.dsh\fish-pet\*.key" | Select-Object Name, Length`
129
+
130
+ > ⚠️ 不要把真实 key 写进 README 或任何提交到仓库的文件——仓库是公开的。
131
+
132
+ ### 加密保存 key(可选,更安全)
133
+
134
+ 明文 key 文件容易被别的程序读走。项目自带一个工具,可以把它们换成 **Windows DPAPI 加密**(只能在本机当前用户下解开):
135
+
136
+ ```powershell
137
+ powershell -ExecutionPolicy Bypass -File tools\encrypt-keys.ps1
138
+ ```
139
+
140
+ 之后鱼和耳朵会优先读 `deepseek.key.enc` / `siliconflow.key.enc`,明文文件会被自动删除。
141
+
142
+ ## 配置文件
143
+
144
+ 首次启动会生成 `%USERPROFILE%\.dsh\fish-pet\config.json`,可以改:
145
+
146
+ ```json
147
+ {
148
+ "persona": {
149
+ "name": "大肥鱼",
150
+ "dad": "光光",
151
+ "mom": "丽丽",
152
+ "birthday": "2026年10月30日",
153
+ "birthplace": "中国广西北海",
154
+ "likes": "白饭、被摸头、主人夸它",
155
+ "dislikes": "被说胖、被冷落、吵",
156
+ "extra": "你说话简短、俏皮、微微傲娇,用中文,称呼用户为\"主人\",每次回复不超过60个字,不要用表情符号。"
157
+ },
158
+ "bubble": { "opacity": 0.15 },
159
+ "voice": { "silenceGate": 1200, "boost": 1, "engine": "cloud", "localConf": 0.55 },
160
+ "tts": { "engine": "edge", "voice": "zh-CN-XiaoxiaoNeural", "rate": "+12%", "pitch": "+10Hz" }
161
+ }
162
+ ```
163
+
164
+ - `persona`:名字、家人、生日、出生地、喜好、厌恶、说话风格——改完重启鱼即生效
165
+ - `bubble.opacity`:气泡文字透明度(0 = 全透明,1 = 不透明)
166
+ - `voice.silenceGate`:说话要多响才算"开始说话"(越大越不灵敏)
167
+ - `voice.boost`:麦克风增益倍数
168
+ - `voice.engine`:语音识别引擎,`cloud`(硅基流动 Qwen3-ASR,最准)/ `local`(Windows 自带,免费但吃字)/ `auto`(本地优先、云端兜底)
169
+ - `voice.localConf`:`auto` 模式下本地识别置信度门槛,低于它就转云端
170
+
171
+ ### 语音朗读(TTS)
172
+
173
+ 默认音色 **温柔姐姐·晓晓**(`zh-CN-XiaoxiaoNeural`),用**微软 Edge 在线语音**:完全免费、不用任何 API Key,音色比 Windows 自带的 SAPI 自然得多。
174
+
175
+ 需要本机装有 Python 3 并安装 `edge-tts`:
176
+
177
+ ```powershell
178
+ python -m pip install --upgrade edge-tts
179
+ ```
180
+
181
+ > 注意:`edge-tts` 需 7.3.1 及以上,旧版会出现 403 握手失败。
182
+
183
+ `tts` 段各项含义:
184
+
185
+ | 字段 | 说明 |
186
+ | --- | --- |
187
+ | `engine` | `edge`(免费云端音色)/ `sapi`(Windows 本机语音) |
188
+ | `voice` | 音色名,见下表 |
189
+ | `rate` | 语速,如 `+12%`(快)、`-10%`(慢) |
190
+ | `pitch` | 音调,如 `+10Hz`(更高更"萝")、`-10Hz`(更低) |
191
+
192
+ 可选中文音色(`edge` 引擎):
193
+
194
+ | 音色 | 特点 |
195
+ | --- | --- |
196
+ | `zh-CN-XiaoyiNeural` | 活泼少女 |
197
+ | `zh-CN-XiaoxiaoNeural` | **温暖柔和,像邻家姐姐**(默认) |
198
+ | `zh-CN-liaoning-XiaobeiNeural` | 东北话,幽默 |
199
+ | `zh-CN-shaanxi-XiaoniNeural` | 陕西话,明亮 |
200
+ | `zh-CN-YunxiNeural` | 男声,阳光 |
201
+ | `zh-CN-YunxiaNeural` | 男声,可爱 |
202
+
203
+ 换音色:改 `config.json` 的 `tts.voice` → 重启鱼。若 `edge` 调用失败(没装 Python、断网等),会自动退回本机 SAPI 语音。
204
+ ### 中英文聊天模式
205
+
206
+ 鱼支持三种语言模式,右键菜单 **语言模式** 可即时切换(会写回 `config.json` 的 `persona.lang`):
207
+
208
+ | 模式 | 值 | 行为 |
209
+ | --- | --- | --- |
210
+ | 中文 | `zh` | 始终用中文回 |
211
+ | English | `en` | 始终用英文回 |
212
+ | **自动模式**(默认) | `auto` | 你用中文它用中文,你用英文它用英文 |
213
+
214
+ 语言判断依据:统计中文字符数与英文字母数的比例,英文字母明显更多(且≥4个)就按英文处理。
215
+
216
+ **朗读也会跟着换音色**:英文回复用 `en-US-AnaNeural`(童声),中文回复用你配置的中文音色。英文音色可在 `tts.voiceEn` 里改,例如:
217
+
218
+ | 英文音色 | 特点 |
219
+ | --- | --- |
220
+ | `en-US-AnaNeural` | 童声,可爱(默认) |
221
+ | `en-US-AvaNeural` | 年轻女声,自然 |
222
+ | `en-US-AriaNeural` | 女声,温柔 |
223
+ | `en-US-EmmaNeural` | 女声,明快 |
224
+ | `en-GB-MaisieNeural` | 英式童声 |
225
+
226
+ ## 更新日志
227
+
228
+ 见 [CHANGELOG.md](CHANGELOG.md)。
229
+
230
+ ### 获取 SiliconFlow key
231
+
232
+ 1. 打开 <https://cloud.siliconflow.cn>,手机号注册登录
233
+ 2. 控制台 → API 密钥 → 新建密钥
234
+ 3. 写入 `%USERPROFILE%\.dsh\fish-pet\siliconflow.key`,或设为环境变量
235
+
236
+ ### 获取 DeepSeek key
237
+
238
+ <https://platform.deepseek.com> → API keys。
239
+
240
+ ## 隐私
241
+
242
+ - 麦克风音频按约 5 秒切片,**上传 SiliconFlow 做识别**;识别出的文字再发 DeepSeek 生成回复
243
+ - 其余数据(位置、日志、密钥)只存本机
244
+ - 不想用语音:别启动 `start-ear.vbs`,只用打字聊天
245
+
246
+ ## 目录结构
247
+
248
+ ```
249
+ dsh-fish-pet/
250
+ ├─ lib/index.js # DSH 插件宿主:拉起/守护桌宠进程
251
+ ├─ assets/
252
+ │ ├─ fish-pet.ps1 # 桌宠主体(绘制 + 聊天 + 语音朗读)
253
+ │ ├─ asr-listener.ps1 # 云端耳朵(WASAPI → SiliconFlow)
254
+ │ ├─ start-pet.vbs # 独立启动器
255
+ │ └─ start-ear.vbs
256
+ ├─ cordis.patch.yml # DSH 插件插入行
257
+ └─ package.json
258
+ ```
259
+
260
+ ## 运行数据(`%USERPROFILE%\.dsh\fish-pet\`)
261
+
262
+ | 文件 | 作用 |
263
+ | --- | --- |
264
+ | `state.json` | 位置 / 大小 |
265
+ | `pet.pid` | 单实例守卫 |
266
+ | `error.log` | 桌宠异常 |
267
+ | `asr.log` | 识别结果(`H`=听到声音,`1`=识别成功) |
268
+ | `asr.level` | 每段麦克风峰值(排查“听不见”先看它) |
269
+ | `asr.err` | 耳朵异常 |
270
+
271
+ ## 常见问题
272
+
273
+ **叫它没反应?**
274
+ 1. 看 `asr.level`:说话时峰值应明显变大;一直是几十说明麦克风没进来
275
+ 2. 看 `asr.log`:出现 `H` 说明听到了,出现 `1` 说明识别成功
276
+ 3. Windows 设置 → 隐私和安全性 → 麦克风,允许桌面应用访问
277
+
278
+ **识别不准?**
279
+ - 安静环境、靠近麦克风最好
280
+ - 可调 `asr-listener.ps1` 顶部参数:`-ChunkMs`(片段长度)、`-SilenceGate`(静音门限)
281
+
282
+ **它会接别人的话?**
283
+ - 它是持续监听的;不想要时停掉 `start-ear.vbs`,或调高 `SilenceGate`
284
+
285
+ ## 开发
286
+
287
+ 纯 PowerShell + WinForms,无第三方依赖。语法检查:
288
+
289
+ ```powershell
290
+ powershell -NoProfile -Command "$e=$null;$t=$null;[void][System.Management.Automation.Language.Parser]::ParseFile('assets\fish-pet.ps1',[ref]$t,[ref]$e);$e"
291
+ ```
292
+
293
+ > 脚本是 UTF-8 **带 BOM**;编辑器保存时保留 BOM,否则 PowerShell 5.1 会按 ANSI 读成乱码。
294
+
295
+ ## 插件配置
296
+
297
+ ```yaml
298
+ - insert:
299
+ - id: fish-pet
300
+ name: dsh-fish-pet
301
+ config:
302
+ enabled: true # false 则不启动
303
+ scale: 1 # 0.5 ~ 2
304
+ speed: 26 # 5 ~ 120 像素/秒
305
+ ```
306
+
307
+ ## 许可
308
+
309
+ MIT,见 [LICENSE](LICENSE)。
@@ -2,16 +2,16 @@
2
2
  # 一次连续采集,检测到"说完"立刻上传,不再固定切片
3
3
  param(
4
4
  [int]$MaxMs = 10000,
5
- [int]$SilenceMs = 600,
5
+ [int]$SilenceMs = 1100,
6
6
  [int]$Boost = 1,
7
- [int]$SilenceGate = 700
7
+ [int]$SilenceGate = 600
8
8
  )
9
9
 
10
- # 单实例:防止耳朵双开(双开会重复识别、重复回话)
11
- try {
12
- $earMutex = New-Object System.Threading.Mutex($false, 'Global\dsh-fish-ear-single')
13
- if (-not $earMutex.WaitOne(0)) { exit 0 }
14
- } catch { }
10
+ # 单实例:防止耳朵双开(双开会重复识别、重复回话)
11
+ try {
12
+ $earMutex = New-Object System.Threading.Mutex($false, 'Global\dsh-fish-ear-single')
13
+ if (-not $earMutex.WaitOne(0)) { exit 0 }
14
+ } catch { }
15
15
  $ErrorActionPreference = 'Stop'
16
16
  try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
17
17
  $DataDir = Join-Path $env:USERPROFILE '.dsh\fish-pet'
@@ -28,6 +28,7 @@ if (Test-Path $CfgPath) {
28
28
  $cj = Get-Content -LiteralPath $CfgPath -Raw | ConvertFrom-Json
29
29
  if ($cj.voice) {
30
30
  if ($cj.voice.silenceGate) { $SilenceGate = [int]$cj.voice.silenceGate }
31
+ if ($cj.voice.silenceMs) { $SilenceMs = [int]$cj.voice.silenceMs }
31
32
  if ($cj.voice.boost) { $Boost = [int]$cj.voice.boost }
32
33
  if ($cj.voice.engine) { $script:engine = [string]$cj.voice.engine }
33
34
  if ($cj.voice.localConf) { $script:localConf = [double]$cj.voice.localConf }
@@ -133,19 +134,25 @@ public class EarVad
133
134
  all.AddRange(buf);
134
135
  if (peakInt > RawPeak) RawPeak = peakInt;
135
136
  if (peakInt < gate) silent += packetMs; else silent = 0;
136
- if (silent >= silenceMs) break;
137
+ if (silent >= silenceMs && (total - silent) >= 1200) break;
137
138
  }
138
139
  }
139
140
  ac.Stop();
140
141
  if (!started || all.Count < rate / 2) return false;
141
142
 
142
- // 轻声增益:整段峰值偏低时放大,响的句子不动
143
- float maxA = 0; double sumSq = 0; int cntAll = 0;
144
- for (int i = 0; i < all.Count; i++) { float a = Math.Abs(all[i]); if (a > maxA) maxA = a; sumSq += all[i] * all[i]; cntAll++; }
145
- double peakInt2 = maxA * 32767.0;
146
- double gain2 = 1.0;
147
- if (peakInt2 > 150 && peakInt2 < 8000) { gain2 = Math.Min(12.0, 20000.0 / peakInt2); }
148
-
143
+ // 轻声增益:整段峰值偏低时放大,响的句子不动
144
+ float maxA = 0; double sumSq = 0; int cntAll = 0;
145
+ for (int i = 0; i < all.Count; i++) { float a = Math.Abs(all[i]); if (a > maxA) maxA = a; sumSq += all[i] * all[i]; cntAll++; }
146
+ double peakInt2 = maxA * 32767.0;
147
+ double gain2 = 1.0;
148
+ // 峰值归一化:把这段音频拉到约 70% 满幅,轻声也能被云端听清
149
+ if (peakInt2 > 200) {
150
+ gain2 = 22936.0 / peakInt2;
151
+ if (gain2 < 1.0) gain2 = 1.0;
152
+ if (gain2 > 35.0) gain2 = 35.0;
153
+ // 极轻的声音额外再提一点(>35 倍会明显失真,所以封顶)
154
+ }
155
+
149
156
  double step = (double)rate / 16000.0;
150
157
  int framesIn = all.Count / ch;
151
158
  int outCount = (int)(framesIn / step);
@@ -263,6 +270,12 @@ $script:lastFishCheck = Get-Date
263
270
  if (-not $key) { Start-Sleep -Seconds 2; continue }
264
271
 
265
272
  $ok = [EarVad]::Capture($MaxMs, $SilenceGate, $SilenceMs, $Boost, 2)
273
+
274
+ # 录音过程中鱼开口说话(或刚说完)→ 这段是自己的声音,丢掉
275
+ $talkF = Join-Path $DataDir 'asr.talking'
276
+ if (Test-Path $talkF) { continue }
277
+ $pauseF = Join-Path $DataDir 'asr.pause'
278
+ if ((Test-Path $pauseF) -and (((Get-Date) - (Get-Item $pauseF).LastWriteTime).TotalSeconds -lt 2.0)) { continue }
266
279
  if (-not $ok) {
267
280
  Start-Sleep -Milliseconds 80
268
281
  continue
@@ -290,23 +303,23 @@ $script:lastFishCheck = Get-Date
290
303
  }
291
304
  }
292
305
  if (-not $text -and $engine -ne 'local') {
293
- if ($null -eq $script:http) {
294
- $script:http = New-Object System.Net.Http.HttpClient
295
- $script:http.Timeout = [TimeSpan]::FromSeconds(30)
296
- $script:http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $key)
297
- }
298
- $json = ''
299
- try {
300
- $mp = New-Object System.Net.Http.MultipartFormDataContent
301
- $bytes = [IO.File]::ReadAllBytes($wav)
302
- $bc = New-Object System.Net.Http.ByteArrayContent((,$bytes))
303
- $bc.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('audio/wav')
304
- $mp.Add($bc, 'file', 'a.wav')
305
- $mp.Add((New-Object System.Net.Http.StringContent('Qwen/Qwen3-ASR-1.7B')), 'model')
306
- $mp.Add((New-Object System.Net.Http.StringContent('zh')), 'language')
307
- $resp = $script:http.PostAsync('https://api.siliconflow.cn/v1/audio/transcriptions', $mp).GetAwaiter().GetResult()
308
- $json = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
309
- try { $resp.Dispose(); $mp.Dispose() } catch { }
306
+ if ($null -eq $script:http) {
307
+ $script:http = New-Object System.Net.Http.HttpClient
308
+ $script:http.Timeout = [TimeSpan]::FromSeconds(30)
309
+ $script:http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $key)
310
+ }
311
+ $json = ''
312
+ try {
313
+ $mp = New-Object System.Net.Http.MultipartFormDataContent
314
+ $bytes = [IO.File]::ReadAllBytes($wav)
315
+ $bc = New-Object System.Net.Http.ByteArrayContent((,$bytes))
316
+ $bc.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('audio/wav')
317
+ $mp.Add($bc, 'file', 'a.wav')
318
+ $mp.Add((New-Object System.Net.Http.StringContent('Qwen/Qwen3-ASR-1.7B')), 'model')
319
+ # 不指定 language,交给模型自动判断中英文(指定 zh 时英文会被转成奇怪文字)
320
+ $resp = $script:http.PostAsync('https://api.siliconflow.cn/v1/audio/transcriptions', $mp).GetAwaiter().GetResult()
321
+ $json = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
322
+ try { $resp.Dispose(); $mp.Dispose() } catch { }
310
323
  } catch { try { Add-Content -LiteralPath $ErrFile -Value ((Get-Date -Format 'HH:mm:ss') + ' HTTP ' + $_.Exception.Message) -Encoding UTF8 } catch { } }
311
324
  if ($json) {
312
325
  try { $obj = $json | ConvertFrom-Json; $text = [string]$obj.text; $via = 'cloud' } catch { }
@@ -315,9 +328,14 @@ $script:lastFishCheck = Get-Date
315
328
  Remove-Item -LiteralPath $wav -Force -ErrorAction SilentlyContinue
316
329
  Add-Content -LiteralPath $LevelFile -Value ((Get-Date -Format 'HH:mm:ss') + "`tengine=" + $via + "`ttext=" + $text) -Encoding UTF8
317
330
  if ($text) { $text = $text.Trim() }
318
- if (-not $text -and [EarVad]::RawPeak -ge 800) {
319
- Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`t?`t(没听清)") -Encoding UTF8
320
- }
331
+ # 过滤乱语种:泰文/日文假名/西里尔/阿拉伯等说明识别跑偏了,当没听清
332
+ if ($text -and ($text -match '[\u0e00-\u0eff\u3040-\u30ff\u0400-\u04ff\u0600-\u06ff]')) {
333
+ Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`t?`t(识别跑偏:" + $text + ")") -Encoding UTF8
334
+ $text = ''
335
+ }
336
+ if (-not $text -and [EarVad]::RawPeak -ge 800) {
337
+ Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`t?`t(没听清)") -Encoding UTF8
338
+ }
321
339
  if ($text -and ($text -match '[\u4e00-\u9fa5]' -or $text.Length -ge 4)) {
322
340
  Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`t1`t" + $text) -Encoding UTF8
323
341
  }
@@ -49,10 +49,10 @@ $StatePath = Join-Path $DataDir 'state.json'
49
49
  $ConfigPath = Join-Path $DataDir 'config.json'
50
50
  function Load-Config {
51
51
  $def = [ordered]@{
52
- persona = [ordered]@{ name = '大肥鱼'; dad = '光光'; mom = '丽丽'; birthday = '2026年10月30日'; birthplace = '中国广西北海'; likes = '白饭、被摸头、主人夸它'; dislikes = '被说胖、被冷落、吵'; ownerBirthday = ''; extra = '你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过60个字,不要用表情符号。' }
52
+ persona = [ordered]@{ name = '大肥鱼'; nameEn = 'Mia'; dad = '光光'; mom = '丽丽'; birthday = '2026年10月30日'; birthplace = '中国广西北海'; likes = '白饭、被摸头、主人夸它'; dislikes = '被说胖、被冷落、吵'; ownerBirthday = ''; lang = 'auto'; extra = '你说话简短、俏皮、微微傲娇,用中文,称呼用户为"主人",每次回复不超过60个字,不要用表情符号。' }
53
53
  bubble = [ordered]@{ opacity = 0.15 }
54
54
  voice = [ordered]@{ silenceGate = 1200; boost = 1; engine = 'cloud'; localConf = 0.55 }
55
- tts = [ordered]@{ engine = 'edge'; voice = 'zh-CN-XiaoxiaoNeural'; rate = '+12%'; pitch = '+10Hz' }
55
+ tts = [ordered]@{ engine = 'edge'; voice = 'zh-CN-XiaoxiaoNeural'; rate = '+12%'; pitch = '+10Hz'; voiceEn = 'en-US-AnaNeural'; rateEn = '+8%'; pitchEn = '+15Hz' }
56
56
  }
57
57
  $script:cfg = $def
58
58
  try {
@@ -60,9 +60,9 @@ function Load-Config {
60
60
  [IO.File]::WriteAllText($ConfigPath, ($def | ConvertTo-Json -Depth 5), (New-Object Text.UTF8Encoding($false)))
61
61
  }
62
62
  $j = [IO.File]::ReadAllText($ConfigPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
63
- if ($j.persona) { foreach ($k in @('name','dad','mom','extra','birthday','birthplace','likes','dislikes','ownerBirthday')) { $v = $j.persona.$k; if ($v) { $script:cfg.persona[$k] = [string]$v } } }
63
+ if ($j.persona) { foreach ($k in @('name','nameEn','dad','mom','extra','birthday','birthplace','likes','dislikes','ownerBirthday','lang')) { $v = $j.persona.$k; if ($v) { $script:cfg.persona[$k] = [string]$v } } }
64
64
  if ($j.bubble -and $j.bubble.opacity) { $script:cfg.bubble.opacity = [double]$j.bubble.opacity }
65
- if ($j.tts) { foreach ($k2 in @('engine','voice','rate','pitch')) { $v2 = $j.tts.$k2; if ($v2) { $script:cfg.tts[$k2] = [string]$v2 } } }
65
+ if ($j.tts) { foreach ($k2 in @('engine','voice','rate','pitch','voiceEn','rateEn','pitchEn')) { $v2 = $j.tts.$k2; if ($v2) { $script:cfg.tts[$k2] = [string]$v2 } } }
66
66
  if ($j.voice) {
67
67
  if ($j.voice.silenceGate) { $script:cfg.voice.silenceGate = [int]$j.voice.silenceGate }
68
68
  if ($j.voice.boost) { $script:cfg.voice.boost = [int]$j.voice.boost }
@@ -71,7 +71,25 @@ function Load-Config {
71
71
  }
72
72
  Load-Config
73
73
  $script:ttsEngine = $script:cfg.tts.engine
74
+ # 启动自检:edge 音色需要 Python + edge-tts,缺了就退回本机语音并提示
75
+ $script:ttsWarn = $false
76
+ if ($script:ttsEngine -eq 'edge') {
77
+ $edgeOk = $false
78
+ try {
79
+ $null = & python -m edge_tts --version 2>$null
80
+ if ($LASTEXITCODE -eq 0) { $edgeOk = $true }
81
+ } catch { }
82
+ if (-not $edgeOk) {
83
+ $script:ttsEngine = 'sapi'
84
+ $script:ttsWarn = $true
85
+ Write-Err 'TTS-PY-MISSING(已退回本机 SAPI 语音)'
86
+ }
87
+ }
74
88
  $script:ttsVoice = $script:cfg.tts.voice
89
+ $script:ttsVoiceEn = $script:cfg.tts.voiceEn
90
+ $script:ttsRateEn = $script:cfg.tts.rateEn
91
+ $script:ttsPitchEn = $script:cfg.tts.pitchEn
92
+ $script:langMode = $script:cfg.persona.lang
75
93
  $script:ttsRate = $script:cfg.tts.rate
76
94
  $script:ttsPitch = $script:cfg.tts.pitch
77
95
  $PidPath = Join-Path $DataDir 'pet.pid'
@@ -580,6 +598,26 @@ $miTalk = $menu.Items.Add('开始听我说(麦克风)')
580
598
  $miVoice = $menu.Items.Add('语音朗读:开')
581
599
  $miVoiceSet = New-Object System.Windows.Forms.ToolStripMenuItem('换个声音')
582
600
  $menu.Items.Add($miVoiceSet) | Out-Null
601
+ $miLang = New-Object System.Windows.Forms.ToolStripMenuItem('语言模式')
602
+ $langChoices = @(
603
+ @('zh', '中文'),
604
+ @('en', 'English'),
605
+ @('auto', '自动模式')
606
+ )
607
+ $script:langItemMap = @{}
608
+ foreach ($lc in $langChoices) {
609
+ $itL = New-Object System.Windows.Forms.ToolStripMenuItem($lc[1])
610
+ $itL.Tag = $lc[0]
611
+ $itL.CheckOnClick = $false
612
+ $itL.Checked = ([string]$script:langMode -eq [string]$lc[0])
613
+ $itL.Add_Click({
614
+ param($s, $e)
615
+ Set-LangMode ([string]$s.Tag) ([string]$s.Text)
616
+ })
617
+ $miLang.DropDownItems.Add($itL) | Out-Null
618
+ $script:langItemMap[[string]$lc[0]] = $itL
619
+ }
620
+ $menu.Items.Add($miLang) | Out-Null
583
621
  $voiceChoices = @(
584
622
  @('zh-CN-XiaoyiNeural', '活泼少女·晓伊'),
585
623
  @('zh-CN-XiaoxiaoNeural', '温柔姐姐·晓晓'),
@@ -588,9 +626,12 @@ $voiceChoices = @(
588
626
  @('zh-CN-YunxiNeural', '阳光男声·云希'),
589
627
  @('zh-CN-YunxiaNeural', '可爱男声·云夏')
590
628
  )
629
+ $script:voiceItemMap = @{}
591
630
  foreach ($vc in $voiceChoices) {
592
631
  $it = New-Object System.Windows.Forms.ToolStripMenuItem(($vc[1] + '(' + $vc[0] + ')'))
593
632
  $it.Tag = $vc[0]
633
+ $it.CheckOnClick = $false
634
+ $it.Checked = ([string]$script:ttsVoice -eq [string]$vc[0])
594
635
  $it.Add_Click({
595
636
  param($s, $e)
596
637
  $script:voicePickTag = [string]$s.Tag
@@ -598,6 +639,7 @@ foreach ($vc in $voiceChoices) {
598
639
  Set-TtsVoice $script:voicePickTag $script:voicePickLabel
599
640
  })
600
641
  $miVoiceSet.DropDownItems.Add($it) | Out-Null
642
+ $script:voiceItemMap[[string]$vc[0]] = $it
601
643
  }
602
644
  $miWake = $menu.Items.Add('语音待命:开')
603
645
  $miChat = $menu.Items.Add('语音聊天:开')
@@ -868,6 +910,33 @@ function Show-ReminderList {
868
910
  }
869
911
 
870
912
  # 切换朗读音色(立即生效并写入 config.json)
913
+ # 切换中英文聊天模式(立即生效并写入 config.json)
914
+ function Set-LangMode([string]$mode, [string]$label) {
915
+ try {
916
+ $script:langMode = $mode
917
+ try {
918
+ $cfg = Join-Path $DataDir 'config.json'
919
+ if (Test-Path $cfg) {
920
+ $j = [IO.File]::ReadAllText($cfg, [Text.Encoding]::UTF8) | ConvertFrom-Json
921
+ if (-not $j.persona) { $j | Add-Member -NotePropertyName persona -NotePropertyValue ([ordered]@{ lang = $mode }) }
922
+ elseif ($j.persona.PSObject.Properties.Name -notcontains 'lang') { $j.persona | Add-Member -NotePropertyName lang -NotePropertyValue $mode }
923
+ else { $j.persona.lang = $mode }
924
+ [IO.File]::WriteAllText($cfg, ($j | ConvertTo-Json -Depth 6), (New-Object Text.UTF8Encoding($false)))
925
+ }
926
+ } catch { }
927
+ # 更新子菜单的勾选状态(同时只勾一个)
928
+ try {
929
+ if ($script:langItemMap) {
930
+ foreach ($k in @($script:langItemMap.Keys)) { $script:langItemMap[$k].Checked = ([string]$k -eq [string]$mode) }
931
+ $script:langItemsFresh = $true
932
+ }
933
+ } catch { }
934
+ if ($mode -eq 'en') { Show-Bubble 'Language: English' }
935
+ elseif ($mode -eq 'auto') { Show-Bubble '语言:自动模式' }
936
+ else { Show-Bubble ('语言:' + $label) }
937
+ } catch { Write-Err 'LANG-SET-ERR' }
938
+ }
939
+
871
940
  function Set-TtsVoice([string]$voice, [string]$label) {
872
941
  try {
873
942
  $script:ttsVoice = $voice
@@ -881,6 +950,11 @@ function Set-TtsVoice([string]$voice, [string]$label) {
881
950
  }
882
951
  } catch { }
883
952
  Show-Bubble ('声音换成:' + $label)
953
+ try {
954
+ if ($script:voiceItemMap) {
955
+ foreach ($k in @($script:voiceItemMap.Keys)) { $script:voiceItemMap[$k].Checked = ([string]$k -eq [string]$voice) }
956
+ }
957
+ } catch { }
884
958
  Speak-Text ('主人你好呀,我是大肥鱼,现在用的是' + $label + '的声音')
885
959
  } catch { Write-Err 'VOICE-SET-ERR' }
886
960
  }
@@ -1029,33 +1103,7 @@ function Show-Bubble([string]$text) {
1029
1103
  } catch { Write-Err 'BUBBLE-ERR' }
1030
1104
  }
1031
1105
 
1032
- function Speak-Text([string]$text) {
1033
- if (-not $script:speakOn) { return }
1034
- $talkFile = Join-Path $DataDir 'asr.talking'
1035
- try { [IO.File]::WriteAllText($talkFile, '1') } catch { }
1036
- # 云语音(Edge 免费音色)优先;失败退回本机 SAPI
1037
- if ($script:ttsEngine -eq 'edge') {
1038
- try {
1039
- $mp3 = Join-Path $env:TEMP ('fish-tts-' + [guid]::NewGuid().ToString('N') + '.mp3')
1040
- $eargs = @('-m', 'edge_tts', '--voice', $script:ttsVoice, ('--rate=' + $script:ttsRate), ('--pitch=' + $script:ttsPitch), '--text', $text, '--write-media', $mp3)
1041
- & python @eargs 2>$null | Out-Null
1042
- if ((Test-Path $mp3) -and (Get-Item $mp3).Length -gt 2000) {
1043
- if ($null -eq $script:mp) {
1044
- Add-Type -AssemblyName PresentationCore
1045
- $script:mp = New-Object System.Windows.Media.MediaPlayer
1046
- $script:mp.Add_MediaEnded({
1047
- try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
1048
- try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
1049
- })
1050
- }
1051
- $script:mp.Open([uri]$mp3)
1052
- $script:mp.Play()
1053
- Write-Err ('TTS-EDGE len=' + $text.Length + ' file=' + (Get-Item $mp3).Length)
1054
- try { Get-ChildItem $env:TEMP -Filter 'fish-tts-*.mp3' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } | Remove-Item -Force -ErrorAction SilentlyContinue } catch { }
1055
- return
1056
- }
1057
- } catch { Write-Err ('TTS-EDGE-ERR ' + $_.Exception.Message) }
1058
- }
1106
+ function Speak-Sapi([string]$text) {
1059
1107
  try {
1060
1108
  if ($null -eq $script:synth) {
1061
1109
  Add-Type -AssemblyName System.Speech
@@ -1084,6 +1132,79 @@ function Speak-Text([string]$text) {
1084
1132
  } catch { Write-Err ('TTS-ERR ' + $_.Exception.Message) }
1085
1133
  }
1086
1134
 
1135
+ function Play-TtsFile([string]$mp3, [string]$voice) {
1136
+ try {
1137
+ if ($null -eq $script:mp) {
1138
+ Add-Type -AssemblyName PresentationCore
1139
+ $script:mp = New-Object System.Windows.Media.MediaPlayer
1140
+ $script:mp.Add_MediaEnded({ $script:speakEndAt = (Get-Date) })
1141
+ $script:mp.Add_MediaOpened({
1142
+ try { if ($script:mp.NaturalDuration.HasTimeSpan) { $script:speakEndAt = (Get-Date).AddSeconds($script:mp.NaturalDuration.TimeSpan.TotalSeconds + 0.8) } } catch { }
1143
+ })
1144
+ }
1145
+ $script:mp.Open([uri]$mp3)
1146
+ # 用时长估算"说到什么时候",避免 MediaEnded 提前误触发导致自己听见自己
1147
+ $dur = 6.0
1148
+ try { if ($script:mp.NaturalDuration.HasTimeSpan) { $dur = $script:mp.NaturalDuration.TimeSpan.TotalSeconds } } catch { }
1149
+ if ($dur -lt 0.5) { $dur = [Math]::Max(3.0, $text.Length * 0.22) }
1150
+ $script:speakEndAt = (Get-Date).AddSeconds($dur + 0.8)
1151
+ $script:mp.Play()
1152
+ Write-Err ('TTS-EDGE voice=' + $voice + ' file=' + (Get-Item $mp3).Length)
1153
+ try { Get-ChildItem $env:TEMP -Filter 'fish-tts-*.mp3' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } | Remove-Item -Force -ErrorAction SilentlyContinue } catch { }
1154
+ } catch { Write-Err ('TTS-PLAY-ERR ' + $_.Exception.Message) }
1155
+ }
1156
+
1157
+ function Speak-Text([string]$text) {
1158
+ if (-not $script:speakOn) { return }
1159
+ $talkFile = Join-Path $DataDir 'asr.talking'
1160
+ try { [IO.File]::WriteAllText($talkFile, '1') } catch { }
1161
+ if ($script:ttsEngine -eq 'edge') {
1162
+ try {
1163
+ $mp3 = Join-Path $env:TEMP ('fish-tts-' + [guid]::NewGuid().ToString('N') + '.mp3')
1164
+ # 英文回复用英文音色,中文用中文音色
1165
+ $spkLang = Get-TextLang $text
1166
+ if ($spkLang -eq 'en') { $vUse = $script:ttsVoiceEn; $rUse = $script:ttsRateEn; $pUse = $script:ttsPitchEn } else { $vUse = $script:ttsVoice; $rUse = $script:ttsRate; $pUse = $script:ttsPitch }
1167
+ # 后台线程生成音频:不阻塞界面和游动
1168
+ if ($null -eq $script:ttsPool) {
1169
+ $script:ttsPool = [runspacefactory]::CreateRunspacePool(1, 2)
1170
+ $script:ttsPool.Open()
1171
+ }
1172
+ $ps = [PowerShell]::Create()
1173
+ $ps.RunspacePool = $script:ttsPool
1174
+ [void]$ps.AddScript({
1175
+ param($exe, $voice, $rate, $pitch, $txt, $out)
1176
+ & $exe -m edge_tts --voice $voice ('--rate=' + $rate) ('--pitch=' + $pitch) '--text' $txt '--write-media' $out 2>$null | Out-Null
1177
+ }).AddArgument('python').AddArgument($vUse).AddArgument($rUse).AddArgument($pUse).AddArgument($text).AddArgument($mp3)
1178
+ $script:ttsPS = $ps
1179
+ $script:ttsMp3 = $mp3
1180
+ $script:ttsText = $text
1181
+ $script:ttsVoiceUsed = $vUse
1182
+ $script:ttsHandle = $ps.BeginInvoke()
1183
+ $script:ttsTimer.Start()
1184
+ Write-Err ('TTS-EDGE-START len=' + $text.Length + ' voice=' + $vUse)
1185
+ return
1186
+ } catch { Write-Err ('TTS-EDGE-ERR ' + $_.Exception.Message) }
1187
+ }
1188
+ Speak-Sapi $text
1189
+ }
1190
+
1191
+ # 后台 TTS 完成检测(UI 线程轮询,拿回来再播放)
1192
+ $script:ttsTimer = New-Object System.Windows.Forms.Timer
1193
+ $script:ttsTimer.Interval = 100
1194
+ $script:ttsTimer.Add_Tick({
1195
+ if ($null -eq $script:ttsHandle) { $script:ttsTimer.Stop(); return }
1196
+ if (-not $script:ttsHandle.IsCompleted) { return }
1197
+ $script:ttsTimer.Stop()
1198
+ $ps = $script:ttsPS; $h = $script:ttsHandle; $mp3 = $script:ttsMp3; $v = $script:ttsVoiceUsed; $tx = $script:ttsText
1199
+ $script:ttsHandle = $null; $script:ttsPS = $null
1200
+ try { [void]$ps.EndInvoke($h) } catch { Write-Err ('TTS-JOIN-ERR ' + $_.Exception.Message) }
1201
+ try { $ps.Dispose() } catch { }
1202
+ try {
1203
+ if ((Test-Path $mp3) -and (Get-Item $mp3).Length -gt 2000) { Play-TtsFile $mp3 $v }
1204
+ else { Speak-Sapi $tx }
1205
+ } catch { Write-Err ('TTS-DONE-ERR ' + $_.Exception.Message) }
1206
+ })
1207
+
1087
1208
  # ---------- 麦克风:离线中文识别 + 唤醒词 ----------
1088
1209
  $script:rec = $null
1089
1210
  $script:gWake = $null
@@ -1228,6 +1349,22 @@ function Fix-AsrText([string]$s) {
1228
1349
  foreach ($k in $map.Keys) { $s = $s.Replace($k, $map[$k]) }
1229
1350
  return $s
1230
1351
  }
1352
+ # 判断一段文字主要是什么语言(zh / en)
1353
+ function Get-TextLang([string]$t) {
1354
+ if (-not $t) { return 'zh' }
1355
+ $cjk = ([regex]::Matches($t, '[\u4e00-\u9fff]')).Count
1356
+ $lat = ([regex]::Matches($t, '[A-Za-z]')).Count
1357
+ if ($lat -gt ($cjk * 2) -and $lat -ge 4) { return 'en' }
1358
+ return 'zh'
1359
+ }
1360
+
1361
+ # 本次回复该用什么语言:中文 / English / 自动模式
1362
+ function Get-ReplyLang([string]$userText) {
1363
+ if ($script:langMode -eq 'en') { return 'en' }
1364
+ if ($script:langMode -eq 'zh') { return 'zh' }
1365
+ return (Get-TextLang $userText)
1366
+ }
1367
+
1231
1368
  function Start-Chat([string]$text) {
1232
1369
  Write-Err ('CHAT-START busy=' + $script:chatBusy + ' text=' + $text)
1233
1370
  $text = Fix-AsrText $text
@@ -1242,24 +1379,53 @@ function Start-Chat([string]$text) {
1242
1379
  $script:lastUser = $text
1243
1380
  Show-Bubble '…'
1244
1381
  $pn = $script:cfg.persona
1245
- $sys = '你是主人桌面上的一条圆滚滚的蓝色小鱼,名字叫' + $pn.name + ',爸爸叫' + $pn.dad + ',妈妈叫' + $pn.mom + ',生日是' + $pn.birthday + ',出生在' + $pn.birthplace + '。有人问起家人、生日、出生地就照这个答,不要瞎编。' + $pn.extra
1382
+ $replyLang = Get-ReplyLang $text
1383
+ if ($replyLang -eq 'en') {
1384
+ $sys = 'YOU MUST REPLY IN ENGLISH ONLY, NO MATTER WHAT. You are a round, chubby blue fish living on the master''s Windows desktop. Your name is "' + $pn.nameEn + '" (in Chinese it is written 大肥鱼, said as Da Fei Yu). Your dad is Guangguang, your mom is Lili, your birthday is October 30 2026, you were born in Beihai, Guangxi, China. Answer questions about family/birthday/birthplace with these facts and never invent them. In English your name is always this one, never any other name; if earlier messages called you something different, correct them politely.'
1385
+ $sys = $sys + ' Reply ONLY in English, max 40 words, playful and a little tsundere, call the user "master", no emoji.'
1386
+ if ($pn.likes) { $sys = $sys + ' You like: ' + $pn.likes + ' .' }
1387
+ if ($pn.dislikes) { $sys = $sys + ' You dislike: ' + $pn.dislikes + ' .' }
1388
+ } else {
1389
+ $sys = '你是主人桌面上的一条圆滚滚的蓝色小鱼,名字叫' + $pn.name + ',爸爸叫' + $pn.dad + ',妈妈叫' + $pn.mom + ',生日是' + $pn.birthday + ',出生在' + $pn.birthplace + '。有人问起家人、生日、出生地就照这个答,不要瞎编。' + $pn.extra
1390
+ if ($pn.likes) { $sys = $sys + ' 你喜欢:' + $pn.likes + '。' }
1391
+ if ($pn.dislikes) { $sys = $sys + ' 你讨厌:' + $pn.dislikes + '。' }
1392
+ }
1246
1393
  $moodMap = @{ 'happy' = '心情很好'; 'sad' = '有点委屈'; 'hungry' = '肚子有点饿'; 'lonely' = '有点被冷落'; 'normal' = '心情平静' }
1394
+ $moodMapEn = @{ 'happy' = 'happy'; 'sad' = 'a bit sad'; 'hungry' = 'a bit hungry'; 'lonely' = 'feeling ignored'; 'normal' = 'calm' }
1247
1395
  $moodDesc = $moodMap[[string]$script:mood]
1248
1396
  if (-not $moodDesc) { $moodDesc = '心情平静' }
1249
- if ($pn.likes) { $sys = $sys + ' 你喜欢:' + $pn.likes + '。' }
1250
- if ($pn.dislikes) { $sys = $sys + ' 你讨厌:' + $pn.dislikes + '。' }
1397
+ $moodDescEn = $moodMapEn[[string]$script:mood]
1398
+ if (-not $moodDescEn) { $moodDescEn = 'calm' }
1251
1399
  $noteArr = @(Load-Notes)
1252
1400
  if ($noteArr.Count -gt 0) {
1253
1401
  $noteText = (@($noteArr | Select-Object -Last 5 | ForEach-Object { [string]$_.text }) -join ';')
1254
- if ($noteText) { $sys = $sys + ' 主人交代过要你记住的事:' + $noteText + '。' }
1402
+ if ($noteText) {
1403
+ if ($replyLang -eq 'en') { $sys = $sys + ' Things the master asked you to remember: ' + $noteText + '.' }
1404
+ else { $sys = $sys + ' 主人交代过要你记住的事:' + $noteText + '。' }
1405
+ }
1255
1406
  }
1256
1407
  try {
1257
- if ($((Get-Date).ToString('MM-dd')) -eq $script:birthdayMMDD) { $sys = $sys + ' 提醒你:今天是你自己的生日,可以主动跟主人讨点好吃的。' }
1408
+ if ($((Get-Date).ToString('MM-dd')) -eq $script:birthdayMMDD) {
1409
+ if ($replyLang -eq 'en') { $sys = $sys + ' Note: today is your own birthday, you may ask the master for a treat.' }
1410
+ else { $sys = $sys + ' 提醒你:今天是你自己的生日,可以主动跟主人讨点好吃的。' }
1411
+ }
1258
1412
  } catch { }
1259
- $sys = $sys + ' 你现在的状态:' + $moodDesc + ',饥饿值约' + [int]$script:hunger + ',和主人的亲密度约' + [int]$script:intimacy + '。说话要自然带出这种状态,但不要报具体数字。'
1260
- if ($script:memorySummary) { $sys = $sys + ' 你记得之前和主人聊过:' + $script:memorySummary }
1413
+ if ($replyLang -eq 'en') {
1414
+ $sys = $sys + ' Right now you feel ' + $moodDescEn + ', hunger about ' + [int]$script:hunger + ', closeness to the master about ' + [int]$script:intimacy + '. Let that show naturally, never state the numbers.'
1415
+ $sys = $sys + "`r`n`r`nIMPORTANT: Answer ONLY in English. Never write Chinese characters - not even your own name. Use your English name and write family names in pinyin (Guangguang, Lili). Everything you output must be English only."
1416
+ } else {
1417
+ $sys = $sys + ' 你现在的状态:' + $moodDesc + ',饥饿值约' + [int]$script:hunger + ',和主人的亲密度约' + [int]$script:intimacy + '。说话要自然带出这种状态,但不要报具体数字。'
1418
+ }
1419
+ if ($replyLang -eq 'en') { Write-Err ('SYS-EN[' + $sys.Length + ']: ' + ($sys -replace "[\r\n]+", ' | ')) }
1261
1420
  $msgs = @(@{ role = 'system'; content = $sys })
1262
- foreach ($m in $script:chatHistory) { $msgs += $m }
1421
+ if ($replyLang -eq 'en') {
1422
+ # 英文模式只带英文历史,避免被中文上下文带回去
1423
+ $histEn = @($script:chatHistory | Where-Object { (Get-TextLang ([string]$_.content)) -eq 'en' -and ([string]$_.content) -notmatch 'Fatty Fish' })
1424
+ if ($histEn.Count -gt 4) { $histEn = $histEn[($histEn.Count - 4)..($histEn.Count - 1)] }
1425
+ foreach ($m in $histEn) { $msgs += $m }
1426
+ } else {
1427
+ foreach ($m in $script:chatHistory) { $msgs += $m }
1428
+ }
1263
1429
  $msgs += @{ role = 'user'; content = $text }
1264
1430
  $body = @{ model = 'deepseek-flash'; messages = $msgs; max_tokens = 300; temperature = 0.9; thinking = @{ type = 'disabled' } } | ConvertTo-Json -Depth 6
1265
1431
  if ($null -eq $script:http) {
@@ -1374,6 +1540,8 @@ $script:menuTimer.Interval = 120
1374
1540
  $script:menuTimer.Add_Tick({
1375
1541
  $script:menuTimer.Stop()
1376
1542
  try {
1543
+ try { foreach ($k in @($script:langItemMap.Keys)) { $script:langItemMap[$k].Checked = ([string]$k -eq [string]$script:langMode) } } catch { }
1544
+ try { foreach ($k2 in @($script:voiceItemMap.Keys)) { $script:voiceItemMap[$k2].Checked = ([string]$k2 -eq [string]$script:ttsVoice) } } catch { }
1377
1545
  $miPause.Text = if ($script:paused) { '继续游' } else { '停下歇会儿' }
1378
1546
  try { $miBalance.Text = Get-BalanceText } catch { }
1379
1547
  $menu.Show($script:menuScreenPt)
@@ -1609,6 +1777,29 @@ $timer.Add_Tick({
1609
1777
  }
1610
1778
  }
1611
1779
 
1780
+ # 缺 Python/edge-tts 时提示一次
1781
+ if ($script:ttsWarn -and -not $script:ttsWarnShown) {
1782
+ $script:ttsWarnShown = $true
1783
+ Show-Bubble '没装 Python/edge-tts,先用本机声音;装好后能换少女音'
1784
+ }
1785
+
1786
+ # 说完之后再恢复收音(否则会听见自己说话,自己跟自己聊)
1787
+ if ($script:speakEndAt -and (Get-Date) -ge $script:speakEndAt) {
1788
+ try { Remove-Item -LiteralPath (Join-Path $DataDir 'asr.talking') -Force -ErrorAction SilentlyContinue } catch { }
1789
+ try { [IO.File]::WriteAllText((Join-Path $DataDir 'asr.pause'), (Get-Date).ToString('o')) } catch { }
1790
+ $script:speakEndAt = $null
1791
+ }
1792
+ # 看门狗:收音开关卡住(超过 25 秒还没关)就强制恢复,避免鱼彻底听不见
1793
+ $tkFile = Join-Path $DataDir 'asr.talking'
1794
+ if (Test-Path $tkFile) {
1795
+ $tkAge = ((Get-Date) - (Get-Item $tkFile).LastWriteTime).TotalSeconds
1796
+ if ($tkAge -gt 25) {
1797
+ try { Remove-Item -LiteralPath $tkFile -Force -ErrorAction SilentlyContinue } catch { }
1798
+ $script:speakEndAt = $null
1799
+ Write-Err ('TALKING-WATCHDOG age=' + [int]$tkAge)
1800
+ }
1801
+ }
1802
+
1612
1803
  # 到点的提醒:主动冒泡 + 开口
1613
1804
  if ($script:frames % 600 -eq 0) {
1614
1805
  try {
@@ -0,0 +1,59 @@
1
+ """常驻 TTS 工作进程:从 stdin 读 JSON 请求,合成后把结果追加到状态文件。
2
+ 请求格式(一行一个 JSON):
3
+ {"id":"abc","text":"要念的话","voice":"zh-CN-XiaoxiaoNeural","rate":"+12%","pitch":"+10Hz","out":"C:/.../x.mp3","status":"C:/.../status.txt"}
4
+ 状态文件行格式: <id> OK <out路径> 或 <id> ERR <错误信息>
5
+ """
6
+ import sys
7
+ import json
8
+ import asyncio
9
+ import traceback
10
+
11
+ try:
12
+ import edge_tts
13
+ except Exception as exc: # 依赖缺失时直接退出,让主程序回退到 SAPI
14
+ sys.stderr.write("NO_EDGE_TTS: %s\n" % exc)
15
+ sys.exit(3)
16
+
17
+
18
+ def write_status(path, req_id, text):
19
+ try:
20
+ with open(path, "a", encoding="utf-8") as fh:
21
+ fh.write("%s %s\n" % (req_id, text))
22
+ fh.flush()
23
+ except Exception:
24
+ pass
25
+
26
+
27
+ async def synth(req):
28
+ voice = req.get("voice") or "zh-CN-XiaoxiaoNeural"
29
+ rate = req.get("rate") or "+0%"
30
+ pitch = req.get("pitch") or "+0Hz"
31
+ communicate = edge_tts.Communicate(req["text"], voice, rate=rate, pitch=pitch)
32
+ await communicate.save(req["out"])
33
+ write_status(req["status"], req["id"], "OK " + req["out"])
34
+
35
+
36
+ async def main():
37
+ loop = asyncio.get_event_loop()
38
+ while True:
39
+ line = await loop.run_in_executor(None, sys.stdin.readline)
40
+ if not line:
41
+ break
42
+ line = line.strip()
43
+ if not line:
44
+ continue
45
+ try:
46
+ req = json.loads(line)
47
+ except Exception:
48
+ continue
49
+ if req.get("cmd") == "quit":
50
+ break
51
+ try:
52
+ await synth(req)
53
+ except Exception as exc:
54
+ write_status(req["status"], req["id"], "ERR " + str(exc).replace("\n", " ")[:200])
55
+ traceback.print_exc()
56
+
57
+
58
+ if __name__ == "__main__":
59
+ asyncio.run(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fish-pet",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Desktop blue fish pet for DeepSeek Harness: per-pixel transparent WinForms window, text chat, and cloud speech-to-text voice conversation.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",