Test 4a: migrate session.update + event names to GA Realtime API

Hardware verification surfaced four shape changes from the beta API that
the spec/plan was written against:

1. Remove the OpenAI-Beta: realtime=v1 header (GA rejects it with
   beta_api_shape_disabled).
2. session.update payload nests under session.audio.{input,output} instead
   of flat fields. session.type = "realtime" + session.model are inline.
3. session.audio.{input,output}.format is now an object
   ({ type: "audio/pcm", rate: 24000 }), not a bare "pcm16" string.
4. 'session.modalities' is no longer accepted; dropped (server uses defaults).
5. response.audio.*  →  response.output_audio.*  (delta, done, transcript).

Also silenced a bunch of GA framing events (conversation.item.added /
.done, response.output_item.added / .done, response.content_part.added /
.done, rate_limits.updated) that were burying signal in the [ws] ignored
log lines.

Plus added an outer try/catch around main so a mid-startup WS error event
(which fires cts.Cancel via the receive loop) routes through cleanup
instead of crashing on the await SendSessionUpdate.

Verified on hardware: full round-trip works, t=0.44 s end-of-speech to
first audio sample arriving, assistant reply audible, clean exit.
This commit is contained in:
2026-06-12 19:41:08 +00:00
parent ac8a3f012c
commit 4cfb0d4b9a
+58 -15
View File
@@ -36,7 +36,8 @@ var state = new SharedState();
var ws = new ClientWebSocket(); var ws = new ClientWebSocket();
ws.Options.SetRequestHeader("Authorization", $"Bearer {apiKey}"); ws.Options.SetRequestHeader("Authorization", $"Bearer {apiKey}");
ws.Options.SetRequestHeader("OpenAI-Beta", "realtime=v1"); // No OpenAI-Beta header — the GA Realtime API rejects beta-shaped requests with
// "beta_api_shape_disabled". Documented at developers.openai.com/api/docs/guides/realtime-websocket.
Stream? inputStream = null; Stream? inputStream = null;
Stream? outputStream = null; Stream? outputStream = null;
@@ -107,6 +108,17 @@ try
Console.WriteLine("[ws] closing"); Console.WriteLine("[ws] closing");
} }
catch (OperationCanceledException)
{
// Mid-startup cancellation (typically a WS error event fired cts.Cancel while
// the main thread was awaiting SendSessionUpdate / a channel write).
// The receive loop has already logged the underlying [error]; we just route through cleanup.
}
catch (Exception ex)
{
Console.Error.WriteLine($"[error] unexpected: {ex.GetType().Name}: {ex.Message}");
Environment.ExitCode = 7;
}
finally finally
{ {
// Cancel any background work first so receive/send loops exit cleanly. // Cancel any background work first so receive/send loops exit cleanly.
@@ -209,23 +221,40 @@ static short[] MakeBeep(double freqHz, double durationS, int sampleRate)
static async Task SendSessionUpdate(ClientWebSocket ws, CancellationToken ct) static async Task SendSessionUpdate(ClientWebSocket ws, CancellationToken ct)
{ {
// GA Realtime API shape: nested session.audio.{input,output} with format + voice + turn_detection
// moved under audio.input. session.type = "realtime" and session.model inline.
// (Beta-era flat shape with session.input_audio_format/turn_detection at the top is rejected.)
var msg = new var msg = new
{ {
type = "session.update", type = "session.update",
session = new session = new
{ {
modalities = new[] { "audio", "text" }, type = "realtime",
model = C.ModelName,
instructions = C.Instructions, instructions = C.Instructions,
voice = C.Voice, // No 'modalities' field — the beta name. GA defaults are fine; if we ever need to
input_audio_format = "pcm16", // constrain to audio-only we'd use 'output_modalities' (the documented GA name in
output_audio_format = "pcm16", // newer OpenAI APIs).
turn_detection = new audio = new
{ {
type = "server_vad", input = new
threshold = 0.5, {
prefix_padding_ms = 300, // GA shape: format is now an object with a "type" field, not a bare string.
silence_duration_ms = 500, format = new { type = "audio/pcm", rate = 24_000 },
create_response = true, turn_detection = new
{
type = "server_vad",
threshold = 0.5,
prefix_padding_ms = 300,
silence_duration_ms = 500,
create_response = true,
},
},
output = new
{
format = new { type = "audio/pcm", rate = 24_000 },
voice = C.Voice,
},
}, },
}, },
}; };
@@ -418,11 +447,12 @@ static async Task ReceiveLoop(ClientWebSocket ws, Channel<short[]> downstream, C
case "response.created": case "response.created":
Console.WriteLine("[ws] response.created"); Console.WriteLine("[ws] response.created");
break; break;
case "response.audio_transcript.delta": // GA event names are response.output_audio* (beta was response.audio*).
case "response.output_audio_transcript.delta":
if (doc.RootElement.TryGetProperty("delta", out var tEl)) if (doc.RootElement.TryGetProperty("delta", out var tEl))
transcript.Append(tEl.GetString()); transcript.Append(tEl.GetString());
break; break;
case "response.audio.delta": case "response.output_audio.delta":
{ {
string b64 = doc.RootElement.GetProperty("delta").GetString() ?? ""; string b64 = doc.RootElement.GetProperty("delta").GetString() ?? "";
byte[] audioBytes = Convert.FromBase64String(b64); byte[] audioBytes = Convert.FromBase64String(b64);
@@ -439,8 +469,21 @@ static async Task ReceiveLoop(ClientWebSocket ws, Channel<short[]> downstream, C
await downstream.Writer.WriteAsync(samples, cts.Token); await downstream.Writer.WriteAsync(samples, cts.Token);
break; break;
} }
case "response.audio.done": case "response.output_audio.done":
Console.WriteLine("[ws] response.audio.done"); Console.WriteLine("[ws] response.output_audio.done");
break;
case "response.output_audio_transcript.done":
// transcript already accumulated; nothing to do here, server signals end of stream.
break;
// GA emits a stream of conversation/response framing events we don't need for the
// probe — quiet them so they don't bury real signal in the log.
case "conversation.item.added":
case "conversation.item.done":
case "response.output_item.added":
case "response.output_item.done":
case "response.content_part.added":
case "response.content_part.done":
case "rate_limits.updated":
break; break;
case "response.done": case "response.done":
state.NoMoreDeltas = true; state.NoMoreDeltas = true;