@goodandready/dsh-voice 0.8.18 → 0.8.20

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.
@@ -0,0 +1,285 @@
1
+ function RecordPill(props) {
2
+ const v = useVoice()
3
+ const canvasRef = React.useRef(null)
4
+ const audioRef = React.useRef(null)
5
+ const [isPlaying, setIsPlaying] = React.useState(false)
6
+ voice.inputActions = props.inputActions
7
+ voice.input = props.input
8
+ const ctx = props.ctx
9
+
10
+ // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
11
+ React.useEffect(() => {
12
+ if (v.phase !== 'recording') return
13
+ const tick = 50
14
+ const dispose = ctx.interval(() => {
15
+ const rec = voice.rec
16
+ if (!rec) return
17
+ const level = currentLevel(rec)
18
+ voice.levels.push(level)
19
+ if (voice.levels.length > 150) voice.levels.shift()
20
+ if (level > 0.06) { rec.hadSpeech = true; rec.silenceMs = 0 }
21
+ else if (rec.hadSpeech) rec.silenceMs += tick
22
+ const speaking = level > 0.06
23
+ if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
24
+ const adapt = Number(voice.settings.vadAdapt) || 0
25
+ let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
26
+ if (adapt > 0 && rec.hadSpeech) {
27
+ // Adaptive threshold (#41): speech density over ~1s (20 samples).
28
+ // Dense speech -> lower threshold (cut more precisely);
29
+ // pause-heavy -> threshold rises toward base (do not cut on breaths).
30
+ const win = voice.levels.slice(-20)
31
+ const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
32
+ const k = adapt * (density - 0.5) * 2
33
+ effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
34
+ }
35
+ // Continuous mode (#40): cut on a timer while speech continues,
36
+ // without waiting for a long pause.
37
+ const stream = !!voice.settings.stream && rec.mode === 'dictation'
38
+ if (stream && rec.hadSpeech && !rec.cutting) {
39
+ rec.streamMs += tick
40
+ const chunk = Number(voice.settings.streamChunkMs) || 1200
41
+ if (rec.streamMs >= chunk) { cutPhrase(); return }
42
+ } else {
43
+ rec.streamMs = 0
44
+ }
45
+ if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= effectiveVad) {
46
+ rec.streamMs = 0
47
+ cutPhrase()
48
+ }
49
+ }, tick)
50
+ return () => dispose()
51
+ }, [v.phase])
52
+
53
+ // Waveform / visualizer animation.
54
+ React.useEffect(() => {
55
+ if (v.phase !== 'recording') return
56
+ let frame = 0
57
+ const dispose = ctx.interval(() => {
58
+ 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++
69
+
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
+ }
81
+
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)
89
+ }
90
+ }, 33)
91
+ return () => dispose()
92
+ }, [v.phase])
93
+
94
+ // Message-mode cancel window.
95
+ React.useEffect(() => {
96
+ if (v.phase !== 'pending') return
97
+ const tick = 100
98
+ const dispose = ctx.interval(() => {
99
+ const p = voice.pending
100
+ if (!p) return
101
+ if (p.undoOnly) {
102
+ // Undo-only mode (#29-5): when the window expires just hide the
103
+ // panel; the text either stayed or was already undone.
104
+ p.leftMs -= tick
105
+ if (p.leftMs <= 0) voice.set({ phase: 'idle' })
106
+ else voice.notify()
107
+ return
108
+ }
109
+ p.leftMs -= tick
110
+ if (p.leftMs <= 0) { submitPending(); return }
111
+ voice.notify()
112
+ }, tick)
113
+ return () => dispose()
114
+ }, [v.phase])
115
+
116
+ if (v.phase === 'idle') {
117
+ if (!voice.showPlayer || !voice.lastNote || !voice.lastNote.url) return null
118
+ return React.createElement('div', { className: 'dvo-pill' },
119
+ React.createElement('div', { className: 'dvo-audio-wrap' },
120
+ React.createElement('button', {
121
+ type: 'button', className: 'dvo-audio-play',
122
+ title: isPlaying ? t('pause') : t('play'),
123
+ onClick: () => {
124
+ const el = audioRef.current
125
+ if (!el) return
126
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
127
+ else { el.pause(); setIsPlaying(false) }
128
+ },
129
+ }, isPlaying ? pauseIcon() : playIcon()),
130
+ React.createElement('audio', {
131
+ ref: audioRef, src: voice.lastNote.url,
132
+ onEnded: () => setIsPlaying(false),
133
+ onPause: () => setIsPlaying(false),
134
+ onPlay: () => setIsPlaying(true),
135
+ }),
136
+ React.createElement('span', { className: 'dvo-audio-time' }, t('lastRecording')),
137
+ ),
138
+ React.createElement('span', { className: 'dvo-status' }, voice.lastNote.text || ''),
139
+ React.createElement('button', {
140
+ type: 'button', className: 'dvo-pbtn', title: t('hide'),
141
+ onClick: () => {
142
+ if (audioRef.current) audioRef.current.pause()
143
+ setIsPlaying(false)
144
+ voice.set({ showPlayer: false })
145
+ },
146
+ }, xIcon()),
147
+ )
148
+ }
149
+
150
+ if (v.phase === 'recording') {
151
+ const inBrowser = !!voice.browser
152
+ const rec = voice.rec
153
+ const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
154
+ // Live caption of what is heard right now. Until the browser emits a
155
+ // final chunk the text is interim and changes on screen.
156
+ const caption = voice.caption || (inBrowser ? '' : null)
157
+ return React.createElement('div', { className: 'dvo-pill' },
158
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
159
+ inBrowser
160
+ ? null
161
+ : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
162
+ React.createElement('span', { className: 'dvo-status' },
163
+ caption
164
+ ? caption
165
+ : (voice.holding
166
+ ? t('holdHint')
167
+ : (inBrowser
168
+ ? t('listening')
169
+ : (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
170
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
171
+ )
172
+ }
173
+
174
+ if (v.phase === 'processing') {
175
+ return React.createElement('div', { className: 'dvo-pill' },
176
+ React.createElement('span', { className: 'dvo-status' }, spinIcon(), t('transcribing')))
177
+ }
178
+
179
+ if (v.phase === 'pending') {
180
+ const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
181
+ if (voice.pending && voice.pending.undoOnly) {
182
+ // Cancel window for delayed dictation insert (#29-5).
183
+ return React.createElement('div', { className: 'dvo-pill' },
184
+ React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
185
+ React.createElement('button', {
186
+ type: 'button', className: 'dvo-pbtn', title: t('undo'),
187
+ onClick: async () => {
188
+ const msg = await undoLastInsert()
189
+ voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
190
+ },
191
+ }, xIcon()),
192
+ )
193
+ }
194
+ return React.createElement('div', { className: 'dvo-pill' },
195
+ voice.lastNote && voice.lastNote.url
196
+ ? React.createElement('div', { className: 'dvo-audio-wrap' },
197
+ React.createElement('button', {
198
+ type: 'button', className: 'dvo-audio-play',
199
+ title: isPlaying ? t('pause') : t('play'),
200
+ onClick: () => {
201
+ const el = audioRef.current
202
+ if (!el) return
203
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
204
+ else { el.pause(); setIsPlaying(false) }
205
+ },
206
+ }, isPlaying ? pauseIcon() : playIcon()),
207
+ React.createElement('audio', {
208
+ ref: audioRef, src: voice.lastNote.url,
209
+ onEnded: () => setIsPlaying(false),
210
+ onPause: () => setIsPlaying(false),
211
+ onPlay: () => setIsPlaying(true),
212
+ }),
213
+ React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
214
+ )
215
+ : null,
216
+ React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
217
+ React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
218
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('keepPending'), onClick: keepPending }, xIcon()),
219
+ )
220
+ }
221
+
222
+ return React.createElement('div', { className: 'dvo-pill' },
223
+ React.createElement('span', { className: 'dvo-status dvo-err' }, warnIcon(), v.error),
224
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('hide'), onClick: () => voice.set({ phase: 'idle', error: '' }) }, xIcon()),
225
+ )
226
+ }
227
+
228
+ // --------------------------------------------------------------- slots
229
+ function registerComposer(ctx) {
230
+ // The hotkey lives for as long as the plugin is applied and can change
231
+ // without restart: the settings card broadcasts and the composer reloads.
232
+ ctx.effect(() => {
233
+ let dispose = () => {}
234
+ let alive = true
235
+
236
+ const reload = () => {
237
+ fetch('/dsh-voice/status', { cache: 'no-store' })
238
+ .then((res) => res.json())
239
+ .then((data) => {
240
+ if (!alive) return
241
+ dispose()
242
+ dispose = () => {}
243
+ const key = data && data.hotkey
244
+ if (key) dispose = installHotkey(ctx, key, 'message')
245
+ // The composer needs fresh settings without opening the card.
246
+ Object.assign(voice.settings, {
247
+ beep: !!(data && data.beep),
248
+ micDeviceId: String((data && data.micDeviceId) || ''),
249
+ historyLimit: Number(data && data.historyLimit),
250
+ voiceCommands: !!(data && data.voiceCommands),
251
+ sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
252
+ stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
253
+ streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
254
+ vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
255
+ wakeWord: String((data && data.wakeWord) || ''),
256
+ bargeIn: !!(data && data.bargeIn),
257
+ polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
258
+ sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
259
+ noiseSuppression: data && data.noiseSuppression !== false,
260
+ contextGlossary: data && data.contextGlossary !== false,
261
+ visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
262
+ })
263
+ })
264
+ .catch(() => { /* no host hint — no hotkey */ })
265
+ }
266
+
267
+ reload()
268
+ window.addEventListener('dsh-voice:settings-saved', reload)
269
+ return () => {
270
+ alive = false
271
+ window.removeEventListener('dsh-voice:settings-saved', reload)
272
+ dispose()
273
+ }
274
+ }, 'dsh-voice: hold hotkey')
275
+
276
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
277
+ { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
278
+ (props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
279
+ ))
280
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
281
+ { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, label: () => t('recordSlot') },
282
+ (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
283
+ ))
284
+ }
285
+