diff --git a/docs/bioacoustic-timeline-watermark.md b/docs/bioacoustic-timeline-watermark.md index 4ca2828..a50ff71 100644 --- a/docs/bioacoustic-timeline-watermark.md +++ b/docs/bioacoustic-timeline-watermark.md @@ -41,6 +41,12 @@ what did this room/mic path do to the spectrum? pivots, harmonic-envelope notches, and payload ornaments. Calibration receipts now persist those intra-call anchors so path learning can discover which features survive each output/mic pair. +- `MimirComplexContourMatchedFilterBank` is the first sonar-shaped receiver + cut: it turns known contour anchors into complex matched-filter responses, + keeps multiple lobes per anchor, and hands them to `MimirDirectPathTracker`. + The tracker takes the current path hypothesis as authority, chooses the + coherent direct-path cluster inside that gate, and records later clusters as + reflection taps instead of letting them become alternate timelines. - Runtime emission sends the left vocabulary to the left speaker and the right vocabulary to the right speaker, so microphones can learn each side of the room separately. @@ -96,6 +102,7 @@ Synthetic checks: dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --bioacoustic-self-test dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --standalone-bioacoustic-self-test --sample-rate 48000 --delay-samples 1269.5 dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --chirp-only-sync-self-test --sample-rate 48000 +dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --complex-contour-tracker-self-test --sample-rate 192000 --delay-samples 693.5 --reflection-delay-samples 116 ``` Expected state: @@ -113,6 +120,10 @@ Expected state: CultCache receipts plus pre-warp, post-warp, and reconstructed-from-detections WAV artifacts. See [[bioacoustic-training-harness|Bioacoustic Training Harness]]. +- `--complex-contour-tracker-self-test` proves the first direct-path tracker + shape with a louder delayed reflection present. The current 192 kHz synthetic + smoke reports about `5.249 us` error, so this is a useful tracker surface, not + the final microsecond claim. Render a raw Float32 preview: @@ -154,4 +165,10 @@ identity; the remaining work is to make each syllable/formant contour emit enough stable anchors that physical mic residuals collapse from tens of microseconds toward the one-microsecond target. The first anchor-rich receipt improves loopback but not the physical mics, which means anchor observability is -now present and anchor survivability is the next design problem. +now present and anchor survivability is the next design problem. The complex +contour tracker now runs against stored Scarlett ASIO captures with seeded path +hypotheses: stored/fresh shotgun estimates land within about `5.860 us` and +`7.247 us` of the existing path fit, while stored/fresh cardioid estimates land +within about `15.817 us` and `8.963 us`. Confidence remains low because the +current matched-filter bank exposes reflection taps but does not yet fit a full +phase/group-delay channel surface. diff --git a/docs/code-algorithm-map.md b/docs/code-algorithm-map.md index 914553b..cf709a7 100644 --- a/docs/code-algorithm-map.md +++ b/docs/code-algorithm-map.md @@ -38,6 +38,11 @@ Active bioacoustic decode uses known codebook/schedule state. It proposes motif times from local energy, classifies log-frequency/formant symbols by bounded matched motif scoring, maps self-identifying words directly to canonical events, fits a clock, and records band response evidence for calibration. +The first sonar-shaped refinement module now sits beside that path: +`MimirComplexContourMatchedFilterBank` emits complex contour-anchor hits with +multiple lobes per anchor, and `MimirDirectPathTracker` uses the current path +prediction to select the direct-path cluster while preserving later clusters as +reflection taps. ## Invariants @@ -842,6 +847,33 @@ Invariant: syllable timing, formants, payload ornaments, speaker tint, and spectral flux are the local anchor field. +### `MimirComplexContourMatchedFilter.cs` + +Owns: + +- The first complex matched-filter/direct-path tracker surface for canary + packet contour anchors. + +Blocks: + +- `MimirComplexContourFilterOptions`: anchor count, candidate lobe count, + score floor, lobe separation, and analysis-window bounds. +- `MimirComplexContourAnchorHit`: event, anchor identity, center frequency, + canonical timeline seconds, sample offset, score, phase, and rank. +- `MimirComplexContourMatchedFilterBank`: renders known packet anchors into + complex kernels, scans current samples around a predicted source offset, keeps + multiple lobe candidates per anchor, and exposes phase/magnitude evidence. +- `MimirDirectPathTracker`: matches reference and candidate anchor hits, gates + updates around the current path prediction, selects the coherent direct-path + cluster, smooths bounded tracking corrections, and reports later coherent + clusters as `MimirAcousticReflectionTap` records. + +Invariant: + +- Recursive refinement is constrained by a current path hypothesis. A loud + reflection may be evidence about the room, but it does not get to become time + just because its correlation peak is bigger. + ## Stage 9: Chirp-Bin Reference Timeline ### `MimirChirpBinTimeline.cs` @@ -1083,6 +1115,8 @@ Function blocks: - `RunPassiveSyncSelfTest`: PHAT passive proof. - `RunHybridSyncSelfTest`: hybrid mode proof. - `RunActiveSyncSelfTest`: chirp-only/hybrid rolling-buffer analyzer proof. +- `RunComplexContourTrackerSelfTest`: synthetic direct-path tracker proof with + a louder delayed reflection present. - `SyncModeLabel`: CLI label helper. - `RenderChirpletFloat32`: writes legacy timeline Float32 samples. - `RenderChirpBinFloat32`: writes chirp-bin timeline Float32 samples. @@ -1091,6 +1125,9 @@ Function blocks: - `LoadOptionalCalibration`: loads model from CLI path. - `AnalyzeAsioFloat32`: reads interleaved ASIO artifact and feeds runtime analyzer. +- `AnalyzeComplexContourAsioFloat32`: reads interleaved ASIO packet-song + captures and runs the complex contour matched-filter/direct-path tracker + against one candidate channel with a seeded path prediction. - `CalibrateChirpBinAsioFloat32`: renders/captures/ingests a calibration session and writes model JSON. - `RunAsioProbeCapture`: drives native ASIO probe capture. diff --git a/notes/current-system-map.md b/notes/current-system-map.md index 55360e4..939b472 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -243,6 +243,16 @@ reflection energy. The follow-up sonar/DSP research note at use complex matched filters, phase-slope/group-delay fitting, acquisition versus tracking loop bandwidths, and sparse multipath residuals before attempting another recursive fitter. +The first implementation slice now exists in +`MimirComplexContourMatchedFilterBank` and `MimirDirectPathTracker`: known +canary packet anchors become complex matched-filter responses with multiple +candidate lobes, then the tracker uses the current path prediction as authority, +selects the coherent direct-path cluster inside that gate, and reports later +clusters as reflection taps. Synthetic 192 kHz reflection smoke currently lands +about 5.249 us from the expected delay; stored Scarlett shotgun runs land within +about 5.860 us and 7.247 us of the seeded path fits, while cardioid runs land +within about 15.817 us and 8.963 us. This is the correct receiver shape, but +not yet the final phase/group-delay channel model. 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 diff --git a/src/Mimir.BufferSmoke/Program.cs b/src/Mimir.BufferSmoke/Program.cs index d72143f..10325c3 100644 --- a/src/Mimir.BufferSmoke/Program.cs +++ b/src/Mimir.BufferSmoke/Program.cs @@ -64,6 +64,14 @@ if (args.Any(arg => string.Equals(arg, "--bioacoustic-actuator-self-test", Strin ParseDoubleOption(args, "--delay-samples", 317.375)); } +if (args.Any(arg => string.Equals(arg, "--complex-contour-tracker-self-test", StringComparison.OrdinalIgnoreCase))) +{ + return RunComplexContourTrackerSelfTest( + ParseIntOption(args, "--sample-rate", 48_000), + ParseDoubleOption(args, "--delay-samples", 173.375), + ParseDoubleOption(args, "--reflection-delay-samples", 29.0)); +} + if (args.Any(arg => string.Equals(arg, "--perfect-machine-profile-smoke", StringComparison.OrdinalIgnoreCase))) { return RunPerfectMachineProfileSmoke(); @@ -168,6 +176,20 @@ 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, "--complex-contour-asio-f32", StringComparison.OrdinalIgnoreCase))) +{ + return AnalyzeComplexContourAsioFloat32( + ParseStringOption(args, "--input", "artifacts/asio/scarlett-canary-packet-anchor-rich-192k-f32.raw"), + ParseIntOption(args, "--sample-rate", MimirBioacousticTimeline.SampleRate), + ParseIntOption(args, "--channels", 4), + ParseIntOption(args, "--reference-channel", 2), + ParseIntOption(args, "--candidate-channel", 1), + ParseDoubleOption(args, "--seconds", 3.0), + ParseDoubleOption(args, "--schedule-offset-samples", 1623.0), + ParseDoubleOption(args, "--predicted-delay-samples", 0.0), + ParseStringOption(args, "--song", MimirBioacousticContestants.CanaryPacketTrill.Id)); +} + if (args.Any(arg => string.Equals(arg, "--calibrate-contestant-asio-f32", StringComparison.OrdinalIgnoreCase))) { return CalibrateContestantAsioFloat32( @@ -1071,6 +1093,45 @@ static int RunBioacousticActuatorSelfTest(int sampleRate, double delaySamples) : 1; } +static int RunComplexContourTrackerSelfTest(int sampleRate, double delaySamples, double reflectionDelaySamples) +{ + var renderer = new MimirBioacousticContestantRenderer(MimirBioacousticContestants.CanaryPacketTrill); + var seconds = 1.45; + var reference = renderer.RenderSequenceMonoFloat(seconds, sampleRate); + var direct = ApplyFractionalDelay(reference, delaySamples); + var reflection = ApplyFractionalDelay(reference, delaySamples + reflectionDelaySamples); + var candidate = new float[reference.Length]; + for (var index = 0; index < candidate.Length; index++) + { + candidate[index] = (float)(direct[index] * 0.62 + reflection[index] * 1.00); + } + + var bank = new MimirComplexContourMatchedFilterBank(renderer, sampleRate); + var eventIndices = Enumerable.Range(0, renderer.ExpectedEventCount(seconds)).Select(index => (ulong)index).ToArray(); + var referenceHits = bank.AnalyzeEvents(reference, eventIndices, 0.0, Math.Max(2, sampleRate / 4_000)); + var candidateHits = bank.AnalyzeEvents(candidate, eventIndices, delaySamples, Math.Max(48, sampleRate / 900)); + var tracker = new MimirDirectPathTracker(sampleRate); + var estimate = tracker.Update(referenceHits, candidateHits, delaySamples); + if (estimate == null) + { + Console.WriteLine( + $"complex-contour-tracker-self-test status=no-lock referenceHits={referenceHits.Count} candidateHits={candidateHits.Count}"); + return 1; + } + + var errorSamples = Math.Abs(estimate.DelaySamples - delaySamples); + var firstReflection = estimate.ReflectionTaps.FirstOrDefault(); + Console.WriteLine( + $"complex-contour-tracker-self-test delaySamples={estimate.DelaySamples:0.000000} expected={delaySamples:0.000000} " + + $"errorSamples={errorSamples:0.000000} errorUs={errorSamples * 1_000_000.0 / sampleRate:0.000} " + + $"confidence={estimate.Confidence:0.000} directHits={estimate.DirectHitCount} maeSamples={estimate.MeanAbsoluteErrorSamples:0.000000} " + + $"referenceHits={referenceHits.Count} candidateHits={candidateHits.Count} reflectionTaps={estimate.ReflectionTaps.Count} " + + $"firstReflectionSamples={(firstReflection?.RelativeDelaySamples ?? double.NaN):0.000}"); + return errorSamples < 3.0 && estimate.DirectHitCount >= 8 + ? 0 + : 1; +} + static int RunPerfectMachineProfileSmoke() { var profiles = MimirPerfectMachineProfiles.All; @@ -1656,6 +1717,77 @@ static int AnalyzeContestantAsioFloat32( return failures == 0 ? 0 : 1; } +static int AnalyzeComplexContourAsioFloat32( + string inputPath, + int sampleRate, + int channels, + int referenceChannel, + int candidateChannel, + double seconds, + double scheduleOffsetSamples, + double predictedDelaySamples, + string songId) +{ + if (!File.Exists(inputPath)) + { + Console.Error.WriteLine($"complex-contour ASIO analysis 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($"complex-contour ASIO analysis failed: unknown song '{songId}'"); + return 1; + } + + if (referenceChannel < 0 || referenceChannel >= channels || + candidateChannel < 0 || candidateChannel >= channels) + { + Console.Error.WriteLine("complex-contour ASIO analysis failed: channel index outside capture channel count"); + return 1; + } + + var channelSamples = ReadInterleavedFloat32(inputPath, channels, out var frameCount); + var renderer = new MimirBioacousticContestantRenderer(profile); + var captureSeconds = seconds > 0.0 ? Math.Min(seconds, frameCount / (double)sampleRate) : frameCount / (double)sampleRate; + var eventIndices = renderer.ExpectedEvents(captureSeconds) + .Order() + .ToArray(); + var bank = new MimirComplexContourMatchedFilterBank(renderer, sampleRate); + var referenceHits = bank.AnalyzeEvents( + channelSamples[referenceChannel], + eventIndices, + scheduleOffsetSamples, + Math.Max(2, sampleRate / 4_000)); + var candidateHits = bank.AnalyzeEvents( + channelSamples[candidateChannel], + eventIndices, + scheduleOffsetSamples + predictedDelaySamples, + Math.Max(48, sampleRate / 900)); + var tracker = new MimirDirectPathTracker(sampleRate); + var estimate = tracker.Update(referenceHits, candidateHits, predictedDelaySamples); + if (estimate == null) + { + Console.WriteLine( + $"complex-contour-asio-f32 status=no-lock input={inputPath} reference=asio-ch{referenceChannel} candidate=asio-ch{candidateChannel} " + + $"referenceHits={referenceHits.Count} candidateHits={candidateHits.Count} predictedDelaySamples={predictedDelaySamples:0.000}"); + return 1; + } + + var predictionError = estimate.DelaySamples - predictedDelaySamples; + var firstReflection = estimate.ReflectionTaps.FirstOrDefault(); + Console.WriteLine( + $"complex-contour-asio-f32 input={inputPath} reference=asio-ch{referenceChannel} candidate=asio-ch{candidateChannel} " + + $"delaySamples={estimate.DelaySamples:0.000000} delayUs={estimate.DelayMicroseconds:0.000} " + + $"predictedDelaySamples={predictedDelaySamples:0.000000} predictionErrorSamples={predictionError:0.000000} predictionErrorUs={predictionError * 1_000_000.0 / sampleRate:0.000} " + + $"confidence={estimate.Confidence:0.000} directHits={estimate.DirectHitCount} maeSamples={estimate.MeanAbsoluteErrorSamples:0.000000} " + + $"referenceHits={referenceHits.Count} candidateHits={candidateHits.Count} reflectionTaps={estimate.ReflectionTaps.Count} " + + $"firstReflectionSamples={(firstReflection?.RelativeDelaySamples ?? double.NaN):0.000}"); + return 0; +} + static int CalibrateContestantAsioFloat32( string inputPath, string outputPath, diff --git a/src/Mimir.Runtime/Synchronization/MimirComplexContourMatchedFilter.cs b/src/Mimir.Runtime/Synchronization/MimirComplexContourMatchedFilter.cs new file mode 100644 index 0000000..9bb86b8 --- /dev/null +++ b/src/Mimir.Runtime/Synchronization/MimirComplexContourMatchedFilter.cs @@ -0,0 +1,396 @@ +using System.Numerics; + +namespace Mimir.Runtime.Synchronization; + +public sealed record MimirComplexContourFilterOptions( + int MaxAnchorsPerEvent = 8, + int MaxCandidatesPerAnchor = 5, + double MinimumScore = 0.020, + double CandidateSeparationSamples = 2.0, + double MinimumWindowSeconds = 0.00075, + double MaximumWindowSeconds = 0.0060); + +public sealed record MimirComplexContourAnchorHit( + ulong EventIndex, + MimirBioacousticAnchorKind Kind, + int SyllableIndex, + double CenterHz, + double TimelineSeconds, + double SampleOffset, + double Score, + double PhaseRadians, + int Rank); + +public sealed record MimirAcousticReflectionTap( + double DelaySamples, + double RelativeDelaySamples, + double Weight, + int HitCount); + +public sealed record MimirDirectPathEstimate( + double DelaySamples, + double DelayMicroseconds, + double Confidence, + double MeanAbsoluteErrorSamples, + int DirectHitCount, + IReadOnlyList ReflectionTaps); + +public sealed class MimirComplexContourMatchedFilterBank( + MimirBioacousticContestantRenderer renderer, + int sampleRate, + MimirComplexContourFilterOptions? options = null) +{ + private readonly Dictionary<(ulong EventIndex, MimirBioacousticAnchorKind Kind, int SyllableIndex), MimirComplexContourTemplate> templates = []; + private readonly MimirComplexContourFilterOptions options = options ?? new(); + + public IReadOnlyList AnalyzeEvents( + ReadOnlySpan samples, + IEnumerable eventIndices, + double sourceOffsetSamples, + int searchRadiusSamples) + { + var output = new List(); + foreach (var eventIndex in eventIndices) + { + foreach (var template in TemplatesForEvent(eventIndex)) + { + var center = renderer.EventStartSeconds(eventIndex) * sampleRate + + sourceOffsetSamples + + template.LocalStartSamples; + AddBestHits(samples, template, center, searchRadiusSamples, output); + } + } + + return output + .OrderBy(hit => hit.EventIndex) + .ThenBy(hit => hit.SampleOffset) + .ThenBy(hit => hit.Rank) + .ToArray(); + } + + private IReadOnlyList TemplatesForEvent(ulong eventIndex) + { + var anchors = renderer.AnchorPlan(eventIndex) + .OrderByDescending(anchor => anchor.Weight) + .Take(options.MaxAnchorsPerEvent) + .OrderBy(anchor => anchor.StartSeconds) + .ToArray(); + var output = new List(anchors.Length); + foreach (var anchor in anchors) + { + var key = (eventIndex, anchor.Kind, anchor.SyllableIndex); + if (!templates.TryGetValue(key, out var template)) + { + template = BuildTemplate(eventIndex, anchor); + templates[key] = template; + } + + output.Add(template); + } + + return output; + } + + private MimirComplexContourTemplate BuildTemplate(ulong eventIndex, MimirBioacousticContestantAnchor anchor) + { + var eventSamples = renderer.RenderEventMonoFloat(eventIndex, sampleRate); + var windowSamples = Math.Clamp( + (int)Math.Round(anchor.DurationSeconds * sampleRate), + Math.Max(16, (int)Math.Round(options.MinimumWindowSeconds * sampleRate)), + Math.Max(16, (int)Math.Round(options.MaximumWindowSeconds * sampleRate))); + var localStart = Math.Clamp( + (int)Math.Round(anchor.StartSeconds * sampleRate), + 0, + Math.Max(0, eventSamples.Length - windowSamples)); + var kernel = new Complex[windowSamples]; + var energy = 0.0; + for (var index = 0; index < windowSamples; index++) + { + var t = index / (double)Math.Max(1, windowSamples - 1); + var taper = Math.Sin(Math.PI * t); + var sample = eventSamples[localStart + index] * taper; + var phase = -Math.Tau * anchor.CenterHz * index / sampleRate; + kernel[index] = new Complex(sample * Math.Cos(phase), sample * Math.Sin(phase)); + energy += sample * sample; + } + + return new MimirComplexContourTemplate( + eventIndex, + anchor.Kind, + anchor.SyllableIndex, + anchor.CenterHz, + renderer.EventStartSeconds(eventIndex) + anchor.StartSeconds, + localStart, + kernel, + Math.Max(energy, 1.0e-18), + anchor.Weight); + } + + private void AddBestHits( + ReadOnlySpan samples, + MimirComplexContourTemplate template, + double centerSample, + int searchRadiusSamples, + List output) + { + var candidates = new List(); + var first = Math.Max(0, (int)Math.Floor(centerSample - searchRadiusSamples)); + var last = Math.Min(samples.Length - template.Kernel.Length - 1, (int)Math.Ceiling(centerSample + searchRadiusSamples)); + for (var offset = first; offset <= last; offset++) + { + var response = ScoreAt(samples, template, offset); + if (response.Score < options.MinimumScore) + { + continue; + } + + var refinedOffset = RefineOffset(samples, template, offset, first, last); + candidates.Add(new MimirComplexContourAnchorHit( + template.EventIndex, + template.Kind, + template.SyllableIndex, + template.CenterHz, + template.TimelineSeconds, + refinedOffset, + response.Score * template.Weight, + response.PhaseRadians, + 0)); + } + + var rank = 0; + foreach (var candidate in candidates + .OrderByDescending(candidate => candidate.Score)) + { + if (output.Any(hit => + hit.EventIndex == candidate.EventIndex && + hit.Kind == candidate.Kind && + hit.SyllableIndex == candidate.SyllableIndex && + Math.Abs(hit.SampleOffset - candidate.SampleOffset) < options.CandidateSeparationSamples)) + { + continue; + } + + output.Add(candidate with { Rank = rank }); + rank++; + if (rank >= options.MaxCandidatesPerAnchor) + { + break; + } + } + } + + private double RefineOffset(ReadOnlySpan samples, MimirComplexContourTemplate template, int offset, int first, int last) + { + if (offset <= first || offset >= last) + { + return offset; + } + + var left = ScoreAt(samples, template, offset - 1).Score; + var middle = ScoreAt(samples, template, offset).Score; + var right = ScoreAt(samples, template, offset + 1).Score; + var denominator = left - 2.0 * middle + right; + return Math.Abs(denominator) <= 1.0e-12 + ? offset + : offset + Math.Clamp(0.5 * (left - right) / denominator, -0.5, 0.5); + } + + private static (double Score, double PhaseRadians) ScoreAt( + ReadOnlySpan samples, + MimirComplexContourTemplate template, + int offset) + { + if (offset < 0 || offset + template.Kernel.Length > samples.Length) + { + return (0.0, 0.0); + } + + var dot = Complex.Zero; + var sampleEnergy = 0.0; + for (var index = 0; index < template.Kernel.Length; index++) + { + var sample = samples[offset + index]; + dot += sample * Complex.Conjugate(template.Kernel[index]); + sampleEnergy += sample * sample; + } + + if (sampleEnergy <= 1.0e-18 || template.Energy <= 1.0e-18) + { + return (0.0, 0.0); + } + + var normalizedMagnitude = dot.Magnitude / Math.Sqrt(sampleEnergy * template.Energy); + return (normalizedMagnitude, Math.Atan2(dot.Imaginary, dot.Real)); + } + + private sealed record MimirComplexContourTemplate( + ulong EventIndex, + MimirBioacousticAnchorKind Kind, + int SyllableIndex, + double CenterHz, + double TimelineSeconds, + int LocalStartSamples, + Complex[] Kernel, + double Energy, + double Weight); +} + +public sealed record MimirDirectPathTrackerOptions( + double DirectClusterRadiusSamples = 6.0, + double MinimumDirectClusterWeightRatio = 0.25, + double TrackingLoopBandwidth = 0.20, + double MaximumTrackingCorrectionSamples = 12.0, + double PredictionGateSamples = 10.0, + int MinimumDirectHits = 8); + +public sealed class MimirDirectPathTracker( + int sampleRate, + MimirDirectPathTrackerOptions? options = null) +{ + private readonly MimirDirectPathTrackerOptions options = options ?? new(); + private double delaySamples; + private bool hasLock; + + public MimirDirectPathEstimate? Update( + IReadOnlyList referenceHits, + IReadOnlyList candidateHits, + double? predictedDelaySamples = null) + { + var referenceByAnchor = referenceHits + .Where(hit => hit.Rank == 0) + .GroupBy(hit => (hit.EventIndex, hit.Kind, hit.SyllableIndex)) + .ToDictionary(group => group.Key, group => group.OrderByDescending(hit => hit.Score).First()); + var deltas = new List<(double Delay, double Weight)>(); + foreach (var candidate in candidateHits) + { + if (!referenceByAnchor.TryGetValue((candidate.EventIndex, candidate.Kind, candidate.SyllableIndex), out var reference)) + { + continue; + } + + var weight = Math.Sqrt(Math.Max(0.0, reference.Score) * Math.Max(0.0, candidate.Score)); + var sampleDelay = candidate.SampleOffset - reference.SampleOffset; + var phaseDelta = WrapRadians(candidate.PhaseRadians - reference.PhaseRadians); + var phaseDelay = candidate.CenterHz <= 1.0 + ? 0.0 + : phaseDelta * sampleRate / (Math.Tau * candidate.CenterHz); + var maxPhaseCorrection = sampleRate / Math.Max(1.0, candidate.CenterHz) * 0.5; + deltas.Add((sampleDelay + Math.Clamp(phaseDelay, -maxPhaseCorrection, maxPhaseCorrection), weight)); + } + + if (deltas.Count < options.MinimumDirectHits) + { + return null; + } + + var clusters = ClusterDelays(deltas); + if (clusters.Length == 0) + { + return null; + } + + var gatedClusters = predictedDelaySamples == null + ? clusters + : clusters + .Where(cluster => Math.Abs(cluster.Delay - predictedDelaySamples.Value) <= options.PredictionGateSamples) + .ToArray(); + if (gatedClusters.Length == 0) + { + return null; + } + + var bestWeight = gatedClusters.Max(cluster => cluster.Weight); + var directCandidates = gatedClusters + .Where(cluster => cluster.Count >= options.MinimumDirectHits) + .Where(cluster => cluster.Weight >= bestWeight * options.MinimumDirectClusterWeightRatio) + .OrderBy(cluster => predictedDelaySamples == null + ? cluster.Delay + : Math.Abs(cluster.Delay - predictedDelaySamples.Value)) + .ToArray(); + var direct = directCandidates.Length == 0 + ? gatedClusters.OrderByDescending(cluster => cluster.Weight).First() + : directCandidates[0]; + + var updatedDelay = direct.Delay; + if (hasLock) + { + var correction = Math.Clamp(updatedDelay - delaySamples, -options.MaximumTrackingCorrectionSamples, options.MaximumTrackingCorrectionSamples); + updatedDelay = delaySamples + correction * options.TrackingLoopBandwidth; + } + + delaySamples = updatedDelay; + hasLock = true; + var directResiduals = deltas + .Where(delta => Math.Abs(delta.Delay - direct.Delay) <= options.DirectClusterRadiusSamples) + .ToArray(); + var totalWeight = Math.Max(1.0e-9, directResiduals.Sum(delta => Math.Max(delta.Weight, 1.0e-9))); + var mae = directResiduals.Sum(delta => Math.Abs(delta.Delay - direct.Delay) * Math.Max(delta.Weight, 1.0e-9)) / totalWeight; + var confidence = Math.Clamp( + direct.Weight / Math.Max(1.0e-9, clusters.Sum(cluster => cluster.Weight)) * + Math.Clamp(direct.Count / 16.0, 0.0, 1.0) * + (1.0 / (1.0 + mae / Math.Max(1.0, sampleRate * 0.00005))), + 0.0, + 1.0); + var reflectionTaps = clusters + .Where(cluster => cluster.Delay > direct.Delay + options.DirectClusterRadiusSamples) + .Select(cluster => new MimirAcousticReflectionTap( + cluster.Delay, + cluster.Delay - direct.Delay, + cluster.Weight, + cluster.Count)) + .OrderBy(tap => tap.RelativeDelaySamples) + .Take(6) + .ToArray(); + return new MimirDirectPathEstimate( + updatedDelay, + updatedDelay * 1_000_000.0 / sampleRate, + confidence, + mae, + direct.Count, + reflectionTaps); + } + + private MimirDelayCluster[] ClusterDelays(IReadOnlyList<(double Delay, double Weight)> deltas) + { + var sorted = deltas.OrderBy(delta => delta.Delay).ToArray(); + var clusters = new List(); + var index = 0; + while (index < sorted.Length) + { + var members = new List<(double Delay, double Weight)> { sorted[index] }; + var end = index + 1; + while (end < sorted.Length && + sorted[end].Delay - members[0].Delay <= options.DirectClusterRadiusSamples) + { + members.Add(sorted[end]); + end++; + } + + var totalWeight = Math.Max(1.0e-9, members.Sum(member => Math.Max(member.Weight, 1.0e-9))); + clusters.Add(new MimirDelayCluster( + members.Sum(member => member.Delay * Math.Max(member.Weight, 1.0e-9)) / totalWeight, + totalWeight, + members.Count)); + index = end; + } + + return clusters.ToArray(); + } + + private sealed record MimirDelayCluster(double Delay, double Weight, int Count); + + private static double WrapRadians(double radians) + { + while (radians <= -Math.PI) + { + radians += Math.Tau; + } + + while (radians > Math.PI) + { + radians -= Math.Tau; + } + + return radians; + } +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 01dd9ef..6b2d558 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -101,3 +101,4 @@ {"ts": "2026-05-25T01:05:00+01:00", "type": "experiment", "status": "rejected", "note": "Tested a recursive event-local waveform delay fitter: start from the current path hypothesis, resample candidate song windows against loopback, shrink the residual search radius, and weight events by phase-lock consistency. The naive time-domain version improved one stored cardioid path from 7.576 us to 5.141 us, but it also worsened the stored shotgun from 6.578 us to 10.236 us and locked the fresh cardioid onto a later reflection lobe at 65.666 us instead of the prior 18.930 us. Reverted the code and receipts. Lesson: recursive refinement is a valid shape only when constrained by a real phase/group-delay/path model; plain waveform resampling is not sufficient and nanosecond claims are physically dishonest in the current acoustic path."} {"ts": "2026-05-25T01:25:00+01:00", "type": "research", "status": "ok", "note": "Looked at open realtime DSP/sonar-adjacent systems after the recursive-fit rejection. liquid-dsp, an underwater acoustic modem using liquid-dsp, pyAPRiL passive radar, GNU Radio stream/tag synchronization, OpenTDS, Signalsmith DSP, Open Echo, and ahoi all point the same way: acquisition should use matched filters/ambiguity search; refinement should track complex phase, timing, SRO, and channel response; multipath should be modeled as later residual taps; and exact stream positions should be propagated as tags/state. Added research/sonar-dsp-recursive-refinement-2026-05-25.md. The next implementation cut should be a complex contour matched-filter bank plus direct-path acoustic tracker, not another raw waveform recursion pass."} {"ts": "2026-05-25T01:41:00+01:00", "type": "research", "status": "ok", "note": "Persisted the sonar/DSP source trail behind recursive refinement. Added research/sonar-dsp-recursive-refinement-2026-05-25/sources.md with per-source summaries and Mimir implementation implications, plus local mirrors under research/sonar-dsp-recursive-refinement-2026-05-25/mirrors/ for liquid-dsp, liquid-dsp releases, the acoustic modem PDF, pyAPRiL, GNU Radio correlate/tag docs, OpenTDS, Signalsmith DSP, Open Echo, and ahoi hardware overview."} +{"ts": "2026-05-25T02:24:00+01:00", "type": "implementation", "status": "partial", "note": "Built the first sonar-shaped recursive refinement slice: MimirComplexContourMatchedFilterBank emits complex matched-filter contour-anchor hits with multiple candidate lobes, and MimirDirectPathTracker uses the current predicted path delay as authority, selects the coherent direct-path cluster inside that gate, and records later clusters as reflection taps. Synthetic reflection smoke passes: at 192 kHz with delay 693.5 samples and a louder reflection 116 samples later, estimate is 692.492189 samples (5.249 us error). Stored Scarlett ASIO receipts run through --complex-contour-asio-f32: shotgun path prediction errors are -5.860 us and +7.247 us; cardioid errors are -15.817 us and +8.963 us. This is a better receiver architecture, but low confidence and multi-sample MAE mean the full phase/group-delay channel model is still missing."} diff --git a/state/map.yaml b/state/map.yaml index 4385f3c..78f2968 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -218,6 +218,14 @@ accepted_design: fitting, acquisition/tracking loop bandwidths, and sparse multipath residuals. Its source ledger and local mirrors live under `research/sonar-dsp-recursive-refinement-2026-05-25/`. + `MimirComplexContourMatchedFilterBank` and `MimirDirectPathTracker` are the + first implementation slice of that shape: complex anchor responses keep + multiple lobes, the current path prediction gates direct-path ownership, and + later coherent clusters are reported as reflection taps. Synthetic 192 kHz + reflection smoke currently lands about 5.249 us from expected delay; stored + Scarlett shotgun receipts land within about 5.860 us and 7.247 us of seeded + path fits, while cardioid receipts land within about 15.817 us and 8.963 us. + Low confidence is expected until phase/group-delay surfaces are learned. Acoustic robustness remains open. The old arbitrary-chirplet matcher is a diagnostic/reference artifact only, not the runtime hot path.