dsh-dafeiyu-emotion 0.3.0 → 0.3.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 +14 -0
- package/README.md +32 -3
- package/cordis.patch.yml +9 -0
- package/docs/MARKETPLACE.md +1 -1
- package/docs/RELEASE-0.3.1.md +39 -0
- package/docs/RELEASE-0.3.2.md +15 -0
- package/lib/client.js +202 -1
- package/package.json +2 -2
- package/src/index.js +419 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
- Added `ttsOnlyAfterVoice` (default `true`): spoken replies are limited to assistant messages within two minutes of a voice input, so normal coding chats do not read every answer aloud.
|
|
6
|
+
- Added the matching settings toggle.
|
|
7
|
+
|
|
8
|
+
## 0.3.1
|
|
9
|
+
|
|
10
|
+
- Added spoken assistant replies. The host now forwards `assistant/message` text over `/plugins/dsh-dafeiyu-emotion/events`; the client plays it with TTS.
|
|
11
|
+
- Added local Piper TTS (`zh_CN-huayan-medium`) as the default engine, with Edge TTS as an optional online engine and browser `speechSynthesis` as the final fallback.
|
|
12
|
+
- Added `ttsEnabled`, `ttsEngine`, `ttsVoice`, `ttsRate`, `ttsPitch` and `ttsPiperModel` settings, including a voice selector and model path field.
|
|
13
|
+
- Added `POST /plugins/dsh-dafeiyu-emotion/tts`, returning WAV for Piper and MP3 for Edge.
|
|
14
|
+
- Edge TTS failures and missing Piper models fall back to local browser speech instead of breaking the reply.
|
|
15
|
+
- Added TTS handler, assistant-text extraction, SSE broadcast and markdown-cleaning tests.
|
|
16
|
+
|
|
3
17
|
## 0.3.0
|
|
4
18
|
|
|
5
19
|
- Added `empathyStyle` with four reply strategies: gentle, direct, humorous and concise. The choice is persisted and injected into the emotion-aware prompt.
|
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
- 📈 多段语音的情绪时间线,可悬停查看每段文本、情绪和置信度;
|
|
77
77
|
- 🔐 设置页内的隐私与运行状态:最近音频大小、模型加载、worker PID、临时文件清理;
|
|
78
78
|
- 🔌 本机 `/context` 接口,其他插件可以读取最近一次情绪上下文;
|
|
79
|
+
- 🔊 大肥鱼回复语音:Piper 本地模型默认朗读,Edge / 浏览器语音可选;
|
|
79
80
|
- 🐋 大肥鱼主题的“鱼耳”入口,和普通对话共用同一个 composer;
|
|
80
81
|
- 🧠 情绪标签:喜悦 / 低落 / 生气 / 紧张 / 惊讶 / 厌烦 / 平静;
|
|
81
82
|
- 📈 额外给出 confidence、valence、arousal 和语气摘要;
|
|
@@ -115,13 +116,34 @@ dsh plugin --profile desktop add github:LiDu-china/dsh-dafeiyu-emotion
|
|
|
115
116
|
### 通过 Release tarball
|
|
116
117
|
|
|
117
118
|
```bash
|
|
118
|
-
curl -L -o dsh-dafeiyu-emotion-0.3.
|
|
119
|
-
https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.3.
|
|
120
|
-
dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.3.
|
|
119
|
+
curl -L -o dsh-dafeiyu-emotion-0.3.2.tgz \
|
|
120
|
+
https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.3.2/dsh-dafeiyu-emotion-0.3.2.tgz
|
|
121
|
+
dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.3.2.tgz"
|
|
121
122
|
```
|
|
122
123
|
|
|
123
124
|
> DSH Desktop 的 profile 由 Electron 应用管理;命令行安装请使用桌面应用自带的 `dsh` 命令。
|
|
124
125
|
|
|
126
|
+
## 大肥鱼语音回复
|
|
127
|
+
|
|
128
|
+
助手回复生成后,插件会通过本机事件流把文本交给 TTS,让大肥鱼把回复读出来。
|
|
129
|
+
|
|
130
|
+
默认使用 **Piper 本地模型**,音色是 `zh_CN-huayan-medium`。它不联网,延迟低,适合日常对话:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
python -m pip install piper-tts
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
把模型放到默认目录:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
~/.dsh/dafeiyu-emotion/tts/piper/zh_CN-huayan-medium.onnx
|
|
140
|
+
~/.dsh/dafeiyu-emotion/tts/piper/zh_CN-huayan-medium.onnx.json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
也可以在设置页填写自定义 Piper 模型路径。模型缺失或合成失败时,会自动回退到浏览器 `speechSynthesis`,不会让回复静默丢失。
|
|
144
|
+
|
|
145
|
+
如果更喜欢更自然的在线音色,可以把引擎切到 **Edge TTS**,默认音色 `zh-CN-XiaoyiNeural`。Edge 需要网络,且回复文本会发送到 Microsoft;如果网络或服务不可用,同样会回退到浏览器语音。
|
|
146
|
+
|
|
125
147
|
## 设置页与侧边栏
|
|
126
148
|
|
|
127
149
|
插件会把自己的设置卡片注册到 DSH 的插件设置页。打开:
|
|
@@ -190,6 +212,13 @@ dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.3.0.tgz"
|
|
|
190
212
|
| `pythonPath` | 空 | 自定义 Python;空则自动寻找 `.venv-ser` |
|
|
191
213
|
| `language` | `zh` | 识别语言:`zh` / `en` / `auto` |
|
|
192
214
|
| `empathyStyle` | `gentle` | 共情策略:`gentle` / `direct` / `humorous` / `concise` |
|
|
215
|
+
| `ttsEnabled` | `true` | 是否朗读大肥鱼回复 |
|
|
216
|
+
| `ttsOnlyAfterVoice` | `true` | 只朗读录音后两分钟内的回复 |
|
|
217
|
+
| `ttsEngine` | `piper` | `piper` 本地 / `edge` 在线 / `browser` 系统语音 |
|
|
218
|
+
| `ttsVoice` | `zh-CN-XiaoyiNeural` | Edge TTS 音色 |
|
|
219
|
+
| `ttsRate` | `+6%` | Piper/Edge 语速 |
|
|
220
|
+
| `ttsPitch` | `+12Hz` | Edge 音调 |
|
|
221
|
+
| `ttsPiperModel` | 空 | 自定义 Piper 模型路径;空则用默认目录 |
|
|
193
222
|
| `maxAudioBytes` | 12 MB | 单次录音大小上限 |
|
|
194
223
|
| `startupTimeoutMs` | 180000 | 首次加载增强后端的最长等待 |
|
|
195
224
|
| `requestTimeoutMs` | 180000 | 单次分析请求超时 |
|
package/cordis.patch.yml
CHANGED
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
pythonPath: ""
|
|
30
30
|
language: zh
|
|
31
31
|
empathyStyle: gentle
|
|
32
|
+
ttsEnabled: true
|
|
33
|
+
ttsOnlyAfterVoice: true
|
|
34
|
+
ttsEngine: piper
|
|
35
|
+
ttsVoice: zh-CN-XiaoyiNeural
|
|
36
|
+
ttsRate: "+6%"
|
|
37
|
+
ttsPitch: "+12Hz"
|
|
38
|
+
ttsCommand: ""
|
|
39
|
+
ttsPiperModel: ""
|
|
40
|
+
ttsPiperCommand: ""
|
|
32
41
|
maxAudioBytes: 12582912
|
|
33
42
|
startupTimeoutMs: 180000
|
|
34
43
|
requestTimeoutMs: 180000
|
package/docs/MARKETPLACE.md
CHANGED
|
@@ -72,7 +72,7 @@ https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.3/dsh-d
|
|
|
72
72
|
dsh plugin --profile desktop add dsh-dafeiyu-emotion
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Published as `dsh-dafeiyu-emotion@0.3.
|
|
75
|
+
Published as `dsh-dafeiyu-emotion@0.3.2` on npm. The 1024Store entry refreshes its one-click install automatically. GitHub-source and Release-tarball installs remain available as fallbacks.
|
|
76
76
|
|
|
77
77
|
## Privacy
|
|
78
78
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
## 0.3.1
|
|
2
|
+
|
|
3
|
+
大肥鱼现在会说话了。
|
|
4
|
+
|
|
5
|
+
### 语音回复
|
|
6
|
+
|
|
7
|
+
- Host 监听 `assistant/message`,把回复文本通过 `/plugins/dsh-dafeiyu-emotion/events` 推给客户端。
|
|
8
|
+
- 客户端收到后朗读回复,默认使用 **Piper 本地模型** `zh_CN-huayan-medium`。
|
|
9
|
+
- 可选 **Edge TTS**(`zh-CN-XiaoyiNeural`,更自然,但需要网络),以及 **浏览器 speechSynthesis** 兜底。
|
|
10
|
+
- Piper/Edge 失败时自动回退浏览器语音,不会让回复静默丢失。
|
|
11
|
+
|
|
12
|
+
### 设置
|
|
13
|
+
|
|
14
|
+
- 朗读开关、合成引擎、Edge 音色、语速、音调、Piper 模型路径。
|
|
15
|
+
- 默认 `ttsEnabled: true`,Piper 模型不存在时自动使用浏览器语音。
|
|
16
|
+
|
|
17
|
+
### 本地模型
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install piper-tts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
默认模型目录:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
~/.dsh/dafeiyu-emotion/tts/piper/zh_CN-huayan-medium.onnx
|
|
27
|
+
~/.dsh/dafeiyu-emotion/tts/piper/zh_CN-huayan-medium.onnx.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
可以在设置页填写自定义路径。
|
|
31
|
+
|
|
32
|
+
### 接口
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
POST /plugins/dsh-dafeiyu-emotion/tts
|
|
36
|
+
GET /plugins/dsh-dafeiyu-emotion/events
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`/tts` 返回 WAV(Piper)或 MP3(Edge),只允许本机访问。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## 0.3.2
|
|
2
|
+
|
|
3
|
+
语音回复的体验收口。
|
|
4
|
+
|
|
5
|
+
- 新增 `ttsOnlyAfterVoice`,默认开启:只在录音后两分钟内的助手回复朗读。
|
|
6
|
+
- 普通代码对话、没有录音触发的回复不再自动朗读。
|
|
7
|
+
- 设置页新增「只朗读语音触发的回复」开关。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
dsh plugin --profile desktop add dsh-dafeiyu-emotion
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
升级后请完全退出并重新启动 the agent harness。
|
package/lib/client.js
CHANGED
|
@@ -10,6 +10,8 @@ window.__ModuleLoader__.load({
|
|
|
10
10
|
const ANALYZE_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/analyze'
|
|
11
11
|
const CONFIG_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/config'
|
|
12
12
|
const HEALTH_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/health'
|
|
13
|
+
const TTS_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/tts'
|
|
14
|
+
const EVENTS_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/events'
|
|
13
15
|
const MAX_RECORDING_MS = 30000
|
|
14
16
|
const PERMISSION_TIMEOUT_MS = 20000
|
|
15
17
|
const EMOTION_LABELS = {
|
|
@@ -823,6 +825,13 @@ window.__ModuleLoader__.load({
|
|
|
823
825
|
|| draft.pythonPath !== config.pythonPath
|
|
824
826
|
|| draft.language !== config.language
|
|
825
827
|
|| draft.empathyStyle !== config.empathyStyle
|
|
828
|
+
|| draft.ttsEnabled !== config.ttsEnabled
|
|
829
|
+
|| draft.ttsOnlyAfterVoice !== config.ttsOnlyAfterVoice
|
|
830
|
+
|| draft.ttsEngine !== config.ttsEngine
|
|
831
|
+
|| draft.ttsVoice !== config.ttsVoice
|
|
832
|
+
|| draft.ttsRate !== config.ttsRate
|
|
833
|
+
|| draft.ttsPitch !== config.ttsPitch
|
|
834
|
+
|| draft.ttsPiperModel !== config.ttsPiperModel
|
|
826
835
|
|| draft.maxAudioBytes !== config.maxAudioBytes
|
|
827
836
|
|| draft.startupTimeoutMs !== config.startupTimeoutMs
|
|
828
837
|
|| draft.requestTimeoutMs !== config.requestTimeoutMs
|
|
@@ -831,7 +840,7 @@ window.__ModuleLoader__.load({
|
|
|
831
840
|
const save = async () => {
|
|
832
841
|
if (!config || !draft || saving) return
|
|
833
842
|
const patch = {}
|
|
834
|
-
for (const key of ['enabled', 'backendRoot', 'pythonPath', 'language', 'empathyStyle', 'maxAudioBytes', 'startupTimeoutMs', 'requestTimeoutMs']) {
|
|
843
|
+
for (const key of ['enabled', 'backendRoot', 'pythonPath', 'language', 'empathyStyle', 'ttsEnabled', 'ttsOnlyAfterVoice', 'ttsEngine', 'ttsVoice', 'ttsRate', 'ttsPitch', 'ttsPiperModel', 'maxAudioBytes', 'startupTimeoutMs', 'requestTimeoutMs']) {
|
|
835
844
|
if (draft[key] !== config[key]) patch[key] = draft[key]
|
|
836
845
|
}
|
|
837
846
|
if (Object.keys(patch).length === 0) return
|
|
@@ -957,6 +966,7 @@ window.__ModuleLoader__.load({
|
|
|
957
966
|
event.currentTarget.style.borderColor = T.border
|
|
958
967
|
}
|
|
959
968
|
const numberStyle = { ...inputStyle, width: 96, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }
|
|
969
|
+
const selectStyle = { ...inputStyle, width: isSidebar ? '100%' : 220, paddingRight: 26 }
|
|
960
970
|
const secondaryButton = {
|
|
961
971
|
height: 32,
|
|
962
972
|
padding: '0 11px',
|
|
@@ -994,6 +1004,15 @@ window.__ModuleLoader__.load({
|
|
|
994
1004
|
},
|
|
995
1005
|
}, value),
|
|
996
1006
|
)
|
|
1007
|
+
const voiceOptions = [
|
|
1008
|
+
['zh-CN-XiaoyiNeural', '小艺 · 活泼'],
|
|
1009
|
+
['zh-CN-XiaoxiaoNeural', '晓晓 · 温柔'],
|
|
1010
|
+
['zh-CN-YunxiNeural', '云希 · 男声'],
|
|
1011
|
+
['zh-CN-liaoning-XiaobeiNeural', '小北 · 东北'],
|
|
1012
|
+
]
|
|
1013
|
+
if (draft.ttsVoice && !voiceOptions.some(([value]) => value === draft.ttsVoice)) {
|
|
1014
|
+
voiceOptions.push([draft.ttsVoice, draft.ttsVoice])
|
|
1015
|
+
}
|
|
997
1016
|
|
|
998
1017
|
return h(Root, {
|
|
999
1018
|
'data-testid': 'dsh-dafeiyu-emotion-settings',
|
|
@@ -1235,6 +1254,94 @@ window.__ModuleLoader__.load({
|
|
|
1235
1254
|
onChange: (value) => update('empathyStyle', value),
|
|
1236
1255
|
})),
|
|
1237
1256
|
),
|
|
1257
|
+
h(SettingsSection, { title: '语音回复' },
|
|
1258
|
+
h(SettingsField, {
|
|
1259
|
+
icon: 'spark',
|
|
1260
|
+
label: '朗读大肥鱼回复',
|
|
1261
|
+
hint: '开启后,助手回复会自动朗读。Piper 本地模型最稳;Edge 需要网络;浏览器引擎留在本机。',
|
|
1262
|
+
}, h(SettingsSwitch, {
|
|
1263
|
+
checked: draft.ttsEnabled === true,
|
|
1264
|
+
disabled,
|
|
1265
|
+
label: '朗读大肥鱼回复',
|
|
1266
|
+
onChange: (value) => update('ttsEnabled', value),
|
|
1267
|
+
})),
|
|
1268
|
+
h(SettingsField, {
|
|
1269
|
+
icon: 'spark',
|
|
1270
|
+
label: '只朗读语音触发的回复',
|
|
1271
|
+
hint: '开启后只朗读录音后两分钟内的回复;关闭后所有助手回复都会朗读。',
|
|
1272
|
+
}, h(SettingsSwitch, {
|
|
1273
|
+
checked: draft.ttsOnlyAfterVoice !== false,
|
|
1274
|
+
disabled: disabled || draft.ttsEnabled !== true,
|
|
1275
|
+
label: '只朗读语音触发的回复',
|
|
1276
|
+
onChange: (value) => update('ttsOnlyAfterVoice', value),
|
|
1277
|
+
})),
|
|
1278
|
+
h(SettingsField, {
|
|
1279
|
+
icon: 'spark',
|
|
1280
|
+
label: '合成引擎',
|
|
1281
|
+
hint: 'Piper 使用本地中文模型;Edge 音色更自然但可能被网络限制;浏览器使用系统语音。',
|
|
1282
|
+
}, h(SettingsSegmented, {
|
|
1283
|
+
value: draft.ttsEngine || 'piper',
|
|
1284
|
+
label: '合成引擎',
|
|
1285
|
+
disabled,
|
|
1286
|
+
options: [
|
|
1287
|
+
{ value: 'piper', label: 'Piper' },
|
|
1288
|
+
{ value: 'edge', label: 'Edge' },
|
|
1289
|
+
{ value: 'browser', label: '浏览器' },
|
|
1290
|
+
],
|
|
1291
|
+
onChange: (value) => update('ttsEngine', value),
|
|
1292
|
+
})),
|
|
1293
|
+
h(SettingsField, {
|
|
1294
|
+
icon: 'spark',
|
|
1295
|
+
label: '音色',
|
|
1296
|
+
hint: 'Edge TTS 的中文女声/男声;浏览器引擎使用系统默认音色。',
|
|
1297
|
+
wide: true,
|
|
1298
|
+
}, h('select', {
|
|
1299
|
+
value: draft.ttsVoice || 'zh-CN-XiaoyiNeural',
|
|
1300
|
+
disabled: disabled || draft.ttsEngine !== 'edge',
|
|
1301
|
+
'aria-label': 'Edge TTS 音色',
|
|
1302
|
+
onChange: (event) => update('ttsVoice', event.target.value),
|
|
1303
|
+
style: selectStyle,
|
|
1304
|
+
}, ...voiceOptions.map(([value, label]) => h('option', { key: value, value }, label)))),
|
|
1305
|
+
draft.ttsEngine === 'piper' || draft.ttsEngine === 'edge'
|
|
1306
|
+
? h(SettingsField, {
|
|
1307
|
+
icon: 'folder',
|
|
1308
|
+
label: 'Piper 模型',
|
|
1309
|
+
hint: '留空使用 ~/.dsh/dafeiyu-emotion/tts/piper/zh_CN-huayan-medium.onnx。',
|
|
1310
|
+
wide: true,
|
|
1311
|
+
}, h('input', {
|
|
1312
|
+
type: 'text',
|
|
1313
|
+
value: draft.ttsPiperModel || '',
|
|
1314
|
+
disabled: disabled || draft.ttsEngine !== 'piper',
|
|
1315
|
+
spellCheck: false,
|
|
1316
|
+
placeholder: '留空使用默认 Piper 模型',
|
|
1317
|
+
'aria-label': 'Piper 模型路径',
|
|
1318
|
+
onChange: (event) => update('ttsPiperModel', event.target.value),
|
|
1319
|
+
style: { ...inputStyle, width: '100%' },
|
|
1320
|
+
}))
|
|
1321
|
+
: null,
|
|
1322
|
+
h(SettingsField, {
|
|
1323
|
+
icon: 'spark',
|
|
1324
|
+
label: '语速 / 音调',
|
|
1325
|
+
hint: '语速 +6% 适用于 Piper/Edge;音调 +12Hz 仅 Edge。',
|
|
1326
|
+
}, h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' } },
|
|
1327
|
+
h('input', {
|
|
1328
|
+
type: 'text',
|
|
1329
|
+
value: draft.ttsRate || '+6%',
|
|
1330
|
+
disabled: disabled || draft.ttsEngine === 'browser',
|
|
1331
|
+
'aria-label': '语速',
|
|
1332
|
+
onChange: (event) => update('ttsRate', event.target.value),
|
|
1333
|
+
style: { ...inputStyle, width: 76 },
|
|
1334
|
+
}),
|
|
1335
|
+
h('input', {
|
|
1336
|
+
type: 'text',
|
|
1337
|
+
value: draft.ttsPitch || '+12Hz',
|
|
1338
|
+
disabled: disabled || draft.ttsEngine !== 'edge',
|
|
1339
|
+
'aria-label': '音调',
|
|
1340
|
+
onChange: (event) => update('ttsPitch', event.target.value),
|
|
1341
|
+
style: { ...inputStyle, width: 86 },
|
|
1342
|
+
}),
|
|
1343
|
+
)),
|
|
1344
|
+
),
|
|
1238
1345
|
h(SettingsSection, { title: '增强后端', hint: '可选' },
|
|
1239
1346
|
h(SettingsField, {
|
|
1240
1347
|
icon: 'folder',
|
|
@@ -1449,6 +1556,13 @@ window.__ModuleLoader__.load({
|
|
|
1449
1556
|
const [sending, setSending] = useState(false)
|
|
1450
1557
|
const [pluginEnabled, setPluginEnabled] = useState(null)
|
|
1451
1558
|
const [language, setLanguage] = useState('zh')
|
|
1559
|
+
const [ttsConfig, setTtsConfig] = useState({
|
|
1560
|
+
enabled: false,
|
|
1561
|
+
engine: 'piper',
|
|
1562
|
+
voice: 'zh-CN-XiaoyiNeural',
|
|
1563
|
+
rate: '+6%',
|
|
1564
|
+
pitch: '+12Hz',
|
|
1565
|
+
})
|
|
1452
1566
|
const recorder = useRef(null)
|
|
1453
1567
|
const abort = useRef(null)
|
|
1454
1568
|
const timer = useRef(null)
|
|
@@ -1458,6 +1572,10 @@ window.__ModuleLoader__.load({
|
|
|
1458
1572
|
const levelsRef = useRef([])
|
|
1459
1573
|
const shortcutAction = useRef(null)
|
|
1460
1574
|
const shortcutRegistration = useRef(null)
|
|
1575
|
+
const ttsRef = useRef(ttsConfig)
|
|
1576
|
+
const audioRef = useRef(null)
|
|
1577
|
+
const speechGeneration = useRef(0)
|
|
1578
|
+
ttsRef.current = ttsConfig
|
|
1461
1579
|
const deliveryGate = useRef(null)
|
|
1462
1580
|
if (!deliveryGate.current) deliveryGate.current = createDeliveryGate()
|
|
1463
1581
|
const useInputSnapshot = typeof useInput === 'function' ? useInput : useNoInput
|
|
@@ -1734,12 +1852,95 @@ window.__ModuleLoader__.load({
|
|
|
1734
1852
|
shortcutRegistration.current?.touch?.()
|
|
1735
1853
|
}
|
|
1736
1854
|
|
|
1855
|
+
function speakBrowser(text) {
|
|
1856
|
+
const synth = window.speechSynthesis
|
|
1857
|
+
if (!synth || typeof SpeechSynthesisUtterance === 'undefined') return false
|
|
1858
|
+
synth.cancel()
|
|
1859
|
+
const utterance = new SpeechSynthesisUtterance(text)
|
|
1860
|
+
utterance.lang = 'zh-CN'
|
|
1861
|
+
utterance.rate = 1.06
|
|
1862
|
+
utterance.pitch = 1.08
|
|
1863
|
+
synth.speak(utterance)
|
|
1864
|
+
return true
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
async function speakReply(text) {
|
|
1868
|
+
const config = ttsRef.current
|
|
1869
|
+
if (!config?.enabled || !text) return false
|
|
1870
|
+
const run = ++speechGeneration.current
|
|
1871
|
+
if (config.engine === 'browser') return speakBrowser(text)
|
|
1872
|
+
try {
|
|
1873
|
+
const response = await fetch(TTS_ENDPOINT, {
|
|
1874
|
+
method: 'POST',
|
|
1875
|
+
headers: { 'content-type': 'application/json' },
|
|
1876
|
+
body: JSON.stringify({
|
|
1877
|
+
text,
|
|
1878
|
+
voice: config.voice,
|
|
1879
|
+
rate: config.rate,
|
|
1880
|
+
pitch: config.pitch,
|
|
1881
|
+
}),
|
|
1882
|
+
})
|
|
1883
|
+
if (!response.ok) {
|
|
1884
|
+
const payload = await response.json().catch(() => ({}))
|
|
1885
|
+
throw new Error(payload.error || `TTS 失败(${response.status})`)
|
|
1886
|
+
}
|
|
1887
|
+
const blob = await response.blob()
|
|
1888
|
+
if (run !== speechGeneration.current) return false
|
|
1889
|
+
const url = URL.createObjectURL(blob)
|
|
1890
|
+
audioRef.current?.pause?.()
|
|
1891
|
+
const audio = new Audio(url)
|
|
1892
|
+
audioRef.current = audio
|
|
1893
|
+
audio.onended = () => {
|
|
1894
|
+
URL.revokeObjectURL(url)
|
|
1895
|
+
if (audioRef.current === audio) audioRef.current = null
|
|
1896
|
+
}
|
|
1897
|
+
try {
|
|
1898
|
+
await audio.play()
|
|
1899
|
+
return true
|
|
1900
|
+
} catch {
|
|
1901
|
+
URL.revokeObjectURL(url)
|
|
1902
|
+
return speakBrowser(text)
|
|
1903
|
+
}
|
|
1904
|
+
} catch {
|
|
1905
|
+
return speakBrowser(text)
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
useEffect(() => {
|
|
1910
|
+
if (typeof EventSource === 'undefined') return undefined
|
|
1911
|
+
const source = new EventSource(EVENTS_ENDPOINT)
|
|
1912
|
+
source.onmessage = (event) => {
|
|
1913
|
+
try {
|
|
1914
|
+
const message = JSON.parse(event.data)
|
|
1915
|
+
if (message?.kind !== 'assistant' || !message.text) return
|
|
1916
|
+
if (message.sessionId && sessionId && message.sessionId !== sessionId) return
|
|
1917
|
+
void speakReply(message.text)
|
|
1918
|
+
} catch {}
|
|
1919
|
+
}
|
|
1920
|
+
return () => source.close()
|
|
1921
|
+
}, [sessionId])
|
|
1922
|
+
|
|
1923
|
+
useEffect(() => () => {
|
|
1924
|
+
speechGeneration.current += 1
|
|
1925
|
+
audioRef.current?.pause?.()
|
|
1926
|
+
window.speechSynthesis?.cancel?.()
|
|
1927
|
+
}, [])
|
|
1928
|
+
|
|
1737
1929
|
useEffect(() => {
|
|
1738
1930
|
let active = true
|
|
1739
1931
|
const applyConfig = (next) => {
|
|
1740
1932
|
if (!active || !next) return
|
|
1741
1933
|
setPluginEnabled(next.enabled !== false)
|
|
1742
1934
|
if (next.language) setLanguage(next.language)
|
|
1935
|
+
setTtsConfig({
|
|
1936
|
+
enabled: next.ttsEnabled === true,
|
|
1937
|
+
engine: next.ttsEngine === 'browser'
|
|
1938
|
+
? 'browser'
|
|
1939
|
+
: next.ttsEngine === 'piper' ? 'piper' : 'edge',
|
|
1940
|
+
voice: next.ttsVoice || 'zh-CN-XiaoyiNeural',
|
|
1941
|
+
rate: next.ttsRate || '+6%',
|
|
1942
|
+
pitch: next.ttsPitch || '+12Hz',
|
|
1943
|
+
})
|
|
1743
1944
|
}
|
|
1744
1945
|
fetch(CONFIG_ENDPOINT, { cache: 'no-store' })
|
|
1745
1946
|
.then((response) => (response.ok ? response.json() : null))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dafeiyu-emotion",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "大肥鱼语音情绪耳朵:听语气、识别情绪,并让 DSH 按对方当下的状态自然回复。 · Voice + emotion-aware companion for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"check": "node --check src/index.js && node --check lib/client.js",
|
|
64
|
-
"test": "node --check src/index.js && node --check lib/client.js && node --test scripts/test-config-schema.mjs scripts/test-config-handler.mjs scripts/test-analyze-handler.mjs scripts/test-backend-cancel.mjs scripts/test-delivery-policy.mjs scripts/test-shortcut-registration.mjs scripts/test-client-registration.mjs"
|
|
64
|
+
"test": "node --check src/index.js && node --check lib/client.js && node --test scripts/test-config-schema.mjs scripts/test-config-handler.mjs scripts/test-analyze-handler.mjs scripts/test-backend-cancel.mjs scripts/test-tts-handler.mjs scripts/test-delivery-policy.mjs scripts/test-shortcut-registration.mjs scripts/test-client-registration.mjs"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@deepseek-ai/schemastery": "3.18.5",
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { createInterface } from 'node:readline'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -14,6 +14,8 @@ const ANALYZE_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/analyze'
|
|
|
14
14
|
const HEALTH_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/health'
|
|
15
15
|
const CONFIG_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/config'
|
|
16
16
|
const CONTEXT_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/context'
|
|
17
|
+
const TTS_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/tts'
|
|
18
|
+
const EVENTS_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/events'
|
|
17
19
|
const SETTINGS_NAMESPACE = 'dsh-dafeiyu-emotion'
|
|
18
20
|
|
|
19
21
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
@@ -28,6 +30,15 @@ const DEFAULTS = Object.freeze({
|
|
|
28
30
|
startupTimeoutMs: 180000,
|
|
29
31
|
requestTimeoutMs: 180000,
|
|
30
32
|
empathyStyle: 'gentle',
|
|
33
|
+
ttsEnabled: true,
|
|
34
|
+
ttsOnlyAfterVoice: true,
|
|
35
|
+
ttsEngine: 'piper',
|
|
36
|
+
ttsVoice: 'zh-CN-XiaoyiNeural',
|
|
37
|
+
ttsRate: '+6%',
|
|
38
|
+
ttsPitch: '+12Hz',
|
|
39
|
+
ttsCommand: '',
|
|
40
|
+
ttsPiperModel: '',
|
|
41
|
+
ttsPiperCommand: '',
|
|
31
42
|
})
|
|
32
43
|
|
|
33
44
|
const CONFIG_FIELDS = new Set(Object.keys(DEFAULTS))
|
|
@@ -69,6 +80,19 @@ export const Config = Schema.object({
|
|
|
69
80
|
Schema.const('humorous').description('幽默'),
|
|
70
81
|
Schema.const('concise').description('简洁'),
|
|
71
82
|
]).default('gentle').description('共情策略').volatile(),
|
|
83
|
+
ttsEnabled: Schema.boolean().default(true).description('朗读大肥鱼回复').volatile(),
|
|
84
|
+
ttsOnlyAfterVoice: Schema.boolean().default(true).description('只朗读语音触发的回复').volatile(),
|
|
85
|
+
ttsEngine: Schema.union([
|
|
86
|
+
Schema.const('piper').description('Piper 本地模型(推荐)'),
|
|
87
|
+
Schema.const('edge').description('Edge TTS(更好听,需要网络)'),
|
|
88
|
+
Schema.const('browser').description('浏览器本地语音'),
|
|
89
|
+
]).default('piper').description('语音合成引擎').volatile(),
|
|
90
|
+
ttsVoice: Schema.string().default('zh-CN-XiaoyiNeural').description('Edge TTS 音色').volatile(),
|
|
91
|
+
ttsRate: Schema.string().default('+6%').description('语速').volatile(),
|
|
92
|
+
ttsPitch: Schema.string().default('+12Hz').description('音调').volatile(),
|
|
93
|
+
ttsCommand: Schema.string().default('').description('edge-tts 可执行文件路径').volatile(),
|
|
94
|
+
ttsPiperModel: Schema.string().default('').description('Piper 模型路径').volatile(),
|
|
95
|
+
ttsPiperCommand: Schema.string().default('').description('Piper 可执行文件路径').volatile(),
|
|
72
96
|
}).description('大肥鱼语音情绪耳朵')
|
|
73
97
|
|
|
74
98
|
const EMOTION_ZH = Object.freeze({
|
|
@@ -125,6 +149,266 @@ function jsonResponse(res, status, body) {
|
|
|
125
149
|
res.end(payload)
|
|
126
150
|
}
|
|
127
151
|
|
|
152
|
+
function resolveEdgeTts(options) {
|
|
153
|
+
const explicit = String(options?.ttsCommand || '').trim()
|
|
154
|
+
if (explicit) return explicit
|
|
155
|
+
const fromEnv = String(process.env.EDGE_TTS_PATH || '').trim()
|
|
156
|
+
if (fromEnv) return fromEnv
|
|
157
|
+
const local = process.env.LOCALAPPDATA
|
|
158
|
+
const roaming = process.env.APPDATA
|
|
159
|
+
const candidates = [
|
|
160
|
+
local && join(local, 'Programs', 'Python', 'Python313', 'Scripts', 'edge-tts.exe'),
|
|
161
|
+
local && join(local, 'Programs', 'Python', 'Python312', 'Scripts', 'edge-tts.exe'),
|
|
162
|
+
roaming && join(roaming, 'Python', 'Python313', 'Scripts', 'edge-tts.exe'),
|
|
163
|
+
'edge-tts',
|
|
164
|
+
].filter(Boolean)
|
|
165
|
+
return candidates.find((candidate) => candidate === 'edge-tts' || existsSync(candidate)) || 'edge-tts'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function defaultPiperModel() {
|
|
169
|
+
const home = process.env.USERPROFILE || process.env.HOME || ''
|
|
170
|
+
return join(home, '.dsh', 'dafeiyu-emotion', 'tts', 'piper', 'zh_CN-huayan-medium.onnx')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolvePiperModel(options) {
|
|
174
|
+
return String(options?.ttsPiperModel || process.env.DSH_DAFEIYU_PIPER_MODEL || defaultPiperModel()).trim()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function resolvePiperCommand(options) {
|
|
178
|
+
const explicit = String(options?.ttsPiperCommand || process.env.PIPER_PATH || '').trim()
|
|
179
|
+
if (explicit) return explicit
|
|
180
|
+
const local = process.env.LOCALAPPDATA
|
|
181
|
+
const candidates = [
|
|
182
|
+
local && join(local, 'Programs', 'Python', 'Python313', 'Scripts', 'piper.exe'),
|
|
183
|
+
local && join(local, 'Programs', 'Python', 'Python312', 'Scripts', 'piper.exe'),
|
|
184
|
+
'piper',
|
|
185
|
+
].filter(Boolean)
|
|
186
|
+
return candidates.find((candidate) => candidate === 'piper' || existsSync(candidate)) || 'piper'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function synthesizePiper(text, options) {
|
|
190
|
+
const model = resolvePiperModel(options)
|
|
191
|
+
const command = resolvePiperCommand(options)
|
|
192
|
+
if (!existsSync(model)) throw new Error(`Piper 模型不存在:${model}`)
|
|
193
|
+
const config = `${model}.json`
|
|
194
|
+
if (!existsSync(config)) throw new Error(`Piper 模型配置不存在:${config}`)
|
|
195
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-dafeiyu-tts-'))
|
|
196
|
+
const input = join(dir, 'input.txt')
|
|
197
|
+
const out = join(dir, 'reply.wav')
|
|
198
|
+
try {
|
|
199
|
+
await writeFile(input, text, 'utf8')
|
|
200
|
+
const speed = Number.parseFloat(String(options.ttsRate || '+0%').replace('%', ''))
|
|
201
|
+
const lengthScale = Number.isFinite(speed) && speed !== 0 ? 1 / (1 + speed / 100) : 1
|
|
202
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
203
|
+
const proc = spawn(command, [
|
|
204
|
+
'-m', model,
|
|
205
|
+
'-c', config,
|
|
206
|
+
'-i', input,
|
|
207
|
+
'-f', out,
|
|
208
|
+
'--length-scale', String(Math.max(0.7, Math.min(1.4, lengthScale))),
|
|
209
|
+
], {
|
|
210
|
+
windowsHide: true,
|
|
211
|
+
env: { ...process.env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8' },
|
|
212
|
+
})
|
|
213
|
+
let stderr = ''
|
|
214
|
+
let settled = false
|
|
215
|
+
const timer = setTimeout(() => {
|
|
216
|
+
if (settled) return
|
|
217
|
+
settled = true
|
|
218
|
+
try { proc.kill() } catch {}
|
|
219
|
+
rejectPromise(new Error('Piper 合成超时'))
|
|
220
|
+
}, 30000)
|
|
221
|
+
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') })
|
|
222
|
+
proc.on('error', (error) => {
|
|
223
|
+
if (settled) return
|
|
224
|
+
settled = true
|
|
225
|
+
clearTimeout(timer)
|
|
226
|
+
rejectPromise(error)
|
|
227
|
+
})
|
|
228
|
+
proc.on('exit', (code) => {
|
|
229
|
+
if (settled) return
|
|
230
|
+
settled = true
|
|
231
|
+
clearTimeout(timer)
|
|
232
|
+
if (code === 0) resolvePromise()
|
|
233
|
+
else rejectPromise(new Error(stderr.trim() || `piper 退出(code=${code})`))
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
return await readFile(out)
|
|
237
|
+
} finally {
|
|
238
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ttsReady(options) {
|
|
243
|
+
if (!options.ttsEnabled) return false
|
|
244
|
+
if (options.ttsEngine === 'browser') return true
|
|
245
|
+
if (options.ttsEngine === 'piper') return existsSync(resolvePiperModel(options))
|
|
246
|
+
return existsSync(resolveEdgeTts(options))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function assistantTextFromEvent(event) {
|
|
250
|
+
const content = event?.data?.message?.content
|
|
251
|
+
if (!Array.isArray(content)) return ''
|
|
252
|
+
return content
|
|
253
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
254
|
+
.map((block) => block.text)
|
|
255
|
+
.join('\n')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function cleanForSpeech(text) {
|
|
259
|
+
return String(text || '')
|
|
260
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
261
|
+
.replace(/`[^`]*`/g, ' ')
|
|
262
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
263
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
264
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
265
|
+
.replace(/[*_~>#]/g, ' ')
|
|
266
|
+
.replace(/\s+/g, ' ')
|
|
267
|
+
.trim()
|
|
268
|
+
.slice(0, 800)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function synthesizeEdge(text, options) {
|
|
272
|
+
const command = resolveEdgeTts(options)
|
|
273
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-dafeiyu-tts-'))
|
|
274
|
+
const out = join(dir, 'reply.mp3')
|
|
275
|
+
try {
|
|
276
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
277
|
+
const proc = spawn(command, [
|
|
278
|
+
'--voice', options.ttsVoice,
|
|
279
|
+
'--rate', options.ttsRate,
|
|
280
|
+
'--pitch', options.ttsPitch,
|
|
281
|
+
'--text', text,
|
|
282
|
+
'--write-media', out,
|
|
283
|
+
], { windowsHide: true })
|
|
284
|
+
let stderr = ''
|
|
285
|
+
let settled = false
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
if (settled) return
|
|
288
|
+
settled = true
|
|
289
|
+
try { proc.kill() } catch {}
|
|
290
|
+
rejectPromise(new Error('TTS 合成超时'))
|
|
291
|
+
}, 25000)
|
|
292
|
+
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') })
|
|
293
|
+
proc.on('error', (error) => {
|
|
294
|
+
if (settled) return
|
|
295
|
+
settled = true
|
|
296
|
+
clearTimeout(timer)
|
|
297
|
+
rejectPromise(error)
|
|
298
|
+
})
|
|
299
|
+
proc.on('exit', (code) => {
|
|
300
|
+
if (settled) return
|
|
301
|
+
settled = true
|
|
302
|
+
clearTimeout(timer)
|
|
303
|
+
if (code === 0) resolvePromise()
|
|
304
|
+
else rejectPromise(new Error(stderr.trim() || `edge-tts 退出(code=${code})`))
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
return await readFile(out)
|
|
308
|
+
} finally {
|
|
309
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function createTtsHandler(runtime) {
|
|
314
|
+
return async (req, res) => {
|
|
315
|
+
if (!isLocalRequest(req)) {
|
|
316
|
+
jsonResponse(res, 403, { error: '只允许本机访问' })
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
if (req.method !== 'POST') {
|
|
320
|
+
jsonResponse(res, 405, { error: 'method not allowed' })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (!runtime.options.ttsEnabled) {
|
|
324
|
+
jsonResponse(res, 503, { error: '语音回复已关闭' })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const body = await readJsonBody(req, 16 * 1024)
|
|
329
|
+
const text = cleanForSpeech(body?.text)
|
|
330
|
+
if (!text) throw new HttpError(400, '没有可朗读的文本')
|
|
331
|
+
if (runtime.options.ttsEngine === 'browser') {
|
|
332
|
+
throw new HttpError(400, '浏览器语音引擎在客户端本地运行,不需要调用 Host TTS')
|
|
333
|
+
}
|
|
334
|
+
const voice = String(body?.voice || runtime.options.ttsVoice)
|
|
335
|
+
const rate = String(body?.rate || runtime.options.ttsRate)
|
|
336
|
+
const pitch = String(body?.pitch || runtime.options.ttsPitch)
|
|
337
|
+
if (!/^[+-]\d{1,3}%$/.test(rate)) throw new HttpError(400, '语速格式不合法')
|
|
338
|
+
let audio
|
|
339
|
+
let contentType
|
|
340
|
+
if (runtime.options.ttsEngine === 'piper') {
|
|
341
|
+
audio = await synthesizePiper(text, { ...runtime.options, ttsRate: rate })
|
|
342
|
+
contentType = 'audio/wav'
|
|
343
|
+
} else {
|
|
344
|
+
if (!/^[A-Za-z]{2,3}-[A-Za-z0-9-]+Neural$/.test(voice)) throw new HttpError(400, '音色不合法')
|
|
345
|
+
if (!/^[+-]\d{1,3}Hz$/.test(pitch)) throw new HttpError(400, '音调格式不合法')
|
|
346
|
+
audio = await synthesizeEdge(text, {
|
|
347
|
+
...runtime.options,
|
|
348
|
+
ttsVoice: voice,
|
|
349
|
+
ttsRate: rate,
|
|
350
|
+
ttsPitch: pitch,
|
|
351
|
+
})
|
|
352
|
+
contentType = 'audio/mpeg'
|
|
353
|
+
}
|
|
354
|
+
res.writeHead(200, {
|
|
355
|
+
'content-type': contentType,
|
|
356
|
+
'cache-control': 'no-store',
|
|
357
|
+
'content-length': audio.length,
|
|
358
|
+
})
|
|
359
|
+
res.end(audio)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const status = error instanceof HttpError ? error.status : 502
|
|
362
|
+
jsonResponse(res, status, {
|
|
363
|
+
error: error instanceof Error ? error.message : String(error),
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function createSpeechStream() {
|
|
370
|
+
const clients = new Set()
|
|
371
|
+
return {
|
|
372
|
+
add(res) {
|
|
373
|
+
clients.add(res)
|
|
374
|
+
res.writeHead(200, {
|
|
375
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
376
|
+
'cache-control': 'no-store',
|
|
377
|
+
connection: 'keep-alive',
|
|
378
|
+
})
|
|
379
|
+
res.write(': connected\n\n')
|
|
380
|
+
const timer = setInterval(() => {
|
|
381
|
+
try {
|
|
382
|
+
res.write(': ping\n\n')
|
|
383
|
+
} catch {
|
|
384
|
+
clearInterval(timer)
|
|
385
|
+
clients.delete(res)
|
|
386
|
+
}
|
|
387
|
+
}, 15000)
|
|
388
|
+
res.on('close', () => {
|
|
389
|
+
clearInterval(timer)
|
|
390
|
+
clients.delete(res)
|
|
391
|
+
})
|
|
392
|
+
},
|
|
393
|
+
broadcast(message) {
|
|
394
|
+
const payload = `data: ${JSON.stringify(message)}\n\n`
|
|
395
|
+
for (const res of clients) {
|
|
396
|
+
try {
|
|
397
|
+
res.write(payload)
|
|
398
|
+
} catch {
|
|
399
|
+
clients.delete(res)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
close() {
|
|
404
|
+
for (const res of clients) {
|
|
405
|
+
try { res.end() } catch {}
|
|
406
|
+
}
|
|
407
|
+
clients.clear()
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
128
412
|
async function readJsonBody(req, limit) {
|
|
129
413
|
const chunks = []
|
|
130
414
|
let bytes = 0
|
|
@@ -181,6 +465,15 @@ function resolveOptions(raw = {}) {
|
|
|
181
465
|
empathyStyle: ['gentle', 'direct', 'humorous', 'concise'].includes(value.empathyStyle)
|
|
182
466
|
? value.empathyStyle
|
|
183
467
|
: DEFAULTS.empathyStyle,
|
|
468
|
+
ttsEnabled: value.ttsEnabled === true,
|
|
469
|
+
ttsOnlyAfterVoice: value.ttsOnlyAfterVoice !== false,
|
|
470
|
+
ttsEngine: ['piper', 'edge', 'browser'].includes(value.ttsEngine) ? value.ttsEngine : DEFAULTS.ttsEngine,
|
|
471
|
+
ttsVoice: String(value.ttsVoice || DEFAULTS.ttsVoice).trim(),
|
|
472
|
+
ttsRate: String(value.ttsRate || DEFAULTS.ttsRate).trim(),
|
|
473
|
+
ttsPitch: String(value.ttsPitch || DEFAULTS.ttsPitch).trim(),
|
|
474
|
+
ttsCommand: String(value.ttsCommand || '').trim(),
|
|
475
|
+
ttsPiperModel: String(value.ttsPiperModel || '').trim(),
|
|
476
|
+
ttsPiperCommand: String(value.ttsPiperCommand || '').trim(),
|
|
184
477
|
maxAudioBytes: Math.min(32 * 1024 * 1024, Math.max(256 * 1024, asNumber(value.maxAudioBytes, DEFAULTS.maxAudioBytes))),
|
|
185
478
|
startupTimeoutMs: Math.min(600000, Math.max(10000, asNumber(value.startupTimeoutMs, DEFAULTS.startupTimeoutMs))),
|
|
186
479
|
requestTimeoutMs: Math.min(600000, Math.max(10000, asNumber(value.requestTimeoutMs, DEFAULTS.requestTimeoutMs))),
|
|
@@ -194,6 +487,15 @@ function publicConfig(options, writable = false) {
|
|
|
194
487
|
pythonPath: options.pythonPath || '',
|
|
195
488
|
language: options.language || DEFAULTS.language,
|
|
196
489
|
empathyStyle: options.empathyStyle || DEFAULTS.empathyStyle,
|
|
490
|
+
ttsEnabled: options.ttsEnabled === true,
|
|
491
|
+
ttsOnlyAfterVoice: options.ttsOnlyAfterVoice !== false,
|
|
492
|
+
ttsEngine: options.ttsEngine || DEFAULTS.ttsEngine,
|
|
493
|
+
ttsVoice: options.ttsVoice || DEFAULTS.ttsVoice,
|
|
494
|
+
ttsRate: options.ttsRate || DEFAULTS.ttsRate,
|
|
495
|
+
ttsPitch: options.ttsPitch || DEFAULTS.ttsPitch,
|
|
496
|
+
ttsCommand: options.ttsCommand || '',
|
|
497
|
+
ttsPiperModel: options.ttsPiperModel || '',
|
|
498
|
+
ttsPiperCommand: options.ttsPiperCommand || '',
|
|
197
499
|
maxAudioBytes: options.maxAudioBytes,
|
|
198
500
|
startupTimeoutMs: options.startupTimeoutMs,
|
|
199
501
|
requestTimeoutMs: options.requestTimeoutMs,
|
|
@@ -227,6 +529,59 @@ function normalizeConfigPatch(value) {
|
|
|
227
529
|
patch.empathyStyle = style
|
|
228
530
|
continue
|
|
229
531
|
}
|
|
532
|
+
if (key === 'ttsEnabled') {
|
|
533
|
+
if (typeof next !== 'boolean') throw new HttpError(400, 'ttsEnabled 必须是布尔值')
|
|
534
|
+
patch.ttsEnabled = next
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (key === 'ttsOnlyAfterVoice') {
|
|
538
|
+
if (typeof next !== 'boolean') throw new HttpError(400, 'ttsOnlyAfterVoice 必须是布尔值')
|
|
539
|
+
patch.ttsOnlyAfterVoice = next
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
if (key === 'ttsEngine') {
|
|
543
|
+
const engine = String(next || '').trim().toLowerCase()
|
|
544
|
+
if (!['piper', 'edge', 'browser'].includes(engine)) {
|
|
545
|
+
throw new HttpError(400, 'ttsEngine 只能是 piper、edge 或 browser')
|
|
546
|
+
}
|
|
547
|
+
patch.ttsEngine = engine
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
if (key === 'ttsVoice') {
|
|
551
|
+
const voice = String(next || '').trim()
|
|
552
|
+
if (!/^[A-Za-z]{2,3}-[A-Za-z0-9-]+Neural$/.test(voice)) {
|
|
553
|
+
throw new HttpError(400, 'ttsVoice 不是合法的 Edge TTS 音色')
|
|
554
|
+
}
|
|
555
|
+
patch.ttsVoice = voice
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
if (key === 'ttsRate' || key === 'ttsPitch') {
|
|
559
|
+
const value = String(next || '').trim()
|
|
560
|
+
const pattern = key === 'ttsRate' ? /^[+-]\d{1,3}%$/ : /^[+-]\d{1,3}Hz$/
|
|
561
|
+
if (!pattern.test(value)) {
|
|
562
|
+
throw new HttpError(400, `${key} 格式应为 ${key === 'ttsRate' ? '+6%' : '+12Hz'}`)
|
|
563
|
+
}
|
|
564
|
+
patch[key] = value
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
if (key === 'ttsCommand') {
|
|
568
|
+
if (typeof next !== 'string') throw new HttpError(400, 'ttsCommand 必须是字符串')
|
|
569
|
+
const text = next.trim()
|
|
570
|
+
if (text.length > 500 || /[\u0000-\u001f]/.test(text)) {
|
|
571
|
+
throw new HttpError(400, 'ttsCommand 不是合法的路径')
|
|
572
|
+
}
|
|
573
|
+
patch.ttsCommand = text
|
|
574
|
+
continue
|
|
575
|
+
}
|
|
576
|
+
if (key === 'ttsPiperModel' || key === 'ttsPiperCommand') {
|
|
577
|
+
if (typeof next !== 'string') throw new HttpError(400, `${key} 必须是字符串`)
|
|
578
|
+
const text = next.trim()
|
|
579
|
+
if (text.length > 500 || /[\u0000-\u001f]/.test(text)) {
|
|
580
|
+
throw new HttpError(400, `${key} 不是合法的路径`)
|
|
581
|
+
}
|
|
582
|
+
patch[key] = text
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
230
585
|
if (key === 'backendRoot' || key === 'pythonPath') {
|
|
231
586
|
if (typeof next !== 'string') throw new HttpError(400, `${key} 必须是字符串`)
|
|
232
587
|
const text = next.trim()
|
|
@@ -1012,6 +1367,7 @@ function createAnalyzeHandler(runtime, backend, getSpeechToText) {
|
|
|
1012
1367
|
}
|
|
1013
1368
|
runtime.lastAudioBytes = buffer.length
|
|
1014
1369
|
runtime.lastAnalysisAt = runtime.lastAnalysis.at
|
|
1370
|
+
runtime.lastVoiceAt = Date.now()
|
|
1015
1371
|
jsonResponse(res, 200, analysis)
|
|
1016
1372
|
} catch (error) {
|
|
1017
1373
|
if (controller?.signal.aborted || res.writableEnded) return
|
|
@@ -1044,11 +1400,13 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1044
1400
|
// shared runtime object and settings.update persists it for the next boot.
|
|
1045
1401
|
options: resolveOptions(rawConfig),
|
|
1046
1402
|
lastAnalysis: null,
|
|
1403
|
+
lastVoiceAt: null,
|
|
1047
1404
|
lastAudioBytes: 0,
|
|
1048
1405
|
lastAnalysisAt: null,
|
|
1049
1406
|
lastCleanupAt: null,
|
|
1050
1407
|
}
|
|
1051
1408
|
const backend = new VoiceBackend(runtime.options, logger)
|
|
1409
|
+
const speechStream = createSpeechStream()
|
|
1052
1410
|
let speechToText
|
|
1053
1411
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
1054
1412
|
settingsService = settingsCtx.settings
|
|
@@ -1060,6 +1418,27 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1060
1418
|
})
|
|
1061
1419
|
})
|
|
1062
1420
|
ctx.effect(() => () => backend.close())
|
|
1421
|
+
ctx.effect(() => () => speechStream.close())
|
|
1422
|
+
|
|
1423
|
+
ctx.inject(['sessions'], (sessionCtx) => {
|
|
1424
|
+
try {
|
|
1425
|
+
const off = sessionCtx.on?.('session/event', (session, event) => {
|
|
1426
|
+
if (event?.type !== 'assistant/message' || !runtime.options.ttsEnabled) return
|
|
1427
|
+
if (runtime.options.ttsOnlyAfterVoice
|
|
1428
|
+
&& (!runtime.lastVoiceAt || Date.now() - runtime.lastVoiceAt > 120000)) return
|
|
1429
|
+
const text = cleanForSpeech(assistantTextFromEvent(event))
|
|
1430
|
+
if (!text) return
|
|
1431
|
+
speechStream.broadcast({
|
|
1432
|
+
kind: 'assistant',
|
|
1433
|
+
sessionId: String(session?.header?.id ?? session?.id ?? ''),
|
|
1434
|
+
text,
|
|
1435
|
+
})
|
|
1436
|
+
})
|
|
1437
|
+
if (typeof off === 'function') ctx.effect(() => off)
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
logger.warn?.('[dsh-dafeiyu-emotion] failed to subscribe to session events:', error)
|
|
1440
|
+
}
|
|
1441
|
+
})
|
|
1063
1442
|
|
|
1064
1443
|
ctx.inject(['webServer'], (httpCtx) => {
|
|
1065
1444
|
httpCtx.effect(
|
|
@@ -1086,6 +1465,11 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1086
1465
|
enabled: options.enabled,
|
|
1087
1466
|
language: options.language,
|
|
1088
1467
|
empathyStyle: options.empathyStyle,
|
|
1468
|
+
ttsEnabled: options.ttsEnabled,
|
|
1469
|
+
ttsOnlyAfterVoice: options.ttsOnlyAfterVoice,
|
|
1470
|
+
ttsEngine: options.ttsEngine,
|
|
1471
|
+
ttsVoice: options.ttsVoice,
|
|
1472
|
+
ttsReady: ttsReady(options),
|
|
1089
1473
|
engine: backendAvailable ? 'SenseVoice + emotion2vec' : 'DSH Speech + prosody',
|
|
1090
1474
|
mode: !backendConfigured
|
|
1091
1475
|
? 'dsh-speech-prosody'
|
|
@@ -1101,6 +1485,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1101
1485
|
privacy: {
|
|
1102
1486
|
lastAudioBytes: runtime.lastAudioBytes,
|
|
1103
1487
|
lastAnalysisAt: runtime.lastAnalysisAt,
|
|
1488
|
+
lastVoiceAt: runtime.lastVoiceAt,
|
|
1104
1489
|
lastCleanupAt: runtime.lastCleanupAt,
|
|
1105
1490
|
workerPid: backendStatus.pid,
|
|
1106
1491
|
modelLoadMs: backendStatus.modelLoadMs,
|
|
@@ -1154,19 +1539,52 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1154
1539
|
}),
|
|
1155
1540
|
'dsh-dafeiyu-emotion: 最近情绪上下文',
|
|
1156
1541
|
)
|
|
1542
|
+
httpCtx.effect(
|
|
1543
|
+
() => httpCtx.webServer.register({
|
|
1544
|
+
kind: 'exact',
|
|
1545
|
+
path: TTS_ENDPOINT,
|
|
1546
|
+
handler: createTtsHandler(runtime),
|
|
1547
|
+
}),
|
|
1548
|
+
'dsh-dafeiyu-emotion: 语音合成',
|
|
1549
|
+
)
|
|
1550
|
+
httpCtx.effect(
|
|
1551
|
+
() => httpCtx.webServer.register({
|
|
1552
|
+
kind: 'exact',
|
|
1553
|
+
path: EVENTS_ENDPOINT,
|
|
1554
|
+
handler(req, res) {
|
|
1555
|
+
if (!isLocalRequest(req)) {
|
|
1556
|
+
jsonResponse(res, 403, { error: '只允许本机访问' })
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
1559
|
+
if (req.method !== 'GET') {
|
|
1560
|
+
jsonResponse(res, 405, { error: 'method not allowed' })
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
speechStream.add(res)
|
|
1564
|
+
},
|
|
1565
|
+
}),
|
|
1566
|
+
'dsh-dafeiyu-emotion: 回复语音事件',
|
|
1567
|
+
)
|
|
1157
1568
|
})
|
|
1158
1569
|
|
|
1159
1570
|
}
|
|
1160
1571
|
|
|
1161
1572
|
export {
|
|
1162
1573
|
VoiceBackend,
|
|
1574
|
+
assistantTextFromEvent,
|
|
1163
1575
|
buildFallbackAnalysis,
|
|
1164
1576
|
buildPrompt,
|
|
1577
|
+
cleanForSpeech,
|
|
1165
1578
|
createConfigHandler,
|
|
1166
1579
|
decorateAnalysis,
|
|
1167
1580
|
normalizeConfigPatch,
|
|
1168
1581
|
plainConfig,
|
|
1169
1582
|
publicConfig,
|
|
1583
|
+
resolveEdgeTts,
|
|
1584
|
+
resolvePiperCommand,
|
|
1585
|
+
resolvePiperModel,
|
|
1170
1586
|
resolveOptions,
|
|
1587
|
+
synthesizePiper,
|
|
1588
|
+
ttsReady,
|
|
1171
1589
|
transcribeWithSpeech,
|
|
1172
1590
|
}
|