dsh-fish-pet 0.2.0 → 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 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
- ![dsh-fish-pet](docs/banner.png)
3
+ ![dsh-fish-pet](https://gitee.com/adengou/dsh-fish-pet/raw/main/docs/banner.png)
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
- - 聊天:输入框打字 → 调 `deepseek-flash` → 气泡回复 + 语音朗读
15
- - **语音对话**:独立“耳朵”进程用 WASAPI 采集麦克风 → 云端 SenseVoice 识别 → 自动接话
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
  ## 环境要求
@@ -34,29 +37,36 @@
34
37
  dsh plugin --profile web add "git+https://gitee.com/adengou/dsh-fish-pet.git"
35
38
  ```
36
39
 
37
- **② 从本地目录 / 离线 tgz 装**
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 装**
38
49
 
39
50
  ```powershell
40
51
  dsh plugin --profile web add "file:C:/path/to/dsh-fish-pet"
41
52
  dsh plugin --profile web add "file:C:/path/to/dsh-fish-pet-0.2.0.tgz"
42
53
  ```
43
54
 
44
- **③ 从服务器上的 tgz 直链装**(把 tgz 传到 Release / 图床等可直链的地方)
55
+ **④ 从服务器上的 tgz 直链装**(把 tgz 传到 Release / 图床等可直链的地方)
45
56
 
46
57
  ```powershell
47
58
  dsh plugin --profile web add "https://你的地址/dsh-fish-pet-0.2.0.tgz"
48
59
  ```
49
60
 
50
- > 版本要求:DSH `>= 0.2.1-rc.5`。版本不符时插件行会被判不兼容而禁用。
61
+ **⑤ 从 GitHub 装**
51
62
 
52
- **暂未提供的方式(等发布后可用)**
63
+ ```powershell
64
+ dsh plugin --profile web add github:adengou/dsh-fish-pet
65
+ ```
53
66
 
54
- | 方式 | 命令 | 现状 |
55
- | --- | --- | --- |
56
- | GitHub | `dsh plugin --profile web add github:adengou/dsh-fish-pet` | ❌ 仓库尚未同步到 GitHub |
57
- | npm | `dsh plugin --profile web add dsh-fish-pet` | ❌ 包尚未发布到 npm(名称目前未被占用) |
67
+ > 仓库地址:https://github.com/adengou/dsh-fish-pet(与 Gitee 同步维护)
58
68
 
59
- 发布后会在这里补上,并更新本表。
69
+ > 版本要求:DSH `>= 0.2.1-rc.5`。版本不符时插件行会被判不兼容而禁用。
60
70
 
61
71
  ### 方式二:独立运行(不装 DSH 也能用)
62
72
 
@@ -96,6 +106,83 @@ Set-Content "$env:USERPROFILE\.dsh\fish-pet\siliconflow.key" -Value "sk-你的
96
106
 
97
107
  > ⚠️ 不要把真实 key 写进 README 或任何提交到仓库的文件——仓库是公开的。
98
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
+
99
186
  ### 获取 SiliconFlow key
100
187
 
101
188
  1. 打开 <https://cloud.siliconflow.cn>,手机号注册登录
@@ -1,7 +1,19 @@
1
- # asr-listener.ps1 · 云端耳朵:WASAPI 采集 → WAV → SiliconFlow SenseVoice → 结果落盘
2
- param([int]$ChunkMs = 6500, [int]$Boost = 1, [int]$SilenceGate = 1200)
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
- [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class MMEnum { }
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 IMMDeviceEnumerator { int EnumAudioEndpoints(int f, int s, out IntPtr c); int GetDefaultAudioEndpoint(int f, int r, out IMMDevice d); }
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 IMMDevice { int Activate(ref Guid iid, int ctx, IntPtr p, [MarshalAs(UnmanagedType.IUnknown)] out object o); }
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 IAudioClient {
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 IAudioCaptureClient {
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
- public class Ear {
35
- public static byte[] Pcm; public static int Peak; public static int RawPeak;
36
- public static bool Record(int ms, int boost, int role) {
37
- Peak = 0; Pcm = new byte[0];
38
- var en = (IMMDeviceEnumerator)(new MMEnum());
39
- IMMDevice dev;
40
- if (en.GetDefaultAudioEndpoint(1, role, out dev) != 0) { if (en.GetDefaultAudioEndpoint(1, 0, out dev) != 0) return false; }
41
- Guid iid = typeof(IAudioClient).GUID; object o; dev.Activate(ref iid, 1, IntPtr.Zero, out o);
42
- var ac = (IAudioClient)o;
43
- IntPtr fmt; ac.GetMixFormat(out fmt);
44
- int rate = Marshal.ReadInt32(fmt, 4); short ch = Marshal.ReadInt16(fmt, 2);
45
- if (ac.Initialize(0, 0, 5000000, 0, fmt, IntPtr.Zero) != 0) return false;
46
- Guid ci = typeof(IAudioCaptureClient).GUID; object co; ac.GetService(ref ci, out co);
47
- var cc = (IAudioCaptureClient)co;
48
- ac.Start();
49
- var samples = new System.Collections.Generic.List<float>();
50
- var b4 = new byte[4];
51
- var end = DateTime.UtcNow.AddMilliseconds(ms);
52
- while (DateTime.UtcNow < end) {
53
- uint pkt; if (cc.GetNextPacketSize(out pkt) != 0 || pkt == 0) { Thread.Sleep(3); continue; }
54
- IntPtr data; uint frames, flags; ulong dp, qp;
55
- if (cc.GetBuffer(out data, out frames, out flags, out dp, out qp) != 0) break;
56
- int n = (int)frames * ch * 4;
57
- for (int i = 0; i + 3 < n; i += 4) {
58
- b4[0] = Marshal.ReadByte(data, i); b4[1] = Marshal.ReadByte(data, i+1);
59
- b4[2] = Marshal.ReadByte(data, i+2); b4[3] = Marshal.ReadByte(data, i+3);
60
- samples.Add(BitConverter.ToSingle(b4, 0));
61
- }
62
- cc.ReleaseBuffer(frames);
63
- }
64
- ac.Stop();
65
- if (samples.Count < 320) return false;
66
- double step = (double)rate / 16000.0;
67
- int framesIn = samples.Count / ch;
68
- int outCount = (int)(framesIn / step);
69
- if (outCount < 160) return false;
70
- var tmp = new int[outCount];
71
- int maxAbs = 0;
72
- for (int i = 0; i < outCount; i++) {
73
- int s0 = (int)(i * step);
74
- int s1 = (int)Math.Min(framesIn - 1, (i + 1) * step);
75
- double sum = 0; int cnt = 0;
76
- for (int f = s0; f <= s1; f++) { sum += samples[f * ch]; cnt++; }
77
- int v = cnt > 0 ? (int)((sum / cnt) * 32767.0 * boost) : 0;
78
- tmp[i] = v; int a = Math.Abs(v); if (a > maxAbs) maxAbs = a;
79
- }
80
- RawPeak = maxAbs;
81
- double gain = 1.0;
82
- if (maxAbs > 200) { gain = Math.Min(12.0, 20000.0 / maxAbs); }
83
- var outp = new byte[outCount * 2];
84
- for (int i = 0; i < outCount; i++) {
85
- int v = (int)(tmp[i] * gain);
86
- if (v > 32767) v = 32767; if (v < -32768) v = -32768;
87
- int a = Math.Abs(v); if (a > Peak) Peak = a;
88
- outp[i*2] = (byte)(v & 0xFF); outp[i*2+1] = (byte)((v >> 8) & 0xFF);
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 1.2) { Start-Sleep -Milliseconds 200; continue }
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 -and (Test-Path $KeyFile)) { $key = (Get-Content -LiteralPath $KeyFile -Raw -ErrorAction SilentlyContinue) }
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 = [Ear]::Record($ChunkMs, $Boost, 2)
123
- if (-not $ok) { Start-Sleep -Milliseconds 300; continue }
124
- $peak = [Ear]::Peak
125
- $pcm = [Ear]::Pcm
126
- $raw = [Ear]::RawPeak
127
- Add-Content -LiteralPath $LevelFile -Value ((Get-Date -Format 'HH:mm:ss') + "`traw=" + $raw + "`tpeak=" + $peak) -Encoding UTF8
128
- if ($raw -lt $SilenceGate -or $pcm.Length -lt 3200) { Start-Sleep -Milliseconds 120; continue }
129
- Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`tH`t(听到声音)") -Encoding UTF8
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
- $json = & curl.exe -s -X POST 'https://api.siliconflow.cn/v1/audio/transcriptions' -H ("Authorization: Bearer " + $key) -F ("file=@" + $wav) -F 'model=FunAudioLLM/SenseVoiceSmall' 2>$null | Out-String
133
- Remove-Item -LiteralPath $wav -Force -ErrorAction SilentlyContinue
134
- if ($json) {
135
- $obj = $json | ConvertFrom-Json
136
- $text = [string]$obj.text
137
- if ($text -and ($text -match '[\u4e00-\u9fa5]' -or $text.Length -ge 5)) {
138
- Add-Content -LiteralPath $OutFile -Value ((Get-Date -Format 'HH:mm:ss') + "`t1`t" + $text.Trim()) -Encoding UTF8
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
- Start-Sleep -Milliseconds 120
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