diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index c080bd3..c692842 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -738,9 +738,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const [selectedTargetIssue, setSelectedTargetIssue] = useState(null); const mediaRecorderRef = useRef(null); + const discardedRecorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const timerRef = useRef(null); + const closeResetTimerRef = useRef(null); const startedAtRef = useRef(0); const audioContextRef = useRef(null); const audioSourceRef = useRef(null); @@ -771,6 +773,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { } }, []); + const clearCloseResetTimer = useCallback(() => { + if (closeResetTimerRef.current) { + window.clearTimeout(closeResetTimerRef.current); + closeResetTimerRef.current = null; + } + }, []); + const stopAudioVisualization = useCallback(() => { if (audioVisualizerFrameRef.current) { window.cancelAnimationFrame(audioVisualizerFrameRef.current); @@ -800,11 +809,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const analyser = audioContext.createAnalyser(); const source = audioContext.createMediaStreamSource(stream); const previousLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, () => 0); - const historyLevels = Array.from({ length: Math.ceil(VOICE_TASK_WAVEFORM_BAR_COUNT / 2) }, () => 0); let smoothedVoiceLevel = 0; let rollingNoiseFloor = 0.035; let rollingVoiceCeiling = 0.28; - let lastHistoryUpdate = 0; analyser.fftSize = 1024; analyser.minDecibels = -90; @@ -867,23 +874,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { smoothedVoiceLevel += (compressedVoice - smoothedVoiceLevel) * voiceAttack; const now = performance.now(); - if (now - lastHistoryUpdate > 34) { - historyLevels.pop(); - historyLevels.unshift(smoothedVoiceLevel); - lastHistoryUpdate = now; - } - - const center = (VOICE_TASK_WAVEFORM_BAR_COUNT - 1) / 2; const phase = now / 72; const nextLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => { - const distanceFromCenter = Math.abs(index - center); - const historyIndex = Math.min(historyLevels.length - 1, Math.floor(distanceFromCenter)); - const nextHistoryIndex = Math.min(historyLevels.length - 1, historyIndex + 1); - const historyMix = distanceFromCenter - historyIndex; - const historyLevel = - historyLevels[historyIndex] + (historyLevels[nextHistoryIndex] - historyLevels[historyIndex]) * historyMix; - const frequencyOffset = distanceFromCenter / (center + 0.5); + const barPosition = index / Math.max(1, VOICE_TASK_WAVEFORM_BAR_COUNT - 1); + const frequencyOffset = Math.sin(barPosition * Math.PI * 0.5); const bandCenter = voiceStartBin + Math.floor((voiceEndBin - voiceStartBin) * frequencyOffset); const bandRadius = Math.max(1, Math.floor((voiceEndBin - voiceStartBin) / VOICE_TASK_WAVEFORM_BAR_COUNT)); let localFrequencyEnergy = 0; @@ -902,10 +897,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { localFrequencyCount > 0 ? clampVoiceTaskLevel((localFrequencyEnergy / localFrequencyCount - rollingNoiseFloor) / adaptiveRange) : 0; - const centerWeight = 0.44 + Math.pow(1 - frequencyOffset, 1.18) * 0.56; + const centerLift = Math.sin(barPosition * Math.PI); + const shapeWeight = 0.72 + Math.pow(centerLift, 0.8) * 0.28; const motion = 0.92 + Math.sin(phase + index * 0.78) * 0.045 + Math.sin(phase * 1.47 + index * 1.73) * 0.025; const targetLevel = clampVoiceTaskLevel( - (historyLevel * 0.84 + spectralTexture * smoothedVoiceLevel * 0.16) * centerWeight * motion + (smoothedVoiceLevel * 0.78 + spectralTexture * smoothedVoiceLevel * 0.22) * shapeWeight * motion ); const previousLevel = previousLevels[index] ?? 0; const smoothing = targetLevel > previousLevel ? 0.52 : 0.24; @@ -929,22 +925,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { streamRef.current = null; }, []); - const stopRecording = useCallback(() => { - const recorder = mediaRecorderRef.current; - clearTimer(); - - if (recorder && recorder.state === "recording") { - recorder.stop(); - return; - } - - stopAudioVisualization(); - stopStream(); - }, [clearTimer, stopAudioVisualization, stopStream]); - - const resetRecording = useCallback(() => { - stopRecording(); - stopAudioVisualization(); + const resetVoiceTaskState = useCallback(() => { setAudioBlob(null); setAudioUrl(null); setAudioLevels([]); @@ -954,20 +935,64 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { setCommitResult(null); setSelectedTargetIssue(null); setStatus("idle"); - }, [stopAudioVisualization, stopRecording]); + }, []); + + const stopRecording = useCallback( + (discard = false) => { + const recorder = mediaRecorderRef.current; + clearTimer(); + + if (discard) { + chunksRef.current = []; + if (recorder) discardedRecorderRef.current = recorder; + } + + if (recorder && recorder.state === "recording") { + recorder.stop(); + if (discard) { + stopAudioVisualization(); + stopStream(); + } + return; + } + + stopAudioVisualization(); + stopStream(); + mediaRecorderRef.current = null; + }, + [clearTimer, stopAudioVisualization, stopStream] + ); + + const resetRecording = useCallback(() => { + clearCloseResetTimer(); + stopRecording(true); + resetVoiceTaskState(); + }, [clearCloseResetTimer, resetVoiceTaskState, stopRecording]); const handleClose = useCallback(() => { - resetRecording(); setIsOpen(false); - }, [resetRecording]); + clearCloseResetTimer(); + stopRecording(true); + closeResetTimerRef.current = window.setTimeout(() => { + resetVoiceTaskState(); + closeResetTimerRef.current = null; + }, 220); + }, [clearCloseResetTimer, resetVoiceTaskState, stopRecording]); + + const openVoiceTasker = useCallback(() => { + clearCloseResetTimer(); + resetVoiceTaskState(); + setIsOpen(true); + }, [clearCloseResetTimer, resetVoiceTaskState]); useEffect( () => () => { clearTimer(); + clearCloseResetTimer(); stopAudioVisualization(); stopStream(); }, - [clearTimer, stopAudioVisualization, stopStream] + [clearCloseResetTimer, clearTimer, stopAudioVisualization, stopStream] ); useEffect(() => { @@ -1009,14 +1034,24 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { mediaRecorderRef.current = recorder; recorder.ondataavailable = (event) => { + if (discardedRecorderRef.current === recorder) return; if (event.data.size > 0) chunksRef.current.push(event.data); }; recorder.onstop = () => { + const isDiscarded = discardedRecorderRef.current === recorder; + const chunks = chunksRef.current; const type = recorder.mimeType || mimeType || "audio/webm"; - setAudioBlob(new Blob(chunksRef.current, { type })); - setStatus("idle"); + + chunksRef.current = []; + mediaRecorderRef.current = null; + if (isDiscarded) discardedRecorderRef.current = null; stopAudioVisualization(); stopStream(); + + if (isDiscarded || !chunks.length) return; + + setAudioBlob(new Blob(chunks, { type })); + setStatus("idle"); }; recorder.start(); @@ -1209,7 +1244,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { : "cursor-not-allowed text-tertiary" )} disabled={!isAvailable} - onClick={() => setIsOpen(true)} + onClick={openVoiceTasker} > @@ -1283,7 +1318,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { )} {isRecording && ( -