@epicbagel/saas-kit 1.11.0 → 1.12.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/README.md +34 -12
- package/dist/{chunk-ZZ7ICQR2.cjs → chunk-7Q4EFCUI.cjs} +93 -28
- package/dist/chunk-JKVVTFAP.js +294 -0
- package/dist/{chunk-UOVA4YKW.js → chunk-KF4X37DP.js} +2 -15
- package/dist/chunk-QWUMIAYD.cjs +294 -0
- package/dist/{chunk-HLAHOVEI.js → chunk-RPSRCB5O.js} +79 -14
- package/dist/{chunk-7QTFYEPE.cjs → chunk-YMI3X2YL.cjs} +2 -15
- package/dist/hooks/use-dictation.cjs +2 -2
- package/dist/hooks/use-dictation.d.cts +27 -5
- package/dist/hooks/use-dictation.d.ts +27 -5
- package/dist/hooks/use-dictation.js +1 -1
- package/dist/primitives/chat.cjs +17 -13
- package/dist/primitives/chat.d.cts +13 -6
- package/dist/primitives/chat.d.ts +13 -6
- package/dist/primitives/chat.js +12 -8
- package/dist/primitives/dictation-provider.cjs +2 -2
- package/dist/primitives/dictation-provider.d.cts +1 -1
- package/dist/primitives/dictation-provider.d.ts +1 -1
- package/dist/primitives/dictation-provider.js +1 -1
- package/dist/primitives/voice-dictation-button.cjs +3 -3
- package/dist/primitives/voice-dictation-button.d.cts +8 -6
- package/dist/primitives/voice-dictation-button.d.ts +8 -6
- package/dist/primitives/voice-dictation-button.js +2 -2
- package/dist/primitives/voice-session-controls.cjs +2 -2
- package/dist/primitives/voice-session-controls.d.cts +2 -2
- package/dist/primitives/voice-session-controls.d.ts +2 -2
- package/dist/primitives/voice-session-controls.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-KQBXAQAY.cjs +0 -221
- package/dist/chunk-T3XMHWYV.js +0 -221
package/README.md
CHANGED
|
@@ -21,17 +21,18 @@ Pass a controlled `voiceSession` to `Chat` or `ChatInput` to show start, mute,
|
|
|
21
21
|
end, connection and speaking controls alongside a separate dictation mic.
|
|
22
22
|
Dictation is disabled during a live call and the typed draft is preserved.
|
|
23
23
|
The active session appears as a compact horizontal strip above the text field:
|
|
24
|
-
microphone badge, audio bars, status,
|
|
25
|
-
It wraps its controls on narrow screens.
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
microphone badge, audio bars, status, **Mute** and **End voice**.
|
|
25
|
+
It wraps its controls on narrow screens. Transcripts belong in the conversation
|
|
26
|
+
thread and are not repeated in the strip. The legacy `transcript` prop and
|
|
27
|
+
`labels.transcriptPlaceholder` are accepted but ignored.
|
|
28
|
+
`VoiceSessionControls` is also exported on its own.
|
|
28
29
|
The host owns audio, authentication, transcripts, retries and cleanup; the kit
|
|
29
30
|
does not import a voice provider. Stop and release audio tracks before setting
|
|
30
31
|
`state: "error"` or unmounting. Every label is replaceable through `labels`.
|
|
31
32
|
|
|
32
33
|
```tsx
|
|
33
34
|
<Chat messages={messages} onSend={sendMessage} voiceSession={{
|
|
34
|
-
state: voiceState, muted, error,
|
|
35
|
+
state: voiceState, muted, error, audioLevels,
|
|
35
36
|
onStart: startVoice, onEnd: endVoice, onMutedChange: setMuted,
|
|
36
37
|
}} />
|
|
37
38
|
```
|
|
@@ -40,11 +41,16 @@ does not import a voice provider. Stop and release audio tracks before setting
|
|
|
40
41
|
|
|
41
42
|
Wrap the application in `DictationProvider` to use an authenticated transcription
|
|
42
43
|
API for every `VoiceDictationButton`, including buttons nested in chat, forms or
|
|
43
|
-
another component library.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
another component library. The recorder opens beside its microphone on desktop
|
|
45
|
+
and fills the screen below 768px, with elapsed time, LiveKit audio bars and
|
|
46
|
+
Done/Cancel controls. The bars use the recording stream without opening a second
|
|
47
|
+
microphone. Resizing keeps the current recording alive.
|
|
48
|
+
|
|
49
|
+
For hidden/programmatic triggers, it uses the focused visible button as the
|
|
50
|
+
desktop anchor. Hosts can pass an explicit `anchorRef` for a custom trigger.
|
|
51
|
+
Without a visible anchor it opens full-screen. Nested and sibling dialogs keep
|
|
52
|
+
their focus and cancellation behavior.
|
|
53
|
+
The recorder runs until **Done**, finishes the last words, then commits the returned text once. Failed requests keep the
|
|
48
54
|
take available for retry. Cancel stops microphone tracks, aborts the request and
|
|
49
55
|
ignores a late answer. The kit never persists audio or knows provider credentials.
|
|
50
56
|
|
|
@@ -60,6 +66,21 @@ import { DictationProvider } from "@epicbagel/saas-kit/primitives/dictation-prov
|
|
|
60
66
|
</DictationProvider>
|
|
61
67
|
```
|
|
62
68
|
|
|
69
|
+
For words that appear while speaking, supply `stream` as well (or on its own).
|
|
70
|
+
The host connects the provided `MediaStream` to its speech service; the kit
|
|
71
|
+
captures the microphone only once. `onTranscript` replaces the live preview
|
|
72
|
+
with `{ transcript, interimTranscript }`. The adapter resolves when ready and
|
|
73
|
+
returns `{ finish, cancel }`: `finish()` drains the final audio and returns the
|
|
74
|
+
complete transcript; `cancel()` releases the transport without saving. The
|
|
75
|
+
adapter must honor `signal` during startup, streaming and finalization, and
|
|
76
|
+
report unexpected disconnection through `onError`.
|
|
77
|
+
|
|
78
|
+
Preview updates never invoke a control's commit callback. Done waits for
|
|
79
|
+
`finish()` before saving. If streaming fails and `transcribe` is also supplied,
|
|
80
|
+
the recorder shows a warning and uses its complete recording on Done. Retry
|
|
81
|
+
uses that same recording. With no batch adapter, an interrupted stream is an
|
|
82
|
+
error; partial words are never presented as a complete note.
|
|
83
|
+
|
|
63
84
|
Set `scopeKey` from the acting user and workspace (including impersonation, if
|
|
64
85
|
the app supports it). A changed scope discards the active take, stops recording
|
|
65
86
|
and aborts transcription without remounting the application. Responses from the
|
|
@@ -76,8 +97,9 @@ native recognition has no audio-level source.
|
|
|
76
97
|
failure. Choose either that result or `onTranscript` to commit; using both would
|
|
77
98
|
duplicate the host action. The callback receives one complete API transcript,
|
|
78
99
|
or finalized chunks with native recognition. API recording requires HTTPS (or
|
|
79
|
-
localhost), microphone permission and MediaRecorder.
|
|
80
|
-
|
|
100
|
+
localhost), microphone permission and MediaRecorder. Live interim text requires
|
|
101
|
+
the host's `stream` adapter; the batch `transcribe` adapter alone returns text
|
|
102
|
+
after Done. Live voice conversation controls remain a separate feature.
|
|
81
103
|
|
|
82
104
|
Without a provider, controls retain browser-native speech recognition. The
|
|
83
105
|
private ui-kit's `VoiceInput` uses this same provider and capture hook, and its
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// src/hooks/use-dictation.ts
|
|
4
4
|
var _react = require('react');
|
|
5
5
|
var DictationContext = _react.createContext.call(void 0, null);
|
|
6
|
-
function DictationProvider({ transcribe, scopeKey, children }) {
|
|
6
|
+
function DictationProvider({ transcribe, stream, scopeKey, children }) {
|
|
7
7
|
const scope = _react.useRef.call(void 0, { key: scopeKey, generation: 0 });
|
|
8
8
|
if (scope.current.key !== scopeKey) scope.current = { key: scopeKey, generation: scope.current.generation + 1 };
|
|
9
9
|
const generation = scope.current.generation;
|
|
10
|
-
const value = _react.useMemo.call(void 0, () => ({ transcribe, scope, generation }), [transcribe, generation]);
|
|
10
|
+
const value = _react.useMemo.call(void 0, () => ({ transcribe, stream, scope, generation }), [transcribe, stream, generation]);
|
|
11
11
|
return _react.createElement.call(void 0, DictationContext.Provider, { value }, children);
|
|
12
12
|
}
|
|
13
13
|
var EMPTY = { state: "idle", transcript: "", interimTranscript: "", error: null, canRetry: false };
|
|
@@ -128,6 +128,8 @@ function releaseTracks(session) {
|
|
|
128
128
|
}
|
|
129
129
|
function dispose(session) {
|
|
130
130
|
session.controller.abort();
|
|
131
|
+
_optionalChain([session, 'access', _18 => _18.streamController, 'optionalAccess', _19 => _19.abort, 'call', _20 => _20()]);
|
|
132
|
+
_optionalChain([session, 'access', _21 => _21.live, 'optionalAccess', _22 => _22.cancel, 'call', _23 => _23()]);
|
|
131
133
|
if (session.nativeTimer) clearTimeout(session.nativeTimer);
|
|
132
134
|
const recorder = session.recorder;
|
|
133
135
|
if (recorder) {
|
|
@@ -157,9 +159,10 @@ function dispose(session) {
|
|
|
157
159
|
}
|
|
158
160
|
function useDictation({ lang = "en-US", continuous = true, onTranscript } = {}) {
|
|
159
161
|
const provider = _react.useContext.call(void 0, DictationContext);
|
|
160
|
-
const transcribe = _nullishCoalesce(_optionalChain([provider, 'optionalAccess',
|
|
161
|
-
const
|
|
162
|
-
const
|
|
162
|
+
const transcribe = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _24 => _24.transcribe]), () => ( null));
|
|
163
|
+
const startStream = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _25 => _25.stream]), () => ( null));
|
|
164
|
+
const scope = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _26 => _26.scope]), () => ( null));
|
|
165
|
+
const scopeGeneration = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _27 => _27.generation]), () => ( 0));
|
|
163
166
|
const [snapshot, setSnapshot] = _react.useState.call(void 0, EMPTY);
|
|
164
167
|
const [supported, setSupported] = _react.useState.call(void 0, false);
|
|
165
168
|
const current = _react.useRef.call(void 0, null);
|
|
@@ -169,8 +172,8 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
169
172
|
activeScope.current = scope;
|
|
170
173
|
callback.current = onTranscript;
|
|
171
174
|
_react.useEffect.call(void 0, () => {
|
|
172
|
-
setSupported(transcribe ? typeof navigator !== "undefined" && !!_optionalChain([navigator, 'access',
|
|
173
|
-
}, [transcribe]);
|
|
175
|
+
setSupported(transcribe || startStream ? typeof navigator !== "undefined" && !!_optionalChain([navigator, 'access', _28 => _28.mediaDevices, 'optionalAccess', _29 => _29.getUserMedia]) && typeof MediaRecorder !== "undefined" : typeof recognitionConstructor() === "function");
|
|
176
|
+
}, [transcribe, startStream]);
|
|
174
177
|
const cancel = _react.useCallback.call(void 0, () => {
|
|
175
178
|
const session = current.current;
|
|
176
179
|
current.current = null;
|
|
@@ -190,9 +193,9 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
190
193
|
if (session) dispose(session);
|
|
191
194
|
};
|
|
192
195
|
}, []);
|
|
193
|
-
const isCurrent = _react.useCallback.call(void 0, (session) => mounted.current && current.current === session && session.scope === activeScope.current && session.scopeGeneration === (_nullishCoalesce(_optionalChain([session, 'access',
|
|
196
|
+
const isCurrent = _react.useCallback.call(void 0, (session) => mounted.current && current.current === session && session.scope === activeScope.current && session.scopeGeneration === (_nullishCoalesce(_optionalChain([session, 'access', _30 => _30.scope, 'optionalAccess', _31 => _31.current, 'access', _32 => _32.generation]), () => ( 0))) && !session.controller.signal.aborted, []);
|
|
194
197
|
const start = _react.useCallback.call(void 0, async () => {
|
|
195
|
-
if (scope !== activeScope.current || scopeGeneration !== (_nullishCoalesce(_optionalChain([scope, 'optionalAccess',
|
|
198
|
+
if (scope !== activeScope.current || scopeGeneration !== (_nullishCoalesce(_optionalChain([scope, 'optionalAccess', _33 => _33.current, 'access', _34 => _34.generation]), () => ( 0)))) return;
|
|
196
199
|
if (current.current && isCurrent(current.current) && ["requesting", "recording", "transcribing"].includes(current.current.phase)) return;
|
|
197
200
|
cancel();
|
|
198
201
|
let resolveCapture;
|
|
@@ -200,9 +203,12 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
200
203
|
const session = {
|
|
201
204
|
language: lang,
|
|
202
205
|
transcribe,
|
|
206
|
+
startStream,
|
|
207
|
+
liveFailed: false,
|
|
203
208
|
scope,
|
|
204
209
|
scopeGeneration,
|
|
205
210
|
controller: new AbortController(),
|
|
211
|
+
streamController: startStream ? new AbortController() : void 0,
|
|
206
212
|
phase: "requesting",
|
|
207
213
|
chunks: [],
|
|
208
214
|
capture: new Promise((resolve) => {
|
|
@@ -217,20 +223,20 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
217
223
|
transcript: "",
|
|
218
224
|
interim: "",
|
|
219
225
|
notified: /* @__PURE__ */ new Set(),
|
|
220
|
-
audioLevels: transcribe ? createAudioLevels() : void 0
|
|
226
|
+
audioLevels: transcribe || startStream ? createAudioLevels() : void 0
|
|
221
227
|
};
|
|
222
228
|
current.current = session;
|
|
223
229
|
setSnapshot({ ...EMPTY, state: "requesting" });
|
|
224
|
-
if (transcribe) {
|
|
230
|
+
if (transcribe || startStream) {
|
|
225
231
|
try {
|
|
226
|
-
if (!_optionalChain([navigator, 'access',
|
|
232
|
+
if (!_optionalChain([navigator, 'access', _35 => _35.mediaDevices, 'optionalAccess', _36 => _36.getUserMedia]) || typeof MediaRecorder === "undefined") throw new Error("unsupported");
|
|
227
233
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
228
234
|
if (!isCurrent(session)) {
|
|
229
235
|
stream.getTracks().forEach((track) => track.stop());
|
|
230
236
|
return;
|
|
231
237
|
}
|
|
232
238
|
session.stream = stream;
|
|
233
|
-
const mimeType = MIME_TYPES.find((mime) => _optionalChain([MediaRecorder, 'access',
|
|
239
|
+
const mimeType = MIME_TYPES.find((mime) => _optionalChain([MediaRecorder, 'access', _37 => _37.isTypeSupported, 'optionalCall', _38 => _38(mime)]));
|
|
234
240
|
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
|
235
241
|
session.recorder = recorder;
|
|
236
242
|
recorder.ondataavailable = (event) => {
|
|
@@ -242,7 +248,7 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
242
248
|
session.resolveCapture(null);
|
|
243
249
|
return;
|
|
244
250
|
}
|
|
245
|
-
const type = recorder.mimeType || _optionalChain([session, 'access',
|
|
251
|
+
const type = recorder.mimeType || _optionalChain([session, 'access', _39 => _39.chunks, 'access', _40 => _40[0], 'optionalAccess', _41 => _41.type]) || mimeType || "audio/webm";
|
|
246
252
|
const blob = new Blob(session.chunks, { type });
|
|
247
253
|
session.blob = blob;
|
|
248
254
|
session.chunks = [];
|
|
@@ -250,16 +256,53 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
250
256
|
};
|
|
251
257
|
recorder.onerror = () => {
|
|
252
258
|
if (!isCurrent(session)) return;
|
|
259
|
+
session.liveFailed = true;
|
|
260
|
+
_optionalChain([session, 'access', _42 => _42.live, 'optionalAccess', _43 => _43.cancel, 'call', _44 => _44()]);
|
|
253
261
|
releaseTracks(session);
|
|
254
262
|
session.phase = "error";
|
|
255
263
|
session.resolveCapture(null);
|
|
256
264
|
setSnapshot({ ...EMPTY, state: "error", error: "Recording was interrupted. Try again." });
|
|
257
265
|
};
|
|
258
266
|
recorder.start();
|
|
267
|
+
if (startStream) {
|
|
268
|
+
const failed = (error) => {
|
|
269
|
+
if (!isCurrent(session) || session.phase === "ready" || session.phase === "error") return;
|
|
270
|
+
session.liveFailed = true;
|
|
271
|
+
_optionalChain([session, 'access', _45 => _45.live, 'optionalAccess', _46 => _46.cancel, 'call', _47 => _47()]);
|
|
272
|
+
if (transcribe) {
|
|
273
|
+
setSnapshot((previous) => ({ ...previous, previewError: "Live text is unavailable. Choose Done to transcribe this recording." }));
|
|
274
|
+
} else {
|
|
275
|
+
releaseTracks(session);
|
|
276
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
277
|
+
session.phase = "error";
|
|
278
|
+
setSnapshot((previous) => ({ ...previous, state: "error", error: error.message || "Live transcription stopped. Try again." }));
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
try {
|
|
282
|
+
const live = await startStream(stream, {
|
|
283
|
+
language: lang,
|
|
284
|
+
signal: session.streamController.signal,
|
|
285
|
+
onTranscript: (preview) => {
|
|
286
|
+
if (!isCurrent(session) || session.liveFailed || session.phase === "error" || session.phase === "ready") return;
|
|
287
|
+
session.transcript = preview.transcript;
|
|
288
|
+
session.interim = preview.interimTranscript;
|
|
289
|
+
setSnapshot((previous) => ({ ...previous, transcript: preview.transcript, interimTranscript: preview.interimTranscript }));
|
|
290
|
+
},
|
|
291
|
+
onError: failed
|
|
292
|
+
});
|
|
293
|
+
if (!isCurrent(session) || session.liveFailed || session.phase === "error") {
|
|
294
|
+
live.cancel();
|
|
295
|
+
} else session.live = live;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
failed(error instanceof Error ? error : new Error("Live transcription could not start. Try again."));
|
|
298
|
+
}
|
|
299
|
+
if (!isCurrent(session) || session.phase !== "requesting") return;
|
|
300
|
+
}
|
|
259
301
|
session.phase = "recording";
|
|
260
302
|
session.stopMeter = meterStream(stream, session.audioLevels, () => isCurrent(session) && session.phase === "recording");
|
|
261
|
-
setSnapshot({ ...
|
|
303
|
+
setSnapshot((previous) => ({ ...previous, state: "recording" }));
|
|
262
304
|
} catch (error) {
|
|
305
|
+
_optionalChain([session, 'access', _48 => _48.live, 'optionalAccess', _49 => _49.cancel, 'call', _50 => _50()]);
|
|
263
306
|
releaseTracks(session);
|
|
264
307
|
if (!isCurrent(session)) return;
|
|
265
308
|
session.phase = "error";
|
|
@@ -295,7 +338,7 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
295
338
|
session.transcript = final.join(" ");
|
|
296
339
|
session.interim = interim.join(" ");
|
|
297
340
|
setSnapshot((previous) => ({ ...previous, transcript: session.transcript, interimTranscript: session.interim }));
|
|
298
|
-
if (chunks.length) _optionalChain([callback, 'access',
|
|
341
|
+
if (chunks.length) _optionalChain([callback, 'access', _51 => _51.current, 'optionalCall', _52 => _52(chunks.join(" "))]);
|
|
299
342
|
};
|
|
300
343
|
recognition.onend = () => {
|
|
301
344
|
session.nativeEnded = true;
|
|
@@ -320,23 +363,23 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
320
363
|
session.phase = "error";
|
|
321
364
|
setSnapshot({ ...EMPTY, state: "error", error: microphoneError(error) });
|
|
322
365
|
}
|
|
323
|
-
}, [cancel, continuous, isCurrent, lang, scope, scopeGeneration, transcribe]);
|
|
366
|
+
}, [cancel, continuous, isCurrent, lang, scope, scopeGeneration, transcribe, startStream]);
|
|
324
367
|
const finish = _react.useCallback.call(void 0, async (session) => {
|
|
325
368
|
if (!isCurrent(session)) return null;
|
|
326
369
|
releaseMeter(session);
|
|
327
370
|
session.phase = "transcribing";
|
|
328
371
|
setSnapshot((previous) => ({ ...previous, state: "transcribing", error: null, canRetry: false }));
|
|
329
|
-
if (!session.transcribe) {
|
|
372
|
+
if (!session.transcribe && !session.startStream) {
|
|
330
373
|
if (!session.nativeEnded) {
|
|
331
374
|
session.nativeTimer = setTimeout(() => {
|
|
332
375
|
try {
|
|
333
|
-
_optionalChain([session, 'access',
|
|
376
|
+
_optionalChain([session, 'access', _53 => _53.recognition, 'optionalAccess', _54 => _54.abort, 'call', _55 => _55()]);
|
|
334
377
|
} catch (e8) {
|
|
335
378
|
}
|
|
336
379
|
session.resolveNativeEnd();
|
|
337
380
|
}, 1e3);
|
|
338
381
|
try {
|
|
339
|
-
_optionalChain([session, 'access',
|
|
382
|
+
_optionalChain([session, 'access', _56 => _56.recognition, 'optionalAccess', _57 => _57.stop, 'call', _58 => _58()]);
|
|
340
383
|
} catch (e9) {
|
|
341
384
|
session.resolveNativeEnd();
|
|
342
385
|
}
|
|
@@ -352,13 +395,29 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
352
395
|
let text;
|
|
353
396
|
try {
|
|
354
397
|
if (!session.blob) {
|
|
355
|
-
if (_optionalChain([session, 'access',
|
|
398
|
+
if (_optionalChain([session, 'access', _59 => _59.recorder, 'optionalAccess', _60 => _60.state]) !== "inactive") _optionalChain([session, 'access', _61 => _61.recorder, 'optionalAccess', _62 => _62.stop, 'call', _63 => _63()]);
|
|
356
399
|
const blob = await session.capture;
|
|
357
400
|
if (!isCurrent(session) || !blob) return null;
|
|
358
401
|
session.blob = blob;
|
|
359
402
|
}
|
|
360
|
-
if (
|
|
361
|
-
|
|
403
|
+
if (session.live && !session.liveFailed) {
|
|
404
|
+
try {
|
|
405
|
+
const final = await session.live.finish();
|
|
406
|
+
if (session.liveFailed) throw new Error("Live transcription was interrupted. Please retry this recording.");
|
|
407
|
+
text = final.trim();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
session.liveFailed = true;
|
|
410
|
+
if (!session.transcribe) throw error;
|
|
411
|
+
} finally {
|
|
412
|
+
session.live.cancel();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (!isCurrent(session)) return null;
|
|
416
|
+
if (text === void 0) {
|
|
417
|
+
if (!session.transcribe) throw new Error("Live transcription could not finish. Please record again.");
|
|
418
|
+
if (!session.blob.size) throw new Error("No audio was recorded. Try again.");
|
|
419
|
+
text = (await session.transcribe(session.blob, { language: session.language, signal: session.controller.signal })).trim();
|
|
420
|
+
}
|
|
362
421
|
if (!isCurrent(session)) return null;
|
|
363
422
|
if (!text) throw new Error("No speech was detected. Record again.");
|
|
364
423
|
} catch (error) {
|
|
@@ -366,13 +425,14 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
366
425
|
releaseTracks(session);
|
|
367
426
|
session.phase = "error";
|
|
368
427
|
const message = error instanceof Error && error.message ? error.message : "Transcription failed. Try again.";
|
|
369
|
-
|
|
428
|
+
_optionalChain([session, 'access', _64 => _64.live, 'optionalAccess', _65 => _65.cancel, 'call', _66 => _66()]);
|
|
429
|
+
setSnapshot((previous) => ({ ...previous, state: "error", error: message, canRetry: !!session.transcribe && !!_optionalChain([session, 'access', _67 => _67.blob, 'optionalAccess', _68 => _68.size]) }));
|
|
370
430
|
return null;
|
|
371
431
|
}
|
|
372
432
|
session.transcript = text;
|
|
373
433
|
session.phase = "ready";
|
|
374
434
|
setSnapshot({ ...EMPTY, state: "ready", transcript: text });
|
|
375
|
-
_optionalChain([callback, 'access',
|
|
435
|
+
_optionalChain([callback, 'access', _69 => _69.current, 'optionalCall', _70 => _70(text)]);
|
|
376
436
|
return text;
|
|
377
437
|
}, [isCurrent]);
|
|
378
438
|
const stop = _react.useCallback.call(void 0, () => {
|
|
@@ -383,8 +443,13 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
383
443
|
return Promise.resolve(null);
|
|
384
444
|
}
|
|
385
445
|
if (session.phase === "requesting") {
|
|
386
|
-
|
|
387
|
-
|
|
446
|
+
if (!session.transcribe || _optionalChain([session, 'access', _71 => _71.recorder, 'optionalAccess', _72 => _72.state]) !== "recording") {
|
|
447
|
+
cancel();
|
|
448
|
+
return Promise.resolve(null);
|
|
449
|
+
}
|
|
450
|
+
session.liveFailed = true;
|
|
451
|
+
_optionalChain([session, 'access', _73 => _73.streamController, 'optionalAccess', _74 => _74.abort, 'call', _75 => _75()]);
|
|
452
|
+
_optionalChain([session, 'access', _76 => _76.live, 'optionalAccess', _77 => _77.cancel, 'call', _78 => _78()]);
|
|
388
453
|
}
|
|
389
454
|
if (session.finish) return session.finish;
|
|
390
455
|
if (session.phase === "error") return Promise.resolve(null);
|
|
@@ -397,7 +462,7 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
|
|
|
397
462
|
cancel();
|
|
398
463
|
return Promise.resolve(null);
|
|
399
464
|
}
|
|
400
|
-
if (!_optionalChain([session, 'optionalAccess',
|
|
465
|
+
if (!_optionalChain([session, 'optionalAccess', _79 => _79.transcribe]) || !_optionalChain([session, 'access', _80 => _80.blob, 'optionalAccess', _81 => _81.size])) return Promise.resolve(null);
|
|
401
466
|
if (session.phase !== "error") return _nullishCoalesce(session.finish, () => ( Promise.resolve(null)));
|
|
402
467
|
session.finish = finish(session);
|
|
403
468
|
return session.finish;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
AgentAudioVisualizerBar
|
|
4
|
+
} from "./chunk-5OF6D3GH.js";
|
|
5
|
+
import {
|
|
6
|
+
Button
|
|
7
|
+
} from "./chunk-W326GKML.js";
|
|
8
|
+
import {
|
|
9
|
+
cn
|
|
10
|
+
} from "./chunk-G6SRSWXB.js";
|
|
11
|
+
import {
|
|
12
|
+
useDictation
|
|
13
|
+
} from "./chunk-RPSRCB5O.js";
|
|
14
|
+
|
|
15
|
+
// src/primitives/voice-dictation-button.tsx
|
|
16
|
+
import {
|
|
17
|
+
useCallback,
|
|
18
|
+
useEffect,
|
|
19
|
+
useId,
|
|
20
|
+
useRef,
|
|
21
|
+
useState,
|
|
22
|
+
useSyncExternalStore
|
|
23
|
+
} from "react";
|
|
24
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
25
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
26
|
+
import { Mic, LoaderCircle } from "lucide-react";
|
|
27
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
28
|
+
function VoiceDictationButton({
|
|
29
|
+
onCommit,
|
|
30
|
+
onCancel,
|
|
31
|
+
title = "Dictate your message",
|
|
32
|
+
caption = "Recording",
|
|
33
|
+
lang = "en-US",
|
|
34
|
+
continuous = true,
|
|
35
|
+
className,
|
|
36
|
+
size = "sm",
|
|
37
|
+
showLabel = false,
|
|
38
|
+
children,
|
|
39
|
+
disabled = false,
|
|
40
|
+
visibility = "visible",
|
|
41
|
+
shrink = true,
|
|
42
|
+
anchorRef
|
|
43
|
+
}) {
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const [anchor, setAnchor] = useState(null);
|
|
46
|
+
const desktop = useSyncExternalStore(subscribeDesktop, isDesktop, () => false);
|
|
47
|
+
const anchored = desktop && isVisibleAnchor(anchor);
|
|
48
|
+
const placement = useRef(anchored);
|
|
49
|
+
placement.current = anchored;
|
|
50
|
+
const isOpen = useRef(open);
|
|
51
|
+
isOpen.current = open;
|
|
52
|
+
const descriptionId = useId();
|
|
53
|
+
const capture = useDictation({ lang, continuous });
|
|
54
|
+
const { transcript, interimTranscript: interim, supported, error } = capture;
|
|
55
|
+
const generation = useRef(0);
|
|
56
|
+
const finishing = useRef(false);
|
|
57
|
+
const previousFocus = useRef(null);
|
|
58
|
+
const cancelControl = useRef(null);
|
|
59
|
+
const elapsed = useRecordingTime(capture.state);
|
|
60
|
+
const busy = capture.state === "requesting" || capture.state === "transcribing";
|
|
61
|
+
const openOverlay = useCallback((event) => {
|
|
62
|
+
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
63
|
+
const explicit = anchorRef?.current;
|
|
64
|
+
const invokingButton = focused?.matches("button:not(:disabled), [role='button']:not([aria-disabled='true'])") ? focused : null;
|
|
65
|
+
const resolved = isVisibleAnchor(explicit) ? explicit : isVisibleAnchor(event.currentTarget) ? event.currentTarget : isVisibleAnchor(invokingButton) ? invokingButton : null;
|
|
66
|
+
previousFocus.current = resolved?.matches("button, a[href], input, select, textarea, [tabindex]") ? resolved : focused;
|
|
67
|
+
setAnchor(resolved);
|
|
68
|
+
generation.current++;
|
|
69
|
+
finishing.current = false;
|
|
70
|
+
setOpen(true);
|
|
71
|
+
void capture.start();
|
|
72
|
+
}, [anchorRef, capture.start]);
|
|
73
|
+
const closeOverlay = useCallback(() => {
|
|
74
|
+
generation.current++;
|
|
75
|
+
finishing.current = false;
|
|
76
|
+
capture.cancel();
|
|
77
|
+
setOpen(false);
|
|
78
|
+
}, [capture.cancel]);
|
|
79
|
+
const handleDone = useCallback(async () => {
|
|
80
|
+
if (finishing.current || busy) return;
|
|
81
|
+
cancelControl.current?.focus({ preventScroll: true });
|
|
82
|
+
if (capture.state === "error" && !capture.canRetry) {
|
|
83
|
+
void capture.start();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
finishing.current = true;
|
|
87
|
+
const started = generation.current;
|
|
88
|
+
const final = await (capture.canRetry ? capture.retry() : capture.stop());
|
|
89
|
+
if (started !== generation.current) return;
|
|
90
|
+
finishing.current = false;
|
|
91
|
+
if (final === null) return;
|
|
92
|
+
closeOverlay();
|
|
93
|
+
if (final) onCommit(final);
|
|
94
|
+
else onCancel?.();
|
|
95
|
+
}, [busy, capture.canRetry, capture.retry, capture.start, capture.state, capture.stop, onCommit, onCancel, closeOverlay]);
|
|
96
|
+
const handleCancel = useCallback(() => {
|
|
97
|
+
closeOverlay();
|
|
98
|
+
onCancel?.();
|
|
99
|
+
}, [closeOverlay, onCancel]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (open && capture.state === "idle") handleCancel();
|
|
102
|
+
}, [capture.state, handleCancel, open]);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
return () => {
|
|
105
|
+
generation.current++;
|
|
106
|
+
};
|
|
107
|
+
}, []);
|
|
108
|
+
if (!supported) return null;
|
|
109
|
+
const displayTranscript = `${transcript}${interim ? (transcript ? " " : "") + interim : ""}`;
|
|
110
|
+
const closeAutoFocus = (event) => {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
if (!isOpen.current && previousFocus.current?.isConnected) previousFocus.current.focus();
|
|
113
|
+
};
|
|
114
|
+
const onKeyDown = (event) => {
|
|
115
|
+
if (event.key === "Enter" && !event.shiftKey && !(event.target instanceof HTMLElement && event.target.closest("button, input, textarea, select"))) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
void handleDone();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const content = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
121
|
+
/* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 items-start justify-between gap-3", !anchored && "w-full max-w-xl self-center"), children: [
|
|
122
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1", children: [
|
|
123
|
+
/* @__PURE__ */ jsx("h2", { className: cn("font-medium", anchored ? "text-sm" : "text-lg"), children: title || "Voice dictation" }),
|
|
124
|
+
/* @__PURE__ */ jsxs("span", { role: "status", className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
|
|
125
|
+
busy ? /* @__PURE__ */ jsx(LoaderCircle, { "aria-hidden": "true", className: "size-3.5 animate-spin motion-reduce:animate-none" }) : /* @__PURE__ */ jsx(Mic, { "aria-hidden": "true", className: "size-3.5" }),
|
|
126
|
+
capture.state === "transcribing" ? "Finishing transcript\u2026" : capture.state === "requesting" ? "Starting dictation\u2026" : error ? "Recording not saved" : capture.state === "ready" ? "Ready" : caption
|
|
127
|
+
] })
|
|
128
|
+
] }),
|
|
129
|
+
/* @__PURE__ */ jsxs("time", { "aria-label": "Recording duration", dateTime: `PT${elapsed}S`, className: "pt-0.5 font-mono text-xs tabular-nums text-muted-foreground", children: [
|
|
130
|
+
Math.floor(elapsed / 60).toString().padStart(2, "0"),
|
|
131
|
+
":",
|
|
132
|
+
(elapsed % 60).toString().padStart(2, "0")
|
|
133
|
+
] })
|
|
134
|
+
] }),
|
|
135
|
+
/* @__PURE__ */ jsxs("div", { className: cn("flex min-h-0 flex-col gap-3", !anchored && "w-full max-w-xl flex-1 justify-center self-center py-8 text-center"), children: [
|
|
136
|
+
capture.audioLevels && /* @__PURE__ */ jsx(DictationWaveform, { source: capture.audioLevels, state: capture.state, className: !anchored ? "h-20" : void 0 }),
|
|
137
|
+
/* @__PURE__ */ jsx("p", { id: descriptionId, className: "text-xs text-muted-foreground", children: capture.state === "transcribing" ? "Finishing your last words\u2026" : capture.state === "requesting" ? "Allow microphone access to start." : error ? capture.canRetry ? "Retry this take or record again." : "Try recording again." : "Speak, then choose Done to add the text." }),
|
|
138
|
+
displayTranscript && /* @__PURE__ */ jsxs("p", { className: "max-h-40 overflow-y-auto text-sm leading-relaxed", children: [
|
|
139
|
+
transcript,
|
|
140
|
+
interim && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
141
|
+
transcript ? " " : "",
|
|
142
|
+
interim
|
|
143
|
+
] })
|
|
144
|
+
] }),
|
|
145
|
+
error && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-xs text-destructive", children: error }),
|
|
146
|
+
!error && capture.previewError && /* @__PURE__ */ jsx("p", { role: "status", className: "text-xs text-muted-foreground", children: capture.previewError })
|
|
147
|
+
] }),
|
|
148
|
+
/* @__PURE__ */ jsxs("div", { className: cn("gap-2", anchored ? "flex flex-wrap items-center justify-end" : "mt-auto grid w-full max-w-xl grid-cols-2 gap-3 self-center"), children: [
|
|
149
|
+
/* @__PURE__ */ jsx(Button, { ref: cancelControl, type: "button", variant: "ghost", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: handleCancel, children: "Cancel" }),
|
|
150
|
+
capture.canRetry && /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", className: anchored ? "min-h-[44px]" : "order-first col-span-2 min-h-[48px]", onClick: () => {
|
|
151
|
+
cancelControl.current?.focus({ preventScroll: true });
|
|
152
|
+
void capture.start();
|
|
153
|
+
}, children: "Record again" }),
|
|
154
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: () => void handleDone(), disabled: busy, children: capture.state === "transcribing" ? "Transcribing\u2026" : capture.canRetry ? "Retry transcription" : error ? "Try again" : "Done" })
|
|
155
|
+
] })
|
|
156
|
+
] });
|
|
157
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
158
|
+
children ? /* @__PURE__ */ jsx(
|
|
159
|
+
"button",
|
|
160
|
+
{
|
|
161
|
+
type: "button",
|
|
162
|
+
onClick: openOverlay,
|
|
163
|
+
disabled,
|
|
164
|
+
"aria-label": "Start dictation",
|
|
165
|
+
className: cn(visibility === "hidden" && "hidden", className),
|
|
166
|
+
children
|
|
167
|
+
}
|
|
168
|
+
) : /* @__PURE__ */ jsx(
|
|
169
|
+
Button,
|
|
170
|
+
{
|
|
171
|
+
type: "button",
|
|
172
|
+
onClick: openOverlay,
|
|
173
|
+
disabled,
|
|
174
|
+
variant: "outline",
|
|
175
|
+
size,
|
|
176
|
+
"aria-label": "Start dictation",
|
|
177
|
+
className: cn("transition-colors", visibility === "hidden" && "hidden", !shrink && "shrink-0", className),
|
|
178
|
+
children: /* @__PURE__ */ jsxs("span", { className: "flex items-center justify-center gap-2", children: [
|
|
179
|
+
/* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" }),
|
|
180
|
+
showLabel && /* @__PURE__ */ jsx("span", { children: "Speak" })
|
|
181
|
+
] })
|
|
182
|
+
}
|
|
183
|
+
),
|
|
184
|
+
/* @__PURE__ */ jsxs(PopoverPrimitive.Root, { modal: true, open: open && anchored, onOpenChange: (next) => {
|
|
185
|
+
if (!next && placement.current) handleCancel();
|
|
186
|
+
}, children: [
|
|
187
|
+
anchored && /* @__PURE__ */ jsx(PopoverPrimitive.Anchor, { virtualRef: { current: anchor } }),
|
|
188
|
+
/* @__PURE__ */ jsx(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx(
|
|
189
|
+
PopoverPrimitive.Content,
|
|
190
|
+
{
|
|
191
|
+
"aria-label": "Voice dictation",
|
|
192
|
+
"aria-describedby": descriptionId,
|
|
193
|
+
"data-slot": "voice-dictation-panel",
|
|
194
|
+
"data-state": capture.state,
|
|
195
|
+
"data-placement": "anchored",
|
|
196
|
+
side: "top",
|
|
197
|
+
align: "center",
|
|
198
|
+
sideOffset: 8,
|
|
199
|
+
collisionPadding: 12,
|
|
200
|
+
avoidCollisions: true,
|
|
201
|
+
onCloseAutoFocus: closeAutoFocus,
|
|
202
|
+
onKeyDown,
|
|
203
|
+
onEscapeKeyDown: (event) => event.stopPropagation(),
|
|
204
|
+
className: "z-[200] flex max-h-[var(--radix-popover-content-available-height)] w-96 max-w-[calc(100vw-1.5rem)] flex-col gap-3 overflow-y-auto rounded-2xl border border-border bg-background p-4 text-foreground shadow-lg outline-hidden",
|
|
205
|
+
children: content
|
|
206
|
+
}
|
|
207
|
+
) })
|
|
208
|
+
] }),
|
|
209
|
+
/* @__PURE__ */ jsx(DialogPrimitive.Root, { open: open && !anchored, onOpenChange: (next) => {
|
|
210
|
+
if (!next && !placement.current) handleCancel();
|
|
211
|
+
}, children: /* @__PURE__ */ jsxs(DialogPrimitive.Portal, { children: [
|
|
212
|
+
/* @__PURE__ */ jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-[199] bg-background" }),
|
|
213
|
+
/* @__PURE__ */ jsxs(
|
|
214
|
+
DialogPrimitive.Content,
|
|
215
|
+
{
|
|
216
|
+
"aria-label": "Voice dictation",
|
|
217
|
+
"aria-labelledby": void 0,
|
|
218
|
+
"data-slot": "voice-dictation-panel",
|
|
219
|
+
"data-state": capture.state,
|
|
220
|
+
"data-placement": "fullscreen",
|
|
221
|
+
onCloseAutoFocus: closeAutoFocus,
|
|
222
|
+
onKeyDown,
|
|
223
|
+
onEscapeKeyDown: (event) => event.stopPropagation(),
|
|
224
|
+
className: "fixed inset-0 z-[200] flex h-dvh w-screen flex-col gap-6 overflow-y-auto overscroll-contain bg-background px-6 pt-[max(1.5rem,env(safe-area-inset-top))] pb-[max(1.5rem,env(safe-area-inset-bottom))] text-foreground outline-hidden",
|
|
225
|
+
children: [
|
|
226
|
+
/* @__PURE__ */ jsx(DialogPrimitive.Title, { className: "sr-only", children: "Voice dictation" }),
|
|
227
|
+
/* @__PURE__ */ jsx(DialogPrimitive.Description, { className: "sr-only", children: "Record your message, then choose Done to add the text. Cancel discards the recording." }),
|
|
228
|
+
content
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
] }) })
|
|
233
|
+
] });
|
|
234
|
+
}
|
|
235
|
+
var SILENCE = [];
|
|
236
|
+
var silence = () => SILENCE;
|
|
237
|
+
function DictationWaveform({ source, state, className }) {
|
|
238
|
+
const bands = useSyncExternalStore(source.subscribe, source.getSnapshot, silence);
|
|
239
|
+
return /* @__PURE__ */ jsx(
|
|
240
|
+
AgentAudioVisualizerBar,
|
|
241
|
+
{
|
|
242
|
+
"aria-hidden": "true",
|
|
243
|
+
"data-slot": "dictation-waveform",
|
|
244
|
+
size: "icon",
|
|
245
|
+
barCount: 24,
|
|
246
|
+
state: state === "recording" ? "listening" : state === "transcribing" ? "thinking" : state === "requesting" ? "connecting" : "disconnected",
|
|
247
|
+
volumeBands: state === "recording" ? bands : void 0,
|
|
248
|
+
className: cn("h-9 w-full text-foreground", className)
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
function useRecordingTime(state) {
|
|
253
|
+
const [elapsed, setElapsed] = useState(0);
|
|
254
|
+
const startedAt = useRef(null);
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (state !== "recording") {
|
|
257
|
+
if (state === "idle" || state === "requesting") setElapsed(0);
|
|
258
|
+
else if (startedAt.current !== null) setElapsed(Math.floor((performance.now() - startedAt.current) / 1e3));
|
|
259
|
+
startedAt.current = null;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
startedAt.current = performance.now();
|
|
263
|
+
const update = () => setElapsed(Math.floor((performance.now() - startedAt.current) / 1e3));
|
|
264
|
+
update();
|
|
265
|
+
const timer = setInterval(update, 1e3);
|
|
266
|
+
return () => {
|
|
267
|
+
clearInterval(timer);
|
|
268
|
+
};
|
|
269
|
+
}, [state]);
|
|
270
|
+
return elapsed;
|
|
271
|
+
}
|
|
272
|
+
var DESKTOP_QUERY = "(min-width: 768px)";
|
|
273
|
+
function isDesktop() {
|
|
274
|
+
return typeof window !== "undefined" && !!window.matchMedia?.(DESKTOP_QUERY).matches;
|
|
275
|
+
}
|
|
276
|
+
function subscribeDesktop(changed) {
|
|
277
|
+
const media = window.matchMedia?.(DESKTOP_QUERY);
|
|
278
|
+
media?.addEventListener("change", changed);
|
|
279
|
+
return () => media?.removeEventListener("change", changed);
|
|
280
|
+
}
|
|
281
|
+
function isVisibleAnchor(element) {
|
|
282
|
+
if (!element?.isConnected || !element.getClientRects().length) return false;
|
|
283
|
+
const rect = element.getBoundingClientRect();
|
|
284
|
+
if (!rect.width || !rect.height) return false;
|
|
285
|
+
for (let parent = element; parent; parent = parent.parentElement) {
|
|
286
|
+
const style = getComputedStyle(parent);
|
|
287
|
+
if (parent.hidden || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number.parseFloat(style.opacity) === 0) return false;
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export {
|
|
293
|
+
VoiceDictationButton
|
|
294
|
+
};
|