openai/openai-python

Public

mirrored from https://github.com/openai/openai-pythonAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.93.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

examples/realtime/push_to_talk_app.py

283lines · modeblame

488ec04bRobert Craigie1 years ago1#!/usr/bin/env uv run
2####################################################################
3# Sample TUI app with a push to talk interface to the Realtime API #
4# If you have `uv` installed and the `OPENAI_API_KEY` #
5# environment variable set, you can run this example with just #
6# #
7# `./examples/realtime/push_to_talk_app.py` #
dc550bf4Jeff Verkoeyen1 years ago8# #
9# On Mac, you'll also need `brew install portaudio ffmpeg` #
488ec04bRobert Craigie1 years ago10####################################################################
11#
12# /// script
13# requires-python = ">=3.9"
14# dependencies = [
15# "textual",
16# "numpy",
17# "pyaudio",
18# "pydub",
19# "sounddevice",
20# "openai[realtime]",
21# ]
22#
23# [tool.uv.sources]
24# openai = { path = "../../", editable = true }
25# ///
26from __future__ import annotations
27
28import base64
29import asyncio
30from typing import Any, cast
31from typing_extensions import override
32
33from textual import events
34from audio_util import CHANNELS, SAMPLE_RATE, AudioPlayerAsync
35from textual.app import App, ComposeResult
36from textual.widgets import Button, Static, RichLog
37from textual.reactive import reactive
38from textual.containers import Container
39
40from openai import AsyncOpenAI
41from openai.types.beta.realtime.session import Session
42from openai.resources.beta.realtime.realtime import AsyncRealtimeConnection
43
44
45class SessionDisplay(Static):
46"""A widget that shows the current session ID."""
47
48session_id = reactive("")
49
50@override
51def render(self) -> str:
52return f"Session ID: {self.session_id}" if self.session_id else "Connecting..."
53
54
55class AudioStatusIndicator(Static):
56"""A widget that shows the current audio recording status."""
57
58is_recording = reactive(False)
59
60@override
61def render(self) -> str:
62status = (
63"🔴 Recording... (Press K to stop)" if self.is_recording else "⚪ Press K to start recording (Q to quit)"
64)
65return status
66
67
68class RealtimeApp(App[None]):
69CSS = """
70Screen {
71background: #1a1b26; /* Dark blue-grey background */
72}
73
74Container {
75border: double rgb(91, 164, 91);
76}
77
78Horizontal {
79width: 100%;
80}
81
82#input-container {
83height: 5; /* Explicit height for input container */
84margin: 1 1;
85padding: 1 2;
86}
87
88Input {
89width: 80%;
90height: 3; /* Explicit height for input */
91}
92
93Button {
94width: 20%;
95height: 3; /* Explicit height for button */
96}
97
98#bottom-pane {
99width: 100%;
100height: 82%; /* Reduced to make room for session display */
101border: round rgb(205, 133, 63);
102content-align: center middle;
103}
104
105#status-indicator {
106height: 3;
107content-align: center middle;
108background: #2a2b36;
109border: solid rgb(91, 164, 91);
110margin: 1 1;
111}
112
113#session-display {
114height: 3;
115content-align: center middle;
116background: #2a2b36;
117border: solid rgb(91, 164, 91);
118margin: 1 1;
119}
120
121Static {
122color: white;
123}
124"""
125
126client: AsyncOpenAI
127should_send_audio: asyncio.Event
128audio_player: AudioPlayerAsync
129last_audio_item_id: str | None
130connection: AsyncRealtimeConnection | None
131session: Session | None
132connected: asyncio.Event
133
134def __init__(self) -> None:
135super().__init__()
136self.connection = None
137self.session = None
138self.client = AsyncOpenAI()
139self.audio_player = AudioPlayerAsync()
140self.last_audio_item_id = None
141self.should_send_audio = asyncio.Event()
142self.connected = asyncio.Event()
143
144@override
145def compose(self) -> ComposeResult:
146"""Create child widgets for the app."""
147with Container():
148yield SessionDisplay(id="session-display")
149yield AudioStatusIndicator(id="status-indicator")
150yield RichLog(id="bottom-pane", wrap=True, highlight=True, markup=True)
151
152async def on_mount(self) -> None:
153self.run_worker(self.handle_realtime_connection())
154self.run_worker(self.send_mic_audio())
155
156async def handle_realtime_connection(self) -> None:
fd763424Robert Craigie1 years ago157async with self.client.beta.realtime.connect(model="gpt-4o-realtime-preview") as conn:
488ec04bRobert Craigie1 years ago158self.connection = conn
159self.connected.set()
160
161# note: this is the default and can be omitted
162# if you want to manually handle VAD yourself, then set `'turn_detection': None`
163await conn.session.update(session={"turn_detection": {"type": "server_vad"}})
164
165acc_items: dict[str, Any] = {}
166
167async for event in conn:
168if event.type == "session.created":
169self.session = event.session
170session_display = self.query_one(SessionDisplay)
171assert event.session.id is not None
172session_display.session_id = event.session.id
173continue
174
175if event.type == "session.updated":
176self.session = event.session
177continue
178
179if event.type == "response.audio.delta":
180if event.item_id != self.last_audio_item_id:
181self.audio_player.reset_frame_count()
182self.last_audio_item_id = event.item_id
183
184bytes_data = base64.b64decode(event.delta)
185self.audio_player.add_data(bytes_data)
186continue
187
188if event.type == "response.audio_transcript.delta":
189try:
190text = acc_items[event.item_id]
191except KeyError:
192acc_items[event.item_id] = event.delta
193else:
194acc_items[event.item_id] = text + event.delta
195
196# Clear and update the entire content because RichLog otherwise treats each delta as a new line
197bottom_pane = self.query_one("#bottom-pane", RichLog)
198bottom_pane.clear()
199bottom_pane.write(acc_items[event.item_id])
200continue
201
202async def _get_connection(self) -> AsyncRealtimeConnection:
203await self.connected.wait()
204assert self.connection is not None
205return self.connection
206
207async def send_mic_audio(self) -> None:
208import sounddevice as sd # type: ignore
209
210sent_audio = False
211
212device_info = sd.query_devices()
213print(device_info)
214
215read_size = int(SAMPLE_RATE * 0.02)
216
217stream = sd.InputStream(
218channels=CHANNELS,
219samplerate=SAMPLE_RATE,
220dtype="int16",
221)
222stream.start()
223
224status_indicator = self.query_one(AudioStatusIndicator)
225
226try:
227while True:
228if stream.read_available < read_size:
229await asyncio.sleep(0)
230continue
231
232await self.should_send_audio.wait()
233status_indicator.is_recording = True
234
235data, _ = stream.read(read_size)
236
237connection = await self._get_connection()
238if not sent_audio:
239asyncio.create_task(connection.send({"type": "response.cancel"}))
240sent_audio = True
241
242await connection.input_audio_buffer.append(audio=base64.b64encode(cast(Any, data)).decode("utf-8"))
243
244await asyncio.sleep(0)
245except KeyboardInterrupt:
246pass
247finally:
248stream.stop()
249stream.close()
250
251async def on_key(self, event: events.Key) -> None:
252"""Handle key press events."""
253if event.key == "enter":
254self.query_one(Button).press()
255return
256
257if event.key == "q":
258self.exit()
259return
260
261if event.key == "k":
262status_indicator = self.query_one(AudioStatusIndicator)
263if status_indicator.is_recording:
264self.should_send_audio.clear()
265status_indicator.is_recording = False
266
267if self.session and self.session.turn_detection is None:
268# The default in the API is that the model will automatically detect when the user has
269# stopped talking and then start responding itself.
270#
271# However if we're in manual `turn_detection` mode then we need to
272# manually tell the model to commit the audio buffer and start responding.
273conn = await self._get_connection()
274await conn.input_audio_buffer.commit()
275await conn.response.create()
276else:
277self.should_send_audio.set()
278status_indicator.is_recording = True
279
280
281if __name__ == "__main__":
282app = RealtimeApp()
283app.run()