dsh-fish-pet 0.2.1 → 0.2.2
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 +63 -0
- package/README.md +85 -5
- package/assets/asr-listener.ps1 +266 -83
- package/assets/fish-pet.ps1 +1657 -993
- package/lib/index.js +3 -2
- package/package.json +3 -1
- package/tools/encrypt-keys.ps1 +28 -0
- package/tools/publish.ps1 +46 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.2] - 2026-10-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- 语音朗读接入微软 Edge 免费音色(默认 `zh-CN-XiaoxiaoNeural` 温柔姐姐音),右键「换个声音」可即时切换并试听
|
|
7
|
+
- 情绪状态机(饥饿/亲密度/心情)、表情随心情变化、便签与定时提醒
|
|
8
|
+
- 右键「加个提醒…」「提醒清单…」「清除记忆…」
|
|
9
|
+
- 全屏随机游动
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- 右键菜单弹出后立即关闭(原因是窗口每帧重设位置)
|
|
13
|
+
- 眼睛高光参数错误导致每帧异常、表情卡在闭眼
|
|
14
|
+
- 识别为空时不再毫无反应(会提示"没听清");插话与追问不再被丢弃
|
|
15
|
+
- README 封面图在 npm 页面上不显示(改为绝对地址)
|
|
16
|
+
## [Unreleased]
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- 右键「打字说两句」:没有麦克风也能聊
|
|
20
|
+
- 持久记忆:`memory.json` 保存最近 24 条对话,重启不丢
|
|
21
|
+
- 长期摘要:每 10 轮把对话压缩成要点,记住很久以前的事
|
|
22
|
+
- 右键菜单直接显示 DeepSeek 余额(打开菜单即刷新,60 秒缓存)
|
|
23
|
+
- 耳朵守护鱼:鱼意外退出会被自动拉起;主动退出则不会复活
|
|
24
|
+
- 日志轮转:`asr.log` / `asr.level` / `error.log` 超过 1MB 自动切分
|
|
25
|
+
- GitHub Actions:push 时自动做 PowerShell / JS 语法检查和打包检查
|
|
26
|
+
|
|
27
|
+
- 语音输入可切换引擎:`cloud`(硅基流动)/ `local`(Windows 自带)/ `auto`
|
|
28
|
+
- 识别模型换用 Qwen3-ASR-1.7B:同一段音频从 1.0 秒降到 0.3 秒
|
|
29
|
+
- 同音纠错:识别结果里的「大飞云 / 白回鱼 / 但肥鱼」「小金 / 小静」自动纠正为「大肥鱼 / 小鲸」
|
|
30
|
+
- 听不清会明说:采到声音但识别为空时,鱼会冒泡「(没听清,再说一遍嘛)」
|
|
31
|
+
- 轻声自动增益:整段峰值低于 8000 时最多放大 12 倍,正常音量不动
|
|
32
|
+
- 插话排队:它正在想或说时补一句,不再被丢弃,回复完自动接上
|
|
33
|
+
- 贴边停靠:拖到屏幕边缘 18px 内自动吸附
|
|
34
|
+
- 情绪反应:被夸冒爱心并轻微脉动,被说胖会鼓起来
|
|
35
|
+
- 外部配置 `config.json`:人设(名字/爸妈/说话风格)、气泡透明度、语音门限与引擎
|
|
36
|
+
|
|
37
|
+
### Changed- 语音识别改为 VAD 断句:说完约 1.2 秒就上传,延迟从 5~9 秒降到 2~3 秒
|
|
38
|
+
- 对话请求关闭模型思考链(thinking disabled),回复更快,`max_tokens` 900 → 500
|
|
39
|
+
- 主循环改为增量读取 `asr.log`(每 0.5 秒一次),不再每帧重读整个文件
|
|
40
|
+
- 单实例改用系统 Mutex,彻底避免双开
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- 气泡背景改为完全透明(色键),文字可调透明度,且逐帧跟随鱼
|
|
44
|
+
- 气泡文字不再因为透明色键被染绿
|
|
45
|
+
|
|
46
|
+
## [0.2.1] - 2026-10-31
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- npm 包发布:`dsh plugin --profile web add dsh-fish-pet`
|
|
50
|
+
- GitHub 与 Gitee 双仓库同步
|
|
51
|
+
- README 补齐在线安装方式(git / GitHub / npm / tgz)
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- 安装文档与实际能力不一致的说明
|
|
55
|
+
|
|
56
|
+
## [0.2.0] - 2026-10-30
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
- 首个公开版本:桌面蓝鱼(逐像素透明窗口,PowerShell + WinForms 绘制)
|
|
60
|
+
- 云端语音对话(SiliconFlow SenseVoice 识别 + DeepSeek 回复 + 本地 TTS)
|
|
61
|
+
- 右键菜单:喂食 / 语音朗读 / 语音聊天开关 / 余额 / 大小 / 退出
|
|
62
|
+
- 固定人设:名字大肥鱼,爸爸光光,妈妈丽丽
|
|
63
|
+
- 封面图、LICENSE(MIT)、`.gitignore`(密钥与日志不入库)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-fish-pet · 桌面蓝鱼
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+

|
|
4
4
|
|
|
5
5
|
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 养一条住在 Windows 桌面上的蓝鱼。
|
|
6
6
|
它不是网页贴图:是一个**逐像素透明的置顶窗口**,用 PowerShell + WinForms 逐帧绘制,还能**听你说话、跟你聊天、用中文语音回你**。
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
## 特性
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
- 交互:拖动换位置、左键喂食、右键菜单(大小 / 回家 / 语音朗读 / 退出)
|
|
14
|
-
-
|
|
15
|
-
-
|
|
12
|
+
- 桌面常驻:全屏随机游动、随机停歇、眨眼、吐泡泡、尾巴与鱼鳍摆动
|
|
13
|
+
- 交互:拖动换位置、左键喂食、右键菜单(大小 / 回家 / 语音朗读 / 提醒 / 清除记忆 / 退出)
|
|
14
|
+
- 聊天:输入框打字或语音 → 调 `deepseek-flash` → 气泡回复 + 语音朗读
|
|
15
|
+
- **语音对话**:独立"耳朵"进程用 WASAPI 采集麦克风 → 云端 Qwen3-ASR 识别 → 自动接话(说完约 1 秒内开始回)
|
|
16
|
+
- **语音朗读**:默认用微软 Edge 的 `zh-CN-XiaoyiNeural` 少女音(免费、无需 Key,可换音色/语速/音调)
|
|
17
|
+
- **情绪系统**:饥饿值、亲密度、心情(开心/委屈/饿/被冷落),会体现在语气、表情和配音上
|
|
18
|
+
- **记忆**:最近 24 条对话 + 长期摘要 + 主人交代的便签 + 定时提醒(到点主动开口)
|
|
16
19
|
- 单实例守卫、异常落盘(不弹框)、耳朵掉线自动重启、位置记忆、开机自启
|
|
17
20
|
|
|
18
21
|
## 环境要求
|
|
@@ -103,6 +106,83 @@ Set-Content "$env:USERPROFILE\.dsh\fish-pet\siliconflow.key" -Value "sk-你的
|
|
|
103
106
|
|
|
104
107
|
> ⚠️ 不要把真实 key 写进 README 或任何提交到仓库的文件——仓库是公开的。
|
|
105
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
|
+
|
|
106
186
|
### 获取 SiliconFlow key
|
|
107
187
|
|
|
108
188
|
1. 打开 <https://cloud.siliconflow.cn>,手机号注册登录
|
package/assets/asr-listener.ps1
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
# asr-listener.ps1 ·
|
|
2
|
-
|
|
1
|
+
# asr-listener.ps1 · 云端耳朵(VAD 连续采集版)
|
|
2
|
+
# 一次连续采集,检测到"说完"立刻上传,不再固定切片
|
|
3
|
+
param(
|
|
4
|
+
[int]$MaxMs = 10000,
|
|
5
|
+
[int]$SilenceMs = 600,
|
|
6
|
+
[int]$Boost = 1,
|
|
7
|
+
[int]$SilenceGate = 700
|
|
8
|
+
)
|
|
3
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 { }
|
|
4
15
|
$ErrorActionPreference = 'Stop'
|
|
16
|
+
try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
|
|
5
17
|
$DataDir = Join-Path $env:USERPROFILE '.dsh\fish-pet'
|
|
6
18
|
if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Force -Path $DataDir | Out-Null }
|
|
7
19
|
$OutFile = Join-Path $DataDir 'asr.log'
|
|
@@ -9,17 +21,33 @@ $LevelFile = Join-Path $DataDir 'asr.level'
|
|
|
9
21
|
$ErrFile = Join-Path $DataDir 'asr.err'
|
|
10
22
|
$KeyFile = Join-Path $DataDir 'siliconflow.key'
|
|
11
23
|
|
|
24
|
+
# 读外部配置(config.json 里的 voice 段)
|
|
25
|
+
$CfgPath = Join-Path $DataDir 'config.json'
|
|
26
|
+
if (Test-Path $CfgPath) {
|
|
27
|
+
try {
|
|
28
|
+
$cj = Get-Content -LiteralPath $CfgPath -Raw | ConvertFrom-Json
|
|
29
|
+
if ($cj.voice) {
|
|
30
|
+
if ($cj.voice.silenceGate) { $SilenceGate = [int]$cj.voice.silenceGate }
|
|
31
|
+
if ($cj.voice.boost) { $Boost = [int]$cj.voice.boost }
|
|
32
|
+
if ($cj.voice.engine) { $script:engine = [string]$cj.voice.engine }
|
|
33
|
+
if ($cj.voice.localConf) { $script:localConf = [double]$cj.voice.localConf }
|
|
34
|
+
}
|
|
35
|
+
} catch { }
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
Add-Type -TypeDefinition @"
|
|
13
39
|
using System;
|
|
40
|
+
using System.Collections.Generic;
|
|
14
41
|
using System.Runtime.InteropServices;
|
|
15
42
|
using System.Threading;
|
|
16
|
-
|
|
43
|
+
|
|
44
|
+
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class VadEnum { }
|
|
17
45
|
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
18
|
-
interface
|
|
46
|
+
interface IVadEnum { int EnumAudioEndpoints(int f, int s, out IntPtr c); int GetDefaultAudioEndpoint(int f, int r, out IVadDev d); }
|
|
19
47
|
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
20
|
-
interface
|
|
48
|
+
interface IVadDev { int Activate(ref Guid iid, int ctx, IntPtr p, [MarshalAs(UnmanagedType.IUnknown)] out object o); }
|
|
21
49
|
[ComImport, Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
22
|
-
interface
|
|
50
|
+
interface IVadClient {
|
|
23
51
|
int Initialize(int share, int flags, long dur, long period, IntPtr fmt, IntPtr sess);
|
|
24
52
|
int GetBufferSize(out uint frames); int GetStreamLatency(out long lat); int GetCurrentPadding(out uint pad);
|
|
25
53
|
int IsFormatSupported(int share, IntPtr fmt, out IntPtr closest); int GetMixFormat(out IntPtr fmt);
|
|
@@ -27,68 +55,116 @@ interface IAudioClient {
|
|
|
27
55
|
int GetService(ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object o);
|
|
28
56
|
}
|
|
29
57
|
[ComImport, Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
30
|
-
interface
|
|
58
|
+
interface IVadCapture {
|
|
31
59
|
int GetBuffer(out IntPtr data, out uint frames, out uint flags, out ulong devpos, out ulong qpc);
|
|
32
60
|
int ReleaseBuffer(uint frames); int GetNextPacketSize(out uint frames);
|
|
33
61
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
62
|
+
|
|
63
|
+
public class EarVad
|
|
64
|
+
{
|
|
65
|
+
public static byte[] Pcm;
|
|
66
|
+
public static int RawPeak;
|
|
67
|
+
public static int DurationMs;
|
|
68
|
+
|
|
69
|
+
public static bool Capture(int maxMs, int gate, int silenceMs, int boost, int role)
|
|
70
|
+
{
|
|
71
|
+
Pcm = new byte[0]; RawPeak = 0; DurationMs = 0;
|
|
72
|
+
var en = (IVadEnum)(new VadEnum());
|
|
73
|
+
IVadDev dev;
|
|
74
|
+
if (en.GetDefaultAudioEndpoint(1, role, out dev) != 0) { if (en.GetDefaultAudioEndpoint(1, 0, out dev) != 0) return false; }
|
|
75
|
+
Guid iid = typeof(IVadClient).GUID; object o; dev.Activate(ref iid, 1, IntPtr.Zero, out o);
|
|
76
|
+
var ac = (IVadClient)o;
|
|
77
|
+
IntPtr fmt; ac.GetMixFormat(out fmt);
|
|
78
|
+
int rate = Marshal.ReadInt32(fmt, 4);
|
|
79
|
+
short ch = Marshal.ReadInt16(fmt, 2);
|
|
80
|
+
if (ac.Initialize(0, 0, 5000000, 0, fmt, IntPtr.Zero) != 0) return false;
|
|
81
|
+
Guid ci = typeof(IVadCapture).GUID; object co; ac.GetService(ref ci, out co);
|
|
82
|
+
var cc = (IVadCapture)co;
|
|
83
|
+
if (ac.Start() != 0) return false;
|
|
84
|
+
|
|
85
|
+
var all = new List<float>();
|
|
86
|
+
var pre = new List<float>();
|
|
87
|
+
bool started = false;
|
|
88
|
+
int silent = 0, total = 0;
|
|
89
|
+
var b4 = new byte[4];
|
|
90
|
+
var end = DateTime.UtcNow.AddMilliseconds(maxMs + 1500);
|
|
91
|
+
|
|
92
|
+
while (DateTime.UtcNow < end && total < maxMs)
|
|
93
|
+
{
|
|
94
|
+
uint pkt;
|
|
95
|
+
if (cc.GetNextPacketSize(out pkt) != 0 || pkt == 0) { Thread.Sleep(3); continue; }
|
|
96
|
+
IntPtr data; uint frames, flags; ulong dp, qp;
|
|
97
|
+
if (cc.GetBuffer(out data, out frames, out flags, out dp, out qp) != 0) break;
|
|
98
|
+
|
|
99
|
+
int n = (int)frames * ch * 4;
|
|
100
|
+
float peak = 0;
|
|
101
|
+
var buf = new float[frames * ch];
|
|
102
|
+
int k = 0;
|
|
103
|
+
for (int i = 0; i + 3 < n; i += 4)
|
|
104
|
+
{
|
|
105
|
+
b4[0] = Marshal.ReadByte(data, i); b4[1] = Marshal.ReadByte(data, i+1);
|
|
106
|
+
b4[2] = Marshal.ReadByte(data, i+2); b4[3] = Marshal.ReadByte(data, i+3);
|
|
107
|
+
float f = BitConverter.ToSingle(b4, 0);
|
|
108
|
+
buf[k++] = f;
|
|
109
|
+
float a = Math.Abs(f); if (a > peak) peak = a;
|
|
110
|
+
}
|
|
111
|
+
cc.ReleaseBuffer(frames);
|
|
112
|
+
|
|
113
|
+
int packetMs = (int)(frames * 1000 / rate);
|
|
114
|
+
total += packetMs;
|
|
115
|
+
int peakInt = (int)(peak * 32767f);
|
|
116
|
+
|
|
117
|
+
if (!started)
|
|
118
|
+
{
|
|
119
|
+
pre.AddRange(buf);
|
|
120
|
+
int keep = rate * ch / 3; // 保留最近约 300ms 作为前置
|
|
121
|
+
if (pre.Count > keep * 2) pre.RemoveRange(0, pre.Count - keep);
|
|
122
|
+
if (peakInt >= gate)
|
|
123
|
+
{
|
|
124
|
+
started = true;
|
|
125
|
+
all.AddRange(pre);
|
|
126
|
+
pre.Clear();
|
|
127
|
+
silent = 0;
|
|
128
|
+
if (peakInt > RawPeak) RawPeak = peakInt;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
{
|
|
133
|
+
all.AddRange(buf);
|
|
134
|
+
if (peakInt > RawPeak) RawPeak = peakInt;
|
|
135
|
+
if (peakInt < gate) silent += packetMs; else silent = 0;
|
|
136
|
+
if (silent >= silenceMs) break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
ac.Stop();
|
|
140
|
+
if (!started || all.Count < rate / 2) return false;
|
|
141
|
+
|
|
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
|
+
|
|
149
|
+
double step = (double)rate / 16000.0;
|
|
150
|
+
int framesIn = all.Count / ch;
|
|
151
|
+
int outCount = (int)(framesIn / step);
|
|
152
|
+
if (outCount < 1600) return false;
|
|
153
|
+
var pcmBytes = new byte[outCount * 2];
|
|
154
|
+
for (int i = 0; i < outCount; i++)
|
|
155
|
+
{
|
|
156
|
+
int s0 = (int)(i * step);
|
|
157
|
+
int s1 = (int)Math.Min(framesIn - 1, (i + 1) * step);
|
|
158
|
+
double sum = 0; int cnt = 0;
|
|
159
|
+
for (int f = s0; f <= s1; f++) { sum += all[f * ch]; cnt++; }
|
|
160
|
+
int v = cnt > 0 ? (int)((sum / cnt) * 32767.0 * boost * gain2) : 0;
|
|
161
|
+
if (v > 32767) v = 32767; if (v < -32768) v = -32768;
|
|
162
|
+
pcmBytes[i*2] = (byte)(v & 0xFF); pcmBytes[i*2+1] = (byte)((v >> 8) & 0xFF);
|
|
163
|
+
}
|
|
164
|
+
Pcm = pcmBytes;
|
|
165
|
+
DurationMs = (int)(outCount * 1000 / 16000);
|
|
166
|
+
return true;
|
|
89
167
|
}
|
|
90
|
-
Pcm = outp; return true;
|
|
91
|
-
}
|
|
92
168
|
}
|
|
93
169
|
"@
|
|
94
170
|
|
|
@@ -103,42 +179,149 @@ function Write-Wav([string]$path, [byte[]]$pcm) {
|
|
|
103
179
|
$bw.Write($pcm); $bw.Close(); $fs.Close()
|
|
104
180
|
}
|
|
105
181
|
|
|
182
|
+
function Read-Key([string]$name) {
|
|
183
|
+
$dir = Join-Path $env:USERPROFILE '.dsh\fish-pet'
|
|
184
|
+
$enc = Join-Path $dir ($name + '.key.enc')
|
|
185
|
+
if (Test-Path $enc) {
|
|
186
|
+
try {
|
|
187
|
+
$sec = ConvertTo-SecureString (Get-Content -LiteralPath $enc -Raw).Trim()
|
|
188
|
+
$b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
|
|
189
|
+
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }
|
|
190
|
+
} catch { }
|
|
191
|
+
}
|
|
192
|
+
$plain = Join-Path $dir ($name + '.key')
|
|
193
|
+
if (Test-Path $plain) { return (Get-Content -LiteralPath $plain -Raw -ErrorAction SilentlyContinue).Trim() }
|
|
194
|
+
return $null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# 用 Windows 自带中文识别(免费、离线)
|
|
199
|
+
function Try-Local([string]$wavPath) {
|
|
200
|
+
try {
|
|
201
|
+
Add-Type -AssemblyName System.Speech -ErrorAction Stop
|
|
202
|
+
$eng = [System.Speech.Recognition.SpeechRecognitionEngine]::InstalledRecognizers() | Where-Object { $_.Culture.Name -eq 'zh-CN' } | Select-Object -First 1
|
|
203
|
+
if (-not $eng) { return $null }
|
|
204
|
+
$rec = New-Object System.Speech.Recognition.SpeechRecognitionEngine($eng)
|
|
205
|
+
$rec.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar))
|
|
206
|
+
$rec.SetInputToWaveFile($wavPath)
|
|
207
|
+
$r = $rec.Recognize()
|
|
208
|
+
$rec.Dispose()
|
|
209
|
+
if ($r -and $r.Text) { return @{ text = [string]$r.Text; conf = [double]$r.Confidence } }
|
|
210
|
+
} catch { }
|
|
211
|
+
return $null
|
|
212
|
+
}
|
|
213
|
+
function Rotate-Log([string]$path) {
|
|
214
|
+
try {
|
|
215
|
+
if (Test-Path $path) {
|
|
216
|
+
if ((Get-Item $path).Length -gt 1MB) {
|
|
217
|
+
$bak = $path + '.1'
|
|
218
|
+
Remove-Item $bak -Force -ErrorAction SilentlyContinue
|
|
219
|
+
Move-Item $path $bak -Force
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch { }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
$script:http = $null
|
|
226
|
+
$script:lastFishCheck = Get-Date
|
|
106
227
|
Add-Content -LiteralPath $OutFile -Value ("# ear(start) pid=" + $PID) -Encoding UTF8
|
|
107
228
|
|
|
108
229
|
while ($true) {
|
|
109
230
|
try {
|
|
110
|
-
|
|
231
|
+
Rotate-Log $OutFile
|
|
232
|
+
Rotate-Log $LevelFile
|
|
233
|
+
|
|
111
234
|
if (Test-Path (Join-Path $DataDir 'asr.talking')) { Start-Sleep -Milliseconds 200; continue }
|
|
112
235
|
$pf = Join-Path $DataDir 'asr.pause'
|
|
113
236
|
if (Test-Path $pf) {
|
|
114
237
|
$age = ((Get-Date) - (Get-Item $pf).LastWriteTime).TotalSeconds
|
|
115
|
-
if ($age -lt
|
|
238
|
+
if ($age -lt 2.5) { Start-Sleep -Milliseconds 200; continue }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (((Get-Date) - $script:lastFishCheck).TotalSeconds -gt 30) {
|
|
242
|
+
$script:http = $null
|
|
243
|
+
$script:lastFishCheck = Get-Date
|
|
244
|
+
if (Test-Path (Join-Path $DataDir 'asr.stop')) { break }
|
|
245
|
+
$fishAlive = $false
|
|
246
|
+
try {
|
|
247
|
+
$pfile = Join-Path $DataDir 'pet.pid'
|
|
248
|
+
if (Test-Path $pfile) {
|
|
249
|
+
$pj = Get-Content -LiteralPath $pfile -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
|
|
250
|
+
if ($pj -and $pj.pid) { $fishAlive = [bool](Get-Process -Id ([int]$pj.pid) -ErrorAction SilentlyContinue) }
|
|
251
|
+
}
|
|
252
|
+
} catch { }
|
|
253
|
+
if (-not $fishAlive) {
|
|
254
|
+
$vbs = Join-Path $PSScriptRoot 'start-pet.vbs'
|
|
255
|
+
if (-not (Test-Path $vbs)) { $vbs = Join-Path $PSScriptRoot 'fish-pet.vbs' }
|
|
256
|
+
if (Test-Path $vbs) { try { Start-Process -FilePath 'wscript.exe' -ArgumentList ('"' + $vbs + '"') -WindowStyle Hidden } catch { } }
|
|
257
|
+
}
|
|
116
258
|
}
|
|
259
|
+
|
|
117
260
|
$key = $env:SILICONFLOW_API_KEY
|
|
118
|
-
if (-not $key
|
|
261
|
+
if (-not $key) { $key = Read-Key 'siliconflow' }
|
|
119
262
|
if ($key) { $key = $key.Trim() }
|
|
120
263
|
if (-not $key) { Start-Sleep -Seconds 2; continue }
|
|
121
264
|
|
|
122
|
-
$ok = [
|
|
123
|
-
if (-not $ok) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
265
|
+
$ok = [EarVad]::Capture($MaxMs, $SilenceGate, $SilenceMs, $Boost, 2)
|
|
266
|
+
if (-not $ok) {
|
|
267
|
+
Start-Sleep -Milliseconds 80
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
$pcm = [EarVad]::Pcm
|
|
272
|
+
$ms = [EarVad]::DurationMs
|
|
273
|
+
Add-Content -LiteralPath $LevelFile -Value ((Get-Date -Format 'HH:mm:ss') + "`traw=" + [EarVad]::RawPeak + "`tms=" + $ms) -Encoding UTF8
|
|
274
|
+
if ($pcm.Length -lt 3200) { continue }
|
|
275
|
+
if ($ms -lt 400 -or [EarVad]::RawPeak -lt $SilenceGate) { continue }
|
|
276
|
+
|
|
277
|
+
Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`tH`t(听到声音 " + $ms + "ms)") -Encoding UTF8
|
|
130
278
|
$wav = Join-Path $DataDir ('chunk-' + [guid]::NewGuid().ToString('N') + '.wav')
|
|
131
279
|
Write-Wav $wav $pcm
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if ($
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
280
|
+
try { Copy-Item $wav (Join-Path $DataDir 'last-chunk.wav') -Force } catch { }
|
|
281
|
+
|
|
282
|
+
$engine = if ($script:engine) { $script:engine } else { 'auto' }
|
|
283
|
+
$confMin = if ($script:localConf) { $script:localConf } else { 0.55 }
|
|
284
|
+
$text = $null
|
|
285
|
+
$via = ''
|
|
286
|
+
if ($engine -ne 'cloud') {
|
|
287
|
+
$lr = Try-Local $wav
|
|
288
|
+
if ($lr -and $lr.text) {
|
|
289
|
+
if ($engine -eq 'local' -or $lr.conf -ge $confMin) { $text = $lr.text; $via = 'local' }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
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 { }
|
|
310
|
+
} catch { try { Add-Content -LiteralPath $ErrFile -Value ((Get-Date -Format 'HH:mm:ss') + ' HTTP ' + $_.Exception.Message) -Encoding UTF8 } catch { } }
|
|
311
|
+
if ($json) {
|
|
312
|
+
try { $obj = $json | ConvertFrom-Json; $text = [string]$obj.text; $via = 'cloud' } catch { }
|
|
139
313
|
}
|
|
140
314
|
}
|
|
141
|
-
|
|
315
|
+
Remove-Item -LiteralPath $wav -Force -ErrorAction SilentlyContinue
|
|
316
|
+
Add-Content -LiteralPath $LevelFile -Value ((Get-Date -Format 'HH:mm:ss') + "`tengine=" + $via + "`ttext=" + $text) -Encoding UTF8
|
|
317
|
+
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
|
+
}
|
|
321
|
+
if ($text -and ($text -match '[\u4e00-\u9fa5]' -or $text.Length -ge 4)) {
|
|
322
|
+
Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`t1`t" + $text) -Encoding UTF8
|
|
323
|
+
}
|
|
324
|
+
Start-Sleep -Milliseconds 60
|
|
142
325
|
} catch {
|
|
143
326
|
Add-Content -LiteralPath $ErrFile -Value ((Get-Date -Format 'HH:mm:ss') + ' ' + $_.Exception.Message) -Encoding UTF8
|
|
144
327
|
Start-Sleep -Milliseconds 800
|