@kpritam/grimoire-output-docusaurus 0.1.8

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/internal/assets.d.ts +9 -0
  7. package/dist/internal/assets.js +50 -0
  8. package/dist/internal/docusaurusConfig.d.ts +9 -0
  9. package/dist/internal/docusaurusConfig.js +259 -0
  10. package/dist/internal/spellbookAssets.d.ts +39 -0
  11. package/dist/internal/spellbookAssets.js +68 -0
  12. package/dist/layer.d.ts +3 -0
  13. package/dist/layer.js +6 -0
  14. package/dist/shared.d.ts +10 -0
  15. package/dist/shared.js +36 -0
  16. package/dist/upstream.d.ts +6 -0
  17. package/dist/upstream.js +84 -0
  18. package/package.json +59 -0
  19. package/src/index.ts +1 -0
  20. package/src/internal/assets.ts +66 -0
  21. package/src/internal/docusaurusConfig.ts +281 -0
  22. package/src/internal/spellbookAssets.ts +80 -0
  23. package/src/layer.ts +12 -0
  24. package/src/shared.ts +43 -0
  25. package/src/upstream.ts +119 -0
  26. package/templates/spellbook/spellbookPlugin.ts +156 -0
  27. package/templates/spellbook/src/components/SpellbookChat/ChatEngine.ts +79 -0
  28. package/templates/spellbook/src/components/SpellbookChat/ChatErrorBoundary.tsx +65 -0
  29. package/templates/spellbook/src/components/SpellbookChat/Markdown.tsx +259 -0
  30. package/templates/spellbook/src/components/SpellbookChat/README.md +111 -0
  31. package/templates/spellbook/src/components/SpellbookChat/SettingsPanel.tsx +376 -0
  32. package/templates/spellbook/src/components/SpellbookChat/VoiceMode.tsx +867 -0
  33. package/templates/spellbook/src/components/SpellbookChat/index.tsx +744 -0
  34. package/templates/spellbook/src/components/SpellbookChat/markdown.module.css +343 -0
  35. package/templates/spellbook/src/components/SpellbookChat/secretStore.ts +106 -0
  36. package/templates/spellbook/src/components/SpellbookChat/streamProviders/anthropic.ts +36 -0
  37. package/templates/spellbook/src/components/SpellbookChat/streamProviders/createCloudProvider.ts +112 -0
  38. package/templates/spellbook/src/components/SpellbookChat/streamProviders/google.ts +33 -0
  39. package/templates/spellbook/src/components/SpellbookChat/streamProviders/index.ts +32 -0
  40. package/templates/spellbook/src/components/SpellbookChat/streamProviders/mapFinishReason.ts +23 -0
  41. package/templates/spellbook/src/components/SpellbookChat/streamProviders/ollama.ts +44 -0
  42. package/templates/spellbook/src/components/SpellbookChat/streamProviders/openai.ts +34 -0
  43. package/templates/spellbook/src/components/SpellbookChat/streamProviders/openaiRealtime.ts +320 -0
  44. package/templates/spellbook/src/components/SpellbookChat/streamProviders/types.ts +172 -0
  45. package/templates/spellbook/src/components/SpellbookChat/streamProviders/webllm.ts +214 -0
  46. package/templates/spellbook/src/components/SpellbookChat/styles.module.css +852 -0
  47. package/templates/spellbook/src/components/SpellbookChat/systemPrompt.ts +107 -0
  48. package/templates/spellbook/src/components/SpellbookChat/transformers-ssr-stub.ts +16 -0
  49. package/templates/spellbook/src/components/SpellbookChat/types.ts +52 -0
  50. package/templates/spellbook/src/components/SpellbookChat/useBundleLoader.ts +46 -0
  51. package/templates/spellbook/src/components/SpellbookChat/useChatEngine.ts +524 -0
  52. package/templates/spellbook/src/components/SpellbookChat/useEmbeddings.ts +147 -0
  53. package/templates/spellbook/src/components/SpellbookChat/useRetrieval.ts +377 -0
  54. package/templates/spellbook/src/components/SpellbookChat/useSileroVAD.ts +236 -0
  55. package/templates/spellbook/src/components/SpellbookChat/useSpeechRecognition.ts +271 -0
  56. package/templates/spellbook/src/components/SpellbookChat/useSpeechSynthesis.ts +229 -0
  57. package/templates/spellbook/src/components/SpellbookChat/useUnifiedSTT.ts +134 -0
  58. package/templates/spellbook/src/components/SpellbookChat/useWhisperSTT.ts +411 -0
  59. package/templates/spellbook/src/components/SpellbookChat/vad-ssr-stub.ts +25 -0
  60. package/templates/spellbook/src/components/SpellbookChat/voiceDebug.ts +60 -0
  61. package/templates/spellbook/src/components/SpellbookChat/voiceFsm.ts +196 -0
  62. package/templates/spellbook/src/components/SpellbookChat/voiceStyles.module.css +334 -0
  63. package/templates/spellbook/src/components/SpellbookChat/webllm-ssr-stub.ts +8 -0
  64. package/templates/spellbook/src/components/SpellbookChatDisabled.tsx +20 -0
  65. package/templates/spellbook/src/theme/Root.tsx +29 -0
@@ -0,0 +1,271 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { transcriptDebug, voiceLog } from "./voiceDebug";
3
+
4
+ /** Narrow surface of the Web Speech API used here (DOM lib may omit these in some TS configs). */
5
+ interface VoiceSpeechRecognitionAlternative {
6
+ readonly transcript: string;
7
+ }
8
+
9
+ interface VoiceSpeechRecognitionResult {
10
+ readonly isFinal: boolean;
11
+ readonly length: number;
12
+ readonly [index: number]: VoiceSpeechRecognitionAlternative;
13
+ }
14
+
15
+ interface VoiceSpeechRecognitionResultList {
16
+ readonly length: number;
17
+ readonly [index: number]: VoiceSpeechRecognitionResult;
18
+ }
19
+
20
+ interface VoiceSpeechRecognitionEvent extends Event {
21
+ readonly resultIndex: number;
22
+ readonly results: VoiceSpeechRecognitionResultList;
23
+ }
24
+
25
+ interface VoiceSpeechRecognitionErrorEvent extends Event {
26
+ readonly error: string;
27
+ readonly message: string;
28
+ }
29
+
30
+ interface VoiceSpeechRecognition extends EventTarget {
31
+ continuous: boolean;
32
+ interimResults: boolean;
33
+ lang: string;
34
+ onresult: ((this: VoiceSpeechRecognition, ev: VoiceSpeechRecognitionEvent) => void) | null;
35
+ onerror:
36
+ | ((this: VoiceSpeechRecognition, ev: VoiceSpeechRecognitionErrorEvent) => void)
37
+ | null;
38
+ onend: ((this: VoiceSpeechRecognition, ev: Event) => void) | null;
39
+ start(): void;
40
+ stop(): void;
41
+ abort(): void;
42
+ }
43
+
44
+ type SpeechRecognitionConstructor = new () => VoiceSpeechRecognition;
45
+
46
+ function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | undefined {
47
+ if (typeof window === "undefined") {
48
+ return undefined;
49
+ }
50
+ const w = window as Window & {
51
+ SpeechRecognition?: SpeechRecognitionConstructor;
52
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
53
+ };
54
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? undefined;
55
+ }
56
+
57
+ /**
58
+ * Fatal native-runtime errors. Once one of these fires the API is effectively
59
+ * dead for this page-load (no network path to Google's speech service, mic
60
+ * blocked at the OS level, etc.) — there's no point retrying it. We surface
61
+ * `unsupported` so the unified hook can transparently fall back to Whisper.
62
+ */
63
+ const FATAL_NATIVE_ERRORS = new Set([
64
+ "network",
65
+ "service-not-allowed",
66
+ "audio-capture",
67
+ ]);
68
+
69
+ export interface UseSpeechRecognitionResult {
70
+ readonly supported: boolean;
71
+ readonly listening: boolean;
72
+ readonly interimTranscript: string;
73
+ readonly finalTranscript: string;
74
+ readonly error: string | null;
75
+ readonly start: (opts?: { lang?: string }) => void;
76
+ /** Graceful stop: the engine still emits any trailing final onresult. */
77
+ readonly stop: () => void;
78
+ /** Hard cancel: drops any pending result. Use on unmount / re-listen. */
79
+ readonly abort: () => void;
80
+ readonly reset: () => void;
81
+ }
82
+
83
+ export function useSpeechRecognition(): UseSpeechRecognitionResult {
84
+ const [listening, setListening] = useState(false);
85
+ const [interimTranscript, setInterimTranscript] = useState("");
86
+ const [finalTranscript, setFinalTranscript] = useState("");
87
+ const [error, setError] = useState<string | null>(null);
88
+ // Sticky once the API errors out fatally — flips `supported` to false so
89
+ // the unified hook can fall back to Whisper for the rest of the session.
90
+ const [runtimeUnsupported, setRuntimeUnsupported] = useState(false);
91
+
92
+ const recognitionRef = useRef<VoiceSpeechRecognition | null>(null);
93
+ // Lazy init avoids ever rendering with `supported: false` on the client.
94
+ // SSR is irrelevant — `Root.tsx` lazy-imports the panel.
95
+ const [hasCtor, setHasCtor] = useState<boolean>(() =>
96
+ Boolean(getSpeechRecognitionCtor()),
97
+ );
98
+
99
+ useEffect(() => {
100
+ const next = Boolean(getSpeechRecognitionCtor());
101
+ setHasCtor((prev) => (prev === next ? prev : next));
102
+ }, []);
103
+
104
+ const supported = hasCtor && !runtimeUnsupported;
105
+
106
+ // Graceful stop — Chrome will still fire one trailing `onresult` (final)
107
+ // and then `onend`. The recognition reference is kept until `onend` so
108
+ // that final result can land in state; only the listening flag flips
109
+ // immediately. Use `abort` if you need to drop the pending result.
110
+ const stop = useCallback(() => {
111
+ const r = recognitionRef.current;
112
+ voiceLog("native.stop", { hasRecognition: Boolean(r) });
113
+ if (!r) {
114
+ setListening(false);
115
+ return;
116
+ }
117
+ try {
118
+ r.stop();
119
+ } catch {
120
+ /* already stopped */
121
+ }
122
+ setListening(false);
123
+ }, []);
124
+
125
+ const abort = useCallback(() => {
126
+ const r = recognitionRef.current;
127
+ voiceLog("native.abort", { hasRecognition: Boolean(r) });
128
+ recognitionRef.current = null;
129
+ try {
130
+ r?.abort();
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ setListening(false);
135
+ }, []);
136
+
137
+ const reset = useCallback(() => {
138
+ voiceLog("native.reset");
139
+ abort();
140
+ setInterimTranscript("");
141
+ setFinalTranscript("");
142
+ setError(null);
143
+ }, [abort]);
144
+
145
+ useEffect(() => {
146
+ return () => {
147
+ const r = recognitionRef.current;
148
+ recognitionRef.current = null;
149
+ try {
150
+ r?.abort();
151
+ } catch {
152
+ /* ignore */
153
+ }
154
+ };
155
+ }, []);
156
+
157
+ const start = useCallback((opts?: { lang?: string }) => {
158
+ const Ctor = getSpeechRecognitionCtor();
159
+ if (!Ctor) {
160
+ voiceLog("native.unsupported");
161
+ return;
162
+ }
163
+ const lang = opts?.lang ?? "en-US";
164
+ voiceLog("native.start", { lang });
165
+ setError(null);
166
+ abort();
167
+
168
+ let recognition: VoiceSpeechRecognition;
169
+ try {
170
+ recognition = new Ctor();
171
+ } catch {
172
+ voiceLog("native.start.error", { message: "constructor failed" });
173
+ setError("Could not start speech recognition.");
174
+ return;
175
+ }
176
+
177
+ recognition.continuous = true;
178
+ recognition.interimResults = true;
179
+ recognition.lang = lang;
180
+
181
+ recognition.onresult = (event: VoiceSpeechRecognitionEvent) => {
182
+ let interim = "";
183
+ let final = "";
184
+
185
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
186
+ const result = event.results[i];
187
+ const piece = result[0]?.transcript ?? "";
188
+ if (result.isFinal) {
189
+ final += piece;
190
+ } else {
191
+ interim += piece;
192
+ }
193
+ }
194
+
195
+ if (final) {
196
+ voiceLog("native.result.final", transcriptDebug(final));
197
+ setFinalTranscript((prev) =>
198
+ prev ? `${prev.trimEnd()} ${final.trim()}` : final.trim(),
199
+ );
200
+ }
201
+ if (interim) {
202
+ voiceLog("native.result.interim", transcriptDebug(interim));
203
+ setInterimTranscript(interim);
204
+ } else if (final) {
205
+ setInterimTranscript("");
206
+ }
207
+ };
208
+
209
+ recognition.onerror = (ev: VoiceSpeechRecognitionErrorEvent) => {
210
+ voiceLog("native.error", {
211
+ error: ev.error,
212
+ message: ev.message,
213
+ });
214
+ if (ev.error === "aborted" || ev.error === "no-speech") {
215
+ return;
216
+ }
217
+ if (ev.error === "not-allowed") {
218
+ setError("Microphone permission denied");
219
+ setListening(false);
220
+ recognitionRef.current = null;
221
+ return;
222
+ }
223
+ if (FATAL_NATIVE_ERRORS.has(ev.error)) {
224
+ // Web Speech API is dead for this session — flip to unsupported so
225
+ // the unified hook switches to Whisper. Don't surface the raw error
226
+ // (the user doesn't care about Google STT internals).
227
+ setRuntimeUnsupported(true);
228
+ setError(null);
229
+ setListening(false);
230
+ recognitionRef.current = null;
231
+ return;
232
+ }
233
+ setError(ev.message || ev.error || "Speech recognition error");
234
+ setListening(false);
235
+ recognitionRef.current = null;
236
+ };
237
+
238
+ recognition.onend = () => {
239
+ voiceLog("native.end");
240
+ if (recognitionRef.current === recognition) {
241
+ recognitionRef.current = null;
242
+ }
243
+ setListening(false);
244
+ };
245
+
246
+ recognitionRef.current = recognition;
247
+ try {
248
+ recognition.start();
249
+ voiceLog("native.started");
250
+ setListening(true);
251
+ setInterimTranscript("");
252
+ } catch {
253
+ voiceLog("native.start.error", { message: "start failed" });
254
+ setError("Could not start speech recognition.");
255
+ recognitionRef.current = null;
256
+ setListening(false);
257
+ }
258
+ }, [abort]);
259
+
260
+ return {
261
+ supported,
262
+ listening,
263
+ interimTranscript,
264
+ finalTranscript,
265
+ error,
266
+ start,
267
+ stop,
268
+ abort,
269
+ reset,
270
+ };
271
+ }
@@ -0,0 +1,229 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Thin, idempotent wrapper around `window.speechSynthesis` with a FIFO queue
5
+ * so streamed answers can be spoken sentence-by-sentence. Guarantees:
6
+ *
7
+ * 1. `cancel()` empties the queue, aborts the live utterance, and resets
8
+ * internal state. Callers that re-enqueue after `cancel()` start a
9
+ * brand new utterance chain.
10
+ * 2. The hook cancels automatically on unmount — no leftover TTS after
11
+ * the chat panel is closed, the mode is switched, or the route changes.
12
+ *
13
+ * Browser quirk: `speechSynthesis.cancel()` fires `onend`/`onerror` on the
14
+ * in-flight utterance which would normally try to advance our queue. We
15
+ * flip `processingRef` to `false` and empty the queue BEFORE calling cancel
16
+ * so the `finish` callback is a no-op on the cancellation path.
17
+ */
18
+ export interface UseSpeechSynthesisResult {
19
+ readonly supported: boolean;
20
+ readonly speaking: boolean;
21
+ readonly voices: readonly SpeechSynthesisVoice[];
22
+ readonly speak: (
23
+ text: string,
24
+ opts?: {
25
+ voice?: SpeechSynthesisVoice;
26
+ rate?: number;
27
+ pitch?: number;
28
+ },
29
+ ) => void;
30
+ readonly cancel: () => void;
31
+ readonly enqueue: (text: string) => void;
32
+ }
33
+
34
+ function pickDefaultVoice(
35
+ list: readonly SpeechSynthesisVoice[],
36
+ ): SpeechSynthesisVoice | undefined {
37
+ const en = list.filter((v) => v.lang.toLowerCase().startsWith("en"));
38
+ const pool = en.length > 0 ? en : [...list];
39
+ const remote = pool.find((v) => v.localService === false);
40
+ if (remote) {
41
+ return remote;
42
+ }
43
+ return pool[0];
44
+ }
45
+
46
+ export function useSpeechSynthesis(): UseSpeechSynthesisResult {
47
+ const supported =
48
+ typeof window !== "undefined" && typeof speechSynthesis !== "undefined";
49
+
50
+ const [voices, setVoices] = useState<readonly SpeechSynthesisVoice[]>([]);
51
+ const [speaking, setSpeaking] = useState(false);
52
+
53
+ const queueRef = useRef<string[]>([]);
54
+ const processingRef = useRef(false);
55
+ const defaultVoiceRef = useRef<SpeechSynthesisVoice | undefined>(undefined);
56
+
57
+ const refreshVoices = useCallback(() => {
58
+ if (!supported) {
59
+ return;
60
+ }
61
+ const v = speechSynthesis.getVoices();
62
+ setVoices(v);
63
+ defaultVoiceRef.current = pickDefaultVoice(v);
64
+ }, [supported]);
65
+
66
+ useEffect(() => {
67
+ if (!supported) {
68
+ return;
69
+ }
70
+ refreshVoices();
71
+ const previous = speechSynthesis.onvoiceschanged;
72
+ const handler: typeof speechSynthesis.onvoiceschanged = () => {
73
+ refreshVoices();
74
+ };
75
+ speechSynthesis.onvoiceschanged = handler;
76
+ return () => {
77
+ if (speechSynthesis.onvoiceschanged === handler) {
78
+ speechSynthesis.onvoiceschanged = previous;
79
+ }
80
+ };
81
+ }, [supported, refreshVoices]);
82
+
83
+ const processQueue = useCallback(() => {
84
+ if (!supported) {
85
+ return;
86
+ }
87
+ if (processingRef.current) {
88
+ return;
89
+ }
90
+ const next = queueRef.current.shift();
91
+ if (!next) {
92
+ setSpeaking(false);
93
+ return;
94
+ }
95
+ processingRef.current = true;
96
+ setSpeaking(true);
97
+
98
+ const utterance = new SpeechSynthesisUtterance(next);
99
+ const voice =
100
+ defaultVoiceRef.current ??
101
+ pickDefaultVoice(speechSynthesis.getVoices());
102
+ if (voice) {
103
+ utterance.voice = voice;
104
+ }
105
+ utterance.lang = voice?.lang ?? "en-US";
106
+
107
+ const finish = (): void => {
108
+ processingRef.current = false;
109
+ if (queueRef.current.length === 0) {
110
+ setSpeaking(false);
111
+ }
112
+ processQueue();
113
+ };
114
+
115
+ utterance.onend = finish;
116
+ utterance.onerror = finish;
117
+
118
+ try {
119
+ speechSynthesis.speak(utterance);
120
+ } catch {
121
+ processingRef.current = false;
122
+ finish();
123
+ }
124
+ }, [supported]);
125
+
126
+ const cancel = useCallback(() => {
127
+ if (!supported) {
128
+ return;
129
+ }
130
+ queueRef.current = [];
131
+ processingRef.current = false;
132
+ try {
133
+ speechSynthesis.cancel();
134
+ } catch {
135
+ // Chromium occasionally throws if cancel races with tab suspension —
136
+ // the subsequent state reset still leaves us in a consistent idle state.
137
+ }
138
+ setSpeaking(false);
139
+ }, [supported]);
140
+
141
+ const speak = useCallback(
142
+ (
143
+ text: string,
144
+ opts?: {
145
+ voice?: SpeechSynthesisVoice;
146
+ rate?: number;
147
+ pitch?: number;
148
+ },
149
+ ) => {
150
+ if (!supported) {
151
+ return;
152
+ }
153
+ cancel();
154
+ const trimmed = text.trim();
155
+ if (!trimmed) {
156
+ return;
157
+ }
158
+ processingRef.current = true;
159
+ setSpeaking(true);
160
+ const utterance = new SpeechSynthesisUtterance(trimmed);
161
+ const voice =
162
+ opts?.voice ??
163
+ defaultVoiceRef.current ??
164
+ pickDefaultVoice(speechSynthesis.getVoices());
165
+ if (voice) {
166
+ utterance.voice = voice;
167
+ }
168
+ utterance.lang = voice?.lang ?? "en-US";
169
+ if (opts?.rate != null) {
170
+ utterance.rate = opts.rate;
171
+ }
172
+ if (opts?.pitch != null) {
173
+ utterance.pitch = opts.pitch;
174
+ }
175
+ const finish = (): void => {
176
+ processingRef.current = false;
177
+ setSpeaking(false);
178
+ };
179
+ utterance.onend = finish;
180
+ utterance.onerror = finish;
181
+ try {
182
+ speechSynthesis.speak(utterance);
183
+ } catch {
184
+ finish();
185
+ }
186
+ },
187
+ [supported, cancel],
188
+ );
189
+
190
+ const enqueue = useCallback(
191
+ (text: string) => {
192
+ if (!supported) {
193
+ return;
194
+ }
195
+ const trimmed = text.trim();
196
+ if (!trimmed) {
197
+ return;
198
+ }
199
+ queueRef.current.push(trimmed);
200
+ processQueue();
201
+ },
202
+ [supported, processQueue],
203
+ );
204
+
205
+ /**
206
+ * Every VoiceMode mount/unmount (panel close, mode switch, route change)
207
+ * must flush `speechSynthesis`; otherwise a live utterance keeps talking
208
+ * long after the UI is gone. Stash cancel in a ref so the cleanup fires
209
+ * exactly once, on unmount.
210
+ */
211
+ const cancelRef = useRef(cancel);
212
+ cancelRef.current = cancel;
213
+ useEffect(() => {
214
+ return () => {
215
+ cancelRef.current();
216
+ };
217
+ }, []);
218
+
219
+ const stableVoices = useMemo(() => voices, [voices]);
220
+
221
+ return {
222
+ supported,
223
+ speaking,
224
+ voices: stableVoices,
225
+ speak,
226
+ cancel,
227
+ enqueue,
228
+ };
229
+ }
@@ -0,0 +1,134 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useSpeechRecognition } from "./useSpeechRecognition";
3
+ import {
4
+ useWhisperSTT,
5
+ type WhisperLoadStatus,
6
+ } from "./useWhisperSTT";
7
+ import { voiceLog } from "./voiceDebug";
8
+
9
+ /**
10
+ * Common shape for native (Web Speech API) and Whisper-fallback STT.
11
+ * Native streams interim + final transcripts; Whisper is single-shot
12
+ * (record → stop → transcript). Whisper-only metadata is reported as
13
+ * `ready` / `1` / `false` in native mode so callers don't need to branch.
14
+ */
15
+ export interface UnifiedSTT {
16
+ readonly mode: "native" | "whisper";
17
+ readonly supported: boolean;
18
+ readonly listening: boolean;
19
+ readonly interimTranscript: string;
20
+ readonly finalTranscript: string;
21
+ readonly error: string | null;
22
+ readonly start: (opts?: { lang?: string }) => void;
23
+ /** Graceful stop — native mode still emits a trailing final transcript. */
24
+ readonly stop: () => void;
25
+ /** Hard cancel — drops any pending transcript. */
26
+ readonly abort: () => void;
27
+ readonly reset: () => void;
28
+ readonly modelLoadStatus: WhisperLoadStatus;
29
+ readonly modelLoadProgress: number;
30
+ readonly transcribing: boolean;
31
+ }
32
+
33
+ /**
34
+ * Prefer the native Web Speech API; fall back to the in-browser Whisper
35
+ * pipeline. Both hooks are always called (hooks must run unconditionally)
36
+ * but only the active one is exercised by `start()`.
37
+ *
38
+ * If the user calls `start()` while native is supported and native then
39
+ * errors fatally (typically `network` because Google's STT service is
40
+ * unreachable), `useSpeechRecognition` flips its `supported` flag and
41
+ * we transparently restart on Whisper. The caller doesn't have to know.
42
+ */
43
+ export function useUnifiedSTT(): UnifiedSTT {
44
+ const native = useSpeechRecognition();
45
+ const whisper = useWhisperSTT();
46
+
47
+ const usingNative = native.supported;
48
+ const wantsListenRef = useRef(false);
49
+ const langRef = useRef<string>("en-US");
50
+
51
+ const start = useCallback(
52
+ (opts?: { lang?: string }) => {
53
+ wantsListenRef.current = true;
54
+ if (opts?.lang) langRef.current = opts.lang;
55
+ voiceLog("stt.start", {
56
+ mode: native.supported ? "native" : "whisper",
57
+ lang: langRef.current,
58
+ });
59
+ if (native.supported) {
60
+ native.start(opts);
61
+ } else {
62
+ whisper.start(opts);
63
+ }
64
+ },
65
+ [native, whisper],
66
+ );
67
+
68
+ const stop = useCallback(() => {
69
+ wantsListenRef.current = false;
70
+ voiceLog("stt.stop", { mode: usingNative ? "native" : "whisper" });
71
+ if (usingNative) native.stop();
72
+ else whisper.stop();
73
+ }, [usingNative, native, whisper]);
74
+
75
+ const abort = useCallback(() => {
76
+ wantsListenRef.current = false;
77
+ voiceLog("stt.abort", { mode: usingNative ? "native" : "whisper" });
78
+ if (usingNative) native.abort();
79
+ else whisper.stop();
80
+ }, [usingNative, native, whisper]);
81
+
82
+ const reset = useCallback(() => {
83
+ wantsListenRef.current = false;
84
+ voiceLog("stt.reset", { mode: usingNative ? "native" : "whisper" });
85
+ if (usingNative) native.reset();
86
+ else whisper.reset();
87
+ }, [usingNative, native, whisper]);
88
+
89
+ /**
90
+ * If we asked native to listen and it died fatally (mode flipped to
91
+ * whisper), kick off whisper so the user doesn't have to tap twice.
92
+ */
93
+ useEffect(() => {
94
+ if (usingNative) return;
95
+ if (!wantsListenRef.current) return;
96
+ if (whisper.listening || whisper.transcribing || whisper.error) return;
97
+ voiceLog("stt.fallback-to-whisper");
98
+ whisper.start({ lang: langRef.current });
99
+ }, [usingNative, whisper]);
100
+
101
+ if (usingNative) {
102
+ return {
103
+ mode: "native",
104
+ supported: true,
105
+ listening: native.listening,
106
+ interimTranscript: native.interimTranscript,
107
+ finalTranscript: native.finalTranscript,
108
+ error: native.error,
109
+ start,
110
+ stop,
111
+ abort,
112
+ reset,
113
+ modelLoadStatus: "ready",
114
+ modelLoadProgress: 1,
115
+ transcribing: false,
116
+ };
117
+ }
118
+
119
+ return {
120
+ mode: "whisper",
121
+ supported: whisper.supported,
122
+ listening: whisper.listening,
123
+ interimTranscript: whisper.interimTranscript,
124
+ finalTranscript: whisper.finalTranscript,
125
+ error: whisper.error,
126
+ start,
127
+ stop,
128
+ abort,
129
+ reset,
130
+ modelLoadStatus: whisper.modelLoadStatus,
131
+ modelLoadProgress: whisper.modelLoadProgress,
132
+ transcribing: whisper.transcribing,
133
+ };
134
+ }