@chai-ui/core 0.8.0 → 0.10.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 +6 -5
- package/dist/composer-view.d.ts +6 -0
- package/dist/composer-view.d.ts.map +1 -1
- package/dist/composer-view.js +6 -0
- package/dist/composer-view.js.map +1 -1
- package/dist/composer.d.ts +1 -1
- package/dist/composer.d.ts.map +1 -1
- package/dist/composer.js +7 -3
- package/dist/composer.js.map +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +1 -1
- package/dist/engines/fal.d.ts +25 -0
- package/dist/engines/fal.d.ts.map +1 -1
- package/dist/engines/fal.js +41 -0
- package/dist/engines/fal.js.map +1 -1
- package/dist/engines/openrouter.d.ts +9 -2
- package/dist/engines/openrouter.d.ts.map +1 -1
- package/dist/engines/openrouter.js +2 -2
- package/dist/engines/openrouter.js.map +1 -1
- package/dist/engines/vercel.d.ts +64 -0
- package/dist/engines/vercel.d.ts.map +1 -0
- package/dist/engines/vercel.js +137 -0
- package/dist/engines/vercel.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/server/chai-handler.d.ts +7 -2
- package/dist/server/chai-handler.d.ts.map +1 -1
- package/dist/server/chai-handler.js +31 -3
- package/dist/server/chai-handler.js.map +1 -1
- package/dist/suggested-models.d.ts.map +1 -1
- package/dist/suggested-models.js +2 -1
- package/dist/suggested-models.js.map +1 -1
- package/dist/voice-input.d.ts +79 -0
- package/dist/voice-input.d.ts.map +1 -0
- package/dist/voice-input.js +207 -0
- package/dist/voice-input.js.map +1 -0
- package/package.json +4 -3
- package/src/composer-view.ts +6 -0
- package/src/composer.ts +7 -3
- package/src/engine.ts +1 -1
- package/src/engines/fal.ts +55 -0
- package/src/engines/openrouter.ts +3 -3
- package/src/engines/vercel.ts +198 -0
- package/src/index.ts +2 -0
- package/src/server/chai-handler.ts +37 -5
- package/src/suggested-models.ts +2 -1
- package/src/voice-input.ts +257 -0
|
@@ -11,14 +11,26 @@
|
|
|
11
11
|
|
|
12
12
|
const FAL_QUEUE_BASE = "https://queue.fal.run";
|
|
13
13
|
const OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
14
|
+
const VERCEL_GATEWAY_BASE = "https://ai-gateway.vercel.sh/v1";
|
|
14
15
|
|
|
15
|
-
export type ChaiProviderId = "fal" | "openrouter";
|
|
16
|
+
export type ChaiProviderId = "fal" | "openrouter" | "vercel";
|
|
17
|
+
|
|
18
|
+
const PROVIDER_LABEL: Record<ChaiProviderId, string> = {
|
|
19
|
+
fal: "Fal.ai",
|
|
20
|
+
openrouter: "OpenRouter",
|
|
21
|
+
vercel: "Vercel AI Gateway",
|
|
22
|
+
};
|
|
16
23
|
|
|
17
24
|
export interface ChaiHandlerOptions {
|
|
18
25
|
/** Defaults to the `FAL_KEY` environment variable. */
|
|
19
26
|
falKey?: string;
|
|
20
27
|
/** Defaults to the `OPENROUTER_API_KEY` environment variable. */
|
|
21
28
|
openRouterKey?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Vercel AI Gateway key. Defaults to `AI_GATEWAY_API_KEY`, then to
|
|
31
|
+
* `VERCEL_OIDC_TOKEN`, which Vercel sets automatically on deployed apps.
|
|
32
|
+
*/
|
|
33
|
+
aiGatewayKey?: string;
|
|
22
34
|
/**
|
|
23
35
|
* Who may use this route. Return false to reject with 403. Without it,
|
|
24
36
|
* anyone who can reach your site can spend your API credit: check your
|
|
@@ -26,7 +38,7 @@ export interface ChaiHandlerOptions {
|
|
|
26
38
|
*/
|
|
27
39
|
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
28
40
|
/**
|
|
29
|
-
* The only model ids this route will run, across
|
|
41
|
+
* The only model ids this route will run, across all providers (e.g.
|
|
30
42
|
* `"fal-ai/flux/schnell"`, `"anthropic/claude-opus-5"`). Anything else is
|
|
31
43
|
* rejected with 403, so nobody can run an expensive model through you.
|
|
32
44
|
* Omit to allow every model.
|
|
@@ -79,6 +91,26 @@ export function createChaiHandler(options: ChaiHandlerOptions = {}): ChaiHandler
|
|
|
79
91
|
});
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
// Vercel AI Gateway is OpenAI-compatible: /chat for text, /images for image generation.
|
|
95
|
+
if (route.provider === "vercel") {
|
|
96
|
+
const upstream = route.rest === "chat" ? "chat/completions" : route.rest === "images" ? "images/generations" : null;
|
|
97
|
+
if (!upstream) return fail(404, `Unknown Vercel AI Gateway path "${route.rest}".`);
|
|
98
|
+
if (request.method !== "POST") return fail(405, "Use POST.");
|
|
99
|
+
const key = options.aiGatewayKey ?? readEnv("AI_GATEWAY_API_KEY") ?? readEnv("VERCEL_OIDC_TOKEN");
|
|
100
|
+
if (!key) return fail(500, "AI_GATEWAY_API_KEY isn't set on the server.");
|
|
101
|
+
const body = await request.text();
|
|
102
|
+
const model = readModel(body);
|
|
103
|
+
if (options.allowedModels && !(model && options.allowedModels.includes(model))) {
|
|
104
|
+
return fail(403, `Model "${model ?? "(none)"}" isn't allowed on this server.`);
|
|
105
|
+
}
|
|
106
|
+
return forward(fetchImpl, route.provider, `${VERCEL_GATEWAY_BASE}/${upstream}`, {
|
|
107
|
+
signal: request.signal,
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
110
|
+
body,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
// Fal's queue API: POST /<model> submits, GET /<model>/requests/<id>[/status]
|
|
83
115
|
// polls, PUT /<model>/requests/<id>/cancel cancels.
|
|
84
116
|
const isCancel = request.method === "PUT" && route.rest.endsWith("/cancel");
|
|
@@ -102,7 +134,7 @@ export function createChaiHandler(options: ChaiHandlerOptions = {}): ChaiHandler
|
|
|
102
134
|
/** Finds the provider segment anywhere in the path, so the route can be mounted at any prefix. */
|
|
103
135
|
function parseRoute(pathname: string): { provider: ChaiProviderId; rest: string } | null {
|
|
104
136
|
const parts = pathname.split("/").filter(Boolean);
|
|
105
|
-
const i = parts.findIndex((p) => p === "fal" || p === "openrouter");
|
|
137
|
+
const i = parts.findIndex((p) => p === "fal" || p === "openrouter" || p === "vercel");
|
|
106
138
|
if (i === -1 || i === parts.length - 1) return null;
|
|
107
139
|
return { provider: parts[i] as ChaiProviderId, rest: parts.slice(i + 1).join("/") };
|
|
108
140
|
}
|
|
@@ -116,11 +148,11 @@ async function forward(fetchImpl: typeof fetch, provider: ChaiProviderId, url: s
|
|
|
116
148
|
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json" },
|
|
117
149
|
});
|
|
118
150
|
} catch (err) {
|
|
119
|
-
return errorResponse(provider, 502, `Couldn't reach ${provider
|
|
151
|
+
return errorResponse(provider, 502, `Couldn't reach ${PROVIDER_LABEL[provider]}: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
152
|
}
|
|
121
153
|
}
|
|
122
154
|
|
|
123
|
-
/** Errors in the shape each engine already reads: Fal's `{ error }`, OpenRouter's `{ error: { message } }`. */
|
|
155
|
+
/** Errors in the shape each engine already reads: Fal's `{ error }`, OpenRouter's and Vercel's `{ error: { message } }`. */
|
|
124
156
|
function errorResponse(provider: ChaiProviderId, status: number, message: string): Response {
|
|
125
157
|
const body = provider === "fal" ? { error: message } : { error: { message } };
|
|
126
158
|
return Response.json(body, { status });
|
package/src/suggested-models.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { DEFAULT_MAX_REGIONS } from "./region-edit.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Suggested models, so builders don't have to write model lists. Composer
|
|
6
6
|
* and MediaAnalyzer use them when no `models` is passed; passing `models`
|
|
7
|
-
* still replaces them.
|
|
7
|
+
* still replaces them. Nothing is picked for the person: with a Model menu
|
|
8
|
+
* they pick or turn on auto-select; with no menu, the list's first runs.
|
|
8
9
|
*
|
|
9
10
|
* Generation runs on Fal (the default engine), analysis on OpenRouter (the
|
|
10
11
|
* reasoning engine). Every entry was checked against the provider's own
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice input for the Composer: record from the microphone, send the clip
|
|
3
|
+
* to a speech-to-text model, and hand back the transcript. Framework-free,
|
|
4
|
+
* so React and Vue each add only the button.
|
|
5
|
+
*/
|
|
6
|
+
import { abortError, isAbortError } from "./engine.js";
|
|
7
|
+
import { createFalTranscriber } from "./engines/fal.js";
|
|
8
|
+
|
|
9
|
+
/** A recorded clip, as a data URL. */
|
|
10
|
+
export interface VoiceClip {
|
|
11
|
+
src: string;
|
|
12
|
+
kind: "audio";
|
|
13
|
+
/** What the browser recorded, e.g. `"audio/webm;codecs=opus"` (Chrome, Firefox) or `"audio/mp4"` (Safari). */
|
|
14
|
+
mimeType: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Turns a recorded clip into text. Reject with an `AbortError` when `signal` aborts. */
|
|
18
|
+
export type Transcribe = (audio: VoiceClip, options?: { signal?: AbortSignal }) => Promise<string>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Turns on the Composer's mic. Name a Fal speech-to-text model and Chai
|
|
22
|
+
* sends the clip through your existing `/api/chai/fal` route, or pass your
|
|
23
|
+
* own `transcribe` for any other provider. There's no default model: the
|
|
24
|
+
* mic shows only once you choose one.
|
|
25
|
+
*/
|
|
26
|
+
export type VoiceInputConfig = (
|
|
27
|
+
| { model: string; transcribe?: never; endpoint?: string }
|
|
28
|
+
| { transcribe: Transcribe; model?: never; endpoint?: never }
|
|
29
|
+
) & {
|
|
30
|
+
/** Longest recording, in seconds. Recording stops and transcribes at this length. Defaults to 60. */
|
|
31
|
+
maxSeconds?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type VoiceInputStatus = "idle" | "recording" | "transcribing" | "error";
|
|
35
|
+
|
|
36
|
+
export interface VoiceInputState {
|
|
37
|
+
status: VoiceInputStatus;
|
|
38
|
+
/** How long the current recording has run. */
|
|
39
|
+
elapsedMs: number;
|
|
40
|
+
/** A short message for the person while `status` is "error". */
|
|
41
|
+
error: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The microphone's recent loudness while recording, oldest first, each
|
|
44
|
+
* 0–1: what a waveform draws, so the person sees they're being heard.
|
|
45
|
+
* Empty when not recording, or when the browser has no Web Audio.
|
|
46
|
+
*/
|
|
47
|
+
levels: number[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** How many level readings a waveform shows. */
|
|
51
|
+
export const VOICE_LEVEL_COUNT = 48;
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_VOICE_MAX_SECONDS = 60;
|
|
54
|
+
|
|
55
|
+
/** Recording formats, best first. Chrome and Firefox record WebM, Safari MP4. */
|
|
56
|
+
const MIME_TYPES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
57
|
+
|
|
58
|
+
/** The `Transcribe` a config describes: its own, or a Fal transcriber for its model. */
|
|
59
|
+
export function voiceTranscriber(config: VoiceInputConfig): Transcribe {
|
|
60
|
+
return config.transcribe ?? createFalTranscriber({ model: config.model!, endpoint: config.endpoint });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** "0:05", "1:00": how long a recording has run. */
|
|
64
|
+
export function formatVoiceElapsed(ms: number): string {
|
|
65
|
+
const s = Math.floor(ms / 1000);
|
|
66
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Why the microphone couldn't start, in words for the person. */
|
|
70
|
+
export function describeMicError(err: unknown): string {
|
|
71
|
+
const { name, message } = (err as { name?: string; message?: string } | null) ?? {};
|
|
72
|
+
// Chrome's wording when the computer, not the site, blocks the browser
|
|
73
|
+
// (macOS Privacy & Security, Windows microphone privacy).
|
|
74
|
+
if (name === "NotAllowedError" && /system/i.test(message ?? "")) {
|
|
75
|
+
return "Your computer is blocking this browser from the microphone. Allow it in your system's privacy settings, then reload.";
|
|
76
|
+
}
|
|
77
|
+
if (name === "NotAllowedError" || name === "SecurityError") {
|
|
78
|
+
return "Microphone access is blocked. Allow it in your browser's site settings to use voice input.";
|
|
79
|
+
}
|
|
80
|
+
if (name === "NotFoundError" || name === "OverconstrainedError") return "No microphone found.";
|
|
81
|
+
if (name === "NotReadableError") return "The microphone is in use by another app.";
|
|
82
|
+
return "The microphone couldn't start.";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface VoiceInputDeps {
|
|
86
|
+
/** Defaults to the browser's `navigator.mediaDevices.getUserMedia`. */
|
|
87
|
+
getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
|
|
88
|
+
/** Defaults to the browser's `MediaRecorder`. */
|
|
89
|
+
MediaRecorder?: typeof MediaRecorder;
|
|
90
|
+
/** Defaults to the browser's `AudioContext`, for the waveform. Without one, recording still works. */
|
|
91
|
+
AudioContext?: typeof AudioContext;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The recorder behind the mic button. `start` asks for the microphone and
|
|
96
|
+
* records; `stop` ends the recording and transcribes it; `cancel` stops
|
|
97
|
+
* either without a transcript. `onTranscript` gets the text: it never
|
|
98
|
+
* submits, so the person can fix it first.
|
|
99
|
+
*/
|
|
100
|
+
export function createVoiceInput(
|
|
101
|
+
getConfig: () => VoiceInputConfig,
|
|
102
|
+
onTranscript: (text: string) => void,
|
|
103
|
+
deps: VoiceInputDeps = {}
|
|
104
|
+
) {
|
|
105
|
+
let state: VoiceInputState = { status: "idle", elapsedMs: 0, error: null, levels: [] };
|
|
106
|
+
const listeners = new Set<() => void>();
|
|
107
|
+
const set = (next: Partial<VoiceInputState>) => {
|
|
108
|
+
state = { ...state, ...next };
|
|
109
|
+
listeners.forEach((l) => l());
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let recorder: MediaRecorder | null = null;
|
|
113
|
+
let stream: MediaStream | null = null;
|
|
114
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
115
|
+
let controller: AbortController | null = null;
|
|
116
|
+
let discard = false;
|
|
117
|
+
let audio: AudioContext | null = null;
|
|
118
|
+
let readLevel: (() => number) | null = null;
|
|
119
|
+
|
|
120
|
+
function release() {
|
|
121
|
+
if (timer) clearInterval(timer);
|
|
122
|
+
timer = null;
|
|
123
|
+
void audio?.close().catch(() => {});
|
|
124
|
+
audio = null;
|
|
125
|
+
readLevel = null;
|
|
126
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
127
|
+
stream = null;
|
|
128
|
+
recorder = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function start() {
|
|
132
|
+
if (state.status === "recording" || state.status === "transcribing") return;
|
|
133
|
+
const Recorder = deps.MediaRecorder ?? (typeof MediaRecorder === "undefined" ? undefined : MediaRecorder);
|
|
134
|
+
const getUserMedia =
|
|
135
|
+
deps.getUserMedia ?? (typeof navigator !== "undefined" ? navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices) : undefined);
|
|
136
|
+
if (!Recorder || !getUserMedia) return set({ status: "error", error: "Voice input isn't supported in this browser." });
|
|
137
|
+
try {
|
|
138
|
+
stream = await getUserMedia({ audio: true });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return set({ status: "error", error: describeMicError(err) });
|
|
141
|
+
}
|
|
142
|
+
const mimeType = MIME_TYPES.find((t) => Recorder.isTypeSupported?.(t));
|
|
143
|
+
const chunks: Blob[] = [];
|
|
144
|
+
discard = false;
|
|
145
|
+
recorder = new Recorder(stream, mimeType ? { mimeType } : undefined);
|
|
146
|
+
recorder.ondataavailable = (e) => {
|
|
147
|
+
if (e.data.size > 0) chunks.push(e.data);
|
|
148
|
+
};
|
|
149
|
+
recorder.onstop = () => {
|
|
150
|
+
const type = recorder?.mimeType || mimeType || "audio/webm";
|
|
151
|
+
release();
|
|
152
|
+
if (discard) return set({ status: "idle", elapsedMs: 0, levels: [] });
|
|
153
|
+
void transcribe(new Blob(chunks, { type }), type);
|
|
154
|
+
};
|
|
155
|
+
recorder.start();
|
|
156
|
+
readLevel = listen(stream);
|
|
157
|
+
const startedAt = Date.now();
|
|
158
|
+
const maxMs = (getConfig().maxSeconds ?? DEFAULT_VOICE_MAX_SECONDS) * 1000;
|
|
159
|
+
set({ status: "recording", elapsedMs: 0, error: null, levels: [] });
|
|
160
|
+
timer = setInterval(() => {
|
|
161
|
+
const elapsedMs = Date.now() - startedAt;
|
|
162
|
+
const levels = readLevel ? [...state.levels, readLevel()].slice(-VOICE_LEVEL_COUNT) : state.levels;
|
|
163
|
+
set({ elapsedMs, levels });
|
|
164
|
+
if (elapsedMs >= maxMs) stop();
|
|
165
|
+
}, 80);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Reads the stream's loudness, 0–1, through Web Audio. Null without it. */
|
|
169
|
+
function listen(source: MediaStream): (() => number) | null {
|
|
170
|
+
const Context = deps.AudioContext ?? (typeof AudioContext === "undefined" ? undefined : AudioContext);
|
|
171
|
+
if (!Context) return null;
|
|
172
|
+
try {
|
|
173
|
+
audio = new Context();
|
|
174
|
+
const analyser = audio.createAnalyser();
|
|
175
|
+
analyser.fftSize = 256;
|
|
176
|
+
audio.createMediaStreamSource(source).connect(analyser);
|
|
177
|
+
const samples = new Uint8Array(analyser.fftSize);
|
|
178
|
+
return () => {
|
|
179
|
+
analyser.getByteTimeDomainData(samples);
|
|
180
|
+
let sum = 0;
|
|
181
|
+
for (const v of samples) sum += ((v - 128) / 128) ** 2;
|
|
182
|
+
// Speech sits low on this scale: lift it so a normal voice fills the bars.
|
|
183
|
+
return Math.min(1, Math.sqrt(sum / samples.length) * 4);
|
|
184
|
+
};
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function transcribe(blob: Blob, mimeType: string) {
|
|
191
|
+
if (blob.size === 0) return set({ status: "error", error: "Nothing was recorded. Try again." });
|
|
192
|
+
set({ status: "transcribing", levels: [] });
|
|
193
|
+
controller = new AbortController();
|
|
194
|
+
try {
|
|
195
|
+
const src = await readAsDataUrl(blob);
|
|
196
|
+
if (controller.signal.aborted) throw abortError();
|
|
197
|
+
const text = (await voiceTranscriber(getConfig())({ src, kind: "audio", mimeType }, { signal: controller.signal })).trim();
|
|
198
|
+
if (!text) return set({ status: "error", error: "Didn't catch that. Try again." });
|
|
199
|
+
onTranscript(text);
|
|
200
|
+
set({ status: "idle", elapsedMs: 0, levels: [] });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (isAbortError(err)) return set({ status: "idle", elapsedMs: 0, levels: [] });
|
|
203
|
+
set({ status: "error", error: "Transcription failed. Try again." });
|
|
204
|
+
} finally {
|
|
205
|
+
controller = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Ends the recording and transcribes it. */
|
|
210
|
+
function stop() {
|
|
211
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Stops recording or transcribing, with no transcript. */
|
|
215
|
+
function cancel() {
|
|
216
|
+
if (state.status === "recording") {
|
|
217
|
+
discard = true;
|
|
218
|
+
stop();
|
|
219
|
+
} else if (state.status === "transcribing") controller?.abort();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Clears an error, back to the idle mic. */
|
|
223
|
+
function dismiss() {
|
|
224
|
+
if (state.status === "error") set({ status: "idle", error: null, elapsedMs: 0, levels: [] });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
getState: () => state,
|
|
229
|
+
subscribe(listener: () => void) {
|
|
230
|
+
listeners.add(listener);
|
|
231
|
+
return () => listeners.delete(listener);
|
|
232
|
+
},
|
|
233
|
+
start,
|
|
234
|
+
stop,
|
|
235
|
+
cancel,
|
|
236
|
+
dismiss,
|
|
237
|
+
/** Stops everything, for when the Composer goes away. */
|
|
238
|
+
dispose() {
|
|
239
|
+
discard = true;
|
|
240
|
+
controller?.abort();
|
|
241
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
242
|
+
release();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type VoiceInput = ReturnType<typeof createVoiceInput>;
|
|
248
|
+
|
|
249
|
+
/** A clip's data URL, for the model's `audio_url`. */
|
|
250
|
+
function readAsDataUrl(blob: Blob): Promise<string> {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const reader = new FileReader();
|
|
253
|
+
reader.onload = () => resolve(String(reader.result));
|
|
254
|
+
reader.onerror = () => reject(reader.error);
|
|
255
|
+
reader.readAsDataURL(blob);
|
|
256
|
+
});
|
|
257
|
+
}
|