UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: чистое закрытие записи Voice Tasker

This commit is contained in:
DCCONSTRUCTIONS 2026-04-26 18:57:07 +03:00
parent 5b1fca5356
commit d867a89a1b
1 changed files with 77 additions and 42 deletions

View File

@ -738,9 +738,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const discardedRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
const closeResetTimerRef = useRef<number | null>(null);
const startedAtRef = useRef(0);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(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}
>
<Mic className="size-7" />
</button>
@ -1283,7 +1318,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</Button>
)}
{isRecording && (
<Button variant="error-fill" size="lg" onClick={stopRecording} disabled={isCommitting}>
<Button variant="error-fill" size="lg" onClick={() => stopRecording()} disabled={isCommitting}>
<Square className="mr-2 size-4" />
Завершить запись
</Button>