642 lines
27 KiB
C#
642 lines
27 KiB
C#
using Mimir.Runtime.Synchronization;
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--chirplet-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RunChirpletSelfTest();
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--chirp-bin-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RunChirpBinSelfTest();
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--standalone-chirp-bin-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RunStandaloneChirpBinSelfTest(
|
|
ParseIntOption(args, "--sample-rate", MimirChirpBinTimeline.SampleRate),
|
|
ParseDoubleOption(args, "--delay-samples", 1269.5));
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--passive-sync-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RunPassiveSyncSelfTest();
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--hybrid-sync-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RunHybridSyncSelfTest(ParseIntOption(args, "--sample-rate", MimirChirpBinTimeline.SampleRate));
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--chirp-only-sync-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RunActiveSyncSelfTest(ParseIntOption(args, "--sample-rate", MimirChirpBinTimeline.SampleRate), MimirAudioSyncMode.ChirpOnly);
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--render-chirplet-f32", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RenderChirpletFloat32(
|
|
ParseStringOption(args, "--output", "artifacts/asio/chirplet-f32.raw"),
|
|
ParseIntOption(args, "--sample-rate", MimirChirpletTimeline.SampleRate),
|
|
ParseDoubleOption(args, "--seconds", 3.0));
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--render-chirp-bin-f32", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return RenderChirpBinFloat32(
|
|
ParseStringOption(args, "--output", "artifacts/asio/chirp-bin-f32.raw"),
|
|
ParseIntOption(args, "--sample-rate", MimirChirpBinTimeline.SampleRate),
|
|
ParseDoubleOption(args, "--seconds", 3.0));
|
|
}
|
|
|
|
if (args.Any(arg => string.Equals(arg, "--analyze-asio-f32", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return AnalyzeAsioFloat32(
|
|
ParseStringOption(args, "--input", "artifacts/asio/scarlett-chirplet-f32.raw"),
|
|
ParseIntOption(args, "--sample-rate", MimirChirpletTimeline.SampleRate),
|
|
ParseIntOption(args, "--channels", 4),
|
|
ParseIntOption(args, "--reference-channel", 2),
|
|
ParseIntOption(args, "--candidate-channel", -1));
|
|
}
|
|
|
|
var duration = TimeSpan.FromSeconds(ParseDoubleOption(args, "--seconds", 10.0));
|
|
var pollDelay = TimeSpan.FromMilliseconds(ParseDoubleOption(args, "--poll-ms", 10.0));
|
|
var requireSamples = args.Any(arg => string.Equals(arg, "--require-samples", StringComparison.OrdinalIgnoreCase));
|
|
var syncReference = ParseStringOption(args, "--sync-reference", "loopback-scarlett-speakers");
|
|
using var configuration = new DisposableConfiguration(MimirRuntimeConfiguration.Load());
|
|
using var hub = new MimirSynchronizationHub(configuration.Value.Settings);
|
|
var syncMode = configuration.Value.Settings.Audio.Mode;
|
|
|
|
foreach (var source in configuration.Value.Sources)
|
|
{
|
|
configuration.Detach(source);
|
|
hub.AddSource(source);
|
|
}
|
|
|
|
var deadline = DateTimeOffset.UtcNow + duration;
|
|
var nextSyncPoll = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(1);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
hub.PollSources(maxSamplesPerSource: 4096);
|
|
if (DateTimeOffset.UtcNow >= nextSyncPoll)
|
|
{
|
|
hub.AnalyzeAudioSynchronization(syncReference, syncMode);
|
|
nextSyncPoll += TimeSpan.FromSeconds(1);
|
|
}
|
|
|
|
await Task.Delay(pollDelay).ConfigureAwait(false);
|
|
}
|
|
|
|
hub.PollSources(maxSamplesPerSource: 16384);
|
|
|
|
Console.WriteLine($"sources={hub.SourceCount} ingested={hub.IngestedSamples} buffers={hub.Buffers.Buffers.Count}");
|
|
var emptyBuffers = new List<string>();
|
|
foreach (var buffer in hub.Buffers.Buffers.OrderBy(buffer => buffer.Descriptor.SourceId, StringComparer.Ordinal))
|
|
{
|
|
var latest = buffer.Latest;
|
|
if (latest?.VideoFrame is { } frame)
|
|
{
|
|
Console.WriteLine(
|
|
$"{buffer.Descriptor.SourceId}: count={buffer.Count} edgeNs={buffer.EdgeNs} latest={frame.Width}x{frame.Height} {frame.PixelFormat} bytes={latest.Value.ByteLength}");
|
|
}
|
|
else if (latest?.AudioBlock is { } block)
|
|
{
|
|
Console.WriteLine(
|
|
$"{buffer.Descriptor.SourceId}: count={buffer.Count} edgeNs={buffer.EdgeNs} latest={block.Channels}ch {block.SampleRate}Hz {block.SampleFormat} frames={block.FrameCount} bytes={latest.Value.ByteLength}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"{buffer.Descriptor.SourceId}: count={buffer.Count} edgeNs={buffer.EdgeNs}");
|
|
}
|
|
|
|
if (buffer.Count == 0)
|
|
{
|
|
emptyBuffers.Add(buffer.Descriptor.SourceId);
|
|
}
|
|
}
|
|
|
|
var reports = hub.AnalyzeAudioSynchronization(syncReference, syncMode);
|
|
foreach (var report in reports.OrderBy(report => report.SourceId, StringComparer.Ordinal))
|
|
{
|
|
Console.WriteLine(
|
|
$"sync {report.ReferenceSourceId}->{report.SourceId}: evidence={report.EvidenceKind} delaySamples={report.DelaySamples} fractionalDelaySamples={report.FractionalDelaySamples:0.000000} delayUs={report.DelayMicroseconds:0.000} delayMs={report.DelayMilliseconds:0.000} confidence={report.Confidence:0.000} bands={DescribeBands(report.BandResponses)} compared={report.ComparedSamples}");
|
|
}
|
|
|
|
foreach (var state in hub.AudioSynchronizationStates)
|
|
{
|
|
Console.WriteLine(
|
|
$"sync-state {state.ReferenceSourceId}->{state.SourceId}: smoothedDelaySamples={state.SmoothedDelaySamples:0.000000} delayUs={state.DelayMicroseconds:0.000} delayMs={state.DelayMilliseconds:0.000} sroPpm={state.SamplingRateOffsetPpm:0.000} confidence={state.Confidence:0.000} bands={DescribeBands(state.BandResponses)}");
|
|
}
|
|
|
|
foreach (var trace in hub.AudioSynchronizationDecodeTraces.OrderBy(trace => trace.SourceId, StringComparer.Ordinal))
|
|
{
|
|
Console.WriteLine(
|
|
$"sync-decode {trace.ReferenceSourceId}->{trace.SourceId}: status={trace.Status} compared={trace.ComparedSamples} rate={trace.SampleRate} refFrames={trace.ReferenceFrames} refAnchors={trace.ReferenceAnchors} refClock={trace.ReferenceClockConfidence:0.000} refEnergy={trace.ReferenceBestEnergy:0.000} candFrames={trace.CandidateFrames} candAnchors={trace.CandidateAnchors} candClock={trace.CandidateClockConfidence:0.000} candEnergy={trace.CandidateBestEnergy:0.000} matched={trace.MatchedEvents} confidence={trace.Confidence:0.000}");
|
|
}
|
|
|
|
foreach (var profile in hub.AudioChirpBinCalibrationProfiles)
|
|
{
|
|
Console.WriteLine(DescribeCalibrationProfile("sync-calibration", profile));
|
|
}
|
|
|
|
if (requireSamples && emptyBuffers.Count > 0)
|
|
{
|
|
Console.Error.WriteLine($"empty buffers: {string.Join(", ", emptyBuffers)}");
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
|
|
static double ParseDoubleOption(IReadOnlyList<string> args, string name, double fallback)
|
|
{
|
|
for (var index = 0; index < args.Count - 1; index++)
|
|
{
|
|
if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase)
|
|
&& double.TryParse(args[index + 1], out var parsed)
|
|
&& parsed > 0)
|
|
{
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
static string ParseStringOption(IReadOnlyList<string> args, string name, string fallback)
|
|
{
|
|
for (var index = 0; index < args.Count - 1; index++)
|
|
{
|
|
if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.IsNullOrWhiteSpace(args[index + 1]))
|
|
{
|
|
return args[index + 1];
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
static string DescribeBands(IReadOnlyList<MimirChirpletBandResponse> bands)
|
|
{
|
|
return bands.Count == 0
|
|
? "none"
|
|
: string.Join(",", bands.Select(band => $"{band.CenterHz:0}Hz:{band.Energy:0.000}"));
|
|
}
|
|
|
|
static string DescribeCalibrationProfile(string prefix, MimirChirpBinCalibrationProfile profile)
|
|
{
|
|
var top = profile.Bands.Count == 0
|
|
? "none"
|
|
: string.Join(",", profile.StrongestBands(8).Select(band =>
|
|
$"{band.CenterHz:0}Hz:mean={band.MeanEnergy:0.000}:rel={band.RelativeGain:0.000}:n={band.ObservationCount}"));
|
|
var mae = double.IsFinite(profile.MeanAnchorErrorSamples)
|
|
? profile.MeanAnchorErrorSamples.ToString("0.000")
|
|
: "none";
|
|
return $"{prefix} {profile.SourceId}: sampleRate={profile.SampleRate} frames={profile.FrameCount} anchors={profile.AnchorCount} clock={profile.ClockConfidence:0.000} maeSamples={mae} usableBins={profile.UsableBandCount}/{profile.Bands.Count} top={top}";
|
|
}
|
|
|
|
static int RunChirpletSelfTest()
|
|
{
|
|
var timeline = MimirChirpletTimeline.Default;
|
|
var decoder = new MimirChirpletStreamDecoder(windowDuration: TimeSpan.FromSeconds(2));
|
|
MimirChirpletStreamDecode decode = new([], [], [], null, []);
|
|
for (ulong segment = 0; segment < 4; segment++)
|
|
{
|
|
decode = decoder.Append(timeline.RenderSegmentMonoFloat(segment));
|
|
}
|
|
|
|
var meanAbsoluteError = decode.ClockFit?.MeanAbsoluteErrorSamples ?? double.PositiveInfinity;
|
|
Console.WriteLine(
|
|
$"chirplet-self-test frames={decode.Frames.Count} symbols={decode.Symbols.Count} anchors={decode.Anchors.Count} " +
|
|
$"clock={(decode.ClockFit is null ? "none" : decode.ClockFit.EffectiveSampleRate.ToString("0.000000"))} " +
|
|
$"confidence={(decode.ClockFit?.Confidence ?? 0.0):0.000} mae={meanAbsoluteError:0.000000}");
|
|
|
|
foreach (var anchor in decode.Anchors)
|
|
{
|
|
var expected = timeline.EventForIndex(anchor.EventIndex).StartSeconds * MimirChirpletTimeline.SampleRate;
|
|
Console.WriteLine(
|
|
$"chirplet-anchor event={anchor.EventIndex} actual={anchor.SampleOffset:0.000} expected={expected:0.000} " +
|
|
$"error={anchor.SampleOffset - expected:0.000} confidence={anchor.Confidence:0.000}");
|
|
}
|
|
|
|
if (decode.ClockFit == null || decode.Anchors.Count < 12 || meanAbsoluteError > 0.25)
|
|
{
|
|
Console.Error.WriteLine("chirplet self-test failed: canonical timeline did not decode to sub-frame anchors");
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
static int RunChirpBinSelfTest()
|
|
{
|
|
var timeline = MimirChirpBinTimeline.Default;
|
|
var samples = new List<float>();
|
|
for (ulong segment = 0; segment < 6; segment++)
|
|
{
|
|
samples.AddRange(timeline.RenderSegmentMonoFloat(segment));
|
|
}
|
|
|
|
var decode = timeline.DecodeStreamWindow(samples.ToArray(), MimirChirpBinTimeline.SampleRate);
|
|
var meanAbsoluteError = decode.ClockFit?.MeanAbsoluteErrorSamples ?? double.PositiveInfinity;
|
|
Console.WriteLine(
|
|
$"chirp-bin-self-test frames={decode.Frames.Count} symbols={decode.Symbols.Count} anchors={decode.Anchors.Count} " +
|
|
$"clock={(decode.ClockFit is null ? "none" : decode.ClockFit.EffectiveSampleRate.ToString("0.000000"))} " +
|
|
$"confidence={(decode.ClockFit?.Confidence ?? 0.0):0.000} mae={meanAbsoluteError:0.000000}");
|
|
Console.WriteLine("chirp-bin-expected " + string.Join(",", Enumerable.Range(0, 12).Select(index => timeline.EventForIndex((ulong)index).SymbolId)));
|
|
Console.WriteLine("chirp-bin-symbols " + string.Join(",", decode.Symbols.Take(12).Select(symbol => $"{symbol.SymbolId}@{symbol.SampleOffset:0}:{symbol.Energy:0.000}")));
|
|
|
|
foreach (var anchor in decode.Anchors.Take(16))
|
|
{
|
|
var expected = timeline.EventForIndex(anchor.EventIndex).StartSeconds * MimirChirpBinTimeline.SampleRate;
|
|
Console.WriteLine(
|
|
$"chirp-bin-anchor event={anchor.EventIndex} actual={anchor.SampleOffset:0.000} expected={expected:0.000} " +
|
|
$"error={anchor.SampleOffset - expected:0.000} confidence={anchor.Confidence:0.000}");
|
|
}
|
|
|
|
if (decode.ClockFit == null || decode.Anchors.Count < 16 || meanAbsoluteError > 2.0)
|
|
{
|
|
Console.Error.WriteLine("chirp-bin self-test failed: dechirp/bin decoder did not recover stable timeline anchors");
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
static int RunStandaloneChirpBinSelfTest(int sampleRate, double delaySamples)
|
|
{
|
|
var timeline = MimirChirpBinTimeline.Default;
|
|
var samples = new List<float>();
|
|
for (ulong segment = 0; segment < 8; segment++)
|
|
{
|
|
samples.AddRange(timeline.RenderSegmentMonoFloat(segment, sampleRate));
|
|
}
|
|
|
|
var delayed = ApplyFractionalDelay(samples.ToArray(), delaySamples);
|
|
var decode = timeline.DecodeStreamWindow(delayed, sampleRate);
|
|
var expectedDelay = delaySamples;
|
|
var actualDelay = decode.ClockFit?.SourceOffsetSamples ?? double.NaN;
|
|
var error = Math.Abs(actualDelay - expectedDelay);
|
|
Console.WriteLine(
|
|
$"standalone-chirp-bin-self-test frames={decode.Frames.Count} anchors={decode.Anchors.Count} " +
|
|
$"clock={(decode.ClockFit is null ? "none" : decode.ClockFit.EffectiveSampleRate.ToString("0.000000"))} " +
|
|
$"sourceOffset={actualDelay:0.000000} expected={expectedDelay:0.000000} errorSamples={error:0.000000} " +
|
|
$"errorUs={error * 1_000_000.0 / sampleRate:0.000} confidence={(decode.ClockFit?.Confidence ?? 0.0):0.000}");
|
|
|
|
foreach (var anchor in decode.Anchors.Take(16))
|
|
{
|
|
var expected = timeline.EventForIndex(anchor.EventIndex).StartSeconds * sampleRate + expectedDelay;
|
|
Console.WriteLine(
|
|
$"standalone-anchor event={anchor.EventIndex} actual={anchor.SampleOffset:0.000} expected={expected:0.000} " +
|
|
$"error={anchor.SampleOffset - expected:0.000} confidence={anchor.Confidence:0.000}");
|
|
}
|
|
|
|
if (decode.ClockFit == null ||
|
|
decode.Anchors.Count < 12 ||
|
|
decode.ClockFit.Confidence < 0.70 ||
|
|
error * 1_000_000.0 / sampleRate > 1.0)
|
|
{
|
|
Console.Error.WriteLine("standalone chirp-bin self-test failed: receiver did not recover canonical source time from delayed audio alone");
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
static int RunPassiveSyncSelfTest()
|
|
{
|
|
const int sampleRate = 48_000;
|
|
const int delaySamples = 1732;
|
|
const int sampleCount = 48_000;
|
|
var reference = new float[sampleCount];
|
|
var candidate = new float[sampleCount];
|
|
var random = new Random(1729);
|
|
var noise = new double[sampleCount];
|
|
for (var index = 0; index < sampleCount; index++)
|
|
{
|
|
noise[index] = random.NextDouble() * 2.0 - 1.0;
|
|
}
|
|
|
|
for (var index = 0; index < sampleCount; index++)
|
|
{
|
|
reference[index] = (float)(
|
|
0.45 * Math.Sin(2.0 * Math.PI * 611.0 * index / sampleRate) +
|
|
0.28 * Math.Sin(2.0 * Math.PI * 1471.0 * index / sampleRate) +
|
|
0.12 * Math.Sin(2.0 * Math.PI * 3253.0 * index / sampleRate) +
|
|
0.08 * noise[index]);
|
|
var source = index - delaySamples;
|
|
candidate[index] = source >= 0 ? reference[source] : 0.0f;
|
|
}
|
|
|
|
var estimator = new MimirPassiveAudioSynchronizationEstimator();
|
|
var estimate = estimator.Estimate(reference, candidate, sampleRate);
|
|
var error = estimate.DelaySamples - delaySamples;
|
|
Console.WriteLine(
|
|
$"passive-sync-self-test delaySamples={estimate.DelaySamples:0.000} expected={delaySamples} " +
|
|
$"error={error:0.000} confidence={estimate.Confidence:0.000} peak={estimate.Peak:0.000000} secondPeak={estimate.SecondPeak:0.000000} floor={estimate.NoiseFloor:0.000000} status={estimate.Status}");
|
|
if (Math.Abs(error) > 1.0 || estimate.Confidence < 0.08 || estimate.Confidence >= 1.0)
|
|
{
|
|
Console.Error.WriteLine("passive sync self-test failed: delayed program signal did not produce a confident passive estimate");
|
|
return 1;
|
|
}
|
|
|
|
var impossible = estimator.Estimate(candidate, reference, sampleRate);
|
|
Console.WriteLine(
|
|
$"passive-sync-negative-test delaySamples={impossible.DelaySamples:0.000} confidence={impossible.Confidence:0.000} status={impossible.Status}");
|
|
if (impossible.DelaySamples < 0.0 && impossible.Confidence > 0.0)
|
|
{
|
|
Console.Error.WriteLine("passive sync self-test failed: negative lag kept confidence");
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
static int RunHybridSyncSelfTest(int sampleRate)
|
|
{
|
|
return RunActiveSyncSelfTest(sampleRate, MimirAudioSyncMode.Hybrid);
|
|
}
|
|
|
|
static int RunActiveSyncSelfTest(int sampleRate, MimirAudioSyncMode mode)
|
|
{
|
|
const string referenceSourceId = "loopback-test";
|
|
const string candidateSourceId = "mic-test";
|
|
var delaySamples = 317.375 * sampleRate / (double)MimirChirpBinTimeline.SampleRate;
|
|
|
|
var firstSegment = MimirChirpBinTimeline.Default.RenderSegmentMonoFloat(0, sampleRate);
|
|
var secondSegment = MimirChirpBinTimeline.Default.RenderSegmentMonoFloat(1, sampleRate);
|
|
var reference = new float[firstSegment.Length + secondSegment.Length];
|
|
Array.Copy(firstSegment, 0, reference, 0, firstSegment.Length);
|
|
Array.Copy(secondSegment, 0, reference, firstSegment.Length, secondSegment.Length);
|
|
var candidate = ApplyFractionalDelay(reference, delaySamples);
|
|
|
|
var referenceBuffer = new MimirRollingStreamBuffer(
|
|
new MimirStreamDescriptor(referenceSourceId, MimirStreamKind.Audio, MimirStreamOrigin.LocalDevice),
|
|
TimeSpan.FromSeconds(5));
|
|
var candidateBuffer = new MimirRollingStreamBuffer(
|
|
new MimirStreamDescriptor(candidateSourceId, MimirStreamKind.Audio, MimirStreamOrigin.LocalDevice),
|
|
TimeSpan.FromSeconds(5));
|
|
|
|
AppendFloatBlock(referenceBuffer, referenceSourceId, reference, sampleRate);
|
|
AppendFloatBlock(candidateBuffer, candidateSourceId, candidate, sampleRate);
|
|
|
|
var analyzer = new MimirAudioSynchronizationAnalyzer();
|
|
var reports = analyzer.Analyze([referenceBuffer, candidateBuffer], referenceSourceId, mode);
|
|
var report = reports.SingleOrDefault();
|
|
foreach (var trace in analyzer.LastDecodeTraces)
|
|
{
|
|
Console.WriteLine(
|
|
$"{SyncModeLabel(mode)}-sync-trace {trace.ReferenceSourceId}->{trace.SourceId}: status={trace.Status} compared={trace.ComparedSamples} refFrames={trace.ReferenceFrames} refAnchors={trace.ReferenceAnchors} candFrames={trace.CandidateFrames} candAnchors={trace.CandidateAnchors} matched={trace.MatchedEvents} confidence={trace.Confidence:0.000}");
|
|
}
|
|
|
|
if (report == null)
|
|
{
|
|
Console.Error.WriteLine($"{SyncModeLabel(mode)} sync self-test failed: analyzer did not report from a short chirp-bin window");
|
|
return 1;
|
|
}
|
|
|
|
var error = Math.Abs(report.FractionalDelaySamples - delaySamples);
|
|
Console.WriteLine(
|
|
$"{SyncModeLabel(mode)}-sync-self-test evidence={report.EvidenceKind} delaySamples={report.FractionalDelaySamples:0.000000} delayUs={report.DelayMicroseconds:0.000} expected={delaySamples:0.000000} errorSamples={error:0.000000} errorUs={error * 1_000_000.0 / report.SampleRate:0.000} confidence={report.Confidence:0.000} events={report.TimelineMatchedEvents} compared={report.ComparedSamples}");
|
|
return string.Equals(report.EvidenceKind, "chirp-bin", StringComparison.Ordinal) &&
|
|
report.TimelineMatchedEvents >= 3 &&
|
|
report.Confidence > 0.70 &&
|
|
error * 1_000_000.0 / report.SampleRate < 1.0
|
|
? 0
|
|
: 1;
|
|
}
|
|
|
|
static string SyncModeLabel(MimirAudioSyncMode mode)
|
|
{
|
|
return mode == MimirAudioSyncMode.ChirpOnly ? "chirp-only" : "hybrid";
|
|
}
|
|
|
|
static int RenderChirpletFloat32(string outputPath, int sampleRate, double seconds)
|
|
{
|
|
var segmentCount = Math.Max(1, (int)Math.Ceiling(seconds / MimirChirpletTimeline.SegmentSeconds));
|
|
var samples = new List<float>(Math.Max(1, (int)Math.Ceiling(seconds * sampleRate)));
|
|
for (var segment = 0; segment < segmentCount; segment++)
|
|
{
|
|
samples.AddRange(MimirChirpletTimeline.Default.RenderSegmentMonoFloat((ulong)segment, sampleRate));
|
|
}
|
|
|
|
var requestedSamples = Math.Max(1, (int)Math.Round(seconds * sampleRate));
|
|
if (samples.Count > requestedSamples)
|
|
{
|
|
samples.RemoveRange(requestedSamples, samples.Count - requestedSamples);
|
|
}
|
|
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var bytes = new byte[samples.Count * sizeof(float)];
|
|
for (var index = 0; index < samples.Count; index++)
|
|
{
|
|
BitConverter.TryWriteBytes(bytes.AsSpan(index * sizeof(float), sizeof(float)), samples[index]);
|
|
}
|
|
|
|
File.WriteAllBytes(outputPath, bytes);
|
|
Console.WriteLine($"chirplet-render-f32 path={outputPath} sampleRate={sampleRate} seconds={seconds:0.000} samples={samples.Count}");
|
|
return 0;
|
|
}
|
|
|
|
static int RenderChirpBinFloat32(string outputPath, int sampleRate, double seconds)
|
|
{
|
|
var segmentCount = Math.Max(1, (int)Math.Ceiling(seconds / MimirChirpBinTimeline.SegmentSeconds));
|
|
var samples = new List<float>(Math.Max(1, (int)Math.Ceiling(seconds * sampleRate)));
|
|
for (var segment = 0; segment < segmentCount; segment++)
|
|
{
|
|
samples.AddRange(MimirChirpBinTimeline.Default.RenderSegmentMonoFloat((ulong)segment, sampleRate));
|
|
}
|
|
|
|
var requestedSamples = Math.Max(1, (int)Math.Round(seconds * sampleRate));
|
|
if (samples.Count > requestedSamples)
|
|
{
|
|
samples.RemoveRange(requestedSamples, samples.Count - requestedSamples);
|
|
}
|
|
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var bytes = new byte[samples.Count * sizeof(float)];
|
|
for (var index = 0; index < samples.Count; index++)
|
|
{
|
|
BitConverter.TryWriteBytes(bytes.AsSpan(index * sizeof(float), sizeof(float)), samples[index]);
|
|
}
|
|
|
|
File.WriteAllBytes(outputPath, bytes);
|
|
Console.WriteLine($"chirp-bin-render-f32 path={outputPath} sampleRate={sampleRate} seconds={seconds:0.000} samples={samples.Count}");
|
|
return 0;
|
|
}
|
|
|
|
static int AnalyzeAsioFloat32(string inputPath, int sampleRate, int channels, int referenceChannel, int candidateChannel)
|
|
{
|
|
if (channels <= 0)
|
|
{
|
|
Console.Error.WriteLine("asio-f32 analysis failed: channel count must be positive");
|
|
return 1;
|
|
}
|
|
|
|
if (referenceChannel < 0 || referenceChannel >= channels)
|
|
{
|
|
Console.Error.WriteLine("asio-f32 analysis failed: reference channel is outside the capture channel count");
|
|
return 1;
|
|
}
|
|
|
|
if (candidateChannel >= channels)
|
|
{
|
|
Console.Error.WriteLine("asio-f32 analysis failed: candidate channel is outside the capture channel count");
|
|
return 1;
|
|
}
|
|
|
|
var bytes = File.ReadAllBytes(inputPath);
|
|
var frameCount = bytes.Length / (sizeof(float) * channels);
|
|
if (frameCount == 0)
|
|
{
|
|
Console.Error.WriteLine("asio-f32 analysis failed: capture file contains no complete frames");
|
|
return 1;
|
|
}
|
|
|
|
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)));
|
|
}
|
|
}
|
|
|
|
var buffers = new List<MimirRollingStreamBuffer>(channels);
|
|
for (var channel = 0; channel < channels; channel++)
|
|
{
|
|
var sourceId = $"asio-ch{channel}";
|
|
var buffer = new MimirRollingStreamBuffer(
|
|
new MimirStreamDescriptor(sourceId, MimirStreamKind.Audio, MimirStreamOrigin.LocalDevice),
|
|
TimeSpan.FromSeconds(10));
|
|
AppendFloatBlock(buffer, sourceId, channelSamples[channel], sampleRate);
|
|
buffers.Add(buffer);
|
|
}
|
|
|
|
var referenceSourceId = $"asio-ch{referenceChannel}";
|
|
var candidates = candidateChannel >= 0
|
|
? new HashSet<string>(StringComparer.Ordinal) { $"asio-ch{candidateChannel}" }
|
|
: null;
|
|
var analyzer = new MimirAudioSynchronizationAnalyzer();
|
|
var reports = analyzer.Analyze(buffers, referenceSourceId, MimirAudioSyncMode.ChirpOnly, candidates);
|
|
|
|
Console.WriteLine($"asio-f32-analysis input={inputPath} sampleRate={sampleRate} channels={channels} frames={frameCount} reference={referenceSourceId} candidate={(candidateChannel >= 0 ? $"asio-ch{candidateChannel}" : "all")}");
|
|
foreach (var trace in analyzer.LastDecodeTraces.OrderBy(trace => trace.SourceId, StringComparer.Ordinal))
|
|
{
|
|
Console.WriteLine(
|
|
$"asio-f32-trace {trace.ReferenceSourceId}->{trace.SourceId}: status={trace.Status} compared={trace.ComparedSamples} refFrames={trace.ReferenceFrames} refAnchors={trace.ReferenceAnchors} refClock={trace.ReferenceClockConfidence:0.000} candFrames={trace.CandidateFrames} candAnchors={trace.CandidateAnchors} candClock={trace.CandidateClockConfidence:0.000} matched={trace.MatchedEvents} confidence={trace.Confidence:0.000}");
|
|
}
|
|
|
|
foreach (var profile in analyzer.LastCalibrationProfiles)
|
|
{
|
|
Console.WriteLine(DescribeCalibrationProfile("asio-f32-calibration", profile));
|
|
}
|
|
|
|
foreach (var report in reports.OrderBy(report => report.SourceId, StringComparer.Ordinal))
|
|
{
|
|
Console.WriteLine(
|
|
$"asio-f32-sync {report.ReferenceSourceId}->{report.SourceId}: evidence={report.EvidenceKind} delaySamples={report.FractionalDelaySamples:0.000000} delayUs={report.DelayMicroseconds:0.000} confidence={report.Confidence:0.000} events={report.TimelineMatchedEvents} bands={DescribeBands(report.BandResponses)} compared={report.ComparedSamples}");
|
|
}
|
|
|
|
return reports.Count > 0 ? 0 : 1;
|
|
}
|
|
|
|
static int ParseIntOption(string[] args, string name, int fallback)
|
|
{
|
|
for (var index = 0; index < args.Length - 1; index++)
|
|
{
|
|
if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase) &&
|
|
int.TryParse(args[index + 1], out var value))
|
|
{
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
static float[] ApplyFractionalDelay(float[] source, double delaySamples)
|
|
{
|
|
var delayed = new float[source.Length];
|
|
for (var index = 0; index < delayed.Length; index++)
|
|
{
|
|
var sourcePosition = index - delaySamples;
|
|
if (sourcePosition < 0.0 || sourcePosition >= source.Length - 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var left = (int)Math.Floor(sourcePosition);
|
|
var fraction = sourcePosition - left;
|
|
delayed[index] = (float)(source[left] + (source[left + 1] - source[left]) * fraction);
|
|
}
|
|
|
|
return delayed;
|
|
}
|
|
|
|
static void AppendFloatBlock(MimirRollingStreamBuffer buffer, string sourceId, float[] samples, int sampleRate)
|
|
{
|
|
var bytes = new byte[samples.Length * sizeof(float)];
|
|
for (var index = 0; index < samples.Length; index++)
|
|
{
|
|
BitConverter.TryWriteBytes(bytes.AsSpan(index * sizeof(float), sizeof(float)), samples[index]);
|
|
}
|
|
|
|
buffer.Append(new MimirStreamSample(
|
|
sourceId,
|
|
MimirStreamKind.Audio,
|
|
MimirStreamOrigin.LocalDevice,
|
|
1_000_000_000L,
|
|
1_000_000_000L,
|
|
1,
|
|
0,
|
|
bytes.Length,
|
|
bytes,
|
|
AudioBlock: new MimirAudioBlockDescriptor(
|
|
sampleRate,
|
|
1,
|
|
MimirAudioSampleFormat.Float32,
|
|
samples.Length,
|
|
1_000_000_000L)));
|
|
}
|
|
|
|
internal sealed class DisposableConfiguration : IDisposable
|
|
{
|
|
private readonly List<IMimirStreamSource> ownedSources;
|
|
|
|
public DisposableConfiguration(MimirRuntimeConfiguration value)
|
|
{
|
|
Value = value;
|
|
ownedSources = value.Sources.ToList();
|
|
}
|
|
|
|
public MimirRuntimeConfiguration Value { get; }
|
|
|
|
public void Detach(IMimirStreamSource source)
|
|
{
|
|
ownedSources.Remove(source);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var source in ownedSources)
|
|
{
|
|
source.Dispose();
|
|
}
|
|
}
|
|
}
|