Build chirp-bin physical calibration model
This commit is contained in:
parent
3a7410d510
commit
488fda5362
18 changed files with 3073 additions and 97 deletions
2326
calibration/chirp-bin/scarlett-artifact.json
Normal file
2326
calibration/chirp-bin/scarlett-artifact.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue