@phenx-inc/ctlsurf 0.8.0 → 0.9.0
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/out/headless/index.mjs +144 -1
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +144 -0
- package/out/preload/index.js +2 -0
- package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BdYp0fp9.js} +3 -3
- package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-Cya-9zr-.js} +1 -1
- package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-EJs9W5Ub.js} +1 -1
- package/out/renderer/assets/{html-D8xFiRmI.js → html-Ex4qy2lh.js} +1 -1
- package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-CCm0aJ6C.js} +3 -3
- package/out/renderer/assets/{index---H6cxNl.js → index-Cj7K9-Ba.js} +265 -242
- package/out/renderer/assets/{index-B-iM7dFC.css → index-nvsNYP5c.css} +34 -0
- package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-B2niCouI.js} +2 -2
- package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BgCUHaFo.js} +3 -3
- package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-DOQmSo4G.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
- package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-DuKdGNKE.js} +1 -1
- package/out/renderer/assets/{python-DKu7rNbs.js → python-BUa4XFG-.js} +1 -1
- package/out/renderer/assets/{razor-BOMpCo6z.js → razor-BUw8jho0.js} +1 -1
- package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
- package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-8FLRnobw.js} +1 -1
- package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-rdwAjCi4.js} +1 -1
- package/out/renderer/assets/{xml-D4PvYeQq.js → xml-HOzT59Qg.js} +1 -1
- package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-B81JfLsW.js} +1 -1
- package/out/renderer/index.html +64 -2
- package/out/repro-toggle.mjs +305 -0
- package/package.json +1 -1
- package/src/main/index.ts +2 -0
- package/src/main/orchestrator.ts +13 -1
- package/src/main/transcripts.ts +134 -0
- package/src/preload/index.ts +3 -0
- package/src/renderer/App.tsx +25 -11
- package/src/renderer/components/FloatingMic.tsx +48 -0
- package/src/renderer/components/VoiceInput.tsx +36 -149
- package/src/renderer/index.html +62 -0
- package/src/renderer/lib/localWhisper.ts +46 -2
- package/src/renderer/lib/speech.ts +18 -0
- package/src/renderer/styles.css +34 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { VoiceInput } from './VoiceInput'
|
|
3
|
+
import { speech } from '../lib/speech'
|
|
3
4
|
|
|
4
5
|
// A draggable, dismissable push-to-talk mic that floats over the panes. It wraps
|
|
5
6
|
// the same <VoiceInput> push-to-talk logic used in the titlebar; only the chrome
|
|
@@ -36,6 +37,29 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
|
|
|
36
37
|
// Pointer-to-element offset captured at drag start; null when not dragging.
|
|
37
38
|
const dragRef = useRef<{ dx: number; dy: number } | null>(null)
|
|
38
39
|
|
|
40
|
+
// Read-aloud (TTS) of the last agent reply. `speaking` mirrors the shared
|
|
41
|
+
// speech singleton; `note` is a transient message (e.g. nothing to read yet).
|
|
42
|
+
const [speaking, setSpeaking] = useState(false)
|
|
43
|
+
const [note, setNote] = useState<string | null>(null)
|
|
44
|
+
useEffect(() => speech.onActivity(setSpeaking), [])
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!note) return
|
|
47
|
+
const t = setTimeout(() => setNote(null), 3000)
|
|
48
|
+
return () => clearTimeout(t)
|
|
49
|
+
}, [note])
|
|
50
|
+
|
|
51
|
+
const onReadAloud = useCallback(async () => {
|
|
52
|
+
// Toggle: a second click while speaking stops it.
|
|
53
|
+
if (speaking) { speech.stop(); return }
|
|
54
|
+
// Prime the audio context from this click (synchronously, before the await)
|
|
55
|
+
// so neural playback isn't blocked by the autoplay policy.
|
|
56
|
+
speech.unlock()
|
|
57
|
+
let text: string | null = null
|
|
58
|
+
try { text = await window.worker.getLastReply() } catch { /* ignore */ }
|
|
59
|
+
if (text) speech.enqueue(text)
|
|
60
|
+
else setNote('No reply to read yet')
|
|
61
|
+
}, [speaking])
|
|
62
|
+
|
|
39
63
|
// Keep the button fully inside the viewport (used on drag, mount, and resize).
|
|
40
64
|
const clamp = useCallback((x: number, y: number): Pos => {
|
|
41
65
|
const el = elRef.current
|
|
@@ -123,6 +147,30 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
|
|
|
123
147
|
</button>
|
|
124
148
|
</div>
|
|
125
149
|
<VoiceInput variant="floating" onTranscript={onTranscript} />
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={`floating-mic-read ${speaking ? 'speaking' : ''}`}
|
|
153
|
+
// Don't let a click on the button start a drag.
|
|
154
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
155
|
+
onClick={onReadAloud}
|
|
156
|
+
title={speaking ? 'Stop reading' : 'Read last agent reply aloud'}
|
|
157
|
+
aria-label={speaking ? 'Stop reading' : 'Read last agent reply aloud'}
|
|
158
|
+
aria-pressed={speaking}
|
|
159
|
+
>
|
|
160
|
+
{speaking ? (
|
|
161
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
|
|
162
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
163
|
+
</svg>
|
|
164
|
+
) : (
|
|
165
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor"
|
|
166
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
167
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
168
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
169
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
170
|
+
</svg>
|
|
171
|
+
)}
|
|
172
|
+
</button>
|
|
173
|
+
{note && <div className="floating-mic-note">{note}</div>}
|
|
126
174
|
</div>
|
|
127
175
|
)
|
|
128
176
|
}
|
|
@@ -1,76 +1,26 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { transcribeBlob, type ModelProgress } from '../lib/localWhisper'
|
|
3
3
|
|
|
4
|
-
// ───
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
interface SpeechRecognitionResult {
|
|
11
|
-
isFinal: boolean
|
|
12
|
-
0: { transcript: string }
|
|
13
|
-
}
|
|
14
|
-
interface SpeechRecognitionEvent {
|
|
15
|
-
resultIndex: number
|
|
16
|
-
results: { length: number;[index: number]: SpeechRecognitionResult }
|
|
17
|
-
}
|
|
18
|
-
interface SpeechRecognitionErrorEvent { error: string }
|
|
19
|
-
interface SpeechRecognitionLike {
|
|
20
|
-
lang: string
|
|
21
|
-
continuous: boolean
|
|
22
|
-
interimResults: boolean
|
|
23
|
-
start: () => void
|
|
24
|
-
stop: () => void
|
|
25
|
-
abort: () => void
|
|
26
|
-
onresult: ((e: SpeechRecognitionEvent) => void) | null
|
|
27
|
-
onerror: ((e: SpeechRecognitionErrorEvent) => void) | null
|
|
28
|
-
onend: (() => void) | null
|
|
29
|
-
}
|
|
30
|
-
type SpeechRecognitionCtor = new () => SpeechRecognitionLike
|
|
4
|
+
// ─── Capabilities ────────────────────────────────────
|
|
5
|
+
// Speech is transcribed entirely on-device via a local Whisper model (see
|
|
6
|
+
// ../lib/localWhisper). The browser's Web Speech API is intentionally not used:
|
|
7
|
+
// it's a cloud service whose Google backend is unreachable inside Electron
|
|
8
|
+
// (Chromium ships without the API key), so it never works in this app.
|
|
31
9
|
|
|
32
|
-
function getRecognitionCtor(): SpeechRecognitionCtor | null {
|
|
33
|
-
const w = window as unknown as {
|
|
34
|
-
SpeechRecognition?: SpeechRecognitionCtor
|
|
35
|
-
webkitSpeechRecognition?: SpeechRecognitionCtor
|
|
36
|
-
}
|
|
37
|
-
return w.SpeechRecognition || w.webkitSpeechRecognition || null
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ─── Capabilities & engine selection ─────────────────
|
|
41
|
-
|
|
42
|
-
type Engine = 'web-speech' | 'local'
|
|
43
10
|
type Phase = 'idle' | 'listening' | 'transcribing'
|
|
44
11
|
|
|
45
|
-
|
|
46
|
-
// Persisted audioinput deviceId for the local engine; null/absent = OS default.
|
|
12
|
+
// Persisted audioinput deviceId; null/absent = OS default.
|
|
47
13
|
const DEVICE_KEY = 'ctlsurf.voiceDeviceId'
|
|
48
14
|
|
|
49
15
|
function loadDeviceId(): string | null {
|
|
50
16
|
try { return localStorage.getItem(DEVICE_KEY) || null } catch { return null }
|
|
51
17
|
}
|
|
52
18
|
|
|
53
|
-
const WEB_SPEECH_SUPPORTED = getRecognitionCtor() !== null
|
|
54
19
|
const LOCAL_SUPPORTED =
|
|
55
20
|
typeof navigator !== 'undefined' &&
|
|
56
21
|
!!navigator.mediaDevices?.getUserMedia &&
|
|
57
22
|
typeof MediaRecorder !== 'undefined' &&
|
|
58
23
|
typeof OfflineAudioContext !== 'undefined'
|
|
59
|
-
const ANY_SUPPORTED = WEB_SPEECH_SUPPORTED || LOCAL_SUPPORTED
|
|
60
|
-
|
|
61
|
-
function loadInitialEngine(): Engine {
|
|
62
|
-
if (!WEB_SPEECH_SUPPORTED && LOCAL_SUPPORTED) return 'local'
|
|
63
|
-
try {
|
|
64
|
-
if (localStorage.getItem(ENGINE_KEY) === 'local' && LOCAL_SUPPORTED) return 'local'
|
|
65
|
-
} catch { /* ignore */ }
|
|
66
|
-
return WEB_SPEECH_SUPPORTED ? 'web-speech' : 'local'
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Web Speech errors that mean the engine itself is unreachable (vs. a mic
|
|
70
|
-
// permission/hardware problem, which would also break the local fallback).
|
|
71
|
-
function isEngineUnavailable(code: string): boolean {
|
|
72
|
-
return code === 'network' || code === 'service-not-allowed'
|
|
73
|
-
}
|
|
74
24
|
|
|
75
25
|
function describeMicError(err: unknown): string {
|
|
76
26
|
const name = (err as { name?: string })?.name
|
|
@@ -88,14 +38,14 @@ interface VoiceInputProps {
|
|
|
88
38
|
}
|
|
89
39
|
|
|
90
40
|
export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputProps) {
|
|
91
|
-
const [engine, setEngine] = useState<Engine>(loadInitialEngine)
|
|
92
41
|
const [phase, setPhase] = useState<Phase>('idle')
|
|
93
|
-
const [interim, setInterim] = useState('')
|
|
94
42
|
const [modelPct, setModelPct] = useState<number | null>(null)
|
|
43
|
+
// Transcript-so-far streamed from the model during transcription (live preview).
|
|
44
|
+
const [partial, setPartial] = useState('')
|
|
95
45
|
const [error, setError] = useState<string | null>(null)
|
|
96
46
|
const [notice, setNotice] = useState<string | null>(null)
|
|
97
47
|
|
|
98
|
-
// Mic source selection
|
|
48
|
+
// Mic source selection
|
|
99
49
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
|
|
100
50
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(loadDeviceId)
|
|
101
51
|
const [showDevicePicker, setShowDevicePicker] = useState(false)
|
|
@@ -103,19 +53,13 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
103
53
|
useEffect(() => { selectedDeviceIdRef.current = selectedDeviceId }, [selectedDeviceId])
|
|
104
54
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
105
55
|
|
|
106
|
-
//
|
|
107
|
-
const recognitionRef = useRef<SpeechRecognitionLike | null>(null)
|
|
108
|
-
const finalRef = useRef('')
|
|
109
|
-
// Local (Whisper) refs
|
|
56
|
+
// Recording refs
|
|
110
57
|
const streamRef = useRef<MediaStream | null>(null)
|
|
111
58
|
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
112
59
|
const chunksRef = useRef<Blob[]>([])
|
|
113
60
|
// Set true when the user releases before getUserMedia resolves (quick tap).
|
|
114
61
|
const cancelGestureRef = useRef(false)
|
|
115
62
|
|
|
116
|
-
const engineRef = useRef(engine)
|
|
117
|
-
useEffect(() => { engineRef.current = engine }, [engine])
|
|
118
|
-
|
|
119
63
|
const onTranscriptRef = useRef(onTranscript)
|
|
120
64
|
useEffect(() => { onTranscriptRef.current = onTranscript }, [onTranscript])
|
|
121
65
|
|
|
@@ -131,12 +75,6 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
131
75
|
return () => clearTimeout(t)
|
|
132
76
|
}, [notice])
|
|
133
77
|
|
|
134
|
-
const switchToLocal = useCallback((reason: string) => {
|
|
135
|
-
try { localStorage.setItem(ENGINE_KEY, 'local') } catch { /* ignore */ }
|
|
136
|
-
setEngine('local')
|
|
137
|
-
setNotice(reason)
|
|
138
|
-
}, [])
|
|
139
|
-
|
|
140
78
|
const stopStream = useCallback(() => {
|
|
141
79
|
streamRef.current?.getTracks().forEach((t) => t.stop())
|
|
142
80
|
streamRef.current = null
|
|
@@ -224,66 +162,7 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
224
162
|
return () => document.removeEventListener('mousedown', onDocDown)
|
|
225
163
|
}, [showDevicePicker])
|
|
226
164
|
|
|
227
|
-
// ───
|
|
228
|
-
|
|
229
|
-
const startWebSpeech = useCallback(() => {
|
|
230
|
-
const Ctor = getRecognitionCtor()
|
|
231
|
-
if (!Ctor || recognitionRef.current) return
|
|
232
|
-
setError(null); setNotice(null); setInterim('')
|
|
233
|
-
finalRef.current = ''
|
|
234
|
-
|
|
235
|
-
const rec = new Ctor()
|
|
236
|
-
rec.lang = navigator.language || 'en-US'
|
|
237
|
-
rec.continuous = true
|
|
238
|
-
rec.interimResults = true
|
|
239
|
-
|
|
240
|
-
rec.onresult = (event) => {
|
|
241
|
-
let finalText = ''
|
|
242
|
-
let interimText = ''
|
|
243
|
-
for (let i = 0; i < event.results.length; i++) {
|
|
244
|
-
const res = event.results[i]
|
|
245
|
-
if (res.isFinal) finalText += res[0].transcript
|
|
246
|
-
else interimText += res[0].transcript
|
|
247
|
-
}
|
|
248
|
-
finalRef.current = finalText
|
|
249
|
-
setInterim(interimText)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
rec.onerror = (event) => {
|
|
253
|
-
if (isEngineUnavailable(event.error) && LOCAL_SUPPORTED) {
|
|
254
|
-
// The streamed audio is gone; switch engines and ask for a retry.
|
|
255
|
-
finalRef.current = ''
|
|
256
|
-
switchToLocal('Voice service unavailable — switched to on-device. Press again.')
|
|
257
|
-
} else if (event.error !== 'no-speech' && event.error !== 'aborted') {
|
|
258
|
-
setError(event.error === 'not-allowed' ? 'Microphone access denied' : `Voice error: ${event.error}`)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
rec.onend = () => {
|
|
263
|
-
const text = finalRef.current.trim()
|
|
264
|
-
recognitionRef.current = null
|
|
265
|
-
setPhase('idle')
|
|
266
|
-
setInterim('')
|
|
267
|
-
if (text) onTranscriptRef.current(text)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
recognitionRef.current = rec
|
|
271
|
-
try {
|
|
272
|
-
rec.start()
|
|
273
|
-
setPhase('listening')
|
|
274
|
-
} catch (err) {
|
|
275
|
-
recognitionRef.current = null
|
|
276
|
-
setPhase('idle')
|
|
277
|
-
setError('Could not start microphone')
|
|
278
|
-
console.error('[voice] web speech start failed', err)
|
|
279
|
-
}
|
|
280
|
-
}, [switchToLocal])
|
|
281
|
-
|
|
282
|
-
const stopWebSpeech = useCallback(() => {
|
|
283
|
-
try { recognitionRef.current?.stop() } catch { /* already stopped */ }
|
|
284
|
-
}, [])
|
|
285
|
-
|
|
286
|
-
// ─── Local (Whisper) engine ────────────────────────
|
|
165
|
+
// ─── On-device (Whisper) transcription ─────────────
|
|
287
166
|
|
|
288
167
|
const handleModelProgress = useCallback((p: ModelProgress) => {
|
|
289
168
|
if (p.status === 'progress' && typeof p.progress === 'number') {
|
|
@@ -299,9 +178,14 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
299
178
|
if (blob.size === 0) { setPhase('idle'); return }
|
|
300
179
|
|
|
301
180
|
setPhase('transcribing')
|
|
302
|
-
|
|
181
|
+
setPartial('')
|
|
303
182
|
try {
|
|
304
|
-
const text = await transcribeBlob(blob, handleModelProgress)
|
|
183
|
+
const text = await transcribeBlob(blob, handleModelProgress, (t) => {
|
|
184
|
+
// First streamed token means the model is loaded and decoding — drop the
|
|
185
|
+
// download indicator and start showing the live transcript.
|
|
186
|
+
setModelPct(null)
|
|
187
|
+
setPartial(t)
|
|
188
|
+
})
|
|
305
189
|
if (text) onTranscriptRef.current(text)
|
|
306
190
|
else setNotice('No speech detected — check the mic source (▾).')
|
|
307
191
|
} catch (err) {
|
|
@@ -310,11 +194,12 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
310
194
|
} finally {
|
|
311
195
|
setPhase('idle')
|
|
312
196
|
setModelPct(null)
|
|
197
|
+
setPartial('')
|
|
313
198
|
}
|
|
314
199
|
}, [stopStream, handleModelProgress])
|
|
315
200
|
|
|
316
201
|
const startLocal = useCallback(async () => {
|
|
317
|
-
setError(null); setNotice(null)
|
|
202
|
+
setError(null); setNotice(null)
|
|
318
203
|
cancelGestureRef.current = false
|
|
319
204
|
try {
|
|
320
205
|
const stream = await getStream()
|
|
@@ -351,21 +236,18 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
351
236
|
// ─── Push-to-talk gesture ──────────────────────────
|
|
352
237
|
|
|
353
238
|
const handlePointerDown = (e: React.PointerEvent) => {
|
|
354
|
-
if (!
|
|
239
|
+
if (!LOCAL_SUPPORTED || phase !== 'idle') return
|
|
355
240
|
e.preventDefault()
|
|
356
241
|
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
357
|
-
|
|
358
|
-
else if (LOCAL_SUPPORTED) void startLocal()
|
|
242
|
+
void startLocal()
|
|
359
243
|
}
|
|
360
244
|
const handlePointerUp = (e: React.PointerEvent) => {
|
|
361
245
|
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
|
362
|
-
|
|
363
|
-
else stopLocal()
|
|
246
|
+
stopLocal()
|
|
364
247
|
}
|
|
365
248
|
|
|
366
249
|
// Clean up on unmount.
|
|
367
250
|
useEffect(() => () => {
|
|
368
|
-
try { recognitionRef.current?.abort() } catch { /* ignore */ }
|
|
369
251
|
try { recorderRef.current?.stop() } catch { /* ignore */ }
|
|
370
252
|
streamRef.current?.getTracks().forEach((t) => t.stop())
|
|
371
253
|
}, [])
|
|
@@ -375,21 +257,27 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
375
257
|
const listening = phase === 'listening'
|
|
376
258
|
const busy = phase === 'transcribing'
|
|
377
259
|
|
|
378
|
-
const title = !
|
|
260
|
+
const title = !LOCAL_SUPPORTED
|
|
379
261
|
? 'Voice typing not supported in this build'
|
|
380
262
|
: listening
|
|
381
263
|
? 'Listening… release to insert'
|
|
382
264
|
: busy
|
|
383
265
|
? 'Transcribing…'
|
|
384
|
-
:
|
|
385
|
-
? 'Hold to talk (on-device) — speech is typed into the terminal'
|
|
386
|
-
: 'Hold to talk — speech is typed into the terminal'
|
|
266
|
+
: 'Hold to talk (on-device) — speech is typed into the terminal'
|
|
387
267
|
|
|
388
268
|
let chip: { kind: 'listening' | 'busy' | 'notice' | 'error'; text: string } | null = null
|
|
389
269
|
if (error && phase === 'idle') chip = { kind: 'error', text: error }
|
|
390
270
|
else if (notice && phase === 'idle') chip = { kind: 'notice', text: notice }
|
|
391
|
-
else if (listening) chip = { kind: 'listening', text:
|
|
392
|
-
else if (busy)
|
|
271
|
+
else if (listening) chip = { kind: 'listening', text: 'Recording…' }
|
|
272
|
+
else if (busy) {
|
|
273
|
+
// Show the most recent ~90 chars of the live transcript as a ticker so a long
|
|
274
|
+
// dictation doesn't overflow the chip.
|
|
275
|
+
const preview = partial.length > 90 ? '…' + partial.slice(-90) : partial
|
|
276
|
+
chip = {
|
|
277
|
+
kind: 'busy',
|
|
278
|
+
text: modelPct !== null ? `Downloading voice model… ${modelPct}%` : (preview || 'Transcribing…'),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
393
281
|
|
|
394
282
|
const floating = variant === 'floating'
|
|
395
283
|
const btnClass = floating
|
|
@@ -408,7 +296,6 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
408
296
|
const activeLabel = selectedDeviceId
|
|
409
297
|
? (realDevices.find((d) => d.deviceId === selectedDeviceId)?.label || 'Selected microphone')
|
|
410
298
|
: defaultLabel
|
|
411
|
-
// Source selection only affects the local (getUserMedia) engine.
|
|
412
299
|
const showSourcePicker = floating && LOCAL_SUPPORTED
|
|
413
300
|
|
|
414
301
|
return (
|
|
@@ -416,7 +303,7 @@ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputPro
|
|
|
416
303
|
<button
|
|
417
304
|
type="button"
|
|
418
305
|
className={btnClass}
|
|
419
|
-
disabled={!
|
|
306
|
+
disabled={!LOCAL_SUPPORTED}
|
|
420
307
|
onPointerDown={handlePointerDown}
|
|
421
308
|
onPointerUp={handlePointerUp}
|
|
422
309
|
onPointerCancel={handlePointerUp}
|
package/src/renderer/index.html
CHANGED
|
@@ -4,9 +4,71 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ctlsurf-worker</title>
|
|
7
|
+
<!-- Boot splash styles are inlined here so they paint on the first frame,
|
|
8
|
+
before the JS bundle and styles.css have loaded. Without this the window
|
|
9
|
+
shows an empty dark pane while React mounts and the startup IPC runs. -->
|
|
10
|
+
<style>
|
|
11
|
+
#boot-splash {
|
|
12
|
+
position: fixed;
|
|
13
|
+
inset: 0;
|
|
14
|
+
z-index: 9999;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
gap: 16px;
|
|
20
|
+
background: #1a1b26;
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
22
|
+
transition: opacity 0.3s ease;
|
|
23
|
+
-webkit-app-region: drag; /* keep the frameless window draggable during load */
|
|
24
|
+
}
|
|
25
|
+
#boot-splash.boot-splash-hidden {
|
|
26
|
+
opacity: 0;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
}
|
|
29
|
+
.boot-spinner {
|
|
30
|
+
width: 34px;
|
|
31
|
+
height: 34px;
|
|
32
|
+
border-radius: 50%;
|
|
33
|
+
border: 3px solid rgba(122, 162, 247, 0.22);
|
|
34
|
+
border-top-color: #7aa2f7;
|
|
35
|
+
animation: boot-spin 0.8s linear infinite;
|
|
36
|
+
}
|
|
37
|
+
@keyframes boot-spin {
|
|
38
|
+
to { transform: rotate(360deg); }
|
|
39
|
+
}
|
|
40
|
+
.boot-splash-title {
|
|
41
|
+
font-size: 15px;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
letter-spacing: 0.02em;
|
|
44
|
+
color: #c0caf5;
|
|
45
|
+
}
|
|
46
|
+
.boot-splash-label {
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
letter-spacing: 0.04em;
|
|
49
|
+
color: #565f89;
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
7
52
|
</head>
|
|
8
53
|
<body>
|
|
54
|
+
<div id="boot-splash">
|
|
55
|
+
<div class="boot-spinner"></div>
|
|
56
|
+
<div class="boot-splash-title">ctlsurf</div>
|
|
57
|
+
<div class="boot-splash-label">Starting up…</div>
|
|
58
|
+
</div>
|
|
9
59
|
<div id="root"></div>
|
|
60
|
+
<script>
|
|
61
|
+
// Fades out and removes the boot splash. Called by the React app once its
|
|
62
|
+
// startup IPC has settled (see App.tsx). The timeout is a safety net so a
|
|
63
|
+
// hung startup never leaves the user stuck behind the splash forever.
|
|
64
|
+
window.__dismissSplash = function () {
|
|
65
|
+
var el = document.getElementById('boot-splash')
|
|
66
|
+
if (!el) return
|
|
67
|
+
el.classList.add('boot-splash-hidden')
|
|
68
|
+
setTimeout(function () { el.remove() }, 350)
|
|
69
|
+
}
|
|
70
|
+
setTimeout(function () { window.__dismissSplash() }, 10000)
|
|
71
|
+
</script>
|
|
10
72
|
<script type="module" src="./main.tsx"></script>
|
|
11
73
|
</body>
|
|
12
74
|
</html>
|
|
@@ -107,7 +107,14 @@ async function blobToPcm16k(blob: Blob): Promise<Float32Array | null> {
|
|
|
107
107
|
return rendered.getChannelData(0)
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export async function transcribeBlob(
|
|
110
|
+
export async function transcribeBlob(
|
|
111
|
+
blob: Blob,
|
|
112
|
+
onProgress?: (p: ModelProgress) => void,
|
|
113
|
+
// Optional live preview: called with the transcript-so-far as the model
|
|
114
|
+
// decodes, for progressive on-screen feedback. The return value (below) is
|
|
115
|
+
// still the authoritative, de-duplicated text used for the actual insert.
|
|
116
|
+
onPartial?: (text: string) => void,
|
|
117
|
+
): Promise<string> {
|
|
111
118
|
// Decode first so a silent clip never triggers the (one-time, ~75MB) model
|
|
112
119
|
// download just to be thrown away below.
|
|
113
120
|
const pcm = await blobToPcm16k(blob)
|
|
@@ -119,7 +126,44 @@ export async function transcribeBlob(blob: Blob, onProgress?: (p: ModelProgress)
|
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
const transcriber = await loadTranscriber(onProgress)
|
|
122
|
-
|
|
129
|
+
|
|
130
|
+
// Stream decoded text to onPartial as it's generated. `committed` holds text
|
|
131
|
+
// from finished chunks; `current` is the in-progress chunk — keeping them
|
|
132
|
+
// separate avoids the stride overlap duplicating words across 30s boundaries
|
|
133
|
+
// in the preview. Best-effort: if the streamer can't be built we just skip the
|
|
134
|
+
// preview and still return the final text.
|
|
135
|
+
let streamer: unknown
|
|
136
|
+
if (onPartial) {
|
|
137
|
+
try {
|
|
138
|
+
const { WhisperTextStreamer } = await import('@huggingface/transformers')
|
|
139
|
+
const tokenizer = (transcriber as unknown as {
|
|
140
|
+
tokenizer: ConstructorParameters<typeof WhisperTextStreamer>[0]
|
|
141
|
+
}).tokenizer
|
|
142
|
+
let committed = ''
|
|
143
|
+
let current = ''
|
|
144
|
+
streamer = new WhisperTextStreamer(tokenizer, {
|
|
145
|
+
on_chunk_start: () => { current = '' },
|
|
146
|
+
callback_function: (delta: string) => {
|
|
147
|
+
current += delta
|
|
148
|
+
onPartial((committed + current).replace(/\s+/g, ' ').trim())
|
|
149
|
+
},
|
|
150
|
+
on_chunk_end: () => { committed = (committed + current).trim() + ' '; current = '' },
|
|
151
|
+
})
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.warn('[voice] streaming preview unavailable', err)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Whisper only attends to a 30s window at a time. Without chunk_length_s the
|
|
158
|
+
// pipeline truncates to that first window, silently dropping anything spoken
|
|
159
|
+
// past ~30s. Chunking transcribes the full clip in overlapping 30s windows
|
|
160
|
+
// (stride gives overlap so words aren't lost at the seams) so long speeches
|
|
161
|
+
// are captured in full.
|
|
162
|
+
const result = await transcriber(pcm, {
|
|
163
|
+
chunk_length_s: 30,
|
|
164
|
+
stride_length_s: 5,
|
|
165
|
+
...(streamer ? { streamer } : {}),
|
|
166
|
+
})
|
|
123
167
|
const text = (Array.isArray(result)
|
|
124
168
|
? result.map((r) => r.text).join(' ')
|
|
125
169
|
: result?.text || '').trim()
|
|
@@ -164,6 +164,23 @@ class SpeechController {
|
|
|
164
164
|
onActivityChange: ((active: boolean) => void) | null = null
|
|
165
165
|
onError: ((message: string) => void) | null = null
|
|
166
166
|
|
|
167
|
+
// Multiple UI surfaces (titlebar SpeakControls + floating read button) need
|
|
168
|
+
// the speaking state at once, so beyond the single onActivityChange slot we
|
|
169
|
+
// also keep a listener set.
|
|
170
|
+
private activityListeners = new Set<(active: boolean) => void>()
|
|
171
|
+
|
|
172
|
+
get isActive(): boolean {
|
|
173
|
+
return this.active
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Subscribe to speaking-state changes. Fires immediately with the current
|
|
177
|
+
// state so a late subscriber syncs. Returns an unsubscribe function.
|
|
178
|
+
onActivity(fn: (active: boolean) => void): () => void {
|
|
179
|
+
this.activityListeners.add(fn)
|
|
180
|
+
fn(this.active)
|
|
181
|
+
return () => { this.activityListeners.delete(fn) }
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
// Prime/resume the AudioContext from a user gesture so neural playback isn't
|
|
168
185
|
// blocked by the browser autoplay policy (the System engine is unaffected).
|
|
169
186
|
unlock(): void {
|
|
@@ -216,6 +233,7 @@ class SpeechController {
|
|
|
216
233
|
if (this.active === a) return
|
|
217
234
|
this.active = a
|
|
218
235
|
this.onActivityChange?.(a)
|
|
236
|
+
this.activityListeners.forEach((fn) => fn(a))
|
|
219
237
|
}
|
|
220
238
|
|
|
221
239
|
private async drain(): Promise<void> {
|
package/src/renderer/styles.css
CHANGED
|
@@ -701,6 +701,40 @@ html, body, #root {
|
|
|
701
701
|
}
|
|
702
702
|
.floating-mic-hide:hover { color: #f7768e; }
|
|
703
703
|
|
|
704
|
+
/* Smaller sibling of the round mic: read the last agent reply aloud. */
|
|
705
|
+
.floating-mic-read {
|
|
706
|
+
width: 30px;
|
|
707
|
+
height: 30px;
|
|
708
|
+
border-radius: 50%;
|
|
709
|
+
display: inline-flex;
|
|
710
|
+
align-items: center;
|
|
711
|
+
justify-content: center;
|
|
712
|
+
background: #2a2b3d;
|
|
713
|
+
border: 1px solid #3b3d57;
|
|
714
|
+
color: #a9b1d6;
|
|
715
|
+
cursor: pointer;
|
|
716
|
+
transition: all 0.15s;
|
|
717
|
+
}
|
|
718
|
+
.floating-mic-read:hover { border-color: #565f89; color: #c0caf5; }
|
|
719
|
+
.floating-mic-read.speaking {
|
|
720
|
+
color: #7aa2f7;
|
|
721
|
+
border-color: #7aa2f7;
|
|
722
|
+
}
|
|
723
|
+
.floating-mic-note {
|
|
724
|
+
position: absolute;
|
|
725
|
+
bottom: -22px;
|
|
726
|
+
left: 50%;
|
|
727
|
+
transform: translateX(-50%);
|
|
728
|
+
white-space: nowrap;
|
|
729
|
+
font-size: 11px;
|
|
730
|
+
color: #565f89;
|
|
731
|
+
background: #16161e;
|
|
732
|
+
border: 1px solid #3b3d57;
|
|
733
|
+
border-radius: 6px;
|
|
734
|
+
padding: 2px 6px;
|
|
735
|
+
pointer-events: none;
|
|
736
|
+
}
|
|
737
|
+
|
|
704
738
|
.voice-btn-floating {
|
|
705
739
|
width: 48px;
|
|
706
740
|
height: 48px;
|