diff --git a/tests/04a-realtime-oneshot-cs/Program.cs b/tests/04a-realtime-oneshot-cs/Program.cs index 1f5e426..c8a65c6 100644 --- a/tests/04a-realtime-oneshot-cs/Program.cs +++ b/tests/04a-realtime-oneshot-cs/Program.cs @@ -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,23 +221,40 @@ 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", - turn_detection = new + // 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 { - type = "server_vad", - threshold = 0.5, - prefix_padding_ms = 300, - silence_duration_ms = 500, - create_response = true, + 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", + 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 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 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;