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); }