fix(voice-gateway): S2S idle timeout + upstream send lock + iOS AudioContext suspended + port 3012→3013

- P0: S2S DialogAudioIdleTimeoutError now notifies client instead of force-closing, sets upstreamReady=false and cancels keepalive
- P0: Reduce audioKeepaliveIntervalMs from 20s to 8s to prevent S2S idle timeout
- P1: Add upstreamSendLock to prevent concurrent IllegalStateException: Send pending
- P1: iOS AudioContext suspended handling - buffer audio chunks and try resume after user interaction
- P1: disconnect() clears pendingAudioChunks and _resuming to prevent memory leak
- Fix: Frontend hardcoded port 3012→3013 in videoApi.js and vite.config.js
- Add complete Java backend source code to git tracking
This commit is contained in:
User
2026-04-16 19:16:11 +08:00
parent fe25229de7
commit ff6a63147b
93 changed files with 10557 additions and 23 deletions

View File

@@ -10,6 +10,8 @@ class NativeVoiceService {
this.playbackTime = 0;
this.activeSources = new Set();
this.pendingSamples = [];
this.pendingAudioChunks = [];
this._resuming = false;
this.readyResolver = null;
this.readyRejector = null;
this.callbacks = {
@@ -19,6 +21,7 @@ class NativeVoiceService {
onAssistantPending: null,
onDiagnostic: null,
onIdleTimeout: null,
onProductLink: null,
};
}
@@ -87,6 +90,15 @@ class NativeVoiceService {
}
this.playbackTime = this.playbackContext.currentTime;
// 安全上下文检查: getUserMedia 需要 HTTPS 或 localhost
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const errMsg = window.isSecureContext === false
? '麦克风访问需要 HTTPS 连接,请使用 https:// 地址访问'
: '当前浏览器不支持麦克风访问';
this.emitConnectionState('error', errMsg);
throw new Error(errMsg);
}
// 并行: 同时预获取麦克风和建立WS连接节省500ms+
const micPromise = navigator.mediaDevices.getUserMedia({
audio: {
@@ -97,7 +109,12 @@ class NativeVoiceService {
},
video: false,
}).catch((err) => {
console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.message);
console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.name, err.message);
if (err.name === 'NotAllowedError' || err.message?.includes('Permission denied')) {
const msg = '麦克风权限被拒绝,请在浏览器设置中允许本站访问麦克风后重试';
this.emitConnectionState('error', msg);
throw new Error(msg);
}
return null;
});
@@ -206,6 +223,14 @@ class NativeVoiceService {
this.callbacks.onIdleTimeout?.(msg.timeout || 300000);
return;
}
if (msg.type === 'product_link') {
this.callbacks.onProductLink?.({
product: msg.product,
link: msg.link,
description: msg.description,
});
return;
}
if (msg.type === 'upstream_closed') {
this.callbacks.onError?.(new Error('语音服务已断开,请重新开始通话'));
return;
@@ -224,35 +249,66 @@ class NativeVoiceService {
if (!this.playbackContext) {
return;
}
const pcm16 = new Int16Array(arrayBuffer);
if (!pcm16.length) {
if (this.playbackContext.state === 'suspended') {
this.pendingAudioChunks.push(arrayBuffer);
this._tryResumePlayback();
return;
}
const audioBuffer = this.playbackContext.createBuffer(1, pcm16.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm16.length; i += 1) {
channel[i] = pcm16[i] / 32768;
this._playPcm(arrayBuffer);
}
_playPcm(arrayBuffer) {
try {
const pcm16 = new Int16Array(arrayBuffer);
if (!pcm16.length) {
return;
}
const audioBuffer = this.playbackContext.createBuffer(1, pcm16.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm16.length; i += 1) {
channel[i] = pcm16[i] / 32768;
}
const source = this.playbackContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.playbackContext.destination);
this.activeSources.add(source);
source.onended = () => {
this.activeSources.delete(source);
try {
source.disconnect();
} catch (_) {}
};
const now = this.playbackContext.currentTime;
if (this.playbackTime < now) {
this.playbackTime = now + 0.02;
}
source.start(this.playbackTime);
this.playbackTime += audioBuffer.duration;
this.emitDiagnostic('audio_chunk', { samples: pcm16.length, duration: audioBuffer.duration });
} catch (err) {
console.warn('[NativeVoice] playPcm failed:', err.message);
}
const source = this.playbackContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.playbackContext.destination);
this.activeSources.add(source);
source.onended = () => {
this.activeSources.delete(source);
try {
source.disconnect();
} catch (_) {}
};
const now = this.playbackContext.currentTime;
if (this.playbackTime < now) {
this.playbackTime = now + 0.02;
}
async _tryResumePlayback() {
if (this._resuming) return;
this._resuming = true;
try {
await this.playbackContext.resume();
while (this.pendingAudioChunks.length > 0) {
this._playPcm(this.pendingAudioChunks.shift());
}
} catch (e) {
console.warn('[NativeVoice] resume failed:', e.message);
} finally {
this._resuming = false;
}
source.start(this.playbackTime);
this.playbackTime += audioBuffer.duration;
this.emitDiagnostic('audio_chunk', { samples: pcm16.length, duration: audioBuffer.duration });
}
async startCapture(preFetchedStream) {
if (!preFetchedStream && (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia)) {
throw new Error('麦克风不可用: 需要 HTTPS 安全连接');
}
this.mediaStream = preFetchedStream || await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
@@ -365,6 +421,8 @@ class NativeVoiceService {
}
this.playbackTime = 0;
this.pendingSamples = [];
this.pendingAudioChunks = [];
this._resuming = false;
this.emitConnectionState('disconnected');
}