Bladeren bron

fix: 修复录音总是"太短"——去掉mouseleave误触+异步竞态

根因:mic按钮的scale放大动画使鼠标瞬间脱离按钮触发mouseleave,
录音刚开就被停。改为:
- window级mouseup监听(松手在哪都能停),去掉mouseleave
- pressing标志处理getUserMedia异步竞态(await期间松手则放弃/补停)
- onaudioprocess不再依赖recording标志,避免漏帧
- 呼吸动画用box-shadow代替scale

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
simonlll 1 week geleden
bovenliggende
commit
03e0811473
1 gewijzigde bestanden met toevoegingen van 44 en 28 verwijderingen
  1. 44 28
      src/pages/ai-chat/index.vue

+ 44 - 28
src/pages/ai-chat/index.vue

@@ -120,12 +120,10 @@
120
       <!-- 麦克风按钮(同时支持触摸和鼠标,方便桌面浏览器测试) -->
120
       <!-- 麦克风按钮(同时支持触摸和鼠标,方便桌面浏览器测试) -->
121
       <view
121
       <view
122
         :class="['mic-btn', recording ? 'recording' : '']"
122
         :class="['mic-btn', recording ? 'recording' : '']"
123
-        @touchstart.prevent="startRecord"
124
-        @touchend.prevent="stopRecord"
125
-        @touchcancel.prevent="cancelRecord"
126
-        @mousedown.prevent="startRecord"
127
-        @mouseup.prevent="stopRecord"
128
-        @mouseleave.prevent="onMicLeave"
123
+        @touchstart.prevent="onPressStart"
124
+        @touchend.prevent="onPressEnd"
125
+        @touchcancel.prevent="onPressCancel"
126
+        @mousedown.prevent="onMouseDown"
129
       >
127
       >
130
         <text class="mic-icon">{{ recording ? '●' : '🎤' }}</text>
128
         <text class="mic-icon">{{ recording ? '●' : '🎤' }}</text>
131
       </view>
129
       </view>
@@ -175,6 +173,7 @@ export default {
175
       exampleTags: ['各班组查获排名', '打火机查获数量', '旅检一部人员资质', '全站党员人数'],
173
       exampleTags: ['各班组查获排名', '打火机查获数量', '旅检一部人员资质', '全站党员人数'],
176
       // 录音相关(Web Audio 直接采集 PCM)
174
       // 录音相关(Web Audio 直接采集 PCM)
177
       recording: false,
175
       recording: false,
176
+      pressing: false,
178
       recordCancelled: false,
177
       recordCancelled: false,
179
       audioCtx: null,
178
       audioCtx: null,
180
       mediaStream: null,
179
       mediaStream: null,
@@ -185,14 +184,39 @@ export default {
185
     }
184
     }
186
   },
185
   },
187
   methods: {
186
   methods: {
188
-    // ============ 录音相关(Web Audio 直接采集原始 PCM,避免 decodeAudioData 失败)============
189
-    async startRecord(e) {
190
-      if (this.loading) return
187
+    // ============ 按压事件包装 ============
188
+    onMouseDown(e) {
189
+      // 鼠标:松手可能在按钮外,用 window 级监听
190
+      this._winUp = () => this.onPressEnd()
191
+      window.addEventListener('mouseup', this._winUp, { once: true })
192
+      this.onPressStart(e)
193
+    },
194
+    onPressStart(e) {
195
+      if (this.loading || this.pressing) return
196
+      this.pressing = true
197
+      this.startRecord()
198
+    },
199
+    onPressEnd(e) {
200
+      if (!this.pressing) return
201
+      this.pressing = false
202
+      this.stopRecord()
203
+    },
204
+    onPressCancel(e) {
205
+      if (!this.pressing) return
206
+      this.pressing = false
207
+      this.recordCancelled = true
208
+      this.stopRecord()
209
+    },
210
+
211
+    // ============ 录音相关(Web Audio 直接采集原始 PCM)============
212
+    async startRecord() {
191
       this.recordCancelled = false
213
       this.recordCancelled = false
192
       this.pcmChunks = []
214
       this.pcmChunks = []
193
 
215
 
194
       try {
216
       try {
195
         const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
217
         const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
218
+        // 异步竞态:若 await 期间已松手,直接放弃
219
+        if (!this.pressing) { stream.getTracks().forEach(t => t.stop()); return }
196
         this.mediaStream = stream
220
         this.mediaStream = stream
197
 
221
 
198
         const AudioCtx = window.AudioContext || window.webkitAudioContext
222
         const AudioCtx = window.AudioContext || window.webkitAudioContext
@@ -204,8 +228,7 @@ export default {
204
         const source = ctx.createMediaStreamSource(stream)
228
         const source = ctx.createMediaStreamSource(stream)
205
         const node = ctx.createScriptProcessor(4096, 1, 1)
229
         const node = ctx.createScriptProcessor(4096, 1, 1)
206
         node.onaudioprocess = (ev) => {
230
         node.onaudioprocess = (ev) => {
207
-          if (!this.recording) return
208
-          // 复制一份当前帧的 Float32 PCM
231
+          // 只要节点还连着就采集(不依赖 recording 标志,避免漏帧)
209
           this.pcmChunks.push(new Float32Array(ev.inputBuffer.getChannelData(0)))
232
           this.pcmChunks.push(new Float32Array(ev.inputBuffer.getChannelData(0)))
210
         }
233
         }
211
         source.connect(node)
234
         source.connect(node)
@@ -214,7 +237,11 @@ export default {
214
         this.sourceNode = source
237
         this.sourceNode = source
215
         this.scriptNode = node
238
         this.scriptNode = node
216
         this.recording = true
239
         this.recording = true
240
+
241
+        // 若设置过程中已松手,立即停止并提交
242
+        if (!this.pressing) this.stopRecord()
217
       } catch (err) {
243
       } catch (err) {
244
+        this.recording = false
218
         let msg = '无法访问麦克风'
245
         let msg = '无法访问麦克风'
219
         if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
246
         if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
220
           msg = '当前环境不支持录音(需HTTPS或localhost)'
247
           msg = '当前环境不支持录音(需HTTPS或localhost)'
@@ -238,7 +265,7 @@ export default {
238
       this.audioCtx = null
265
       this.audioCtx = null
239
     },
266
     },
240
 
267
 
241
-    stopRecord(e) {
268
+    stopRecord() {
242
       if (!this.recording) return
269
       if (!this.recording) return
243
       this.recording = false
270
       this.recording = false
244
       const rate = this.pcmSampleRate
271
       const rate = this.pcmSampleRate
@@ -247,18 +274,6 @@ export default {
247
       if (!this.recordCancelled) this.submitAudio(chunks, rate)
274
       if (!this.recordCancelled) this.submitAudio(chunks, rate)
248
     },
275
     },
249
 
276
 
250
-    cancelRecord(e) {
251
-      if (!this.recording) return
252
-      this.recordCancelled = true
253
-      this.recording = false
254
-      this._teardownAudio()
255
-    },
256
-
257
-    // 鼠标移出按钮:录音中则发送(等同松手)
258
-    onMicLeave(e) {
259
-      if (this.recording) this.stopRecord(e)
260
-    },
261
-
262
     async submitAudio(chunks, srcRate) {
277
     async submitAudio(chunks, srcRate) {
263
       // 合并所有 PCM 帧
278
       // 合并所有 PCM 帧
264
       let total = 0
279
       let total = 0
@@ -742,14 +757,15 @@ $text-sub: #999;
742
   transition: background 0.2s;
757
   transition: background 0.2s;
743
   &.recording {
758
   &.recording {
744
     background: #ff4d4f;
759
     background: #ff4d4f;
745
-    animation: pulse 0.8s infinite;
760
+    animation: micPulse 0.8s infinite;
746
   }
761
   }
747
 }
762
 }
748
 .mic-icon { font-size: 32rpx; line-height: 1; }
763
 .mic-icon { font-size: 32rpx; line-height: 1; }
749
 
764
 
750
-@keyframes pulse {
751
-  0%, 100% { transform: scale(1); }
752
-  50% { transform: scale(1.12); }
765
+/* 用阴影呼吸代替 scale,避免缩放触发 mouseleave */
766
+@keyframes micPulse {
767
+  0%, 100% { box-shadow: 0 0 0 0 rgba(255,77,79,0.5); }
768
+  50% { box-shadow: 0 0 0 12rpx rgba(255,77,79,0); }
753
 }
769
 }
754
 
770
 
755
 /* 录音浮层 */
771
 /* 录音浮层 */