070cc0767a
Stale Set() from a delayed cleanup task could short-circuit a future PLAYBACK state; new flag per cycle avoids the cross-cycle leak. Also log when the 2 s done.Wait timeout fires so silent PortAudio callback faults are visible during hardware debugging.
384 lines
13 KiB
C#
384 lines
13 KiB
C#
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<short[]>(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
|
|
PlaybackDoneFlag? playbackDone = null;
|
|
|
|
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
|
|
{
|
|
playbackDone = new PlaybackDoneFlag();
|
|
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.
|
|
if (!done.Wait(TimeSpan.FromSeconds(2)))
|
|
Console.Error.WriteLine("[error] beep playback timed out — callback may have faulted");
|
|
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.
|
|
if (!done.Wait(TimeSpan.FromSeconds(2)))
|
|
Console.Error.WriteLine("[error] recording playback timed out — callback may have faulted");
|
|
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);
|
|
}
|