chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
import asyncio
import queue
import sys
import threading
from typing import Any
import numpy as np
import sounddevice as sd
from agents import function_tool
from agents.realtime import (
RealtimeAgent,
RealtimePlaybackTracker,
RealtimeRunner,
RealtimeSession,
RealtimeSessionEvent,
)
from agents.realtime.model import RealtimeModelConfig
# Audio configuration
CHUNK_LENGTH_S = 0.04 # 40ms aligns with realtime defaults
SAMPLE_RATE = 24000
FORMAT = np.int16
CHANNELS = 1
ENERGY_THRESHOLD = 0.015 # RMS threshold for bargein while assistant is speaking
PREBUFFER_CHUNKS = 3 # initial jitter buffer (~120ms with 40ms chunks)
FADE_OUT_MS = 12 # short fade to avoid clicks when interrupting
PLAYBACK_ECHO_MARGIN = 0.002 # extra energy above playback echo required to count as speech
# Set up logging for OpenAI agents SDK
# logging.basicConfig(
# level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# )
# logger.logger.setLevel(logging.ERROR)
@function_tool
def get_weather(city: str) -> str:
"""Get the weather in a city."""
return f"The weather in {city} is sunny."
agent = RealtimeAgent(
name="Assistant",
instructions="You always greet the user with 'Top of the morning to you'.",
tools=[get_weather],
)
def _truncate_str(s: str, max_length: int) -> str:
if len(s) > max_length:
return s[:max_length] + "..."
return s
class NoUIDemo:
def __init__(self) -> None:
self.session: RealtimeSession | None = None
self.audio_stream: sd.InputStream | None = None
self.audio_player: sd.OutputStream | None = None
self.recording = False
# Playback tracker lets the model know our real playback progress
self.playback_tracker = RealtimePlaybackTracker()
# Audio output state for callback system
# Store tuples: (samples_np, item_id, content_index)
# Use an unbounded queue to avoid drops that sound like skipped words.
self.output_queue: queue.Queue[Any] = queue.Queue(maxsize=0)
self.interrupt_event = threading.Event()
self.current_audio_chunk: tuple[np.ndarray[Any, np.dtype[Any]], str, int] | None = None
self.chunk_position = 0
self.bytes_per_sample = np.dtype(FORMAT).itemsize
# Jitter buffer and fade-out state
self.prebuffering = True
self.prebuffer_target_chunks = PREBUFFER_CHUNKS
self.fading = False
self.fade_total_samples = 0
self.fade_done_samples = 0
self.fade_samples = int(SAMPLE_RATE * (FADE_OUT_MS / 1000.0))
self.playback_rms = 0.0 # smoothed playback energy to filter out echo
def _output_callback(self, outdata, frames: int, time, status) -> None:
"""Callback for audio output - handles continuous audio stream from server."""
if status:
print(f"Output callback status: {status}")
# Handle interruption with a short fade-out to prevent clicks.
if self.interrupt_event.is_set():
outdata.fill(0)
if self.current_audio_chunk is None:
# Nothing to fade, just flush everything and reset.
while not self.output_queue.empty():
try:
self.output_queue.get_nowait()
except queue.Empty:
break
self.prebuffering = True
self.interrupt_event.clear()
return
# Prepare fade parameters
if not self.fading:
self.fading = True
self.fade_done_samples = 0
# Remaining samples in the current chunk
remaining_in_chunk = len(self.current_audio_chunk[0]) - self.chunk_position
self.fade_total_samples = min(self.fade_samples, max(0, remaining_in_chunk))
samples, item_id, content_index = self.current_audio_chunk
samples_filled = 0
while (
samples_filled < len(outdata) and self.fade_done_samples < self.fade_total_samples
):
remaining_output = len(outdata) - samples_filled
remaining_fade = self.fade_total_samples - self.fade_done_samples
n = min(remaining_output, remaining_fade)
src = samples[self.chunk_position : self.chunk_position + n].astype(np.float32)
# Linear ramp from current level down to 0 across remaining fade samples
idx = np.arange(
self.fade_done_samples, self.fade_done_samples + n, dtype=np.float32
)
gain = 1.0 - (idx / float(self.fade_total_samples))
ramped = np.clip(src * gain, -32768.0, 32767.0).astype(np.int16)
outdata[samples_filled : samples_filled + n, 0] = ramped
self._update_playback_rms(ramped)
# Optionally report played bytes (ramped) to playback tracker
try:
self.playback_tracker.on_play_bytes(
item_id=item_id, item_content_index=content_index, bytes=ramped.tobytes()
)
except Exception:
pass
samples_filled += n
self.chunk_position += n
self.fade_done_samples += n
# If fade completed, flush the remaining audio and reset state
if self.fade_done_samples >= self.fade_total_samples:
self.current_audio_chunk = None
self.chunk_position = 0
while not self.output_queue.empty():
try:
self.output_queue.get_nowait()
except queue.Empty:
break
self.fading = False
self.prebuffering = True
self.interrupt_event.clear()
return
# Fill output buffer from queue and current chunk
outdata.fill(0) # Start with silence
samples_filled = 0
while samples_filled < len(outdata):
# If we don't have a current chunk, try to get one from queue
if self.current_audio_chunk is None:
try:
# Respect a small jitter buffer before starting playback
if (
self.prebuffering
and self.output_queue.qsize() < self.prebuffer_target_chunks
):
break
self.prebuffering = False
self.current_audio_chunk = self.output_queue.get_nowait()
self.chunk_position = 0
except queue.Empty:
# No more audio data available - this causes choppiness
# Uncomment next line to debug underruns:
# print(f"Audio underrun: {samples_filled}/{len(outdata)} samples filled")
break
# Copy data from current chunk to output buffer
remaining_output = len(outdata) - samples_filled
samples, item_id, content_index = self.current_audio_chunk
remaining_chunk = len(samples) - self.chunk_position
samples_to_copy = min(remaining_output, remaining_chunk)
if samples_to_copy > 0:
chunk_data = samples[self.chunk_position : self.chunk_position + samples_to_copy]
# More efficient: direct assignment for mono audio instead of reshape
outdata[samples_filled : samples_filled + samples_to_copy, 0] = chunk_data
self._update_playback_rms(chunk_data)
samples_filled += samples_to_copy
self.chunk_position += samples_to_copy
# Inform playback tracker about played bytes
try:
self.playback_tracker.on_play_bytes(
item_id=item_id,
item_content_index=content_index,
bytes=chunk_data.tobytes(),
)
except Exception:
pass
# If we've used up the entire chunk, reset for next iteration
if self.chunk_position >= len(samples):
self.current_audio_chunk = None
self.chunk_position = 0
async def run(self) -> None:
print("Connecting, may take a few seconds...")
# Initialize audio player with callback
chunk_size = int(SAMPLE_RATE * CHUNK_LENGTH_S)
self.audio_player = sd.OutputStream(
channels=CHANNELS,
samplerate=SAMPLE_RATE,
dtype=FORMAT,
callback=self._output_callback,
blocksize=chunk_size, # Match our chunk timing for better alignment
)
self.audio_player.start()
try:
runner = RealtimeRunner(agent)
# Attach playback tracker and enable serverside interruptions + auto response.
model_config: RealtimeModelConfig = {
"playback_tracker": self.playback_tracker,
"initial_model_settings": {
"model_name": "gpt-realtime-2.1",
"turn_detection": {
"type": "semantic_vad",
"interrupt_response": True,
"create_response": True,
},
},
}
async with await runner.run(model_config=model_config) as session:
self.session = session
print("Connected. Starting audio recording...")
# Start audio recording
await self.start_audio_recording()
print("Audio recording started. You can start speaking - expect lots of logs!")
# Process session events
async for event in session:
await self._on_event(event)
finally:
# Clean up audio player
if self.audio_player and self.audio_player.active:
self.audio_player.stop()
if self.audio_player:
self.audio_player.close()
print("Session ended")
async def start_audio_recording(self) -> None:
"""Start recording audio from the microphone."""
# Set up audio input stream
self.audio_stream = sd.InputStream(
channels=CHANNELS,
samplerate=SAMPLE_RATE,
dtype=FORMAT,
)
self.audio_stream.start()
self.recording = True
# Start audio capture task
asyncio.create_task(self.capture_audio())
async def capture_audio(self) -> None:
"""Capture audio from the microphone and send to the session."""
if not self.audio_stream or not self.session:
return
# Buffer size in samples
read_size = int(SAMPLE_RATE * CHUNK_LENGTH_S)
try:
while self.recording:
# Check if there's enough data to read
if self.audio_stream.read_available < read_size:
await asyncio.sleep(0.01)
continue
# Read audio data
data, _ = self.audio_stream.read(read_size)
# Convert numpy array to bytes
audio_bytes = data.tobytes()
# Smart bargein: if assistant audio is playing, send only if mic has speech.
assistant_playing = (
self.current_audio_chunk is not None or not self.output_queue.empty()
)
if assistant_playing:
# Compute RMS energy to detect speech while assistant is talking
samples = data.reshape(-1)
mic_rms = self._compute_rms(samples)
# Require the mic to be louder than the echo of the assistant playback.
playback_gate = max(
ENERGY_THRESHOLD,
self.playback_rms * 0.6 + PLAYBACK_ECHO_MARGIN,
)
if mic_rms >= playback_gate:
# Locally flush queued assistant audio for snappier interruption.
self.interrupt_event.set()
await self.session.send_audio(audio_bytes)
else:
await self.session.send_audio(audio_bytes)
# Yield control back to event loop
await asyncio.sleep(0)
except Exception as e:
print(f"Audio capture error: {e}")
finally:
if self.audio_stream and self.audio_stream.active:
self.audio_stream.stop()
if self.audio_stream:
self.audio_stream.close()
async def _on_event(self, event: RealtimeSessionEvent) -> None:
"""Handle session events."""
try:
if event.type == "agent_start":
print(f"Agent started: {event.agent.name}")
elif event.type == "agent_end":
print(f"Agent ended: {event.agent.name}")
elif event.type == "handoff":
print(f"Handoff from {event.from_agent.name} to {event.to_agent.name}")
elif event.type == "tool_start":
print(f"Tool started: {event.tool.name}")
elif event.type == "tool_end":
print(f"Tool ended: {event.tool.name}; output: {event.output}")
elif event.type == "audio_end":
print("Audio ended")
elif event.type == "audio":
# Enqueue audio for callback-based playback with metadata
np_audio = np.frombuffer(event.audio.data, dtype=np.int16)
# Non-blocking put; queue is unbounded, so drops wont occur.
self.output_queue.put_nowait((np_audio, event.item_id, event.content_index))
elif event.type == "audio_interrupted":
print("Audio interrupted")
# Begin graceful fade + flush in the audio callback and rebuild jitter buffer.
self.prebuffering = True
self.interrupt_event.set()
elif event.type == "error":
print(f"Error: {event.error}")
elif event.type == "history_updated":
pass # Skip these frequent events
elif event.type == "history_added":
pass # Skip these frequent events
elif event.type == "raw_model_event":
print(f"Raw model event: {_truncate_str(str(event.data), 200)}")
else:
print(f"Unknown event type: {event.type}")
except Exception as e:
print(f"Error processing event: {_truncate_str(str(e), 200)}")
def _compute_rms(self, samples: np.ndarray[Any, np.dtype[Any]]) -> float:
"""Compute RMS energy for int16 samples normalized to [-1, 1]."""
if samples.size == 0:
return 0.0
x = samples.astype(np.float32) / 32768.0
return float(np.sqrt(np.mean(x * x)))
def _update_playback_rms(self, samples: np.ndarray[Any, np.dtype[Any]]) -> None:
"""Keep a smoothed estimate of playback energy to filter out echo feedback."""
sample_rms = self._compute_rms(samples)
self.playback_rms = 0.9 * self.playback_rms + 0.1 * sample_rms
if __name__ == "__main__":
demo = NoUIDemo()
try:
asyncio.run(demo.run())
except KeyboardInterrupt:
print("\nExiting...")
sys.exit(0)