Add ASIO signal inspector
This commit is contained in:
parent
38fa830211
commit
a07bb2c8cf
3 changed files with 153 additions and 5 deletions
|
|
@ -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<float> 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<float> 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,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,27 @@ public sealed class MimirAudioSpectrumAnalyzer
|
|||
return snapshots;
|
||||
}
|
||||
|
||||
public MimirAudioSpectrumSnapshot? AnalyzeSamples(
|
||||
string sourceId,
|
||||
ReadOnlySpan<float> 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(
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue