dsh-dafeiyu-emotion 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/backend/voice_worker.py +6 -3
- package/docs/MARKETPLACE.md +1 -1
- package/lib/client.js +184 -40
- package/package.json +7 -2
- package/scripts/test-analyze-handler.mjs +209 -0
- package/scripts/test-delivery-policy.mjs +47 -0
- package/scripts/test-shortcut-registration.mjs +3 -0
- package/src/index.js +192 -39
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Fixed a duplicate-send race between auto-send and the manual send button.
|
|
6
|
+
- Cancelled stale deliveries when the session changes or the component unmounts.
|
|
7
|
+
- Stopped overwriting a busy composer when `insertText` is refused; send failures now keep the analysis for retry.
|
|
8
|
+
- Added a cancel action and timeout while waiting for microphone permission.
|
|
9
|
+
- Made prosody extraction yield to the UI thread instead of blocking on long recordings.
|
|
10
|
+
- Corrected the base64 request-body limit and mapped validation failures to 400/413 instead of 500.
|
|
11
|
+
- Pinned speech-provider readiness checks to the provider actually used for transcription.
|
|
12
|
+
- Reported missing enhanced backends in health and preserved the real worker failure reason.
|
|
13
|
+
- Added regression tests for delivery gating, shortcut availability and the analyze handler.
|
|
14
|
+
|
|
3
15
|
## 0.2.1
|
|
4
16
|
|
|
5
17
|
- Added a native DSH voice shortcut, configurable from DSH keyboard settings.
|
package/README.md
CHANGED
|
@@ -90,6 +90,8 @@ dsh-dafeiyu-emotion
|
|
|
90
90
|
|
|
91
91
|
`dsh-dafeiyu-emotion` 已发布到 npm。1024Store 会自动刷新一键安装入口;如果客户端尚未缓存到新版本,可以直接使用下面的 npm 命令。
|
|
92
92
|
|
|
93
|
+
> `0.2.2` 目前通过 GitHub Release 提供;npm 上的最新版仍是 `0.2.1`,待 npm 发布完成后一键安装会自动升级。
|
|
94
|
+
|
|
93
95
|
### 通过 npm
|
|
94
96
|
|
|
95
97
|
```bash
|
|
@@ -107,9 +109,9 @@ dsh plugin --profile desktop add github:LiDu-china/dsh-dafeiyu-emotion
|
|
|
107
109
|
### 通过 Release tarball
|
|
108
110
|
|
|
109
111
|
```bash
|
|
110
|
-
curl -L -o dsh-dafeiyu-emotion-0.2.
|
|
111
|
-
https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.
|
|
112
|
-
dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.
|
|
112
|
+
curl -L -o dsh-dafeiyu-emotion-0.2.2.tgz \
|
|
113
|
+
https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.2/dsh-dafeiyu-emotion-0.2.2.tgz
|
|
114
|
+
dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.2.tgz"
|
|
113
115
|
```
|
|
114
116
|
|
|
115
117
|
> DSH Desktop 的 profile 由 Electron 应用管理;命令行安装请使用桌面应用自带的 `dsh` 命令。
|
package/backend/voice_worker.py
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
186
|
-
|
|
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
|
package/docs/MARKETPLACE.md
CHANGED
|
@@ -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
|
-
|
|
71
|
+
The `0.2.2` build is attached to the GitHub Release. npm publication is pending, so npm and the 1024Store one-click entry currently serve `0.2.1`; they will refresh after the maintainer publishes `0.2.2`. 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
|
|
41
|
-
return
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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:
|
|
682
|
-
'aria-label':
|
|
683
|
-
onClick:
|
|
684
|
-
},
|
|
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
|
-
|
|
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: () =>
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
|
87
|
+
if (bytes > limit) throw new HttpError(413, '请求体过大,请缩短录音后重试')
|
|
73
88
|
chunks.push(chunk)
|
|
74
89
|
}
|
|
75
|
-
|
|
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
|
-
|
|
403
|
+
this.lastError = `语音后端目录不存在:${this.options.backendRoot}`
|
|
404
|
+
rejectPromise(new Error(this.lastError))
|
|
351
405
|
return
|
|
352
406
|
}
|
|
353
407
|
if (!existsSync(defaultWorker)) {
|
|
354
|
-
|
|
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
|
-
|
|
379
|
-
rejectPromise(
|
|
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
|
-
|
|
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.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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.
|
|
446
|
-
this.proc
|
|
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 (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
558
|
+
this.pending.set(id, {
|
|
559
|
+
resolve: (message) => finish(resolvePromise, message),
|
|
560
|
+
reject: (error) => finish(rejectPromise, error),
|
|
561
|
+
timer,
|
|
562
|
+
})
|
|
478
563
|
})
|
|
479
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
679
|
+
if (!base64) throw new HttpError(400, '缺少录音数据')
|
|
553
680
|
const buffer = Buffer.from(base64, 'base64')
|
|
554
|
-
if (buffer.length < 400) throw new
|
|
555
|
-
if (buffer.length > options.maxAudioBytes) throw new
|
|
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
|
|
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
|
-
|
|
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:
|
|
624
|
-
|
|
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
|
},
|