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 音频长度来,用户话音一落地就恢复收音
- 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
|
| 508 |
* Audio from arecord will not be forwarded to STT or VAD while muted.
|
| 509 |
-
* Automatically unmutes
|
| 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 |
-
//
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
|
|
|
|
|
|
| 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 |
-
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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) {
|