@goodandready/dsh-voice 0.8.24 → 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.
@@ -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
@@ -1,10 +1,10 @@
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
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}' +
@@ -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 < 1200) return // too short — not speech
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
- const dispose = ctx.interval(() => {
57
+ let animId = null
58
+ let running = true
59
+
60
+ const render = () => {
61
+ if (!running) return
58
62
  const canvas = canvasRef.current
59
- if (!canvas) return
60
- const g = canvas.getContext('2d')
61
- const w = canvas.width, h = canvas.height
62
- g.clearRect(0, 0, w, h)
63
- if (!voice.waveColor) {
64
- try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
65
- }
66
- const levels = voice.levels
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
- if (style === 'off') {
71
- g.beginPath()
72
- g.globalAlpha = 0.25
73
- g.strokeStyle = voice.waveColor
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
- if (style === 'dynamic-orb') {
83
- drawDynamicOrb(g, w, h, levels, voice.waveColor, frame)
84
- } else if (style === 'bars') {
85
- drawClassicBars(g, w, h, levels, voice.waveColor)
86
- } else {
87
- // default: liquid-wave
88
- drawLiquidWave(g, w, h, levels, voice.waveColor, frame)
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
- }, 33)
91
- return () => dispose()
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: !!(snap && snap.beep),
78
- micDeviceId: String((snap && snap.micDeviceId) || ''),
79
- historyLimit: Number(snap && snap.historyLimit),
80
- voiceCommands: !!(snap && snap.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((snap && snap.wakeWord) || ''),
86
- bargeIn: !!(snap && snap.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((snap && snap.polishBaseUrl) || ''),
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, snap])
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('select', {
202
- value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
203
- onChange: (e) => setTop('micDeviceId', e.target.value),
204
- },
205
- React.createElement('option', { value: '' }, t('micDefault')),
206
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
207
- d.label || d.deviceId.slice(0, 12))),
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,11 +189,11 @@ 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
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}' +
@@ -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 < 1200) return // too short — not speech
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
- const dispose = ctx.interval(() => {
1121
+ let animId = null
1122
+ let running = true
1123
+
1124
+ const render = () => {
1125
+ if (!running) return
1105
1126
  const canvas = canvasRef.current
1106
- if (!canvas) return
1107
- const g = canvas.getContext('2d')
1108
- const w = canvas.width, h = canvas.height
1109
- g.clearRect(0, 0, w, h)
1110
- if (!voice.waveColor) {
1111
- try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
1112
- }
1113
- const levels = voice.levels
1114
- const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
1115
- frame++
1116
-
1117
- if (style === 'off') {
1118
- g.beginPath()
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
- if (style === 'dynamic-orb') {
1130
- drawDynamicOrb(g, w, h, levels, voice.waveColor, frame)
1131
- } else if (style === 'bars') {
1132
- drawClassicBars(g, w, h, levels, voice.waveColor)
1133
- } else {
1134
- // default: liquid-wave
1135
- drawLiquidWave(g, w, h, levels, voice.waveColor, frame)
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
- }, 33)
1138
- return () => dispose()
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: !!(snap && snap.beep),
1624
- micDeviceId: String((snap && snap.micDeviceId) || ''),
1625
- historyLimit: Number(snap && snap.historyLimit),
1626
- voiceCommands: !!(snap && snap.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((snap && snap.wakeWord) || ''),
1632
- bargeIn: !!(snap && snap.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((snap && snap.polishBaseUrl) || ''),
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, snap])
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('select', {
1748
- value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1749
- onChange: (e) => setTop('micDeviceId', e.target.value),
1750
- },
1751
- React.createElement('option', { value: '' }, t('micDefault')),
1752
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1753
- d.label || d.deviceId.slice(0, 12))),
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.24",
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",