Keep realtime conversation items ordered across turns

This commit is contained in:
Mikei386
2026-09-16 14:42:30 +02:00
parent b9dcadedd5
commit 97c8072b3d
3 changed files with 82 additions and 13 deletions
+32 -7
View File
@@ -31,8 +31,8 @@ class Microphone(MediaStreamTrack):
async def recv(self):
await asyncio.sleep(max(0, self.start + self.count * .02 - time.monotonic()))
# 0.6 s speech followed by silence to trigger server VAD.
if 4 <= self.count < 44:
# Two utterances separated by enough time for the first TTS reply.
if 4 <= self.count < 44 or 750 <= self.count < 790:
index = np.arange(FRAME_SAMPLES) + self.count * FRAME_SAMPLES
samples = (8000 * np.sin(2 * np.pi * 440 * index / SAMPLE_RATE)).astype("<i2")
else:
@@ -132,16 +132,20 @@ class VoiceSmokeTest(unittest.IsolatedAsyncioTestCase):
voice_runner, voice_url = await serve(create_app())
pc = RTCPeerConnection()
events = []
event_times = []
done = asyncio.Event()
audio_received = asyncio.Event()
completed_responses = 0
function_calls = 0
channel = pc.createDataChannel("oai-events")
pc.addTrack(Microphone())
microphone = Microphone()
pc.addTrack(microphone)
@pc.on("track")
def on_track(track):
if track.kind == "audio":
async def consume():
for _ in range(150):
for _ in range(600):
frame = await track.recv()
if np.max(np.abs(frame.to_ndarray())) > 100:
audio_received.set()
@@ -154,11 +158,14 @@ class VoiceSmokeTest(unittest.IsolatedAsyncioTestCase):
@channel.on("message")
def on_message(raw):
nonlocal completed_responses, function_calls
event = json.loads(raw)
events.append(event)
event_times.append((round(time.monotonic() - microphone.start, 1), event.get("type")))
if event.get("type") == "response.done":
output = event.get("response", {}).get("output", [])
if output and output[0].get("type") == "function_call":
function_calls += 1
call = output[0]
channel.send(json.dumps({"type": "conversation.item.create", "item": {
"type": "function_call_output", "call_id": call["call_id"],
@@ -166,7 +173,9 @@ class VoiceSmokeTest(unittest.IsolatedAsyncioTestCase):
}}))
channel.send(json.dumps({"type": "response.create"}))
elif output and output[0].get("type") == "message":
done.set()
completed_responses += 1
if completed_responses == 2:
done.set()
try:
await pc.setLocalDescription(await pc.createOffer())
@@ -186,13 +195,29 @@ class VoiceSmokeTest(unittest.IsolatedAsyncioTestCase):
answer = await response.text()
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer, type="answer"))
try:
await asyncio.wait_for(done.wait(), timeout=20)
await asyncio.wait_for(done.wait(), timeout=25)
except asyncio.TimeoutError:
self.fail(f"No completed voice response; events: {[e.get('type') for e in events]}")
self.fail(f"No completed voice response; state={pc.connectionState}, "
f"mic_frames={microphone.count}, events: {event_times}")
await asyncio.wait_for(audio_received.wait(), timeout=5)
self.assertEqual(function_calls, 2)
self.assertTrue(any(event.get("type") ==
"conversation.item.input_audio_transcription.completed"
for event in events))
user_items = [event for event in events
if event.get("type") == "input_audio_buffer.committed"]
assistant_items = [event for event in events
if event.get("type") == "conversation.item.created"
and event.get("item", {}).get("role") == "assistant"]
assistant_transcripts = [event for event in events
if event.get("type") == "response.output_audio_transcript.done"]
self.assertEqual(len(user_items), 2)
self.assertEqual(len(assistant_items), 2)
self.assertEqual(len(assistant_transcripts), 2)
self.assertEqual(assistant_items[0]["previous_item_id"], user_items[0]["item_id"])
self.assertEqual(assistant_transcripts[0]["item_id"], assistant_items[0]["item"]["id"])
self.assertEqual(user_items[1]["previous_item_id"], assistant_items[0]["item"]["id"])
self.assertEqual(assistant_items[1]["previous_item_id"], user_items[1]["item_id"])
committed = next((index, event) for index, event in enumerate(events)
if event.get("type") == "input_audio_buffer.committed")
completed = next((index, event) for index, event in enumerate(events)