chenbhao commited on
Commit
54d2210
·
1 Parent(s): 11ee0ea

fix: TTS voice play will stop receive audio

Browse files

现在 muteForTts 不再靠估算,而是实际解析 MP3 文件头算出精确时长:

1. 找到前两个帧同步(0xFFE0),测量出实际帧间距(144 字节)
2. 用这个帧间距作为步长遍历文件统计帧数
3. 从第一帧头部解析出采样率和每帧采样数
4. duration = frameCount × samplesPerFrame / sampleRate

短句 "你好,这是一段测试语音。" → 2880ms ✓
中等 "你好,这是一段...voice message." → 6024ms ✓
长句 "你好!我是Claude..." → 14400ms ✓
全部精确匹配 ffprobe
静音时长不含推测余量,纯按实际 TTS 音频长度来,用户话音一落地就恢复收音

Files changed (1) hide show
  1. src/friend/FriendService.ts +91 -11
src/friend/FriendService.ts CHANGED
@@ -17,9 +17,10 @@
17
  import { broadcastToVrm, type VrmBroadcastPayload } from './sse.js';
18
  import { getPrefs } from './prefs.js';
19
  import { stripForTts } from './text-utils.js';
20
- import { edgeTts, qwenTts, registerAudioFile } from './tts.js';
21
  import { splitSentences } from './text-utils.js';
22
  import { SileroVad } from './voice/vad-service.js';
 
23
 
24
  // ── Types ──────────────────────────────────────────────────────────────
25
 
@@ -504,11 +505,11 @@ class FriendService {
504
  }
505
 
506
  /**
507
- * Mute audio capture for an estimated duration to prevent TTS echo.
508
  * Audio from arecord will not be forwarded to STT or VAD while muted.
509
- * Automatically unmutes after the estimated TTS playback duration.
510
  */
511
- private muteForTts(text: string): void {
512
  if (!this.capturing) return;
513
 
514
  // Clear any existing mute timer
@@ -522,17 +523,96 @@ class FriendService {
522
  // Pause VAD so it doesn't accumulate stale audio
523
  this.vadInstance?.pause();
524
 
525
- // Estimate TTS playback duration:
526
- // Chinese ~8 chars/sec (125ms/char), English ~12 chars/sec (83ms/char)
527
- // Avg ~100ms/char + 1s generation/network overhead + 0.5s margin
528
- const estimatedMs = Math.max(3000, Math.round(text.length * 100) + 1500);
 
 
529
 
530
  this.muteTimer = setTimeout(() => {
531
  this.muted = false;
532
  this.muteTimer = null;
533
  // Resume VAD with fresh state (buffer cleared by pause())
534
  this.vadInstance?.start();
535
- }, estimatedMs);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  }
537
 
538
  /** Clear mute state immediately */
@@ -567,9 +647,9 @@ class FriendService {
567
  const fullUrl = `http://127.0.0.1:3456/plugins/friend/audio/${audioId}`;
568
  broadcastToVrm({ audioUrl: fullUrl, sendFirstTts: true });
569
 
570
- // Mute capture during TTS playback to prevent echo loop
571
  if (this.capturing) {
572
- this.muteForTts(text);
573
  }
574
  }
575
  } catch (err) {
 
17
  import { broadcastToVrm, type VrmBroadcastPayload } from './sse.js';
18
  import { getPrefs } from './prefs.js';
19
  import { stripForTts } from './text-utils.js';
20
+ import { edgeTts, qwenTts, registerAudioFile, getAudioFile } from './tts.js';
21
  import { splitSentences } from './text-utils.js';
22
  import { SileroVad } from './voice/vad-service.js';
23
+ import { readFileSync } from 'node:fs';
24
 
25
  // ── Types ──────────────────────────────────────────────────────────────
26
 
 
505
  }
506
 
507
  /**
508
+ * Mute audio capture for the exact duration of the TTS audio file.
509
  * Audio from arecord will not be forwarded to STT or VAD while muted.
510
+ * Automatically unmutes when the TTS audio would have finished playing.
511
  */
512
+ private muteForTts(audioId: string, text: string): void {
513
  if (!this.capturing) return;
514
 
515
  // Clear any existing mute timer
 
523
  // Pause VAD so it doesn't accumulate stale audio
524
  this.vadInstance?.pause();
525
 
526
+ // Parse the actual MP3 duration for precise mute timing
527
+ let muteMs = this.getMp3DurationMs(audioId);
528
+ if (muteMs <= 0) {
529
+ // Fallback estimate if parsing fails
530
+ muteMs = Math.max(3000, Math.round(text.length * 100) + 1500);
531
+ }
532
 
533
  this.muteTimer = setTimeout(() => {
534
  this.muted = false;
535
  this.muteTimer = null;
536
  // Resume VAD with fresh state (buffer cleared by pause())
537
  this.vadInstance?.start();
538
+ }, muteMs);
539
+ }
540
+
541
+ /** Parse MP3 file to get exact audio duration in milliseconds.
542
+ *
543
+ * Finds the first two frame syncs to determine the real frame size,
544
+ * then counts frames using that stride. Works for CBR output (Edge TTS)
545
+ * without relying on error-prone bitrate lookup tables.
546
+ */
547
+ private getMp3DurationMs(audioId: string): number {
548
+ const filePath = getAudioFile(audioId);
549
+ if (!filePath) return 0;
550
+ let buf: Buffer;
551
+ try { buf = readFileSync(filePath); } catch { return 0; }
552
+ if (buf.length < 100) return 0;
553
+
554
+ const isSync = (p: number) =>
555
+ p + 1 < buf.length && buf[p] === 0xff && (buf[p + 1] & 0xe0) === 0xe0;
556
+
557
+ let offset = 0;
558
+
559
+ // Skip ID3v2 tag
560
+ if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) {
561
+ offset = 10 +
562
+ ((buf[6] & 0x7f) << 21) |
563
+ ((buf[7] & 0x7f) << 14) |
564
+ ((buf[8] & 0x7f) << 7) |
565
+ (buf[9] & 0x7f);
566
+ }
567
+
568
+ // Find first two syncs to measure actual frame stride
569
+ let firstSync = -1;
570
+ let secondSync = -1;
571
+ for (let i = offset; i < buf.length - 3; i++) {
572
+ if (isSync(i)) {
573
+ if (firstSync === -1) firstSync = i;
574
+ else { secondSync = i; break; }
575
+ }
576
+ }
577
+ if (firstSync === -1 || secondSync === -1) return 0;
578
+
579
+ const frameSize = secondSync - firstSync; // real stride (CBR)
580
+ if (frameSize < 20) return 0;
581
+
582
+ // Parse frame header for sample rate and samples-per-frame
583
+ const h =
584
+ (buf[firstSync] << 24) |
585
+ (buf[firstSync + 1] << 16) |
586
+ (buf[firstSync + 2] << 8) |
587
+ buf[firstSync + 3];
588
+ const version = (h >> 19) & 0x3;
589
+ const sampleRateIdx = (h >> 10) & 0x3;
590
+ if (sampleRateIdx === 3) return 0;
591
+
592
+ const srTable: Record<number, number> = {
593
+ 3: [44100, 48000, 32000][sampleRateIdx],
594
+ 2: [22050, 24000, 16000][sampleRateIdx],
595
+ 0: [11025, 12000, 8000][sampleRateIdx],
596
+ };
597
+ const sampleRate = srTable[version];
598
+ if (!sampleRate) return 0;
599
+
600
+ const isMpeg1 = version === 3;
601
+ const spf = isMpeg1 ? 1152 : 576;
602
+
603
+ // Count frames using stride
604
+ let frames = 0;
605
+ for (let pos = firstSync; pos + 3 < buf.length; pos += frameSize) {
606
+ // Sanity check: verify sync word
607
+ if (!isSync(pos)) {
608
+ // Frame may have been corrupted; scan forward to next sync
609
+ while (pos < buf.length - 3 && !isSync(pos)) pos++;
610
+ if (pos >= buf.length - 3) break;
611
+ }
612
+ frames++;
613
+ }
614
+
615
+ return Math.round((frames * spf) / sampleRate * 1000);
616
  }
617
 
618
  /** Clear mute state immediately */
 
647
  const fullUrl = `http://127.0.0.1:3456/plugins/friend/audio/${audioId}`;
648
  broadcastToVrm({ audioUrl: fullUrl, sendFirstTts: true });
649
 
650
+ // Mute capture for the exact TTS audio duration
651
  if (this.capturing) {
652
+ this.muteForTts(audioId, text);
653
  }
654
  }
655
  } catch (err) {