From ac8f375465178ee771ec4e454c68150548c25b44 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Wed, 10 Jun 2026 21:21:57 +0100 Subject: [PATCH] lab: lift the Saffire UpdateIsochBufferParams latency model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the decompile trace of Saffire::UpdateIsochBufferParams (0xf506) as a portable constexpr unit — the constants the original kext fed IOAudioFamily's setSampleOffset/setSampleLatency, now fully liftable with no bench capture in the loop: - Protocols/Audio/DICE/SaffireIsochLatency.hpp: the latencyMode x rate delay-packet table ({14,2}/{16,6}/{18,10}/{20,14}, +2 at 88.2/96k, +4 at 176.4/192k), DMA-program depth (160/80/40 packets), frames-per-packet (8/16/32), and the derived safety-offset frame helpers. Full provenance in the header: host-internal chain (latencyMode is a host preference, not a device register; writer called only from initHardware / RestartStreaming), DICE-quirk note. Unsupported rates are rejected. - Two prose/table discrepancies in the trace flagged for adjudication (the table wins in code): "safest output = 160 frames" matches the INPUT column (output mode 3 = 14x8 = 112), and "constant ~20 ms DMA buffer" only holds for the <=48k family (160/80/40 packets = 20/10/5 ms). - Tests/SaffireIsochLatencyTests: every table cell pinned, rate bumps on both columns, depth, packet-time math, the quoted 16-frame lowest-mode output, constexpr usability, unsupported-rate rejection. - Driver/VirtualAudioDevice: the SetOutputSafetyOffset(0) placeholder now takes the model value (mode kLow per the trace recommendation: start 0-1 for TX, widen only if framesWithoutPacket climbs) and SetOutputLatency carries the same value — which ADK property maps to which IOAudioFamily field is a bench question; both start at the model value and get observed. Lookup failure falls back to defaults, logged. Suite: 18854 checks, 0 failures. Dext target compiles (signing-off check; the signed lane is unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Driver/VirtualAudioDevice.cpp | 49 ++++++- .../Audio/DICE/SaffireIsochLatency.hpp | 135 ++++++++++++++++++ .../Tests/SaffireIsochLatencyTests.cpp | 127 ++++++++++++++++ ADKVirtualAudioLab/Tests/main.cpp | 2 + 4 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 ADKVirtualAudioLab/Protocols/Audio/DICE/SaffireIsochLatency.hpp create mode 100644 ADKVirtualAudioLab/Tests/SaffireIsochLatencyTests.cpp diff --git a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp index 9dd6cb65..f029ea7c 100644 --- a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp +++ b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp @@ -12,6 +12,7 @@ #include "../Lab/PacketDumpBlob.hpp" #include "../Lab/StickyCounterSink.hpp" #include "../Lab/VerifyingSlotProvider.hpp" +#include "../Protocols/Audio/DICE/SaffireIsochLatency.hpp" using namespace ASFW::Driver; @@ -252,13 +253,47 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, LAB_LOG("init - SetCanBeDefaultSystemOutputDevice set to true"); } - // Set output safety offset - LAB_LOG("init - setting output safety offset to 0"); - kr = SetOutputSafetyOffset(0); - if (kr != kIOReturnSuccess) { - LAB_LOG("init - SetOutputSafetyOffset failed (kr = 0x%{public}08x)", kr); - } else { - LAB_LOG("init - SetOutputSafetyOffset set to 0"); + // Output safety offset + latency from the lifted Saffire model + // (SaffireIsochLatency.hpp — the UpdateIsochBufferParams trace). Mode + // per the trace's recommendation: start at 0–1 for TX, widen only if + // framesWithoutPacket climbs. delayPackets × framesPerPacket = frames. + { + constexpr auto kLabLatencyMode = + ASFW::Protocols::Audio::DICE::SaffireLatencyMode::kLow; + ASFW::Protocols::Audio::DICE::SaffireIsochBufferParams latencyParams{}; + if (ASFW::Protocols::Audio::DICE::SaffireIsochLatency::Lookup( + kLabLatencyMode, kSampleRate, latencyParams)) { + const uint32_t outputOffsetFrames = + latencyParams.OutputSafetyOffsetFrames(); + LAB_LOG("init - Saffire latency model: mode=%{public}u " + "out_packets=%{public}u out_frames=%{public}u " + "in_packets=%{public}u dma_depth=%{public}u", + static_cast(kLabLatencyMode), + latencyParams.outputDelayPackets, outputOffsetFrames, + latencyParams.inputDelayPackets, + latencyParams.dmaProgramDepthPackets); + + kr = SetOutputSafetyOffset(outputOffsetFrames); + if (kr != kIOReturnSuccess) { + LAB_LOG("init - SetOutputSafetyOffset failed (kr = 0x%{public}08x)", kr); + } else { + LAB_LOG("init - SetOutputSafetyOffset set to %{public}u", outputOffsetFrames); + } + + // The kext stated both fields (setSampleOffset/setSampleLatency); + // which ADK property maps to which is a bench question — start + // with both carrying the model value and observe. + kr = SetOutputLatency(outputOffsetFrames); + if (kr != kIOReturnSuccess) { + LAB_LOG("init - SetOutputLatency failed (kr = 0x%{public}08x)", kr); + } else { + LAB_LOG("init - SetOutputLatency set to %{public}u", outputOffsetFrames); + } + } else { + LAB_LOG("init - Saffire latency lookup failed for rate %{public}u; " + "leaving safety offset at default", + kSampleRate); + } } // Float32 output stream shaped by the profile caps. diff --git a/ADKVirtualAudioLab/Protocols/Audio/DICE/SaffireIsochLatency.hpp b/ADKVirtualAudioLab/Protocols/Audio/DICE/SaffireIsochLatency.hpp new file mode 100644 index 00000000..e065f51d --- /dev/null +++ b/ADKVirtualAudioLab/Protocols/Audio/DICE/SaffireIsochLatency.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include + +namespace ASFW::Protocols::Audio::DICE { + +// Saffire isoch buffer / latency model, lifted from the Saffire.kext +// decompile (Saffire::UpdateIsochBufferParams, 0xf506) — the constants the +// original driver fed IOAudioFamily's setSampleOffset/setSampleLatency. +// +// Provenance (the trace that established this is host-internal and fully +// liftable — no bench capture in the loop): +// +// - UpdateIsochBufferParams is written only from initHardware and +// RestartStreaming, never from a register-read path: the values are a +// pure latencyMode × sampleRate lookup, not device state. +// - latencyMode is a host-side preference (Focusrite's buffer-size / +// "safe mode" setting), not a device register. +// - Base delay-packet table (input, output): +// mode 0 (lowest): 14, 2 +// mode 1: 16, 6 +// mode 2: 18, 10 +// mode 3 (safest): 20, 14 +// plus a rate bump to BOTH columns: +2 at 88.2/96 kHz, +4 at 176.4/192. +// - The same function sets the isoch DMA-program depth: 160 / 80 / 40 +// packets for ≤48 / ≤96 / ≤192 kHz. (The trace prose calls this "a +// constant ~20 ms hardware buffer regardless of rate", but in packet +// time 160/80/40 × 125 µs = 20/10/5 ms — only the ≤48 k family is +// 20 ms. Second prose/table discrepancy, flagged for adjudication; +// this header encodes the packet counts.) +// - Safety offset in frames = delayPackets × framesPerDataPacket +// (8 / 16 / 32 at ≤48 / ≤96 / ≤192 kHz). Because the delay is counted +// in packets, the time-domain offset is rate-independent until the +// high-rate bump. +// +// Note: this is DICE-family behavior above the IEC 61883 spec (a quirk, not +// spec-derived) — the numbers are device-validated, not derivable. +// +// Known prose/table discrepancy in the source trace, flagged for review: +// the trace prose says "safest mode = 160 frames (~3.3 ms)" for the OUTPUT +// offset at 48 kHz, but the table gives output mode 3 = 14 × 8 = 112 frames +// (160 = the INPUT column, 20 × 8). This header encodes the TABLE. +// +// Policy recommendation from the trace, for consumers: expose latencyMode as +// a safe-mode preference, start at mode 0–1 for TX (output offset 2–6 +// packets), widen only if the payload writer's framesWithoutPacket climbs. + +enum class SaffireLatencyMode : uint8_t { + kLowest = 0, + kLow = 1, + kMedium = 2, + kSafest = 3, +}; + +struct SaffireIsochBufferParams final { + uint32_t inputDelayPackets{0}; + uint32_t outputDelayPackets{0}; + uint32_t dmaProgramDepthPackets{0}; + uint32_t framesPerDataPacket{0}; + + [[nodiscard]] constexpr uint32_t InputSafetyOffsetFrames() const noexcept { + return inputDelayPackets * framesPerDataPacket; + } + [[nodiscard]] constexpr uint32_t OutputSafetyOffsetFrames() const noexcept { + return outputDelayPackets * framesPerDataPacket; + } + // 125 µs per isoch packet. + [[nodiscard]] constexpr uint32_t DmaProgramDepthMicroseconds() const noexcept { + return dmaProgramDepthPackets * 125u; + } +}; + +class SaffireIsochLatency final { +public: + [[nodiscard]] static constexpr bool Lookup( + SaffireLatencyMode mode, uint32_t sampleRate, + SaffireIsochBufferParams& outParams) noexcept { + uint32_t rateBumpPackets = 0; + uint32_t framesPerDataPacket = 0; + uint32_t dmaDepthPackets = 0; + switch (sampleRate) { + case 44100u: + case 48000u: + rateBumpPackets = 0; + framesPerDataPacket = 8; + dmaDepthPackets = 160; + break; + case 88200u: + case 96000u: + rateBumpPackets = 2; + framesPerDataPacket = 16; + dmaDepthPackets = 80; + break; + case 176400u: + case 192000u: + rateBumpPackets = 4; + framesPerDataPacket = 32; + dmaDepthPackets = 40; + break; + default: + return false; // not a Saffire-supported rate + } + + uint32_t inputBasePackets = 0; + uint32_t outputBasePackets = 0; + switch (mode) { + case SaffireLatencyMode::kLowest: + inputBasePackets = 14; + outputBasePackets = 2; + break; + case SaffireLatencyMode::kLow: + inputBasePackets = 16; + outputBasePackets = 6; + break; + case SaffireLatencyMode::kMedium: + inputBasePackets = 18; + outputBasePackets = 10; + break; + case SaffireLatencyMode::kSafest: + inputBasePackets = 20; + outputBasePackets = 14; + break; + default: + return false; + } + + outParams.inputDelayPackets = inputBasePackets + rateBumpPackets; + outParams.outputDelayPackets = outputBasePackets + rateBumpPackets; + outParams.dmaProgramDepthPackets = dmaDepthPackets; + outParams.framesPerDataPacket = framesPerDataPacket; + return true; + } +}; + +} // namespace ASFW::Protocols::Audio::DICE diff --git a/ADKVirtualAudioLab/Tests/SaffireIsochLatencyTests.cpp b/ADKVirtualAudioLab/Tests/SaffireIsochLatencyTests.cpp new file mode 100644 index 00000000..da2a5b00 --- /dev/null +++ b/ADKVirtualAudioLab/Tests/SaffireIsochLatencyTests.cpp @@ -0,0 +1,127 @@ +#include "TestHarness.hpp" + +#include "../Protocols/Audio/DICE/SaffireIsochLatency.hpp" + +// Pins every cell of the lifted Saffire latency model (the +// UpdateIsochBufferParams trace): the 4×6 delay-packet table with rate +// bumps, the DMA-program depth, frames-per-packet, and the derived +// safety-offset frame counts the prose quotes. + +namespace ASFW::LabTests { + +using Protocols::Audio::DICE::SaffireIsochBufferParams; +using Protocols::Audio::DICE::SaffireIsochLatency; +using Protocols::Audio::DICE::SaffireLatencyMode; + +void RunSaffireIsochLatencyTests(TestContext& ctx) { + // The full base table at 48 kHz (no rate bump, 8 frames/packet). + { + struct Row final { + SaffireLatencyMode mode; + uint32_t input; + uint32_t output; + }; + constexpr Row kRows[4] = { + {SaffireLatencyMode::kLowest, 14, 2}, + {SaffireLatencyMode::kLow, 16, 6}, + {SaffireLatencyMode::kMedium, 18, 10}, + {SaffireLatencyMode::kSafest, 20, 14}, + }; + for (const auto& row : kRows) { + SaffireIsochBufferParams params{}; + CHECK(ctx, SaffireIsochLatency::Lookup(row.mode, 48000, params)); + CHECK_EQ_U32(ctx, params.inputDelayPackets, row.input); + CHECK_EQ_U32(ctx, params.outputDelayPackets, row.output); + CHECK_EQ_U32(ctx, params.framesPerDataPacket, 8); + CHECK_EQ_U32(ctx, params.dmaProgramDepthPackets, 160); + } + } + + // The trace's quoted figures: 48 kHz lowest-mode output = 2 × 8 = 16 + // frames; safest-mode output per the TABLE = 14 × 8 = 112 frames (the + // prose's "160 frames" matches the INPUT column, 20 × 8 — flagged in the + // header for adjudication). + { + SaffireIsochBufferParams lowest{}; + SaffireIsochBufferParams safest{}; + CHECK(ctx, SaffireIsochLatency::Lookup(SaffireLatencyMode::kLowest, + 48000, lowest)); + CHECK(ctx, SaffireIsochLatency::Lookup(SaffireLatencyMode::kSafest, + 48000, safest)); + CHECK_EQ_U32(ctx, lowest.OutputSafetyOffsetFrames(), 16); + CHECK_EQ_U32(ctx, safest.OutputSafetyOffsetFrames(), 112); + CHECK_EQ_U32(ctx, safest.InputSafetyOffsetFrames(), 160); + } + + // Rate bumps apply to BOTH columns; frames-per-packet and DMA depth + // follow the rate family. + { + struct RateRow final { + uint32_t rate; + uint32_t bump; + uint32_t framesPerPacket; + uint32_t depth; + }; + // Depth-in-time: 160/80/40 packets × 125 µs = 20/10/5 ms. The trace + // prose calls this "a constant ~20 ms hardware buffer regardless of + // rate", which only matches the ≤48 k family — second prose/table + // discrepancy flagged for adjudication; the table wins here too. + constexpr RateRow kRates[6] = { + {44100, 0, 8, 160}, {48000, 0, 8, 160}, {88200, 2, 16, 80}, + {96000, 2, 16, 80}, {176400, 4, 32, 40}, {192000, 4, 32, 40}, + }; + for (const auto& rateRow : kRates) { + SaffireIsochBufferParams params{}; + CHECK(ctx, SaffireIsochLatency::Lookup(SaffireLatencyMode::kLow, + rateRow.rate, params)); + CHECK_EQ_U32(ctx, params.inputDelayPackets, 16 + rateRow.bump); + CHECK_EQ_U32(ctx, params.outputDelayPackets, 6 + rateRow.bump); + CHECK_EQ_U32(ctx, params.framesPerDataPacket, + rateRow.framesPerPacket); + CHECK_EQ_U32(ctx, params.dmaProgramDepthPackets, rateRow.depth); + CHECK_EQ_U32(ctx, params.DmaProgramDepthMicroseconds(), + rateRow.depth * 125u); + } + } + + // Packet-counted delay means the time-domain offset is rate-independent + // until the bump: mode-low output = 6 packets = 750 µs at 48 k; at 96 k + // it is (6+2) packets but each packet still spans 125 µs of bus time. + { + SaffireIsochBufferParams at48k{}; + SaffireIsochBufferParams at96k{}; + CHECK(ctx, SaffireIsochLatency::Lookup(SaffireLatencyMode::kLow, 48000, + at48k)); + CHECK(ctx, SaffireIsochLatency::Lookup(SaffireLatencyMode::kLow, 96000, + at96k)); + CHECK_EQ_U32(ctx, at48k.outputDelayPackets * 125u, 750); + CHECK_EQ_U32(ctx, at96k.outputDelayPackets * 125u, 1000); + // Frame counts scale with rate so the packet model holds: + CHECK_EQ_U32(ctx, at48k.OutputSafetyOffsetFrames(), 48); + CHECK_EQ_U32(ctx, at96k.OutputSafetyOffsetFrames(), 128); + } + + // Unsupported rates are rejected, never guessed. + { + SaffireIsochBufferParams params{}; + CHECK(ctx, !SaffireIsochLatency::Lookup(SaffireLatencyMode::kLowest, + 22050, params)); + CHECK(ctx, !SaffireIsochLatency::Lookup(SaffireLatencyMode::kLowest, + 384000, params)); + } + + // The table is constexpr-usable (compile-time single source of truth). + { + constexpr auto kCompileTime = [] { + SaffireIsochBufferParams params{}; + (void)SaffireIsochLatency::Lookup(SaffireLatencyMode::kLow, 48000, + params); + return params; + }(); + static_assert(kCompileTime.OutputSafetyOffsetFrames() == 48, + "mode 1 output offset at 48 kHz must be 48 frames"); + CHECK_EQ_U32(ctx, kCompileTime.outputDelayPackets, 6); + } +} + +} // namespace ASFW::LabTests diff --git a/ADKVirtualAudioLab/Tests/main.cpp b/ADKVirtualAudioLab/Tests/main.cpp index 9999bdba..3f917ad3 100644 --- a/ADKVirtualAudioLab/Tests/main.cpp +++ b/ADKVirtualAudioLab/Tests/main.cpp @@ -18,6 +18,7 @@ void RunVerifierScenarioTests(TestContext& ctx); void RunTxTimingModelTests(TestContext& ctx); void RunWriteEndTraceReplayerTests(TestContext& ctx); void RunPacketDumpBlobTests(TestContext& ctx); +void RunSaffireIsochLatencyTests(TestContext& ctx); } // namespace ASFW::LabTests @@ -40,6 +41,7 @@ int main() { RunWriteEndTraceReplayerTests(ctx); RunVerifierScenarioTests(ctx); RunPacketDumpBlobTests(ctx); + RunSaffireIsochLatencyTests(ctx); std::printf("%d checks, %d failures\n", ctx.checks, ctx.failures); return ctx.failures == 0 ? 0 : 1;