Add bioacoustic physical calibration receipt
This commit is contained in:
parent
9b5a78aeb3
commit
45d7059375
6 changed files with 11473 additions and 0 deletions
10824
calibration/bioacoustic/scarlett-canary-packet-192k-rerun.json
Normal file
10824
calibration/bioacoustic/scarlett-canary-packet-192k-rerun.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -145,6 +145,17 @@ a named invariant that the native runtime cannot protect yet.
|
|||
with two reliable symbols. Acoustic robustness remains separate from the clean
|
||||
loopback timing proof, but failed timing windows now leave usable response
|
||||
evidence instead of silence.
|
||||
- `Mimir.BufferSmoke --calibrate-contestant-asio-f32` persists the active
|
||||
packet-song physical calibration model under `calibration/bioacoustic/`.
|
||||
It learns per-channel schedule offset, polarity, payload reliability,
|
||||
response-normalization bands, gain, confidence, and pairwise propagation
|
||||
delay from the same 192 kHz ASIO capture. The latest Scarlett receipt
|
||||
`calibration/bioacoustic/scarlett-canary-packet-192k-rerun.json` clears the
|
||||
current hot-loop budget at 10.7x realtime across four channels. Loopback
|
||||
channels decode 37/37 with 2.524 us MAE; the co-streamer shotgun decodes
|
||||
37/37 payload with 58.785 us MAE; the cardioid decodes 26/37 with
|
||||
90.558 us MAE. This is a real response/propagation-delay model, but not yet
|
||||
microsecond-accurate physical mic sync.
|
||||
- `config/mimir-runtime.asio.example.json` is the minimal continuous Scarlett
|
||||
runtime ingest proof. It loads `native/asio_capture` in process at 192 kHz
|
||||
and declares `asio-ch0` through `asio-ch3` as accepted audio sources. A
|
||||
|
|
|
|||
|
|
@ -191,6 +191,18 @@ artifact, but it leaves a concrete response profile: 14 frames, 12 anchors,
|
|||
0.865 clock confidence, and strongest bins around 4525, 4075, and 7225 Hz.
|
||||
Acoustic robustness is now the open problem, not clean loopback timing,
|
||||
standalone decoder shape, or basic response evidence.
|
||||
`Mimir.BufferSmoke --calibrate-contestant-asio-f32` now owns the active
|
||||
packet-song physical calibration receipt. It runs the canary packet decoder
|
||||
against interleaved ASIO Float32 captures, performs a fast per-channel global
|
||||
delay hypothesis search, then tight scheduled packet scoring with sub-sample
|
||||
refinement. The latest persisted model at
|
||||
`calibration/bioacoustic/scarlett-canary-packet-192k-rerun.json` ran at 10.7x
|
||||
realtime across four 192 kHz channels and records per-channel polarity,
|
||||
schedule offset, payload reliability, gain, response-normalization bands, and
|
||||
pairwise propagation delay. Current physical precision is loopback 2.524 us
|
||||
MAE, co-streamer shotgun 58.785 us MAE, and cardioid 90.558 us MAE; do not
|
||||
claim physical microsecond sync until those mic MAEs collapse by another order
|
||||
of magnitude.
|
||||
The first actuator proof now exists: `faust/mimir_alignment_actuator.dsp` owns
|
||||
six channels of bounded fractional delay/gain controls for Faust/native DSP,
|
||||
and `Mimir.BufferSmoke --bioacoustic-actuator-self-test --sample-rate 48000
|
||||
|
|
|
|||
|
|
@ -168,6 +168,22 @@ if (args.Any(arg => string.Equals(arg, "--analyze-contestant-asio-f32", StringCo
|
|||
ParseStringOption(args, "--song", MimirBioacousticContestants.CanaryPacketTrill.Id));
|
||||
}
|
||||
|
||||
if (args.Any(arg => string.Equals(arg, "--calibrate-contestant-asio-f32", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return CalibrateContestantAsioFloat32(
|
||||
ParseStringOption(args, "--input", "artifacts/asio/scarlett-canary-packet-f32.raw"),
|
||||
ParseStringOption(args, "--output", "calibration/bioacoustic/latest.json"),
|
||||
ParseIntOption(args, "--sample-rate", MimirBioacousticTimeline.SampleRate),
|
||||
ParseIntOption(args, "--channels", 4),
|
||||
ParseIntOption(args, "--reference-channel", 2),
|
||||
ParseDoubleOption(args, "--seconds", 3.0),
|
||||
ParseDoubleOption(args, "--schedule-offset-samples", 0.0),
|
||||
ParseDoubleOption(args, "--search-radius-us", 180.0),
|
||||
ParseDoubleOption(args, "--delay-search-us", 6_000.0),
|
||||
ParseStringOption(args, "--song", MimirBioacousticContestants.CanaryPacketTrill.Id),
|
||||
ParseDoubleOption(args, "--min-realtime-factor", 10.0));
|
||||
}
|
||||
|
||||
if (args.Any(arg => string.Equals(arg, "--calibrate-chirp-bin-asio-f32", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return CalibrateChirpBinAsioFloat32(
|
||||
|
|
@ -1640,6 +1656,130 @@ static int AnalyzeContestantAsioFloat32(
|
|||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int CalibrateContestantAsioFloat32(
|
||||
string inputPath,
|
||||
string outputPath,
|
||||
int sampleRate,
|
||||
int channels,
|
||||
int referenceChannel,
|
||||
double seconds,
|
||||
double scheduleOffsetSamples,
|
||||
double searchRadiusUs,
|
||||
double delaySearchUs,
|
||||
string songId,
|
||||
double minRealtimeFactor)
|
||||
{
|
||||
if (!File.Exists(inputPath))
|
||||
{
|
||||
Console.Error.WriteLine($"contestant calibration failed: input not found: {inputPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var profile = MimirBioacousticContestants.BuiltIn.FirstOrDefault(profile =>
|
||||
string.Equals(profile.Id, songId, StringComparison.OrdinalIgnoreCase));
|
||||
if (profile == null)
|
||||
{
|
||||
Console.Error.WriteLine($"contestant calibration failed: unknown song '{songId}'");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (referenceChannel < 0 || referenceChannel >= channels)
|
||||
{
|
||||
Console.Error.WriteLine("contestant calibration failed: reference channel is outside the capture channel count");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var channelSamples = ReadInterleavedFloat32(inputPath, channels, out var frameCount);
|
||||
if (frameCount == 0)
|
||||
{
|
||||
Console.Error.WriteLine("contestant calibration failed: capture file contains no complete frames");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var captureSeconds = seconds > 0.0 ? Math.Min(seconds, frameCount / (double)sampleRate) : frameCount / (double)sampleRate;
|
||||
var renderer = new MimirBioacousticContestantRenderer(profile);
|
||||
var expectedEvents = renderer.ExpectedEvents(captureSeconds)
|
||||
.Order()
|
||||
.ToArray();
|
||||
if (expectedEvents.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("contestant calibration failed: no complete expected packet events in capture");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var searchRadiusSamples = Math.Max(1, (int)Math.Round(searchRadiusUs * sampleRate / 1_000_000.0));
|
||||
var delaySearchSamples = Math.Max(0, (int)Math.Round(delaySearchUs * sampleRate / 1_000_000.0));
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var channelCalibrations = new List<BioacousticPhysicalChannelCalibration>(channels);
|
||||
for (var channel = 0; channel < channels; channel++)
|
||||
{
|
||||
channelCalibrations.Add(CalibrateContestantChannel(
|
||||
$"asio-ch{channel}",
|
||||
channelSamples[channel],
|
||||
sampleRate,
|
||||
renderer,
|
||||
expectedEvents,
|
||||
scheduleOffsetSamples,
|
||||
searchRadiusSamples,
|
||||
delaySearchSamples));
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var reference = channelCalibrations[referenceChannel];
|
||||
var paths = channelCalibrations
|
||||
.Select(channel => BuildContestantPathCalibration(reference, channel, sampleRate))
|
||||
.ToArray();
|
||||
var analyzedAudioSeconds = captureSeconds * channels;
|
||||
var realtimeFactor = analyzedAudioSeconds / Math.Max(stopwatch.Elapsed.TotalSeconds, 1.0e-9);
|
||||
var model = new BioacousticPhysicalCalibrationModel(
|
||||
Schema: "mimir.bioacoustic.physical-calibration.v1",
|
||||
CreatedUtc: DateTimeOffset.UtcNow,
|
||||
InputPath: inputPath,
|
||||
SongId: profile.Id,
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
ReferenceSourceId: reference.SourceId,
|
||||
CaptureSeconds: captureSeconds,
|
||||
SearchRadiusSamples: searchRadiusSamples,
|
||||
ScheduleOffsetSamples: scheduleOffsetSamples,
|
||||
DecodeMilliseconds: stopwatch.Elapsed.TotalMilliseconds,
|
||||
RealtimeFactor: realtimeFactor,
|
||||
ChannelsModel: channelCalibrations.ToArray(),
|
||||
Paths: paths);
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
outputPath,
|
||||
JsonSerializer.Serialize(model, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
Console.WriteLine(
|
||||
$"contestant-calibration path={outputPath} input={inputPath} song={profile.Id} sampleRate={sampleRate} channels={channels} events={expectedEvents.Length} decodeMs={stopwatch.Elapsed.TotalMilliseconds:0.000} realtime={realtimeFactor:0.0}x budget={minRealtimeFactor:0.0}x");
|
||||
foreach (var channel in channelCalibrations)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"contestant-calibration-channel {channel.SourceId}: events={channel.DetectedEvents}/{channel.ExpectedEvents} payload={channel.PayloadAccuracy:0.000} confidence={channel.Confidence:0.000} polarity={channel.Polarity:+0;-0;0} scheduleOffsetSamples={channel.ScheduleOffsetSamples:0.000} offsetSamples={channel.SourceOffsetSamples:0.000000} offsetUs={channel.SourceOffsetSamples * 1_000_000.0 / sampleRate:0.000} maeSamples={channel.MeanAbsoluteErrorSamples:0.000000} maeUs={channel.MeanAbsoluteErrorSamples * 1_000_000.0 / sampleRate:0.000} rms={channel.Rms:0.000000} peak={channel.Peak:0.000000} usableBands={channel.Bands.Count(band => band.Usable)}/{channel.Bands.Length}");
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"contestant-calibration-path {path.ReferenceSourceId}->{path.SourceId}: delaySamples={path.DelaySamples:0.000000} delayUs={path.DelayMicroseconds:0.000} syncMaeUs={path.SyncMeanAbsoluteErrorMicroseconds:0.000} confidence={path.Confidence:0.000} gain={path.RelativeGain:0.000} polarity={path.RelativePolarity:+0;-0;0} normalizedBands={path.ResponseNormalizationBands.Count(band => band.Usable)}/{path.ResponseNormalizationBands.Length}");
|
||||
}
|
||||
|
||||
if (realtimeFactor < minRealtimeFactor)
|
||||
{
|
||||
Console.Error.WriteLine($"contestant calibration failed budget: realtime={realtimeFactor:0.000}x below required {minRealtimeFactor:0.000}x");
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static IReadOnlyList<BioacousticTrainingHypothesis> BioacousticTrainingHypotheses()
|
||||
{
|
||||
var baseProfiles = MimirBioacousticDecoderConfiguration.BuiltInProfiles
|
||||
|
|
@ -2415,6 +2555,418 @@ static double ScoreContestantOffset(float[] samples, float[] template, int offse
|
|||
: dot / Math.Sqrt(sampleEnergy * templateEnergy);
|
||||
}
|
||||
|
||||
static BioacousticPhysicalChannelCalibration CalibrateContestantChannel(
|
||||
string sourceId,
|
||||
float[] samples,
|
||||
int sampleRate,
|
||||
MimirBioacousticContestantRenderer renderer,
|
||||
IReadOnlyList<ulong> expectedEvents,
|
||||
double scheduleOffsetSamples,
|
||||
int searchRadiusSamples,
|
||||
int delaySearchSamples)
|
||||
{
|
||||
var motifSamples = renderer.RenderEventMonoFloat(0, sampleRate).Length;
|
||||
var channelScheduleOffsetSamples = delaySearchSamples <= 0
|
||||
? scheduleOffsetSamples
|
||||
: EstimateContestantScheduleOffset(
|
||||
samples,
|
||||
sampleRate,
|
||||
renderer,
|
||||
expectedEvents,
|
||||
scheduleOffsetSamples,
|
||||
delaySearchSamples,
|
||||
motifSamples);
|
||||
var observations = new List<BioacousticPhysicalEventCalibration>(expectedEvents.Count);
|
||||
var payloadCorrect = 0;
|
||||
foreach (var eventIndex in expectedEvents)
|
||||
{
|
||||
var expectedPayload = renderer.PayloadSymbolForEvent(eventIndex);
|
||||
var template = renderer.RenderEventMonoFloat(eventIndex, sampleRate, expectedPayload);
|
||||
var center = (int)Math.Round(renderer.EventStartSeconds(eventIndex) * sampleRate + channelScheduleOffsetSamples);
|
||||
var start = Math.Max(0, center - searchRadiusSamples);
|
||||
var end = Math.Min(samples.Length - motifSamples, center + searchRadiusSamples);
|
||||
if (start > end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bestOffset = center;
|
||||
var bestScore = double.NegativeInfinity;
|
||||
for (var offset = start; offset <= end; offset += 2)
|
||||
{
|
||||
var score = ScoreStreamingPacketOffset(samples, template, offset, motifSamples);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestOffset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestScore < 0.06)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var refinedOffset = RefineContestantExpectedPayloadOffset(samples, template, bestOffset, motifSamples);
|
||||
var signedScore = ScoreContestantOffset(samples, template, (int)Math.Round(refinedOffset), motifSamples);
|
||||
var polarity = signedScore < 0.0 ? -1 : 1;
|
||||
var payload = ClassifyContestantPayloadAt(samples, sampleRate, renderer, eventIndex, refinedOffset, motifSamples);
|
||||
if (payload == expectedPayload)
|
||||
{
|
||||
payloadCorrect++;
|
||||
}
|
||||
|
||||
var residual = ComputeContestantResidual(samples, template, (int)Math.Round(refinedOffset), motifSamples, signedScore);
|
||||
var delaySamples = refinedOffset - renderer.EventStartSeconds(eventIndex) * sampleRate;
|
||||
observations.Add(new BioacousticPhysicalEventCalibration(
|
||||
eventIndex,
|
||||
expectedPayload,
|
||||
payload,
|
||||
refinedOffset,
|
||||
delaySamples,
|
||||
Math.Clamp(Math.Abs(signedScore), 0.0, 1.0),
|
||||
polarity,
|
||||
residual,
|
||||
EstimateContestantEventBands(samples, template, renderer, eventIndex, sampleRate, (int)Math.Round(refinedOffset), motifSamples)));
|
||||
}
|
||||
|
||||
var clock = FitContestantClockHypothesis(
|
||||
observations
|
||||
.Select(observation => new CepstralWordObservation(
|
||||
observation.EventIndex,
|
||||
observation.SampleOffset,
|
||||
observation.Confidence,
|
||||
observation.ExpectedPayloadSymbol,
|
||||
observation.Confidence))
|
||||
.ToArray(),
|
||||
renderer,
|
||||
sampleRate,
|
||||
expectedEvents.Count);
|
||||
var sourceOffsetSamples = clock?.SourceOffsetSamples ?? 0.0;
|
||||
var meanAbsoluteErrorSamples = clock?.MeanAbsoluteErrorSamples ?? 0.0;
|
||||
var confidence = observations.Count == 0
|
||||
? 0.0
|
||||
: (clock?.Confidence ?? 0.0) * 0.55 + observations.Average(observation => observation.Confidence) * 0.30 + Math.Clamp(observations.Count / (double)expectedEvents.Count, 0.0, 1.0) * 0.15;
|
||||
var polarityVote = observations.Sum(observation => observation.Polarity * observation.Confidence);
|
||||
return new BioacousticPhysicalChannelCalibration(
|
||||
sourceId,
|
||||
expectedEvents.Count,
|
||||
observations.Count,
|
||||
expectedEvents.Count == 0 ? 0.0 : payloadCorrect / (double)expectedEvents.Count,
|
||||
confidence,
|
||||
polarityVote < 0.0 ? -1 : 1,
|
||||
channelScheduleOffsetSamples,
|
||||
sourceOffsetSamples,
|
||||
sourceOffsetSamples * 1_000_000.0 / sampleRate,
|
||||
meanAbsoluteErrorSamples,
|
||||
meanAbsoluteErrorSamples * 1_000_000.0 / sampleRate,
|
||||
samples.Length,
|
||||
RootMeanSquare(samples),
|
||||
samples.Length == 0 ? 0.0 : samples.Max(sample => Math.Abs(sample)),
|
||||
CollapseContestantBands(observations),
|
||||
observations.ToArray());
|
||||
}
|
||||
|
||||
static double EstimateContestantScheduleOffset(
|
||||
float[] samples,
|
||||
int sampleRate,
|
||||
MimirBioacousticContestantRenderer renderer,
|
||||
IReadOnlyList<ulong> expectedEvents,
|
||||
double seedOffsetSamples,
|
||||
int delaySearchSamples,
|
||||
int motifSamples)
|
||||
{
|
||||
var coarseStep = Math.Max(4, sampleRate / 6_000);
|
||||
var stride = Math.Max(4, sampleRate / 24_000);
|
||||
var scoringEvents = expectedEvents
|
||||
.Take(Math.Min(14, expectedEvents.Count))
|
||||
.ToArray();
|
||||
var scoringTemplates = scoringEvents
|
||||
.Select(eventIndex => (
|
||||
EventIndex: eventIndex,
|
||||
Template: renderer.RenderEventMonoFloat(eventIndex, sampleRate, renderer.PayloadSymbolForEvent(eventIndex)),
|
||||
TimelineSamples: renderer.EventStartSeconds(eventIndex) * sampleRate))
|
||||
.ToArray();
|
||||
var bestOffset = seedOffsetSamples;
|
||||
var bestScore = double.NegativeInfinity;
|
||||
for (var candidate = (int)Math.Round(seedOffsetSamples) - delaySearchSamples;
|
||||
candidate <= (int)Math.Round(seedOffsetSamples) + delaySearchSamples;
|
||||
candidate += coarseStep)
|
||||
{
|
||||
var score = 0.0;
|
||||
var count = 0;
|
||||
foreach (var scoringTemplate in scoringTemplates)
|
||||
{
|
||||
var offset = (int)Math.Round(scoringTemplate.TimelineSamples + candidate);
|
||||
var eventScore = ScoreContestantOffsetStrided(samples, scoringTemplate.Template, offset, motifSamples, stride);
|
||||
if (!double.IsFinite(eventScore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
score += Math.Abs(eventScore);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
score /= count;
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestOffset = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var fineStart = (int)Math.Round(bestOffset) - coarseStep;
|
||||
var fineEnd = (int)Math.Round(bestOffset) + coarseStep;
|
||||
for (var candidate = fineStart; candidate <= fineEnd; candidate += 2)
|
||||
{
|
||||
var score = 0.0;
|
||||
var count = 0;
|
||||
foreach (var scoringTemplate in scoringTemplates)
|
||||
{
|
||||
var offset = (int)Math.Round(scoringTemplate.TimelineSamples + candidate);
|
||||
var eventScore = ScoreContestantOffsetStrided(samples, scoringTemplate.Template, offset, motifSamples, stride);
|
||||
if (!double.IsFinite(eventScore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
score += Math.Abs(eventScore);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0 && score / count > bestScore)
|
||||
{
|
||||
bestScore = score / count;
|
||||
bestOffset = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestOffset;
|
||||
}
|
||||
|
||||
static double ScoreContestantOffsetStrided(float[] samples, float[] template, int offset, int motifSamples, int stride)
|
||||
{
|
||||
if (offset < 0 || offset + motifSamples > samples.Length)
|
||||
{
|
||||
return double.NegativeInfinity;
|
||||
}
|
||||
|
||||
var dot = 0.0;
|
||||
var sampleEnergy = 0.0;
|
||||
var templateEnergy = 0.0;
|
||||
for (var index = 0; index < Math.Min(motifSamples, template.Length); index += Math.Max(1, stride))
|
||||
{
|
||||
var sample = samples[offset + index];
|
||||
var expected = template[index];
|
||||
dot += sample * expected;
|
||||
sampleEnergy += sample * sample;
|
||||
templateEnergy += expected * expected;
|
||||
}
|
||||
|
||||
return sampleEnergy <= 1.0e-12 || templateEnergy <= 1.0e-12
|
||||
? double.NegativeInfinity
|
||||
: dot / Math.Sqrt(sampleEnergy * templateEnergy);
|
||||
}
|
||||
|
||||
static double RefineContestantExpectedPayloadOffset(float[] samples, float[] template, int bestOffset, int motifSamples)
|
||||
{
|
||||
if (bestOffset <= 0 || bestOffset >= samples.Length - motifSamples - 1)
|
||||
{
|
||||
return bestOffset;
|
||||
}
|
||||
|
||||
var left = ScoreStreamingPacketOffset(samples, template, bestOffset - 1, motifSamples);
|
||||
var center = ScoreStreamingPacketOffset(samples, template, bestOffset, motifSamples);
|
||||
var right = ScoreStreamingPacketOffset(samples, template, bestOffset + 1, motifSamples);
|
||||
var denominator = left - 2.0 * center + right;
|
||||
return Math.Abs(denominator) <= 1.0e-12
|
||||
? bestOffset
|
||||
: bestOffset + Math.Clamp(0.5 * (left - right) / denominator, -0.5, 0.5);
|
||||
}
|
||||
|
||||
static int ClassifyContestantPayloadAt(
|
||||
float[] samples,
|
||||
int sampleRate,
|
||||
MimirBioacousticContestantRenderer renderer,
|
||||
ulong eventIndex,
|
||||
double sampleOffset,
|
||||
int motifSamples)
|
||||
{
|
||||
var payloadCount = 1 << Math.Clamp(renderer.Profile.PayloadBitsPerEvent, 0, 8);
|
||||
var offset = (int)Math.Round(sampleOffset);
|
||||
var bestPayload = 0;
|
||||
var bestScore = double.NegativeInfinity;
|
||||
for (var payload = 0; payload < payloadCount; payload++)
|
||||
{
|
||||
var template = renderer.RenderEventMonoFloat(eventIndex, sampleRate, payload);
|
||||
var score = ScoreStreamingPacketOffset(samples, template, offset, motifSamples);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestPayload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
return bestPayload;
|
||||
}
|
||||
|
||||
static double ComputeContestantResidual(float[] samples, float[] template, int offset, int motifSamples, double signedCorrelation)
|
||||
{
|
||||
if (offset < 0 || offset + motifSamples > samples.Length)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
var sampleEnergy = 0.0;
|
||||
var templateEnergy = 0.0;
|
||||
var dot = 0.0;
|
||||
for (var index = 0; index < Math.Min(motifSamples, template.Length); index++)
|
||||
{
|
||||
var sample = samples[offset + index];
|
||||
var expected = template[index];
|
||||
sampleEnergy += sample * sample;
|
||||
templateEnergy += expected * expected;
|
||||
dot += sample * expected;
|
||||
}
|
||||
|
||||
if (sampleEnergy <= 1.0e-12 || templateEnergy <= 1.0e-12)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
var gain = dot / templateEnergy;
|
||||
var residual = Math.Max(0.0, sampleEnergy - 2.0 * gain * dot + gain * gain * templateEnergy);
|
||||
return Math.Sqrt(residual / sampleEnergy) * (signedCorrelation < 0.0 ? -1.0 : 1.0);
|
||||
}
|
||||
|
||||
static BioacousticPhysicalBandCalibration[] EstimateContestantEventBands(
|
||||
float[] samples,
|
||||
float[] template,
|
||||
MimirBioacousticContestantRenderer renderer,
|
||||
ulong eventIndex,
|
||||
int sampleRate,
|
||||
int offset,
|
||||
int motifSamples)
|
||||
{
|
||||
if (offset < 0 || offset + motifSamples > samples.Length)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var bands = EstimateContestantBandCenters(renderer, eventIndex);
|
||||
var output = new BioacousticPhysicalBandCalibration[bands.Length];
|
||||
for (var index = 0; index < bands.Length; index++)
|
||||
{
|
||||
var centerHz = bands[index];
|
||||
var bandStart = (int)Math.Round(index * motifSamples / (double)Math.Max(1, bands.Length + 1));
|
||||
var bandLength = Math.Max(1, Math.Min(motifSamples - bandStart, motifSamples / Math.Max(3, bands.Length)));
|
||||
var observed = WindowMeanSquare(samples.AsSpan(offset + bandStart, bandLength));
|
||||
var expected = WindowMeanSquare(template.AsSpan(bandStart, Math.Min(bandLength, template.Length - bandStart)));
|
||||
var gain = expected <= 1.0e-12 ? 0.0 : Math.Sqrt(Math.Max(0.0, observed) / expected);
|
||||
output[index] = new BioacousticPhysicalBandCalibration(centerHz, observed, expected, gain, observed > expected * 0.08);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static double[] EstimateContestantBandCenters(MimirBioacousticContestantRenderer renderer, ulong eventIndex)
|
||||
{
|
||||
var symbolId = (int)((eventIndex * 73UL + eventIndex / 5UL * 19UL) % MimirBioacousticContestants.SymbolCount);
|
||||
var t = symbolId / (double)(MimirBioacousticContestants.SymbolCount - 1);
|
||||
var root = Math.Exp(Math.Log(renderer.Profile.LowestRootHz) + (Math.Log(renderer.Profile.HighestRootHz) - Math.Log(renderer.Profile.LowestRootHz)) * t);
|
||||
var centers = new double[Math.Min(renderer.Profile.SyllableCount, 8)];
|
||||
for (var index = 0; index < centers.Length; index++)
|
||||
{
|
||||
centers[index] = Math.Clamp(root * Math.Pow(2.0, (-0.5 + index * 0.72) / 12.0), 200.0, renderer.Profile.HighestRootHz * 1.8);
|
||||
}
|
||||
|
||||
return centers;
|
||||
}
|
||||
|
||||
static double WindowMeanSquare(ReadOnlySpan<float> samples)
|
||||
{
|
||||
if (samples.Length == 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
var energy = 0.0;
|
||||
for (var index = 0; index < samples.Length; index++)
|
||||
{
|
||||
energy += samples[index] * samples[index];
|
||||
}
|
||||
|
||||
return energy / samples.Length;
|
||||
}
|
||||
|
||||
static BioacousticPhysicalBandCalibration[] CollapseContestantBands(IReadOnlyList<BioacousticPhysicalEventCalibration> observations)
|
||||
{
|
||||
return observations
|
||||
.SelectMany(observation => observation.Bands)
|
||||
.GroupBy(band => Math.Round(band.CenterHz / 25.0) * 25.0)
|
||||
.Select(group => new BioacousticPhysicalBandCalibration(
|
||||
group.Key,
|
||||
group.Average(band => band.ObservedEnergy),
|
||||
group.Average(band => band.ExpectedEnergy),
|
||||
group.Average(band => band.Gain),
|
||||
group.Count(band => band.Usable) >= Math.Max(1, group.Count() / 2)))
|
||||
.OrderBy(band => band.CenterHz)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
static BioacousticPhysicalPathCalibration BuildContestantPathCalibration(
|
||||
BioacousticPhysicalChannelCalibration reference,
|
||||
BioacousticPhysicalChannelCalibration candidate,
|
||||
int sampleRate)
|
||||
{
|
||||
var candidateByEvent = candidate.Events.ToDictionary(observation => observation.EventIndex);
|
||||
var matched = reference.Events
|
||||
.Where(observation => candidateByEvent.ContainsKey(observation.EventIndex))
|
||||
.Select(observation =>
|
||||
{
|
||||
var other = candidateByEvent[observation.EventIndex];
|
||||
var weight = Math.Sqrt(observation.Confidence * other.Confidence);
|
||||
return (Delay: other.SampleOffset - observation.SampleOffset, Weight: weight);
|
||||
})
|
||||
.ToArray();
|
||||
var delay = matched.Length == 0
|
||||
? candidate.SourceOffsetSamples - reference.SourceOffsetSamples
|
||||
: matched.Sum(pair => pair.Delay * Math.Max(pair.Weight, 1.0e-6)) / matched.Sum(pair => Math.Max(pair.Weight, 1.0e-6));
|
||||
var mae = matched.Length == 0
|
||||
? Math.Abs(candidate.MeanAbsoluteErrorSamples - reference.MeanAbsoluteErrorSamples)
|
||||
: matched.Sum(pair => Math.Abs(pair.Delay - delay) * Math.Max(pair.Weight, 1.0e-6)) / matched.Sum(pair => Math.Max(pair.Weight, 1.0e-6));
|
||||
var relativeGain = reference.Rms <= 1.0e-12 ? 0.0 : candidate.Rms / reference.Rms;
|
||||
var referenceBands = reference.Bands.ToDictionary(band => band.CenterHz);
|
||||
var normalizedBands = candidate.Bands
|
||||
.Select(band =>
|
||||
{
|
||||
var normalizer = referenceBands.TryGetValue(band.CenterHz, out var referenceBand) && referenceBand.Gain > 1.0e-12
|
||||
? referenceBand.Gain
|
||||
: 1.0;
|
||||
return band with { Gain = band.Gain / normalizer };
|
||||
})
|
||||
.ToArray();
|
||||
var confidence = Math.Sqrt(reference.Confidence * candidate.Confidence) *
|
||||
(1.0 / (1.0 + mae / Math.Max(1.0, sampleRate * 0.00010)));
|
||||
return new BioacousticPhysicalPathCalibration(
|
||||
reference.SourceId,
|
||||
candidate.SourceId,
|
||||
delay,
|
||||
delay * 1_000_000.0 / sampleRate,
|
||||
mae,
|
||||
mae * 1_000_000.0 / sampleRate,
|
||||
confidence,
|
||||
relativeGain,
|
||||
reference.Polarity * candidate.Polarity,
|
||||
normalizedBands);
|
||||
}
|
||||
|
||||
static IReadOnlyDictionary<ulong, int> ClassifyContestantPayloads(
|
||||
float[] samples,
|
||||
int sampleRate,
|
||||
|
|
@ -3563,3 +4115,67 @@ public sealed record BioacousticContestantObservation(
|
|||
double SampleOffset,
|
||||
double Confidence,
|
||||
double ShapeAccuracy);
|
||||
|
||||
public sealed record BioacousticPhysicalCalibrationModel(
|
||||
string Schema,
|
||||
DateTimeOffset CreatedUtc,
|
||||
string InputPath,
|
||||
string SongId,
|
||||
int SampleRate,
|
||||
int Channels,
|
||||
string ReferenceSourceId,
|
||||
double CaptureSeconds,
|
||||
int SearchRadiusSamples,
|
||||
double ScheduleOffsetSamples,
|
||||
double DecodeMilliseconds,
|
||||
double RealtimeFactor,
|
||||
BioacousticPhysicalChannelCalibration[] ChannelsModel,
|
||||
BioacousticPhysicalPathCalibration[] Paths);
|
||||
|
||||
public sealed record BioacousticPhysicalChannelCalibration(
|
||||
string SourceId,
|
||||
int ExpectedEvents,
|
||||
int DetectedEvents,
|
||||
double PayloadAccuracy,
|
||||
double Confidence,
|
||||
int Polarity,
|
||||
double ScheduleOffsetSamples,
|
||||
double SourceOffsetSamples,
|
||||
double SourceOffsetMicroseconds,
|
||||
double MeanAbsoluteErrorSamples,
|
||||
double MeanAbsoluteErrorMicroseconds,
|
||||
int SampleCount,
|
||||
double Rms,
|
||||
double Peak,
|
||||
BioacousticPhysicalBandCalibration[] Bands,
|
||||
BioacousticPhysicalEventCalibration[] Events);
|
||||
|
||||
public sealed record BioacousticPhysicalPathCalibration(
|
||||
string ReferenceSourceId,
|
||||
string SourceId,
|
||||
double DelaySamples,
|
||||
double DelayMicroseconds,
|
||||
double SyncMeanAbsoluteErrorSamples,
|
||||
double SyncMeanAbsoluteErrorMicroseconds,
|
||||
double Confidence,
|
||||
double RelativeGain,
|
||||
int RelativePolarity,
|
||||
BioacousticPhysicalBandCalibration[] ResponseNormalizationBands);
|
||||
|
||||
public sealed record BioacousticPhysicalEventCalibration(
|
||||
ulong EventIndex,
|
||||
int ExpectedPayloadSymbol,
|
||||
int ObservedPayloadSymbol,
|
||||
double SampleOffset,
|
||||
double DelaySamples,
|
||||
double Confidence,
|
||||
int Polarity,
|
||||
double NormalizedResidual,
|
||||
BioacousticPhysicalBandCalibration[] Bands);
|
||||
|
||||
public sealed record BioacousticPhysicalBandCalibration(
|
||||
double CenterHz,
|
||||
double ObservedEnergy,
|
||||
double ExpectedEnergy,
|
||||
double Gain,
|
||||
bool Usable);
|
||||
|
|
|
|||
|
|
@ -93,3 +93,4 @@
|
|||
{"ts": "2026-05-24T22:47:10+01:00", "type": "verification", "status": "ok", "note": "Verified faust/mimir_packet_razor_frontend.dsp with Faust 2.85.5 C++ generation. The DSP front-end exposes 16 log-spaced resonant band-energy outputs plus 16 positive-flux outputs for the packet razor streaming receiver lowering target."}
|
||||
{"ts": "2026-05-24T23:04:57+01:00", "type": "experiment", "status": "partial", "note": "First real-world packet razor ASIO test: rendered canary-packet-trill at 192 kHz, played through Focusrite ASIO, captured four inputs. Corrected probe needed --set-sample-rate 192000; first run stayed at 44.1 kHz. Loopback channels 2/3 correlated at +1623 samples and decoded 37/37 events with payload=1.000, timing=1.000, delay=8453.125 us. Acoustic channel 1 decoded only 1/37 and channel 0 none, so room/mic path still needs acquisition/level/channel work."}
|
||||
{"ts": "2026-05-24T23:10:02+01:00", "type": "experiment", "status": "partial", "note": "Second physical 192 kHz packet run with cardioid and co-streamer shotgun: loopback channels 2/3 still decode 37/37 payload=1.000 at offset 1623 samples. Shotgun channel 1 locks with per-channel offset 2122 samples: 37/37 payload=1.000, timing=0.828, delay=11166.397 us. Cardioid channel 0 partially decodes at offset 2519: 26/37 payload=0.595. Added polarity-invariant scoring for the streaming packet-local path because mic correlations were inverted."}
|
||||
{"ts": "2026-05-24T23:22:46+01:00", "type": "experiment", "status": "partial", "note": "Promoted canary-packet physical ASIO calibration into a persisted bioacoustic response model at calibration/bioacoustic/scarlett-canary-packet-192k-rerun.json. The command clears the hot-loop budget at 10.7x realtime across four 192 kHz channels after template caching and two-sample coarse search with sub-sample refinement. Loopback channels remain 37/37 with 2.524 us MAE. Co-streamer shotgun reaches 37/37 payload=1.000 with 58.785 us MAE and 2.940 ms acoustic delay relative to loopback. Cardioid reaches 26/37 payload=0.703 with 90.558 us MAE and 4.636 ms acoustic delay. This is exact enough to build response normalization and propagation-delay profiles, but not yet true microsecond sync for physical mics."}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,15 @@ accepted_design:
|
|||
192 kHz ingested more than 12,000 sample-bearing blocks across `asio-ch0`
|
||||
through `asio-ch3` and retained 2,048 blocks per channel, all from the
|
||||
Focusrite ASIO callback stream.
|
||||
`Mimir.BufferSmoke --calibrate-contestant-asio-f32` now persists the active
|
||||
packet-song physical response model under `calibration/bioacoustic/`,
|
||||
including per-channel delay search, polarity, payload reliability,
|
||||
response-normalization bands, gain, confidence, and pairwise propagation
|
||||
delay. The latest canary-packet Scarlett receipt runs at 10.7x realtime
|
||||
across four 192 kHz channels: loopback is 37/37 with 2.524 us MAE,
|
||||
co-streamer shotgun is 37/37 payload with 58.785 us MAE, and the cardioid
|
||||
is 26/37 with 90.558 us MAE. This is a serious calibration surface, not yet
|
||||
true physical microsecond sync.
|
||||
Acoustic robustness remains open.
|
||||
The old arbitrary-chirplet matcher is a diagnostic/reference artifact only,
|
||||
not the runtime hot path.
|
||||
|
|
|
|||
Loading…
Reference in a new issue