Skip to content

Live voice frontend integration

AIHub uses GPT-Live 1 for continuous simulation voice. The simulation WebSocket requires an updated frontend. Coordinate frontend and backend deployment when migrating from the former Realtime protocol.

Path Purpose Required query parameters Optional query parameters
/chatbot/api/v1/simulation/ws/chat Spoken roleplay and subsequent analysis user_application_id, counterpart_user_id, simulation_type conversation_id, JSON context (including voice), source=Web

source is Web, Agent or TeamsApp. Simulation types are manager_feedback, peer_feedback, sales_simulation, interview_simulation and neutral_simulation. Encode query values with URLSearchParams. A new conversation ID is generated when omitted. Retain the ID returned by the server. All paths are relative to the deployed AIHub base URL, including any reverse-proxy prefix. WebSockets are documented here, not in OpenAPI/Swagger.

Authentication and startup

Use the same AIHub user authentication as existing chat. A browser can negotiate pairs in Sec-WebSocket-Protocol: x-api-key, <AIHub user key>, optionally client-id, <tenant client ID> and bearer, <JWT>. The server echoes the offered x-api-key protocol label, never the credential. Server clients can instead use X-API-Key and Client-ID headers. Conflicting header/protocol values and query string API keys are rejected. The OpenAI project key stays on AIHub. MongoDB tenant resolution uses the authenticated key and Client-ID; PostgreSQL uses the deployment's configured database, as for ordinary chat.

const url = new URL(`${aihubBaseUrl}/chatbot/api/v1/simulation/ws/chat`);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.search = new URLSearchParams({
  user_application_id: userApplicationId,
  counterpart_user_id: counterpartUserId,
  simulation_type: "manager_feedback",
  context: JSON.stringify({voice: "brise"}),
  source: "Web",
});
const protocols = ["x-api-key", aihubUserKey];
if (clientId) protocols.push("client-id", clientId);
if (jwt) protocols.push("bearer", jwt);
const socket = new WebSocket(url, protocols);

Wait for this application event before sending microphone audio:

{
  "type": "session.started",
  "session_id": "live-example",
  "conversation_id": "f138bb08-62d5-4483-b8c6-c35bfa2a2cea",
  "audio": {"type": "audio/pcm", "rate": 24000, "channels": 1}
}

Do not send OpenAI session.start or session.update. AIHub owns instructions, model selection, history roles and tools. Startup has a 20-second timeout; sessions have a one-hour application limit. Voice cannot change during a session. The default is brise, a natural French voice with a feminine presentation. sillage is the natural French alternative with a masculine presentation. Supported voice names include the pinned OpenAI SDK's BuiltInVoice catalogue and these two documented French voices. Unsupported voices are rejected, not substituted. An explicit context.voice overrides the default: remove or replace any frontend voice: "marin" selection to use Brise. Start a new WebSocket session to apply a different voice.

Capture and playback

Both directions carry raw mono signed 16-bit little-endian PCM at 24,000 Hz, Base64 encoded inside JSON text frames. There is no WAV header, MP3, WebM, MediaRecorder container or binary WebSocket frame.

Use getUserMedia and an AudioWorklet. Request echo cancellation and implement resampling from the actual AudioContext.sampleRate to 24,000 Hz; requesting a sample rate does not guarantee the device uses it. Preserve resampler state between chunks. Convert float samples to signed PCM16 and encode little-endian bytes. Recommended chunks are 20–100 ms (960–4,800 bytes). Each frame must contain complete two-byte samples and at most one second of audio (48,000 bytes).

Pace input in real time, including silence while the microphone is active. Do not send a recorded file as fast as possible. Keep input and output running together; Live handles listening and speaking simultaneously. Avoid feeding speaker output back into the microphone. Bound browser buffers and monitor socket.bufferedAmount; if a connection cannot keep up, stop with a visible connection error instead of building an unbounded queue or silently losing user audio.

function sendEvent(type, fields = {}) {
  if (socket.readyState !== WebSocket.OPEN) return;
  socket.send(JSON.stringify({type, event_id: crypto.randomUUID(), ...fields}));
}
function sendPcm16(pcmBytes) {
  // pcmBytes is an even-length Uint8Array, already resampled to 24 kHz.
  let binary = "";
  for (const byte of pcmBytes) binary += String.fromCharCode(byte);
  sendEvent("session.input_audio.append", {audio: btoa(binary)});
}

On session.output_audio.delta, decode delta and queue PCM for uninterrupted playback in arrival order. Resample output if the player runs at another rate. Audio events have no timestamps or end-of-answer event. Do not wait for an old Realtime response.done or response.audio.done event to start or finish playback.

There is no Realtime VAD event, response.cancel, item truncation or buffer commit command in the browser contract. Speech can overlap intentionally. The player owns its local queue: if the UI deliberately discards buffered output, report the discarded samples. Preserve received transcript fragments even when their corresponding audio is discarded.

Browser-to-AIHub events

Every event requires a unique string event_id (1–128 characters). Unknown fields and event types are rejected. A JSON frame is limited to 100,000 characters.

Type Additional fields Meaning
session.input_audio.append audio: Base64 PCM16 Append microphone audio after startup
session.input_audio.mute none Pause microphone input to the model
session.input_audio.unmute none Resume microphone input
playback.progress played_samples, discarded_samples: nonnegative integers Cumulative output samples consumed or discarded by this browser
session.close none Finish accepted backend work, drain transcripts and close

Playback counters count 24 kHz samples, not bytes, frames or milliseconds. They start at zero for each Live session, never decrease, and their sum cannot exceed the output samples received. Send periodic updates and a final update before close. These counters help diagnose playback; transcript captions identify generated speech and are not proof of what the user heard.

AIHub-to-browser events

Type Fields to consume Frontend behavior
session.started session_id, conversation_id, audio Save IDs; enable capture
session.output_audio.delta delta Decode and queue PCM16 for playback
session.input_transcript.delta delta, start_ms, end_ms, event_id Append user captions
session.output_transcript.delta same Append assistant captions
session.input_audio.muted / session.input_audio.unmuted type Reflect microphone state
session.usage.updated usage.seconds Replace the running duration; do not sum snapshots
session.closed reason, usage.seconds Finalization confirmed; transcript persistence has drained
error code, message Display the sanitized message; handle recoverable or terminal error below

Append each speaker's transcript delta exactly, preserving whitespace and repeated words. Timing is relative to the start of the Live session. Events can arrive late and have no item ID or completed-turn marker. A pause in delivery is not a turn boundary. Use timestamps for caption placement and event_id to avoid rendering the same event twice. AIHub groups captions independently for each speaker and Live session. A gap greater than 2,000 ms between that speaker's audio intervals starts a new history message; network delivery delays and captions from the other speaker do not split it. The threshold is a grouping heuristic, so a long pause can split a single semantic response.

Each accepted fragment updates the same history document and messageApplicationId. createdAt remains the first fragment's persistence time; updatedAt advances. The message's data.voiceTurn records startMs, endMs, fragmentCount and delivery (transcribed for user speech, generated for assistant speech). History pagination and simulation analysis therefore operate on grouped turns. The WebSocket still sends the original caption events for immediate display. Duplicate event IDs are ignored for persistence within one session. Session state is bounded to 50,000 distinct caption events and 100,000 characters per turn; exceeding either limit terminates the session instead of truncating the text.

socket.onmessage = ({data}) => {
  const event = JSON.parse(data);
  switch (event.type) {
    case "session.started":
      // Store IDs, then start the application's AudioWorklet capture.
      startCapture(event.audio.rate);
      break;
    case "session.output_audio.delta":
      queueAudio(Uint8Array.from(atob(event.delta), c => c.charCodeAt(0)));
      break;
    case "session.input_transcript.delta":
    case "session.output_transcript.delta":
      appendCaption(event); // Preserve delta and timestamp interval.
      break;
    case "session.closed":
      stopCapture();
      // Drain the local playback queue before releasing the player.
      finishPlayback();
      break;
    case "error":
      showVoiceError(event.code, event.message);
      break;
  }
};

The capture, playback and rendering functions above are frontend integration points, not supplied browser APIs.

Closing, failures and reconnection

Stop capture, send the last playback.progress, then session.close. Keep the socket open while queued backend events drain, the provider finalizes (up to 15 seconds), and transcript persistence drains (up to 120 seconds). The application emits session.closed before closing with code 1000. Only then request simulation analysis using conversation_id; its session identifier is session_id.

invalid_client_event is recoverable: that frame was rejected and not forwarded. voice_session_failed is terminal, followed by close code 1011. Invalid identity, query or authentication input is rejected with 1008 or before the handshake. A connection that ends without session.closed has unconfirmed provider usage and may have unfinished persistence. Do not automatically replay audio.

Reconnect explicitly using the existing conversation_id when continuing the same simulation. Each connection starts a new Live session with fresh caption timing. Previously saved messages remain available for analysis, but earlier spoken turns are not automatically replayed into the new session. Preserve each session_id when requesting analysis for a specific session.

Simulation voice does not run through the Oria graph classifier. Application-owned scenario instructions and validated browser commands define its separate boundary.

Configuration and rollout

OPENAI_API_KEY is required. The dedicated continuous-voice selector remains llm_realtime_model; its default and OpenAI catalogue alias are now gpt-live-1 and gpt_live_1. It names the Live API model, despite the retained configuration field name. Live's delegated Responses backend uses the OpenAI mini semantic profile, preserving its configured model and reasoning effort. Voice duration and delegated backend token usage are separate costs.

The separate Realtime transcription catalogue and tenant selector have been removed. Standalone file transcription and simulation MP3 output retain their dedicated models. Their endpoints continue to operate independently.

Existing stored configuration is not updated by changing code or seeds. Before switching traffic, inspect the appropriate dry run, then apply the explicit data migration in the deployment environment:

python -m scripts.migrate_live_voice --backend mongodb
python -m scripts.migrate_live_voice --backend mongodb --apply
# For a PostgreSQL deployment, use --backend postgres instead.

This visits registered MongoDB tenants plus the provider catalogue, or PostgreSQL's configured schema and singleton config. It changes exact retired model references, removes the obsolete transcription selector, preserves unrelated/custom profiles and fails on conflicting canonical aliases. MongoDB writes compare the fields read; PostgreSQL writes use one transaction. Repeating the migration is safe. Alembic revision 20261021_28 removes the retired PostgreSQL provider columns; its downgrade restores empty nullable columns, not deleted model selections. Do not run the old application concurrently after dropping its columns. Coordinate maintenance, migration, API deployment and frontend deployment. No production configuration is changed automatically by the code migration.

TLS retains verified certificates using OPENAI_CA_BUNDLE, SSL_CERT_FILE, then certifi. The initial WebSocket handshake may make up to three attempts with exponential backoff, within one 20-second opening deadline. Permanent errors fail immediately; retry-control headers are respected. Provider reconnect/replay retries remain disabled after connection, so audio and delegated actions are never automatically replayed.

Before rollout, test microphone resampling, echo cancellation, simultaneous speech, queue limits, mute/unmute, interruptions, late captions, graceful close, network loss and simulation analysis with the actual frontend. Unit tests use simulated provider events; device/network/audio quality requires this end-to-end acceptance check.

Official references: Live migration, WebSockets, delegation.