diff --git a/config/mimir-runtime.sync-smoke.example.json b/config/mimir-runtime.sync-smoke.example.json new file mode 100644 index 0000000..c996182 --- /dev/null +++ b/config/mimir-runtime.sync-smoke.example.json @@ -0,0 +1,41 @@ +{ + "bufferSeconds": 5.0, + "streams": [ + { + "sourceId": "mic-focusrite-local", + "kind": "Audio", + "origin": "LocalDevice", + "enabled": true, + "adapter": "frame-events", + "command": "../native/probes/wasapi_audio_cadence/build/Release/wasapi_audio_cadence.exe", + "arguments": [ + "--source-id", + "mic-focusrite-local", + "--endpoint", + "{0.0.1.00000000}.{16af9455-4539-4de2-8172-563d991d9e34}", + "--seconds", + "30", + "--emit-json-blocks", + "--include-samples" + ] + }, + { + "sourceId": "mic-usb-camera-0", + "kind": "Audio", + "origin": "LocalDevice", + "enabled": true, + "adapter": "frame-events", + "command": "../native/probes/wasapi_audio_cadence/build/Release/wasapi_audio_cadence.exe", + "arguments": [ + "--source-id", + "mic-usb-camera-0", + "--endpoint", + "{0.0.1.00000000}.{57befef3-599c-4c73-a0cd-9539ffc2f54f}", + "--seconds", + "30", + "--emit-json-blocks", + "--include-samples" + ] + } + ] +} diff --git a/docs/audio-field.md b/docs/audio-field.md index 350947d..49e91db 100644 --- a/docs/audio-field.md +++ b/docs/audio-field.md @@ -42,6 +42,12 @@ USB Camera / PS3 Eye mics, and Scarlett speaker loopback in rolling buffers when loopback audio is actively playing. One PS3 Eye mic previously enumerated but produced zero WASAPI packets until that Eye was unplugged and replugged. +`config/mimir-runtime.sync-smoke.example.json` enables sample-bearing blocks for +the Focusrite reference and one PS3 Eye mic. `MimirAudioSynchronizationAnalyzer` +uses those payloads to estimate current delay by normalized cross-correlation. +This is the measurement half of the old synchronization machine; adaptive +resampling and fractional-delay correction are still the next cut. + Next, replace the diagnostic bridge with native audio capture workers that append typed blocks into `Mimir.Runtime`, then expose buffer depth, clock state, delay estimates, and stem routing in Aquarium UI. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index fb13d4e..3243ca1 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -39,6 +39,9 @@ a named invariant that the native runtime cannot protect yet. - `native/probes/wasapi_audio_cadence` captures WASAPI mic or render-loopback block metadata and emits `audio-block` JSON events for the diagnostic runtime adapter. +- `MimirAudioSynchronizationAnalyzer` ports the first live sync measurement: + sample-bearing audio blocks can be cross-correlated against + `mic-focusrite-local` to estimate current delay and confidence. - `MimirVideoFrameDescriptor` for dimensions, pixel format, stride, device timestamp, and native/GPU handle metadata. - `IMimirVideoCaptureDriver` and `MimirVideoCaptureDriverSource` as the live @@ -72,11 +75,13 @@ a named invariant that the native runtime cannot protect yet. frame cadence in the rolling buffers. 3. Replace the WASAPI frame-event diagnostic bridge with native audio capture workers for local mic, loopback, and network audio feeds. -4. Bind Aquarium UI to the synchronization hub so buffer depth, stream cadence, +4. Add the synchronization actuator: smooth delay/SRO estimates, then drive a + variable-rate resampler and fractional delay line per non-reference stream. +5. Bind Aquarium UI to the synchronization hub so buffer depth, stream cadence, source timestamps, and output settings are visible and adjustable. -5. Move GPU feature extraction, fusion, material fitting, render budgeting, and +6. Move GPU feature extraction, fusion, material fitting, render budgeting, and Spout2 publication into Aquarium. -6. Move mic alignment, room suppression, voice separation, spatialization, and +7. Move mic alignment, room suppression, voice separation, spatialization, and stem generation into Faust/native DSP. -7. Keep the OBS bridge witness ledger as evidence before expanding receiver +8. Keep the OBS bridge witness ledger as evidence before expanding receiver machinery. diff --git a/native/probes/wasapi_audio_cadence/CMakeLists.txt b/native/probes/wasapi_audio_cadence/CMakeLists.txt index f3a038b..6869847 100644 --- a/native/probes/wasapi_audio_cadence/CMakeLists.txt +++ b/native/probes/wasapi_audio_cadence/CMakeLists.txt @@ -12,4 +12,5 @@ target_compile_definitions(wasapi_audio_cadence PRIVATE _WIN32_WINNT=0x0A00) target_link_libraries(wasapi_audio_cadence PRIVATE ole32 - avrt) + avrt + crypt32) diff --git a/native/probes/wasapi_audio_cadence/wasapi_audio_cadence.cpp b/native/probes/wasapi_audio_cadence/wasapi_audio_cadence.cpp index d26255e..be6f464 100644 --- a/native/probes/wasapi_audio_cadence/wasapi_audio_cadence.cpp +++ b/native/probes/wasapi_audio_cadence/wasapi_audio_cadence.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ #include #include #include +#include namespace { @@ -147,6 +149,33 @@ const char* option(int argc, char** argv, const char* name, const char* fallback return fallback; } +std::string base64(const BYTE* data, DWORD byteLength) +{ + DWORD chars = 0; + if (byteLength == 0) + { + return {}; + } + + if (!CryptBinaryToStringA(data, byteLength, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &chars) || chars == 0) + { + return {}; + } + + std::string encoded(chars, '\0'); + if (!CryptBinaryToStringA(data, byteLength, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, encoded.data(), &chars)) + { + return {}; + } + + if (!encoded.empty() && encoded.back() == '\0') + { + encoded.pop_back(); + } + + return encoded; +} + void listEndpoints(IMMDeviceEnumerator* enumerator) { for (const auto flow : { eCapture, eRender }) @@ -243,6 +272,7 @@ int main(int argc, char** argv) const char* sourceId = option(argc, argv, "--source-id", loopback ? "host-loopback" : "host-mic"); const double seconds = std::atof(option(argc, argv, "--seconds", "0")); const bool emitJsonBlocks = hasArg(argc, argv, "--emit-json-blocks"); + const bool includeSamples = hasArg(argc, argv, "--include-samples"); const EDataFlow flow = loopback ? eRender : eCapture; ComPtr device; @@ -338,8 +368,17 @@ int main(int argc, char** argv) ++sequence; if (emitJsonBlocks) { + std::vector silentBytes; + const BYTE* payload = data; + if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0) + { + silentBytes.assign(static_cast(byteLength), 0); + payload = silentBytes.data(); + } + + const std::string encoded = includeSamples ? base64(payload, static_cast(byteLength)) : std::string(); std::printf( - "{\"type\":\"audio-block\",\"sourceId\":\"%s\",\"timestampNs\":%lld,\"sequence\":%llu,\"sampleRate\":%lu,\"channels\":%u,\"sampleFormat\":\"%s\",\"frameCount\":%u,\"byteLength\":%d}\n", + "{\"type\":\"audio-block\",\"sourceId\":\"%s\",\"timestampNs\":%lld,\"sequence\":%llu,\"sampleRate\":%lu,\"channels\":%u,\"sampleFormat\":\"%s\",\"frameCount\":%u,\"byteLength\":%d", sourceId, nowNs(), sequence, @@ -348,6 +387,11 @@ int main(int argc, char** argv) format.c_str(), static_cast(frames), byteLength); + if (includeSamples) + { + std::printf(",\"samplesBase64\":\"%s\"", encoded.c_str()); + } + std::printf("}\n"); } captureClient->ReleaseBuffer(frames); diff --git a/src/Mimir.BufferSmoke/Program.cs b/src/Mimir.BufferSmoke/Program.cs index 5889165..0325190 100644 --- a/src/Mimir.BufferSmoke/Program.cs +++ b/src/Mimir.BufferSmoke/Program.cs @@ -47,6 +47,13 @@ foreach (var buffer in hub.Buffers.Buffers.OrderBy(buffer => buffer.Descriptor.S } } +var reports = hub.AnalyzeAudioSynchronization("mic-focusrite-local"); +foreach (var report in reports.OrderBy(report => report.SourceId, StringComparer.Ordinal)) +{ + Console.WriteLine( + $"sync {report.ReferenceSourceId}->{report.SourceId}: delaySamples={report.DelaySamples} delayMs={report.DelayMilliseconds:0.000} confidence={report.Confidence:0.000} compared={report.ComparedSamples}"); +} + if (requireSamples && emptyBuffers.Count > 0) { Console.Error.WriteLine($"empty buffers: {string.Join(", ", emptyBuffers)}"); diff --git a/src/Mimir.Runtime/MimirRuntime.cs b/src/Mimir.Runtime/MimirRuntime.cs index 443a970..99c28a3 100644 --- a/src/Mimir.Runtime/MimirRuntime.cs +++ b/src/Mimir.Runtime/MimirRuntime.cs @@ -109,6 +109,7 @@ public sealed class MimirRuntime : IAquariumRuntime panel.Readout("Last poll", () => $"{lastPollCount} samples"); panel.Readout("Ingested", () => $"{synchronization.IngestedSamples}"); panel.Readout("Buffer details", DescribeBuffers); + panel.Readout("Audio sync", DescribeAudioSync); }); } @@ -132,4 +133,12 @@ public sealed class MimirRuntime : IAquariumRuntime return $"{buffer.Descriptor.SourceId}: {buffer.Count} edge {buffer.EdgeNs}"; } + + private string DescribeAudioSync() + { + var reports = synchronization.AnalyzeAudioSynchronization("mic-focusrite-local"); + return reports.Count == 0 + ? "no payload windows" + : string.Join(" | ", reports.Select(report => $"{report.SourceId}: {report.DelaySamples} samples {report.DelayMilliseconds:0.00}ms c={report.Confidence:0.00}")); + } } diff --git a/src/Mimir.Runtime/Synchronization/MimirAudioSynchronizationAnalyzer.cs b/src/Mimir.Runtime/Synchronization/MimirAudioSynchronizationAnalyzer.cs new file mode 100644 index 0000000..eb05039 --- /dev/null +++ b/src/Mimir.Runtime/Synchronization/MimirAudioSynchronizationAnalyzer.cs @@ -0,0 +1,174 @@ +using System.Buffers.Binary; + +namespace Mimir.Runtime.Synchronization; + +public sealed class MimirAudioSynchronizationAnalyzer +{ + private const int MaxWindowSamples = 4_800; + private const int MaxLagSamples = 2_400; + + public IReadOnlyList Analyze( + IEnumerable buffers, + string referenceSourceId) + { + var audioBuffers = buffers + .Where(buffer => buffer.Descriptor.Kind == MimirStreamKind.Audio) + .ToArray(); + var reference = audioBuffers.FirstOrDefault(buffer => + string.Equals(buffer.Descriptor.SourceId, referenceSourceId, StringComparison.Ordinal)); + if (reference == null) + { + return []; + } + + var referenceSamples = ExtractMonoWindow(reference, out var referenceBlock); + if (referenceSamples.Length == 0 || referenceBlock == null) + { + return []; + } + + var reports = new List(); + foreach (var buffer in audioBuffers) + { + if (ReferenceEquals(buffer, reference)) + { + continue; + } + + var candidateSamples = ExtractMonoWindow(buffer, out var candidateBlock); + if (candidateSamples.Length == 0 || candidateBlock == null) + { + continue; + } + + if (candidateBlock.SampleRate != referenceBlock.SampleRate || + candidateBlock.SampleFormat != MimirAudioSampleFormat.Float32 || + referenceBlock.SampleFormat != MimirAudioSampleFormat.Float32) + { + continue; + } + + var compared = Math.Min(referenceSamples.Length, candidateSamples.Length); + if (compared < 256) + { + continue; + } + + var maxLag = Math.Min(MaxLagSamples, compared / 2); + var (delaySamples, confidence) = EstimateDelay(referenceSamples.AsSpan(^compared..), candidateSamples.AsSpan(^compared..), maxLag); + reports.Add(new MimirAudioSynchronizationReport( + reference.Descriptor.SourceId, + buffer.Descriptor.SourceId, + referenceBlock.SampleRate, + delaySamples, + delaySamples * 1000.0 / referenceBlock.SampleRate, + confidence, + compared, + reference.Latest?.Sequence ?? 0, + buffer.Latest?.Sequence ?? 0)); + } + + return reports; + } + + private static float[] ExtractMonoWindow(MimirRollingStreamBuffer buffer, out MimirAudioBlockDescriptor? latestBlock) + { + latestBlock = buffer.Latest?.AudioBlock; + if (latestBlock == null || latestBlock.SampleFormat != MimirAudioSampleFormat.Float32) + { + return []; + } + + var samples = new List(MaxWindowSamples); + foreach (var sample in buffer.Snapshot().Where(sample => sample.AudioBlock != null && !sample.Data.IsEmpty).Reverse()) + { + var block = sample.AudioBlock!; + if (block.SampleFormat != MimirAudioSampleFormat.Float32 || block.Channels <= 0) + { + continue; + } + + var mono = ExtractFirstChannel(sample.Data.Span, block.Channels); + for (var index = mono.Length - 1; index >= 0 && samples.Count < MaxWindowSamples; index--) + { + samples.Add(mono[index]); + } + + if (samples.Count >= MaxWindowSamples) + { + break; + } + } + + samples.Reverse(); + return samples.ToArray(); + } + + private static float[] ExtractFirstChannel(ReadOnlySpan data, int channels) + { + const int bytesPerSample = sizeof(float); + var frameCount = data.Length / (bytesPerSample * channels); + var samples = new float[frameCount]; + for (var frame = 0; frame < frameCount; frame++) + { + var offset = frame * channels * bytesPerSample; + var bits = BinaryPrimitives.ReadInt32LittleEndian(data.Slice(offset, bytesPerSample)); + samples[frame] = BitConverter.Int32BitsToSingle(bits); + } + + return samples; + } + + private static (int DelaySamples, double Confidence) EstimateDelay(ReadOnlySpan reference, ReadOnlySpan candidate, int maxLag) + { + var bestLag = 0; + var best = double.NegativeInfinity; + for (var lag = -maxLag; lag <= maxLag; lag++) + { + var score = NormalizedCorrelation(reference, candidate, lag); + if (score > best) + { + best = score; + bestLag = lag; + } + } + + return (bestLag, Math.Max(0.0, Math.Min(1.0, best))); + } + + private static double NormalizedCorrelation(ReadOnlySpan reference, ReadOnlySpan candidate, int lag) + { + var startReference = lag < 0 ? -lag : 0; + var startCandidate = lag > 0 ? lag : 0; + var count = Math.Min(reference.Length - startReference, candidate.Length - startCandidate); + if (count < 128) + { + return double.NegativeInfinity; + } + + var sumReference = 0.0; + var sumCandidate = 0.0; + for (var index = 0; index < count; index++) + { + sumReference += reference[startReference + index]; + sumCandidate += candidate[startCandidate + index]; + } + + var meanReference = sumReference / count; + var meanCandidate = sumCandidate / count; + var cross = 0.0; + var energyReference = 0.0; + var energyCandidate = 0.0; + for (var index = 0; index < count; index++) + { + var a = reference[startReference + index] - meanReference; + var b = candidate[startCandidate + index] - meanCandidate; + cross += a * b; + energyReference += a * a; + energyCandidate += b * b; + } + + var denominator = Math.Sqrt(energyReference * energyCandidate); + return denominator > 1.0e-12 ? cross / denominator : double.NegativeInfinity; + } +} diff --git a/src/Mimir.Runtime/Synchronization/MimirAudioSynchronizationReport.cs b/src/Mimir.Runtime/Synchronization/MimirAudioSynchronizationReport.cs new file mode 100644 index 0000000..65dfd9c --- /dev/null +++ b/src/Mimir.Runtime/Synchronization/MimirAudioSynchronizationReport.cs @@ -0,0 +1,12 @@ +namespace Mimir.Runtime.Synchronization; + +public sealed record MimirAudioSynchronizationReport( + string ReferenceSourceId, + string SourceId, + int SampleRate, + int DelaySamples, + double DelayMilliseconds, + double Confidence, + int ComparedSamples, + ulong ReferenceSequence, + ulong SourceSequence); diff --git a/src/Mimir.Runtime/Synchronization/MimirFrameEventProcessStreamSource.cs b/src/Mimir.Runtime/Synchronization/MimirFrameEventProcessStreamSource.cs index 3f73e7c..134203d 100644 --- a/src/Mimir.Runtime/Synchronization/MimirFrameEventProcessStreamSource.cs +++ b/src/Mimir.Runtime/Synchronization/MimirFrameEventProcessStreamSource.cs @@ -153,6 +153,8 @@ public sealed class MimirFrameEventProcessStreamSource : IMimirStreamSource } var sequence = frameEvent.Sequence ?? fallbackSequence++; + var data = DecodePayload(frameEvent.SamplesBase64, byteLength); + sample = new MimirStreamSample( sourceId, sampleKind, @@ -162,12 +164,30 @@ public sealed class MimirFrameEventProcessStreamSource : IMimirStreamSource sequence, payloadHandle, byteLength, - default, + data, videoFrame, audioBlock); return true; } + private static ReadOnlyMemory DecodePayload(string? base64, int expectedBytes) + { + if (string.IsNullOrWhiteSpace(base64)) + { + return default; + } + + try + { + var data = Convert.FromBase64String(base64); + return expectedBytes <= 0 || data.Length == expectedBytes ? data : default; + } + catch (FormatException) + { + return default; + } + } + private bool AcceptsSourceId(string sourceId) { if (Options.AcceptedSourceIds is { Count: > 0 } acceptedSourceIds) @@ -308,6 +328,8 @@ public sealed class MimirFrameEventProcessStreamSource : IMimirStreamSource public int FrameCount { get; set; } + public string? SamplesBase64 { get; set; } + public ulong NativeHandle { get; set; } public string? NativeHandleKind { get; set; } diff --git a/src/Mimir.Runtime/Synchronization/MimirSynchronizationHub.cs b/src/Mimir.Runtime/Synchronization/MimirSynchronizationHub.cs index fad9d88..2d726d6 100644 --- a/src/Mimir.Runtime/Synchronization/MimirSynchronizationHub.cs +++ b/src/Mimir.Runtime/Synchronization/MimirSynchronizationHub.cs @@ -3,6 +3,7 @@ namespace Mimir.Runtime.Synchronization; public sealed class MimirSynchronizationHub : IDisposable { private readonly List sources = []; + private readonly MimirAudioSynchronizationAnalyzer audioSynchronization = new(); private ulong ingestedSamples; public MimirSynchronizationHub(MimirSynchronizationSettings settings) @@ -57,6 +58,12 @@ public sealed class MimirSynchronizationHub : IDisposable return $"{video} video / {audio} audio buffers"; } + public IReadOnlyList AnalyzeAudioSynchronization(string referenceSourceId) + { + ObjectDisposedException.ThrowIf(disposed, this); + return audioSynchronization.Analyze(Buffers.Buffers, referenceSourceId); + } + private bool disposed; public void Dispose() diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 3a12756..16df361 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -29,3 +29,4 @@ {"ts":"2026-05-22T05:35:00+00:00","type":"verification","status":"ok","note":"Added native/probes/wasapi_audio_cadence and MimirAudioBlockDescriptor so WASAPI audio-block metadata enters Mimir.Runtime rolling buffers through the diagnostic event adapter. Endpoint inventory found five capture endpoints and four render endpoints. Full Mimir.BufferSmoke with --require-samples and active music on Scarlett speakers proved ten non-empty buffers: five camera buffers, Focusrite mic, Kiyo mic, Kiyo Pro mic, one USB Camera mic, and Scarlett speaker loopback. Scarlett loopback is silent/no-packet when no render stream is active; the second USB Camera mic endpoint enumerates OK but produced zero WASAPI packets in standalone and simultaneous tests."} {"ts":"2026-05-22T05:50:00+00:00","type":"verification","status":"partial","note":"Correlated the two PS3 Eye audio endpoints to their USB parents. The working endpoint is Microphone (5- USB Camera-B4.09.24.1), MMDEVAPI {57befef3-599c-4c73-a0cd-9539ffc2f54f}, parent USB\\VID_1415&PID_2000&MI_01\\9&35158e62&0&0001 / composite USB\\VID_1415&PID_2000\\8&29c54ea8&0&4; its camera interface MI_00 is bound to libusbK and WASAPI emits 480-frame blocks. The silent endpoint is Microphone (4- USB Camera-B4.09.24.1), MMDEVAPI {f26c55af-5018-438a-8cc1-6dbead205a3c}, parent USB\\VID_1415&PID_2000&MI_01\\9&11052345&0&0001 / composite USB\\VID_1415&PID_2000\\8&2efe0359&0&1; its camera interface MI_00 is bound to WinUSB/libwdi and standalone WASAPI capture opens but emits zero packets. Both MI_01 audio devices report Microsoft usbaudio OK, so the failure is stream data, not endpoint enumeration."} {"ts":"2026-05-22T06:05:00+00:00","type":"verification","status":"ok","note":"After the other PS3 Eye was unplugged/replugged, the previously silent Microphone (4- USB Camera-B4.09.24.1) endpoint started emitting 480-frame WASAPI blocks even though its MI_00 camera interface remained on oem26.inf / WinUSB/libwdi. Full Mimir.BufferSmoke with --require-samples proved 11 non-empty buffers: five camera buffers, Focusrite mic, Kiyo mic, Kiyo Pro mic, both PS3 Eye mics, and active Scarlett speaker loopback. The earlier driver-as-cause hypothesis is weakened; the failure was fixed by device re-enumeration without changing the camera-side driver binding."} +{"ts":"2026-05-22T06:25:00+00:00","type":"implementation","status":"ok","note":"Ported the first synchronization measurement loop into the current runtime. WASAPI probe can emit base64 sample payloads with audio-block JSON, MimirFrameEventProcessStreamSource decodes them into MimirStreamSample.Data, and MimirAudioSynchronizationAnalyzer estimates delay by normalized cross-correlation against mic-focusrite-local. Two-mic sync smoke with sample payloads proved both buffers and reported mic-focusrite-local->mic-usb-camera-0 delaySamples=2138, delayMs=44.542, confidence=0.393 over 4800 compared samples. This is measurement only; SRO smoothing, adaptive resampling, and fractional-delay correction are still next."} diff --git a/state/map.yaml b/state/map.yaml index eea550d..5c493a2 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -59,6 +59,10 @@ accepted_design: JSON-line video frame and audio block metadata from native probes into runtime rolling buffers; it does not carry pixels/audio payloads or own the final local-device hot path. + - The diagnostic audio path can optionally carry base64 float sample payloads + for sync smoke tests. `MimirAudioSynchronizationAnalyzer` estimates current + delay against `mic-focusrite-local` from those payload-bearing rolling + buffers. - `IMimirVideoCaptureDriver` and `MimirVideoCaptureDriverSource` are the live seam for Leap, Media Foundation, DirectShow, libusb, LeapC, or shared texture capture. @@ -155,13 +159,17 @@ 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: > - Replace the diagnostic JSON-line process bridge with direct native capture - drivers that push buffer handles into Mimir.Runtime. Current + Add the synchronization actuator after the current measurement loop: + smooth delay/SRO estimates, then drive variable-rate resampling and + fractional-delay correction per non-reference stream. Replace the diagnostic + JSON-line process bridge with direct native capture drivers that push buffer + handles into Mimir.Runtime. Current Mimir.BufferSmoke with `--require-samples` proves all five declared local optical buffers plus Focusrite mic, Kiyo Pro mic, Kiyo mic, one USB Camera mic, both PS3 Eye mics, and active Scarlett speaker loopback receive samples. One PS3 Eye mic previously enumerated OK but emitted zero WASAPI packets; unplug/replug fixed the audio stream without changing that Eye's - MI_00 camera binding from WinUSB/libwdi to libusbK. Keep the Focusrite away - from the Leap USB neighborhood and treat Kiyo Pro as a ~25 fps source until - its SuperSpeed negotiation is fixed. + MI_00 camera binding from WinUSB/libwdi to libusbK. A two-mic payload sync + smoke reports live Focusrite-to-PS3-Eye delay by cross-correlation. Keep the + Focusrite away from the Leap USB neighborhood and treat Kiyo Pro as a ~25 + fps source until its SuperSpeed negotiation is fixed.