@goodandready/dsh-voice 0.8.23 → 0.8.25
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/lib/client-src/10-locale.js +3 -0
- package/lib/client-src/20-css.js +8 -5
- package/lib/client-src/30-core.js +3 -1
- package/lib/client-src/40-recording.js +9 -1
- package/lib/client-src/41-buttons.js +2 -1
- package/lib/client-src/60-composer.js +55 -32
- package/lib/client-src/72-voice-section.js +92 -15
- package/lib/client.js +173 -56
- package/lib/index.js +8 -0
- package/lib/providers.js +3 -0
- package/package.json +1 -1
|
@@ -153,6 +153,9 @@
|
|
|
153
153
|
'hardwareDesc': 'Microphone device, local whisper.cpp and SenseVoice runtime settings.',
|
|
154
154
|
'uiError': 'Voice UI Error:',
|
|
155
155
|
'retry': 'Retry',
|
|
156
|
+
'testMic': 'Test Mic',
|
|
157
|
+
'stopTest': 'Stop Test',
|
|
158
|
+
'micLevel': 'Mic level',
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
// Strings are also needed outside components — in recording handlers and
|
package/lib/client-src/20-css.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// ------------------------------------------------------------------ css
|
|
2
2
|
const CSS =
|
|
3
|
-
'.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box}' +
|
|
3
|
+
'.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
|
|
4
4
|
'.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
|
|
5
5
|
'.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
6
|
-
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box}' +
|
|
7
|
-
'.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary)}' +
|
|
6
|
+
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
|
|
7
|
+
'.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
|
|
8
8
|
'.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
|
|
9
|
-
'.dvo-wave{flex:1;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
9
|
+
'.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
10
10
|
'.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
11
11
|
'.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
|
|
12
12
|
'.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
'.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}' +
|
|
28
28
|
'.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}' +
|
|
29
29
|
'.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
|
|
30
|
-
'.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
|
|
30
|
+
'.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}' +
|
|
31
|
+
'.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}' +
|
|
32
|
+
'.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
|
|
33
|
+
'.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}'
|
|
31
34
|
const cssId = 'dsh-voice/client.module.css'
|
|
32
35
|
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
|
|
33
36
|
const tag = document.createElement('style')
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
function applyVoiceCommands(text) {
|
|
127
127
|
let s = text
|
|
128
128
|
for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
|
|
129
|
-
return s.replace(/[ \t]+/g, ' ').trim()
|
|
129
|
+
return s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// Insert history for undo (#29-9). Browser-only storage.
|
|
@@ -282,3 +282,5 @@
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
// Fetch the mode chain from the host once. Only needed to decide whether
|
|
285
|
+
|
|
286
|
+
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory }
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
function teardown(rec) {
|
|
24
24
|
if (!rec) return
|
|
25
25
|
try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
|
|
26
|
+
if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
|
|
26
27
|
if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -60,7 +61,9 @@
|
|
|
60
61
|
: (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
61
62
|
if (AC) {
|
|
62
63
|
rec.audioCtx = new AC()
|
|
64
|
+
if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
|
|
63
65
|
const src = rec.audioCtx.createMediaStreamSource(stream)
|
|
66
|
+
rec.srcNode = src
|
|
64
67
|
rec.analyser = rec.audioCtx.createAnalyser()
|
|
65
68
|
rec.analyser.fftSize = 128
|
|
66
69
|
src.connect(rec.analyser)
|
|
@@ -81,6 +84,8 @@
|
|
|
81
84
|
|
|
82
85
|
// Cut the current phrase: stop the recorder, send the finished file and
|
|
83
86
|
// immediately start a new recording with the same recorder.
|
|
87
|
+
let dictationQueue = Promise.resolve()
|
|
88
|
+
|
|
84
89
|
function cutPhrase() {
|
|
85
90
|
const rec = voice.rec
|
|
86
91
|
if (!rec || rec.cutting || rec.closing) return
|
|
@@ -97,7 +102,8 @@
|
|
|
97
102
|
try { rec.recorder.start() } catch (e) { /* stream already closed */ }
|
|
98
103
|
}
|
|
99
104
|
rec.cutting = false
|
|
100
|
-
if (blob.size <
|
|
105
|
+
if (blob.size < 600) return // too short — not speech
|
|
106
|
+
dictationQueue = dictationQueue.then(async () => {
|
|
101
107
|
try {
|
|
102
108
|
const out = await sendAudio(blob, rec.mime, 'dictation')
|
|
103
109
|
const text = out && out.text ? out.text : ''
|
|
@@ -113,6 +119,7 @@
|
|
|
113
119
|
} catch (e) {
|
|
114
120
|
voice.set({ error: String(e && e.message ? e.message : e) })
|
|
115
121
|
}
|
|
122
|
+
})
|
|
116
123
|
})
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -171,6 +178,7 @@
|
|
|
171
178
|
// Announce before opening the mic: the sooner playback mutes, the less
|
|
172
179
|
// of it ends up in the recording.
|
|
173
180
|
announceVoice('start')
|
|
181
|
+
dictationQueue = Promise.resolve()
|
|
174
182
|
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
|
|
175
183
|
modeChain(mode).then((info) => {
|
|
176
184
|
// Browser leg only when it is explicitly first in the chain.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
voice.input = props.input
|
|
6
6
|
if (v.phase !== 'idle' && v.phase !== 'error') return null
|
|
7
7
|
const err = v.phase === 'error'
|
|
8
|
+
const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
|
|
8
9
|
return React.createElement(React.Fragment, null,
|
|
9
10
|
React.createElement('button', {
|
|
10
11
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
@@ -12,7 +13,7 @@
|
|
|
12
13
|
}, micIcon()),
|
|
13
14
|
React.createElement('button', {
|
|
14
15
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
15
|
-
title: err ? v.error : t('messageBtn'),
|
|
16
|
+
title: err ? v.error : (t('messageBtn') + hk),
|
|
16
17
|
// Hold: record while pressed; leaving the control cancels.
|
|
17
18
|
onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
|
|
18
19
|
onPointerUp: () => endHold(false),
|
|
@@ -50,45 +50,67 @@
|
|
|
50
50
|
return () => dispose()
|
|
51
51
|
}, [v.phase])
|
|
52
52
|
|
|
53
|
-
// Waveform / visualizer animation
|
|
53
|
+
// Waveform / visualizer animation: requestAnimationFrame with HiDPI scaling
|
|
54
54
|
React.useEffect(() => {
|
|
55
55
|
if (v.phase !== 'recording') return
|
|
56
56
|
let frame = 0
|
|
57
|
-
|
|
57
|
+
let animId = null
|
|
58
|
+
let running = true
|
|
59
|
+
|
|
60
|
+
const render = () => {
|
|
61
|
+
if (!running) return
|
|
58
62
|
const canvas = canvasRef.current
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
|
|
68
|
-
frame++
|
|
63
|
+
if (canvas) {
|
|
64
|
+
const g = canvas.getContext('2d')
|
|
65
|
+
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
|
|
66
|
+
const rect = canvas.getBoundingClientRect()
|
|
67
|
+
const cssW = rect.width || 720
|
|
68
|
+
const cssH = rect.height || 40
|
|
69
|
+
const targetW = Math.max(1, Math.round(cssW * dpr))
|
|
70
|
+
const targetH = Math.max(1, Math.round(cssH * dpr))
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
g.lineWidth = 1
|
|
75
|
-
g.moveTo(10, h / 2)
|
|
76
|
-
g.lineTo(w - 10, h / 2)
|
|
77
|
-
g.stroke()
|
|
78
|
-
g.globalAlpha = 1
|
|
79
|
-
return
|
|
80
|
-
}
|
|
72
|
+
if (canvas.width !== targetW || canvas.height !== targetH) {
|
|
73
|
+
canvas.width = targetW
|
|
74
|
+
canvas.height = targetH
|
|
75
|
+
}
|
|
81
76
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
g.save()
|
|
78
|
+
g.scale(dpr, dpr)
|
|
79
|
+
g.clearRect(0, 0, cssW, cssH)
|
|
80
|
+
|
|
81
|
+
if (!voice.waveColor) {
|
|
82
|
+
try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
|
|
83
|
+
}
|
|
84
|
+
const levels = voice.levels
|
|
85
|
+
const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
|
|
86
|
+
frame++
|
|
87
|
+
|
|
88
|
+
if (style === 'off') {
|
|
89
|
+
g.beginPath()
|
|
90
|
+
g.globalAlpha = 0.25
|
|
91
|
+
g.strokeStyle = voice.waveColor
|
|
92
|
+
g.lineWidth = 1
|
|
93
|
+
g.moveTo(10, cssH / 2)
|
|
94
|
+
g.lineTo(cssW - 10, cssH / 2)
|
|
95
|
+
g.stroke()
|
|
96
|
+
g.globalAlpha = 1
|
|
97
|
+
} else if (style === 'dynamic-orb') {
|
|
98
|
+
drawDynamicOrb(g, cssW, cssH, levels, voice.waveColor, frame)
|
|
99
|
+
} else if (style === 'bars') {
|
|
100
|
+
drawClassicBars(g, cssW, cssH, levels, voice.waveColor)
|
|
101
|
+
} else {
|
|
102
|
+
drawLiquidWave(g, cssW, cssH, levels, voice.waveColor, frame)
|
|
103
|
+
}
|
|
104
|
+
g.restore()
|
|
89
105
|
}
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
animId = requestAnimationFrame(render)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
animId = requestAnimationFrame(render)
|
|
110
|
+
return () => {
|
|
111
|
+
running = false
|
|
112
|
+
if (animId) cancelAnimationFrame(animId)
|
|
113
|
+
}
|
|
92
114
|
}, [v.phase])
|
|
93
115
|
|
|
94
116
|
// Message-mode cancel window.
|
|
@@ -241,6 +263,7 @@
|
|
|
241
263
|
dispose()
|
|
242
264
|
dispose = () => {}
|
|
243
265
|
const key = data && data.hotkey
|
|
266
|
+
voice.hotkey = key || ''
|
|
244
267
|
if (key) dispose = installHotkey(ctx, key, 'message')
|
|
245
268
|
// The composer needs fresh settings without opening the card.
|
|
246
269
|
Object.assign(voice.settings, {
|
|
@@ -74,23 +74,84 @@
|
|
|
74
74
|
voice.settings = Object.assign({}, voice.settings, {
|
|
75
75
|
vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
|
|
76
76
|
autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
|
|
77
|
-
beep: !!(
|
|
78
|
-
micDeviceId: String((
|
|
79
|
-
historyLimit: Number(
|
|
80
|
-
voiceCommands: !!(
|
|
77
|
+
beep: !!(value && value.beep),
|
|
78
|
+
micDeviceId: String((value && value.micDeviceId) || ''),
|
|
79
|
+
historyLimit: Number(value && value.historyLimit) || 20,
|
|
80
|
+
voiceCommands: !!(value && value.voiceCommands),
|
|
81
81
|
sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
|
|
82
82
|
stream: !!(value && value.dictation && value.dictation.stream),
|
|
83
83
|
streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
|
|
84
84
|
vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
|
|
85
|
-
wakeWord: String((
|
|
86
|
-
bargeIn: !!(
|
|
85
|
+
wakeWord: String((value && value.wakeWord) || ''),
|
|
86
|
+
bargeIn: !!(value && value.bargeIn),
|
|
87
87
|
polishSend: !!(value && value.message && value.message.polishSend),
|
|
88
88
|
sessionCommands: !!(value && value.message && value.message.sessionCommands),
|
|
89
|
-
polishBaseUrl: String((
|
|
89
|
+
polishBaseUrl: String((value && value.polishBaseUrl) || ''),
|
|
90
90
|
noiseSuppression: value.noiseSuppression !== false,
|
|
91
91
|
contextGlossary: value.contextGlossary !== false,
|
|
92
|
+
visualizerStyle: (value && value.visualizerStyle) || 'liquid-wave',
|
|
92
93
|
})
|
|
93
|
-
}, [ready, value
|
|
94
|
+
}, [ready, value])
|
|
95
|
+
|
|
96
|
+
// Interactive microphone tester
|
|
97
|
+
const [testingMic, setTestingMic] = React.useState(false)
|
|
98
|
+
const [testLevel, setTestLevel] = React.useState(0)
|
|
99
|
+
const testRef = React.useRef(null)
|
|
100
|
+
|
|
101
|
+
const toggleTestMic = async () => {
|
|
102
|
+
if (testingMic) {
|
|
103
|
+
if (testRef.current) {
|
|
104
|
+
testRef.current.active = false
|
|
105
|
+
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
|
|
106
|
+
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
|
|
107
|
+
testRef.current = null
|
|
108
|
+
}
|
|
109
|
+
setTestingMic(false)
|
|
110
|
+
setTestLevel(0)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
|
|
115
|
+
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
116
|
+
if (devId) audio.deviceId = { exact: devId }
|
|
117
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio })
|
|
118
|
+
const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
119
|
+
if (!AC) return
|
|
120
|
+
const audioCtx = new AC()
|
|
121
|
+
if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
|
|
122
|
+
const src = audioCtx.createMediaStreamSource(stream)
|
|
123
|
+
const analyser = audioCtx.createAnalyser()
|
|
124
|
+
analyser.fftSize = 128
|
|
125
|
+
src.connect(analyser)
|
|
126
|
+
testRef.current = { stream, audioCtx, analyser, active: true }
|
|
127
|
+
setTestingMic(true)
|
|
128
|
+
|
|
129
|
+
const check = () => {
|
|
130
|
+
if (!testRef.current || !testRef.current.active) return
|
|
131
|
+
const data = new Uint8Array(analyser.frequencyBinCount)
|
|
132
|
+
analyser.getByteFrequencyData(data)
|
|
133
|
+
let sum = 0
|
|
134
|
+
for (let i = 0; i < data.length; i++) sum += data[i]
|
|
135
|
+
const lvl = Math.min(100, Math.round((sum / data.length / 255) * 220))
|
|
136
|
+
setTestLevel(lvl)
|
|
137
|
+
requestAnimationFrame(check)
|
|
138
|
+
}
|
|
139
|
+
requestAnimationFrame(check)
|
|
140
|
+
} catch (e) {
|
|
141
|
+
setErr(String(e && e.message ? e.message : e))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
return () => {
|
|
147
|
+
if (testRef.current) {
|
|
148
|
+
testRef.current.active = false
|
|
149
|
+
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
|
|
150
|
+
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
|
|
151
|
+
testRef.current = null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}, [])
|
|
94
155
|
|
|
95
156
|
const [statusData, setStatusData] = React.useState(null)
|
|
96
157
|
const [statusLatency, setStatusLatency] = React.useState(null)
|
|
@@ -198,14 +259,30 @@
|
|
|
198
259
|
|
|
199
260
|
const micField = () => React.createElement('div', { className: 'cb-field' },
|
|
200
261
|
React.createElement('label', null, t('mic')),
|
|
201
|
-
React.createElement('
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
262
|
+
React.createElement('div', { className: 'cb-row' },
|
|
263
|
+
React.createElement('select', {
|
|
264
|
+
value: String((draft && draft.micDeviceId) ?? (value && value.micDeviceId) ?? ''), disabled: !writable || testingMic,
|
|
265
|
+
onChange: (e) => setTop('micDeviceId', e.target.value),
|
|
266
|
+
style: { flex: 1 },
|
|
267
|
+
},
|
|
268
|
+
React.createElement('option', { value: '' }, t('micDefault')),
|
|
269
|
+
devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
|
|
270
|
+
d.label || d.deviceId.slice(0, 12))),
|
|
271
|
+
),
|
|
272
|
+
React.createElement('button', {
|
|
273
|
+
type: 'button',
|
|
274
|
+
className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
|
|
275
|
+
onClick: toggleTestMic,
|
|
276
|
+
style: { flex: 'none' },
|
|
277
|
+
}, testingMic ? t('stopTest') : t('testMic')),
|
|
208
278
|
),
|
|
279
|
+
testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
|
|
280
|
+
React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
|
|
281
|
+
React.createElement('div', { className: 'dvo-meter-bar' },
|
|
282
|
+
React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
|
|
283
|
+
),
|
|
284
|
+
React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
|
|
285
|
+
) : null,
|
|
209
286
|
)
|
|
210
287
|
|
|
211
288
|
const langField = (mode) => React.createElement('div', { className: 'cb-field' },
|
package/lib/client.js
CHANGED
|
@@ -177,6 +177,9 @@ window.__ModuleLoader__.load({
|
|
|
177
177
|
'hardwareDesc': 'Microphone device, local whisper.cpp and SenseVoice runtime settings.',
|
|
178
178
|
'uiError': 'Voice UI Error:',
|
|
179
179
|
'retry': 'Retry',
|
|
180
|
+
'testMic': 'Test Mic',
|
|
181
|
+
'stopTest': 'Stop Test',
|
|
182
|
+
'micLevel': 'Mic level',
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -186,13 +189,13 @@ window.__ModuleLoader__.load({
|
|
|
186
189
|
|
|
187
190
|
// ------------------------------------------------------------------ css
|
|
188
191
|
const CSS =
|
|
189
|
-
'.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box}' +
|
|
192
|
+
'.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
|
|
190
193
|
'.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
|
|
191
194
|
'.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
192
|
-
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box}' +
|
|
193
|
-
'.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary)}' +
|
|
195
|
+
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
|
|
196
|
+
'.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
|
|
194
197
|
'.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
|
|
195
|
-
'.dvo-wave{flex:1;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
198
|
+
'.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
196
199
|
'.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
197
200
|
'.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
|
|
198
201
|
'.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
|
|
@@ -213,7 +216,10 @@ window.__ModuleLoader__.load({
|
|
|
213
216
|
'.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}' +
|
|
214
217
|
'.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}' +
|
|
215
218
|
'.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
|
|
216
|
-
'.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
|
|
219
|
+
'.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}' +
|
|
220
|
+
'.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}' +
|
|
221
|
+
'.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
|
|
222
|
+
'.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}'
|
|
217
223
|
const cssId = 'dsh-voice/client.module.css'
|
|
218
224
|
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
|
|
219
225
|
const tag = document.createElement('style')
|
|
@@ -351,7 +357,7 @@ window.__ModuleLoader__.load({
|
|
|
351
357
|
function applyVoiceCommands(text) {
|
|
352
358
|
let s = text
|
|
353
359
|
for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
|
|
354
|
-
return s.replace(/[ \t]+/g, ' ').trim()
|
|
360
|
+
return s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
|
|
355
361
|
}
|
|
356
362
|
|
|
357
363
|
// Insert history for undo (#29-9). Browser-only storage.
|
|
@@ -507,6 +513,8 @@ window.__ModuleLoader__.load({
|
|
|
507
513
|
}
|
|
508
514
|
|
|
509
515
|
// Fetch the mode chain from the host once. Only needed to decide whether
|
|
516
|
+
|
|
517
|
+
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory }
|
|
510
518
|
// to use browser recognition or record a file.
|
|
511
519
|
let chainsPromise = null
|
|
512
520
|
if (typeof window !== 'undefined') {
|
|
@@ -532,6 +540,7 @@ window.__ModuleLoader__.load({
|
|
|
532
540
|
function teardown(rec) {
|
|
533
541
|
if (!rec) return
|
|
534
542
|
try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
|
|
543
|
+
if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
|
|
535
544
|
if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
|
|
536
545
|
}
|
|
537
546
|
|
|
@@ -569,7 +578,9 @@ window.__ModuleLoader__.load({
|
|
|
569
578
|
: (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
570
579
|
if (AC) {
|
|
571
580
|
rec.audioCtx = new AC()
|
|
581
|
+
if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
|
|
572
582
|
const src = rec.audioCtx.createMediaStreamSource(stream)
|
|
583
|
+
rec.srcNode = src
|
|
573
584
|
rec.analyser = rec.audioCtx.createAnalyser()
|
|
574
585
|
rec.analyser.fftSize = 128
|
|
575
586
|
src.connect(rec.analyser)
|
|
@@ -590,6 +601,8 @@ window.__ModuleLoader__.load({
|
|
|
590
601
|
|
|
591
602
|
// Cut the current phrase: stop the recorder, send the finished file and
|
|
592
603
|
// immediately start a new recording with the same recorder.
|
|
604
|
+
let dictationQueue = Promise.resolve()
|
|
605
|
+
|
|
593
606
|
function cutPhrase() {
|
|
594
607
|
const rec = voice.rec
|
|
595
608
|
if (!rec || rec.cutting || rec.closing) return
|
|
@@ -606,7 +619,8 @@ window.__ModuleLoader__.load({
|
|
|
606
619
|
try { rec.recorder.start() } catch (e) { /* stream already closed */ }
|
|
607
620
|
}
|
|
608
621
|
rec.cutting = false
|
|
609
|
-
if (blob.size <
|
|
622
|
+
if (blob.size < 600) return // too short — not speech
|
|
623
|
+
dictationQueue = dictationQueue.then(async () => {
|
|
610
624
|
try {
|
|
611
625
|
const out = await sendAudio(blob, rec.mime, 'dictation')
|
|
612
626
|
const text = out && out.text ? out.text : ''
|
|
@@ -622,6 +636,7 @@ window.__ModuleLoader__.load({
|
|
|
622
636
|
} catch (e) {
|
|
623
637
|
voice.set({ error: String(e && e.message ? e.message : e) })
|
|
624
638
|
}
|
|
639
|
+
})
|
|
625
640
|
})
|
|
626
641
|
}
|
|
627
642
|
|
|
@@ -680,6 +695,7 @@ window.__ModuleLoader__.load({
|
|
|
680
695
|
// Announce before opening the mic: the sooner playback mutes, the less
|
|
681
696
|
// of it ends up in the recording.
|
|
682
697
|
announceVoice('start')
|
|
698
|
+
dictationQueue = Promise.resolve()
|
|
683
699
|
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
|
|
684
700
|
modeChain(mode).then((info) => {
|
|
685
701
|
// Browser leg only when it is explicitly first in the chain.
|
|
@@ -901,6 +917,7 @@ window.__ModuleLoader__.load({
|
|
|
901
917
|
voice.input = props.input
|
|
902
918
|
if (v.phase !== 'idle' && v.phase !== 'error') return null
|
|
903
919
|
const err = v.phase === 'error'
|
|
920
|
+
const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
|
|
904
921
|
return React.createElement(React.Fragment, null,
|
|
905
922
|
React.createElement('button', {
|
|
906
923
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
@@ -908,7 +925,7 @@ window.__ModuleLoader__.load({
|
|
|
908
925
|
}, micIcon()),
|
|
909
926
|
React.createElement('button', {
|
|
910
927
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
911
|
-
title: err ? v.error : t('messageBtn'),
|
|
928
|
+
title: err ? v.error : (t('messageBtn') + hk),
|
|
912
929
|
// Hold: record while pressed; leaving the control cancels.
|
|
913
930
|
onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
|
|
914
931
|
onPointerUp: () => endHold(false),
|
|
@@ -1097,45 +1114,67 @@ window.__ModuleLoader__.load({
|
|
|
1097
1114
|
return () => dispose()
|
|
1098
1115
|
}, [v.phase])
|
|
1099
1116
|
|
|
1100
|
-
// Waveform / visualizer animation
|
|
1117
|
+
// Waveform / visualizer animation: requestAnimationFrame with HiDPI scaling
|
|
1101
1118
|
React.useEffect(() => {
|
|
1102
1119
|
if (v.phase !== 'recording') return
|
|
1103
1120
|
let frame = 0
|
|
1104
|
-
|
|
1121
|
+
let animId = null
|
|
1122
|
+
let running = true
|
|
1123
|
+
|
|
1124
|
+
const render = () => {
|
|
1125
|
+
if (!running) return
|
|
1105
1126
|
const canvas = canvasRef.current
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
g.globalAlpha = 0.25
|
|
1120
|
-
g.strokeStyle = voice.waveColor
|
|
1121
|
-
g.lineWidth = 1
|
|
1122
|
-
g.moveTo(10, h / 2)
|
|
1123
|
-
g.lineTo(w - 10, h / 2)
|
|
1124
|
-
g.stroke()
|
|
1125
|
-
g.globalAlpha = 1
|
|
1126
|
-
return
|
|
1127
|
-
}
|
|
1127
|
+
if (canvas) {
|
|
1128
|
+
const g = canvas.getContext('2d')
|
|
1129
|
+
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
|
|
1130
|
+
const rect = canvas.getBoundingClientRect()
|
|
1131
|
+
const cssW = rect.width || 720
|
|
1132
|
+
const cssH = rect.height || 40
|
|
1133
|
+
const targetW = Math.max(1, Math.round(cssW * dpr))
|
|
1134
|
+
const targetH = Math.max(1, Math.round(cssH * dpr))
|
|
1135
|
+
|
|
1136
|
+
if (canvas.width !== targetW || canvas.height !== targetH) {
|
|
1137
|
+
canvas.width = targetW
|
|
1138
|
+
canvas.height = targetH
|
|
1139
|
+
}
|
|
1128
1140
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1141
|
+
g.save()
|
|
1142
|
+
g.scale(dpr, dpr)
|
|
1143
|
+
g.clearRect(0, 0, cssW, cssH)
|
|
1144
|
+
|
|
1145
|
+
if (!voice.waveColor) {
|
|
1146
|
+
try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
|
|
1147
|
+
}
|
|
1148
|
+
const levels = voice.levels
|
|
1149
|
+
const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
|
|
1150
|
+
frame++
|
|
1151
|
+
|
|
1152
|
+
if (style === 'off') {
|
|
1153
|
+
g.beginPath()
|
|
1154
|
+
g.globalAlpha = 0.25
|
|
1155
|
+
g.strokeStyle = voice.waveColor
|
|
1156
|
+
g.lineWidth = 1
|
|
1157
|
+
g.moveTo(10, cssH / 2)
|
|
1158
|
+
g.lineTo(cssW - 10, cssH / 2)
|
|
1159
|
+
g.stroke()
|
|
1160
|
+
g.globalAlpha = 1
|
|
1161
|
+
} else if (style === 'dynamic-orb') {
|
|
1162
|
+
drawDynamicOrb(g, cssW, cssH, levels, voice.waveColor, frame)
|
|
1163
|
+
} else if (style === 'bars') {
|
|
1164
|
+
drawClassicBars(g, cssW, cssH, levels, voice.waveColor)
|
|
1165
|
+
} else {
|
|
1166
|
+
drawLiquidWave(g, cssW, cssH, levels, voice.waveColor, frame)
|
|
1167
|
+
}
|
|
1168
|
+
g.restore()
|
|
1136
1169
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1170
|
+
animId = requestAnimationFrame(render)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
animId = requestAnimationFrame(render)
|
|
1174
|
+
return () => {
|
|
1175
|
+
running = false
|
|
1176
|
+
if (animId) cancelAnimationFrame(animId)
|
|
1177
|
+
}
|
|
1139
1178
|
}, [v.phase])
|
|
1140
1179
|
|
|
1141
1180
|
// Message-mode cancel window.
|
|
@@ -1288,6 +1327,7 @@ window.__ModuleLoader__.load({
|
|
|
1288
1327
|
dispose()
|
|
1289
1328
|
dispose = () => {}
|
|
1290
1329
|
const key = data && data.hotkey
|
|
1330
|
+
voice.hotkey = key || ''
|
|
1291
1331
|
if (key) dispose = installHotkey(ctx, key, 'message')
|
|
1292
1332
|
// The composer needs fresh settings without opening the card.
|
|
1293
1333
|
Object.assign(voice.settings, {
|
|
@@ -1620,23 +1660,84 @@ window.__ModuleLoader__.load({
|
|
|
1620
1660
|
voice.settings = Object.assign({}, voice.settings, {
|
|
1621
1661
|
vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
|
|
1622
1662
|
autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
|
|
1623
|
-
beep: !!(
|
|
1624
|
-
micDeviceId: String((
|
|
1625
|
-
historyLimit: Number(
|
|
1626
|
-
voiceCommands: !!(
|
|
1663
|
+
beep: !!(value && value.beep),
|
|
1664
|
+
micDeviceId: String((value && value.micDeviceId) || ''),
|
|
1665
|
+
historyLimit: Number(value && value.historyLimit) || 20,
|
|
1666
|
+
voiceCommands: !!(value && value.voiceCommands),
|
|
1627
1667
|
sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
|
|
1628
1668
|
stream: !!(value && value.dictation && value.dictation.stream),
|
|
1629
1669
|
streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
|
|
1630
1670
|
vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
|
|
1631
|
-
wakeWord: String((
|
|
1632
|
-
bargeIn: !!(
|
|
1671
|
+
wakeWord: String((value && value.wakeWord) || ''),
|
|
1672
|
+
bargeIn: !!(value && value.bargeIn),
|
|
1633
1673
|
polishSend: !!(value && value.message && value.message.polishSend),
|
|
1634
1674
|
sessionCommands: !!(value && value.message && value.message.sessionCommands),
|
|
1635
|
-
polishBaseUrl: String((
|
|
1675
|
+
polishBaseUrl: String((value && value.polishBaseUrl) || ''),
|
|
1636
1676
|
noiseSuppression: value.noiseSuppression !== false,
|
|
1637
1677
|
contextGlossary: value.contextGlossary !== false,
|
|
1678
|
+
visualizerStyle: (value && value.visualizerStyle) || 'liquid-wave',
|
|
1638
1679
|
})
|
|
1639
|
-
}, [ready, value
|
|
1680
|
+
}, [ready, value])
|
|
1681
|
+
|
|
1682
|
+
// Interactive microphone tester
|
|
1683
|
+
const [testingMic, setTestingMic] = React.useState(false)
|
|
1684
|
+
const [testLevel, setTestLevel] = React.useState(0)
|
|
1685
|
+
const testRef = React.useRef(null)
|
|
1686
|
+
|
|
1687
|
+
const toggleTestMic = async () => {
|
|
1688
|
+
if (testingMic) {
|
|
1689
|
+
if (testRef.current) {
|
|
1690
|
+
testRef.current.active = false
|
|
1691
|
+
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
|
|
1692
|
+
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
|
|
1693
|
+
testRef.current = null
|
|
1694
|
+
}
|
|
1695
|
+
setTestingMic(false)
|
|
1696
|
+
setTestLevel(0)
|
|
1697
|
+
return
|
|
1698
|
+
}
|
|
1699
|
+
try {
|
|
1700
|
+
const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
|
|
1701
|
+
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
1702
|
+
if (devId) audio.deviceId = { exact: devId }
|
|
1703
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio })
|
|
1704
|
+
const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
1705
|
+
if (!AC) return
|
|
1706
|
+
const audioCtx = new AC()
|
|
1707
|
+
if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
|
|
1708
|
+
const src = audioCtx.createMediaStreamSource(stream)
|
|
1709
|
+
const analyser = audioCtx.createAnalyser()
|
|
1710
|
+
analyser.fftSize = 128
|
|
1711
|
+
src.connect(analyser)
|
|
1712
|
+
testRef.current = { stream, audioCtx, analyser, active: true }
|
|
1713
|
+
setTestingMic(true)
|
|
1714
|
+
|
|
1715
|
+
const check = () => {
|
|
1716
|
+
if (!testRef.current || !testRef.current.active) return
|
|
1717
|
+
const data = new Uint8Array(analyser.frequencyBinCount)
|
|
1718
|
+
analyser.getByteFrequencyData(data)
|
|
1719
|
+
let sum = 0
|
|
1720
|
+
for (let i = 0; i < data.length; i++) sum += data[i]
|
|
1721
|
+
const lvl = Math.min(100, Math.round((sum / data.length / 255) * 220))
|
|
1722
|
+
setTestLevel(lvl)
|
|
1723
|
+
requestAnimationFrame(check)
|
|
1724
|
+
}
|
|
1725
|
+
requestAnimationFrame(check)
|
|
1726
|
+
} catch (e) {
|
|
1727
|
+
setErr(String(e && e.message ? e.message : e))
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
React.useEffect(() => {
|
|
1732
|
+
return () => {
|
|
1733
|
+
if (testRef.current) {
|
|
1734
|
+
testRef.current.active = false
|
|
1735
|
+
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
|
|
1736
|
+
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
|
|
1737
|
+
testRef.current = null
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}, [])
|
|
1640
1741
|
|
|
1641
1742
|
const [statusData, setStatusData] = React.useState(null)
|
|
1642
1743
|
const [statusLatency, setStatusLatency] = React.useState(null)
|
|
@@ -1744,14 +1845,30 @@ window.__ModuleLoader__.load({
|
|
|
1744
1845
|
|
|
1745
1846
|
const micField = () => React.createElement('div', { className: 'cb-field' },
|
|
1746
1847
|
React.createElement('label', null, t('mic')),
|
|
1747
|
-
React.createElement('
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1848
|
+
React.createElement('div', { className: 'cb-row' },
|
|
1849
|
+
React.createElement('select', {
|
|
1850
|
+
value: String((draft && draft.micDeviceId) ?? (value && value.micDeviceId) ?? ''), disabled: !writable || testingMic,
|
|
1851
|
+
onChange: (e) => setTop('micDeviceId', e.target.value),
|
|
1852
|
+
style: { flex: 1 },
|
|
1853
|
+
},
|
|
1854
|
+
React.createElement('option', { value: '' }, t('micDefault')),
|
|
1855
|
+
devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
|
|
1856
|
+
d.label || d.deviceId.slice(0, 12))),
|
|
1857
|
+
),
|
|
1858
|
+
React.createElement('button', {
|
|
1859
|
+
type: 'button',
|
|
1860
|
+
className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
|
|
1861
|
+
onClick: toggleTestMic,
|
|
1862
|
+
style: { flex: 'none' },
|
|
1863
|
+
}, testingMic ? t('stopTest') : t('testMic')),
|
|
1754
1864
|
),
|
|
1865
|
+
testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
|
|
1866
|
+
React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
|
|
1867
|
+
React.createElement('div', { className: 'dvo-meter-bar' },
|
|
1868
|
+
React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
|
|
1869
|
+
),
|
|
1870
|
+
React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
|
|
1871
|
+
) : null,
|
|
1755
1872
|
)
|
|
1756
1873
|
|
|
1757
1874
|
const langField = (mode) => React.createElement('div', { className: 'cb-field' },
|
package/lib/index.js
CHANGED
|
@@ -205,7 +205,10 @@ export function apply(ctx, baseConfig) {
|
|
|
205
205
|
} catch { return false }
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
let startingWhisper = false
|
|
208
209
|
async function startWhisper() {
|
|
210
|
+
if (startingWhisper) return false
|
|
211
|
+
startingWhisper = true
|
|
209
212
|
const cfg = live()
|
|
210
213
|
if (!cfg.autoStart) return false
|
|
211
214
|
// Without a model path there is nothing to start: the package cannot know
|
|
@@ -226,6 +229,7 @@ export function apply(ctx, baseConfig) {
|
|
|
226
229
|
}
|
|
227
230
|
return false
|
|
228
231
|
} catch { return false }
|
|
232
|
+
finally { startingWhisper = false }
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
startWhisper().catch(() => {})
|
|
@@ -246,7 +250,10 @@ export function apply(ctx, baseConfig) {
|
|
|
246
250
|
} catch { return false }
|
|
247
251
|
}
|
|
248
252
|
|
|
253
|
+
let startingSensevoice = false
|
|
249
254
|
async function startSensevoice() {
|
|
255
|
+
if (startingSensevoice) return false
|
|
256
|
+
startingSensevoice = true
|
|
250
257
|
const cfg = live()
|
|
251
258
|
if (!cfg.sensevoiceAutostart) return false
|
|
252
259
|
if (!cfg.sensevoiceModel) return false
|
|
@@ -268,6 +275,7 @@ export function apply(ctx, baseConfig) {
|
|
|
268
275
|
}
|
|
269
276
|
return false
|
|
270
277
|
} catch { return false }
|
|
278
|
+
finally { startingSensevoice = false }
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
startSensevoice().catch(() => {})
|
package/lib/providers.js
CHANGED
|
@@ -140,6 +140,7 @@ export function makeProviders(deps, req) {
|
|
|
140
140
|
headers: { authorization: `Token ${key}`, 'content-type': mime },
|
|
141
141
|
body: bytes,
|
|
142
142
|
signal,
|
|
143
|
+
keepalive: true,
|
|
143
144
|
})
|
|
144
145
|
if (!res.ok) throw new Error(await readErrorDetail(res, 'Deepgram'))
|
|
145
146
|
let data
|
|
@@ -163,6 +164,7 @@ export function makeProviders(deps, req) {
|
|
|
163
164
|
headers: { authorization: `Bearer ${key}` },
|
|
164
165
|
body: form,
|
|
165
166
|
signal,
|
|
167
|
+
keepalive: true,
|
|
166
168
|
})
|
|
167
169
|
if (!res.ok) throw new Error(await readErrorDetail(res, 'Groq'))
|
|
168
170
|
let data
|
|
@@ -182,6 +184,7 @@ export function makeProviders(deps, req) {
|
|
|
182
184
|
headers: { authorization: `Bearer ${token}`, 'content-type': mime, 'x-wait-for-model': 'true' },
|
|
183
185
|
body: bytes,
|
|
184
186
|
signal,
|
|
187
|
+
keepalive: true,
|
|
185
188
|
})
|
|
186
189
|
if (!res.ok) throw new Error(await readErrorDetail(res, 'HF'))
|
|
187
190
|
let data
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.25",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|