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 [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const discardedRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const chunksRef = useRef<BlobPart[]>([]);
|
const chunksRef = useRef<BlobPart[]>([]);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const closeResetTimerRef = useRef<number | null>(null);
|
||||||
const startedAtRef = useRef(0);
|
const startedAtRef = useRef(0);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const audioSourceRef = useRef<MediaStreamAudioSourceNode | 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(() => {
|
const stopAudioVisualization = useCallback(() => {
|
||||||
if (audioVisualizerFrameRef.current) {
|
if (audioVisualizerFrameRef.current) {
|
||||||
window.cancelAnimationFrame(audioVisualizerFrameRef.current);
|
window.cancelAnimationFrame(audioVisualizerFrameRef.current);
|
||||||
|
|
@ -800,11 +809,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
const previousLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, () => 0);
|
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 smoothedVoiceLevel = 0;
|
||||||
let rollingNoiseFloor = 0.035;
|
let rollingNoiseFloor = 0.035;
|
||||||
let rollingVoiceCeiling = 0.28;
|
let rollingVoiceCeiling = 0.28;
|
||||||
let lastHistoryUpdate = 0;
|
|
||||||
|
|
||||||
analyser.fftSize = 1024;
|
analyser.fftSize = 1024;
|
||||||
analyser.minDecibels = -90;
|
analyser.minDecibels = -90;
|
||||||
|
|
@ -867,23 +874,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
smoothedVoiceLevel += (compressedVoice - smoothedVoiceLevel) * voiceAttack;
|
smoothedVoiceLevel += (compressedVoice - smoothedVoiceLevel) * voiceAttack;
|
||||||
|
|
||||||
const now = performance.now();
|
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 phase = now / 72;
|
||||||
|
|
||||||
const nextLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => {
|
const nextLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => {
|
||||||
const distanceFromCenter = Math.abs(index - center);
|
const barPosition = index / Math.max(1, VOICE_TASK_WAVEFORM_BAR_COUNT - 1);
|
||||||
const historyIndex = Math.min(historyLevels.length - 1, Math.floor(distanceFromCenter));
|
const frequencyOffset = Math.sin(barPosition * Math.PI * 0.5);
|
||||||
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 bandCenter = voiceStartBin + Math.floor((voiceEndBin - voiceStartBin) * frequencyOffset);
|
const bandCenter = voiceStartBin + Math.floor((voiceEndBin - voiceStartBin) * frequencyOffset);
|
||||||
const bandRadius = Math.max(1, Math.floor((voiceEndBin - voiceStartBin) / VOICE_TASK_WAVEFORM_BAR_COUNT));
|
const bandRadius = Math.max(1, Math.floor((voiceEndBin - voiceStartBin) / VOICE_TASK_WAVEFORM_BAR_COUNT));
|
||||||
let localFrequencyEnergy = 0;
|
let localFrequencyEnergy = 0;
|
||||||
|
|
@ -902,10 +897,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
localFrequencyCount > 0
|
localFrequencyCount > 0
|
||||||
? clampVoiceTaskLevel((localFrequencyEnergy / localFrequencyCount - rollingNoiseFloor) / adaptiveRange)
|
? clampVoiceTaskLevel((localFrequencyEnergy / localFrequencyCount - rollingNoiseFloor) / adaptiveRange)
|
||||||
: 0;
|
: 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 motion = 0.92 + Math.sin(phase + index * 0.78) * 0.045 + Math.sin(phase * 1.47 + index * 1.73) * 0.025;
|
||||||
const targetLevel = clampVoiceTaskLevel(
|
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 previousLevel = previousLevels[index] ?? 0;
|
||||||
const smoothing = targetLevel > previousLevel ? 0.52 : 0.24;
|
const smoothing = targetLevel > previousLevel ? 0.52 : 0.24;
|
||||||
|
|
@ -929,22 +925,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stopRecording = useCallback(() => {
|
const resetVoiceTaskState = useCallback(() => {
|
||||||
const recorder = mediaRecorderRef.current;
|
|
||||||
clearTimer();
|
|
||||||
|
|
||||||
if (recorder && recorder.state === "recording") {
|
|
||||||
recorder.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAudioVisualization();
|
|
||||||
stopStream();
|
|
||||||
}, [clearTimer, stopAudioVisualization, stopStream]);
|
|
||||||
|
|
||||||
const resetRecording = useCallback(() => {
|
|
||||||
stopRecording();
|
|
||||||
stopAudioVisualization();
|
|
||||||
setAudioBlob(null);
|
setAudioBlob(null);
|
||||||
setAudioUrl(null);
|
setAudioUrl(null);
|
||||||
setAudioLevels([]);
|
setAudioLevels([]);
|
||||||
|
|
@ -954,20 +935,64 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
setCommitResult(null);
|
setCommitResult(null);
|
||||||
setSelectedTargetIssue(null);
|
setSelectedTargetIssue(null);
|
||||||
setStatus("idle");
|
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(() => {
|
const handleClose = useCallback(() => {
|
||||||
resetRecording();
|
|
||||||
setIsOpen(false);
|
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(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
clearCloseResetTimer();
|
||||||
stopAudioVisualization();
|
stopAudioVisualization();
|
||||||
stopStream();
|
stopStream();
|
||||||
},
|
},
|
||||||
[clearTimer, stopAudioVisualization, stopStream]
|
[clearCloseResetTimer, clearTimer, stopAudioVisualization, stopStream]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1009,14 +1034,24 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
mediaRecorderRef.current = recorder;
|
mediaRecorderRef.current = recorder;
|
||||||
|
|
||||||
recorder.ondataavailable = (event) => {
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (discardedRecorderRef.current === recorder) return;
|
||||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||||
};
|
};
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
|
const isDiscarded = discardedRecorderRef.current === recorder;
|
||||||
|
const chunks = chunksRef.current;
|
||||||
const type = recorder.mimeType || mimeType || "audio/webm";
|
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();
|
stopAudioVisualization();
|
||||||
stopStream();
|
stopStream();
|
||||||
|
|
||||||
|
if (isDiscarded || !chunks.length) return;
|
||||||
|
|
||||||
|
setAudioBlob(new Blob(chunks, { type }));
|
||||||
|
setStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.start();
|
recorder.start();
|
||||||
|
|
@ -1209,7 +1244,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
: "cursor-not-allowed text-tertiary"
|
: "cursor-not-allowed text-tertiary"
|
||||||
)}
|
)}
|
||||||
disabled={!isAvailable}
|
disabled={!isAvailable}
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={openVoiceTasker}
|
||||||
>
|
>
|
||||||
<Mic className="size-7" />
|
<Mic className="size-7" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1283,7 +1318,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isRecording && (
|
{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" />
|
<Square className="mr-2 size-4" />
|
||||||
Завершить запись
|
Завершить запись
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue