Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fresh-live-voice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'orb-ui': minor
---

Add `createOpenAILiveAdapter` for GPT-Live WebRTC sessions, full-duplex input/output metering,
application event forwarding, and graceful session finalization. Make GPT-Live the primary OpenAI
documentation and homepage example while preserving the existing Realtime adapter.

Live uses a server-side `/v1/live/sessions` SDP exchange through `createSession`; it is not a model
substitution for Realtime's `getClientSecret` flow. See the new Live guide for provider migration,
delegation, playback, and final usage handling.
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ held in page memory only and exchanged for provider session credentials; they ar
local storage or accepted through `VITE_*` variables. Self-hosted Pipecat SmallWebRTC only needs a
public `/api/offer` URL.

GPT-Live is also available in the playground. `pnpm dev:demo` serves its session endpoint locally.
Provide `OPENAI_API_KEY` to the dev server process to use a saved server-side credential, or paste
a test key in the GPT-Live panel for the current page session. Never use a `VITE_*` variable for
the key. Server credentials are accepted only on same-origin loopback requests. The deployed
endpoint requires each caller's own key. Select **OpenAI GPT-Live**, then click the orb to start
and click it again to end the billed session. Model settings persist; pasted Live keys do not.

Non-secret playground values are saved in browser local storage for that origin, and the Clear
button removes the selected provider's values. To prefill the fields during local development,
copy `demo/.env.example` to `demo/.env.local`, fill in non-secret `VITE_*` defaults, and restart the
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Voice agent UI that feels alive.**

Expressive, accessible React components for realtime voice agents. Connect Vapi, ElevenLabs, LiveKit, Pipecat, OpenAI Realtime, Gemini Live, or your own voice stack through one consistent UI layer.
Expressive, accessible React components for realtime voice agents. Connect Vapi, ElevenLabs, LiveKit, Pipecat, OpenAI Live, OpenAI Realtime, Gemini Live, or your own voice stack through one consistent UI layer.

<p align="center">
<a href="https://orb-ui.com">
Expand Down Expand Up @@ -55,7 +55,7 @@ npm install orb-ui livekit-client
# Pipecat (choose the transport used by your agent)
npm install orb-ui @pipecat-ai/client-js @pipecat-ai/small-webrtc-transport

# OpenAI Realtime uses browser WebRTC and has no additional client SDK
# OpenAI Live and Realtime use browser WebRTC and need no additional client SDK
npm install orb-ui

# Gemini Live
Expand All @@ -80,6 +80,7 @@ The only difference is how the adapter obtains a provider session:
| [ElevenLabs guide](https://orb-ui.com/docs/adapters/elevenlabs) | Pass `Conversation` plus an `agentId`, signed URL, or conversation token |
| [LiveKit guide](https://orb-ui.com/docs/adapters/livekit) | Provide a token endpoint and optional agent name |
| [Pipecat guide](https://orb-ui.com/docs/adapters/pipecat) | Pass a configured `PipecatClient` plus its connect callback |
| [OpenAI Live guide](https://orb-ui.com/docs/adapters/openai-live) | Exchange an SDP offer through your server with `createSession` |
| [OpenAI Realtime guide](https://orb-ui.com/docs/adapters/openai-realtime) | Return a fresh short-lived client secret from `getClientSecret` |
| [Gemini Live guide](https://orb-ui.com/docs/adapters/gemini-live) | Open the official Google Live session in `connect` |

Expand Down Expand Up @@ -166,6 +167,29 @@ The Pipecat adapter consumes the standard RTVI event surface and meters the clie
a browser fallback, so it works with Pipecat Cloud, Daily, SmallWebRTC, and transports that emit
sparse audio-level events. See the [Pipecat guide](https://orb-ui.com/docs/adapters/pipecat).

### With OpenAI GPT-Live

```tsx
import { createOpenAILiveAdapter } from 'orb-ui/adapters'

const adapter = createOpenAILiveAdapter({
createSession: async (sdp, signal) => {
const response = await fetch('/api/openai-live-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sdp }),
signal,
})
if (!response.ok) throw new Error('Could not create a Live session')
return response.json()
},
})
```

Your server creates a `gpt-live-1` session through `/v1/live/sessions` and returns the session ID
and SDP answer. Live handles listening and speaking concurrently; configure backend delegation
on your server. See the [OpenAI Live guide](https://orb-ui.com/docs/adapters/openai-live).

### With OpenAI Realtime

```jsx
Expand Down Expand Up @@ -348,6 +372,7 @@ the orb with the typed style variable:
| [ElevenLabs](https://elevenlabs.io/conversational-ai) | `createElevenLabsAdapter` from `orb-ui/adapters` |
| [LiveKit](https://livekit.io) | `createLiveKitAdapter` from `orb-ui/adapters` |
| [Pipecat](https://pipecat.ai) | `createPipecatAdapter` from `orb-ui/adapters` |
| [OpenAI GPT-Live](https://developers.openai.com/api/docs/guides/live) | `createOpenAILiveAdapter` from `orb-ui/adapters` |
| [OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime) | `createOpenAIRealtimeAdapter` from `orb-ui/adapters` |
| [Gemini Live](https://ai.google.dev/gemini-api/docs/live-api) | `createGeminiLiveAdapter` from `orb-ui/adapters` |
| Custom | Use controlled mode with a directional `signal` or build an adapter |
Expand Down
13 changes: 13 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ Cloud/Daily, self-hosted SmallWebRTC, and custom client transports. It normalize
audio levels while leaving agent deployment and connection credentials in the application. Direct
browser-track metering fills gaps when a transport does not emit frequent RTVI audio-level events.

### OpenAI GPT-Live adapter — implemented

A separate Live adapter owns browser WebRTC, concurrent input/output metering, event forwarding,
and graceful session finalization. GPT-Live is the primary OpenAI documentation path. Session
creation and delegation configuration stay on the application's server; Realtime remains supported.
Protocol and lifecycle tests cover startup, playback, cancellation, failure, and shutdown. Real
GPT-Live WebRTC sessions validate audio, interruption, delegated responses, same-adapter restart,
and final usage on graceful shutdown using synthetic spoken input. A Chrome playground session
also verifies physical microphone input and assistant playback; independent Live voice calibration
remains outstanding.
The provider playground includes GPT-Live with model settings, directional calibration, and a
local session endpoint that can use a server-side test credential.

### OpenAI Realtime adapter — complete

The OpenAI Realtime adapter owns browser WebRTC, audio playback, input/output metering, and current
Expand Down
64 changes: 64 additions & 0 deletions demo/api/openai-live-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
function json(data: unknown, status = 200) {
return Response.json(data, { status, headers: { 'Cache-Control': 'no-store' } })
}

/** A local dev server may supply its key; deployed playgrounds require the caller's own key. */
export async function createLiveSession(request: Request, localApiKey?: string) {
if (request.method !== 'POST') return json({ error: 'Method not allowed.' }, 405)
let body: Record<string, unknown>
try {
body = await request.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Invalid body')
} catch {
return json({ error: 'Expected a JSON object.' }, 400)
}
const value = (key: string) => (typeof body[key] === 'string' ? body[key].trim() : '')
const apiKey = value('apiKey') || localApiKey
if (!apiKey) return json({ error: 'An OpenAI API key is required.' }, 400)
// SDP is line-oriented; preserve the browser's trailing CRLF.
const sdp = typeof body.sdp === 'string' ? body.sdp : ''
if (!sdp.trim() || sdp.length > 64000)
return json({ error: 'A valid SDP offer is required.' }, 400)

try {
const response = await fetch('https://api.openai.com/v1/live/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]),
body: JSON.stringify({
session: {
model: value('model') || 'gpt-live-1',
store: false,
instructions:
value('instructions') ||
'Be a friendly voice assistant. Honor interruptions and delegate factual questions to the backend.',
delegation: {
type: 'responses',
responses: {
model: value('backendModel') || 'gpt-5.6-terra',
instructions:
'Answer accurately and concisely for a spoken conversation. Follow the latest caller request.',
},
},
},
transport: { type: 'webrtc', sdp },
}),
})
const payload = await response.json()
if (!response.ok)
return json(
{
error: `OpenAI Live session creation failed (${response.status}). Check your key and model access.`,
},
response.status,
)
if (!payload.session?.id || payload.transport?.type !== 'webrtc' || !payload.transport.sdp) {
return json({ error: 'OpenAI returned an incomplete Live session.' }, 502)
}
return json({ session: { id: payload.session.id }, transport: payload.transport })
} catch {
return json({ error: 'OpenAI Live session request failed or timed out.' }, 502)
}
}

export default { fetch: (request: Request) => createLiveSession(request) }
58 changes: 58 additions & 0 deletions demo/openai-live-dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Plugin } from 'vite'
import { env } from 'node:process'
import { createLiveSession } from './api/openai-live-session'

/** Keep the optional server credential confined to same-origin loopback requests. */
export function openAILiveDevPlugin(): Plugin {
return {
name: 'orb-ui-openai-live-local',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const path = req.url?.split('?')[0]
if (path !== '/api/openai-live-session' && path !== '/api/openai-live-status') return next()
const reply = (status: number, data: unknown) => {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
const origin = `http://${req.headers.host}`
let hostname: string
try {
hostname = new URL(origin).hostname
} catch {
return reply(403, { error: 'Local access only.' })
}
if (!['localhost', '127.0.0.1', '[::1]'].includes(hostname))
return reply(403, { error: 'Local access only.' })
if (path === '/api/openai-live-status' && req.method === 'GET') {
return reply(200, { configured: Boolean(env.OPENAI_API_KEY) })
}
if (req.method !== 'POST' || req.headers.origin !== origin)
return reply(403, { error: 'Same-origin POST required.' })
const controller = new AbortController()
res.on('close', () => {
if (!res.writableEnded) controller.abort()
})
try {
let body = ''
for await (const chunk of req) {
body += chunk
if (body.length > 100000) return reply(413, { error: 'Request too large.' })
}
const result = await createLiveSession(
new Request(`${origin}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: controller.signal,
}),
env.OPENAI_API_KEY,
)
res.writeHead(result.status, Object.fromEntries(result.headers))
res.end(await result.text())
} catch {
reply(502, { error: 'Local Live session request failed.' })
}
})
},
}
}
41 changes: 25 additions & 16 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,18 @@ export function VoiceOrb() {
}`

const OPENAI_CODE = `import { Orb } from "orb-ui"
import { createOpenAIRealtimeAdapter } from "orb-ui/adapters"

const adapter = createOpenAIRealtimeAdapter({
getClientSecret: async () => {
const response = await fetch("/api/openai-realtime-token", { method: "POST" })
return (await response.json()).value
import { createOpenAILiveAdapter } from "orb-ui/adapters"

const adapter = createOpenAILiveAdapter({
createSession: async (sdp, signal) => {
const response = await fetch("/api/openai-live-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp }),
signal
})
if (!response.ok) throw new Error("Could not create a Live session")
return response.json()
}
})

Expand Down Expand Up @@ -226,7 +232,7 @@ const NAV_LINKS = [
] as const

const PROOF_POINTS = [
{ value: '6+', label: 'Provider paths', detail: 'Plus controlled mode' },
{ value: '7', label: 'Provider paths', detail: 'Plus controlled mode' },
{ value: '2-way', label: 'Audio response', detail: 'Input and output levels' },
{ value: 'A11y', label: 'Accessible controls', detail: 'Keyboard-ready semantics' },
{ value: 'MIT', label: 'Open source', detail: 'Use it anywhere' },
Expand Down Expand Up @@ -265,8 +271,8 @@ const CODE_OPTIONS: ReadonlyArray<{
{
id: 'openai',
label: 'OpenAI',
detail: 'Realtime',
description: 'Use native browser WebRTC with short-lived client secrets.',
detail: 'GPT-Live',
description: 'Connect GPT-Live through a server-created WebRTC session.',
},
{
id: 'gemini',
Expand All @@ -293,7 +299,7 @@ const SEO_SECTIONS = [
{
id: 'adapters',
title: 'Provider adapters',
copy: 'Use adapters for Vapi, ElevenLabs, LiveKit, Pipecat, OpenAI Realtime, and Gemini Live.',
copy: 'Use adapters for Vapi, ElevenLabs, LiveKit, Pipecat, OpenAI Live, OpenAI Realtime, and Gemini Live.',
link: '/docs/adapters/overview',
linkLabel: 'Explore adapters',
},
Expand All @@ -315,7 +321,7 @@ const SEO_SECTIONS = [
id: 'roadmap',
title: 'Native realtime voice adapters',
copy: 'Drive the UI from managed browser audio, provider state, and separate input/output levels.',
link: '/docs/adapters/openai-realtime',
link: '/docs/adapters/openai-live',
linkLabel: 'OpenAI setup',
},
] as const
Expand All @@ -325,6 +331,7 @@ const PROVIDER_GUIDES = [
{ href: '/docs/adapters/elevenlabs', label: 'ElevenLabs', detail: 'Conversational AI' },
{ href: '/docs/adapters/livekit', label: 'LiveKit', detail: 'Agents' },
{ href: '/docs/adapters/pipecat', label: 'Pipecat', detail: 'RTVI' },
{ href: '/docs/adapters/openai-live', label: 'OpenAI GPT-Live', detail: 'Full duplex' },
{ href: '/docs/adapters/openai-realtime', label: 'OpenAI Realtime', detail: 'WebRTC' },
{ href: '/docs/adapters/gemini-live', label: 'Gemini Live', detail: 'Live API' },
{ href: '/docs/adapters/custom', label: 'Custom voice stack', detail: 'Controlled mode' },
Expand Down Expand Up @@ -1784,11 +1791,13 @@ export default function App() {

<div className="hero-providers" aria-label="Supported provider guides">
<span>Native paths</span>
{PROVIDER_GUIDES.slice(0, 6).map((provider) => (
<a key={provider.href} href={provider.href}>
{provider.label}
</a>
))}
{PROVIDER_GUIDES.filter((provider) => provider.href !== '/docs/adapters/custom').map(
(provider) => (
<a key={provider.href} href={provider.href}>
{provider.label}
</a>
),
)}
</div>
</div>

Expand Down
Loading
Loading