From 5a498e9c911a9605de5ea171ff83fcffe981b821 Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Fri, 12 Jun 2026 13:31:25 +0000 Subject: [PATCH] Test 3: full-cycle state machine (IDLE/BEEPING/RECORDING/PLAYBACK) --- tests/03-full-cycle-cs/Program.cs | 380 ++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 tests/03-full-cycle-cs/Program.cs diff --git a/tests/03-full-cycle-cs/Program.cs b/tests/03-full-cycle-cs/Program.cs new file mode 100644 index 0000000..416c5c2 --- /dev/null +++ b/tests/03-full-cycle-cs/Program.cs @@ -0,0 +1,380 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using FullCycleProbe; +using Microsoft.ML.OnnxRuntime; +using PortAudioSharp; +using Stream = PortAudioSharp.Stream; + +const int SampleRate = WakewordModel.SampleRate; // 16 000 +const int Channels = 1; +const uint BlockFrames = WakewordModel.FrameSamples; // 1280 (80 ms) +const float Threshold = 0.5f; +const double CooldownSeconds = 1.0; +const double BeepHz = 880.0; +const double BeepSeconds = 0.2; + +const int BeepingFrames = 3; // 3 * 80 ms = 240 ms (covers 200 ms beep + 40 ms drain/reverb margin) +const int RecordingFrames = 63; // 63 * 80 ms = 5.04 s (rounded up from 5 s to avoid partial-frame handling) +const int RecordBufSamples = RecordingFrames * (int)BlockFrames; // 80640 + +// .NET-side mirror for any readers that go through Environment.GetEnvironmentVariable, +// AND libc setenv so PortAudio / onnxruntime (which use getenv()) see the values. +Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1"); +Libc.setenv("PA_ALSA_PLUGHW", "1", 1); +Environment.SetEnvironmentVariable("ORT_LOGGING_LEVEL", "3"); +Libc.setenv("ORT_LOGGING_LEVEL", "3", 1); +// ORT 1.26.0: the env var does NOT suppress early GPU-discovery warnings from device_discovery.cc. +// CreateInstanceWithOptions sets the log level at OrtEnv creation time, before EP discovery runs. +var ortEnvOpts = new EnvironmentCreationOptions { logId = "FullCycleProbe", logLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR }; +OrtEnv.CreateInstanceWithOptions(ref ortEnvOpts); + +PortAudio.Initialize(); +WakewordModel? model = null; +try +{ + int device = FindUsbDevice(); + Console.WriteLine($"Using device {device} ('{PortAudio.GetDeviceInfo(device).name}')"); + + Console.WriteLine("Loading WakewordModel..."); + var t0 = DateTime.UtcNow; + string modelsDir = Path.Combine(AppContext.BaseDirectory, "models"); + model = new WakewordModel( + Path.Combine(modelsDir, "melspectrogram.onnx"), + Path.Combine(modelsDir, "embedding_model.onnx"), + Path.Combine(modelsDir, "alexa.onnx")); + Console.WriteLine($"Loaded in {(DateTime.UtcNow - t0).TotalSeconds:0.00}s."); + + var queue = new BlockingCollection(boundedCapacity: 16); + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + var inParams = new StreamParameters + { + device = device, + channelCount = 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 (frameCount != BlockFrames) + { + Console.Error.WriteLine($"[status] unexpected callback frameCount={frameCount} (want {BlockFrames})"); + return StreamCallbackResult.Continue; + } + + var frame = new short[BlockFrames]; + unsafe + { + short* src = (short*)input.ToPointer(); + fixed (short* dst = &frame[0]) + Buffer.MemoryCopy(src, dst, BlockFrames * sizeof(short), BlockFrames * sizeof(short)); + } + if (!queue.TryAdd(frame, 0)) + Console.Error.WriteLine("[status] consumer behind, dropping frame"); + return StreamCallbackResult.Continue; + }; + + using var stream = new Stream( + inParams, null, SampleRate, BlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero); + stream.Start(); + short[] beepBuffer = MakeBeep(BeepHz, BeepSeconds, SampleRate); + Console.WriteLine("Listening. Say 'alexa'. Ctrl-C to exit."); + + var sw = Stopwatch.StartNew(); + TimeSpan lastTrigger = TimeSpan.FromSeconds(-CooldownSeconds); + + // State machine (single-writer: this consumer thread). + State state = State.Idle; + int beepFrames = 0; + int recordOffset = 0; + short[] recordBuf = null!; // assigned on BEEPING→RECORDING transition + var playbackDone = new PlaybackDoneFlag(); + + try + { + while (!cts.IsCancellationRequested) + { + short[] frame = queue.Take(cts.Token); + + switch (state) + { + case State.Idle: + { + float score = model.Predict(frame); + var now = sw.Elapsed; + if (score >= Threshold && (now - lastTrigger).TotalSeconds >= CooldownSeconds) + { + Console.WriteLine($"DETECTED alexa score={score:0.000} t={now.TotalSeconds:0.0}s"); + lastTrigger = now; + try + { + FireAndForgetBeep(device, beepBuffer); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[error] beep stream failed to start: {ex.Message}"); + // Stay in IDLE — no cycle to abort yet. + break; + } + Console.WriteLine("[state] BEEPING"); + state = State.Beeping; + beepFrames = 0; + } + break; + } + + case State.Beeping: + { + beepFrames++; + if (beepFrames >= BeepingFrames) + { + Console.WriteLine("[state] RECORDING"); + state = State.Recording; + recordBuf = new short[RecordBufSamples]; + recordOffset = 0; + } + break; + } + + case State.Recording: + { + unsafe + { + fixed (short* src = &frame[0]) + fixed (short* dst = &recordBuf[recordOffset]) + Buffer.MemoryCopy(src, dst, BlockFrames * sizeof(short), BlockFrames * sizeof(short)); + } + recordOffset += (int)BlockFrames; + if (recordOffset >= RecordBufSamples) + { + try + { + FireAndForgetPlayback(device, recordBuf, playbackDone); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[error] playback stream failed to start: {ex.Message}"); + // Drop the recording, reset model, return to IDLE. + model.Reset(); + Console.WriteLine("[state] IDLE"); + state = State.Idle; + break; + } + Console.WriteLine("[state] PLAYBACK"); + state = State.Playback; + } + break; + } + + case State.Playback: + { + // Discard input during playback (gate the detector). + if (playbackDone.Consume()) + { + model.Reset(); + Console.WriteLine("[state] IDLE"); + state = State.Idle; + } + break; + } + } + } + } + catch (OperationCanceledException) { /* Ctrl-C path */ } + + stream.Stop(); + Console.WriteLine("\nbye"); +} +finally +{ + model?.Dispose(); + PortAudio.Terminate(); +} + +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 void FireAndForgetBeep(int device, short[] beepBuffer) +{ + int offset = 0; + var done = new ManualResetEventSlim(false); + + var outParams = new StreamParameters + { + device = device, + channelCount = 1, + sampleFormat = SampleFormat.Int16, + suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero, + }; + + Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount, + ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) => + { + int remaining = beepBuffer.Length - offset; + int take = (int)Math.Min(frameCount, (uint)remaining); + unsafe + { + short* dst = (short*)output.ToPointer(); + if (take > 0) + { + fixed (short* src = &beepBuffer[offset]) + Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short)); + offset += take; + } + for (int i = take; i < frameCount; i++) dst[i] = 0; + } + if (offset >= beepBuffer.Length) + { + done.Set(); + return StreamCallbackResult.Complete; + } + return StreamCallbackResult.Continue; + }; + + var stream = new Stream( + null, outParams, WakewordModel.SampleRate, 1024, StreamFlags.NoFlag, playCb, IntPtr.Zero); + try + { + stream.Start(); + } + catch + { + stream.Dispose(); + done.Dispose(); + throw; + } + Task.Run(() => + { + // 2 s timeout so a failed callback (which PortAudio silently swallows + // and would leave 'done' unset) eventually releases the stream + handle + // instead of leaking them for the process lifetime. + done.Wait(TimeSpan.FromSeconds(2)); + Thread.Sleep(200); + stream.Stop(); + stream.Dispose(); + done.Dispose(); + }); +} + +static void FireAndForgetPlayback(int device, short[] recordBuf, PlaybackDoneFlag doneFlag) +{ + int offset = 0; + var done = new ManualResetEventSlim(false); + + var outParams = new StreamParameters + { + device = device, + channelCount = 1, + sampleFormat = SampleFormat.Int16, + suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero, + }; + + Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount, + ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) => + { + int remaining = recordBuf.Length - offset; + int take = (int)Math.Min(frameCount, (uint)remaining); + unsafe + { + short* dst = (short*)output.ToPointer(); + if (take > 0) + { + fixed (short* src = &recordBuf[offset]) + Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short)); + offset += take; + } + for (int i = take; i < frameCount; i++) dst[i] = 0; + } + if (offset >= recordBuf.Length) + { + done.Set(); + return StreamCallbackResult.Complete; + } + return StreamCallbackResult.Continue; + }; + + var stream = new Stream( + null, outParams, WakewordModel.SampleRate, 1024, StreamFlags.NoFlag, playCb, IntPtr.Zero); + try + { + stream.Start(); + } + catch + { + stream.Dispose(); + done.Dispose(); + throw; + } + Task.Run(() => + { + // Same 2 s safety timeout as the beep cleanup. + done.Wait(TimeSpan.FromSeconds(2)); + Thread.Sleep(200); + stream.Stop(); + stream.Dispose(); + done.Dispose(); + doneFlag.Set(); // tell the consumer it's safe to flip PLAYBACK→IDLE + }); +} + +enum State { Idle, Beeping, Recording, Playback } + +// Volatile flag set by the playback cleanup task and consumed by the consumer thread. +// Wrapped in a tiny class so we can pass it by reference into the helper. +sealed class PlaybackDoneFlag +{ + private volatile bool _set; + public void Set() => _set = true; + public bool Consume() + { + if (!_set) return false; + _set = false; + return true; + } +} + +static class Libc +{ + [DllImport("libc", EntryPoint = "setenv")] + public static extern int setenv(string name, string value, int overwrite); +}