UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: чистое закрытие записи Voice Tasker
This commit is contained in:
parent
5b1fca5356
commit
d867a89a1b
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue