Build chirp-bin physical calibration model

This commit is contained in:
Meta Vens 2026-05-23 20:22:01 +01:00
commit 488fda5362
18 changed files with 3073 additions and 97 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,31 @@
"bufferSeconds": 5.0,
"audioSync": {
"mode": "hybrid",
"referenceSourceId": "loopback-scarlett-speakers",
"referenceSourceId": "asio-ch2",
"calibrationGain": 2.0,
"watermarkGain": 0.035
"watermarkGain": 0.035,
"calibrationModelPath": "../calibration/chirp-bin/scarlett-artifact.json"
},
"streams": [
{
"sourceId": "focusrite-asio",
"kind": "Audio",
"origin": "LocalDevice",
"enabled": true,
"adapter": "frame-events",
"command": "../native/probes/asio_audio_cadence/build/Release/asio_audio_cadence.exe",
"arguments": [
"--set-sample-rate",
"192000",
"--emit-json-blocks"
],
"acceptSourceIds": [
"asio-ch0",
"asio-ch1",
"asio-ch2",
"asio-ch3"
]
},
{
"sourceId": "local-ks-probe",
"kind": "Video",

View file

@ -75,11 +75,15 @@ the de Bruijn triplet trellis. A constrained local waveform correlation around
the decoded anchor delay provides the final fractional offset. The decoder keeps
the full bin-energy surface for each classified chirp and aggregates it into
per-band response evidence, so the same stream can calibrate timing and the
speaker/room/mic transfer function. `MimirChirpBinCalibrationProfile` is the
first named surface for that evidence: it summarizes frames, anchors, clock
confidence, anchor error, usable bands, and strongest measured bins per source.
The profile is kept even when a timing report is rejected, because a failed
sync window can still teach us which bands survived the speaker/room/mic path.
speaker/room/mic transfer function. `MimirChirpBinCalibrationModel` is the live
surface for that evidence. It stores measured usable bands per source,
expected-symbol versus observed-bin confusion observations, timing residuals,
delay hypotheses, phase summaries, and an adaptive codebook plan. The raw
profile is kept even when a timing report is rejected, because a failed sync
window can still teach us which bands survived the speaker/room/mic path. The
decoder can consume the persisted model to weight reliable symbols, downweight
dead bands, apply first-order group-delay correction, and seed global delay
hypotheses before selecting the coherent anchor path.
The synthetic invariant is
`dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --chirp-bin-self-test`;
it renders the chirp-bin timeline, decodes by dechirp plus Goertzel bins, and
@ -123,8 +127,11 @@ buffer, and captures nonzero 4-channel `Int32LSB` input callbacks at 192 kHz.
Raven also has a loopback-capable Scarlett ASIO path at 192 kHz for
co-streamer/game timing evidence, but Starfire owns the heavy local alignment,
soundfield, and sensor-fusion work.
The next cut is not more WASAPI probing; it is feeding those ASIO buffers
directly into `Mimir.Runtime`.
The worker now also supports `--emit-json-blocks`, which streams each ASIO
callback as sample-bearing `audio-block` events (`asio-ch0` through `asio-ch3`)
for `MimirFrameEventProcessStreamSource`. That is a continuous ASIO runtime
ingest path through the existing process boundary; the later native ABI cut
should remove stdout/base64 overhead, not change ASIO ownership.
The probe also has a `--monitor-sweep` mode for measuring the acoustic ceiling.
At 192 kHz, ASIO loopback detected emitted 8-40 kHz tones cleanly, but the
@ -174,12 +181,15 @@ that window. Reports carry rounded integer delay, fractional delay, and the
count/confidence of timeline-symbol anchors used to derive the report.
The same timeline also starts the frequency-response path. Each active chirp-bin
decode emits a calibration profile for the source, whether or not that source
earns a timing report. That is not a finished room/mic normalizer yet, but it is
the live surface that will become response-curve estimation: loopback carries
what was emitted, each mic carries what survived speaker, air, room, and
capsule, and the ratio over the continuous timeline becomes gain/phase
correction evidence.
decode can emit a persisted calibration model for the source set, whether or
not each source earns a timing report. That model is not a finished room/mic
normalizer yet, but it is the live surface that will become response-curve
estimation: loopback carries what was emitted, each mic carries what survived
speaker, air, room, and capsule, and the ratio over the continuous timeline
becomes gain/phase correction evidence. Use
`Mimir.BufferSmoke --calibrate-chirp-bin-asio-f32` to render the calibration
timeline, optionally capture it through the ASIO worker with `--capture-asio`,
compute the response/confusion/delay model, and persist it for runtime decode.
`MimirAudioSynchronizationStateTracker` turns continuous observations into
state. It confidence-gates reports, smooths fractional delay per source, and

View file

@ -102,11 +102,12 @@ a named invariant that the native runtime cannot protect yet.
with constrained local waveform correlation around the decoded active delay.
Each classified chirp carries the full bin-energy response surface, and stream
decodes aggregate that into per-band calibration evidence for frequency
response normalization. `MimirChirpBinCalibrationProfile` preserves that
evidence per source, including frames, anchors, clock confidence, anchor
error, usable band count, and strongest measured bins, even when the source
does not produce a timing report. Reports/states expose delay in microseconds
as well as fractional samples.
response normalization. `MimirChirpBinCalibrationModel` now preserves usable
bands, expected-symbol versus observed-bin confusion observations, timing
residuals, delay hypotheses, phase summaries, and an adaptive codebook plan.
The active decoder can consume that model as learned response weighting,
first-order group-delay correction, and global delay-hypothesis seeds.
Reports/states expose delay in microseconds as well as fractional samples.
Hybrid emits this as low-gain half-second bursts every two seconds only while
passive confidence is weak.
Use `--chirp-bin-self-test` to prove the codebook/decoder and
@ -116,12 +117,14 @@ a named invariant that the native runtime cannot protect yet.
- `Mimir.BufferSmoke --render-chirp-bin-f32` and `--analyze-asio-f32` provide
the current active Scarlett artifact proof. A 192 kHz chirp-bin run decoded
Focusrite `Loopback 1 -> Loopback 2` at `0.000 us` with 12 matched anchors and
0.999 confidence. The ASIO analyzer now prints chirp-bin calibration profiles
for every decoded source; physical input 1 still failed pairwise timing in
the stored artifact, but it produced 14 frames, 12 anchors, 0.865 clock
confidence, and a different strongest band set. Acoustic robustness remains
separate from the clean loopback timing proof, but failed timing windows now
leave usable response evidence instead of silence.
0.999 confidence. `--calibrate-chirp-bin-asio-f32` can render/capture/analyze
a calibration session and persist the response/confusion/delay model under
`calibration/chirp-bin/`. `--analyze-asio-f32 --calibration ...` loads that
model into the active decoder. Physical input 1 still failed pairwise timing
in the stored artifact, but it produced a useful response/confusion model
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.
A standalone 192 kHz synthetic receiver test recovers a 500 ms delayed audio
stream to below printed microsecond precision using only the chirp-bin
codebook and schedule state.

View file

@ -9,6 +9,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <mutex>
#include <thread>
#include <string>
#include <vector>
@ -127,6 +128,8 @@ struct CaptureState
std::vector<float>* recordedInput = nullptr;
unsigned long long recordFrames = 0;
unsigned long long sweepFrame = 0;
bool emitJsonBlocks = false;
std::mutex emitMutex;
std::atomic<unsigned long long> callbacks{0};
std::atomic<unsigned long long> frames{0};
std::atomic<unsigned long long> nonZeroSamples{0};
@ -219,6 +222,51 @@ void writeSample(unsigned char* sample, ASIOSampleType type, double value)
}
}
std::string base64Encode(const unsigned char* data, size_t size)
{
static constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string output;
output.reserve(((size + 2) / 3) * 4);
for (size_t index = 0; index < size; index += 3)
{
const unsigned int a = data[index];
const unsigned int b = index + 1 < size ? data[index + 1] : 0;
const unsigned int c = index + 2 < size ? data[index + 2] : 0;
const unsigned int triple = (a << 16) | (b << 8) | c;
output.push_back(alphabet[(triple >> 18) & 0x3f]);
output.push_back(alphabet[(triple >> 12) & 0x3f]);
output.push_back(index + 1 < size ? alphabet[(triple >> 6) & 0x3f] : '=');
output.push_back(index + 2 < size ? alphabet[triple & 0x3f] : '=');
}
return output;
}
void emitJsonAudioBlock(size_t channel, unsigned long long startFrame, const std::vector<float>& samples)
{
if (!g_capture.emitJsonBlocks || g_capture.sampleRate <= 0.0 || samples.empty())
{
return;
}
const auto timestampNs = static_cast<unsigned long long>(
(static_cast<double>(startFrame) / g_capture.sampleRate) * 1000000000.0);
const auto encoded = base64Encode(
reinterpret_cast<const unsigned char*>(samples.data()),
samples.size() * sizeof(float));
std::lock_guard<std::mutex> lock(g_capture.emitMutex);
std::printf(
"{\"type\":\"audio-block\",\"sourceId\":\"asio-ch%zu\",\"timestampNs\":%llu,\"sequence\":%llu,\"sampleRate\":%.0f,\"channels\":1,\"sampleFormat\":\"Float32\",\"frameCount\":%zu,\"byteLength\":%zu,\"samplesBase64\":\"%s\"}\n",
channel,
timestampNs,
startFrame / static_cast<unsigned long long>(std::max(1L, g_capture.bufferSize)),
g_capture.sampleRate,
samples.size(),
samples.size() * sizeof(float),
encoded.c_str());
std::fflush(stdout);
}
void processOutput(long doubleBufferIndex)
{
const bool hasPlayback = g_capture.playbackSamples && !g_capture.playbackSamples->empty();
@ -335,6 +383,7 @@ void processSweepInput(long doubleBufferIndex)
void bufferSwitch(long doubleBufferIndex, ASIOBool)
{
const auto callbackStartFrame = g_capture.frames.load(std::memory_order_relaxed);
g_capture.callbacks.fetch_add(1, std::memory_order_relaxed);
g_capture.frames.fetch_add(static_cast<unsigned long long>(g_capture.bufferSize), std::memory_order_relaxed);
processOutput(doubleBufferIndex);
@ -357,13 +406,25 @@ void bufferSwitch(long doubleBufferIndex, ASIOBool)
continue;
}
std::vector<float> emittedSamples;
if (g_capture.emitJsonBlocks)
{
emittedSamples.resize(static_cast<size_t>(g_capture.bufferSize));
}
for (long frame = 0; frame < g_capture.bufferSize; ++frame)
{
const auto absoluteFrame = g_capture.sweepFrame + static_cast<unsigned long long>(frame);
const auto value = static_cast<float>(readSample(raw + frame * sampleBytes, info.type));
if (g_capture.recordedInput && absoluteFrame < g_capture.recordFrames)
{
(*g_capture.recordedInput)[static_cast<size_t>(absoluteFrame) * static_cast<size_t>(g_capture.inputCount) + channel] =
static_cast<float>(readSample(raw + frame * sampleBytes, info.type));
value;
}
if (g_capture.emitJsonBlocks)
{
emittedSamples[static_cast<size_t>(frame)] = value;
}
if (sampleIsNonZero(raw + frame * sampleBytes, sampleBytes))
@ -371,6 +432,8 @@ void bufferSwitch(long doubleBufferIndex, ASIOBool)
++nonZero;
}
}
emitJsonAudioBlock(channel, callbackStartFrame, emittedSamples);
}
g_capture.nonZeroSamples.fetch_add(nonZero, std::memory_order_relaxed);
}
@ -509,6 +572,7 @@ int main(int argc, char** argv)
const auto captureSeconds = doubleOption(argc, argv, "--capture-seconds", 0.0);
const auto setSampleRate = doubleOption(argc, argv, "--set-sample-rate", 0.0);
const bool monitorSweep = hasArg(argc, argv, "--monitor-sweep");
const bool emitJsonBlocks = hasArg(argc, argv, "--emit-json-blocks");
const char* playFloat32MonoPath = option(argc, argv, "--play-f32-mono", nullptr);
const char* recordFloat32InterleavedPath = option(argc, argv, "--record-f32-interleaved", nullptr);
const auto playGain = doubleOption(argc, argv, "--play-gain", 1.0);
@ -670,7 +734,7 @@ int main(int argc, char** argv)
std::printf("asio playback-f32-mono: path=%s samples=%zu gain=%.5f\n", playFloat32MonoPath, playbackSamples.size(), playGain);
}
if (captureSeconds > 0.0 || monitorSweep || !playbackSamples.empty())
if (captureSeconds > 0.0 || monitorSweep || !playbackSamples.empty() || emitJsonBlocks)
{
std::vector<double> sweepFrequencies;
if (monitorSweep)
@ -681,6 +745,10 @@ int main(int argc, char** argv)
auto runSeconds = monitorSweep
? (sweepToneSeconds + sweepGapSeconds) * static_cast<double>(sweepFrequencies.size()) + 0.25
: captureSeconds;
if (runSeconds <= 0.0 && emitJsonBlocks)
{
runSeconds = 86400.0;
}
if (runSeconds <= 0.0 && !playbackSamples.empty() && currentRate > 0.0)
{
runSeconds = static_cast<double>(playbackSamples.size()) / currentRate;
@ -739,8 +807,8 @@ int main(int argc, char** argv)
{
const auto requestedFrames = static_cast<unsigned long long>(std::ceil(runSeconds * currentRate)) + static_cast<unsigned long long>(preferredSize);
recordedInput.assign(static_cast<size_t>(requestedFrames) * static_cast<size_t>(inputs), 0.0f);
g_capture.recordedInput = &recordedInput;
g_capture.recordFrames = requestedFrames;
g_capture.recordedInput = &recordedInput;
g_capture.recordFrames = requestedFrames;
}
g_capture.bufferSize = preferredSize;
g_capture.inputCount = inputs;
@ -752,6 +820,7 @@ int main(int argc, char** argv)
g_capture.sweepGain = sweepGain;
g_capture.playbackSamples = playbackSamples.empty() ? nullptr : &playbackSamples;
g_capture.playbackGain = playGain;
g_capture.emitJsonBlocks = emitJsonBlocks;
g_capture.sweepFrame = 0;
g_capture.sweepEnergy.assign(sweepFrequencies.size() * static_cast<size_t>(inputs), 0.0);
g_capture.sweepPeak.assign(sweepFrequencies.size() * static_cast<size_t>(inputs), 0.0);
@ -854,6 +923,7 @@ int main(int argc, char** argv)
g_capture.outputChannels = nullptr;
g_capture.playbackSamples = nullptr;
g_capture.recordedInput = nullptr;
g_capture.emitJsonBlocks = false;
driver->disposeBuffers();
}

View file

@ -111,12 +111,13 @@ scheduled-waveform correlation for standalone source-offset refinement. Pairwise
sync compares matched anchors first, then only accepts independent clock-fit
offsets inside the live latency horizon so period aliases do not become absurd
reports. Each classified chirp carries the full dechirped bin-energy surface,
and decodes aggregate that into a `MimirChirpBinCalibrationProfile` per source
with frames, anchors, clock confidence, anchor error, usable band count, and
strongest bins. The analyzer keeps those profiles even when no timing report is
accepted, so a physical mic path can fail synchronization while still teaching
the next decoder which bands survived. Reports/states expose `delayUs` next to
fractional sample delay.
and decodes aggregate that into a `MimirChirpBinCalibrationModel` with measured
usable bands, expected-symbol versus observed-bin confusion observations,
timing residuals, delay hypotheses, phase summaries, and an adaptive codebook
plan. The analyzer keeps raw profiles even when no timing report is accepted,
and the active decoder can consume a persisted model for learned response
weighting, first-order group-delay correction, and global delay-hypothesis
seeding. Reports/states expose `delayUs` next to fractional sample delay.
`Mimir.BufferSmoke
--chirp-only-sync-self-test --sample-rate 192000` and `--hybrid-sync-self-test
--sample-rate 192000` both recover a 1269.5-sample synthetic delay with printed
@ -158,7 +159,13 @@ evidence; Starfire still owns the heavy soundfield and sensor-fusion work.
The ASIO probe can now play raw mono Float32 timeline audio through the
Focusrite outputs while capturing every ASIO input as raw interleaved Float32.
`Mimir.BufferSmoke --analyze-asio-f32` feeds those captured channels into the
same runtime active analyzer. A real 192 kHz chirp-bin Scarlett artifact decoded
same runtime active analyzer. `--calibrate-chirp-bin-asio-f32` computes and
persists the response/confusion/delay model, and `--analyze-asio-f32
--calibration ...` loads it into the active decoder. The ASIO worker also
supports `--emit-json-blocks`, so Focusrite ASIO callbacks can feed
sample-bearing `audio-block` events into `Mimir.Runtime` continuously through
the existing frame-event adapter while the native ABI cut is pending. A real
192 kHz chirp-bin Scarlett artifact decoded
`Loopback 1 -> Loopback 2` at exactly `0.000 us` with 12 matched anchors and
0.999 confidence. The same analyzer now prints calibration profiles for
decoded sources. Physical input 1 still fails pairwise timing in the stored

View file

@ -90,8 +90,11 @@ Get-Content .\state\evidence.jsonl -Tail 8
correlation for final fractional delay. It also supports standalone source
offset recovery from schedule/codebook state, which is the Raven/phone shape.
Each classified chirp carries the full dechirped bin-energy surface; sync
reports expose those bands, and `MimirChirpBinCalibrationProfile` preserves
per-source calibration evidence even when timing is rejected.
reports expose those bands, and `MimirChirpBinCalibrationModel` preserves
measured usable bands, response/confusion observations, timing residuals,
delay hypotheses, phase summaries, and adaptive codebook plans. The active
decoder can load the model for learned weighting, first-order group-delay
correction, and global delay-hypothesis seeds.
`Mimir.BufferSmoke
--chirp-only-sync-self-test --sample-rate 192000` and
`--hybrid-sync-self-test --sample-rate 192000` both recover a 1269.5-sample
@ -119,20 +122,22 @@ Get-Content .\state\evidence.jsonl -Tail 8
comparison and is now diagnostic only. A real Scarlett chirp-bin artifact run
at 192 kHz decoded `Loopback 1 -> Loopback 2` at `0.000 us` delay with 12
matched anchors and 0.999 confidence in the normal active analyzer. Physical
inputs still do not earn accepted timing against loopback, but the active
analyzer now prints calibration profiles for decoded channels. In the stored
artifact, physical input 1 produced 14 frames, 12 anchors, 0.865 clock
confidence, and a different strongest-bin profile while failing pairwise
timing. Acoustic robustness is still open, but failed timing now leaves
response evidence.
inputs still do not earn accepted timing against loopback, but
`--calibrate-chirp-bin-asio-f32` now persists a real response/confusion/delay
model and `--analyze-asio-f32 --calibration ...` consumes it. In the stored
artifact, physical input 1 produced two reliable symbols and a different
strongest-bin profile while failing pairwise timing. The ASIO native worker
also supports `--emit-json-blocks` so `Mimir.Runtime` can ingest Focusrite ASIO
callbacks continuously through the frame-event adapter. Acoustic robustness is
still open, but failed timing now leaves response evidence.
- Raven also has a 192 kHz loopback-capable Scarlett for co-streamer/game timing
evidence. Do not move the heavy soundfield or sensor-fusion workload there.
## Immediate Re-entry Instruction
Use the chirp-bin calibration profiles from real microphones to tune the active
codebook/bands/weighting, then feed Scarlett ASIO callbacks into `Mimir.Runtime`
and turn decoded clock fits into the SRO/fractional-delay actuator. Keep
loopback as timing authority. Do not call synchronization analysis from
UI/telemetry readouts. Do not restore deleted script infrastructure because a
stale doc once missed it.
Use the chirp-bin calibration models from real microphones to tune the active
codebook/bands/weighting, then replace the ASIO JSON-block process bridge with
a native ABI callback source and turn decoded clock fits into the
SRO/fractional-delay actuator. Keep loopback as timing authority. Do not call
synchronization analysis from UI/telemetry readouts. Do not restore deleted
script infrastructure because a stale doc once missed it.

View file

@ -1,3 +1,4 @@
using System.Diagnostics;
using Mimir.Runtime.Synchronization;
if (args.Any(arg => string.Equals(arg, "--chirplet-self-test", StringComparison.OrdinalIgnoreCase)))
@ -55,7 +56,19 @@ if (args.Any(arg => string.Equals(arg, "--analyze-asio-f32", StringComparison.Or
ParseIntOption(args, "--sample-rate", MimirChirpletTimeline.SampleRate),
ParseIntOption(args, "--channels", 4),
ParseIntOption(args, "--reference-channel", 2),
ParseIntOption(args, "--candidate-channel", -1));
ParseIntOption(args, "--candidate-channel", -1),
ParseStringOption(args, "--calibration", ""));
}
if (args.Any(arg => string.Equals(arg, "--calibrate-chirp-bin-asio-f32", StringComparison.OrdinalIgnoreCase)))
{
return CalibrateChirpBinAsioFloat32(
args,
ParseStringOption(args, "--input", "artifacts/asio/scarlett-chirp-bin-192k-f32.raw"),
ParseStringOption(args, "--output", "calibration/chirp-bin/latest.json"),
ParseIntOption(args, "--sample-rate", MimirChirpBinTimeline.SampleRate),
ParseIntOption(args, "--channels", 4),
ParseIntOption(args, "--reference-channel", 2));
}
var duration = TimeSpan.FromSeconds(ParseDoubleOption(args, "--seconds", 10.0));
@ -474,7 +487,13 @@ static int RenderChirpBinFloat32(string outputPath, int sampleRate, double secon
return 0;
}
static int AnalyzeAsioFloat32(string inputPath, int sampleRate, int channels, int referenceChannel, int candidateChannel)
static int AnalyzeAsioFloat32(
string inputPath,
int sampleRate,
int channels,
int referenceChannel,
int candidateChannel,
string calibrationPath)
{
if (channels <= 0)
{
@ -533,9 +552,12 @@ static int AnalyzeAsioFloat32(string inputPath, int sampleRate, int channels, in
? new HashSet<string>(StringComparer.Ordinal) { $"asio-ch{candidateChannel}" }
: null;
var analyzer = new MimirAudioSynchronizationAnalyzer();
var reports = analyzer.Analyze(buffers, referenceSourceId, MimirAudioSyncMode.ChirpOnly, candidates);
var calibration = string.IsNullOrWhiteSpace(calibrationPath) || !File.Exists(calibrationPath)
? null
: MimirChirpBinCalibrationModel.Load(calibrationPath);
var reports = analyzer.Analyze(buffers, referenceSourceId, MimirAudioSyncMode.ChirpOnly, candidates, calibration);
Console.WriteLine($"asio-f32-analysis input={inputPath} sampleRate={sampleRate} channels={channels} frames={frameCount} reference={referenceSourceId} candidate={(candidateChannel >= 0 ? $"asio-ch{candidateChannel}" : "all")}");
Console.WriteLine($"asio-f32-analysis input={inputPath} sampleRate={sampleRate} channels={channels} frames={frameCount} reference={referenceSourceId} candidate={(candidateChannel >= 0 ? $"asio-ch{candidateChannel}" : "all")} calibration={(calibration == null ? "none" : calibrationPath)}");
foreach (var trace in analyzer.LastDecodeTraces.OrderBy(trace => trace.SourceId, StringComparer.Ordinal))
{
Console.WriteLine(
@ -556,6 +578,152 @@ static int AnalyzeAsioFloat32(string inputPath, int sampleRate, int channels, in
return reports.Count > 0 ? 0 : 1;
}
static int CalibrateChirpBinAsioFloat32(
string[] args,
string inputPath,
string outputPath,
int sampleRate,
int channels,
int referenceChannel)
{
if (args.Any(arg => string.Equals(arg, "--capture-asio", StringComparison.OrdinalIgnoreCase)))
{
var probe = ParseStringOption(
args,
"--asio-probe",
"native/probes/asio_audio_cadence/build/Release/asio_audio_cadence.exe");
var seconds = ParseDoubleOption(args, "--seconds", 6.0);
var gain = ParseDoubleOption(args, "--gain", 1.0);
var renderPath = Path.Combine("artifacts", "asio", $"chirp-bin-calibration-{sampleRate}.raw");
RenderChirpBinFloat32(renderPath, sampleRate, seconds);
var probeExit = RunAsioProbeCapture(probe, renderPath, inputPath, sampleRate, seconds, gain);
if (probeExit != 0)
{
return probeExit;
}
}
var channelSamples = ReadInterleavedFloat32(inputPath, channels, out var frameCount);
if (frameCount == 0)
{
Console.Error.WriteLine("chirp-bin calibration failed: capture file contains no complete frames");
return 1;
}
if (referenceChannel < 0 || referenceChannel >= channels)
{
Console.Error.WriteLine("chirp-bin calibration failed: reference channel is outside the capture channel count");
return 1;
}
var decodes = new Dictionary<string, MimirChirpletStreamDecode>(StringComparer.Ordinal);
for (var channel = 0; channel < channels; channel++)
{
decodes[$"asio-ch{channel}"] = MimirChirpBinTimeline.Default.DecodeStreamWindow(channelSamples[channel], sampleRate);
}
var referenceSourceId = $"asio-ch{referenceChannel}";
var model = MimirChirpBinCalibrationModel.FromDecodes(referenceSourceId, sampleRate, decodes);
model.Save(outputPath);
Console.WriteLine($"chirp-bin-calibration path={outputPath} input={inputPath} sampleRate={sampleRate} channels={channels} frames={frameCount} reference={referenceSourceId}");
foreach (var path in model.Paths)
{
var reliable = path.CodebookPlan.ReliableSymbolIds.Count == 0
? "none"
: string.Join(",", path.CodebookPlan.ReliableSymbolIds.Take(16));
Console.WriteLine(
$"chirp-bin-calibration-path {path.SourceId}: frames={path.Profile.FrameCount} anchors={path.Profile.AnchorCount} clock={path.Profile.ClockConfidence:0.000} usableBins={path.Profile.UsableBandCount}/{path.Profile.Bands.Count} symbols={path.Symbols.Count} reliableSymbols={path.CodebookPlan.ReliableSymbolCount} recommendedOrder={path.CodebookPlan.RecommendedOrder} reliable={reliable}");
foreach (var hypothesis in path.DelayHypotheses.Take(3))
{
Console.WriteLine(
$"chirp-bin-delay-hypothesis {path.SourceId}: delaySamples={hypothesis.DelaySamples:0.000} binShift={hypothesis.BinShift} support={hypothesis.SupportCount} confidence={hypothesis.Confidence:0.000} residual={hypothesis.MeanResidualSamples:0.000}");
}
}
return 0;
}
static int RunAsioProbeCapture(
string probePath,
string playbackPath,
string capturePath,
int sampleRate,
double seconds,
double gain)
{
var startInfo = new ProcessStartInfo(Path.GetFullPath(probePath))
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("--set-sample-rate");
startInfo.ArgumentList.Add(sampleRate.ToString());
startInfo.ArgumentList.Add("--play-f32-mono");
startInfo.ArgumentList.Add(Path.GetFullPath(playbackPath));
startInfo.ArgumentList.Add("--play-gain");
startInfo.ArgumentList.Add(gain.ToString("0.#####"));
startInfo.ArgumentList.Add("--record-f32-interleaved");
startInfo.ArgumentList.Add(Path.GetFullPath(capturePath));
startInfo.ArgumentList.Add("--capture-seconds");
startInfo.ArgumentList.Add(seconds.ToString("0.###"));
using var process = Process.Start(startInfo);
if (process == null)
{
Console.Error.WriteLine($"chirp-bin calibration failed: could not start ASIO probe {probePath}");
return 1;
}
process.OutputDataReceived += (_, e) =>
{
if (e.Data != null)
{
Console.WriteLine(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null)
{
Console.Error.WriteLine(e.Data);
}
};
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
return process.ExitCode;
}
static float[][] ReadInterleavedFloat32(string inputPath, int channels, out int frameCount)
{
if (channels <= 0)
{
frameCount = 0;
return [];
}
var bytes = File.ReadAllBytes(inputPath);
frameCount = bytes.Length / (sizeof(float) * channels);
var channelSamples = new float[channels][];
for (var channel = 0; channel < channels; channel++)
{
channelSamples[channel] = new float[frameCount];
}
var span = bytes.AsSpan(0, frameCount * channels * sizeof(float));
for (var frame = 0; frame < frameCount; frame++)
{
for (var channel = 0; channel < channels; channel++)
{
channelSamples[channel][frame] = BitConverter.ToSingle(span.Slice((frame * channels + channel) * sizeof(float), sizeof(float)));
}
}
return channelSamples;
}
static int ParseIntOption(string[] args, string name, int fallback)
{
for (var index = 0; index < args.Length - 1; index++)

View file

@ -23,7 +23,8 @@ public sealed class MimirAudioSynchronizationAnalyzer
IEnumerable<MimirRollingStreamBuffer> buffers,
string referenceSourceId,
MimirAudioSyncMode mode = MimirAudioSyncMode.ChirpOnly,
IReadOnlySet<string>? candidateSourceIds = null)
IReadOnlySet<string>? candidateSourceIds = null,
MimirChirpBinCalibrationModel? calibrationModel = null)
{
var audioBuffers = buffers
.Where(buffer => buffer.Descriptor.Kind == MimirStreamKind.Audio)
@ -168,10 +169,16 @@ public sealed class MimirAudioSynchronizationAnalyzer
}
var comparedReferenceDecode = useChirpBinTimeline
? MimirChirpBinTimeline.Default.DecodeStreamWindow(referenceWindow, activeReferenceBlock.SampleRate)
? MimirChirpBinTimeline.Default.DecodeStreamWindow(
referenceWindow,
activeReferenceBlock.SampleRate,
calibrationModel?.PathFor(reference.Descriptor.SourceId))
: MimirChirpletTimeline.Default.DecodeStreamWindow(referenceWindow, activeReferenceBlock.SampleRate);
var candidateDecode = useChirpBinTimeline
? MimirChirpBinTimeline.Default.DecodeStreamWindow(candidateWindow, activeReferenceBlock.SampleRate)
? MimirChirpBinTimeline.Default.DecodeStreamWindow(
candidateWindow,
activeReferenceBlock.SampleRate,
calibrationModel?.PathFor(buffer.Descriptor.SourceId))
: MimirChirpletTimeline.Default.DecodeStreamWindow(candidateWindow, activeReferenceBlock.SampleRate);
if (useChirpBinTimeline)
{

View file

@ -0,0 +1,225 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Mimir.Runtime.Synchronization;
public sealed record MimirChirpBinCalibrationModel(
string ModelId,
DateTimeOffset CreatedAt,
int SampleRate,
string ReferenceSourceId,
IReadOnlyList<MimirChirpBinPathCalibration> Paths)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
public MimirChirpBinPathCalibration? PathFor(string sourceId) =>
Paths.FirstOrDefault(path => string.Equals(path.SourceId, sourceId, StringComparison.Ordinal));
public static MimirChirpBinCalibrationModel Load(string path) =>
JsonSerializer.Deserialize<MimirChirpBinCalibrationModel>(File.ReadAllText(path), JsonOptions)
?? throw new InvalidOperationException($"Could not read chirp-bin calibration model: {path}");
public void Save(string path)
{
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(path, JsonSerializer.Serialize(this, JsonOptions));
}
public static MimirChirpBinCalibrationModel FromDecodes(
string referenceSourceId,
int sampleRate,
IReadOnlyDictionary<string, MimirChirpletStreamDecode> decodes)
{
var paths = decodes
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => MimirChirpBinPathCalibration.FromDecode(pair.Key, sampleRate, pair.Value))
.ToArray();
return new MimirChirpBinCalibrationModel(
$"chirp-bin-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssZ}",
DateTimeOffset.UtcNow,
sampleRate,
referenceSourceId,
paths);
}
}
public sealed record MimirChirpBinPathCalibration(
string SourceId,
int SampleRate,
MimirChirpBinCalibrationProfile Profile,
MimirChirpBinCodebookPlan CodebookPlan,
IReadOnlyList<MimirChirpBinSymbolCalibration> Symbols,
IReadOnlyList<MimirChirpBinConfusionObservation> Confusion,
IReadOnlyList<MimirChirpBinDelayHypothesis> DelayHypotheses)
{
public double SymbolWeight(int symbolId)
{
var symbol = Symbols.FirstOrDefault(item => item.SymbolId == symbolId);
return symbol == null ? 1.0 : Math.Clamp(symbol.Reliability, 0.05, 1.0);
}
public double GroupDelayCorrectionSamples(int symbolId)
{
var symbol = Symbols.FirstOrDefault(item => item.SymbolId == symbolId);
return symbol?.MeanTimingResidualSamples ?? 0.0;
}
public static MimirChirpBinPathCalibration FromDecode(
string sourceId,
int sampleRate,
MimirChirpletStreamDecode decode)
{
var profile = MimirChirpBinCalibrationProfile.FromDecode(sourceId, sampleRate, decode);
var observations = MimirChirpBinTimeline.Default.CalibrationObservations(decode, sampleRate);
var symbols = observations
.GroupBy(observation => observation.ExpectedSymbolId)
.Select(group =>
{
var items = group.ToArray();
var meanConfidence = items.Average(item => item.Confidence);
var meanResidual = items.Average(item => item.TimingResidualSamples);
var residualSpread = items.Average(item => Math.Abs(item.TimingResidualSamples - meanResidual));
var correct = items.Count(item => item.ExpectedSymbolId == item.ObservedSymbolId);
var reliability = meanConfidence *
(correct / (double)items.Length) *
(1.0 / (1.0 + residualSpread / Math.Max(1.0, sampleRate * 0.00025)));
var phase = CircularMean(items.Select(item => item.PhaseRadians));
return new MimirChirpBinSymbolCalibration(
group.Key,
reliability,
items.Average(item => item.ExpectedCenterHz),
items.Average(item => item.ObservedCenterHz),
items.Average(item => item.ObservedEnergy),
meanResidual,
residualSpread,
phase,
items.Length);
})
.OrderBy(symbol => symbol.SymbolId)
.ToArray();
var hypotheses = BuildDelayHypotheses(decode);
var plan = MimirChirpBinCodebookPlan.FromSymbols(symbols);
return new MimirChirpBinPathCalibration(
sourceId,
sampleRate,
profile,
plan,
symbols,
observations,
hypotheses);
}
private static IReadOnlyList<MimirChirpBinDelayHypothesis> BuildDelayHypotheses(MimirChirpletStreamDecode decode)
{
if (decode.ClockFit == null)
{
return [];
}
var liveHorizonSamples = decode.ClockFit.EffectiveSampleRate * 10.0;
var hypotheses = decode.Anchors
.GroupBy(anchor => Math.Round(anchor.SampleOffset - anchor.TimelineSeconds * decode.ClockFit.EffectiveSampleRate))
.Select(group =>
{
var delay = group.Average(anchor => anchor.SampleOffset - anchor.TimelineSeconds * decode.ClockFit!.EffectiveSampleRate);
var residual = group.Average(anchor => Math.Abs(anchor.SampleOffset - (delay + anchor.TimelineSeconds * decode.ClockFit!.EffectiveSampleRate)));
return new MimirChirpBinDelayHypothesis(
delay,
0,
group.Count(),
group.Average(anchor => anchor.Confidence),
residual);
})
.Where(hypothesis => Math.Abs(hypothesis.DelaySamples) <= liveHorizonSamples)
.OrderByDescending(hypothesis => hypothesis.Confidence * hypothesis.SupportCount)
.Take(8)
.ToArray();
if (hypotheses.Length > 0)
{
return hypotheses;
}
return Math.Abs(decode.ClockFit.SourceOffsetSamples) <= liveHorizonSamples
? [new MimirChirpBinDelayHypothesis(decode.ClockFit.SourceOffsetSamples, 0, decode.ClockFit.AnchorCount, decode.ClockFit.Confidence, decode.ClockFit.MeanAbsoluteErrorSamples)]
: [];
}
private static double CircularMean(IEnumerable<double> radians)
{
var sin = 0.0;
var cos = 0.0;
var count = 0;
foreach (var value in radians)
{
sin += Math.Sin(value);
cos += Math.Cos(value);
count++;
}
return count == 0 ? 0.0 : Math.Atan2(sin / count, cos / count);
}
}
public sealed record MimirChirpBinCodebookPlan(
int ReliableSymbolCount,
int RecommendedOrder,
IReadOnlyList<int> ReliableSymbolIds)
{
public static MimirChirpBinCodebookPlan FromSymbols(IReadOnlyList<MimirChirpBinSymbolCalibration> symbols)
{
var reliable = symbols
.Where(symbol => symbol.Reliability >= 0.20)
.OrderByDescending(symbol => symbol.Reliability)
.ThenBy(symbol => symbol.SymbolId)
.Select(symbol => symbol.SymbolId)
.ToArray();
var count = Math.Max(2, reliable.Length);
var order = 3;
while (Math.Pow(count, order) < 120_000.0 && order < 8)
{
order++;
}
return new MimirChirpBinCodebookPlan(reliable.Length, order, reliable);
}
}
public sealed record MimirChirpBinSymbolCalibration(
int SymbolId,
double Reliability,
double ExpectedCenterHz,
double ObservedCenterHz,
double MeanEnergy,
double MeanTimingResidualSamples,
double TimingResidualSpreadSamples,
double MeanPhaseRadians,
int ObservationCount);
public sealed record MimirChirpBinConfusionObservation(
ulong EventIndex,
int ExpectedSymbolId,
int ObservedSymbolId,
double ExpectedCenterHz,
double ObservedCenterHz,
double ObservedEnergy,
double TimingResidualSamples,
double Confidence,
double DelayHypothesisSamples,
double PhaseRadians);
public sealed record MimirChirpBinDelayHypothesis(
double DelaySamples,
int BinShift,
int SupportCount,
double Confidence,
double MeanResidualSamples);

View file

@ -71,7 +71,7 @@ public sealed record MimirChirpBinCalibrationProfile(
decode.Frames.Count,
decode.Anchors.Count,
decode.ClockFit?.Confidence ?? 0.0,
decode.ClockFit?.MeanAbsoluteErrorSamples ?? double.NaN,
decode.ClockFit?.MeanAbsoluteErrorSamples ?? 0.0,
bands);
}
}

View file

@ -15,6 +15,10 @@ internal sealed record ChirpBinKernelSet(
ChirpBinKernel[] Symbols,
double ReferenceEnergy);
internal sealed record ChirpBinScore(
double Energy,
double PhaseRadians);
public sealed class MimirChirpBinTimeline
{
public const int SampleRate = 48_000;
@ -57,21 +61,24 @@ public sealed class MimirChirpBinTimeline
return samples;
}
public MimirChirpletStreamDecode DecodeStreamWindow(ReadOnlySpan<float> samples, int sampleRate)
public MimirChirpletStreamDecode DecodeStreamWindow(
ReadOnlySpan<float> samples,
int sampleRate,
MimirChirpBinPathCalibration? calibration = null)
{
if (samples.Length == 0)
{
return new MimirChirpletStreamDecode([], [], [], null, []);
}
var frames = DetectFrames(samples, sampleRate);
var frames = DetectFrames(samples, sampleRate, calibration);
var symbols = frames
.Select(frame => new MimirChirpletSymbolObservation(
frame.BestCandidate.SymbolId,
frame.SampleOffset,
frame.BestCandidate.Energy))
.ToArray();
var anchors = DecodeAnchors(frames, sampleRate);
var anchors = DecodeAnchors(frames, sampleRate, calibration);
var clock = FitClock(anchors, sampleRate);
if (clock != null)
{
@ -82,6 +89,44 @@ public sealed class MimirChirpBinTimeline
return new MimirChirpletStreamDecode(frames, symbols, anchors, clock, EstimateBandResponse(frames));
}
public IReadOnlyList<MimirChirpBinConfusionObservation> CalibrationObservations(
MimirChirpletStreamDecode decode,
int sampleRate)
{
if (decode.ClockFit == null)
{
return [];
}
var observations = new List<MimirChirpBinConfusionObservation>(decode.Frames.Count);
foreach (var frame in decode.Frames)
{
if (!TryExpectedEventForSample(frame.SampleOffset, sampleRate, decode.ClockFit, out var timelineEvent))
{
continue;
}
var best = frame.BestCandidate;
var bestBand = best.BandResponses?
.OrderByDescending(response => response.Energy)
.FirstOrDefault();
var expectedSample = decode.ClockFit.SampleForTimelineSeconds(timelineEvent.StartSeconds);
observations.Add(new MimirChirpBinConfusionObservation(
timelineEvent.Index,
timelineEvent.SymbolId,
best.SymbolId,
SymbolCenterHz(Symbols[timelineEvent.SymbolId]),
bestBand?.CenterHz ?? SymbolCenterHz(Symbols[best.SymbolId]),
bestBand?.Energy ?? best.Energy,
frame.SampleOffset - expectedSample,
best.Energy,
decode.ClockFit.SourceOffsetSamples,
bestBand?.PhaseRadians ?? 0.0));
}
return observations;
}
public MimirChirpletTimelineEvent EventForIndex(ulong eventIndex)
{
var symbolId = SymbolForEvent(eventIndex);
@ -118,7 +163,10 @@ public sealed class MimirChirpBinTimeline
return events;
}
private static IReadOnlyList<MimirChirpletTransformFrame> DetectFrames(ReadOnlySpan<float> samples, int sampleRate)
private static IReadOnlyList<MimirChirpletTransformFrame> DetectFrames(
ReadOnlySpan<float> samples,
int sampleRate,
MimirChirpBinPathCalibration? calibration)
{
var chirpSamples = Math.Max(1, (int)Math.Round(ChirpDurationSeconds * sampleRate));
if (samples.Length < chirpSamples)
@ -149,7 +197,7 @@ public sealed class MimirChirpBinTimeline
.Take(maxExpectedEvents)
.Order())
{
var frame = ClassifyAt(samples, sampleRate, proposal, sampleRate / 1_000);
var frame = ClassifyAt(samples, sampleRate, proposal, sampleRate / 1_000, calibration);
if (frame != null)
{
frames.Add(frame);
@ -163,7 +211,8 @@ public sealed class MimirChirpBinTimeline
ReadOnlySpan<float> samples,
int sampleRate,
int predictedOffset,
int searchRadiusSamples)
int searchRadiusSamples,
MimirChirpBinPathCalibration? calibration)
{
var chirpSamples = Math.Max(1, (int)Math.Round(ChirpDurationSeconds * sampleRate));
if (samples.Length < chirpSamples)
@ -179,7 +228,7 @@ public sealed class MimirChirpBinTimeline
var step = Math.Max(2, sampleRate / 4_000);
for (var offset = start; offset <= end; offset += step)
{
var candidates = ScoreBins(samples.Slice(offset, chirpSamples), sampleRate, offset);
var candidates = ScoreBins(samples.Slice(offset, chirpSamples), sampleRate, offset, calibration);
var energy = candidates.Length == 0 ? 0.0 : candidates[0].Energy;
if (energy > bestEnergy || PreferLaterPlateauOffset(sampleRate, energy, bestEnergy, offset, bestOffset))
{
@ -194,7 +243,7 @@ public sealed class MimirChirpBinTimeline
var localEnd = Math.Min(end, bestOffset + localStep * 4);
for (var offset = localStart; offset <= localEnd; offset += localStep)
{
var candidates = ScoreBins(samples.Slice(offset, chirpSamples), sampleRate, offset);
var candidates = ScoreBins(samples.Slice(offset, chirpSamples), sampleRate, offset, calibration);
var energy = candidates.Length == 0 ? 0.0 : candidates[0].Energy;
if (energy > bestEnergy || PreferLaterPlateauOffset(sampleRate, energy, bestEnergy, offset, bestOffset))
{
@ -228,7 +277,8 @@ public sealed class MimirChirpBinTimeline
var correctedEnergy = ScoreSymbolEnergy(
samples.Slice((int)Math.Round(correctedOffset), chirpSamples),
sampleRate,
canonicalSymbol);
canonicalSymbol) * (calibration?.SymbolWeight(canonicalSymbol) ?? 1.0);
correctedOffset -= calibration?.GroupDelayCorrectionSamples(canonicalSymbol) ?? 0.0;
correctedCandidates.Add(new MimirChirpletSymbolCandidate(
canonicalSymbol,
correctedOffset,
@ -247,10 +297,11 @@ public sealed class MimirChirpBinTimeline
private static MimirChirpletSymbolCandidate[] ScoreBins(
ReadOnlySpan<float> samples,
int sampleRate,
int sampleOffset)
int sampleOffset,
MimirChirpBinPathCalibration? calibration)
{
var kernels = KernelSets.GetOrAdd(sampleRate, BuildKernelSet);
var scores = new double[Symbols.Length];
var scores = new ChirpBinScore[Symbols.Length];
var sampleEnergy = 0.0;
for (var index = 0; index < samples.Length; index++)
{
@ -265,15 +316,23 @@ public sealed class MimirChirpBinTimeline
for (var symbol = 0; symbol < Symbols.Length; symbol++)
{
var score = DechirpedBin(samples, kernels.Symbols[symbol]);
scores[symbol] = 2.0 * score / Math.Sqrt(sampleEnergy * kernels.ReferenceEnergy);
var energy = 2.0 * score.Energy / Math.Sqrt(sampleEnergy * kernels.ReferenceEnergy);
scores[symbol] = score with { Energy = energy };
}
var bandResponses = scores
.Select((energy, symbol) => new MimirChirpletBandResponse(SymbolCenterHz(Symbols[symbol]), energy))
.Select((score, symbol) => new MimirChirpletBandResponse(
SymbolCenterHz(Symbols[symbol]),
score.Energy,
score.PhaseRadians))
.ToArray();
return scores
.Select((energy, symbol) => new MimirChirpletSymbolCandidate(symbol, sampleOffset, energy, bandResponses))
.Select((score, symbol) => new MimirChirpletSymbolCandidate(
symbol,
sampleOffset - (calibration?.GroupDelayCorrectionSamples(symbol) ?? 0.0),
score.Energy * (calibration?.SymbolWeight(symbol) ?? 1.0),
bandResponses))
.OrderByDescending(candidate => candidate.Energy)
.Take(MaxSymbolCandidatesPerFrame)
.ToArray();
@ -294,7 +353,7 @@ public sealed class MimirChirpBinTimeline
}
var score = DechirpedBin(samples, kernels.Symbols[symbolId]);
return 2.0 * score / Math.Sqrt(sampleEnergy * kernels.ReferenceEnergy);
return 2.0 * score.Energy / Math.Sqrt(sampleEnergy * kernels.ReferenceEnergy);
}
private static double RefineOffset(
@ -334,7 +393,7 @@ public sealed class MimirChirpBinTimeline
offset > bestOffset;
}
private static double DechirpedBin(ReadOnlySpan<float> samples, ChirpBinKernel kernel)
private static ChirpBinScore DechirpedBin(ReadOnlySpan<float> samples, ChirpBinKernel kernel)
{
var real = 0.0;
var imaginary = 0.0;
@ -344,12 +403,15 @@ public sealed class MimirChirpBinTimeline
imaginary += samples[index] * kernel.Imaginary[index];
}
return Math.Sqrt(real * real + imaginary * imaginary);
return new ChirpBinScore(
Math.Sqrt(real * real + imaginary * imaginary),
Math.Atan2(imaginary, real));
}
private static IReadOnlyList<MimirChirpletTimelineAnchor> DecodeAnchors(
IReadOnlyList<MimirChirpletTransformFrame> frames,
int sampleRate)
int sampleRate,
MimirChirpBinPathCalibration? calibration)
{
if (frames.Count < TimelineOrder)
{
@ -402,12 +464,13 @@ public sealed class MimirChirpBinTimeline
}
}
return SelectCoherentAnchorPath(candidates, sampleRate);
return SelectCoherentAnchorPath(candidates, sampleRate, calibration);
}
private static IReadOnlyList<MimirChirpletTimelineAnchor> SelectCoherentAnchorPath(
IReadOnlyList<MimirChirpletTimelineAnchor> candidates,
int sampleRate)
int sampleRate,
MimirChirpBinPathCalibration? calibration)
{
if (candidates.Count == 0)
{
@ -416,9 +479,8 @@ public sealed class MimirChirpBinTimeline
List<MimirChirpletTimelineAnchor> bestPath = [];
var bestScore = double.NegativeInfinity;
foreach (var seed in candidates.OrderByDescending(anchor => anchor.Confidence).Take(64))
foreach (var seedOffset in CandidateDelaySeeds(candidates, sampleRate, calibration))
{
var seedOffset = seed.SampleOffset - seed.TimelineSeconds * sampleRate;
var path = candidates
.Where(anchor => Math.Abs((anchor.SampleOffset - anchor.TimelineSeconds * sampleRate) - seedOffset) <= sampleRate * 0.006)
.GroupBy(anchor => anchor.EventIndex)
@ -453,6 +515,27 @@ public sealed class MimirChirpBinTimeline
.ToArray();
}
private static IEnumerable<double> CandidateDelaySeeds(
IReadOnlyList<MimirChirpletTimelineAnchor> candidates,
int sampleRate,
MimirChirpBinPathCalibration? calibration)
{
if (calibration != null)
{
foreach (var hypothesis in calibration.DelayHypotheses
.OrderByDescending(hypothesis => hypothesis.Confidence * hypothesis.SupportCount)
.Take(16))
{
yield return hypothesis.DelaySamples;
}
}
foreach (var seed in candidates.OrderByDescending(anchor => anchor.Confidence).Take(64))
{
yield return seed.SampleOffset - seed.TimelineSeconds * sampleRate;
}
}
private static MimirChirpletClockFit? FitClock(
IReadOnlyList<MimirChirpletTimelineAnchor> anchors,
int sampleRate)
@ -738,6 +821,24 @@ public sealed class MimirChirpBinTimeline
private static double EventStartSeconds(ulong eventIndex) =>
FirstEventSeconds + eventIndex * EventSpacingSeconds;
private static bool TryExpectedEventForSample(
double sampleOffset,
int sampleRate,
MimirChirpletClockFit clockFit,
out MimirChirpletTimelineEvent timelineEvent)
{
var timelineSeconds = (sampleOffset - clockFit.SourceOffsetSamples) / clockFit.EffectiveSampleRate;
var eventIndex = (long)Math.Round((timelineSeconds - FirstEventSeconds) / EventSpacingSeconds);
if (eventIndex < 0)
{
timelineEvent = default!;
return false;
}
timelineEvent = Default.EventForIndex((ulong)eventIndex);
return Math.Abs(timelineSeconds - timelineEvent.StartSeconds) <= Math.Max(0.012, 6.0 / sampleRate);
}
private static int[] BuildDeBruijn(int alphabetSize, int order)
{
var a = new int[alphabetSize * order];

View file

@ -14,7 +14,8 @@ public sealed record MimirChirpletTone(
public sealed record MimirChirpletBandResponse(
double CenterHz,
double Energy);
double Energy,
double PhaseRadians = 0.0);
public sealed record MimirChirpletTimelineEvent(
ulong Index,

View file

@ -201,6 +201,8 @@ public sealed class MimirAudioSyncConfig
public float WatermarkGain { get; set; } = float.NaN;
public string CalibrationModelPath { get; set; } = "";
public MimirAudioSynchronizationSettings ToSettings(MimirAudioSynchronizationSettings fallback)
{
return new MimirAudioSynchronizationSettings
@ -215,6 +217,9 @@ public sealed class MimirAudioSyncConfig
WatermarkGain = float.IsFinite(WatermarkGain)
? Math.Clamp(WatermarkGain, 0.0f, 0.25f)
: fallback.WatermarkGain,
CalibrationModelPath = string.IsNullOrWhiteSpace(CalibrationModelPath)
? fallback.CalibrationModelPath
: CalibrationModelPath.Trim(),
};
}
}

View file

@ -6,6 +6,7 @@ public sealed class MimirSynchronizationHub : IDisposable
private readonly MimirAudioSynchronizationAnalyzer audioSynchronization = new();
private readonly MimirAudioSynchronizationStateTracker audioSynchronizationState = new();
private readonly Dictionary<string, MimirAudioSynchronizationReport> audioSynchronizationReports = new(StringComparer.Ordinal);
private readonly MimirChirpBinCalibrationModel? chirpBinCalibrationModel;
private ulong ingestedSamples;
private int nextAudioSynchronizationCandidate;
@ -13,6 +14,7 @@ public sealed class MimirSynchronizationHub : IDisposable
{
Settings = settings;
Buffers = new MimirStreamBufferSet(settings.BufferDuration);
chirpBinCalibrationModel = TryLoadCalibrationModel(settings.Audio.CalibrationModelPath);
foreach (var stream in settings.Streams.Where(stream => stream.Enabled))
{
Buffers.EnsureBuffer(stream);
@ -85,7 +87,7 @@ public sealed class MimirSynchronizationHub : IDisposable
buffer.Latest?.AudioBlock != null)
.Select(buffer => buffer.Descriptor.SourceId)
.ToArray();
var reports = audioSynchronization.Analyze(Buffers.Buffers, referenceSourceId, mode);
var reports = audioSynchronization.Analyze(Buffers.Buffers, referenceSourceId, mode, calibrationModel: chirpBinCalibrationModel);
StoreAudioSynchronizationReports(reports, candidates);
return reports;
}
@ -120,13 +122,24 @@ public sealed class MimirSynchronizationHub : IDisposable
Buffers.Buffers,
referenceSourceId,
mode,
selected);
selected,
chirpBinCalibrationModel);
StoreAudioSynchronizationReports(reports, selected);
return reports;
}
private bool disposed;
private static MimirChirpBinCalibrationModel? TryLoadCalibrationModel(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
return MimirChirpBinCalibrationModel.Load(path);
}
private void StoreAudioSynchronizationReports(
IReadOnlyList<MimirAudioSynchronizationReport> reports,
IEnumerable<string> analyzedCandidateIds)

View file

@ -66,6 +66,8 @@ public sealed class MimirAudioSynchronizationSettings
public float WatermarkGain { get; init; } = DefaultWatermarkGain;
public string CalibrationModelPath { get; init; } = "";
public static MimirAudioSynchronizationSettings FromEnvironment()
{
return new MimirAudioSynchronizationSettings().WithEnvironmentOverrides();
@ -79,6 +81,7 @@ public sealed class MimirAudioSynchronizationSettings
ReferenceSourceId = ReadReferenceSourceId(ReferenceSourceId),
CalibrationGain = ReadCalibrationGain(CalibrationGain),
WatermarkGain = ReadWatermarkGain(WatermarkGain),
CalibrationModelPath = ReadCalibrationModelPath(CalibrationModelPath),
};
}
@ -119,6 +122,12 @@ public sealed class MimirAudioSynchronizationSettings
? Math.Clamp(gain, 0.0f, 0.25f)
: fallback;
}
private static string ReadCalibrationModelPath(string fallback)
{
var path = Environment.GetEnvironmentVariable("MIMIR_CHIRP_BIN_CALIBRATION");
return string.IsNullOrWhiteSpace(path) ? fallback : path.Trim();
}
}
public enum MimirAudioSyncMode

View file

@ -71,3 +71,4 @@
{"ts": "2026-05-23T19:40:50+01:00", "type": "implementation", "status": "ok", "note": "Extended active chirp-bin decoding toward the Perfect Machine receiver shape: standalone DecodeStreamWindow now refines source offset against the scheduled waveform so a listener with only codebook/schedule state can place delayed audio on canonical time. Synthetic 192 kHz standalone tests recover 1269.5 samples exactly, 96000 samples / 500 ms to below printed microsecond precision, and 13789.75 samples within 0.021 us. Pairwise Mimir sync now rejects independent clock-fit differences outside a 10s live horizon so coherent period aliases do not become absurd delays. Fensalir project references were updated from removed Aquarium.LocalCast to Aquarium.Fensalir."}
{"ts": "2026-05-23T19:47:34+01:00", "type": "implementation", "status": "ok", "note": "Active chirp-bin decoding now preserves calibration evidence as well as timing: each classified chirp carries the full dechirped 32-bin energy surface, stream decodes aggregate those bins into BandResponses, and sync reports print/expose those per-band response estimates. This makes the chirp stream a timing witness and a frequency-response calibration surface. Verified dotnet build, 192 kHz chirp-only self-test, 500 ms standalone self-test, and Scarlett ASIO loopback artifact with response bands."}
{"ts": "2026-05-23T20:03:21+01:00", "type": "implementation", "status": "ok", "note": "Added chirp-bin calibration profiles as the live meatspace response surface. MimirChirpBinCalibrationProfile summarizes per-source frames, anchors, clock confidence, anchor error, usable bins, and strongest measured bands from active chirp-bin decodes. MimirAudioSynchronizationAnalyzer keeps profiles even when timing reports are rejected, and BufferSmoke now prints them for runtime and ASIO artifact analysis. Verified build, 192 kHz chirp-only self-test, 500 ms standalone receiver self-test, Scarlett loopback sync, and physical input calibration evidence from the stored ASIO artifact."}
{"ts": "2026-05-23T20:19:36+01:00", "type": "implementation", "status": "ok", "note": "Replaced the profile-only chirp-bin calibration cut with a usable physical calibration model. The model records per-source usable bands, symbol reliability, expected-vs-observed confusion observations, timing residuals, delay hypotheses, phase summaries, and an adaptive codebook plan. MimirChirpBinTimeline can now decode with learned per-symbol weights, group-delay correction, and persisted delay-hypothesis seeds. BufferSmoke can run --calibrate-chirp-bin-asio-f32 to render/capture/analyze/persist the model, and --analyze-asio-f32 can load it via --calibration. The ASIO native worker now supports --emit-json-blocks so Focusrite ASIO callbacks can feed Mimir.Runtime continuously through the existing frame-event adapter while the native ABI cut is still pending."}

View file

@ -110,12 +110,14 @@ accepted_design:
final active delay with constrained local waveform correlation around the
decoded anchor delay. Each classified chirp carries the full dechirped
bin-energy surface, and decodes aggregate that into per-band response
evidence for frequency-response normalization. `MimirChirpBinCalibrationProfile`
now preserves that evidence per source with frame count, anchor count, clock
confidence, anchor error, usable band count, and strongest measured bins.
The analyzer keeps calibration profiles even when a source fails pairwise
timing, so physical mic failures can still guide acoustic codebook and
response-weighting work. Reports and sync states expose
evidence for frequency-response normalization. `MimirChirpBinCalibrationModel`
now preserves measured usable bands, expected-symbol versus observed-bin
confusion observations, timing residuals, delay hypotheses, phase summaries,
and an adaptive codebook plan. The active decoder can consume a persisted
model for learned response weighting, first-order group-delay correction,
and global delay-hypothesis seeding. The analyzer keeps calibration profiles
even when a source fails pairwise timing, so physical mic failures can still
guide acoustic codebook and response-weighting work. Reports and sync states expose
delay in microseconds as well as fractional samples. Hybrid emits it as low-gain half-second
bursts every two seconds while passive confidence is weak, never as catch-up
backlog. The analyzer no longer applies the passive two-second window floor
@ -132,10 +134,13 @@ accepted_design:
`native/probes/asio_audio_cadence --play-f32-mono --record-f32-interleaved`
now provides a real Scarlett ASIO artifact proof for active mode. At 192 kHz,
Loopback 1 -> Loopback 2 decoded at 0.000 us with 12 anchors and 0.999
confidence. Physical inputs still fail accepted pairwise timing against
loopback in the stored artifact, but decoded mic channels now leave
calibration profiles; physical input 1 produced 14 frames, 12 anchors, 0.865
clock confidence, and strongest measured bins around 4525, 4075, and 7225 Hz.
confidence. `Mimir.BufferSmoke --calibrate-chirp-bin-asio-f32` now renders
or ingests a calibration capture and persists the response/confusion/delay
model under `calibration/chirp-bin/`; `--analyze-asio-f32 --calibration`
loads it back into the active decoder. Physical inputs still fail accepted
pairwise timing against loopback in the stored artifact, but decoded mic
channels now leave calibration models; physical input 1 produced two reliable
symbols and a different strongest measured band set.
Acoustic robustness remains open.
The old arbitrary-chirplet matcher is a diagnostic/reference artifact only,
not the runtime hot path.
@ -237,9 +242,9 @@ current_status:
Kiyo 30.18 fps, PS3 Eye 0 59.66 fps, and PS3 Eye 1 59.95 fps. A mouse in
the Leap USB neighborhood does not reproduce the Focusrite-induced collapse.
next_action: >
Use chirp-bin calibration profiles from real microphones to tune the active
codebook/bands/weighting, then feed Scarlett ASIO callbacks directly into
Mimir.Runtime and add the synchronization
Use chirp-bin calibration models from real microphones to tune the active
codebook/bands/weighting, then replace the ASIO JSON-block process bridge
with a native ABI callback source and add the synchronization
actuator.
Current app testing proves Fensalir WASAPI output wakes Scarlett speaker
loopback, live mic buffers stay populated, runtime sync analysis now runs as