From 2e830eaed48404223b802bdf521ec507fe3b16ee Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Fri, 12 Jun 2026 11:57:54 +0000 Subject: [PATCH] Live wakeword loop: PortAudio input + queue + threshold detection Replaces the Task 3 smoke-test Program.cs with the full inference loop: - PA_ALSA_PLUGHW=1 and ORT_LOGGING_LEVEL=3 via both Environment.SetEnvironmentVariable and Libc.setenv (P/Invoke) per the Test 1 findings.md env-var gotcha. - OrtEnv.CreateInstanceWithOptions with ORT_LOGGING_LEVEL_ERROR to suppress early GPU-discovery warnings from device_discovery.cc in ORT 1.26.0 (the env var alone does not silence these; CreateInstanceWithOptions does). - Callback-driven PortAudio input stream at 16 kHz / Int16 / 1280 frames/block. - BlockingCollection(16) queue; per-call new short[1280] allocation (no races). - Main loop: Take() -> WakewordModel.Predict() -> threshold >= 0.5 with 1.0s cooldown -> DETECTED alexa score=... t=...s. No beep (Task 5 adds it). - Console.CancelKeyPress -> cts.Cancel() -> graceful stream.Stop() + model.Dispose() + PortAudio.Terminate(). --- tests/02-wakeword-cs/Program.cs | 149 ++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 17 deletions(-) diff --git a/tests/02-wakeword-cs/Program.cs b/tests/02-wakeword-cs/Program.cs index 94b47af..c93f38f 100644 --- a/tests/02-wakeword-cs/Program.cs +++ b/tests/02-wakeword-cs/Program.cs @@ -1,22 +1,137 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.ML.OnnxRuntime; +using PortAudioSharp; using WakewordProbe; +using Stream = PortAudioSharp.Stream; -string modelsDir = Path.Combine(AppContext.BaseDirectory, "models"); -Console.WriteLine("Loading WakewordModel..."); -var t0 = DateTime.UtcNow; -using var 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."); +const int SampleRate = WakewordModel.SampleRate; +const int Channels = 1; +const uint BlockFrames = WakewordModel.FrameSamples; // 1280 +const float Threshold = 0.5f; +const double CooldownSeconds = 1.0; -// Smoke test: feed 50 frames of silence (1280 zero samples each). -// Expect every score to be 0f (warmup gate + no signal). -var silentFrame = new short[WakewordModel.FrameSamples]; -int nonZero = 0; -for (int i = 0; i < 50; i++) +// .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 = "WakewordProbe", logLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR }; +OrtEnv.CreateInstanceWithOptions(ref ortEnvOpts); + +PortAudio.Initialize(); +WakewordModel? model = null; +try { - float s = model.Predict(silentFrame); - if (s != 0f) nonZero++; + 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(); + Console.WriteLine("Listening. Say 'alexa'. Ctrl-C to exit."); + + var sw = Stopwatch.StartNew(); + TimeSpan lastTrigger = TimeSpan.FromSeconds(-CooldownSeconds); + + try + { + while (!cts.IsCancellationRequested) + { + short[] frame = queue.Take(cts.Token); + 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; + // Beep is added in Task 5. + } + } + } + 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 class Libc +{ + [DllImport("libc", EntryPoint = "setenv")] + public static extern int setenv(string name, string value, int overwrite); } -Console.WriteLine($"Silence test: {nonZero}/50 non-zero scores (expected: 0 — but small drift is OK)."); -Console.WriteLine("Predict pipeline ran without throwing.");