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();
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user