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;
}
}