Add web dictation recorder and shortcuts
This commit is contained in:
@@ -261,6 +261,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return false;
|
return false;
|
||||||
@@ -457,6 +458,44 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
}
|
}
|
||||||
}, [isAgentRunning, ws.isConnected]);
|
}, [isAgentRunning, ws.isConnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_WEB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictationShortcut = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
|
||||||
|
if (event.metaKey && !event.altKey && !event.ctrlKey && key === "d") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isRecording) {
|
||||||
|
void handleSendRecording();
|
||||||
|
} else if (!isRealtimeMode && shouldShowVoiceControls && ws.isConnected) {
|
||||||
|
void handleVoicePress();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "escape" && isRecording) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleCancelRecording();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleDictationShortcut);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleDictationShortcut);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isRecording,
|
||||||
|
isRealtimeMode,
|
||||||
|
shouldShowVoiceControls,
|
||||||
|
ws,
|
||||||
|
handleVoicePress,
|
||||||
|
handleSendRecording,
|
||||||
|
handleCancelRecording,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
focusWebTextInput();
|
focusWebTextInput();
|
||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./use-audio-recorder.native";
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface AudioCaptureConfig {
|
||||||
|
sampleRate?: number;
|
||||||
|
numberOfChannels?: number;
|
||||||
|
bitRate?: number;
|
||||||
|
onAudioLevel?: (level: number) => void;
|
||||||
|
onSpeechSegment?: (audioBlob: Blob) => void;
|
||||||
|
enableContinuousRecording?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_TYPE_CANDIDATES = [
|
||||||
|
"audio/webm;codecs=opus",
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg;codecs=opus",
|
||||||
|
"audio/ogg",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const supportedMimeTypeRef = useRef<string | null | undefined>(undefined);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
const configRef = useRef<AudioCaptureConfig | undefined>(config);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = config;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const detectSupportedMimeType = useCallback((): string | null => {
|
||||||
|
if (supportedMimeTypeRef.current !== undefined) {
|
||||||
|
return supportedMimeTypeRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === "undefined" || typeof window.MediaRecorder === "undefined") {
|
||||||
|
supportedMimeTypeRef.current = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detected = MIME_TYPE_CANDIDATES.find((candidate) =>
|
||||||
|
window.MediaRecorder.isTypeSupported(candidate)
|
||||||
|
);
|
||||||
|
|
||||||
|
supportedMimeTypeRef.current = detected ?? null;
|
||||||
|
return supportedMimeTypeRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopMetering = useCallback(() => {
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyserRef.current?.disconnect();
|
||||||
|
analyserRef.current = null;
|
||||||
|
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
// Closing can reject if context already closed; ignore.
|
||||||
|
void audioContextRef.current.close().catch(() => undefined);
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupStream = useCallback(() => {
|
||||||
|
if (mediaStreamRef.current) {
|
||||||
|
mediaStreamRef.current.getTracks().forEach((track) => {
|
||||||
|
try {
|
||||||
|
track.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore track stop errors.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mediaStreamRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startMetering = useCallback((stream: MediaStream) => {
|
||||||
|
const onAudioLevel = configRef.current?.onAudioLevel;
|
||||||
|
if (!onAudioLevel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioContextCtor =
|
||||||
|
(typeof window !== "undefined" &&
|
||||||
|
((window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext ||
|
||||||
|
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext)) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!AudioContextCtor) {
|
||||||
|
console.warn("[AudioRecorder][Web] AudioContext unavailable for metering");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioContext = new AudioContextCtor();
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
|
||||||
|
const dataArray = new Uint8Array(analyser.fftSize);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
let sumSquares = 0;
|
||||||
|
for (let i = 0; i < dataArray.length; i++) {
|
||||||
|
const value = (dataArray[i] - 128) / 128;
|
||||||
|
sumSquares += value * value;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||||
|
const normalized = Math.min(1, Math.max(0, rms * 2));
|
||||||
|
onAudioLevel(normalized);
|
||||||
|
rafIdRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[AudioRecorder][Web] Failed to start metering", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||||
|
throw new Error("Already recording");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof navigator === "undefined" ||
|
||||||
|
!navigator.mediaDevices ||
|
||||||
|
typeof navigator.mediaDevices.getUserMedia !== "function"
|
||||||
|
) {
|
||||||
|
throw new Error("Microphone capture is not supported in this environment");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = configRef.current;
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
audio: {
|
||||||
|
channelCount: options?.numberOfChannels ?? 1,
|
||||||
|
sampleRate: options?.sampleRate,
|
||||||
|
noiseSuppression: true,
|
||||||
|
echoCancellation: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream: MediaStream;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to access microphone: ${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaStreamRef.current = stream;
|
||||||
|
|
||||||
|
const recorderOptions: MediaRecorderOptions = {};
|
||||||
|
const mimeType = detectSupportedMimeType();
|
||||||
|
if (mimeType) {
|
||||||
|
recorderOptions.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
if (options?.bitRate) {
|
||||||
|
recorderOptions.audioBitsPerSecond = options.bitRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recorder: MediaRecorder;
|
||||||
|
try {
|
||||||
|
recorder = new MediaRecorder(stream, recorderOptions);
|
||||||
|
} catch (error: any) {
|
||||||
|
cleanupStream();
|
||||||
|
throw new Error(`Failed to initialize recorder: ${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorderRef.current = recorder;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data);
|
||||||
|
if (options?.enableContinuousRecording && options?.onSpeechSegment) {
|
||||||
|
options.onSpeechSegment(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onerror = (event) => {
|
||||||
|
const error = (event as { error?: Error }).error;
|
||||||
|
console.error("[AudioRecorder][Web] Recorder error", error ?? event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeslice = options?.enableContinuousRecording ? 1000 : undefined;
|
||||||
|
|
||||||
|
startMetering(stream);
|
||||||
|
setIsRecording(true);
|
||||||
|
|
||||||
|
if (timeslice !== undefined) {
|
||||||
|
recorder.start(timeslice);
|
||||||
|
} else {
|
||||||
|
recorder.start();
|
||||||
|
}
|
||||||
|
}, [cleanupStream, detectSupportedMimeType, startMetering]);
|
||||||
|
|
||||||
|
const stop = useCallback(async (): Promise<Blob> => {
|
||||||
|
const recorder = mediaRecorderRef.current;
|
||||||
|
if (!recorder || recorder.state === "inactive") {
|
||||||
|
throw new Error("Not recording");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
const finalize = () => {
|
||||||
|
recorder.removeEventListener("stop", finalize);
|
||||||
|
recorder.removeEventListener("error", handleError as EventListener);
|
||||||
|
|
||||||
|
stopMetering();
|
||||||
|
cleanupStream();
|
||||||
|
mediaRecorderRef.current = null;
|
||||||
|
setIsRecording(false);
|
||||||
|
|
||||||
|
const mimeType = recorder.mimeType || detectSupportedMimeType() || "audio/webm";
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||||
|
chunksRef.current = [];
|
||||||
|
resolve(blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (event: unknown) => {
|
||||||
|
recorder.removeEventListener("stop", finalize);
|
||||||
|
recorder.removeEventListener("error", handleError as EventListener);
|
||||||
|
|
||||||
|
stopMetering();
|
||||||
|
cleanupStream();
|
||||||
|
mediaRecorderRef.current = null;
|
||||||
|
chunksRef.current = [];
|
||||||
|
setIsRecording(false);
|
||||||
|
|
||||||
|
const error =
|
||||||
|
typeof event === "object" && event && "error" in event
|
||||||
|
? (event as { error?: Error }).error
|
||||||
|
: null;
|
||||||
|
reject(error ?? new Error("Failed to stop recording"));
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.addEventListener("stop", finalize);
|
||||||
|
recorder.addEventListener("error", handleError as EventListener);
|
||||||
|
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch (error) {
|
||||||
|
recorder.removeEventListener("stop", finalize);
|
||||||
|
recorder.removeEventListener("error", handleError as EventListener);
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [cleanupStream, detectSupportedMimeType, stopMetering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||||
|
try {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore stop during unmount.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopMetering();
|
||||||
|
cleanupStream();
|
||||||
|
};
|
||||||
|
}, [cleanupStream, stopMetering]);
|
||||||
|
|
||||||
|
const getSupportedMimeType = useCallback(() => detectSupportedMimeType(), [
|
||||||
|
detectSupportedMimeType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
isRecording: () => isRecording,
|
||||||
|
getSupportedMimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user