[Bugfix] Fix Qwen3-Omni realtime playback interruption and conversation history (#7791)
Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
1 parent
d3396fc5fb
commit
2d0b03f717
13 files changed
+1292
-68
No files matched your search
@@ -79,6 +79,13 @@
|
||||
let echoTimer = null;
|
||||
let sessionGeneration = 0;
|
||||
let audioChain = Promise.resolve();
|
||||
// Retain interrupted IDs until startSession() resets the session. Queued
|
||||
// events and pending audio decodes can outlive response.done, so removing
|
||||
// IDs there could resume interrupted playback. This set grows with the
|
||||
// interruptions in a call; sessionGeneration rejects old-session decodes
|
||||
// after the set is cleared for a new session.
|
||||
const interruptedResponses = new Set();
|
||||
const pendingEvents = new Set();
|
||||
let assistantTextChannel = null;
|
||||
let connectionReady = false;
|
||||
let turnCounter = 0;
|
||||
@@ -389,8 +396,9 @@
|
||||
currentResponseId = action.responseId || currentResponseId || `turn-${turnCounter}`;
|
||||
setModel('Speaking');
|
||||
const generation = sessionGeneration;
|
||||
const responseId = currentResponseId;
|
||||
const decoded = await decodeAudioDelta(action.event);
|
||||
if (generation === sessionGeneration) feedPlayback(decoded, currentResponseId);
|
||||
if (generation === sessionGeneration && !interruptedResponses.has(responseId)) feedPlayback(decoded, responseId);
|
||||
}
|
||||
|
||||
function handleTranscriptEvent(action) {
|
||||
@@ -410,19 +418,32 @@
|
||||
} else finishTranscript(action.role, action.text);
|
||||
}
|
||||
|
||||
async function handleEvent(event) {
|
||||
async function handleEvent(event, action = profile.mapEvent(event)) {
|
||||
appendEventLog(event);
|
||||
const action = profile.mapEvent(event);
|
||||
const responseId = action.responseId;
|
||||
const responseId = action.responseId || responseIdOf(event);
|
||||
if (responseId && interruptedResponses.has(responseId) && action.kind !== 'interrupt') return;
|
||||
switch (action.kind) {
|
||||
case 'connected':
|
||||
setConnection('Connected', 'online');
|
||||
if (!assistantActive) setModel(profile.waiting);
|
||||
break;
|
||||
case 'interrupt':
|
||||
case 'interrupt': {
|
||||
if (responseId) interruptedResponses.add(responseId);
|
||||
// A late cancellation belongs to its own response, not the newer one
|
||||
// currently playing. Speech-start events interrupt the local playback.
|
||||
if (responseId && responseId !== currentResponseId) break;
|
||||
if (currentResponseId) interruptedResponses.add(currentResponseId);
|
||||
sessionGeneration += 1;
|
||||
// A cancelled response never reaches 'done', so nothing else would
|
||||
// disarm the turn watchdog it left armed.
|
||||
audioChain = Promise.resolve();
|
||||
// Move queued events off the interrupted decode, preserving any newer
|
||||
// response already received. Its chunks must not be lost with the old one.
|
||||
const queued = [...pendingEvents];
|
||||
pendingEvents.clear();
|
||||
for (const pending of queued) enqueueEvent(pending.event, pending.action, pending.source);
|
||||
clearTimeout(echoTimer);
|
||||
echoTimer = null;
|
||||
// Local playback completes immediately on interruption, including
|
||||
// when the server already completed generation.
|
||||
clearTimeout(turnTimeout);
|
||||
if (playbackNode) playbackNode.port.postMessage({ type: 'clear' });
|
||||
responseComplete = true;
|
||||
@@ -435,6 +456,7 @@
|
||||
setPlayback('Idle');
|
||||
setModel(profile.waiting);
|
||||
break;
|
||||
}
|
||||
case 'listen':
|
||||
assistantActive = false;
|
||||
setModel(profile.waiting);
|
||||
@@ -538,6 +560,18 @@
|
||||
await captureContext.resume();
|
||||
}
|
||||
|
||||
function enqueueEvent(event, action, source) {
|
||||
const pending = { event, action, source };
|
||||
pendingEvents.add(pending);
|
||||
const generation = sessionGeneration;
|
||||
audioChain = audioChain.then(() => {
|
||||
if (!pendingEvents.delete(pending)) return;
|
||||
if (socket === source || action.kind === 'closed') return handleEvent(event, action);
|
||||
}).catch((error) => {
|
||||
if (generation === sessionGeneration) return failSession(`Server event failed: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function openSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = realtimeUrl();
|
||||
@@ -591,11 +625,13 @@
|
||||
runtimeDetail.textContent = `${captureRate} Hz capture / ${playbackRate} Hz playback`;
|
||||
resolve();
|
||||
}
|
||||
// Serialize decoding with terminal events: a drain must never overtake
|
||||
// an asynchronously decoded WAV chunk.
|
||||
audioChain = audioChain.then(() => {
|
||||
if (socket === current || action.kind === 'closed') return handleEvent(event);
|
||||
}).catch((error) => failSession(`Server event failed: ${error.message}`));
|
||||
// Stop the speaker immediately, even while an old WAV is decoding.
|
||||
// Ordinary completion still waits for its preceding audio chunks.
|
||||
if (action.kind === 'interrupt') {
|
||||
void handleEvent(event, action);
|
||||
return;
|
||||
}
|
||||
enqueueEvent(event, action, current);
|
||||
};
|
||||
current.onerror = () => rejectOnce(`WebSocket connection failed. ${profile.connectionHint}`);
|
||||
current.onclose = (event) => {
|
||||
@@ -628,6 +664,8 @@
|
||||
try {
|
||||
sessionGeneration += 1;
|
||||
audioChain = Promise.resolve();
|
||||
interruptedResponses.clear();
|
||||
pendingEvents.clear();
|
||||
await openPlayback();
|
||||
await openCapture();
|
||||
await openSocket();
|
||||
@@ -731,6 +769,7 @@
|
||||
|
||||
async function cleanupSession({ terminal = true } = {}) {
|
||||
sessionGeneration += 1;
|
||||
pendingEvents.clear();
|
||||
connectionReady = false;
|
||||
clearTimeout(echoTimer);
|
||||
clearTimeout(turnTimeout);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const profiles = root.OmniRealtimeProfiles;
|
||||
profiles['qwen3-turn'] = (config) => {
|
||||
const vad = config.adapter === 'vad';
|
||||
let interruptOnSpeech = vad;
|
||||
let imageSeq = 0;
|
||||
let imageItems = [];
|
||||
// The engine refuses a ninth image (or 4 MiB of them) for the whole
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
initialMessages(config, instructions) {
|
||||
imageItems = [];
|
||||
interruptOnSpeech = vad;
|
||||
if (!vad) return [
|
||||
{ type: 'session.update', model: config.model },
|
||||
{ type: 'input_audio_buffer.commit', final: false },
|
||||
@@ -70,6 +72,20 @@
|
||||
ack: (responseId, playedMs) => vad ? { type: 'playback.ack', response_id: responseId,
|
||||
item_id: `item_${responseId}`, played_ms: playedMs, committed_ms: playedMs } : null,
|
||||
mapEvent(event) {
|
||||
if (event.type === 'session.created' || event.type === 'session.updated') {
|
||||
const input = event.session?.audio?.input;
|
||||
const turnDetection = input && 'turn_detection' in input
|
||||
? input.turn_detection : event.session?.turn_detection;
|
||||
if (turnDetection !== undefined) {
|
||||
interruptOnSpeech = vad && turnDetection?.type === 'server_vad'
|
||||
&& turnDetection.interrupt_response !== false;
|
||||
}
|
||||
}
|
||||
// Generation can finish long before the speaker drains its queue. In
|
||||
// that case the server has no active response left to cancel for us.
|
||||
if (interruptOnSpeech && event.type === 'input_audio_buffer.speech_started') {
|
||||
return { kind: 'interrupt' };
|
||||
}
|
||||
if (event.type === 'playback.acknowledged') return { kind: 'ack', committedMs: (event.event || event).committed_ms || 0 };
|
||||
if (event.type === 'response.output_text.delta' || event.type === 'transcription.delta') {
|
||||
// On the shipped STT route transcription.* is model-generated text,
|
||||
@@ -81,7 +97,7 @@
|
||||
}
|
||||
if (vad && (event.type === 'output_audio_buffer.cleared' ||
|
||||
(event.type === 'response.done' && event.response?.status === 'cancelled'))) {
|
||||
return { kind: 'interrupt' };
|
||||
return { kind: 'interrupt', responseId: event.response_id || event.response?.id || null };
|
||||
}
|
||||
if (event.type === 'input_audio_buffer.committed') return { kind: 'begin' };
|
||||
if (event.type === 'input_audio_buffer.cleared') return { kind: 'backpressure', message: 'Input cleared. Model is answering; please wait.' };
|
||||
|
||||
Reference in new issue
Block a user