Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
1 parent
eb1abbadf8
commit
882bf8bc95
53 files changed
+3438
-633
No files matched your search
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import sys
|
||||
@@ -70,7 +73,12 @@ def test_build_app_injects_public_realtime_url():
|
||||
def test_build_app_versions_client_bundle_and_disables_index_cache():
|
||||
client = TestClient(build_app())
|
||||
expected_version_hash = hashlib.sha256()
|
||||
for asset_name in ("app.js", "pcm_worklet.js", "playback_worklet.js"):
|
||||
for asset_name in (
|
||||
"app.js",
|
||||
"pcm_worklet.js",
|
||||
"playback_worklet.js",
|
||||
*[str(p.relative_to(STATIC_DIR)) for p in sorted((STATIC_DIR / "profiles").glob("*.js"))],
|
||||
):
|
||||
expected_version_hash.update((STATIC_DIR / asset_name).read_bytes())
|
||||
expected_version = expected_version_hash.hexdigest()[:12]
|
||||
|
||||
@@ -97,3 +105,41 @@ def test_build_app_exposes_realtime_websocket_and_static_assets():
|
||||
assert client.get("/static/app.js").status_code == 200
|
||||
assert client.get("/static/pcm_worklet.js").status_code == 200
|
||||
assert client.get("/static/playback_worklet.js").status_code == 200
|
||||
|
||||
|
||||
def test_qwen_config_does_not_inject_reference_voice():
|
||||
client = TestClient(build_app(profile="qwen3-turn", adapter="vad", model="Qwen/Qwen3-Omni-30B-A3B-Instruct"))
|
||||
page = client.get("/").text
|
||||
assert '"profile": "qwen3-turn"' in page
|
||||
assert '"adapter": "vad"' in page
|
||||
assert '"refAudio": null' in page
|
||||
for name in ("common", "minicpm_native", "qwen3_turn"):
|
||||
assert client.get(f"/static/profiles/{name}.js").status_code == 200
|
||||
with pytest.raises(ValueError, match="ref-audio"):
|
||||
build_app(profile="qwen3-turn", ref_audio="unused.wav")
|
||||
|
||||
|
||||
def test_config_cannot_close_its_script_element():
|
||||
page = TestClient(build_app(model="</script><script>alert(1)</script>")).get("/").text
|
||||
assert "</script><script>alert(1)" not in page
|
||||
assert "\\u003c/script>" in page
|
||||
|
||||
|
||||
def test_proxy_reports_unreachable_backend(monkeypatch):
|
||||
from examples.online_serving.realtime_web import server as shared_server
|
||||
|
||||
class RefusedConnection:
|
||||
async def __aenter__(self):
|
||||
raise ConnectionRefusedError(111, "Connection refused")
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(shared_server.websockets, "connect", lambda *args, **kwargs: RefusedConnection())
|
||||
client = TestClient(build_app(ws_backend="ws://127.0.0.1:8091"))
|
||||
with client.websocket_connect("/v1/realtime?duplex=0") as ws:
|
||||
event = ws.receive_json()
|
||||
assert event["type"] == "error"
|
||||
assert event["code"] == "backend_unavailable"
|
||||
assert "ws://127.0.0.1:8091" in event["error"]
|
||||
assert "--port" in event["error"]
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
@@ -9,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "examples" / "online_serving" / "minicpmo" / "realtime_web"
|
||||
ROOT = Path(__file__).resolve().parents[2] / "examples" / "online_serving" / "realtime_web"
|
||||
APP_ROOT = ROOT / "app"
|
||||
STATIC_ROOT = APP_ROOT / "static"
|
||||
|
||||
@@ -30,73 +29,37 @@ def test_page_exposes_focused_call_conversation_and_log_surfaces():
|
||||
assert "Server VAD" not in html
|
||||
|
||||
|
||||
def test_client_uses_proxy_relative_realtime_url_and_model_policy_session():
|
||||
def test_shared_shell_delegates_wire_contract_to_profiles():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
assert "profile.url(config, window.location.href)" in source
|
||||
assert "profile.initialMessages(config, instructions)" in source
|
||||
assert "profile.append(" in source
|
||||
assert "profile.mapEvent(event)" in source
|
||||
assert "native_duplex" not in source
|
||||
assert "server_vad" not in source
|
||||
|
||||
assert "new URL(config.realtimePath, window.location.href)" in source
|
||||
assert "url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'" in source
|
||||
assert "url.searchParams.set('autostart', '0')" in source
|
||||
|
||||
def test_native_profile_preserves_session_and_capture_policy():
|
||||
source = (STATIC_ROOT / "profiles/minicpm_native.js").read_text(encoding="utf-8")
|
||||
assert "native_duplex" not in source
|
||||
assert "auto_response: true" in source
|
||||
assert "input_audio_buffer.append" in source
|
||||
assert "input_audio_buffer.commit" not in source
|
||||
assert "playback.ack" in source
|
||||
assert "event.event || event" in source
|
||||
assert "response.output_audio.delta" in source
|
||||
assert "response.output_audio_transcript.delta" in source
|
||||
assert "conversation.item.input_audio_transcription" in source
|
||||
assert "force_barge_in" not in source
|
||||
assert "server_vad" not in source
|
||||
assert "type: 'response.create'" not in source
|
||||
assert 'type: "response.create"' not in source
|
||||
|
||||
|
||||
def test_web_server_requires_ref_audio_for_audio_output_session():
|
||||
source = (ROOT / "server.py").read_text(encoding="utf-8")
|
||||
|
||||
ref_audio_arg = re.search(
|
||||
r"parser\.add_argument\(\s*\"--ref-audio\",(?P<body>.*?)\n\s*\)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert ref_audio_arg is not None
|
||||
assert "required=True" in ref_audio_arg.group("body")
|
||||
assert "Optional reference voice" not in ref_audio_arg.group("body")
|
||||
|
||||
|
||||
def test_client_sends_ref_audio_in_realtime_session_contract():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "if (config.refAudio) session.ref_audio = config.refAudio;" in source
|
||||
assert "extraBody.ref_audio" not in source
|
||||
assert "halfDuplex: false" in source
|
||||
assert "commitMessages: () => []" in source
|
||||
assert "video_frames" in source
|
||||
assert "playback.ack" in source
|
||||
|
||||
|
||||
def test_client_has_transactional_cleanup_and_visible_event_logging():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "async function stopSession" in source
|
||||
assert "type: 'session.close'" in source
|
||||
assert "async function cleanupSession" in source
|
||||
assert "waitForSessionClosed" in source
|
||||
assert "SESSION_CLOSE_TIMEOUT_MS" in source
|
||||
assert "case 'session.closed':" in source
|
||||
assert "track.stop()" in source
|
||||
assert "clearInterval(sendTimer)" in source
|
||||
assert "appendEventLog(event)" in source
|
||||
assert "stopSession({ terminal: false })" in source
|
||||
|
||||
|
||||
def test_client_keeps_microphone_upload_active_during_assistant_playback():
|
||||
source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||
upload_gate = re.search(r"function microphoneUploadEnabled\(\) \{(?P<body>.*?)\n \}", source, re.DOTALL)
|
||||
begin_assistant = re.search(r"function beginAssistant\(responseId\) \{(?P<body>.*?)\n \}", source, re.DOTALL)
|
||||
|
||||
assert upload_gate is not None
|
||||
assert "return running && !muted;" in upload_gate.group("body")
|
||||
assert "assistantActive" not in upload_gate.group("body")
|
||||
assert begin_assistant is not None
|
||||
assert "pendingCapture = []" not in begin_assistant.group("body")
|
||||
|
||||
|
||||
def test_audio_worklets_define_capture_and_playback_processors():
|
||||
capture = (STATIC_ROOT / "pcm_worklet.js").read_text(encoding="utf-8")
|
||||
playback = (STATIC_ROOT / "playback_worklet.js").read_text(encoding="utf-8")
|
||||
@@ -138,6 +101,7 @@ def test_playback_worklet_waits_before_playing_and_rebuffers_after_underrun():
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node is required for the AudioWorklet regression test")
|
||||
assert node is not None
|
||||
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
@@ -216,6 +180,7 @@ def test_playback_worklet_fades_terminal_drain_to_zero():
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node is required for the AudioWorklet regression test")
|
||||
assert node is not None
|
||||
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = path.resolve(__dirname, '../../examples/online_serving/realtime_web/app/static');
|
||||
const context = vm.createContext({ URL });
|
||||
for (const name of ['common', 'minicpm_native', 'qwen3_turn']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(root, `profiles/${name}.js`), 'utf8'), context);
|
||||
}
|
||||
const profiles = context.OmniRealtimeProfiles;
|
||||
const plain = (value) => JSON.parse(JSON.stringify(value));
|
||||
const config = { model: 'Qwen/Qwen3-Omni-30B-A3B-Instruct', realtimePath: 'v1/realtime' };
|
||||
|
||||
test('MiniCPM keeps native flags, reference voice, camera, acknowledgements and no commits', () => {
|
||||
const native = profiles['minicpm-native']();
|
||||
const url = new URL(native.url({ ...config, model: 'MiniCPM' }, 'https://localhost/ui/'));
|
||||
assert.equal(url.protocol, 'wss:');
|
||||
assert.equal(url.searchParams.get('duplex'), '1');
|
||||
assert.equal(url.searchParams.has('native_duplex'), false);
|
||||
const [update] = native.initialMessages({ refAudio: 'data:audio/wav;base64,AA==' }, 'Prompt');
|
||||
assert.equal(update.session.ref_audio, 'data:audio/wav;base64,AA==');
|
||||
assert.equal(update.session.extra_body.native_duplex, undefined);
|
||||
assert.equal(update.session.instructions, 'Prompt');
|
||||
assert.deepEqual(plain(native.commitMessages()), []);
|
||||
assert.equal(native.append('PCM', 'JPEG').video_frames[0], 'JPEG');
|
||||
assert.equal(native.ack('r1', 200).type, 'playback.ack');
|
||||
assert.equal(native.halfDuplex, false);
|
||||
});
|
||||
|
||||
test('STT uses the shipped commit sequence and audio payload, including each new turn', () => {
|
||||
const stt = profiles['qwen3-turn'](config);
|
||||
for (let turn = 0; turn < 2; turn++) {
|
||||
assert.deepEqual(plain(stt.initialMessages(config, 'ignored')), [
|
||||
{ type: 'session.update', model: config.model },
|
||||
{ type: 'input_audio_buffer.commit', final: false },
|
||||
]);
|
||||
assert.deepEqual(plain(stt.commitMessages()), [{ type: 'input_audio_buffer.commit', final: true }]);
|
||||
}
|
||||
assert.equal(stt.reconnectEachTurn, true);
|
||||
assert.equal(stt.mapEvent({ type: 'response.output_audio.delta', audio: 'PCM' }).event.delta, 'PCM');
|
||||
assert.equal(stt.mapEvent({ type: 'response.output_audio.done' }).kind, 'done');
|
||||
assert.equal(stt.mapEvent({ type: 'transcription.delta', delta: 'hello' }).role, 'assistant');
|
||||
});
|
||||
|
||||
test('Qwen enables camera and playback ACK only for the duplex VAD profile', () => {
|
||||
for (const adapter of ['stt', 'vad']) {
|
||||
const p = profiles['qwen3-turn']({ ...config, adapter });
|
||||
const url = new URL(p.url({ ...config, realtimePath: 'wss://backend/v1/realtime?native_duplex=1&minicpmo45_native_duplex=1' }, 'http://localhost/'));
|
||||
assert.equal(url.protocol, 'wss:');
|
||||
assert.equal(url.searchParams.get('duplex'), adapter === 'vad' ? '1' : '0');
|
||||
assert.equal(url.searchParams.has('native_duplex'), false);
|
||||
assert.equal(url.searchParams.has('minicpmo45_native_duplex'), false);
|
||||
assert.equal(p.append('PCM', 'JPEG').video_frames, undefined);
|
||||
assert.equal(plain(p.imageMessages('JPEG')).length, adapter === 'vad' ? 1 : 0);
|
||||
assert.equal(p.ack('r', 100)?.type || null, adapter === 'vad' ? 'playback.ack' : null);
|
||||
assert.equal(p.camera, adapter === 'vad');
|
||||
assert.equal(p.mapEvent({ type: 'response.listen' }).kind, 'ignore');
|
||||
}
|
||||
});
|
||||
|
||||
test('VAD uses nested format and interruptible endpoint detection', () => {
|
||||
const vad = profiles['qwen3-turn']({ ...config, adapter: 'vad' });
|
||||
const [update] = vad.initialMessages(config, 'help');
|
||||
assert.deepEqual(plain(update.session.audio.input), {
|
||||
format: { type: 'audio/pcm', rate: 24000 },
|
||||
turn_detection: { type: 'server_vad', threshold: 0.5, prefix_padding_ms: 300,
|
||||
silence_duration_ms: 500, create_response: true, interrupt_response: true },
|
||||
});
|
||||
assert.equal(update.session.extra_body, undefined);
|
||||
assert.deepEqual(plain(vad.commitMessages()), []);
|
||||
assert.equal(vad.mapEvent({ type: 'response.audio.done' }).kind, 'drain');
|
||||
assert.equal(vad.mapEvent({ type: 'response.done' }).kind, 'done');
|
||||
assert.equal(vad.mapEvent({ type: 'response.output_text.delta', delta: 'hello' }).text, 'hello');
|
||||
assert.equal(vad.mapEvent({ type: 'error', error: { code: 'input_backpressure' } }).kind, 'backpressure');
|
||||
assert.equal(vad.mapEvent({ type: 'input_audio_buffer.cleared' }).kind, 'backpressure');
|
||||
assert.match(vad.mapEvent({ type: 'error', code: 'unsupported', error: 'Unavailable' }).message, /session_mode: duplex/);
|
||||
assert.match(vad.mapEvent({ type: 'error', code: 'server_vad_initialization_failed' }).message, /Silero/);
|
||||
});
|
||||
|
||||
function shell(profileName, adapter = 'stt', options = {}) {
|
||||
const elements = new Map();
|
||||
class Element {
|
||||
constructor() { this.style = {}; this.listeners = {}; this.children = []; this.value = ''; this.textContent = ''; this.classList = { add() {}, remove() {}, toggle() {} }; }
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
appendChild(child) { this.children.push(child); }
|
||||
replaceChildren() { this.children = []; }
|
||||
remove() {}
|
||||
}
|
||||
const document = {
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
||||
createElement() { return new Element(); },
|
||||
};
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
const sockets = [];
|
||||
const nodes = [];
|
||||
class Socket {
|
||||
static OPEN = 1;
|
||||
constructor() {
|
||||
this.readyState = 1; this.sent = []; sockets.push(this);
|
||||
queueMicrotask(() => {
|
||||
this.onopen?.();
|
||||
if (!options.silent) this.onmessage?.({ data: JSON.stringify(options.error || { type: adapter === 'stt' && profileName === 'qwen3-turn' ? 'session.created' : 'session.updated' }) });
|
||||
});
|
||||
}
|
||||
send(value) { this.sent.push(JSON.parse(value)); }
|
||||
close() { this.readyState = 3; this.onclose?.({ code: 1000 }); }
|
||||
}
|
||||
class AudioContext {
|
||||
constructor(options) { this.sampleRate = options.sampleRate; this.audioWorklet = { addModule: async () => {} }; this.destination = {}; }
|
||||
createMediaStreamSource() { return { connect() {} }; }
|
||||
createGain() { return { gain: {}, connect() {} }; }
|
||||
async resume() {}
|
||||
async close() {}
|
||||
}
|
||||
class AudioWorkletNode {
|
||||
constructor(_context, name) { this.name = name; this.sent = []; nodes.push(this); this.port = { postMessage: (message) => this.sent.push(message) }; }
|
||||
connect(target) { return target; }
|
||||
}
|
||||
const ctx = vm.createContext({
|
||||
URL, document, AudioContext, AudioWorkletNode, WebSocket: Socket,
|
||||
navigator: { mediaDevices: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) } },
|
||||
location: { href: 'https://localhost/' },
|
||||
OMNI_REALTIME_CONFIG: { ...config, profile: profileName, adapter },
|
||||
addEventListener() {},
|
||||
setTimeout(fn, ms) { const id = ++timerId; timers.set(id, { fn, ms }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
setInterval() { return ++timerId; }, clearInterval() {},
|
||||
btoa: (value) => Buffer.from(value, 'binary').toString('base64'),
|
||||
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
|
||||
});
|
||||
ctx.window = ctx;
|
||||
for (const name of ['common', 'minicpm_native', 'qwen3_turn']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(root, `profiles/${name}.js`), 'utf8'), ctx);
|
||||
}
|
||||
// Expose closure controls only in the test VM; production has no test API.
|
||||
const source = fs.readFileSync(path.join(root, 'app.js'), 'utf8').replace(/\}\)\(\);\s*$/, `
|
||||
globalThis.testUI = { startSession, stopSession, handleEvent, playbackDrained,
|
||||
microphoneUploadEnabled, flushCapture,
|
||||
capture() { pendingCapture.push(new Int16Array([100, 200])); },
|
||||
state() { return { running, connectionReady, assistantActive }; }
|
||||
};
|
||||
})();`);
|
||||
vm.runInContext(source, ctx);
|
||||
return { ui: ctx.testUI, elements, sockets, nodes,
|
||||
timeout() { for (const timer of timers.values()) if (timer.ms === 15000) timer.fn(); },
|
||||
async echo() {
|
||||
for (const [id, timer] of timers) if (timer.ms === 300) { timers.delete(id); await timer.fn(); }
|
||||
},
|
||||
async turnWatchdog() {
|
||||
for (const [id, timer] of timers) if (timer.ms === 120000) { timers.delete(id); await timer.fn(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('shared shell STT sends final commit and starts a fresh second turn only after playback', async () => {
|
||||
const app = shell('qwen3-turn');
|
||||
await app.ui.startSession();
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true);
|
||||
app.ui.capture(); app.ui.flushCapture();
|
||||
app.elements.get('sendTurnButton').listeners.click();
|
||||
assert.equal(app.sockets[0].sent.at(-1).final, true);
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), false);
|
||||
await app.ui.handleEvent({ type: 'response.output_audio.delta', audio: 'AAAAAA==' });
|
||||
await app.ui.handleEvent({ type: 'response.output_audio.done' });
|
||||
await app.echo();
|
||||
assert.equal(app.sockets.length, 1, 'wait for actual speaker drain');
|
||||
app.ui.playbackDrained({ responseId: 'turn-1', playedMs: 1 });
|
||||
await app.echo();
|
||||
assert.equal(app.sockets.length, 2);
|
||||
assert.equal(app.sockets[1].sent[1].final, false);
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true);
|
||||
assert.equal(app.elements.get('cameraButton').hidden, true);
|
||||
assert.equal(app.sockets.flatMap(s => s.sent).some(e => e.type === 'playback.ack'), false);
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
test('VAD keeps uploading while generating and waits for response.done after audio drain', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'error', code: 'input_backpressure' });
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true);
|
||||
await app.ui.handleEvent({ type: 'response.created', response: { id: 'r1' } });
|
||||
await app.ui.handleEvent({ type: 'response.audio.delta', delta: 'AAAAAA==', response_id: 'r1' });
|
||||
await app.ui.handleEvent({ type: 'response.audio.done', response_id: 'r1' });
|
||||
app.ui.playbackDrained({ responseId: 'r1', playedMs: 1 });
|
||||
await app.echo();
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true, 'continuous capture remains enabled');
|
||||
assert.equal(app.ui.state().assistantActive, true, 'audio drain must wait for response.done');
|
||||
assert.equal(app.sockets[0].sent.at(-1).type, 'playback.ack');
|
||||
await app.ui.handleEvent({ type: 'response.done', response: { id: 'r1' } });
|
||||
await app.echo();
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true);
|
||||
assert.equal(app.sockets.length, 1, 'VAD keeps its session');
|
||||
assert.equal(app.sockets[0].sent.some(e => e.type === 'input_audio_buffer.commit'), false);
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
test('a rejected operation is reported without hanging up the call', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'error', code: 'invalid_image', error: 'input_image requires a JPEG or PNG base64 data URL' });
|
||||
assert.equal(app.ui.state().running, true, 'one refused image must not end the session');
|
||||
assert.equal(app.ui.state().connectionReady, true);
|
||||
assert.match(app.elements.get('runtimeDetail').textContent, /JPEG or PNG/);
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
test('a session-level error still tears the call down', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'error', code: 'unknown_session', error: 'Unknown or closed duplex session' });
|
||||
assert.equal(app.ui.state().running, false);
|
||||
assert.equal(app.elements.get('connectionState').textContent, 'Error');
|
||||
});
|
||||
|
||||
test('an interrupted response disarms the turn watchdog it left armed', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'response.created', response: { id: 'r1' } });
|
||||
await app.ui.handleEvent({ type: 'error', code: 'input_backpressure' });
|
||||
await app.ui.handleEvent({ type: 'response.done', response: { id: 'r1', status: 'cancelled' } });
|
||||
await app.turnWatchdog();
|
||||
assert.equal(app.ui.state().running, true, 'barge-in must not time the session out two minutes later');
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
test('MiniCPM retains microphone upload while speaking and acknowledges speaker drain', async () => {
|
||||
const app = shell('minicpm-native');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'response.speak', response_id: 'native-r1' });
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true);
|
||||
await app.ui.handleEvent({ type: 'response.output_audio.delta', delta: 'AAAAAA==', response_id: 'native-r1' });
|
||||
app.ui.playbackDrained({ responseId: 'native-r1', playedMs: 100 });
|
||||
assert.equal(app.sockets[0].sent.at(-1).type, 'playback.ack');
|
||||
assert.equal(app.sockets[0].sent.some(e => e.type === 'input_audio_buffer.commit'), false);
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
await app.echo();
|
||||
assert.equal(app.ui.state().running, false);
|
||||
});
|
||||
|
||||
test('VAD handshake errors remain visible after resource cleanup', async () => {
|
||||
const app = shell('qwen3-turn', 'vad', { error: { type: 'error', code: 'unsupported', error: 'Realtime API is not available' } });
|
||||
await app.ui.startSession();
|
||||
assert.equal(app.ui.state().running, false);
|
||||
assert.equal(app.elements.get('connectionState').textContent, 'Error');
|
||||
assert.match(app.elements.get('runtimeDetail').textContent, /session_mode: duplex/);
|
||||
assert.equal(app.sockets[0].readyState, 3);
|
||||
});
|
||||
|
||||
test('missing session acknowledgement fails instead of leaving Start disabled forever', async () => {
|
||||
const app = shell('qwen3-turn', 'vad', { silent: true });
|
||||
const starting = app.ui.startSession();
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
app.timeout();
|
||||
await starting;
|
||||
assert.equal(app.ui.state().running, false);
|
||||
assert.equal(app.elements.get('callButton').disabled, false);
|
||||
assert.match(app.elements.get('runtimeDetail').textContent, /handshake timed out/);
|
||||
});
|
||||
|
||||
test('Qwen does not duplicate text and audio-transcript channels', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'response.audio_transcript.delta', delta: '' });
|
||||
await app.ui.handleEvent({ type: 'response.output_text.delta', delta: 'Hello' });
|
||||
await app.ui.handleEvent({ type: 'response.audio_transcript.delta', delta: 'Hello' });
|
||||
await app.ui.handleEvent({ type: 'response.output_text.done', text: 'Hello' });
|
||||
await app.ui.handleEvent({ type: 'response.done' });
|
||||
const turns = app.elements.get('conversation').children;
|
||||
assert.equal(turns.length, 1);
|
||||
assert.equal(turns[0].children[1].textContent, 'Hello');
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
const flushTasks = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
||||
|
||||
test('closing socket accepts only session.closed and completes without the fallback timer', async () => {
|
||||
const app = shell('minicpm-native');
|
||||
await app.ui.startSession();
|
||||
const old = app.sockets[0];
|
||||
let stopped = false;
|
||||
const stopping = app.ui.stopSession().then(() => { stopped = true; });
|
||||
assert.equal(old.sent.at(-1).type, 'session.close');
|
||||
old.onmessage({ data: JSON.stringify({ type: 'session.updated' }) });
|
||||
await flushTasks();
|
||||
assert.equal(stopped, false);
|
||||
old.onmessage({ data: JSON.stringify({ type: 'session.closed' }) });
|
||||
await flushTasks();
|
||||
assert.equal(stopped, true, 'must finish without firing the timeout');
|
||||
await stopping;
|
||||
await app.ui.startSession();
|
||||
old.onmessage({ data: JSON.stringify({ type: 'session.closed' }) });
|
||||
old.onmessage({ data: '{broken' });
|
||||
await flushTasks();
|
||||
assert.equal(app.ui.state().running, true, 'stale socket cannot affect a new session');
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
test('malformed JSON before readiness rejects the handshake and cleans up', async () => {
|
||||
const app = shell('qwen3-turn', 'vad', { silent: true });
|
||||
const starting = app.ui.startSession();
|
||||
await flushTasks();
|
||||
app.sockets[0].onmessage({ data: '{broken' });
|
||||
await starting;
|
||||
assert.equal(app.ui.state().running, false);
|
||||
assert.equal(app.sockets[0].readyState, 3);
|
||||
assert.equal(app.elements.get('connectionState').textContent, 'Error');
|
||||
assert.match(app.elements.get('runtimeDetail').textContent, /Invalid server event/);
|
||||
});
|
||||
|
||||
test('malformed JSON after readiness fails the session and releases resources', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
app.sockets[0].onmessage({ data: '{broken' });
|
||||
await flushTasks();
|
||||
assert.equal(app.ui.state().running, false);
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), false);
|
||||
assert.equal(app.sockets[0].readyState, 3);
|
||||
assert.equal(app.elements.get('connectionState').textContent, 'Error');
|
||||
assert.match(app.elements.get('runtimeDetail').textContent, /Invalid server event/);
|
||||
});
|
||||
|
||||
|
||||
test('Qwen VAD interruption clears playback while continuing microphone upload', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
await app.ui.handleEvent({ type: 'response.created', response: { id: 'r1' } });
|
||||
await app.ui.handleEvent({ type: 'response.audio.delta', delta: 'AAAAAA==', response_id: 'r1' });
|
||||
await app.ui.handleEvent({ type: 'output_audio_buffer.cleared', response_id: 'r1' });
|
||||
assert.equal(app.ui.state().assistantActive, false);
|
||||
assert.equal(app.ui.microphoneUploadEnabled(), true);
|
||||
assert.ok(app.nodes.some(node => node.sent.some(message => message.type === 'clear')));
|
||||
await app.ui.stopSession({ terminal: false });
|
||||
});
|
||||
|
||||
|
||||
test('Qwen runtime errors do not suggest changing a working deployment', () => {
|
||||
const p = profiles['qwen3-turn']({ adapter: 'vad' });
|
||||
const message = 'playback.ack arrived after a later user input was committed.';
|
||||
assert.equal(p.mapEvent({ type: 'error', code: 'playback_ack_too_late', error: message }).message, message);
|
||||
});
|
||||
|
||||
test('interrupted playback reports its cursor before clear resets it', () => {
|
||||
let Playback;
|
||||
const messages = [];
|
||||
const ctx = vm.createContext({
|
||||
sampleRate: 24000,
|
||||
AudioWorkletProcessor: class { constructor() { this.port = { postMessage: m => messages.push(m) }; } },
|
||||
registerProcessor: (_name, cls) => { Playback = cls; },
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'playback_worklet.js'), 'utf8'), ctx);
|
||||
const player = new Playback();
|
||||
player.handleMessage({ type: 'audio', responseId: 'old', pcm: new Int16Array(24000), initialBufferMs: 0 });
|
||||
player.process([], [[new Float32Array(2400)]]);
|
||||
player.handleMessage({ type: 'clear' });
|
||||
const report = messages.find(m => m.type === 'playback-stopped');
|
||||
assert.equal(report.responseId, 'old');
|
||||
assert.equal(report.playedMs, 100);
|
||||
assert.equal(player.playedFrames, 0);
|
||||
assert.equal(player.bufferedFrames(), 0);
|
||||
player.handleMessage({ type: 'clear' });
|
||||
assert.equal(messages.filter(m => m.type === 'playback-stopped').length, 1);
|
||||
});
|
||||
|
||||
test('late interruption cursor acknowledges old response without finishing the new response', async () => {
|
||||
const app = shell('qwen3-turn', 'vad');
|
||||
await app.ui.startSession();
|
||||
const player = app.nodes.find(n => n.name === 'fullduplex-pcm-playback');
|
||||
await app.ui.handleEvent({ type: 'response.created', response: { id: 'old' } });
|
||||
await app.ui.handleEvent({ type: 'output_audio_buffer.cleared', response_id: 'old' });
|
||||
await app.ui.handleEvent({ type: 'response.created', response: { id: 'new' } });
|
||||
player.port.onmessage({ data: { type: 'playback-stopped', responseId: 'old', playedMs: 100 } });
|
||||
const ack = app.sockets[0].sent.find(m => m.type === 'playback.ack');
|
||||
assert.equal(ack.response_id, 'old');
|
||||
assert.equal(ack.played_ms, 100);
|
||||
assert.equal(app.ui.state().assistantActive, true);
|
||||
});
|
||||
|
||||
|
||||
test('Qwen camera frames ride the OpenAI image interface, not the audio append', () => {
|
||||
const p = profiles['qwen3-turn']({ ...config, adapter: 'vad' });
|
||||
assert.deepEqual(plain(p.append('PCM', 'JPEG')), { type: 'input_audio_buffer.append', audio: 'PCM' });
|
||||
|
||||
const [created] = plain(p.imageMessages('JPEG'));
|
||||
assert.equal(created.type, 'conversation.item.create');
|
||||
assert.equal(created.item.role, 'user');
|
||||
assert.deepEqual(created.item.content, [
|
||||
{ type: 'input_image', image_url: 'data:image/jpeg;base64,JPEG' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('the camera retires its oldest image instead of exhausting the session budget', () => {
|
||||
const p = profiles['qwen3-turn']({ ...config, adapter: 'vad' });
|
||||
const ids = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const messages = plain(p.imageMessages('JPEG'));
|
||||
assert.equal(messages.length, 1, 'nothing to retire while under the budget');
|
||||
ids.push(messages[0].item.id);
|
||||
}
|
||||
// The engine counts what it already stores, so the delete has to lead.
|
||||
const ninth = plain(p.imageMessages('JPEG'));
|
||||
assert.deepEqual(ninth[0], { type: 'conversation.item.delete', item_id: ids[0] });
|
||||
assert.equal(ninth[1].type, 'conversation.item.create');
|
||||
assert.equal(new Set(ids).size, 8, 'item ids must be distinct to be deletable');
|
||||
|
||||
// A new call reopens the budget; stale ids from the old one must not linger.
|
||||
assert.equal(plain(p.initialMessages(config, 'x')).length > 0, true);
|
||||
assert.equal(plain(p.imageMessages('JPEG')).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_realtime_profiles_and_shell_lifecycle():
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node is required for browser protocol tests")
|
||||
assert node is not None
|
||||
subprocess.run(
|
||||
[node, "--test", str(Path(__file__).with_suffix(".cjs"))],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
Reference in new issue
Block a user