2026-05-22 00:52:43 +01:00
|
|
|
using Mimir.Runtime.Synchronization;
|
|
|
|
|
|
2026-05-22 16:46:50 +01:00
|
|
|
if (args.Any(arg => string.Equals(arg, "--chirplet-self-test", StringComparison.OrdinalIgnoreCase)))
|
|
|
|
|
{
|
|
|
|
|
return RunChirpletSelfTest();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 00:52:43 +01:00
|
|
|
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));
|
2026-05-22 11:40:56 +01:00
|
|
|
var syncReference = ParseStringOption(args, "--sync-reference", "loopback-scarlett-speakers");
|
2026-05-22 00:52:43 +01:00
|
|
|
using var configuration = new DisposableConfiguration(MimirRuntimeConfiguration.Load());
|
|
|
|
|
using var hub = new MimirSynchronizationHub(configuration.Value.Settings);
|
|
|
|
|
|
|
|
|
|
foreach (var source in configuration.Value.Sources)
|
|
|
|
|
{
|
|
|
|
|
configuration.Detach(source);
|
|
|
|
|
hub.AddSource(source);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var deadline = DateTimeOffset.UtcNow + duration;
|
2026-05-22 12:46:51 +01:00
|
|
|
var nextSyncPoll = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(1);
|
2026-05-22 00:52:43 +01:00
|
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
|
|
|
{
|
|
|
|
|
hub.PollSources(maxSamplesPerSource: 4096);
|
2026-05-22 12:46:51 +01:00
|
|
|
if (DateTimeOffset.UtcNow >= nextSyncPoll)
|
|
|
|
|
{
|
|
|
|
|
hub.AnalyzeAudioSynchronization(syncReference);
|
|
|
|
|
nextSyncPoll += TimeSpan.FromSeconds(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 00:52:43 +01:00
|
|
|
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}");
|
|
|
|
|
}
|
2026-05-22 01:07:30 +01:00
|
|
|
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}");
|
|
|
|
|
}
|
2026-05-22 00:52:43 +01:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine($"{buffer.Descriptor.SourceId}: count={buffer.Count} edgeNs={buffer.EdgeNs}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (buffer.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
emptyBuffers.Add(buffer.Descriptor.SourceId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 11:40:56 +01:00
|
|
|
var reports = hub.AnalyzeAudioSynchronization(syncReference);
|
2026-05-22 01:22:36 +01:00
|
|
|
foreach (var report in reports.OrderBy(report => report.SourceId, StringComparer.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine(
|
2026-05-22 12:40:30 +01:00
|
|
|
$"sync {report.ReferenceSourceId}->{report.SourceId}: delaySamples={report.DelaySamples} fractionalDelaySamples={report.FractionalDelaySamples:0.000} delayMs={report.DelayMilliseconds:0.000} confidence={report.Confidence:0.000} bands={DescribeBands(report.BandResponses)} compared={report.ComparedSamples}");
|
2026-05-22 01:22:36 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 12:46:51 +01:00
|
|
|
foreach (var state in hub.AudioSynchronizationStates)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine(
|
|
|
|
|
$"sync-state {state.ReferenceSourceId}->{state.SourceId}: smoothedDelaySamples={state.SmoothedDelaySamples:0.000} delayMs={state.DelayMilliseconds:0.000} sroPpm={state.SamplingRateOffsetPpm:0.000} confidence={state.Confidence:0.000} bands={DescribeBands(state.BandResponses)}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 00:52:43 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 11:40:56 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 12:40:30 +01:00
|
|
|
static string DescribeBands(IReadOnlyList<MimirChirpletBandResponse> bands)
|
|
|
|
|
{
|
|
|
|
|
return bands.Count == 0
|
|
|
|
|
? "none"
|
|
|
|
|
: string.Join(",", bands.Select(band => $"{band.CenterHz:0}Hz:{band.Energy:0.000}"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 16:46:50 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 00:52:43 +01:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|