import { splitProps, Show, For, createSignal, createEffect, on } from 'solid-js'; import { cn } from '../utils/cn'; import { Button } from '../ui/button'; import { Tooltip } from '../ui/tooltip'; import { useVoiceRecorder } from '../primitives/use-voice-recorder'; import { useSpeechRecognition } from '../primitives/use-speech-recognition'; /** Imperative handle exposed via `controllerRef` — surfaces the recorder's latent * start/stop so the `` facade can forward them as instance * methods (push-to-talk). Both run the SAME getUserMedia → blob → transcription * path as clicking the mic, so manual + programmatic emit identically. */ export interface VoiceInputController { /** Begin recording programmatically (same path as clicking the mic). */ start(): void; /** Stop the in-progress recording (produces the blob → onTranscribe). */ stop(): void; } export interface VoiceInputProps { onTranscribe: (audio: Blob) => Promise; onTranscription: (text: string) => void; disabled?: boolean; class?: string; /** Host supplied a `transcribe` callback. When true the MediaRecorder → * onTranscribe path runs. When false the component prefers native * SpeechRecognition (capable browsers), falling back to record-only. */ hasTranscribe?: boolean; /** BCP-47 language tag for native recognition (e.g. `en-US`). */ lang?: string; /** Emit live partial transcripts via `onInterim` (native path only). */ interim?: boolean; /** Live partial transcript from native recognition (when `interim`). */ onInterim?: (text: string) => void; /** Fires whenever recording starts or stops. Guarded against the spurious * initial `false` — only true transitions emit (the facade maps this to * kai-recording-change). */ onRecordingChange?: (recording: boolean) => void; /** Receive the imperative controller once mounted. The `` * facade forwards these as element methods (start/stop). */ controllerRef?: (controller: VoiceInputController) => void; } export function VoiceInput(props: VoiceInputProps) { const [local] = splitProps(props, ['onTranscribe', 'onTranscription', 'disabled', 'class']); const { isRecording, start, stop } = useVoiceRecorder(); const speech = useSpeechRecognition({ interim: props.interim }); const [isProcessing, setIsProcessing] = createSignal(false); // Path selection (§6): a `transcribe` callback always wins (MediaRecorder → // host transcriber). Otherwise prefer native SpeechRecognition when the browser // supports it. The unsupported-no-callback case falls through to recording the // blob (→ onTranscribe → kai-audio-captured) with no text. const useNative = () => !props.hasTranscribe && speech.isSupported; const label = () => isProcessing() ? 'Transcribing...' : isActive() ? 'Stop recording' : 'Voice input'; const isActive = () => (useNative() ? speech.isListening() : isRecording()); // Native path: SpeechRecognition resolves with the final transcript when it // ends (click again or controller.stop()). Final text → onTranscription. async function beginRecognition() { try { const text = await speech.start({ lang: props.lang, onInterim: props.interim ? (t) => props.onInterim?.(t) : undefined, }); if (text.trim()) local.onTranscription(text.trim()); } catch { /* surfaced via speech.error; the mic returns to idle */ } } // Begin recording, then run transcription once the blob resolves. Shared by the // mic click and the imperative start() so both flows emit identically. start() // resolves its Blob when the recorder stops (click again or controller.stop()). async function beginRecording() { try { const blob = await start(); setIsProcessing(true); try { const text = await local.onTranscribe(blob); if (text.trim()) local.onTranscription(text.trim()); } finally { setIsProcessing(false); } } catch { setIsProcessing(false); } } // Drive whichever path is active (§6: start/stop must follow the live path). function begin() { if (useNative()) void beginRecognition(); else void beginRecording(); } function end() { if (useNative()) speech.stop(); else stop(); } async function handleClick() { if (isActive()) end(); else begin(); } // Emit recording transitions to the host. `on(..., { defer: true })` skips the // initial mount value (a spurious {recording:false}) — only real start/stop // transitions fire. Drives BOTH manual (click) and programmatic (start/stop) // since they all flip the active path's signal (MediaRecorder or recognition). createEffect(on(isActive, (recording) => { props.onRecordingChange?.(recording); }, { defer: true })); // Imperative controller (Pattern C): hand the facade a start/stop handle. // start() runs the active path; stop() ends the in-progress session (resolving // the active path's promise). Guard start() so a double-start while already // active is a no-op (matches the click toggle). props.controllerRef?.({ start: () => { if (!isActive()) begin(); }, stop: () => end(), }); // Mic icon (not recording) const MicIcon = () => ( ); // Square stop icon (recording) const StopIcon = () => ( ); // Spinner (processing transcription) const Spinner = () => ( ); return (
{/* Animated pulse rings when recording */} {(index) => (
)}
); }