Files
tes 4cfb0d4b9a 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.
2026-06-12 19:41:08 +00:00

543 lines
20 KiB
C#

using System.Net.WebSockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using PortAudioSharp;
using Stream = PortAudioSharp.Stream;
// .NET-side mirror for any readers that go through Environment.GetEnvironmentVariable,
// AND libc setenv so PortAudio (which uses getenv()) sees the value.
Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1");
Libc.setenv("PA_ALSA_PLUGHW", "1", 1);
string apiKey = LoadApiKey();
PortAudio.Initialize();
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
var upstreamChannel = Channel.CreateBounded<short[]>(new BoundedChannelOptions(C.UpstreamChannelCapacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = true,
SingleReader = true,
});
var downstreamChannel = Channel.CreateBounded<short[]>(new BoundedChannelOptions(C.DownstreamChannelCapacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = false,
SingleReader = true,
});
var doneFlag = new ManualResetEventSlim(false);
var state = new SharedState();
var ws = new ClientWebSocket();
ws.Options.SetRequestHeader("Authorization", $"Bearer {apiKey}");
// 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;
Task? receiveTask = null;
Task? sendTask = null;
try
{
var uri = new Uri($"wss://api.openai.com/v1/realtime?model={C.ModelName}");
Console.WriteLine($"[ws] connecting to {uri}");
try
{
await ws.ConnectAsync(uri, cts.Token);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[error] WS connect failed: {ex.Message}");
if (ex.InnerException is HttpRequestException httpEx)
Console.Error.WriteLine($"[error] inner: HTTP {(int?)httpEx.StatusCode}");
Environment.ExitCode = 3;
return;
}
Console.WriteLine("[ws] connected");
receiveTask = Task.Run(() => ReceiveLoop(ws, downstreamChannel, cts, state));
await SendSessionUpdate(ws, cts.Token);
int device = FindUsbDevice();
Console.WriteLine($"Using device {device} ('{PortAudio.GetDeviceInfo(device).name}')");
inputStream = OpenInputStream(device, upstreamChannel, state);
try { inputStream.Start(); }
catch (Exception ex)
{
Console.Error.WriteLine($"[error] input stream start failed: {ex.Message}");
Environment.ExitCode = 5;
return;
}
outputStream = OpenOutputStream(device, downstreamChannel, state, doneFlag);
try { outputStream.Start(); }
catch (Exception ex)
{
Console.Error.WriteLine($"[error] output stream start failed: {ex.Message}");
Environment.ExitCode = 6;
return;
}
sendTask = Task.Run(() => UpstreamSendLoop(ws, upstreamChannel, cts, state));
// Startup beep — queued onto downstreamChannel before the receive loop produces any audio.
short[] beep = MakeBeep(C.BeepHz, C.BeepSeconds, C.SampleRate);
Console.WriteLine("[beep]");
await downstreamChannel.Writer.WriteAsync(beep, cts.Token);
// Wait for beep to drain (200 ms beep + 100 ms margin) so it doesn't get captured
// by the mic and shipped to OpenAI.
Thread.Sleep((int)(C.BeepSeconds * 1000) + C.BeepDrainMs);
state.MicArmed = true;
Console.WriteLine("Speak now.");
try
{
doneFlag.Wait(cts.Token);
}
catch (OperationCanceledException) { /* Ctrl-C path */ }
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.
cts.Cancel();
if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived)
{
try
{
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", closeTimeout.Token);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ws] close failed: {ex.Message}");
}
}
try { inputStream?.Stop(); } catch { }
try { outputStream?.Stop(); } catch { }
try { inputStream?.Dispose(); } catch { }
try { outputStream?.Dispose(); } catch { }
try { ws.Dispose(); } catch { }
try { PortAudio.Terminate(); } catch { }
Console.WriteLine("bye");
}
static string LoadApiKey()
{
string home = Environment.GetEnvironmentVariable("HOME") ?? "/root";
string path = Path.Combine(home, ".openai_key");
if (!File.Exists(path))
{
Console.Error.WriteLine($"[error] API key file not found at {path}");
Environment.Exit(2);
}
// Non-fatal mode-600 check.
try
{
var mode = File.GetUnixFileMode(path);
var leaky = mode & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute
| UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
if (leaky != UnixFileMode.None)
Console.Error.WriteLine($"[warn] {path} mode includes group/other perms ({mode}); expected 600");
}
catch (PlatformNotSupportedException) { /* not Unix */ }
catch (Exception ex)
{
Console.Error.WriteLine($"[warn] could not check {path} mode: {ex.Message}");
}
string key = File.ReadAllText(path).Trim();
if (string.IsNullOrEmpty(key))
{
Console.Error.WriteLine($"[error] API key file empty");
Environment.Exit(2);
}
if (!key.StartsWith("sk-"))
{
Console.Error.WriteLine($"[error] API key doesn't start with sk-");
Environment.Exit(2);
}
return key;
}
static int FindUsbDevice()
{
int count = PortAudio.DeviceCount;
for (int i = 0; i < count; i++)
{
var info = PortAudio.GetDeviceInfo(i);
if (info.name.ToLowerInvariant().Contains("usb") && info.maxInputChannels >= 1)
return i;
}
Console.Error.WriteLine("USB audio device not found. Devices:");
for (int i = 0; i < count; i++)
{
var info = PortAudio.GetDeviceInfo(i);
Console.Error.WriteLine(
$" [{i}] {info.name} in={info.maxInputChannels} out={info.maxOutputChannels}");
}
Environment.Exit(1);
return -1; // unreachable
}
static short[] MakeBeep(double freqHz, double durationS, int sampleRate)
{
int n = (int)(sampleRate * durationS);
var buf = new short[n];
for (int i = 0; i < n; i++)
{
double t = i / (double)sampleRate;
double v = 0.3 * Math.Sin(2.0 * Math.PI * freqHz * t);
buf[i] = (short)(v * short.MaxValue);
}
return buf;
}
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
{
type = "realtime",
model = C.ModelName,
instructions = C.Instructions,
// 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",
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,
},
},
},
};
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(msg);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, endOfMessage: true, ct);
}
static Stream OpenInputStream(int device, Channel<short[]> upstream, SharedState state)
{
var inParams = new StreamParameters
{
device = device,
channelCount = C.Channels,
sampleFormat = SampleFormat.Int16,
suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowInputLatency,
hostApiSpecificStreamInfo = IntPtr.Zero,
};
Stream.Callback callback = (IntPtr input, IntPtr _, uint frameCount,
ref StreamCallbackTimeInfo _2, StreamCallbackFlags status, IntPtr _3) =>
{
if (status.HasFlag(StreamCallbackFlags.InputOverflow))
Console.Error.WriteLine("[status] input overflow");
if (!state.MicArmed || state.StopSending)
return StreamCallbackResult.Continue;
if (frameCount != C.MicBlockFrames)
{
Console.Error.WriteLine($"[status] unexpected input frameCount={frameCount} (want {C.MicBlockFrames})");
return StreamCallbackResult.Continue;
}
var frame = new short[C.MicBlockFrames];
unsafe
{
short* src = (short*)input.ToPointer();
fixed (short* dst = &frame[0])
Buffer.MemoryCopy(src, dst, C.MicBlockFrames * sizeof(short), C.MicBlockFrames * sizeof(short));
}
if (!upstream.Writer.TryWrite(frame))
Console.Error.WriteLine("[status] upstream behind, dropping mic frame");
return StreamCallbackResult.Continue;
};
return new Stream(inParams, null, C.SampleRate, C.MicBlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero);
}
static Stream OpenOutputStream(int device, Channel<short[]> downstream, SharedState state, ManualResetEventSlim doneFlag)
{
var outParams = new StreamParameters
{
device = device,
channelCount = C.Channels,
sampleFormat = SampleFormat.Int16,
suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency,
hostApiSpecificStreamInfo = IntPtr.Zero,
};
Stream.Callback callback = (IntPtr _, IntPtr output, uint frameCount,
ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) =>
{
int fill = 0;
while (fill < frameCount)
{
// Pull next chunk if we have no working one.
if (state.CurrentChunk == null)
{
if (!downstream.Reader.TryRead(out var nextChunk))
break; // ring empty
state.CurrentChunk = nextChunk;
state.ChunkOffset = 0;
}
// Local non-null alias so the compiler doesn't warn on dereference inside `fixed`.
short[] chunk = state.CurrentChunk!;
int slack = (int)frameCount - fill;
int remaining = chunk.Length - state.ChunkOffset;
int take = Math.Min(remaining, slack);
unsafe
{
short* dst = (short*)output.ToPointer();
fixed (short* src = &chunk[state.ChunkOffset])
Buffer.MemoryCopy(src, dst + fill, take * sizeof(short), take * sizeof(short));
}
state.ChunkOffset += take;
fill += take;
if (state.ChunkOffset >= chunk.Length)
state.CurrentChunk = null;
}
unsafe
{
short* dst = (short*)output.ToPointer();
for (int i = fill; i < (int)frameCount; i++) dst[i] = 0;
}
// Completion = working chunk drained AND channel empty AND no more deltas expected.
// _noMoreDeltas is the spec's "redundant fast path" — set immediately on response.done,
// avoids touching downstream.Reader.Completion (a Task allocation per callback).
if (state.CurrentChunk == null
&& downstream.Reader.Count == 0
&& state.NoMoreDeltas)
{
doneFlag.Set();
return StreamCallbackResult.Complete;
}
return StreamCallbackResult.Continue;
};
return new Stream(null, outParams, C.SampleRate, C.OutBlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero);
}
static async Task UpstreamSendLoop(ClientWebSocket ws, Channel<short[]> upstream, CancellationTokenSource cts, SharedState state)
{
try
{
await foreach (var frame in upstream.Reader.ReadAllAsync(cts.Token))
{
if (state.StopSending)
{
// After server VAD said the user is done, drop in-flight mic frames and exit
// once the channel is drained. Matches the spec's "_stopSending && empty" exit.
if (upstream.Reader.Count == 0) break;
continue;
}
ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(frame.AsSpan());
string base64 = Convert.ToBase64String(bytes);
var msg = new { type = "input_audio_buffer.append", audio = base64 };
byte[] json = JsonSerializer.SerializeToUtf8Bytes(msg);
await ws.SendAsync(new ArraySegment<byte>(json), WebSocketMessageType.Text, endOfMessage: true, cts.Token);
}
}
catch (OperationCanceledException) { /* expected on Cancel */ }
catch (Exception ex)
{
Console.Error.WriteLine($"[error] upstream send: {ex.Message}");
cts.Cancel();
}
}
static async Task ReceiveLoop(ClientWebSocket ws, Channel<short[]> downstream, CancellationTokenSource cts, SharedState state)
{
var transcript = new StringBuilder();
var buffer = new byte[4096];
bool firstDeltaSeen = false;
DateTime speechStoppedTime = DateTime.MinValue;
using var msgBuffer = new MemoryStream();
try
{
while (!cts.IsCancellationRequested)
{
msgBuffer.SetLength(0);
WebSocketReceiveResult result;
do
{
result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
if (result.MessageType == WebSocketMessageType.Close) return;
msgBuffer.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
msgBuffer.Position = 0;
using var doc = JsonDocument.Parse(msgBuffer);
string type = doc.RootElement.GetProperty("type").GetString() ?? "";
switch (type)
{
case "session.created":
{
string? sid = doc.RootElement.TryGetProperty("session", out var sess)
&& sess.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
Console.WriteLine($"[ws] session.created {sid}");
break;
}
case "session.updated":
Console.WriteLine("[ws] session.updated — session pinned");
break;
case "input_audio_buffer.speech_started":
Console.WriteLine("[vad] speech started");
break;
case "input_audio_buffer.speech_stopped":
Console.WriteLine("[vad] speech stopped");
speechStoppedTime = DateTime.UtcNow;
state.StopSending = true;
break;
case "input_audio_buffer.committed":
Console.WriteLine("[ws] input_audio_buffer.committed");
break;
case "response.created":
Console.WriteLine("[ws] response.created");
break;
// 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.output_audio.delta":
{
string b64 = doc.RootElement.GetProperty("delta").GetString() ?? "";
byte[] audioBytes = Convert.FromBase64String(b64);
short[] samples = new short[audioBytes.Length / 2];
Buffer.BlockCopy(audioBytes, 0, samples, 0, audioBytes.Length);
if (!firstDeltaSeen)
{
firstDeltaSeen = true;
double elapsed = speechStoppedTime == DateTime.MinValue
? -1
: (DateTime.UtcNow - speechStoppedTime).TotalSeconds;
Console.WriteLine($"[audio] first delta received (t = {elapsed:0.00} s since speech_stopped)");
}
await downstream.Writer.WriteAsync(samples, cts.Token);
break;
}
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;
downstream.Writer.Complete();
Console.WriteLine("[ws] response.done");
Console.WriteLine($"[transcript] {transcript}");
break;
case "error":
Console.Error.WriteLine($"[error] WS error event: {doc.RootElement.GetRawText()}");
cts.Cancel();
break;
default:
Console.Error.WriteLine($"[ws] ignored type={type}");
break;
}
}
}
catch (OperationCanceledException) { /* expected on Cancel */ }
catch (Exception ex)
{
Console.Error.WriteLine($"[error] receive loop: {ex.Message}");
cts.Cancel();
}
}
sealed class SharedState
{
public volatile bool MicArmed;
public volatile bool StopSending;
public volatile bool NoMoreDeltas;
// Output-callback-thread-local; do NOT touch from anywhere else.
public short[]? CurrentChunk;
public int ChunkOffset;
}
static class C
{
public const int SampleRate = 24_000;
public const int Channels = 1;
public const uint MicBlockFrames = 1920; // 40 ms @ 24 kHz
public const uint OutBlockFrames = 1024; // ~43 ms @ 24 kHz
public const string ModelName = "gpt-realtime";
public const string Voice = "alloy";
public const string Instructions = "Reply in one short sentence.";
public const double BeepHz = 880.0;
public const double BeepSeconds = 0.2;
public const int BeepDrainMs = 100;
public const int UpstreamChannelCapacity = 64;
public const int DownstreamChannelCapacity = 64;
}
static class Libc
{
[DllImport("libc", EntryPoint = "setenv")]
public static extern int setenv(string name, string value, int overwrite);
}