Qwen Omni

View as Markdown

This guide shows how to integrate the AgentDuet SDK with Alibaba Cloud’s Qwen-Omni Realtime API for bidirectional real-time audio streaming, including sample-rate bridging between AgentDuet and Qwen.

What this integration provides

A phone agent that answers calls, downsamples AgentDuet 24 kHz PCM to Qwen’s 16 kHz input, plays Qwen’s 24 kHz output back on the call, cancels the model turn and clears the outbound buffer on barge-in, and closes the WebSocket on hangup.

Prerequisites

$pip install "agentduet==1.0.0" websockets soxr numpy python-dotenv

Step 1: Create a project folder

$mkdir agentduet-qwen-omni-bridge && cd $_
$python3.12 -m venv .venv && source .venv/bin/activate
$pip install "agentduet==1.0.0" websockets soxr numpy python-dotenv

Step 2: Configure .env

$cat > .env << 'EOF'
$AGENTDUET_API_KEY=your-connector-api-key
$AGENTDUET_CONNECTOR_UUID=your-connector-uuid
$DASHSCOPE_API_KEY=
$DASHSCOPE_REGION=intl
$EOF

Step 3: Write qwen_omni_bridge.py

Create the file next to .env:

1import asyncio
2import base64
3import json
4import logging
5import os
6import time
7from typing import Any, Callable, Dict, Optional
8
9import numpy as np
10import soxr
11import websockets
12from dotenv import load_dotenv
13
14from agentduet import (
15 BufferFullError,
16 Call,
17 CallAudioConfig,
18 CallClosedError,
19 IncomingCallNotification,
20 SessionManager,
21 SessionManagerConfig,
22 new_session_id,
23)
24
25load_dotenv()
26
27logging.basicConfig(
28 level=logging.INFO,
29 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30)
31logger = logging.getLogger(__name__)
32
33REGION = os.getenv("DASHSCOPE_REGION", "intl")
34BASE_DOMAIN = (
35 "dashscope-intl.aliyuncs.com" if REGION == "intl" else "dashscope.aliyuncs.com"
36)
37QWEN_WS_URL = f"wss://{BASE_DOMAIN}/api-ws/v1/realtime"
38MODEL = "qwen3.5-omni-flash-realtime"
39
40
41class QwenRealtimeClient:
42 """Minimal Qwen-Omni Realtime WebSocket client."""
43
44 def __init__(
45 self,
46 url: str,
47 api_key: str,
48 model: str,
49 voice: str = "Jennifer",
50 instructions: str = "You are a helpful and respectful AI assistant.",
51 on_audio_delta: Optional[Callable[[bytes], None]] = None,
52 on_interruption: Optional[Callable[[], Any]] = None,
53 ):
54 self.url = f"{url}?model={model}"
55 self.api_key = api_key
56 self.voice = voice
57 self.instructions = instructions
58 self.on_audio_delta = on_audio_delta
59 self.on_interruption = on_interruption
60 self.ws = None
61 self._is_responding = False
62
63 async def connect(self):
64 headers = {"Authorization": f"Bearer {self.api_key}"}
65 self.ws = await websockets.connect(self.url, additional_headers=headers)
66 logger.info("Connected to Qwen-Omni WebSocket")
67 await self.send_event(
68 {
69 "type": "session.update",
70 "session": {
71 "modalities": ["text", "audio"],
72 "voice": self.voice,
73 "instructions": self.instructions,
74 "input_audio_format": "pcm16", # 16-bit 16 kHz mono
75 "output_audio_format": "pcm24", # 16-bit 24 kHz mono
76 "turn_detection": {
77 "type": "server_vad",
78 "threshold": 0.5,
79 "prefix_padding_ms": 300,
80 "silence_duration_ms": 500,
81 },
82 "input_audio_transcription": {"model": "gummy-realtime-v1"},
83 },
84 }
85 )
86
87 async def send_event(self, event: Dict[str, Any]):
88 if "event_id" not in event:
89 event["event_id"] = f"evt_{int(time.time() * 1000)}"
90 await self.ws.send(json.dumps(event))
91
92 async def stream_audio(self, audio_chunk: bytes):
93 await self.send_event(
94 {
95 "type": "input_audio_buffer.append",
96 "audio": base64.b64encode(audio_chunk).decode(),
97 }
98 )
99
100 async def cancel_response(self):
101 if self._is_responding:
102 logger.info("Sending response.cancel to Qwen")
103 await self.send_event({"type": "response.cancel"})
104 self._is_responding = False
105
106 async def receive_loop(self):
107 try:
108 async for message in self.ws:
109 event = json.loads(message)
110 event_type = event.get("type")
111
112 if event_type == "response.audio.delta":
113 audio_bytes = base64.b64decode(event["delta"])
114 if self.on_audio_delta:
115 self.on_audio_delta(audio_bytes)
116
117 elif event_type == "input_audio_buffer.speech_started":
118 logger.debug("Speech start - possible interruption")
119 if self.on_interruption:
120 await self.on_interruption()
121
122 elif event_type == "response.created":
123 self._is_responding = True
124
125 elif event_type == "response.done":
126 self._is_responding = False
127
128 elif event_type == "error":
129 logger.error("Qwen error: %s", event.get("error"))
130
131 elif event_type == "conversation.item.input_audio_transcription.completed":
132 logger.info("User: %s", event.get("transcript"))
133
134 elif event_type == "response.audio_transcript.done":
135 logger.info("AI: %s", event.get("transcript"))
136
137 except websockets.exceptions.ConnectionClosed:
138 logger.info("Qwen WebSocket closed")
139 except Exception:
140 logger.exception("Error in Qwen receive loop")
141
142 async def close(self):
143 if self.ws:
144 await self.ws.close()
145
146
147class QwenRealtimeIntegration:
148 def __init__(self, call: Call, qwen_client: QwenRealtimeClient):
149 self._call = call
150 self._qwen_client = qwen_client
151 self._stream_task: Optional[asyncio.Task] = None
152 self._receive_task: Optional[asyncio.Task] = None
153 self._terminated = False
154
155 async def _on_hangup(self, evt):
156 logger.info("Call terminated - cleaning up Qwen")
157 self._terminated = True
158 await self._qwen_client.close()
159 if self._stream_task:
160 self._stream_task.cancel()
161 if self._receive_task:
162 self._receive_task.cancel()
163
164 async def handle_interruption(self):
165 logger.info("Interruption - cancel Qwen response and clear send buffer")
166 await self._qwen_client.cancel_response()
167 await self._call.clear_send_audio_buffer()
168
169 async def run(self):
170 self._call.on_hangup(self._on_hangup)
171 await self._qwen_client.connect()
172 self._stream_task = asyncio.create_task(self.stream_to_qwen())
173 self._receive_task = asyncio.create_task(self._qwen_client.receive_loop())
174 await asyncio.gather(
175 self._stream_task, self._receive_task, return_exceptions=True
176 )
177
178 async def stream_to_qwen(self):
179 try:
180 async for audio_chunk in self._call.caller.audio_stream():
181 if self._terminated:
182 break
183 resampled = self.downsample_24to16(audio_chunk)
184 await self._qwen_client.stream_audio(resampled)
185 except CallClosedError:
186 logger.debug("Call closed; stopping stream to Qwen")
187 except Exception:
188 logger.exception("Error in stream to Qwen")
189
190 def downsample_24to16(self, audio_data: bytes) -> bytes:
191 if not audio_data:
192 return b""
193 samples = np.frombuffer(audio_data, dtype=np.int16)
194 resampled = soxr.resample(samples, 24000, 16000)
195 return resampled.astype(np.int16).tobytes()
196
197 def on_qwen_audio(self, audio_bytes: bytes):
198 if self._terminated:
199 return
200 asyncio.create_task(self._send_audio_to_call(audio_bytes))
201
202 async def _send_audio_to_call(self, audio_bytes: bytes):
203 try:
204 await self._call.send_audio(audio_bytes)
205 except BufferFullError:
206 logger.warning("Call audio buffer full, dropping chunk")
207 except CallClosedError:
208 logger.debug("Call closed; dropping Qwen audio")
209 except Exception:
210 logger.exception("Error sending audio to call")
211
212
213async def main():
214 config = SessionManagerConfig.create(
215 api_key=os.getenv("AGENTDUET_API_KEY"),
216 connector_uuid=os.getenv("AGENTDUET_CONNECTOR_UUID"),
217 call_audio=CallAudioConfig(
218 sample_rate=24000,
219 buffer_size=8 * 1024 * 1024,
220 ),
221 )
222
223 async with SessionManager(config) as sm:
224 logger.info("Connected. Waiting for calls...")
225
226 @sm.on_incoming_call
227 async def on_call(noti: IncomingCallNotification):
228 session = await sm.open_session(new_session_id(), noti.subscriber)
229 call = await session.process_call(noti)
230 logger.info("Incoming call: %s", call.id)
231 try:
232 integration: Optional[QwenRealtimeIntegration] = None
233
234 async def on_interruption_handler():
235 if integration:
236 await integration.handle_interruption()
237
238 qwen_client = QwenRealtimeClient(
239 url=QWEN_WS_URL,
240 api_key=os.getenv("DASHSCOPE_API_KEY"),
241 model=MODEL,
242 on_audio_delta=lambda data: (
243 integration.on_qwen_audio(data) if integration else None
244 ),
245 on_interruption=on_interruption_handler,
246 )
247 integration = QwenRealtimeIntegration(call, qwen_client)
248
249 result = await call.answer()
250 if not result:
251 logger.error(
252 "Answer failed %s: %s (%s)",
253 call.id,
254 result.error_message,
255 result.error_code,
256 )
257 return
258 await integration.run()
259 except Exception:
260 logger.exception("Error in Qwen bridge")
261 await call.close()
262
263 await sm.run_forever()
264
265
266if __name__ == "__main__":
267 asyncio.run(main())

Step 4: Run the bridge

$python qwen_omni_bridge.py

Keep the process running. Confirm logs show the SessionManager connected and waiting for calls.

Step 5: Call your number

Dial the connector phone number. Verify two-way audio, then interrupt mid-reply to confirm barge-in clears playback. Hang up and confirm the process remains ready for the next call.

How the pieces fit

SessionManager receives the inbound call → you open_session + process_callanswer() → two tasks move PCM between AgentDuet and the model. Hangup closes the model session and cancels those tasks. Telephony stays AgentDuet’s job; the model never sees SIP.

Notes

  • Rate mismatch: AgentDuet and Qwen output are 24 kHz; Qwen input is 16 kHz. Always downsample uplink with soxr (or equivalent). Do not change AgentDuet to 16 kHz unless you also resample downlink.
  • Interrupt: On speech start, cancel the Qwen response and await call.clear_send_audio_buffer().
  • Buffer size: Qwen can burst audio; a larger buffer_size (e.g. 8 MB) reduces BufferFullError drops.
  • Region: Use DASHSCOPE_REGION=cn for the China endpoint; default is international.
  • Callbacks: Wire on_audio_delta / on_interruption after constructing the integration so closures see a live instance.