@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
136 lines (120 loc) • 4.45 kB
text/typescript
import { createSignal, onCleanup } from 'solid-js';
// Minimal Web Speech API surface. The DOM lib doesn't ship these types reliably
// across TS versions, so we declare only what we touch. `webkitSpeechRecognition`
// is the Chrome/Safari-prefixed constructor; the unprefixed one is rare.
interface SpeechRecognitionAlternative {
transcript: string;
}
interface SpeechRecognitionResult {
readonly isFinal: boolean;
readonly length: number;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEventLike {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionLike {
lang: string;
continuous: boolean;
interimResults: boolean;
start(): void;
stop(): void;
abort(): void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: { error?: string }) => void) | null;
onend: (() => void) | null;
}
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
function getCtor(): SpeechRecognitionCtor | undefined {
if (typeof window === 'undefined') return undefined;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
}
export interface UseSpeechRecognitionOptions {
/** Emit live, non-final partials via the `onInterim` callback passed to start(). */
interim?: boolean;
}
export interface SpeechRecognitionStartOptions {
/** BCP-47 language tag, e.g. `en-US`. */
lang?: string;
/** Called with each interim (non-final) transcript when `interim` is enabled. */
onInterim?: (text: string) => void;
}
/**
* Wrap the browser's Web Speech `SpeechRecognition` (Chrome/Safari; no Firefox).
* Mirrors `useVoiceRecorder`'s shape: an `isListening` signal, an `error` signal,
* and `start`/`stop` controls. `start()` resolves with the final transcript when
* recognition ends; `stop()` ends the in-progress session (resolving start()).
*
* Caveat (documented at the element): in Chrome this is cloud-based — audio is
* sent to Google — so it is "native to the browser," not on-device/private.
*/
export function useSpeechRecognition(options: UseSpeechRecognitionOptions = {}) {
const [isListening, setIsListening] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const isSupported = getCtor() !== undefined;
let recognition: SpeechRecognitionLike | undefined;
let resolveText: ((text: string) => void) | undefined;
let finalText = '';
function start(opts: SpeechRecognitionStartOptions = {}): Promise<string> {
setError(null);
finalText = '';
const Ctor = getCtor();
if (!Ctor) {
const message = 'SpeechRecognition is not supported in this browser';
setError(message);
return Promise.reject(new Error(message));
}
try {
recognition = new Ctor();
if (opts.lang) recognition.lang = opts.lang;
recognition.interimResults = options.interim === true;
recognition.continuous = false;
recognition.onresult = (event) => {
let interim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const transcript = result[0]?.transcript ?? '';
if (result.isFinal) {
finalText += transcript;
} else {
interim += transcript;
}
}
if (interim && opts.onInterim) opts.onInterim(interim);
};
recognition.onerror = (event) => {
setError(event.error ?? 'Speech recognition error');
};
recognition.onend = () => {
setIsListening(false);
resolveText?.(finalText);
recognition = undefined;
};
recognition.start();
setIsListening(true);
return new Promise<string>((resolve) => { resolveText = resolve; });
} catch (err) {
setError(err instanceof Error ? err.message : 'Speech recognition failed to start');
setIsListening(false);
throw err;
}
}
function stop() {
if (recognition && isListening()) {
recognition.stop();
}
}
onCleanup(() => {
if (recognition) recognition.abort();
});
return { isSupported, isListening, error, start, stop };
}