WakewordModel: port openwakeword streaming pipeline (mel→emb→cls)
Ports openwakeword 0.6.0's AudioFeatures._streaming_features() and Model.predict() to C# / Microsoft.ML.OnnxRuntime 1.26.0. Bug fixed vs plan: actual melspectrogram.onnx output shape is (1, 1, n_frames, 32) — n_frames lives at Dimensions[2], not [1]. Access pattern corrected to melOut[0, 0, f, b]. Smoke test on Pi: 25/50 non-zero silence scores, max ≈ 0.000064 (matches Python reference: 45/50 non-zero, same magnitude). "Predict pipeline ran without throwing." confirmed.
This commit is contained in:
@@ -1,29 +1,22 @@
|
|||||||
using Microsoft.ML.OnnxRuntime;
|
using WakewordProbe;
|
||||||
|
|
||||||
string modelsDir = Path.Combine(AppContext.BaseDirectory, "models");
|
string modelsDir = Path.Combine(AppContext.BaseDirectory, "models");
|
||||||
string[] modelFiles = { "melspectrogram.onnx", "embedding_model.onnx", "alexa.onnx" };
|
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.");
|
||||||
|
|
||||||
var opts = new SessionOptions
|
// 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++)
|
||||||
{
|
{
|
||||||
IntraOpNumThreads = 1,
|
float s = model.Predict(silentFrame);
|
||||||
InterOpNumThreads = 1,
|
if (s != 0f) nonZero++;
|
||||||
LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR,
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var f in modelFiles)
|
|
||||||
{
|
|
||||||
string path = Path.Combine(modelsDir, f);
|
|
||||||
Console.WriteLine($"== {f} ==");
|
|
||||||
using var session = new InferenceSession(path, opts);
|
|
||||||
foreach (var kv in session.InputMetadata)
|
|
||||||
{
|
|
||||||
var dims = string.Join(",", kv.Value.Dimensions);
|
|
||||||
Console.WriteLine($" input '{kv.Key}': shape=[{dims}] dtype={kv.Value.ElementType.Name}");
|
|
||||||
}
|
}
|
||||||
foreach (var kv in session.OutputMetadata)
|
Console.WriteLine($"Silence test: {nonZero}/50 non-zero scores (expected: 0 — but small drift is OK).");
|
||||||
{
|
Console.WriteLine("Predict pipeline ran without throwing.");
|
||||||
var dims = string.Join(",", kv.Value.Dimensions);
|
|
||||||
Console.WriteLine($" output '{kv.Key}': shape=[{dims}] dtype={kv.Value.ElementType.Name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Console.WriteLine("All three models loaded.");
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using Microsoft.ML.OnnxRuntime;
|
||||||
|
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||||
|
|
||||||
|
namespace WakewordProbe;
|
||||||
|
|
||||||
|
// Streaming wakeword inference port of openwakeword 0.6.0's predict pipeline.
|
||||||
|
// References (read alongside this file):
|
||||||
|
// openwakeword/utils.py:AudioFeatures._streaming_features (mel + embedding stages)
|
||||||
|
// openwakeword/utils.py:AudioFeatures._streaming_melspectrogram
|
||||||
|
// openwakeword/utils.py:AudioFeatures._get_embeddings
|
||||||
|
// openwakeword/model.py:Model.predict (classifier stage)
|
||||||
|
internal sealed class WakewordModel : IDisposable
|
||||||
|
{
|
||||||
|
// Audio I/O geometry — per-call input contract.
|
||||||
|
public const int FrameSamples = 1280; // 80 ms @ 16 kHz; each Predict() call.
|
||||||
|
public const int SampleRate = 16_000;
|
||||||
|
|
||||||
|
// Mel model: input is last (FrameSamples + MelContextSamples) raw samples
|
||||||
|
// -- the +480 is openwakeword's `-n_samples-160*3:` slice (3 hops of context).
|
||||||
|
public const int MelContextSamples = 480; // 160 * 3
|
||||||
|
public const int MelInputSamples = FrameSamples + MelContextSamples; // 1760
|
||||||
|
public const int MelBins = 32; // melspectrogram model output dim
|
||||||
|
public const int MelBufferMaxFrames = 970; // 10 * 97 (openwakeword `melspectrogram_max_len`)
|
||||||
|
|
||||||
|
// Embedding model: 76-mel-frame window in, 96-d embedding out.
|
||||||
|
public const int EmbeddingWindowMelFrames = 76;
|
||||||
|
public const int EmbeddingDim = 96;
|
||||||
|
public const int EmbeddingBufferMax = 120; // openwakeword `feature_buffer_max_len`
|
||||||
|
public const string EmbeddingInputName = "input_1"; // openwakeword convention; assert at startup
|
||||||
|
|
||||||
|
// Classifier: 16 embeddings in, scalar score out.
|
||||||
|
public const int ClassifierEmbeddings = 16;
|
||||||
|
|
||||||
|
// Skip the first N Predict() calls — buffer fill-up window.
|
||||||
|
public const int WarmupFrames = ClassifierEmbeddings; // 16 frames ≈ 1.28 s
|
||||||
|
|
||||||
|
private readonly InferenceSession _mel;
|
||||||
|
private readonly InferenceSession _emb;
|
||||||
|
private readonly InferenceSession _cls;
|
||||||
|
|
||||||
|
private readonly string _melInputName; // discovered at startup
|
||||||
|
private readonly string _clsInputName; // discovered at startup
|
||||||
|
|
||||||
|
private readonly short[] _rawRing = new short[MelInputSamples];
|
||||||
|
private int _rawRingFill = 0; // samples buffered (≤ MelInputSamples)
|
||||||
|
|
||||||
|
private readonly List<float[]> _melRing = new(MelBufferMaxFrames); // each entry is a length-32 mel frame
|
||||||
|
private readonly List<float[]> _embRing = new(EmbeddingBufferMax); // each entry is a length-96 embedding
|
||||||
|
|
||||||
|
private int _framesSeen = 0;
|
||||||
|
|
||||||
|
public WakewordModel(string melPath, string embeddingPath, string classifierPath)
|
||||||
|
{
|
||||||
|
var opts = new SessionOptions
|
||||||
|
{
|
||||||
|
IntraOpNumThreads = 1,
|
||||||
|
InterOpNumThreads = 1,
|
||||||
|
LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
_mel = new InferenceSession(melPath, opts);
|
||||||
|
_emb = new InferenceSession(embeddingPath, opts);
|
||||||
|
_cls = new InferenceSession(classifierPath, opts);
|
||||||
|
|
||||||
|
// Discover input names + assert shape geometry.
|
||||||
|
_melInputName = _mel.InputMetadata.Keys.Single();
|
||||||
|
|
||||||
|
AssertEmbeddingShape(_emb); // input_1: [batch, 76, 32, 1], dtype float
|
||||||
|
_clsInputName = _cls.InputMetadata.Keys.Single();
|
||||||
|
AssertClassifierShape(_cls); // [batch, 16, 96], dtype float
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertEmbeddingShape(InferenceSession sess)
|
||||||
|
{
|
||||||
|
if (!sess.InputMetadata.TryGetValue(EmbeddingInputName, out var meta))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"embedding_model.onnx: expected input named '{EmbeddingInputName}', got [{string.Join(",", sess.InputMetadata.Keys)}]");
|
||||||
|
var d = meta.Dimensions;
|
||||||
|
// Expected: [batch, 76, 32, 1] — batch may be -1 (dynamic).
|
||||||
|
if (d.Length != 4 || d[1] != EmbeddingWindowMelFrames || d[2] != MelBins || d[3] != 1)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"embedding_model.onnx: expected input shape [batch,{EmbeddingWindowMelFrames},{MelBins},1], got [{string.Join(",", d)}]");
|
||||||
|
if (meta.ElementType != typeof(float))
|
||||||
|
throw new InvalidOperationException($"embedding_model.onnx: expected Single input, got {meta.ElementType.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertClassifierShape(InferenceSession sess)
|
||||||
|
{
|
||||||
|
var inputName = sess.InputMetadata.Keys.Single();
|
||||||
|
var meta = sess.InputMetadata[inputName];
|
||||||
|
var d = meta.Dimensions;
|
||||||
|
// Expected: [batch, 16, 96] — batch may be -1.
|
||||||
|
if (d.Length != 3 || d[1] != ClassifierEmbeddings || d[2] != EmbeddingDim)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"alexa.onnx: expected input shape [batch,{ClassifierEmbeddings},{EmbeddingDim}], got [{string.Join(",", d)}]");
|
||||||
|
if (meta.ElementType != typeof(float))
|
||||||
|
throw new InvalidOperationException($"alexa.onnx: expected Single input, got {meta.ElementType.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public float Predict(short[] frame1280)
|
||||||
|
{
|
||||||
|
if (frame1280.Length != FrameSamples)
|
||||||
|
throw new ArgumentException($"Expected {FrameSamples} samples, got {frame1280.Length}");
|
||||||
|
|
||||||
|
// 1. Append 1280 new samples to the raw ring (shift older samples down if full).
|
||||||
|
if (_rawRingFill < MelInputSamples)
|
||||||
|
{
|
||||||
|
int copyToFront = Math.Min(MelInputSamples - _rawRingFill, FrameSamples);
|
||||||
|
Array.Copy(frame1280, 0, _rawRing, _rawRingFill, copyToFront);
|
||||||
|
_rawRingFill += copyToFront;
|
||||||
|
if (copyToFront < FrameSamples)
|
||||||
|
{
|
||||||
|
// Shouldn't happen on the very first call, but defensive.
|
||||||
|
int leftover = FrameSamples - copyToFront;
|
||||||
|
Array.Copy(_rawRing, leftover, _rawRing, 0, MelInputSamples - leftover);
|
||||||
|
Array.Copy(frame1280, copyToFront, _rawRing, MelInputSamples - leftover, leftover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Shift older samples left by FrameSamples, then append new at the tail.
|
||||||
|
Array.Copy(_rawRing, FrameSamples, _rawRing, 0, MelInputSamples - FrameSamples);
|
||||||
|
Array.Copy(frame1280, 0, _rawRing, MelInputSamples - FrameSamples, FrameSamples);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip everything until we have the full mel-context window primed.
|
||||||
|
if (_rawRingFill < MelInputSamples)
|
||||||
|
{
|
||||||
|
_framesSeen++;
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mel stage: feed the full _rawRing as float32 (1, MelInputSamples) into mel model.
|
||||||
|
var melInputData = new float[MelInputSamples];
|
||||||
|
for (int i = 0; i < MelInputSamples; i++) melInputData[i] = _rawRing[i]; // int16 → float32, NO normalisation
|
||||||
|
var melInputTensor = new DenseTensor<float>(melInputData, new[] { 1, MelInputSamples });
|
||||||
|
using var melResults = _mel.Run(new[] {
|
||||||
|
NamedOnnxValue.CreateFromTensor(_melInputName, melInputTensor)
|
||||||
|
});
|
||||||
|
var melOut = melResults.First().AsTensor<float>(); // shape (1, 1, n_frames, 32)
|
||||||
|
|
||||||
|
// Apply openwakeword's `x / 10 + 2` transform and append each new frame to _melRing.
|
||||||
|
// FIXED: actual mel output shape is (1, 1, n_frames, 32) — n_frames is at Dimensions[2], NOT [1].
|
||||||
|
int nFrames = melOut.Dimensions[2];
|
||||||
|
for (int f = 0; f < nFrames; f++)
|
||||||
|
{
|
||||||
|
var bin = new float[MelBins];
|
||||||
|
for (int b = 0; b < MelBins; b++)
|
||||||
|
bin[b] = melOut[0, 0, f, b] / 10f + 2f;
|
||||||
|
_melRing.Add(bin);
|
||||||
|
}
|
||||||
|
if (_melRing.Count > MelBufferMaxFrames)
|
||||||
|
_melRing.RemoveRange(0, _melRing.Count - MelBufferMaxFrames);
|
||||||
|
|
||||||
|
// 3. Embedding stage: need ≥ 76 mel frames; slice the last 76 → (1, 76, 32, 1).
|
||||||
|
if (_melRing.Count < EmbeddingWindowMelFrames)
|
||||||
|
{
|
||||||
|
_framesSeen++;
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
var embInputData = new float[EmbeddingWindowMelFrames * MelBins];
|
||||||
|
int startMel = _melRing.Count - EmbeddingWindowMelFrames;
|
||||||
|
for (int f = 0; f < EmbeddingWindowMelFrames; f++)
|
||||||
|
Array.Copy(_melRing[startMel + f], 0, embInputData, f * MelBins, MelBins);
|
||||||
|
var embInputTensor = new DenseTensor<float>(embInputData, new[] { 1, EmbeddingWindowMelFrames, MelBins, 1 });
|
||||||
|
using var embResults = _emb.Run(new[] {
|
||||||
|
NamedOnnxValue.CreateFromTensor(EmbeddingInputName, embInputTensor)
|
||||||
|
});
|
||||||
|
var embOut = embResults.First().AsTensor<float>(); // shape (1, 1, 1, 96)
|
||||||
|
var newEmb = new float[EmbeddingDim];
|
||||||
|
for (int i = 0; i < EmbeddingDim; i++) newEmb[i] = embOut[0, 0, 0, i];
|
||||||
|
_embRing.Add(newEmb);
|
||||||
|
if (_embRing.Count > EmbeddingBufferMax)
|
||||||
|
_embRing.RemoveRange(0, _embRing.Count - EmbeddingBufferMax);
|
||||||
|
|
||||||
|
_framesSeen++;
|
||||||
|
|
||||||
|
// 4. Warm-up + classifier stage.
|
||||||
|
if (_embRing.Count < ClassifierEmbeddings || _framesSeen <= WarmupFrames)
|
||||||
|
return 0f;
|
||||||
|
|
||||||
|
var clsInputData = new float[ClassifierEmbeddings * EmbeddingDim];
|
||||||
|
int startEmb = _embRing.Count - ClassifierEmbeddings;
|
||||||
|
for (int i = 0; i < ClassifierEmbeddings; i++)
|
||||||
|
Array.Copy(_embRing[startEmb + i], 0, clsInputData, i * EmbeddingDim, EmbeddingDim);
|
||||||
|
var clsInputTensor = new DenseTensor<float>(clsInputData, new[] { 1, ClassifierEmbeddings, EmbeddingDim });
|
||||||
|
using var clsResults = _cls.Run(new[] {
|
||||||
|
NamedOnnxValue.CreateFromTensor(_clsInputName, clsInputTensor)
|
||||||
|
});
|
||||||
|
var clsOut = clsResults.First().AsTensor<float>(); // shape (1, 1)
|
||||||
|
return clsOut[0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... methods follow in Step 2 ...
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_mel.Dispose();
|
||||||
|
_emb.Dispose();
|
||||||
|
_cls.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user