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 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.0.tgz \
119
- https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.3.0/dsh-dafeiyu-emotion-0.3.0.tgz
120
- dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.3.0.tgz"
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
@@ -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.0` on npm. The 1024Store entry refreshes its one-click install automatically. GitHub-source and Release-tarball installs remain available as fallbacks.
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.0",
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
  }