dsh-dafeiyu-emotion 0.2.1 → 0.2.3

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3
4
+
5
+ - Documentation-only release: npm and the 1024Store entry now serve the corrected install notes for the 0.2.2 bug-fix release.
6
+ - No runtime behavior changed from 0.2.2.
7
+
8
+ ## 0.2.2
9
+
10
+ - Fixed a duplicate-send race between auto-send and the manual send button.
11
+ - Cancelled stale deliveries when the session changes or the component unmounts.
12
+ - Stopped overwriting a busy composer when `insertText` is refused; send failures now keep the analysis for retry.
13
+ - Added a cancel action and timeout while waiting for microphone permission.
14
+ - Made prosody extraction yield to the UI thread instead of blocking on long recordings.
15
+ - Corrected the base64 request-body limit and mapped validation failures to 400/413 instead of 500.
16
+ - Pinned speech-provider readiness checks to the provider actually used for transcription.
17
+ - Reported missing enhanced backends in health and preserved the real worker failure reason.
18
+ - Added regression tests for delivery gating, shortcut availability and the analyze handler.
19
+
3
20
  ## 0.2.1
4
21
 
5
22
  - Added a native DSH voice shortcut, configurable from DSH keyboard settings.
package/README.md CHANGED
@@ -107,9 +107,9 @@ dsh plugin --profile desktop add github:LiDu-china/dsh-dafeiyu-emotion
107
107
  ### 通过 Release tarball
108
108
 
109
109
  ```bash
110
- curl -L -o dsh-dafeiyu-emotion-0.2.1.tgz \
111
- https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.1/dsh-dafeiyu-emotion-0.2.1.tgz
112
- dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.1.tgz"
110
+ curl -L -o dsh-dafeiyu-emotion-0.2.3.tgz \
111
+ https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.3/dsh-dafeiyu-emotion-0.2.3.tgz
112
+ dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.3.tgz"
113
113
  ```
114
114
 
115
115
  > DSH Desktop 的 profile 由 Electron 应用管理;命令行安装请使用桌面应用自带的 `dsh` 命令。
@@ -96,6 +96,7 @@ def main() -> int:
96
96
  if not line:
97
97
  continue
98
98
  request: dict[str, Any] = {}
99
+ request_id: Any = None
99
100
  try:
100
101
  request = json.loads(line)
101
102
  if request.get("type") == "shutdown":
@@ -175,15 +176,17 @@ def main() -> int:
175
176
  detail = traceback.format_exc()
176
177
  sys.stderr.write(detail)
177
178
  emit({
178
- "id": request.get("id"),
179
+ "id": request_id,
179
180
  "ok": False,
180
181
  "error": f"{type(exc).__name__}: {exc}",
181
182
  })
182
183
 
183
184
  try:
184
185
  with contextlib.redirect_stdout(sys.stderr):
185
- emotion.close()
186
- asr.close()
186
+ try:
187
+ emotion.close()
188
+ finally:
189
+ asr.close()
187
190
  except Exception:
188
191
  sys.stderr.write(traceback.format_exc())
189
192
  return 0
@@ -68,7 +68,7 @@ https://raw.githubusercontent.com/LiDu-china/dsh-dafeiyu-emotion/main/assets/ico
68
68
  dsh plugin --profile desktop add dsh-dafeiyu-emotion
69
69
  ```
70
70
 
71
- Published as `dsh-dafeiyu-emotion@0.2.1` on npm. The 1024Store entry refreshes its one-click install automatically. GitHub-source install remains available as a fallback.
71
+ Published as `dsh-dafeiyu-emotion@0.2.3` on npm. The 1024Store entry refreshes its one-click install automatically. GitHub-source and Release-tarball installs remain available as fallbacks.
72
72
 
73
73
  ## Privacy
74
74
 
package/lib/client.js CHANGED
@@ -9,6 +9,7 @@ window.__ModuleLoader__.load({
9
9
 
10
10
  const ANALYZE_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/analyze'
11
11
  const MAX_RECORDING_MS = 30000
12
+ const PERMISSION_TIMEOUT_MS = 20000
12
13
  const EMOTION_LABELS = {
13
14
  happy: '喜悦',
14
15
  sad: '低落',
@@ -29,6 +30,10 @@ window.__ModuleLoader__.load({
29
30
  }
30
31
  const voiceShortcutHandlers = []
31
32
 
33
+ function useNoInput() {
34
+ return null
35
+ }
36
+
32
37
  function registerVoiceShortcut(handler) {
33
38
  voiceShortcutHandlers.push(handler)
34
39
  return () => {
@@ -37,22 +42,52 @@ window.__ModuleLoader__.load({
37
42
  }
38
43
  }
39
44
 
40
- function hasVoiceShortcutHandler() {
41
- return voiceShortcutHandlers.length > 0
45
+ function canUseVoiceShortcut(phase, locked) {
46
+ return !locked && (phase === 'idle' || phase === 'recording' || phase === 'error')
47
+ }
48
+
49
+ function canInvokeVoiceShortcut() {
50
+ const handler = voiceShortcutHandlers.at(-1)
51
+ if (!handler) return false
52
+ try {
53
+ return handler.canHandle?.() === true
54
+ } catch {
55
+ return false
56
+ }
42
57
  }
43
58
 
44
59
  function invokeVoiceShortcut() {
45
60
  const handler = voiceShortcutHandlers.at(-1)
46
61
  if (!handler) return false
47
62
  try {
48
- handler()
49
- return true
63
+ return handler.run?.() === true
50
64
  } catch (error) {
51
65
  console.error('[dsh-dafeiyu-emotion] voice shortcut failed:', error)
52
66
  return false
53
67
  }
54
68
  }
55
69
 
70
+ function createDeliveryGate() {
71
+ let active = false
72
+ return {
73
+ begin() {
74
+ if (active) return false
75
+ active = true
76
+ return true
77
+ },
78
+ end() {
79
+ active = false
80
+ },
81
+ get active() {
82
+ return active
83
+ },
84
+ }
85
+ }
86
+
87
+ function isRunCurrent(run, generation) {
88
+ return run === generation.current
89
+ }
90
+
56
91
  const S = {
57
92
  trigger: {
58
93
  display: 'inline-flex',
@@ -214,7 +249,8 @@ window.__ModuleLoader__.load({
214
249
  return Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length)
215
250
  }
216
251
 
217
- function computeProsody(samples, sampleRate) {
252
+ async function computeProsody(samples, sampleRate, shouldStop) {
253
+ if (shouldStop?.()) return {}
218
254
  const duration = samples.length / sampleRate
219
255
  if (samples.length < Math.round(sampleRate * 0.3)) {
220
256
  return { duration, voiced_ratio: 0, pause_ratio: 1, energy_n: 0.1, pitch_n: 0.5, var_n: 0.2 }
@@ -229,6 +265,10 @@ window.__ModuleLoader__.load({
229
265
  let frameIndex = 0
230
266
  let pitchFrames = 0
231
267
  for (let start = 0; start + frameLength <= samples.length; start += hop, frameIndex += 1) {
268
+ if ((frameIndex & 127) === 0) {
269
+ if (shouldStop?.()) return {}
270
+ await new Promise((resolve) => setTimeout(resolve, 0))
271
+ }
232
272
  let energy = 0
233
273
  let crossings = 0
234
274
  let previous = samples[start] >= 0
@@ -361,7 +401,7 @@ window.__ModuleLoader__.load({
361
401
  this.silentGain.connect(this.context.destination)
362
402
  }
363
403
 
364
- async stop() {
404
+ async stop(isCancelled = () => false) {
365
405
  if (!this.active) return { wav: new Uint8Array(), prosody: {} }
366
406
  this.active = false
367
407
  const inputRate = this.sampleRate
@@ -374,10 +414,11 @@ window.__ModuleLoader__.load({
374
414
  offset += chunk.length
375
415
  }
376
416
  await this.dispose()
417
+ if (isCancelled()) return { wav: new Uint8Array(), prosody: {} }
377
418
  const resampled = resample(merged, inputRate, 16000)
378
419
  return {
379
420
  wav: encodeWav(resampled, 16000),
380
- prosody: computeProsody(resampled, 16000),
421
+ prosody: await computeProsody(resampled, 16000, isCancelled),
381
422
  }
382
423
  }
383
424
 
@@ -420,7 +461,7 @@ window.__ModuleLoader__.load({
420
461
  )
421
462
  }
422
463
 
423
- function VoiceFish({ sessionId, inputActions, locked, onActiveChange }) {
464
+ function VoiceFish({ sessionId, inputActions, useInput, locked, onActiveChange }) {
424
465
  const [phase, setPhase] = useState('idle')
425
466
  const [level, setLevel] = useState(0)
426
467
  const [levels, setLevels] = useState([])
@@ -428,13 +469,21 @@ window.__ModuleLoader__.load({
428
469
  const [message, setMessage] = useState('')
429
470
  const [analysis, setAnalysis] = useState(null)
430
471
  const [sent, setSent] = useState(false)
472
+ const [sending, setSending] = useState(false)
431
473
  const recorder = useRef(null)
432
474
  const abort = useRef(null)
433
475
  const timer = useRef(null)
434
476
  const ticker = useRef(null)
477
+ const permissionTimer = useRef(null)
435
478
  const generation = useRef(0)
436
479
  const levelsRef = useRef([])
437
480
  const shortcutAction = useRef(null)
481
+ const deliveryGate = useRef(null)
482
+ if (!deliveryGate.current) deliveryGate.current = createDeliveryGate()
483
+ const useInputSnapshot = typeof useInput === 'function' ? useInput : useNoInput
484
+ const input = useInputSnapshot((snapshot) => snapshot)
485
+ const inputRef = useRef(input)
486
+ inputRef.current = input
438
487
  const expanded = phase !== 'idle'
439
488
 
440
489
  useLayoutEffect(() => {
@@ -451,8 +500,10 @@ window.__ModuleLoader__.load({
451
500
  const clearTimers = () => {
452
501
  if (timer.current) clearTimeout(timer.current)
453
502
  if (ticker.current) clearInterval(ticker.current)
503
+ if (permissionTimer.current) clearTimeout(permissionTimer.current)
454
504
  timer.current = null
455
505
  ticker.current = null
506
+ permissionTimer.current = null
456
507
  }
457
508
 
458
509
  const reset = async () => {
@@ -471,6 +522,7 @@ window.__ModuleLoader__.load({
471
522
  setMessage('')
472
523
  setAnalysis(null)
473
524
  setSent(false)
525
+ setSending(false)
474
526
  }
475
527
 
476
528
  useEffect(() => {
@@ -498,14 +550,30 @@ window.__ModuleLoader__.load({
498
550
  const start = async () => {
499
551
  if (locked || recorder.current) return
500
552
  const run = ++generation.current
553
+ clearTimers()
501
554
  setMessage('')
502
555
  setAnalysis(null)
503
556
  setSent(false)
557
+ setSending(false)
504
558
  setPhase('requesting')
505
559
  const active = new Recorder()
506
560
  recorder.current = active
561
+ permissionTimer.current = setTimeout(() => {
562
+ permissionTimer.current = null
563
+ if (run !== generation.current) return
564
+ generation.current += 1
565
+ const pending = recorder.current
566
+ recorder.current = null
567
+ if (pending) pending.dispose().catch(() => {})
568
+ setPhase('error')
569
+ setMessage('麦克风授权超时,请检查浏览器权限后重试')
570
+ }, PERMISSION_TIMEOUT_MS)
507
571
  try {
508
572
  await active.start(onLevel)
573
+ if (permissionTimer.current) {
574
+ clearTimeout(permissionTimer.current)
575
+ permissionTimer.current = null
576
+ }
509
577
  if (run !== generation.current) {
510
578
  await active.dispose()
511
579
  return
@@ -520,8 +588,13 @@ window.__ModuleLoader__.load({
520
588
  void finish()
521
589
  }, MAX_RECORDING_MS)
522
590
  } catch (error) {
591
+ if (permissionTimer.current) {
592
+ clearTimeout(permissionTimer.current)
593
+ permissionTimer.current = null
594
+ }
523
595
  recorder.current = null
524
596
  await active.dispose().catch(() => {})
597
+ if (run !== generation.current) return
525
598
  setPhase('error')
526
599
  setMessage(error instanceof Error ? error.message : String(error))
527
600
  }
@@ -536,7 +609,7 @@ window.__ModuleLoader__.load({
536
609
  setPhase('analyzing')
537
610
  setMessage('大肥鱼正在听语气…')
538
611
  try {
539
- const capture = await active.stop()
612
+ const capture = await active.stop(() => run !== generation.current)
540
613
  const wav = capture.wav
541
614
  if (run !== generation.current) return
542
615
  if (wav.length < 12000) {
@@ -562,7 +635,7 @@ window.__ModuleLoader__.load({
562
635
  setAnalysis(payload)
563
636
  setPhase('result')
564
637
  setMessage('')
565
- await deliver(payload)
638
+ await deliver(payload, run)
566
639
  } catch (error) {
567
640
  if (error?.name === 'AbortError') return
568
641
  if (run !== generation.current) return
@@ -573,28 +646,58 @@ window.__ModuleLoader__.load({
573
646
  }
574
647
  }
575
648
 
576
- const deliver = async (payload) => {
649
+ const deliver = async (payload, run = generation.current) => {
650
+ if (!isRunCurrent(run, generation)) return false
577
651
  const prompt = String(payload.prompt || '').trim()
578
652
  if (!prompt) {
579
- setPhase('error')
580
- setMessage('没有生成可发送的语音内容')
581
- return
653
+ if (isRunCurrent(run, generation)) {
654
+ setPhase('error')
655
+ setMessage('没有生成可发送的语音内容')
656
+ }
657
+ return false
582
658
  }
659
+ const gate = deliveryGate.current
660
+ if (!gate.begin()) return false
661
+ setSending(true)
583
662
  try {
584
- let inserted = false
585
- try {
586
- inserted = inputActions?.insertText?.(prompt, inputActions.captureInsertion()) === true
587
- } catch {}
588
- if (!inserted) inputActions?.setDraft?.(prompt)
663
+ if (!isRunCurrent(run, generation)) return false
664
+ const inputPhase = inputRef.current?.phase
665
+ if (inputPhase === 'adjudicating' || inputPhase === 'submitting') {
666
+ throw new Error('输入框正在提交,请稍后再试')
667
+ }
668
+ if (typeof inputActions?.insertText === 'function') {
669
+ if (typeof inputActions.captureInsertion !== 'function') {
670
+ throw new Error('当前 DSH 版本缺少输入位置接口')
671
+ }
672
+ const inserted = inputActions.insertText(prompt, inputActions.captureInsertion()) === true
673
+ if (!inserted) throw new Error('输入框正忙,请稍后再试')
674
+ } else if (typeof inputActions?.setDraft === 'function') {
675
+ inputActions.setDraft(prompt)
676
+ } else {
677
+ throw new Error('当前 DSH 版本不支持自动填入')
678
+ }
679
+ if (!isRunCurrent(run, generation)) return false
589
680
  await sleep(120)
590
- inputActions?.submit?.()
681
+ if (!isRunCurrent(run, generation)) return false
682
+ if (typeof inputActions?.submit !== 'function') {
683
+ throw new Error('当前 DSH 版本不支持自动发送')
684
+ }
685
+ inputActions.submit()
686
+ if (!isRunCurrent(run, generation)) return false
591
687
  setSent(true)
592
- setMessage(`听出${payload.emotionZh || '你的情绪'},已交给大肥鱼回复`)
688
+ setMessage(`听出${payload.emotionZh || '你的情绪'},已提交给大肥鱼`)
593
689
  await sleep(1350)
594
- await reset()
690
+ if (isRunCurrent(run, generation)) await reset()
691
+ return true
595
692
  } catch (error) {
596
- setPhase('error')
597
- setMessage(`已识别,但发送失败:${error instanceof Error ? error.message : String(error)}`)
693
+ if (isRunCurrent(run, generation)) {
694
+ setPhase('error')
695
+ setMessage(`已识别,但发送失败:${error instanceof Error ? error.message : String(error)}`)
696
+ }
697
+ return false
698
+ } finally {
699
+ gate.end()
700
+ if (isRunCurrent(run, generation)) setSending(false)
598
701
  }
599
702
  }
600
703
 
@@ -606,17 +709,38 @@ window.__ModuleLoader__.load({
606
709
  void start()
607
710
  }
608
711
 
712
+ const retrySend = () => {
713
+ if (!analysis) return
714
+ setPhase('result')
715
+ setMessage('')
716
+ void deliver(analysis, generation.current)
717
+ }
718
+
609
719
  useEffect(() => {
610
- shortcutAction.current = () => {
611
- if (locked) return
612
- if (phase === 'idle') void start()
613
- else if (phase === 'recording') void finish()
614
- else if (phase === 'error') retry()
720
+ shortcutAction.current = {
721
+ canHandle: () => canUseVoiceShortcut(phase, locked),
722
+ run: () => {
723
+ if (locked) return false
724
+ if (phase === 'idle') {
725
+ void start()
726
+ return true
727
+ }
728
+ if (phase === 'recording') {
729
+ void finish()
730
+ return true
731
+ }
732
+ if (phase === 'error') {
733
+ retry()
734
+ return true
735
+ }
736
+ return false
737
+ },
615
738
  }
616
739
  }, [locked, phase, sessionId])
617
740
 
618
- useEffect(() => registerVoiceShortcut(() => {
619
- shortcutAction.current?.()
741
+ useEffect(() => registerVoiceShortcut({
742
+ canHandle: () => shortcutAction.current?.canHandle?.() === true,
743
+ run: () => shortcutAction.current?.run?.() === true,
620
744
  }), [])
621
745
 
622
746
  if (phase === 'idle') {
@@ -675,13 +799,27 @@ window.__ModuleLoader__.load({
675
799
  'aria-label': '结束并分析',
676
800
  onClick: () => void finish(),
677
801
  }, '■'),
678
- (phase === 'error' || phase === 'result') && h('button', {
802
+ phase === 'requesting' && h('button', {
803
+ type: 'button',
804
+ style: S.round,
805
+ title: '取消等待麦克风',
806
+ 'aria-label': '取消等待麦克风',
807
+ onClick: () => void reset(),
808
+ }, '×'),
809
+ phase === 'error' && h('button', {
810
+ type: 'button',
811
+ style: S.round,
812
+ title: analysis ? '重试发送' : '重试录音',
813
+ 'aria-label': analysis ? '重试发送' : '重试录音',
814
+ onClick: analysis ? retrySend : retry,
815
+ }, analysis ? '↺' : '↻'),
816
+ phase === 'result' && h('button', {
679
817
  type: 'button',
680
818
  style: S.round,
681
- title: phase === 'error' ? '重试' : '关闭',
682
- 'aria-label': phase === 'error' ? '重试' : '关闭',
683
- onClick: phase === 'error' ? retry : () => void reset(),
684
- }, phase === 'error' ? '↻' : '×'),
819
+ title: '关闭',
820
+ 'aria-label': '关闭',
821
+ onClick: () => void reset(),
822
+ }, '×'),
685
823
  phase === 'analyzing' && h('button', {
686
824
  type: 'button',
687
825
  style: S.round,
@@ -689,10 +827,11 @@ window.__ModuleLoader__.load({
689
827
  'aria-label': '取消分析',
690
828
  onClick: () => void reset(),
691
829
  }, '×'),
692
- phase === 'result' && !sent && h('button', {
830
+ phase === 'result' && !sent && !sending && h('button', {
693
831
  type: 'button',
694
832
  style: { ...S.trigger, height: 26, fontSize: 12 },
695
- onClick: () => void deliver(analysis),
833
+ disabled: sending,
834
+ onClick: () => void deliver(analysis, generation.current),
696
835
  }, '发送'),
697
836
  )
698
837
  }
@@ -712,7 +851,7 @@ window.__ModuleLoader__.load({
712
851
  },
713
852
  regions: ['page', 'editable'],
714
853
  modals: [],
715
- resolve: () => hasVoiceShortcutHandler()
854
+ resolve: () => canInvokeVoiceShortcut()
716
855
  ? {
717
856
  status: 'handled',
718
857
  run: () => {
@@ -728,7 +867,7 @@ window.__ModuleLoader__.load({
728
867
  name: 'conversation.input.activity',
729
868
  id: 'dsh-dafeiyu-emotion',
730
869
  order: 35,
731
- inject: () => ({}),
870
+ inject: (sessionId) => ({ sessionId }),
732
871
  }, VoiceFish)
733
872
  } catch (error) {
734
873
  console.error('[dsh-dafeiyu-emotion] failed to register voice activity:', error)
@@ -746,6 +885,11 @@ window.__ModuleLoader__.load({
746
885
  name: 'dsh-dafeiyu-emotion-client',
747
886
  inject: ['slots'],
748
887
  apply,
888
+ __test: {
889
+ createDeliveryGate,
890
+ canUseVoiceShortcut,
891
+ isRunCurrent,
892
+ },
749
893
  }
750
894
  return module.exports
751
895
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dafeiyu-emotion",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "大肥鱼语音情绪耳朵:听语气、识别情绪,并让 DSH 按对方当下的状态自然回复。 · Voice + emotion-aware companion for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -53,17 +53,22 @@
53
53
  "url": "https://github.com/LiDu-china/dsh-dafeiyu-emotion/issues"
54
54
  },
55
55
  "license": "MIT",
56
+ "engines": {
57
+ "node": "^22.19.0 || >=24.0.0",
58
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
59
+ },
56
60
  "publishConfig": {
57
61
  "registry": "https://registry.npmjs.org/"
58
62
  },
59
63
  "scripts": {
60
64
  "check": "node --check src/index.js && node --check lib/client.js",
61
- "test": "node --check src/index.js && node --check lib/client.js && node --test scripts/test-shortcut-registration.mjs"
65
+ "test": "node --check src/index.js && node --check lib/client.js && node --test scripts/test-shortcut-registration.mjs scripts/test-delivery-policy.mjs scripts/test-analyze-handler.mjs"
62
66
  },
63
67
  "dependencies": {
64
68
  "dsh-dafeiyu": "0.1.23"
65
69
  },
66
70
  "dsh": {
71
+ "manifestVersion": 1,
67
72
  "client": {
68
73
  "platform": "web",
69
74
  "inject": []
@@ -0,0 +1,209 @@
1
+ import assert from 'node:assert/strict'
2
+ import { EventEmitter } from 'node:events'
3
+ import { Readable } from 'node:stream'
4
+
5
+ import { apply } from '../src/index.js'
6
+
7
+ const ANALYZE = '/plugins/dsh-dafeiyu-emotion/analyze'
8
+ const HEALTH = '/plugins/dsh-dafeiyu-emotion/health'
9
+
10
+ function makeCtx(speechToText) {
11
+ const handlers = new Map()
12
+ const ctx = {
13
+ logger: { debug() {}, info() {}, error() {} },
14
+ effect(run) {
15
+ const dispose = run()
16
+ return typeof dispose === 'function' ? dispose : () => {}
17
+ },
18
+ inject(dependencies, callback) {
19
+ if (dependencies.includes('webServer')) {
20
+ callback({
21
+ effect(run) {
22
+ const dispose = run()
23
+ return typeof dispose === 'function' ? dispose : () => {}
24
+ },
25
+ webServer: {
26
+ register(spec) {
27
+ handlers.set(spec.path, spec.handler)
28
+ return () => {}
29
+ },
30
+ },
31
+ })
32
+ } else if (dependencies.includes('speechToText')) {
33
+ callback({
34
+ speechToText,
35
+ effect(run) {
36
+ const dispose = run()
37
+ return typeof dispose === 'function' ? dispose : () => {}
38
+ },
39
+ })
40
+ }
41
+ },
42
+ }
43
+ return { ctx, handlers }
44
+ }
45
+
46
+ function makeReq({ body, method = 'POST', remoteAddress = '127.0.0.1' } = {}) {
47
+ const payload = body === undefined ? '' : JSON.stringify(body)
48
+ const req = Readable.from([Buffer.from(payload)])
49
+ req.method = method
50
+ req.socket = { remoteAddress }
51
+ req.headers = { host: '127.0.0.1:13344' }
52
+ return req
53
+ }
54
+
55
+ function makeRes() {
56
+ const res = new EventEmitter()
57
+ res.writableEnded = false
58
+ res.status = 0
59
+ res.headers = null
60
+ res.payload = null
61
+ res.writeHead = (status, headers) => {
62
+ res.status = status
63
+ res.headers = headers
64
+ }
65
+ res.end = (payload) => {
66
+ res.payload = payload
67
+ res.writableEnded = true
68
+ res.emit('finish')
69
+ }
70
+ return res
71
+ }
72
+
73
+ function json(res) {
74
+ return JSON.parse(res.payload)
75
+ }
76
+
77
+ function validWav() {
78
+ const wav = Buffer.alloc(500)
79
+ wav.write('RIFF', 0, 'ascii')
80
+ wav.write('WAVE', 8, 'ascii')
81
+ return wav
82
+ }
83
+
84
+ async function call(handler, req) {
85
+ const res = makeRes()
86
+ await handler(req, res)
87
+ return res
88
+ }
89
+
90
+ // Non-local requests are refused before the body is read.
91
+ {
92
+ const { ctx, handlers } = makeCtx(undefined)
93
+ apply(ctx, { enabled: true, backendRoot: '' })
94
+ const res = await call(handlers.get(ANALYZE), makeReq({
95
+ body: { audioBase64: 'AA==' },
96
+ remoteAddress: '10.0.0.5',
97
+ }))
98
+ assert.equal(res.status, 403)
99
+ assert.equal(json(res).error, '只允许本机访问')
100
+ }
101
+
102
+ // Wrong method is rejected.
103
+ {
104
+ const { ctx, handlers } = makeCtx(undefined)
105
+ apply(ctx, { enabled: true, backendRoot: '' })
106
+ const res = await call(handlers.get(ANALYZE), makeReq({ method: 'GET' }))
107
+ assert.equal(res.status, 405)
108
+ }
109
+
110
+ // Body too large is 413, not 500.
111
+ {
112
+ const { ctx, handlers } = makeCtx(undefined)
113
+ apply(ctx, { enabled: true, backendRoot: '', maxAudioBytes: 256 * 1024 })
114
+ const res = await call(handlers.get(ANALYZE), makeReq({
115
+ body: { audioBase64: 'A'.repeat(500000) },
116
+ }))
117
+ assert.equal(res.status, 413)
118
+ assert.match(json(res).error, /请求体过大/)
119
+ }
120
+
121
+ // Invalid JSON and invalid WAV are 400.
122
+ {
123
+ const { ctx, handlers } = makeCtx(undefined)
124
+ apply(ctx, { enabled: true, backendRoot: '' })
125
+ const invalidJson = await call(handlers.get(ANALYZE), makeReq({ body: undefined }))
126
+ assert.equal(invalidJson.status, 400)
127
+ const invalidWav = await call(handlers.get(ANALYZE), makeReq({
128
+ body: { audioBase64: Buffer.alloc(500).toString('base64') },
129
+ }))
130
+ assert.equal(invalidWav.status, 400)
131
+ assert.match(json(invalidWav).error, /WAV/)
132
+ }
133
+
134
+ // The readiness check and resolve must use the selected provider.
135
+ {
136
+ let resolveArgs
137
+ const speechToText = {
138
+ snapshot: () => ({
139
+ providers: [
140
+ { id: 'first', preparation: { phase: 'ready' } },
141
+ { id: 'second', preparation: { phase: 'ready' } },
142
+ ],
143
+ selection: { providerId: 'second' },
144
+ }),
145
+ resolve(args) {
146
+ resolveArgs = args
147
+ return { provider: { id: args.providerId }, audio: args.audio, language: args.language }
148
+ },
149
+ async transcribe() {
150
+ return { text: '你好' }
151
+ },
152
+ }
153
+ const { ctx, handlers } = makeCtx(speechToText)
154
+ apply(ctx, { enabled: true, backendRoot: '' })
155
+ const res = await call(handlers.get(ANALYZE), makeReq({
156
+ body: { audioBase64: validWav().toString('base64'), prosody: {} },
157
+ }))
158
+ assert.equal(res.status, 200)
159
+ assert.equal(resolveArgs.providerId, 'second')
160
+ assert.equal(json(res).transcript, '你好')
161
+ }
162
+
163
+ // A configured but missing backend is visible in both analyze and health.
164
+ {
165
+ const missing = 'Z:/dsh-dafeiyu-emotion-missing-backend'
166
+ const speechToText = {
167
+ snapshot: () => ({ providers: [], selection: {} }),
168
+ resolve() {
169
+ throw new Error('should not resolve')
170
+ },
171
+ async transcribe() {
172
+ throw new Error('speech unavailable')
173
+ },
174
+ }
175
+ const { ctx, handlers } = makeCtx(speechToText)
176
+ apply(ctx, { enabled: true, backendRoot: missing })
177
+ const analyze = await call(handlers.get(ANALYZE), makeReq({
178
+ body: { audioBase64: validWav().toString('base64'), prosody: {} },
179
+ }))
180
+ assert.equal(analyze.status, 500)
181
+ assert.match(json(analyze).error, /语音后端目录不存在/)
182
+
183
+ const health = await call(handlers.get(HEALTH), makeReq({ method: 'GET' }))
184
+ const payload = json(health)
185
+ assert.equal(payload.mode, 'enhanced-backend-missing')
186
+ assert.equal(payload.backendConfigured, true)
187
+ assert.equal(payload.backendAvailable, false)
188
+ assert.match(payload.lastError, /语音后端目录不存在/)
189
+ }
190
+
191
+ // Health reflects real provider readiness, not just service presence.
192
+ {
193
+ const speechToText = {
194
+ snapshot: () => ({
195
+ providers: [{ id: 'sensevoice', preparation: { phase: 'ready' } }],
196
+ selection: { providerId: 'sensevoice' },
197
+ }),
198
+ }
199
+ const { ctx, handlers } = makeCtx(speechToText)
200
+ apply(ctx, { enabled: true, backendRoot: '' })
201
+ const health = await call(handlers.get(HEALTH), makeReq({ method: 'GET' }))
202
+ const payload = json(health)
203
+ assert.equal(payload.mode, 'dsh-speech-prosody')
204
+ assert.equal(payload.dshSpeechReady, true)
205
+ assert.equal(payload.speechProvider, 'sensevoice')
206
+ assert.equal(payload.speechPhase, 'ready')
207
+ }
208
+
209
+ console.log('analyze handler ok')
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ let clientModule
4
+
5
+ globalThis.window = {
6
+ __ModuleLoader__: {
7
+ load({ factory }) {
8
+ clientModule = factory((id) => {
9
+ assert.equal(id, 'react')
10
+ return {
11
+ createElement() {},
12
+ Fragment: {},
13
+ useEffect() {},
14
+ useLayoutEffect() {},
15
+ useRef() {},
16
+ useState() {},
17
+ }
18
+ })
19
+ },
20
+ },
21
+ }
22
+
23
+ await import(new URL('../lib/client.js', import.meta.url).href)
24
+ assert.ok(clientModule?.__test, 'client bundle should expose delivery test helpers')
25
+
26
+ const { createDeliveryGate, canUseVoiceShortcut, isRunCurrent } = clientModule.__test
27
+
28
+ const gate = createDeliveryGate()
29
+ assert.equal(gate.begin(), true, 'first delivery should acquire the gate')
30
+ assert.equal(gate.begin(), false, 'a concurrent delivery must be rejected')
31
+ gate.end()
32
+ assert.equal(gate.begin(), true, 'the gate should reopen after settlement')
33
+ gate.end()
34
+
35
+ assert.equal(canUseVoiceShortcut('idle', false), true)
36
+ assert.equal(canUseVoiceShortcut('recording', false), true)
37
+ assert.equal(canUseVoiceShortcut('error', false), true)
38
+ assert.equal(canUseVoiceShortcut('requesting', false), false)
39
+ assert.equal(canUseVoiceShortcut('analyzing', false), false)
40
+ assert.equal(canUseVoiceShortcut('result', false), false)
41
+ assert.equal(canUseVoiceShortcut('idle', true), false)
42
+
43
+ assert.equal(isRunCurrent(2, { current: 2 }), true)
44
+ assert.equal(isRunCurrent(2, { current: 3 }), false)
45
+
46
+ delete globalThis.window
47
+ console.log('delivery policy ok')
@@ -60,6 +60,9 @@ assert.deepEqual(registeredCommand?.defaults['web:windows'], {
60
60
  modifiers: ['primary', 'alt'],
61
61
  })
62
62
  assert.deepEqual(registeredCommand?.resolve(), { status: 'pass' })
63
+ assert.equal(clientModule.__test.canUseVoiceShortcut('idle', false), true)
64
+ assert.equal(clientModule.__test.canUseVoiceShortcut('analyzing', false), false)
65
+ assert.equal(clientModule.__test.canUseVoiceShortcut('recording', true), false)
63
66
 
64
67
  delete globalThis.window
65
68
  console.log('shortcut registration ok')
package/src/index.js CHANGED
@@ -35,6 +35,20 @@ const EMOTION_ZH = Object.freeze({
35
35
  neutral: '平静',
36
36
  })
37
37
 
38
+ class HttpError extends Error {
39
+ constructor(status, message) {
40
+ super(message)
41
+ this.name = 'HttpError'
42
+ this.status = status
43
+ }
44
+ }
45
+
46
+ function createAbortError() {
47
+ const error = new Error('已取消')
48
+ error.name = 'AbortError'
49
+ return error
50
+ }
51
+
38
52
  function isLocalRequest(req) {
39
53
  if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket?.remoteAddress)) return false
40
54
  if (req.headers?.host) {
@@ -55,6 +69,7 @@ function isLocalRequest(req) {
55
69
  }
56
70
 
57
71
  function jsonResponse(res, status, body) {
72
+ if (res.writableEnded || res.destroyed) return
58
73
  const payload = JSON.stringify(body)
59
74
  res.writeHead(status, {
60
75
  'content-type': 'application/json; charset=utf-8',
@@ -69,10 +84,14 @@ async function readJsonBody(req, limit) {
69
84
  let bytes = 0
70
85
  for await (const chunk of req) {
71
86
  bytes += chunk.length
72
- if (bytes > limit) throw new Error('请求体过大,请缩短录音后重试')
87
+ if (bytes > limit) throw new HttpError(413, '请求体过大,请缩短录音后重试')
73
88
  chunks.push(chunk)
74
89
  }
75
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
90
+ try {
91
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
92
+ } catch {
93
+ throw new HttpError(400, '请求体不是合法的 JSON')
94
+ }
76
95
  }
77
96
 
78
97
  function asNumber(value, fallback) {
@@ -325,6 +344,7 @@ class VoiceBackend {
325
344
  this.proc = null
326
345
  this.ready = null
327
346
  this.startup = null
347
+ this.startupAbort = null
328
348
  this.pending = new Map()
329
349
  this.nextId = 0
330
350
  this.queue = Promise.resolve()
@@ -332,6 +352,39 @@ class VoiceBackend {
332
352
  this.lastError = null
333
353
  }
334
354
 
355
+ noteError(message) {
356
+ this.lastError = message
357
+ }
358
+
359
+ /**
360
+ * Stop one worker deliberately. Unlike an unexpected exit this keeps the
361
+ * existing lastError (a timeout/cancel reason) instead of overwriting it
362
+ * with a generic "worker exited" message.
363
+ */
364
+ stopProcess(proc, { error, preserveLastError = false } = {}) {
365
+ if (this.proc !== proc) return
366
+ this.startupAbort?.()
367
+ this.proc = null
368
+ this.ready = false
369
+ if (!preserveLastError && error) this.lastError = error.message
370
+ const pending = [...this.pending.entries()]
371
+ this.pending.clear()
372
+ for (const [, slot] of pending) {
373
+ clearTimeout(slot.timer)
374
+ slot.reject(error || new Error('语音后端已停止'))
375
+ }
376
+ try {
377
+ proc.kill()
378
+ } catch {}
379
+ }
380
+
381
+ /** Abort the in-flight worker so a cancelled request cannot block the queue. */
382
+ cancel() {
383
+ const proc = this.proc
384
+ if (!proc) return
385
+ this.stopProcess(proc, { error: createAbortError(), preserveLastError: true })
386
+ }
387
+
335
388
  status() {
336
389
  return {
337
390
  state: this.proc && !this.proc.killed ? (this.ready ? 'ready' : 'starting') : 'sleeping',
@@ -347,11 +400,13 @@ class VoiceBackend {
347
400
  if (this.startup) return this.startup
348
401
  this.startup = new Promise((resolvePromise, rejectPromise) => {
349
402
  if (!existsSync(this.options.backendRoot)) {
350
- rejectPromise(new Error(`语音后端目录不存在:${this.options.backendRoot}`))
403
+ this.lastError = `语音后端目录不存在:${this.options.backendRoot}`
404
+ rejectPromise(new Error(this.lastError))
351
405
  return
352
406
  }
353
407
  if (!existsSync(defaultWorker)) {
354
- rejectPromise(new Error(`插件自带 worker 缺失:${defaultWorker}`))
408
+ this.lastError = `插件自带 worker 缺失:${defaultWorker}`
409
+ rejectPromise(new Error(this.lastError))
355
410
  return
356
411
  }
357
412
  const proc = spawn(this.python, [defaultWorker, this.options.backendRoot], {
@@ -374,18 +429,21 @@ class VoiceBackend {
374
429
  const timer = setTimeout(() => {
375
430
  if (settled) return
376
431
  settled = true
432
+ const error = new Error(`语音模型加载超时(>${Math.round(this.options.startupTimeoutMs / 1000)} 秒)`)
377
433
  this.lastError = '语音模型加载超时'
378
- proc.kill()
379
- rejectPromise(new Error(`${this.lastError}(>${Math.round(this.options.startupTimeoutMs / 1000)} 秒)`))
434
+ this.stopProcess(proc, { error, preserveLastError: true })
435
+ rejectPromise(error)
380
436
  }, this.options.startupTimeoutMs)
381
437
 
382
438
  const finishReady = (error) => {
439
+ if (this.proc !== proc) return
383
440
  if (settled) return
384
441
  settled = true
442
+ this.startupAbort = null
385
443
  clearTimeout(timer)
386
444
  if (error) {
387
445
  this.lastError = error.message
388
- proc.kill()
446
+ this.stopProcess(proc, { error, preserveLastError: true })
389
447
  rejectPromise(error)
390
448
  return
391
449
  }
@@ -394,6 +452,14 @@ class VoiceBackend {
394
452
  resolvePromise()
395
453
  }
396
454
 
455
+ this.startupAbort = () => {
456
+ if (settled) return
457
+ settled = true
458
+ this.startupAbort = null
459
+ clearTimeout(timer)
460
+ rejectPromise(createAbortError())
461
+ }
462
+
397
463
  const lines = createInterface({ input: proc.stdout })
398
464
  lines.on('line', (line) => {
399
465
  const text = line.trim()
@@ -435,28 +501,35 @@ class VoiceBackend {
435
501
  })
436
502
 
437
503
  const fail = (error) => {
438
- this.ready = false
439
- this.lastError = error.message
440
- if (!settled) finishReady(error)
441
- for (const slot of this.pending.values()) {
442
- clearTimeout(slot.timer)
443
- slot.reject(error)
504
+ if (this.proc !== proc) return
505
+ if (!settled) {
506
+ finishReady(error)
507
+ return
444
508
  }
445
- this.pending.clear()
446
- this.proc = null
447
- this.startup = null
509
+ this.lastError = error.message
510
+ this.stopProcess(proc, { error, preserveLastError: true })
448
511
  }
449
512
  proc.on('error', fail)
450
513
  proc.on('exit', (code) => {
451
- if (!settled) finishReady(new Error(`语音后端退出(code=${code})`))
452
- else if (this.proc === proc) {
453
- const detail = this.stderr.slice(-6).join('\n')
454
- fail(new Error(`语音后端意外退出(code=${code})${detail ? `\n${detail}` : ''}`))
514
+ if (this.proc !== proc) return
515
+ if (!settled) {
516
+ finishReady(new Error(`语音后端退出(code=${code})`))
517
+ return
455
518
  }
519
+ const detail = this.stderr.slice(-6).join('\n')
520
+ const error = new Error(`语音后端意外退出(code=${code})${detail ? `\n${detail}` : ''}`)
521
+ this.lastError = error.message
522
+ this.stopProcess(proc, { error, preserveLastError: true })
523
+ })
524
+ proc.stdin.on('error', (error) => {
525
+ if (this.proc !== proc) return
526
+ const wrapped = error instanceof Error ? error : new Error(String(error))
527
+ this.lastError = `语音后端写入失败:${wrapped.message}`
528
+ this.stopProcess(proc, { error: wrapped, preserveLastError: true })
456
529
  })
457
- proc.stdin.on('error', () => {})
458
530
  }).finally(() => {
459
531
  this.startup = null
532
+ this.startupAbort = null
460
533
  })
461
534
  return this.startup
462
535
  }
@@ -467,16 +540,36 @@ class VoiceBackend {
467
540
  const proc = this.proc
468
541
  if (!proc || proc.killed || !proc.stdin) throw new Error('语音后端不可用')
469
542
  const id = ++this.nextId
543
+ let settled = false
544
+ let timer
470
545
  const result = new Promise((resolvePromise, rejectPromise) => {
471
- const timer = setTimeout(() => {
546
+ const finish = (callback, value) => {
547
+ if (settled) return
548
+ settled = true
549
+ if (timer) clearTimeout(timer)
472
550
  this.pending.delete(id)
551
+ callback(value)
552
+ }
553
+ timer = setTimeout(() => {
554
+ const error = new Error(`语音分析超时(>${Math.round(this.options.requestTimeoutMs / 1000)} 秒)`)
473
555
  this.lastError = '语音分析超时'
474
- proc.kill()
475
- rejectPromise(new Error(`${this.lastError}(>${Math.round(this.options.requestTimeoutMs / 1000)} 秒)`))
556
+ this.stopProcess(proc, { error, preserveLastError: true })
476
557
  }, this.options.requestTimeoutMs)
477
- this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer })
558
+ this.pending.set(id, {
559
+ resolve: (message) => finish(resolvePromise, message),
560
+ reject: (error) => finish(rejectPromise, error),
561
+ timer,
562
+ })
478
563
  })
479
- proc.stdin.write(`${JSON.stringify({ id, wav: wavPath, language: this.options.language })}\n`)
564
+ try {
565
+ proc.stdin.write(`${JSON.stringify({ id, wav: wavPath, language: this.options.language })}\n`)
566
+ } catch (error) {
567
+ const wrapped = error instanceof Error ? error : new Error(String(error))
568
+ this.lastError = `语音后端写入失败:${wrapped.message}`
569
+ this.stopProcess(proc, { error: wrapped, preserveLastError: true })
570
+ result.catch(() => {})
571
+ throw wrapped
572
+ }
480
573
  return result
481
574
  }
482
575
  const task = this.queue.then(run, run)
@@ -490,6 +583,7 @@ class VoiceBackend {
490
583
 
491
584
  async close() {
492
585
  const proc = this.proc
586
+ this.startupAbort?.()
493
587
  this.proc = null
494
588
  this.ready = false
495
589
  this.startup = null
@@ -515,7 +609,8 @@ function speechProviderReady(provider) {
515
609
  return phase === undefined || ['ready', 'standby', 'waking'].includes(phase)
516
610
  }
517
611
 
518
- async function transcribeWithSpeech(audio, options, service) {
612
+ async function transcribeWithSpeech(audio, options, service, signal) {
613
+ if (signal?.aborted) throw createAbortError()
519
614
  if (!service) {
520
615
  throw new Error('DSH 自带语音输入未启用:请先在 插件 → Voice Input 中启用并准备本地模型,或配置 VoiceSense backendRoot')
521
616
  }
@@ -528,13 +623,32 @@ async function transcribeWithSpeech(audio, options, service) {
528
623
  const spec = service.resolve({
529
624
  audio,
530
625
  language: options.language || 'auto',
626
+ providerId: selected.id,
531
627
  })
532
- const result = await service.transcribe(spec, AbortSignal.timeout(options.requestTimeoutMs))
628
+ const timeout = AbortSignal.timeout(options.requestTimeoutMs)
629
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
630
+ const result = await service.transcribe(spec, combined)
533
631
  const text = String(result?.text || '').trim()
534
632
  if (!text) throw new Error('DSH 语音输入没有返回文字')
535
633
  return text
536
634
  }
537
635
 
636
+ function speechStatus(service) {
637
+ if (!service) return { ready: false, provider: null, phase: null }
638
+ try {
639
+ const snapshot = service.snapshot?.() ?? { providers: [], selection: {} }
640
+ const selected = snapshot.providers?.find((provider) => provider.id === snapshot.selection?.providerId)
641
+ ?? snapshot.providers?.[0]
642
+ return {
643
+ ready: Boolean(selected && speechProviderReady(selected)),
644
+ provider: selected?.id ?? null,
645
+ phase: selected?.preparation?.phase ?? null,
646
+ }
647
+ } catch {
648
+ return { ready: false, provider: null, phase: null }
649
+ }
650
+ }
651
+
538
652
  function createAnalyzeHandler(options, backend, getSpeechToText) {
539
653
  return async (req, res) => {
540
654
  if (!isLocalRequest(req)) {
@@ -546,21 +660,37 @@ function createAnalyzeHandler(options, backend, getSpeechToText) {
546
660
  return
547
661
  }
548
662
  let tempDir
663
+ let controller
664
+ const onClose = () => {
665
+ if (res.writableEnded) return
666
+ controller?.abort()
667
+ backend.cancel()
668
+ }
549
669
  try {
550
- const body = await readJsonBody(req, options.maxAudioBytes + 64 * 1024)
670
+ controller = new AbortController()
671
+ req.once?.('aborted', onClose)
672
+ res.once?.('close', onClose)
673
+ const bodyLimit = Math.ceil(options.maxAudioBytes / 3) * 4 + 64 * 1024
674
+ const body = await readJsonBody(req, bodyLimit)
675
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
676
+ throw new HttpError(400, '请求体必须是 JSON 对象')
677
+ }
551
678
  const base64 = String(body.audioBase64 || '').replace(/^data:[^,]+,/, '')
552
- if (!base64) throw new Error('缺少录音数据')
679
+ if (!base64) throw new HttpError(400, '缺少录音数据')
553
680
  const buffer = Buffer.from(base64, 'base64')
554
- if (buffer.length < 400) throw new Error('录音太短,没有听清')
555
- if (buffer.length > options.maxAudioBytes) throw new Error('录音太长,请控制在 30 秒以内')
681
+ if (buffer.length < 400) throw new HttpError(400, '录音太短,没有听清')
682
+ if (buffer.length > options.maxAudioBytes) throw new HttpError(413, '录音太大,请缩短录音后重试')
556
683
  if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') {
557
- throw new Error('录音格式不是 WAV')
684
+ throw new HttpError(400, '录音格式不是 WAV')
558
685
  }
559
686
  tempDir = await mkdtemp(join(tmpdir(), 'dsh-dafeiyu-emotion-'))
560
687
  const wavPath = join(tempDir, 'voice.wav')
561
688
  await writeFile(wavPath, buffer)
562
689
  let analysis
563
690
  let localError
691
+ if (options.backendRoot && !existsSync(options.backendRoot)) {
692
+ localError = `语音后端目录不存在:${options.backendRoot}`
693
+ }
564
694
  if (options.backendRoot && existsSync(options.backendRoot)) {
565
695
  try {
566
696
  analysis = await backend.analyze(wavPath.replaceAll('\\', '/'))
@@ -568,9 +698,10 @@ function createAnalyzeHandler(options, backend, getSpeechToText) {
568
698
  localError = error instanceof Error ? error.message : String(error)
569
699
  }
570
700
  }
701
+ if (controller.signal.aborted) return
571
702
  if (!analysis) {
572
703
  try {
573
- const transcript = await transcribeWithSpeech(buffer, options, getSpeechToText())
704
+ const transcript = await transcribeWithSpeech(buffer, options, getSpeechToText(), controller.signal)
574
705
  analysis = decorateAnalysis(buildFallbackAnalysis(transcript, body.prosody || {}))
575
706
  } catch (speechError) {
576
707
  const parts = []
@@ -579,13 +710,18 @@ function createAnalyzeHandler(options, backend, getSpeechToText) {
579
710
  throw new Error(parts.join('\n'))
580
711
  }
581
712
  }
713
+ if (controller.signal.aborted || res.writableEnded) return
582
714
  jsonResponse(res, 200, analysis)
583
715
  } catch (error) {
584
- jsonResponse(res, 500, {
716
+ if (controller?.signal.aborted || res.writableEnded) return
717
+ const status = error instanceof HttpError ? error.status : 500
718
+ jsonResponse(res, status, {
585
719
  error: error instanceof Error ? error.message : String(error),
586
- detail: backend.stderr?.slice(-8).join('\n') || undefined,
720
+ detail: status >= 500 ? backend.stderr?.slice(-8).join('\n') || undefined : undefined,
587
721
  })
588
722
  } finally {
723
+ req.off?.('aborted', onClose)
724
+ res.off?.('close', onClose)
589
725
  if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => {})
590
726
  }
591
727
  }
@@ -596,6 +732,9 @@ export function apply(ctx, rawConfig = {}) {
596
732
  if (!options.enabled) return
597
733
  const logger = ctx.logger ?? console
598
734
  const backend = new VoiceBackend(options, logger)
735
+ if (options.backendRoot && !existsSync(options.backendRoot)) {
736
+ backend.noteError(`语音后端目录不存在:${options.backendRoot}`)
737
+ }
599
738
  let speechToText
600
739
  ctx.inject(['speechToText'], (speechCtx) => {
601
740
  speechToText = speechCtx.speechToText
@@ -615,13 +754,27 @@ export function apply(ctx, rawConfig = {}) {
615
754
  jsonResponse(res, 403, { error: '只允许本机访问' })
616
755
  return
617
756
  }
757
+ const speech = speechStatus(speechToText)
758
+ const backendConfigured = Boolean(options.backendRoot)
759
+ const backendAvailable = backendConfigured && existsSync(options.backendRoot)
760
+ if (backendAvailable && backend.lastError?.startsWith('语音后端目录不存在:')) {
761
+ backend.noteError(null)
762
+ }
618
763
  jsonResponse(res, 200, {
619
764
  ok: true,
620
765
  enabled: options.enabled,
621
766
  language: options.language,
622
- engine: 'SenseVoice + emotion2vec',
623
- mode: options.backendRoot ? 'enhanced-local-backend' : 'dsh-speech-prosody',
624
- dshSpeechReady: Boolean(speechToText),
767
+ engine: backendAvailable ? 'SenseVoice + emotion2vec' : 'DSH Speech + prosody',
768
+ mode: !backendConfigured
769
+ ? 'dsh-speech-prosody'
770
+ : backendAvailable
771
+ ? 'enhanced-local-backend'
772
+ : 'enhanced-backend-missing',
773
+ dshSpeechReady: speech.ready,
774
+ speechProvider: speech.provider,
775
+ speechPhase: speech.phase,
776
+ backendConfigured,
777
+ backendAvailable,
625
778
  ...backend.status(),
626
779
  })
627
780
  },