diff --git a/apps/client/src/features/dictation/components/mic-button.module.css b/apps/client/src/features/dictation/components/mic-button.module.css new file mode 100644 index 00000000..268335cb --- /dev/null +++ b/apps/client/src/features/dictation/components/mic-button.module.css @@ -0,0 +1,22 @@ +.recordingWrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Translucent red halo that sits behind the stop button and scales with the + live microphone level (scale set inline from audioLevel). */ +.pulse { + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mantine-color-red-5); + opacity: 0.35; + transform-origin: center; + transform: scale(1); + transition: transform 90ms linear; + pointer-events: none; + will-change: transform; + z-index: 0; +} diff --git a/apps/client/src/features/dictation/components/mic-button.tsx b/apps/client/src/features/dictation/components/mic-button.tsx index b04e753a..aaa4b63c 100644 --- a/apps/client/src/features/dictation/components/mic-button.tsx +++ b/apps/client/src/features/dictation/components/mic-button.tsx @@ -1,8 +1,10 @@ import { FC } from "react"; import { ActionIcon, Loader, Tooltip } from "@mantine/core"; +import { useReducedMotion } from "@mantine/hooks"; import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useDictation } from "@/features/dictation/hooks/use-dictation"; +import classes from "./mic-button.module.css"; interface MicButtonProps { onText: (text: string) => void; @@ -26,21 +28,32 @@ export const MicButton: FC = ({ size = "lg", }) => { const { t } = useTranslation(); - const { status, start, stop } = useDictation({ onText, onStart }); + const { status, start, stop, audioLevel } = useDictation({ onText, onStart }); + const reduceMotion = useReducedMotion(); const iconSize = size === "lg" ? 18 : 16; if (status === "recording") { + // Live volume-driven halo, or a static halo when the user prefers reduced motion. + const haloScale = reduceMotion ? 1.15 : 1 + Math.min(1, audioLevel) * 0.9; return ( - - - + + ); } diff --git a/apps/client/src/features/dictation/hooks/use-dictation.ts b/apps/client/src/features/dictation/hooks/use-dictation.ts index 86af4c78..0d32402f 100644 --- a/apps/client/src/features/dictation/hooks/use-dictation.ts +++ b/apps/client/src/features/dictation/hooks/use-dictation.ts @@ -16,6 +16,8 @@ interface UseDictationResult { start: () => Promise; stop: () => void; cancel: () => void; + // Smoothed live microphone level in the 0..1 range while recording (0 when idle). + audioLevel: number; } // Candidate container/codec combinations in preference order. The first one the @@ -56,6 +58,7 @@ export function useDictation( ): UseDictationResult { const { t } = useTranslation(); const [status, setStatus] = useState("idle"); + const [audioLevel, setAudioLevel] = useState(0); // Keep the latest callbacks in a ref so the recorder's onstop closure always // calls the current handlers without re-creating the recorder. @@ -70,6 +73,15 @@ export function useDictation( const canceledRef = useRef(false); const startingRef = useRef(false); + // Web Audio metering: derives a live input level from the captured stream. + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const rafRef = useRef(null); + // Exponentially smoothed level, and the last value pushed to React state. + const smoothedLevelRef = useRef(0); + const emittedLevelRef = useRef(0); + const clearTimer = useCallback(() => { if (timerRef.current !== null) { clearTimeout(timerRef.current); @@ -82,6 +94,91 @@ export function useDictation( streamRef.current = null; }, []); + // Tear the audio meter down fully. Safe to call multiple times and on any exit + // path; defensive try/catch so cleanup never throws. + const stopMeter = useCallback(() => { + // Cancel the rAF first so getByteTimeDomainData can't run on a closed context. + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + try { + sourceRef.current?.disconnect(); + sourceRef.current = null; + analyserRef.current = null; + if (audioContextRef.current && audioContextRef.current.state !== "closed") { + void audioContextRef.current.close(); + } + audioContextRef.current = null; + } catch (err) { + // Cleanup must never throw; just log for diagnosis. + console.warn("[dictation] audio meter teardown failed", err); + } + smoothedLevelRef.current = 0; + emittedLevelRef.current = 0; + setAudioLevel(0); + }, []); + + // Set up Web Audio metering on the already-captured stream. Reuses the existing + // MediaStream — never requests a second mic. Failure here must not break + // recording: on any error we warn and return, leaving the recorder running. + const startMeter = useCallback((stream: MediaStream) => { + try { + const Ctor = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!Ctor) return; + + const audioContext = new Ctor(); + // Some browsers start the context suspended; resume so the loop produces + // data. Swallow rejection (e.g. context already closed by a fast + // start/stop race) to avoid an unhandled promise rejection. + audioContext.resume().catch(() => {}); + const source = audioContext.createMediaStreamSource(stream); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.5; + // Connect ONLY to the analyser — never to destination, which would echo the + // mic back to the speakers. + source.connect(analyser); + + audioContextRef.current = audioContext; + sourceRef.current = source; + analyserRef.current = analyser; + + // Allocate the time-domain buffer once and reuse it on every tick. + const data = new Uint8Array(analyser.fftSize); + + const tick = () => { + const a = analyserRef.current; + if (!a) return; + a.getByteTimeDomainData(data); + // RMS of the centered waveform (samples are 0..255, midpoint 128). + let sumSquares = 0; + for (let i = 0; i < data.length; i++) { + const v = (data[i] - 128) / 128; + sumSquares += v * v; + } + const rms = Math.sqrt(sumSquares / data.length); + // Boost + clamp so normal speech maps to a visible 0..1 range. + const level = Math.min(1, rms * 3); + // Exponential smoothing to avoid jitter. + smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2; + // Throttle React re-renders: only push when it changed meaningfully. + if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) { + emittedLevelRef.current = smoothedLevelRef.current; + setAudioLevel(smoothedLevelRef.current); + } + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + } catch (err) { + // Web Audio unavailable or threw: recording continues without the meter. + console.warn("[dictation] audio meter unavailable", err); + } + }, []); + const start = useCallback(async (): Promise => { // Synchronous live guard: status is stale between renders, so also block on // refs to prevent a double-click from opening two MediaStreams (the first @@ -163,8 +260,9 @@ export function useDictation( const recordedMime = recorder.mimeType || mimeType || "audio/webm"; const wasCanceled = canceledRef.current; - // Stop the mic tracks regardless of how we got here. + // Stop the mic tracks and the audio meter regardless of how we got here. stopTracks(); + stopMeter(); recorderRef.current = null; if (wasCanceled) { @@ -237,34 +335,49 @@ export function useDictation( // Recording has truly begun; release the synchronous start guard. startingRef.current = false; + // Start the live audio meter on the stream we already acquired. + startMeter(stream); + const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000; timerRef.current = setTimeout(() => { if (recorderRef.current?.state === "recording") { recorderRef.current.stop(); } }, maxDurationMs); - }, [status, t, clearTimer, stopTracks]); + }, [status, t, clearTimer, stopTracks, startMeter, stopMeter]); const stop = useCallback((): void => { clearTimer(); const recorder = recorderRef.current; if (recorder && recorder.state === "recording") { + // Normal path: onstop tears down tracks + meter and runs transcription. recorder.stop(); + } else { + // No live recorder (e.g. the track ended on its own): tear everything + // down directly so the meter/AudioContext and stream don't leak, and + // recover the UI to idle. + stopTracks(); + stopMeter(); + recorderRef.current = null; + chunksRef.current = []; + setStatus("idle"); } - }, [clearTimer]); + }, [clearTimer, stopTracks, stopMeter]); const cancel = useCallback((): void => { clearTimer(); canceledRef.current = true; const recorder = recorderRef.current; if (recorder && recorder.state === "recording") { - // onstop sees canceledRef and skips transcription; it also stops tracks. + // onstop sees canceledRef and skips transcription; it also stops tracks + // and the meter. recorder.stop(); } else { stopTracks(); + stopMeter(); } setStatus("idle"); - }, [clearTimer, stopTracks]); + }, [clearTimer, stopTracks, stopMeter]); // Clean up on unmount: stop any live recorder/stream and clear the timers. useEffect(() => { @@ -280,8 +393,9 @@ export function useDictation( recorder.stop(); } stopTracks(); + stopMeter(); }; - }, [clearTimer, stopTracks]); + }, [clearTimer, stopTracks, stopMeter]); - return { status, start, stop, cancel }; + return { status, start, stop, cancel, audioLevel }; }