|
|
@@ -136,10 +136,12 @@ const isTranscribing = ref(false);
|
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
|
let audioChunks: Blob[] = [];
|
|
|
let recordingTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
+let recordingStartTime = 0; // 实际开始时间戳
|
|
|
let startY = 0;
|
|
|
-const CANCEL_THRESHOLD = 80; // 上移取消阈值(px)
|
|
|
-const MAX_DURATION = 60; // 最大录音时长(秒)
|
|
|
-const MIN_DURATION = 1; // 最短录音时长(秒)
|
|
|
+let isStartingRecording = false; // 防止重复启动锁
|
|
|
+const CANCEL_THRESHOLD = 80;
|
|
|
+const MAX_DURATION = 60;
|
|
|
+const MIN_DURATION = 1;
|
|
|
|
|
|
onMounted(() => {
|
|
|
// 检测浏览器是否支持录音
|
|
|
@@ -179,16 +181,30 @@ const formatDuration = (seconds: number): string => {
|
|
|
};
|
|
|
|
|
|
const startRecording = async (event: MouseEvent | TouchEvent) => {
|
|
|
- if (isTranscribing.value || props.isLoading) return;
|
|
|
+ // 防止重复启动(mousedown + touchstart 同时触发 / 快速重复点击)
|
|
|
+ if (isStartingRecording || isRecording.value || isTranscribing.value || props.isLoading) return;
|
|
|
+ isStartingRecording = true;
|
|
|
|
|
|
startY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
|
isCancelling.value = false;
|
|
|
audioChunks = [];
|
|
|
recordingDuration.value = 0;
|
|
|
|
|
|
+ // 确保旧的 timer 被清理
|
|
|
+ if (recordingTimer) {
|
|
|
+ clearInterval(recordingTimer);
|
|
|
+ recordingTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
|
+ // getUserMedia 是异步的,等待期间用户可能已松手,此时应放弃录音
|
|
|
+ if (!isStartingRecording) {
|
|
|
+ audioStream.getTracks().forEach(track => track.stop());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
|
? 'audio/webm;codecs=opus'
|
|
|
: 'audio/webm';
|
|
|
@@ -199,22 +215,33 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
|
|
|
if (e.data.size > 0) audioChunks.push(e.data);
|
|
|
};
|
|
|
|
|
|
- mediaRecorder.start(100); // 每 100ms 生成一个 chunk
|
|
|
+ mediaRecorder.start(100);
|
|
|
isRecording.value = true;
|
|
|
+ recordingStartTime = Date.now();
|
|
|
|
|
|
+ // 使用时间戳计算时长,避免多个 timer 叠加
|
|
|
recordingTimer = setInterval(() => {
|
|
|
- recordingDuration.value++;
|
|
|
- if (recordingDuration.value >= MAX_DURATION) {
|
|
|
+ const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000);
|
|
|
+ recordingDuration.value = elapsed;
|
|
|
+ if (elapsed >= MAX_DURATION) {
|
|
|
stopRecording();
|
|
|
}
|
|
|
- }, 1000);
|
|
|
+ }, 200);
|
|
|
} catch (err: any) {
|
|
|
console.error('无法获取麦克风权限:', err);
|
|
|
alert('请允许麦克风权限以使用语音功能');
|
|
|
+ } finally {
|
|
|
+ isStartingRecording = false;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const stopRecording = async () => {
|
|
|
+ // 如果正在启动录音(等待麦克风权限),标记取消并返回
|
|
|
+ if (isStartingRecording) {
|
|
|
+ isStartingRecording = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
if (!isRecording.value || !mediaRecorder) return;
|
|
|
|
|
|
if (isCancelling.value) {
|
|
|
@@ -228,8 +255,8 @@ const stopRecording = async () => {
|
|
|
recordingTimer = null;
|
|
|
}
|
|
|
|
|
|
- // 检查录音时长
|
|
|
- const duration = recordingDuration.value;
|
|
|
+ // 用实际时间戳计算时长(不依赖 timer 累加值)
|
|
|
+ const actualDuration = (Date.now() - recordingStartTime) / 1000;
|
|
|
|
|
|
// 停止录音
|
|
|
mediaRecorder.stop();
|
|
|
@@ -245,7 +272,7 @@ const stopRecording = async () => {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- if (duration < MIN_DURATION) {
|
|
|
+ if (actualDuration < MIN_DURATION) {
|
|
|
alert('录音太短,请至少录制 1 秒');
|
|
|
audioChunks = [];
|
|
|
return;
|