@schlessera/brain-ui-react 0.4.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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/components/chat/ask-user-card.d.ts +12 -0
- package/dist/components/chat/ask-user-card.d.ts.map +1 -0
- package/dist/components/chat/ask-user-card.js +185 -0
- package/dist/components/chat/ask-user-card.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +35 -0
- package/dist/components/chat/brain-markdown.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown.js +363 -0
- package/dist/components/chat/brain-markdown.js.map +1 -0
- package/dist/components/chat/chat-page.d.ts +2 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -0
- package/dist/components/chat/chat-page.js +442 -0
- package/dist/components/chat/chat-page.js.map +1 -0
- package/dist/components/chat/command-palette.d.ts +5 -0
- package/dist/components/chat/command-palette.d.ts.map +1 -0
- package/dist/components/chat/command-palette.js +18 -0
- package/dist/components/chat/command-palette.js.map +1 -0
- package/dist/components/chat/markdown-content.d.ts +4 -0
- package/dist/components/chat/markdown-content.d.ts.map +1 -0
- package/dist/components/chat/markdown-content.js +6 -0
- package/dist/components/chat/markdown-content.js.map +1 -0
- package/dist/components/chat/message-bubble.d.ts +9 -0
- package/dist/components/chat/message-bubble.d.ts.map +1 -0
- package/dist/components/chat/message-bubble.js +129 -0
- package/dist/components/chat/message-bubble.js.map +1 -0
- package/dist/components/chat/message-share.d.ts +10 -0
- package/dist/components/chat/message-share.d.ts.map +1 -0
- package/dist/components/chat/message-share.js +65 -0
- package/dist/components/chat/message-share.js.map +1 -0
- package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
- package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/claude-tools.js +38 -0
- package/dist/components/chat/renderers/claude-tools.js.map +1 -0
- package/dist/components/chat/renderers/generic.d.ts +9 -0
- package/dist/components/chat/renderers/generic.d.ts.map +1 -0
- package/dist/components/chat/renderers/generic.js +68 -0
- package/dist/components/chat/renderers/generic.js.map +1 -0
- package/dist/components/chat/renderers/index.d.ts +5 -0
- package/dist/components/chat/renderers/index.d.ts.map +1 -0
- package/dist/components/chat/renderers/index.js +17 -0
- package/dist/components/chat/renderers/index.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +20 -0
- package/dist/components/chat/risk-hints.d.ts.map +1 -0
- package/dist/components/chat/risk-hints.js +81 -0
- package/dist/components/chat/risk-hints.js.map +1 -0
- package/dist/components/chat/session-drawer.d.ts +6 -0
- package/dist/components/chat/session-drawer.d.ts.map +1 -0
- package/dist/components/chat/session-drawer.js +96 -0
- package/dist/components/chat/session-drawer.js.map +1 -0
- package/dist/components/chat/share-block.d.ts +9 -0
- package/dist/components/chat/share-block.d.ts.map +1 -0
- package/dist/components/chat/share-block.js +70 -0
- package/dist/components/chat/share-block.js.map +1 -0
- package/dist/components/chat/tool-call-timeline.d.ts +8 -0
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
- package/dist/components/chat/tool-call-timeline.js +100 -0
- package/dist/components/chat/tool-call-timeline.js.map +1 -0
- package/dist/components/chat/tool-views.d.ts +116 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -0
- package/dist/components/chat/tool-views.js +628 -0
- package/dist/components/chat/tool-views.js.map +1 -0
- package/dist/components/chat/welcome-state.d.ts +4 -0
- package/dist/components/chat/welcome-state.d.ts.map +1 -0
- package/dist/components/chat/welcome-state.js +10 -0
- package/dist/components/chat/welcome-state.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts +5 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
- package/dist/components/connectivity/connection-gate.js +42 -0
- package/dist/components/connectivity/connection-gate.js.map +1 -0
- package/dist/components/connectivity/login-screen.d.ts +9 -0
- package/dist/components/connectivity/login-screen.d.ts.map +1 -0
- package/dist/components/connectivity/login-screen.js +99 -0
- package/dist/components/connectivity/login-screen.js.map +1 -0
- package/dist/components/files/file-panel.d.ts +5 -0
- package/dist/components/files/file-panel.d.ts.map +1 -0
- package/dist/components/files/file-panel.js +46 -0
- package/dist/components/files/file-panel.js.map +1 -0
- package/dist/components/files/file-tree.d.ts +2 -0
- package/dist/components/files/file-tree.d.ts.map +1 -0
- package/dist/components/files/file-tree.js +63 -0
- package/dist/components/files/file-tree.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts +5 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
- package/dist/components/files/file-viewer-binary.js +20 -0
- package/dist/components/files/file-viewer-binary.js.map +1 -0
- package/dist/components/files/file-viewer-html.d.ts +4 -0
- package/dist/components/files/file-viewer-html.d.ts.map +1 -0
- package/dist/components/files/file-viewer-html.js +5 -0
- package/dist/components/files/file-viewer-html.js.map +1 -0
- package/dist/components/files/file-viewer-markdown.d.ts +4 -0
- package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
- package/dist/components/files/file-viewer-markdown.js +10 -0
- package/dist/components/files/file-viewer-markdown.js.map +1 -0
- package/dist/components/files/file-viewer-raw.d.ts +5 -0
- package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
- package/dist/components/files/file-viewer-raw.js +15 -0
- package/dist/components/files/file-viewer-raw.js.map +1 -0
- package/dist/components/files/file-viewer.d.ts +2 -0
- package/dist/components/files/file-viewer.d.ts.map +1 -0
- package/dist/components/files/file-viewer.js +175 -0
- package/dist/components/files/file-viewer.js.map +1 -0
- package/dist/components/files/frontmatter-panel.d.ts +5 -0
- package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
- package/dist/components/files/frontmatter-panel.js +12 -0
- package/dist/components/files/frontmatter-panel.js.map +1 -0
- package/dist/components/layout/app-shell.d.ts +5 -0
- package/dist/components/layout/app-shell.d.ts.map +1 -0
- package/dist/components/layout/app-shell.js +10 -0
- package/dist/components/layout/app-shell.js.map +1 -0
- package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
- package/dist/components/layout/mobile-tab-bar.js +16 -0
- package/dist/components/layout/mobile-tab-bar.js.map +1 -0
- package/dist/components/layout/side-rail.d.ts +2 -0
- package/dist/components/layout/side-rail.d.ts.map +1 -0
- package/dist/components/layout/side-rail.js +26 -0
- package/dist/components/layout/side-rail.js.map +1 -0
- package/dist/components/layout/slide-panel.d.ts +9 -0
- package/dist/components/layout/slide-panel.d.ts.map +1 -0
- package/dist/components/layout/slide-panel.js +18 -0
- package/dist/components/layout/slide-panel.js.map +1 -0
- package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/streaming-modal.js +92 -0
- package/dist/components/quick-actions/streaming-modal.js.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.js +82 -0
- package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
- package/dist/components/settings/passkey-panel.d.ts +10 -0
- package/dist/components/settings/passkey-panel.d.ts.map +1 -0
- package/dist/components/settings/passkey-panel.js +118 -0
- package/dist/components/settings/passkey-panel.js.map +1 -0
- package/dist/components/share/share-menu.d.ts +23 -0
- package/dist/components/share/share-menu.d.ts.map +1 -0
- package/dist/components/share/share-menu.js +58 -0
- package/dist/components/share/share-menu.js.map +1 -0
- package/dist/components/voice/dictation-sheet.d.ts +6 -0
- package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
- package/dist/components/voice/dictation-sheet.js +91 -0
- package/dist/components/voice/dictation-sheet.js.map +1 -0
- package/dist/components/voice/mic-button.d.ts +6 -0
- package/dist/components/voice/mic-button.d.ts.map +1 -0
- package/dist/components/voice/mic-button.js +9 -0
- package/dist/components/voice/mic-button.js.map +1 -0
- package/dist/components/voice/review-card.d.ts +8 -0
- package/dist/components/voice/review-card.d.ts.map +1 -0
- package/dist/components/voice/review-card.js +8 -0
- package/dist/components/voice/review-card.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +10 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +2 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -0
- package/dist/hooks/use-vpn-status.js +71 -0
- package/dist/hooks/use-vpn-status.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +8 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -0
- package/dist/hooks/use-websocket.js +366 -0
- package/dist/hooks/use-websocket.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-client.d.ts +115 -0
- package/dist/lib/api-client.d.ts.map +1 -0
- package/dist/lib/api-client.js +70 -0
- package/dist/lib/api-client.js.map +1 -0
- package/dist/lib/backend.d.ts +7 -0
- package/dist/lib/backend.d.ts.map +1 -0
- package/dist/lib/backend.js +33 -0
- package/dist/lib/backend.js.map +1 -0
- package/dist/lib/frontmatter.d.ts +21 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +88 -0
- package/dist/lib/frontmatter.js.map +1 -0
- package/dist/lib/image-attachments.d.ts +36 -0
- package/dist/lib/image-attachments.d.ts.map +1 -0
- package/dist/lib/image-attachments.js +175 -0
- package/dist/lib/image-attachments.js.map +1 -0
- package/dist/lib/passkeys.d.ts +18 -0
- package/dist/lib/passkeys.d.ts.map +1 -0
- package/dist/lib/passkeys.js +36 -0
- package/dist/lib/passkeys.js.map +1 -0
- package/dist/lib/share.d.ts +53 -0
- package/dist/lib/share.d.ts.map +1 -0
- package/dist/lib/share.js +144 -0
- package/dist/lib/share.js.map +1 -0
- package/dist/lib/strip-markdown.d.ts +10 -0
- package/dist/lib/strip-markdown.d.ts.map +1 -0
- package/dist/lib/strip-markdown.js +37 -0
- package/dist/lib/strip-markdown.js.map +1 -0
- package/dist/lib/tool-names.d.ts +17 -0
- package/dist/lib/tool-names.d.ts.map +1 -0
- package/dist/lib/tool-names.js +23 -0
- package/dist/lib/tool-names.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/lib/ws-client.d.ts +26 -0
- package/dist/lib/ws-client.d.ts.map +1 -0
- package/dist/lib/ws-client.js +93 -0
- package/dist/lib/ws-client.js.map +1 -0
- package/dist/stores/chat-store.d.ts +131 -0
- package/dist/stores/chat-store.d.ts.map +1 -0
- package/dist/stores/chat-store.js +376 -0
- package/dist/stores/chat-store.js.map +1 -0
- package/dist/stores/connection-store.d.ts +11 -0
- package/dist/stores/connection-store.d.ts.map +1 -0
- package/dist/stores/connection-store.js +8 -0
- package/dist/stores/connection-store.js.map +1 -0
- package/dist/stores/file-store.d.ts +53 -0
- package/dist/stores/file-store.d.ts.map +1 -0
- package/dist/stores/file-store.js +284 -0
- package/dist/stores/file-store.js.map +1 -0
- package/dist/stores/provider-store.d.ts +24 -0
- package/dist/stores/provider-store.d.ts.map +1 -0
- package/dist/stores/provider-store.js +56 -0
- package/dist/stores/provider-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +21 -0
- package/dist/stores/ui-store.d.ts.map +1 -0
- package/dist/stores/ui-store.js +23 -0
- package/dist/stores/ui-store.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/voice/asr-clients.d.ts +2 -0
- package/dist/voice/asr-clients.d.ts.map +1 -0
- package/dist/voice/asr-clients.js +33 -0
- package/dist/voice/asr-clients.js.map +1 -0
- package/dist/voice/asr-deepgram.d.ts +38 -0
- package/dist/voice/asr-deepgram.d.ts.map +1 -0
- package/dist/voice/asr-deepgram.js +307 -0
- package/dist/voice/asr-deepgram.js.map +1 -0
- package/dist/voice/asr-webspeech.d.ts +23 -0
- package/dist/voice/asr-webspeech.d.ts.map +1 -0
- package/dist/voice/asr-webspeech.js +93 -0
- package/dist/voice/asr-webspeech.js.map +1 -0
- package/dist/voice/use-dictation.d.ts +6 -0
- package/dist/voice/use-dictation.d.ts.map +1 -0
- package/dist/voice/use-dictation.js +174 -0
- package/dist/voice/use-dictation.js.map +1 -0
- package/dist/voice/voice-store.d.ts +26 -0
- package/dist/voice/voice-store.d.ts.map +1 -0
- package/dist/voice/voice-store.js +27 -0
- package/dist/voice/voice-store.js.map +1 -0
- package/package.json +71 -0
- package/src/components/chat/ask-user-card.tsx +451 -0
- package/src/components/chat/brain-markdown.tsx +547 -0
- package/src/components/chat/chat-page.tsx +820 -0
- package/src/components/chat/command-palette.tsx +68 -0
- package/src/components/chat/markdown-content.tsx +5 -0
- package/src/components/chat/message-bubble.tsx +362 -0
- package/src/components/chat/message-share.ts +78 -0
- package/src/components/chat/renderers/claude-tools.tsx +53 -0
- package/src/components/chat/renderers/generic.tsx +85 -0
- package/src/components/chat/renderers/index.ts +19 -0
- package/src/components/chat/risk-hints.ts +95 -0
- package/src/components/chat/session-drawer.tsx +213 -0
- package/src/components/chat/share-block.tsx +128 -0
- package/src/components/chat/tool-call-timeline.tsx +322 -0
- package/src/components/chat/tool-views.tsx +898 -0
- package/src/components/chat/welcome-state.tsx +105 -0
- package/src/components/connectivity/connection-gate.tsx +137 -0
- package/src/components/connectivity/login-screen.tsx +169 -0
- package/src/components/files/file-panel.tsx +116 -0
- package/src/components/files/file-tree.tsx +143 -0
- package/src/components/files/file-viewer-binary.tsx +63 -0
- package/src/components/files/file-viewer-html.tsx +10 -0
- package/src/components/files/file-viewer-markdown.tsx +14 -0
- package/src/components/files/file-viewer-raw.tsx +44 -0
- package/src/components/files/file-viewer.tsx +290 -0
- package/src/components/files/frontmatter-panel.tsx +78 -0
- package/src/components/layout/app-shell.tsx +25 -0
- package/src/components/layout/mobile-tab-bar.tsx +54 -0
- package/src/components/layout/side-rail.tsx +128 -0
- package/src/components/layout/slide-panel.tsx +64 -0
- package/src/components/quick-actions/streaming-modal.tsx +168 -0
- package/src/components/quick-actions/whatsup-modal.tsx +149 -0
- package/src/components/settings/passkey-panel.tsx +281 -0
- package/src/components/share/share-menu.tsx +121 -0
- package/src/components/voice/dictation-sheet.tsx +217 -0
- package/src/components/voice/mic-button.tsx +31 -0
- package/src/components/voice/review-card.tsx +66 -0
- package/src/config.ts +27 -0
- package/src/hooks/use-vpn-status.ts +75 -0
- package/src/hooks/use-websocket.ts +403 -0
- package/src/index.ts +46 -0
- package/src/lib/api-client.ts +173 -0
- package/src/lib/backend.ts +35 -0
- package/src/lib/frontmatter.ts +99 -0
- package/src/lib/image-attachments.ts +221 -0
- package/src/lib/passkeys.ts +50 -0
- package/src/lib/share.ts +167 -0
- package/src/lib/strip-markdown.ts +38 -0
- package/src/lib/tool-names.ts +27 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/ws-client.ts +109 -0
- package/src/stores/chat-store.ts +619 -0
- package/src/stores/connection-store.ts +23 -0
- package/src/stores/file-store.ts +344 -0
- package/src/stores/provider-store.ts +85 -0
- package/src/stores/ui-store.ts +48 -0
- package/src/styles.css +4 -0
- package/src/theme.css +425 -0
- package/src/voice/asr-clients.ts +45 -0
- package/src/voice/asr-deepgram.ts +325 -0
- package/src/voice/asr-webspeech.ts +135 -0
- package/src/voice/use-dictation.ts +187 -0
- package/src/voice/voice-store.ts +55 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Deepgram streaming ASR client.
|
|
2
|
+
// Captures mic audio with MediaRecorder (Opus) and pipes it to Deepgram's
|
|
3
|
+
// streaming endpoint via WebSocket. The full connect URL (base params + domain
|
|
4
|
+
// keyterms) and a short-lived token are minted server-side and injected here;
|
|
5
|
+
// this client just opens the socket and emits partial + final transcript
|
|
6
|
+
// events. Implements the @schlessera/brain-ui-sdk AsrClient surface.
|
|
7
|
+
|
|
8
|
+
import type { AsrClient } from "@schlessera/brain-ui-sdk/client";
|
|
9
|
+
import type { AsrEvent } from "@schlessera/brain-ui-sdk/protocol";
|
|
10
|
+
|
|
11
|
+
export interface DeepgramClientOptions {
|
|
12
|
+
/** Full Deepgram streaming WS URL (base params + keyterms), from the server. */
|
|
13
|
+
url: string;
|
|
14
|
+
/** Short-lived Deepgram token, from the server. */
|
|
15
|
+
token: string;
|
|
16
|
+
onEvent: (evt: AsrEvent) => void;
|
|
17
|
+
onAudioLevel?: (level: number) => void;
|
|
18
|
+
onError?: (err: Error) => void;
|
|
19
|
+
onClose?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class DeepgramClient implements AsrClient {
|
|
23
|
+
private ws: WebSocket | null = null;
|
|
24
|
+
private recorder: MediaRecorder | null = null;
|
|
25
|
+
private stream: MediaStream | null = null;
|
|
26
|
+
private audioContext: AudioContext | null = null;
|
|
27
|
+
private analyser: AnalyserNode | null = null;
|
|
28
|
+
private levelRaf: number | null = null;
|
|
29
|
+
private opts: DeepgramClientOptions;
|
|
30
|
+
// Audio chunks captured before the Deepgram socket opens. The recorder starts
|
|
31
|
+
// as soon as the mic is granted; this holds the head of the utterance until
|
|
32
|
+
// it can be flushed on ws.onopen.
|
|
33
|
+
private pending: Blob[] = [];
|
|
34
|
+
private closed = false;
|
|
35
|
+
private drainResolve: (() => void) | null = null;
|
|
36
|
+
private wakeLock: WakeLockSentinel | null = null;
|
|
37
|
+
private wakeLockHandler: (() => void) | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(opts: DeepgramClientOptions) {
|
|
40
|
+
this.opts = opts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async start(): Promise<void> {
|
|
44
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
45
|
+
audio: {
|
|
46
|
+
echoCancellation: true,
|
|
47
|
+
noiseSuppression: true,
|
|
48
|
+
channelCount: 1,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
// Stopped while the mic permission was resolving? Release the freshly
|
|
52
|
+
// granted stream now — otherwise it stays live with no reference left to
|
|
53
|
+
// stop it (cleanup() already ran before the stream existed).
|
|
54
|
+
if (this.closed) {
|
|
55
|
+
for (const track of stream.getTracks()) track.stop();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.stream = stream;
|
|
59
|
+
|
|
60
|
+
void this.acquireWakeLock();
|
|
61
|
+
this.startAudioLevelMeter(this.stream);
|
|
62
|
+
|
|
63
|
+
// Record from the moment the mic is live — chunks buffer in `pending` until
|
|
64
|
+
// the socket opens, so connect latency doesn't eat the first words.
|
|
65
|
+
this.startRecorder();
|
|
66
|
+
|
|
67
|
+
if (this.closed) return;
|
|
68
|
+
|
|
69
|
+
// Token is passed as a sec-websocket-protocol header per Deepgram spec.
|
|
70
|
+
this.ws = new WebSocket(this.opts.url, ["token", this.opts.token]);
|
|
71
|
+
|
|
72
|
+
this.ws.onopen = () => {
|
|
73
|
+
this.flushPending();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.ws.onmessage = (evt) => {
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(evt.data);
|
|
79
|
+
this.handleDeepgramMessage(data);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
this.opts.onError?.(
|
|
82
|
+
err instanceof Error ? err : new Error("Bad Deepgram message")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.ws.onerror = () => {
|
|
88
|
+
this.opts.onError?.(new Error("Deepgram socket error"));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
this.ws.onclose = () => {
|
|
92
|
+
this.cleanup();
|
|
93
|
+
if (!this.closed) this.opts.onClose?.();
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private startRecorder() {
|
|
98
|
+
if (!this.stream) return;
|
|
99
|
+
const mimeType = pickOpusMime();
|
|
100
|
+
this.recorder = new MediaRecorder(this.stream, {
|
|
101
|
+
mimeType,
|
|
102
|
+
audioBitsPerSecond: 32000,
|
|
103
|
+
});
|
|
104
|
+
this.recorder.ondataavailable = (evt) => {
|
|
105
|
+
if (evt.data.size === 0) return;
|
|
106
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
107
|
+
this.ws.send(evt.data);
|
|
108
|
+
} else {
|
|
109
|
+
this.pending.push(evt.data);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
// 250ms chunks — small enough to feel live, big enough to be Opus-frame-aligned
|
|
113
|
+
this.recorder.start(250);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private flushPending() {
|
|
117
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
118
|
+
for (const chunk of this.pending) this.ws.send(chunk);
|
|
119
|
+
this.pending = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private handleDeepgramMessage(data: any) {
|
|
123
|
+
if (data.type === "Results") {
|
|
124
|
+
const alt = data.channel?.alternatives?.[0];
|
|
125
|
+
const transcript: string = alt?.transcript ?? "";
|
|
126
|
+
if (data.is_final) {
|
|
127
|
+
if (transcript) {
|
|
128
|
+
this.opts.onEvent({
|
|
129
|
+
type: "final",
|
|
130
|
+
text: transcript,
|
|
131
|
+
endsTurn: !!data.speech_final,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// If we are draining, this final settles the wait — but allow more
|
|
135
|
+
// arrivals up to the deadline. We resolve on Metadata or timeout.
|
|
136
|
+
} else if (transcript) {
|
|
137
|
+
this.opts.onEvent({ type: "partial", text: transcript });
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (data.type === "UtteranceEnd") {
|
|
142
|
+
this.opts.onEvent({ type: "final", text: "", endsTurn: true });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (data.type === "Metadata") {
|
|
146
|
+
// Final summary — Deepgram sends this when the stream is closing.
|
|
147
|
+
this.drainResolve?.();
|
|
148
|
+
this.drainResolve = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Keep the screen on for the duration of capture so the phone doesn't sleep
|
|
154
|
+
// mid-sentence and clip the audio. The lock is released when the page becomes
|
|
155
|
+
// hidden, so re-acquire on visibility return.
|
|
156
|
+
private async acquireWakeLock(): Promise<void> {
|
|
157
|
+
if (typeof navigator === "undefined" || !("wakeLock" in navigator)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
this.wakeLock = await navigator.wakeLock.request("screen");
|
|
162
|
+
this.wakeLock.addEventListener("release", () => {
|
|
163
|
+
this.wakeLock = null;
|
|
164
|
+
});
|
|
165
|
+
if (!this.wakeLockHandler) {
|
|
166
|
+
this.wakeLockHandler = () => {
|
|
167
|
+
if (
|
|
168
|
+
document.visibilityState === "visible" &&
|
|
169
|
+
!this.closed &&
|
|
170
|
+
!this.wakeLock
|
|
171
|
+
) {
|
|
172
|
+
void this.acquireWakeLock();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
document.addEventListener("visibilitychange", this.wakeLockHandler);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn("[voice] Wake lock request failed:", err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private releaseWakeLock(): void {
|
|
183
|
+
if (this.wakeLockHandler) {
|
|
184
|
+
document.removeEventListener("visibilitychange", this.wakeLockHandler);
|
|
185
|
+
this.wakeLockHandler = null;
|
|
186
|
+
}
|
|
187
|
+
if (this.wakeLock) {
|
|
188
|
+
this.wakeLock.release().catch(() => {});
|
|
189
|
+
this.wakeLock = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private startAudioLevelMeter(stream: MediaStream) {
|
|
194
|
+
if (!this.opts.onAudioLevel) return;
|
|
195
|
+
try {
|
|
196
|
+
const ctx = new AudioContext();
|
|
197
|
+
const src = ctx.createMediaStreamSource(stream);
|
|
198
|
+
const analyser = ctx.createAnalyser();
|
|
199
|
+
analyser.fftSize = 512;
|
|
200
|
+
src.connect(analyser);
|
|
201
|
+
this.audioContext = ctx;
|
|
202
|
+
this.analyser = analyser;
|
|
203
|
+
const buf = new Uint8Array(analyser.frequencyBinCount);
|
|
204
|
+
const tick = () => {
|
|
205
|
+
if (!this.analyser) return;
|
|
206
|
+
this.analyser.getByteTimeDomainData(buf);
|
|
207
|
+
let sum = 0;
|
|
208
|
+
for (let i = 0; i < buf.length; i++) {
|
|
209
|
+
const v = (buf[i] - 128) / 128;
|
|
210
|
+
sum += v * v;
|
|
211
|
+
}
|
|
212
|
+
const rms = Math.sqrt(sum / buf.length);
|
|
213
|
+
this.opts.onAudioLevel?.(Math.min(1, rms * 4));
|
|
214
|
+
this.levelRaf = requestAnimationFrame(tick);
|
|
215
|
+
};
|
|
216
|
+
tick();
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.warn("[voice] Audio level meter failed:", err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
stop() {
|
|
223
|
+
this.closed = true;
|
|
224
|
+
this.cleanup();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Graceful stop: tells Deepgram to flush any buffered audio, waits for the
|
|
228
|
+
// remaining final results, then closes. Use this for "Done" — it preserves
|
|
229
|
+
// the tail of an utterance that hasn't been finalized yet.
|
|
230
|
+
async drainAndStop(maxWaitMs = 2000): Promise<void> {
|
|
231
|
+
this.closed = true;
|
|
232
|
+
if (this.recorder && this.recorder.state === "recording") {
|
|
233
|
+
try {
|
|
234
|
+
this.recorder.stop();
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
// If the socket is still connecting, give it a moment to open so the
|
|
238
|
+
// buffered head of the utterance can still be delivered and transcribed.
|
|
239
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
240
|
+
await new Promise<void>((resolve) => {
|
|
241
|
+
const timer = setTimeout(resolve, maxWaitMs);
|
|
242
|
+
const settle = () => {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
resolve();
|
|
245
|
+
};
|
|
246
|
+
this.ws!.addEventListener("open", settle, { once: true });
|
|
247
|
+
this.ws!.addEventListener("close", settle, { once: true });
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
251
|
+
this.cleanup();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.flushPending();
|
|
255
|
+
await new Promise<void>((resolve) => {
|
|
256
|
+
let done = false;
|
|
257
|
+
const finish = () => {
|
|
258
|
+
if (done) return;
|
|
259
|
+
done = true;
|
|
260
|
+
this.drainResolve = null;
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
resolve();
|
|
263
|
+
};
|
|
264
|
+
const timer = setTimeout(finish, maxWaitMs);
|
|
265
|
+
this.drainResolve = finish;
|
|
266
|
+
try {
|
|
267
|
+
// Finalize emits final results for buffered audio without closing.
|
|
268
|
+
this.ws!.send(JSON.stringify({ type: "Finalize" }));
|
|
269
|
+
// CloseStream then triggers a final Metadata message and closes.
|
|
270
|
+
this.ws!.send(JSON.stringify({ type: "CloseStream" }));
|
|
271
|
+
} catch {
|
|
272
|
+
finish();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
this.cleanup();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private cleanup() {
|
|
279
|
+
this.releaseWakeLock();
|
|
280
|
+
if (this.levelRaf !== null) {
|
|
281
|
+
cancelAnimationFrame(this.levelRaf);
|
|
282
|
+
this.levelRaf = null;
|
|
283
|
+
}
|
|
284
|
+
if (this.recorder && this.recorder.state !== "inactive") {
|
|
285
|
+
try {
|
|
286
|
+
this.recorder.stop();
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
this.recorder = null;
|
|
290
|
+
this.pending = [];
|
|
291
|
+
if (this.stream) {
|
|
292
|
+
for (const track of this.stream.getTracks()) track.stop();
|
|
293
|
+
this.stream = null;
|
|
294
|
+
}
|
|
295
|
+
if (this.audioContext) {
|
|
296
|
+
this.audioContext.close().catch(() => {});
|
|
297
|
+
this.audioContext = null;
|
|
298
|
+
}
|
|
299
|
+
this.analyser = null;
|
|
300
|
+
if (this.ws && this.ws.readyState <= WebSocket.OPEN) {
|
|
301
|
+
try {
|
|
302
|
+
// Send Deepgram's CloseStream control message before closing
|
|
303
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
304
|
+
this.ws.send(JSON.stringify({ type: "CloseStream" }));
|
|
305
|
+
}
|
|
306
|
+
this.ws.close();
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
this.ws = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function pickOpusMime(): string {
|
|
314
|
+
const candidates = [
|
|
315
|
+
"audio/webm;codecs=opus",
|
|
316
|
+
"audio/ogg;codecs=opus",
|
|
317
|
+
"audio/webm",
|
|
318
|
+
];
|
|
319
|
+
for (const c of candidates) {
|
|
320
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(c)) {
|
|
321
|
+
return c;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return "audio/webm";
|
|
325
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Web Speech API ASR client — the `webspeech` provider. Recognition runs
|
|
2
|
+
// entirely in the browser (no server session, no token), so this is the
|
|
3
|
+
// zero-dependency / zero-cost STT path. Availability varies by browser
|
|
4
|
+
// (Chrome/Edge yes, Firefox no), so callers must guard with
|
|
5
|
+
// isWebSpeechAvailable() before selecting it.
|
|
6
|
+
|
|
7
|
+
import type { AsrClient } from "@schlessera/brain-ui-sdk/client";
|
|
8
|
+
import type { AsrEvent } from "@schlessera/brain-ui-sdk/protocol";
|
|
9
|
+
|
|
10
|
+
// The Web Speech API isn't in the standard TS DOM lib; declare the minimal
|
|
11
|
+
// surface we use.
|
|
12
|
+
interface SpeechRecognitionAlternativeLike {
|
|
13
|
+
transcript: string;
|
|
14
|
+
}
|
|
15
|
+
interface SpeechRecognitionResultLike {
|
|
16
|
+
isFinal: boolean;
|
|
17
|
+
0: SpeechRecognitionAlternativeLike;
|
|
18
|
+
}
|
|
19
|
+
interface SpeechRecognitionEventLike {
|
|
20
|
+
resultIndex: number;
|
|
21
|
+
results: ArrayLike<SpeechRecognitionResultLike>;
|
|
22
|
+
}
|
|
23
|
+
interface SpeechRecognitionLike {
|
|
24
|
+
lang: string;
|
|
25
|
+
continuous: boolean;
|
|
26
|
+
interimResults: boolean;
|
|
27
|
+
start(): void;
|
|
28
|
+
stop(): void;
|
|
29
|
+
abort(): void;
|
|
30
|
+
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
|
31
|
+
onerror: ((event: { error?: string }) => void) | null;
|
|
32
|
+
onend: (() => void) | null;
|
|
33
|
+
}
|
|
34
|
+
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
|
35
|
+
|
|
36
|
+
function getRecognitionCtor(): SpeechRecognitionCtor | null {
|
|
37
|
+
if (typeof window === "undefined") return null;
|
|
38
|
+
const w = window as unknown as {
|
|
39
|
+
SpeechRecognition?: SpeechRecognitionCtor;
|
|
40
|
+
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
|
41
|
+
};
|
|
42
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when this browser supports the Web Speech API. */
|
|
46
|
+
export function isWebSpeechAvailable(): boolean {
|
|
47
|
+
return getRecognitionCtor() !== null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WebSpeechClientOptions {
|
|
51
|
+
onEvent: (evt: AsrEvent) => void;
|
|
52
|
+
onError?: (err: Error) => void;
|
|
53
|
+
onEnd?: () => void;
|
|
54
|
+
/** BCP-47 language tag. Defaults to "en-US". */
|
|
55
|
+
lang?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class WebSpeechClient implements AsrClient {
|
|
59
|
+
private recognition: SpeechRecognitionLike | null = null;
|
|
60
|
+
private opts: WebSpeechClientOptions;
|
|
61
|
+
private closed = false;
|
|
62
|
+
private endResolvers: Array<() => void> = [];
|
|
63
|
+
|
|
64
|
+
constructor(opts: WebSpeechClientOptions) {
|
|
65
|
+
this.opts = opts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async start(): Promise<void> {
|
|
69
|
+
const Ctor = getRecognitionCtor();
|
|
70
|
+
if (!Ctor) {
|
|
71
|
+
throw new Error("Web Speech API is not available in this browser.");
|
|
72
|
+
}
|
|
73
|
+
const recognition = new Ctor();
|
|
74
|
+
recognition.lang = this.opts.lang ?? "en-US";
|
|
75
|
+
recognition.continuous = true;
|
|
76
|
+
recognition.interimResults = true;
|
|
77
|
+
|
|
78
|
+
recognition.onresult = (event) => {
|
|
79
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
80
|
+
const result = event.results[i];
|
|
81
|
+
const transcript = result[0]?.transcript ?? "";
|
|
82
|
+
if (!transcript) continue;
|
|
83
|
+
if (result.isFinal) {
|
|
84
|
+
// No reliable end-of-utterance signal, so never auto-end the turn.
|
|
85
|
+
this.opts.onEvent({ type: "final", text: transcript, endsTurn: false });
|
|
86
|
+
} else {
|
|
87
|
+
this.opts.onEvent({ type: "partial", text: transcript });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
recognition.onerror = (event) => {
|
|
93
|
+
if (this.closed) return;
|
|
94
|
+
this.opts.onError?.(new Error(`Speech recognition error: ${event.error ?? "unknown"}`));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
recognition.onend = () => {
|
|
98
|
+
this.settleEnd();
|
|
99
|
+
if (!this.closed) this.opts.onEnd?.();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
this.recognition = recognition;
|
|
103
|
+
recognition.start();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stop() {
|
|
107
|
+
this.closed = true;
|
|
108
|
+
try {
|
|
109
|
+
this.recognition?.abort();
|
|
110
|
+
} catch {}
|
|
111
|
+
this.recognition = null;
|
|
112
|
+
this.settleEnd();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async drainAndStop(): Promise<void> {
|
|
116
|
+
this.closed = true;
|
|
117
|
+
if (!this.recognition) return;
|
|
118
|
+
// stop() flushes the pending final result, then fires onend.
|
|
119
|
+
const done = new Promise<void>((resolve) => this.endResolvers.push(resolve));
|
|
120
|
+
try {
|
|
121
|
+
this.recognition.stop();
|
|
122
|
+
} catch {
|
|
123
|
+
this.settleEnd();
|
|
124
|
+
}
|
|
125
|
+
// Bound the wait so a browser that never fires onend can't hang the UI.
|
|
126
|
+
await Promise.race([done, new Promise<void>((r) => setTimeout(r, 1500))]);
|
|
127
|
+
this.recognition = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private settleEnd() {
|
|
131
|
+
const resolvers = this.endResolvers;
|
|
132
|
+
this.endResolvers = [];
|
|
133
|
+
for (const resolve of resolvers) resolve();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useVoiceStore } from "./voice-store.js";
|
|
3
|
+
import {
|
|
4
|
+
createAsrClient,
|
|
5
|
+
speechUiHints,
|
|
6
|
+
type AsrClient,
|
|
7
|
+
} from "@schlessera/brain-ui-sdk/client";
|
|
8
|
+
import type { PronunciationOverride } from "@schlessera/brain-ui-sdk/protocol";
|
|
9
|
+
import { api } from "../lib/api-client.js";
|
|
10
|
+
import { registerAsrClients } from "./asr-clients.js";
|
|
11
|
+
|
|
12
|
+
// Register the built-in ASR clients once at module load.
|
|
13
|
+
registerAsrClients();
|
|
14
|
+
|
|
15
|
+
/** Apply pronunciation overrides client-side to a finalized transcript. */
|
|
16
|
+
function applyOverrides(
|
|
17
|
+
text: string,
|
|
18
|
+
overrides: PronunciationOverride[]
|
|
19
|
+
): string {
|
|
20
|
+
if (overrides.length === 0) return text;
|
|
21
|
+
let out = text;
|
|
22
|
+
for (const { match, replacement } of overrides) {
|
|
23
|
+
if (!match) continue;
|
|
24
|
+
const escaped = match.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
out = out.replace(new RegExp(`\\b${escaped}\\b`, "gi"), replacement);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useDictation() {
|
|
31
|
+
const clientRef = useRef<AsrClient | null>(null);
|
|
32
|
+
const overridesRef = useRef<PronunciationOverride[]>([]);
|
|
33
|
+
// Bumped on every start() and on every stop/cancel/unmount. A start that
|
|
34
|
+
// discovers its generation has been superseded aborts instead of opening
|
|
35
|
+
// (or continuing to hold) the mic — this closes the window where a slow
|
|
36
|
+
// session fetch would open a MediaStream after the user already stopped.
|
|
37
|
+
const startGenRef = useRef(0);
|
|
38
|
+
// AbortController for the in-flight session fetch, so stop/cancel/unmount
|
|
39
|
+
// can actually cancel a pending connect rather than just ignoring its result.
|
|
40
|
+
const sessionAbortRef = useRef<AbortController | null>(null);
|
|
41
|
+
const setMode = useVoiceStore((s) => s.setMode);
|
|
42
|
+
const setConnecting = useVoiceStore((s) => s.setConnecting);
|
|
43
|
+
const setProviderId = useVoiceStore((s) => s.setProviderId);
|
|
44
|
+
const setPartial = useVoiceStore((s) => s.setPartial);
|
|
45
|
+
const appendFinal = useVoiceStore((s) => s.appendFinal);
|
|
46
|
+
const setError = useVoiceStore((s) => s.setError);
|
|
47
|
+
const resetCapture = useVoiceStore((s) => s.resetCapture);
|
|
48
|
+
const setReviewText = useVoiceStore((s) => s.setReviewText);
|
|
49
|
+
|
|
50
|
+
const start = useCallback(async () => {
|
|
51
|
+
const gen = ++startGenRef.current;
|
|
52
|
+
resetCapture();
|
|
53
|
+
setProviderId(null);
|
|
54
|
+
// Show the sheet immediately, but as "connecting" — the mic is still shut
|
|
55
|
+
// during the session fetch, so we must not present a live "Listening" state
|
|
56
|
+
// (or its pulsing dot) until capture is actually open.
|
|
57
|
+
setConnecting(true);
|
|
58
|
+
setMode("dictate");
|
|
59
|
+
|
|
60
|
+
const abort = new AbortController();
|
|
61
|
+
sessionAbortRef.current = abort;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Fetch the dictation session + pronunciation overrides. The AsrClient's
|
|
65
|
+
// start() opens the mic and buffers audio until its stream is ready, so
|
|
66
|
+
// words spoken during connect are not lost.
|
|
67
|
+
const [session, ov] = await Promise.all([
|
|
68
|
+
api.voiceSession(abort.signal),
|
|
69
|
+
api
|
|
70
|
+
.voiceOverrides(abort.signal)
|
|
71
|
+
.catch(() => ({ overrides: [] as PronunciationOverride[] })),
|
|
72
|
+
]);
|
|
73
|
+
// Superseded by a Stop/Escape/backdrop/unmount while the session was in
|
|
74
|
+
// flight? Bail before opening the mic — a MediaStream created here would
|
|
75
|
+
// have no reference left to stop it.
|
|
76
|
+
if (startGenRef.current !== gen) return;
|
|
77
|
+
if (sessionAbortRef.current === abort) sessionAbortRef.current = null;
|
|
78
|
+
overridesRef.current = ov.overrides;
|
|
79
|
+
// Surface which provider actually served this session (e.g. a silent
|
|
80
|
+
// fallback to browser speech) — see the sheet's provider indicator.
|
|
81
|
+
setProviderId(session.providerId);
|
|
82
|
+
|
|
83
|
+
const hints = speechUiHints(session.capabilities);
|
|
84
|
+
const client = createAsrClient({
|
|
85
|
+
session,
|
|
86
|
+
onEvent: (evt) => {
|
|
87
|
+
if (evt.type === "partial") {
|
|
88
|
+
// Providers without interim results skip live partials.
|
|
89
|
+
if (hints.showPartials) {
|
|
90
|
+
setPartial(applyOverrides(evt.text, overridesRef.current));
|
|
91
|
+
}
|
|
92
|
+
} else if (evt.type === "final" && evt.text) {
|
|
93
|
+
appendFinal(applyOverrides(evt.text, overridesRef.current));
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
onError: (err) => setError(err.message),
|
|
97
|
+
});
|
|
98
|
+
// Publish the client before awaiting start() so a concurrent stop() can
|
|
99
|
+
// grab and close it (releasing its MediaStream) while the mic is opening.
|
|
100
|
+
clientRef.current = client;
|
|
101
|
+
await client.start();
|
|
102
|
+
// Stopped while the mic was opening? Close the client we just started —
|
|
103
|
+
// its start() honors the closed flag and releases the stream.
|
|
104
|
+
if (startGenRef.current !== gen) {
|
|
105
|
+
client.stop();
|
|
106
|
+
if (clientRef.current === client) clientRef.current = null;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Capture is live now — drop the connecting state so the sheet can show
|
|
110
|
+
// the honest "Listening" indicator.
|
|
111
|
+
setConnecting(false);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// A superseded start failing (e.g. the aborted session fetch) is
|
|
114
|
+
// expected — swallow it so it doesn't surface a spurious error.
|
|
115
|
+
if (startGenRef.current !== gen) return;
|
|
116
|
+
if (sessionAbortRef.current === abort) sessionAbortRef.current = null;
|
|
117
|
+
setError(err instanceof Error ? err.message : "Voice start failed");
|
|
118
|
+
setConnecting(false);
|
|
119
|
+
setMode("idle");
|
|
120
|
+
}
|
|
121
|
+
}, [
|
|
122
|
+
resetCapture,
|
|
123
|
+
setMode,
|
|
124
|
+
setConnecting,
|
|
125
|
+
setProviderId,
|
|
126
|
+
setPartial,
|
|
127
|
+
appendFinal,
|
|
128
|
+
setError,
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const stop = useCallback(
|
|
132
|
+
async (commitToReview = true) => {
|
|
133
|
+
// Invalidate any in-flight start and cancel its session fetch, so a slow
|
|
134
|
+
// connect can't open the mic after the user has asked it to stop.
|
|
135
|
+
startGenRef.current++;
|
|
136
|
+
sessionAbortRef.current?.abort();
|
|
137
|
+
sessionAbortRef.current = null;
|
|
138
|
+
|
|
139
|
+
const client = clientRef.current;
|
|
140
|
+
clientRef.current = null;
|
|
141
|
+
|
|
142
|
+
const setDraining = useVoiceStore.getState().setDraining;
|
|
143
|
+
if (client) {
|
|
144
|
+
if (commitToReview) {
|
|
145
|
+
setDraining(true);
|
|
146
|
+
try {
|
|
147
|
+
await client.drainAndStop();
|
|
148
|
+
} finally {
|
|
149
|
+
setDraining(false);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
client.stop();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { finalText, partial, reviewText } = useVoiceStore.getState();
|
|
157
|
+
const merged = [finalText, partial].filter(Boolean).join(" ").trim();
|
|
158
|
+
setConnecting(false);
|
|
159
|
+
setMode("idle");
|
|
160
|
+
if (commitToReview && merged) {
|
|
161
|
+
// Append to any text already under review so a prompt can be built up
|
|
162
|
+
// across multiple record/edit rounds without losing earlier takes.
|
|
163
|
+
setReviewText(reviewText ? `${reviewText} ${merged}` : merged);
|
|
164
|
+
}
|
|
165
|
+
resetCapture();
|
|
166
|
+
},
|
|
167
|
+
[resetCapture, setMode, setConnecting, setReviewText]
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const cancel = useCallback(() => {
|
|
171
|
+
void stop(false);
|
|
172
|
+
}, [stop]);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
return () => {
|
|
176
|
+
// Tear down on unmount: invalidate the in-flight start, abort its fetch,
|
|
177
|
+
// and stop the client so no MediaStream survives the component.
|
|
178
|
+
startGenRef.current++;
|
|
179
|
+
sessionAbortRef.current?.abort();
|
|
180
|
+
sessionAbortRef.current = null;
|
|
181
|
+
clientRef.current?.stop();
|
|
182
|
+
clientRef.current = null;
|
|
183
|
+
};
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
return { start, stop, cancel };
|
|
187
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import type { VoiceMode } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
|
+
|
|
4
|
+
interface VoiceState {
|
|
5
|
+
mode: VoiceMode;
|
|
6
|
+
connecting: boolean; // session fetch / mic open in progress (mic not yet live)
|
|
7
|
+
draining: boolean; // post-Done flush in progress
|
|
8
|
+
partial: string;
|
|
9
|
+
finalText: string; // accumulated final transcript for current capture
|
|
10
|
+
audioLevel: number; // 0..1 RMS level for waveform
|
|
11
|
+
reviewText: string; // text waiting in the review card (post-stop)
|
|
12
|
+
providerId: string | null; // active speech provider (from the session response)
|
|
13
|
+
error: string | null;
|
|
14
|
+
|
|
15
|
+
setMode: (mode: VoiceMode) => void;
|
|
16
|
+
setConnecting: (connecting: boolean) => void;
|
|
17
|
+
setDraining: (draining: boolean) => void;
|
|
18
|
+
setPartial: (text: string) => void;
|
|
19
|
+
appendFinal: (text: string) => void;
|
|
20
|
+
setAudioLevel: (level: number) => void;
|
|
21
|
+
setReviewText: (text: string) => void;
|
|
22
|
+
clearReview: () => void;
|
|
23
|
+
setProviderId: (providerId: string | null) => void;
|
|
24
|
+
setError: (err: string | null) => void;
|
|
25
|
+
resetCapture: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useVoiceStore = create<VoiceState>((set) => ({
|
|
29
|
+
mode: "idle",
|
|
30
|
+
connecting: false,
|
|
31
|
+
draining: false,
|
|
32
|
+
partial: "",
|
|
33
|
+
finalText: "",
|
|
34
|
+
audioLevel: 0,
|
|
35
|
+
reviewText: "",
|
|
36
|
+
providerId: null,
|
|
37
|
+
error: null,
|
|
38
|
+
|
|
39
|
+
setMode: (mode) => set({ mode }),
|
|
40
|
+
setConnecting: (connecting) => set({ connecting }),
|
|
41
|
+
setDraining: (draining) => set({ draining }),
|
|
42
|
+
setPartial: (partial) => set({ partial }),
|
|
43
|
+
appendFinal: (text) =>
|
|
44
|
+
set((s) => ({
|
|
45
|
+
finalText: (s.finalText ? s.finalText + " " : "") + text.trim(),
|
|
46
|
+
partial: "",
|
|
47
|
+
})),
|
|
48
|
+
setAudioLevel: (audioLevel) => set({ audioLevel }),
|
|
49
|
+
setReviewText: (reviewText) => set({ reviewText }),
|
|
50
|
+
clearReview: () => set({ reviewText: "" }),
|
|
51
|
+
setProviderId: (providerId) => set({ providerId }),
|
|
52
|
+
setError: (error) => set({ error }),
|
|
53
|
+
resetCapture: () =>
|
|
54
|
+
set({ partial: "", finalText: "", audioLevel: 0, error: null }),
|
|
55
|
+
}));
|