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:
@@ -36,7 +36,8 @@ var state = new SharedState();
|
||||
|
||||
var ws = new ClientWebSocket();
|
||||
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? outputStream = null;
|
||||
@@ -107,6 +108,17 @@ try
|
||||
|
||||
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
|
||||
{
|
||||
// Cancel any background work first so receive/send loops exit cleanly.
|
||||
@@ -209,16 +221,26 @@ static short[] MakeBeep(double freqHz, double durationS, int sampleRate)
|
||||
|
||||
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
|
||||
{
|
||||
type = "session.update",
|
||||
session = new
|
||||
{
|
||||
modalities = new[] { "audio", "text" },
|
||||
type = "realtime",
|
||||
model = C.ModelName,
|
||||
instructions = C.Instructions,
|
||||
voice = C.Voice,
|
||||
input_audio_format = "pcm16",
|
||||
output_audio_format = "pcm16",
|
||||
// No 'modalities' field — the beta name. GA defaults are fine; if we ever need to
|
||||
// constrain to audio-only we'd use 'output_modalities' (the documented GA name in
|
||||
// newer OpenAI APIs).
|
||||
audio = new
|
||||
{
|
||||
input = new
|
||||
{
|
||||
// GA shape: format is now an object with a "type" field, not a bare string.
|
||||
format = new { type = "audio/pcm", rate = 24_000 },
|
||||
turn_detection = new
|
||||
{
|
||||
type = "server_vad",
|
||||
@@ -228,6 +250,13 @@ static async Task SendSessionUpdate(ClientWebSocket ws, CancellationToken ct)
|
||||
create_response = true,
|
||||
},
|
||||
},
|
||||
output = new
|
||||
{
|
||||
format = new { type = "audio/pcm", rate = 24_000 },
|
||||
voice = C.Voice,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(msg);
|
||||
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
@@ -418,11 +447,12 @@ static async Task ReceiveLoop(ClientWebSocket ws, Channel<short[]> downstream, C
|
||||
case "response.created":
|
||||
Console.WriteLine("[ws] response.created");
|
||||
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))
|
||||
transcript.Append(tEl.GetString());
|
||||
break;
|
||||
case "response.audio.delta":
|
||||
case "response.output_audio.delta":
|
||||
{
|
||||
string b64 = doc.RootElement.GetProperty("delta").GetString() ?? "";
|
||||
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);
|
||||
break;
|
||||
}
|
||||
case "response.audio.done":
|
||||
Console.WriteLine("[ws] response.audio.done");
|
||||
case "response.output_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;
|
||||
case "response.done":
|
||||
state.NoMoreDeltas = true;
|
||||
|
||||
Reference in New Issue
Block a user