@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.
Files changed (37) hide show
  1. package/out/headless/index.mjs +144 -1
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +144 -0
  4. package/out/preload/index.js +2 -0
  5. package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BdYp0fp9.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-Cya-9zr-.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-EJs9W5Ub.js} +1 -1
  8. package/out/renderer/assets/{html-D8xFiRmI.js → html-Ex4qy2lh.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-CCm0aJ6C.js} +3 -3
  10. package/out/renderer/assets/{index---H6cxNl.js → index-Cj7K9-Ba.js} +265 -242
  11. package/out/renderer/assets/{index-B-iM7dFC.css → index-nvsNYP5c.css} +34 -0
  12. package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-B2niCouI.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BgCUHaFo.js} +3 -3
  14. package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-DOQmSo4G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
  16. package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-DuKdGNKE.js} +1 -1
  17. package/out/renderer/assets/{python-DKu7rNbs.js → python-BUa4XFG-.js} +1 -1
  18. package/out/renderer/assets/{razor-BOMpCo6z.js → razor-BUw8jho0.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-8FLRnobw.js} +1 -1
  21. package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-rdwAjCi4.js} +1 -1
  22. package/out/renderer/assets/{xml-D4PvYeQq.js → xml-HOzT59Qg.js} +1 -1
  23. package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-B81JfLsW.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/index.ts +2 -0
  28. package/src/main/orchestrator.ts +13 -1
  29. package/src/main/transcripts.ts +134 -0
  30. package/src/preload/index.ts +3 -0
  31. package/src/renderer/App.tsx +25 -11
  32. package/src/renderer/components/FloatingMic.tsx +48 -0
  33. package/src/renderer/components/VoiceInput.tsx +36 -149
  34. package/src/renderer/index.html +62 -0
  35. package/src/renderer/lib/localWhisper.ts +46 -2
  36. package/src/renderer/lib/speech.ts +18 -0
  37. 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
- // ─── Minimal Web Speech API typings ──────────────────
5
- // webkitSpeechRecognition isn't in the standard DOM lib, so declare just the
6
- // surface we use. This API is frequently unavailable inside Electron (Chromium
7
- // ships without Google's speech backend); when it fails we fall back to a local
8
- // Whisper model (see ../lib/localWhisper).
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
- const ENGINE_KEY = 'ctlsurf.voiceEngine'
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 (local engine only)
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
- // Web Speech refs
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
- // ─── Web Speech engine ─────────────────────────────
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
- setInterim('')
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); setInterim('')
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 (!ANY_SUPPORTED || phase !== 'idle') return
239
+ if (!LOCAL_SUPPORTED || phase !== 'idle') return
355
240
  e.preventDefault()
356
241
  e.currentTarget.setPointerCapture?.(e.pointerId)
357
- if (engineRef.current === 'web-speech' && WEB_SPEECH_SUPPORTED) startWebSpeech()
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
- if (engineRef.current === 'web-speech') stopWebSpeech()
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 = !ANY_SUPPORTED
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
- : engine === 'local'
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: interim || (engine === 'local' ? 'Recording…' : 'Listening…') }
392
- else if (busy) chip = { kind: 'busy', text: modelPct !== null ? `Downloading voice model… ${modelPct}%` : 'Transcribing…' }
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={!ANY_SUPPORTED}
306
+ disabled={!LOCAL_SUPPORTED}
420
307
  onPointerDown={handlePointerDown}
421
308
  onPointerUp={handlePointerUp}
422
309
  onPointerCancel={handlePointerUp}
@@ -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(blob: Blob, onProgress?: (p: ModelProgress) => void): Promise<string> {
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
- const result = await transcriber(pcm)
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> {
@@ -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;