From b232cc5bb02c9564128bdfb19dada3731e03c5f5 Mon Sep 17 00:00:00 2001 From: Lok3sh-Kumar Date: Sat, 27 Jun 2026 14:17:10 +0530 Subject: [PATCH] feat(open-webui): redesign voice recording with live waveform Move the mic to the right of the input (shown when the box is empty). While recording, the input becomes a recording bar with a cancel button, a live microphone waveform (Web Audio analyser), an elapsed timer and a confirm button that stops and transcribes into the message box. --- packages/translation/src/lang/en.json | 1 + .../widgets/src/open-webui/chat.module.css | 25 + packages/widgets/src/open-webui/chat.tsx | 631 +++++++++++------- 3 files changed, 399 insertions(+), 258 deletions(-) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index fd995549c..5defbdf6c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3586,6 +3586,7 @@ "noteError": "Could not load that note", "record": "Record voice message", "stopRecording": "Stop recording", + "cancelRecording": "Cancel recording", "micError": "Could not access the microphone", "transcribeError": "Could not transcribe the recording", "error": { diff --git a/packages/widgets/src/open-webui/chat.module.css b/packages/widgets/src/open-webui/chat.module.css index 844944f73..50381fd89 100644 --- a/packages/widgets/src/open-webui/chat.module.css +++ b/packages/widgets/src/open-webui/chat.module.css @@ -108,6 +108,31 @@ background-color: black; } +.recordingBar { + width: 100%; +} + +.waveformWrap { + flex: 1; + min-width: 0; + height: 32px; + display: flex; + align-items: center; +} + +.waveformCanvas { + width: 100%; + height: 32px; + display: block; + color: var(--mantine-primary-color-filled); +} + +.recordingTime { + font-variant-numeric: tabular-nums; + min-width: 34px; + text-align: right; +} + .thumb { position: relative; line-height: 0; diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx index 4060f02b1..c1bf18a1d 100644 --- a/packages/widgets/src/open-webui/chat.tsx +++ b/packages/widgets/src/open-webui/chat.tsx @@ -29,6 +29,7 @@ import { IconArrowLeft, IconArrowUp, IconCamera, + IconCheck, IconChevronDown, IconChevronRight, IconDatabase, @@ -44,6 +45,7 @@ import { IconRobot, IconUser, IconWorld, + IconX, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -124,6 +126,8 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro const [captureOpened, setCaptureOpened] = useState(false); const [isRecording, setIsRecording] = useState(false); + const [recordingSeconds, setRecordingSeconds] = useState(0); + const [analyser, setAnalyser] = useState(null); const messagesRef = useRef([]); const streamingTextRef = useRef(""); @@ -134,6 +138,9 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const audioStreamRef = useRef(null); + const audioContextRef = useRef(null); + const recordingTimerRef = useRef(null); + const recordingCancelledRef = useRef(false); const [composerHeight, setComposerHeight] = useState(72); const utils = clientApi.useUtils(); @@ -217,7 +224,9 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro useEffect( () => () => { stopCaptureStream(); + if (recordingTimerRef.current !== null) window.clearInterval(recordingTimerRef.current); audioStreamRef.current?.getTracks().forEach((track) => track.stop()); + void audioContextRef.current?.close().catch(() => undefined); }, [], ); @@ -510,36 +519,68 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro }; // ---- Voice recording (speech-to-text) ---- + const teardownRecording = () => { + if (recordingTimerRef.current !== null) { + window.clearInterval(recordingTimerRef.current); + recordingTimerRef.current = null; + } + audioStreamRef.current?.getTracks().forEach((track) => track.stop()); + audioStreamRef.current = null; + void audioContextRef.current?.close().catch(() => undefined); + audioContextRef.current = null; + setAnalyser(null); + setIsRecording(false); + }; + const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioStreamRef.current = stream; + + // Live audio analyser drives the waveform animation. + const audioContext = new AudioContext(); + const sourceNode = audioContext.createMediaStreamSource(stream); + const analyserNode = audioContext.createAnalyser(); + analyserNode.fftSize = 256; + sourceNode.connect(analyserNode); + audioContextRef.current = audioContext; + setAnalyser(analyserNode); + const recorder = new MediaRecorder(stream); audioChunksRef.current = []; + recordingCancelledRef.current = false; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) audioChunksRef.current.push(event.data); }); - recorder.addEventListener("stop", () => void transcribeRecording()); + recorder.addEventListener("stop", () => void finishRecording()); mediaRecorderRef.current = recorder; recorder.start(); - setIsRecording(true); + setError(null); + setRecordingSeconds(0); + recordingTimerRef.current = window.setInterval(() => setRecordingSeconds((value) => value + 1), 1000); + setIsRecording(true); } catch { setError(t("widget.openWebUi.micError")); } }; - const stopRecording = () => { + const cancelRecording = () => { + recordingCancelledRef.current = true; mediaRecorderRef.current?.stop(); - setIsRecording(false); }; - const transcribeRecording = async () => { - audioStreamRef.current?.getTracks().forEach((track) => track.stop()); - audioStreamRef.current = null; + const confirmRecording = () => { + recordingCancelledRef.current = false; + mediaRecorderRef.current?.stop(); + }; + + const finishRecording = async () => { + const cancelled = recordingCancelledRef.current; const chunks = audioChunksRef.current; audioChunksRef.current = []; - if (chunks.length === 0 || !integrationId) return; + teardownRecording(); + if (cancelled || chunks.length === 0 || !integrationId) return; const blob = new Blob(chunks, { type: chunks[0]?.type ?? "audio/webm" }); try { @@ -705,274 +746,305 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro ) : null} -