dsh-fish-pet 0.2.3 → 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 +21 -1
- package/README.md +307 -263
- package/assets/asr-listener.ps1 +53 -35
- package/assets/fish-pet.ps1 +230 -39
- package/assets/tts_worker.py +59 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.2.
|
|
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
|
-
](https://gitee.com/adengou/dsh-fish-pet/stargazers)
|
|
4
|
+
[](https://gitee.com/adengou/dsh-fish-pet/members)
|
|
5
|
+
[](https://github.com/adengou/dsh-fish-pet/stargazers)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+

|
|
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)。
|
package/assets/asr-listener.ps1
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
# 一次连续采集,检测到"说完"立刻上传,不再固定切片
|
|
3
3
|
param(
|
|
4
4
|
[int]$MaxMs = 10000,
|
|
5
|
-
[int]$SilenceMs =
|
|
5
|
+
[int]$SilenceMs = 1100,
|
|
6
6
|
[int]$Boost = 1,
|
|
7
|
-
[int]$SilenceGate =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
}
|
package/assets/fish-pet.ps1
CHANGED
|
@@ -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-
|
|
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
|
-
$
|
|
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
|
-
|
|
1250
|
-
if ($
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|