From a07bb2c8cfe0e279681e8f1d1d95f61beeb489c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 07:52:14 +0100 Subject: [PATCH] Add ASIO signal inspector --- src/Mimir.BufferSmoke/Program.cs | 117 ++++++++++++++++++ .../MimirAudioSpectrumAnalyzer.cs | 40 +++++- state/evidence.jsonl | 1 + 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/Mimir.BufferSmoke/Program.cs b/src/Mimir.BufferSmoke/Program.cs index 9c854e2..fe3617b 100644 --- a/src/Mimir.BufferSmoke/Program.cs +++ b/src/Mimir.BufferSmoke/Program.cs @@ -171,6 +171,14 @@ if (args.Any(arg => string.Equals(arg, "--analyze-asio-f32", StringComparison.Or ParseStringOption(args, "--calibration", "")); } +if (args.Any(arg => string.Equals(arg, "--inspect-asio-f32", StringComparison.OrdinalIgnoreCase))) +{ + return InspectAsioFloat32( + ParseStringOption(args, "--input", "artifacts/asio/scarlett-channel-id-f32.raw"), + ParseIntOption(args, "--sample-rate", MimirBioacousticTimeline.SampleRate), + ParseIntOption(args, "--channels", 4)); +} + if (args.Any(arg => string.Equals(arg, "--analyze-contestant-asio-f32", StringComparison.OrdinalIgnoreCase))) { return AnalyzeContestantAsioFloat32( @@ -1798,6 +1806,39 @@ static int AnalyzeContestantAsioFloat32( return failures == 0 ? 0 : 1; } +static int InspectAsioFloat32(string inputPath, int sampleRate, int channels) +{ + if (!File.Exists(inputPath)) + { + Console.Error.WriteLine($"asio-f32 inspect failed: input not found: {inputPath}"); + return 1; + } + + if (channels <= 0 || sampleRate <= 0) + { + Console.Error.WriteLine("asio-f32 inspect failed: sample rate and channel count must be positive"); + return 1; + } + + var channelSamples = ReadInterleavedFloat32(inputPath, channels, out var frameCount); + var analyzer = new MimirAudioSpectrumAnalyzer(8192, 48); + Console.WriteLine($"asio-f32-inspect input={inputPath} sampleRate={sampleRate} channels={channels} frames={frameCount} seconds={frameCount / (double)sampleRate:0.000}"); + for (var channel = 0; channel < channels; channel++) + { + var samples = channelSamples[channel]; + var full = SignalStats(samples); + var speech = BandLimitedStats(samples, sampleRate, 80.0, 8000.0); + var ultrasonic = BandLimitedStats(samples, sampleRate, 16000.0, 48000.0); + var spectrum = analyzer.AnalyzeSamples($"asio-ch{channel}", samples, sampleRate); + Console.WriteLine( + $"asio-f32-channel ch={channel} rms={full.Rms:0.000000} peak={full.Peak:0.000000} dc={full.Mean:0.000000} " + + $"speechRms={speech.Rms:0.000000} speechPeak={speech.Peak:0.000000} ultrasonicRms={ultrasonic.Rms:0.000000} " + + $"peaks={DescribeSpectrumPeaks(spectrum)}"); + } + + return 0; +} + static int AnalyzeComplexContourAsioFloat32( string inputPath, int sampleRate, @@ -2708,6 +2749,80 @@ static float[][] ReadInterleavedFloat32(string inputPath, int channels, out int return channelSamples; } +static ChannelSignalStats SignalStats(ReadOnlySpan samples) +{ + if (samples.Length == 0) + { + return new ChannelSignalStats(0.0, 0.0, 0.0); + } + + var sum = 0.0; + var sumSquares = 0.0; + var peak = 0.0; + foreach (var sample in samples) + { + sum += sample; + sumSquares += sample * sample; + peak = Math.Max(peak, Math.Abs(sample)); + } + + return new ChannelSignalStats( + Math.Sqrt(sumSquares / samples.Length), + peak, + sum / samples.Length); +} + +static ChannelSignalStats BandLimitedStats(ReadOnlySpan samples, int sampleRate, double lowHz, double highHz) +{ + if (samples.Length == 0 || sampleRate <= 0 || highHz <= lowHz) + { + return new ChannelSignalStats(0.0, 0.0, 0.0); + } + + var highPassed = new float[samples.Length]; + var lowCut = Math.Clamp(lowHz, 1.0, sampleRate * 0.45); + var highCut = Math.Clamp(highHz, lowCut + 1.0, sampleRate * 0.49); + var dt = 1.0 / sampleRate; + var hpRc = 1.0 / (2.0 * Math.PI * lowCut); + var hpAlpha = hpRc / (hpRc + dt); + var previousInput = (double)samples[0]; + var previousOutput = 0.0; + for (var index = 0; index < samples.Length; index++) + { + var input = samples[index]; + var output = hpAlpha * (previousOutput + input - previousInput); + highPassed[index] = (float)output; + previousInput = input; + previousOutput = output; + } + + var lpRc = 1.0 / (2.0 * Math.PI * highCut); + var lpAlpha = dt / (lpRc + dt); + var lowPassed = 0.0; + var sum = 0.0; + var sumSquares = 0.0; + var peak = 0.0; + foreach (var sample in highPassed) + { + lowPassed += lpAlpha * (sample - lowPassed); + sum += lowPassed; + sumSquares += lowPassed * lowPassed; + peak = Math.Max(peak, Math.Abs(lowPassed)); + } + + return new ChannelSignalStats( + Math.Sqrt(sumSquares / samples.Length), + peak, + sum / samples.Length); +} + +static string DescribeSpectrumPeaks(MimirAudioSpectrumSnapshot? spectrum) +{ + return spectrum == null || spectrum.Peaks.Count == 0 + ? "none" + : string.Join(",", spectrum.Peaks.Select(peak => $"{peak.FrequencyHz / 1000.0:0.00}kHz/{peak.Decibels:0.0}dB")); +} + static float[] RoundTripThroughDegradedCepstrum( float[] source, int sampleRate, @@ -5018,6 +5133,8 @@ internal sealed class DisposableConfiguration : IDisposable } } +internal readonly record struct ChannelSignalStats(double Rms, double Peak, double Mean); + internal sealed record CepstralDegradationSetting( string Name, double WarpFrames, diff --git a/src/Mimir.Runtime/Synchronization/MimirAudioSpectrumAnalyzer.cs b/src/Mimir.Runtime/Synchronization/MimirAudioSpectrumAnalyzer.cs index 407d933..2e6b4c7 100644 --- a/src/Mimir.Runtime/Synchronization/MimirAudioSpectrumAnalyzer.cs +++ b/src/Mimir.Runtime/Synchronization/MimirAudioSpectrumAnalyzer.cs @@ -67,6 +67,27 @@ public sealed class MimirAudioSpectrumAnalyzer return snapshots; } + public MimirAudioSpectrumSnapshot? AnalyzeSamples( + string sourceId, + ReadOnlySpan samples, + int sampleRate) + { + if (samples.Length < MinimumFftSize / 2 || sampleRate <= 0) + { + return null; + } + + Array.Clear(spectrum); + var written = Math.Min(samples.Length, fftSize); + var window = samples[^written..]; + for (var index = 0; index < written; index++) + { + spectrum[index] = new Complex(window[index], 0.0); + } + + return AnalyzePreparedSpectrum(sourceId, sampleRate, written, edgeNs: 0); + } + private MimirAudioSpectrumSnapshot? AnalyzeBuffer(MimirRollingStreamBuffer buffer) { var latestBlock = buffer.Latest?.AudioBlock; @@ -82,6 +103,15 @@ public sealed class MimirAudioSpectrumAnalyzer return null; } + return AnalyzePreparedSpectrum(buffer.Descriptor.SourceId, latestBlock.SampleRate, written, buffer.EdgeNs); + } + + private MimirAudioSpectrumSnapshot AnalyzePreparedSpectrum( + string sourceId, + int sampleRate, + int written, + long edgeNs) + { var rms = 0.0; var peak = 0.0; for (var index = 0; index < written; index++) @@ -101,12 +131,12 @@ public sealed class MimirAudioSpectrumAnalyzer magnitudes[index] = spectrum[index].Magnitude / Math.Max(1, written); } - var bands = BuildLogBands(magnitudes, latestBlock.SampleRate); - var peaks = FindPeaks(magnitudes, latestBlock.SampleRate, count: 6); + var bands = BuildLogBands(magnitudes, sampleRate); + var peaks = FindPeaks(magnitudes, sampleRate, count: 6); var floor = bands.Count == 0 ? -120.0 : bands.OrderBy(value => value).ElementAt(Math.Clamp(bands.Count / 4, 0, bands.Count - 1)); return new MimirAudioSpectrumSnapshot( - buffer.Descriptor.SourceId, - latestBlock.SampleRate, + sourceId, + sampleRate, fftSize, written, rms, @@ -114,7 +144,7 @@ public sealed class MimirAudioSpectrumAnalyzer floor, peaks, bands, - buffer.EdgeNs); + edgeNs); } private int FillLatestMonoWindow( diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d541879..df6952c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -111,3 +111,4 @@ {"ts": "2026-05-25T06:59:00+01:00", "type": "correction", "status": "invalidated", "note": "Invalidated the apparent cardioid timing proof in `calibration/bioacoustic/complex-contour-live-20260525-063229.md`. The log-mel spectrogram and channel statistics show `asio-ch0` is not a real acoustic signal in that capture: RMS is almost flat (`0.00208350` to `0.00209815` by 0.1s block), peak is only `0.00334287`, and zero-lag correlation with loopback is `0.000032`. The near-seed +1.547 us contour estimate was a seeded tracker fit against noise, not physical mic evidence. The receipt remains valid as a loopback/interface proof (`asio-ch2` -> `asio-ch3` at -0.014 us, confidence 0.971) plus a weak/non-authoritative input witness."} {"ts": "2026-05-25T07:05:34+01:00", "type": "implementation", "status": "ok", "note": "Added runtime FFT spectra and Fensalir readiness gating. Fensalir now calls IAquariumRuntime.OnSceneReady after the first presented ready frame; Mimir waits for that callback before emitting bioacoustic test audio, running sync analysis, or updating the cached spectrum diagnostics. MimirAudioSpectrumAnalyzer computes bounded Hann-windowed radix-2 FFT snapshots from rolling audio buffers, exposes top peaks/log bands/RMS/peak, and Mimir renders cached spectra in the DirectWrite UI plus telemetry. Verification run with ASIO config showed scene-ready before bioacoustic chunks and live 8192-point spectra for asio-ch0 through asio-ch3; asio-ch0 remains flat low-level noise while loopback/mic-bearing channels show song-band peaks."} {"ts": "2026-05-25T07:11:48+01:00", "type": "correction", "status": "ok", "note": "Tightened the Fensalir scene-ready gate after runtime spectra testing showed the app still created sources and emitted telemetry before the render path was ready. Runtime config now stores lazy stream-source factories; Mimir.App creates/registers ASIO/process/native sources only inside OnSceneReady, MimirRuntime.Update returns without polling, spectra, sync, telemetry, or audio emission until that callback, and Fensalir constructs the WASAPI/AquaSynth host only after the first presented ready frame. Verification log artifacts/runtime/mimir-ready-gate-20260525-071121.out.log shows no ASIO buffer polling, spectrum telemetry, or bioacoustic batch before D3D12 shader pipelines ready and Mimir Fensalir scene ready."} +{"ts": "2026-05-25T07:51:20+01:00", "type": "verification", "status": "ok", "note": "Audacity WASAPI speech spectrogram corresponds to live physical mic input, not the previously invalidated ASIO noise lane. Focusrite ASIO channel enumeration reports input[0]=Input 1, input[1]=Input 2, input[2]=Loopback 1, input[3]=Loopback 2. A fresh 5s raw ASIO capture artifacts/asio/scarlett-channel-id-20260525-075058-f32.raw inspected with --inspect-asio-f32 shows ch1/Input 2 carrying real speech-band energy (rms 0.009809, peak 0.069316, speechRms 0.007206), ch0/Input 1 at low floor (rms 0.002092), and loopbacks ch2/ch3 silent with no playback. Treat Audacity channel 2 as one-based physical Input 2 => Mimir asio-ch1; do not interpret it as loopback asio-ch2."}