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:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user