import { COMPOSER_VOICE_INPUT_START_EVENT, composerVoiceEventScope, } from "../composer-voice-events"; import type { ClientVoiceInputConfig, OpenGeniClient, TranscriptionAdapter, WorkspaceTranscriptionPolicy, } from "@opengeni/sdk"; import { ClipboardPasteIcon, LoaderCircleIcon, MicIcon, RefreshCwIcon, SquareIcon, Trash2Icon, XIcon, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/cn"; import { useComposerFooterSignal } from "../lib/composer-footer-signals"; import { useVoiceInput } from "../hooks/use-voice-input"; import type { VoiceRecordingStore } from "../voice-recording-store"; import { useChatComposer } from "./composer"; import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; import { ShimmerText } from "../timeline/shimmer-text"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; function Tip({ tip, children }: { tip: string; children: ReactElement }) { return ( {children} {tip} ); } export type ComposerTranscriptionMessages = { start: string; stop: string; cancel: string; retry: string; /** Transcribe a recording that was stopped without transcribing or recovered. */ transcribeSaved: string; /** Shown near the automatic stop; `{seconds}` is replaced. */ timeRemaining: string; pauseRetrying: string; requestingPermission: string; recording: string; saving: string; transcribing: string; retrying: string; recovered: string; recoveredTranscript: string; insertRecoveredTranscript: string; discardRecovered: string; unavailableDisabled: string; unavailable: string; errorPermissionDenied: string; errorNotSupported: string; errorUnavailable: string; errorInsufficientCredits: string; errorAllowanceExhausted: string; errorPolicyBlocked: string; errorTooLarge: string; errorInvalidAudio: string; errorStorageUnavailable: string; errorRetryable: string; errorHandoffUncertain: string; errorUnknown: string; }; const defaultMessages = (t: OpenGeniText): ComposerTranscriptionMessages => ({ start: t("composerTranscriptionControl.startVoiceInput"), stop: t("composerTranscriptionControl.stopAndTranscribe"), cancel: t("composerTranscriptionControl.stopWithoutTranscribing"), retry: t("composerTranscriptionControl.retryVoiceInput"), transcribeSaved: t("composerTranscriptionControl.transcribeSavedRecording"), timeRemaining: t("composerTranscriptionControl.stopsAndTranscribesInSeconds", { seconds: "{seconds}", }), pauseRetrying: t("composerTranscriptionControl.pauseAutomaticRetry"), requestingPermission: t("composerTranscriptionControl.requestingMicrophone"), recording: t("composerTranscriptionControl.recordingPressEscapeToStop"), saving: t("composerTranscriptionControl.savingAudioLocally"), transcribing: t("composerTranscriptionControl.transcribing"), retrying: t("composerTranscriptionControl.savedRetryingTranscription"), recovered: t("composerTranscriptionControl.recordingSavedOnThisDevice"), recoveredTranscript: t("composerTranscriptionControl.transcriptSavedInsertItInto"), insertRecoveredTranscript: t("composerTranscriptionControl.insertSavedTranscript"), discardRecovered: t("composerTranscriptionControl.discardSavedRecording"), unavailableDisabled: t("composerTranscriptionControl.voiceInputIsUnavailableWhile"), unavailable: t("composerTranscriptionControl.voiceInputIsUnavailableFor"), errorPermissionDenied: t("composerTranscriptionControl.microphoneAccessIsBlockedAllow"), errorNotSupported: t("composerTranscriptionControl.voiceInputIsNotSupported"), errorUnavailable: t("composerTranscriptionControl.voiceInputIsNotConfigured"), errorInsufficientCredits: t("composerTranscriptionControl.voiceInputNeedsCreditsRecording"), errorAllowanceExhausted: t("composerTranscriptionControl.usageLimitReachedRecordingSaved"), errorPolicyBlocked: t("composerTranscriptionControl.voiceInputIsnTAllowed"), errorTooLarge: t("composerTranscriptionControl.recordingIsTooLargeTry"), errorInvalidAudio: t("composerTranscriptionControl.theRecordingCouldNotBe"), errorStorageUnavailable: t("composerTranscriptionControl.voiceInputStoppedBecauseAudio"), errorRetryable: t("composerTranscriptionControl.savedTranscriptionFailedRetryWhen"), errorHandoffUncertain: t("composerTranscriptionControl.transcriptSavedCheckYourDraft"), errorUnknown: t("composerTranscriptionControl.voiceInputCouldNotStart"), }); export type ComposerTranscriptionControlProps = { client?: Pick | null | undefined; workspaceId?: string | undefined; capability?: ClientVoiceInputConfig | null | undefined; workspaceEnabled?: boolean | undefined; /** @deprecated Native MediaRecorder input no longer uses host adapters. */ adapter?: TranscriptionAdapter | null | undefined; /** @deprecated Native MediaRecorder input no longer uses host policies. */ policy?: WorkspaceTranscriptionPolicy | undefined; /** @deprecated Native input uses the server capability duration limit. */ lifecycleTimeouts?: unknown; /** @deprecated Native input does not expose provider diagnostics. */ onDiagnostic?: unknown; messages?: Partial | undefined; className?: string | undefined; /** Test/embed seam. Production defaults to origin-scoped IndexedDB. */ createRecordingStore?: (() => VoiceRecordingStore) | undefined; /** Test/embed seam. Production acquires a coordinated browser-document owner lease. */ createOwnerId?: (() => string) | undefined; /** Hide while realtime voice owns the mic — collapses with a layout-friendly exit. */ suppressed?: boolean | undefined; }; const WAVEFORM_BARS = 18; const WAVEFORM_BAR_KEYS = Array.from( { length: WAVEFORM_BARS }, (_, index) => `voice-waveform-bar-${index + 1}`, ); /** One provider-neutral microphone control for the nearest editable composer. */ export function ComposerTranscriptionControl({ client = null, workspaceId = "", capability = null, workspaceEnabled = false, messages: overrides, className, createRecordingStore, createOwnerId, suppressed = false, }: ComposerTranscriptionControlProps) { const t = useOpenGeniText(); const composer = useChatComposer(); const messages = { ...defaultMessages(t), ...overrides }; const transcription = useVoiceInput({ client, workspaceId, capability, enabled: workspaceEnabled && !suppressed, value: composer.value, setValue: composer.setValue, focusInput: composer.focusInput, disabled: composer.disabled, createRecordingStore, createOwnerId, }); const { status } = transcription; const retrying = status === "retrying"; const capturing = status === "requesting-permission" || status === "recording"; const cancelTranscription = transcription.cancel; const discardTranscription = transcription.discard; const hasRecoverableRecording = transcription.hasRecoverableRecording; useEffect(() => { if (!suppressed) return; if (status === "recording" || status === "requesting-permission") { cancelTranscription(); } else if (status === "error" && !hasRecoverableRecording) { // Live voice took over: a stale start error (nothing saved) would only // duplicate whatever live voice reports next to it. void discardTranscription(); } }, [cancelTranscription, discardTranscription, hasRecoverableRecording, status, suppressed]); const active = capturing || status === "saving" || status === "transcribing"; const recoverable = transcription.hasRecoverableRecording && (retrying || status === "recovered" || status === "transcript-ready" || status === "error"); const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null; const unavailableMessage = composer.disabled ? messages.unavailableDisabled : !capability?.available || !workspaceEnabled ? messages.unavailable : !transcription.available ? messages.errorStorageUnavailable : null; const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start); const errorMessage = transcription.error ? transcriptionErrorMessage(transcription.error, messages) : null; const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "saving" ? messages.saving : status === "transcribing" ? messages.transcribing : retrying ? messages.retrying : status === "recovered" ? messages.recovered : status === "transcript-ready" ? messages.recoveredTranscript : status === "error" ? (errorMessage ?? messages.errorUnknown) : unavailableMessage; const recoveredLabel = savedTranscript ? (errorMessage ?? messages.recoveredTranscript) : retrying ? messages.retrying : status === "error" ? (errorMessage ?? messages.errorRetryable) : messages.recovered; function start(event: MouseEvent) { if (unavailableMessage) { event.preventDefault(); return; } composerVoiceEventScope(event.currentTarget)?.dispatchEvent( new Event(COMPOSER_VOICE_INPUT_START_EVENT), ); void transcription.start(); } const needsAttention = !capturing && (recoverable || (status === "error" && !!errorMessage)); useComposerFooterSignal("transcription-capturing", !suppressed && capturing); useComposerFooterSignal("transcription-attention", !suppressed && needsAttention); return ( {suppressed ? null : ( {recoverable ? ( {recoveredLabel} ) : active ? ( {status === "requesting-permission" ? ( ) : ( {status === "recording" ? ( ) : null} {status === "recording" && transcription.recordingStartedAt !== null ? ( ) : null} )} {status === "recording" ? ( <> ) : status === "transcribing" || status === "saving" ? ( {status === "saving" ? messages.saving : messages.transcribing} ) : ( {messages.requestingPermission} )} ) : ( )} {status === "error" && errorMessage && !recoverable ? ( // Nothing is saved in this state, so dismissing loses no speech. ) : null} {announcement} )} ); } /** Elapsed dictation time; warns before the automatic stop-and-transcribe. */ function RecordingClock({ startedAt, maxSeconds, remainingLabel, }: { startedAt: number; maxSeconds: number | null; remainingLabel: string; }) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 500); return () => clearInterval(timer); }, []); const elapsed = Math.max(0, Math.floor((now - startedAt) / 1_000)); const remaining = maxSeconds === null ? null : Math.max(0, maxSeconds - elapsed); const warn = remaining !== null && remaining <= 15; const label = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`; return ( {label} {warn ? ( {remainingLabel.replace("{seconds}", String(remaining))} ) : null} ); } function VoiceWaveform({ stream, mode, }: { stream: MediaStream | null; mode: "recording" | "transcribing"; }) { const [levels, setLevels] = useState(() => restingLevels(WAVEFORM_BARS)); const [live, setLive] = useState(false); useEffect(() => { if (!stream || mode !== "recording") { setLive(false); setLevels(restingLevels(WAVEFORM_BARS)); return; } const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioCtx) return; let cancelled = false; let frame = 0; let context: AudioContext | null = null; try { context = new AudioCtx(); const source = context.createMediaStreamSource(stream); const analyser = context.createAnalyser(); analyser.fftSize = 64; analyser.smoothingTimeConstant = 0.8; source.connect(analyser); const bins = new Uint8Array(analyser.frequencyBinCount); const tick = () => { if (cancelled) return; analyser.getByteFrequencyData(bins); const next = new Array(WAVEFORM_BARS); const usable = Math.max(8, Math.floor(bins.length * 0.65)); for (let i = 0; i < WAVEFORM_BARS; i++) { const start = Math.floor((i / WAVEFORM_BARS) * usable); const end = Math.max(start + 1, Math.floor(((i + 1) / WAVEFORM_BARS) * usable)); let sum = 0; for (let j = start; j < end; j++) sum += bins[j] ?? 0; const avg = sum / (end - start) / 255; next[i] = Math.min(1, 0.14 + avg * 1.65); } setLevels(next); setLive(true); frame = requestAnimationFrame(tick); }; void context.resume().then(() => { if (!cancelled) frame = requestAnimationFrame(tick); }); } catch { setLive(false); } return () => { cancelled = true; cancelAnimationFrame(frame); void context?.close(); }; }, [stream, mode]); return ( {WAVEFORM_BAR_KEYS.map((key, index) => { const level = levels[index] ?? 0; return ( ); })} ); } function restingLevels(count: number): number[] { return Array.from({ length: count }, (_, index) => 0.22 + ((index * 17) % 5) * 0.06); } function transcriptionErrorMessage(code: string, messages: ComposerTranscriptionMessages): string { switch (code) { case "insufficient_credits": return messages.errorInsufficientCredits; case "allowance_exhausted": case "monthly_model_cost_limit": return messages.errorAllowanceExhausted; case "policy_blocked": return messages.errorPolicyBlocked; case "permission_denied": return messages.errorPermissionDenied; case "not_supported": return messages.errorNotSupported; case "unavailable": return messages.errorUnavailable; case "too_large": return messages.errorTooLarge; case "invalid_audio": return messages.errorInvalidAudio; case "storage_unavailable": return messages.errorStorageUnavailable; case "network": case "provider": case "timeout": return messages.errorRetryable; case "handoff_uncertain": return messages.errorHandoffUncertain; case "unknown": return messages.errorUnknown; default: return messages.errorUnknown; } }