Skip to content

audio: lift the Saffire UpdateIsochBufferParams latency model into TimingCursorPolicy#27

Open
boggspa wants to merge 269 commits into
mrmidi:DICEfrom
boggspa:audio/saffire-latency-model
Open

audio: lift the Saffire UpdateIsochBufferParams latency model into TimingCursorPolicy#27
boggspa wants to merge 269 commits into
mrmidi:DICEfrom
boggspa:audio/saffire-latency-model

Conversation

@boggspa

@boggspa boggspa commented Jun 10, 2026

Copy link
Copy Markdown

What

The UpdateIsochBufferParams lift, redone in the right tree — replaces the misplaced #26 (lab untouched; the lab-side version stays parked on boggspa:lab/saffire-latency-model if you ever want it as a rehearsal copy).

Where it landed

Your TimingCursorPolicy already had the trace's numbers hand-planted (48/128 cursor frames = mode-kLow × 8, 6/16 packets in Snapshot()) — so the table became the single source there:

  • Audio/Runtime/SaffireIsochLatency.hpp — the trace as a constexpr unit: delay-packet table ({14,2}/{16,6}/{18,10}/{20,14}, +2 at 88.2/96k, +4 at 176.4/192k), DMA depth 160/80/40, frames-per-packet 8/16/32, derived delay-frame helpers, full provenance (host-internal chain, latencyMode = host preference, DICE-quirk note), unsupported rates rejected.
  • TimingCursorPolicyCursorOffsetFrames and the snapshot's delay packets now derive from the table at LatencyMode() = kLow. Zero behavior change, proven by static_asserts pinning 48/128/6/16. Your bench-tuned ADK values (ReportedLatencyFrames 29, SafetyOffsetFrames 8) are deliberately untouched — documented as sitting on top of the cursor offset, mapping still a bench question.
  • tests/audio/SaffireIsochLatencyTests — every table cell pinned + the policy-derives-from-table single-sourcing check.

Two prose/table discrepancies in the trace — please adjudicate (code follows the table)

  1. "safest mode = 160 frames" for the output offset at 48 kHz — table says output mode 3 = 14×8 = 112; 160 = the input column (20×8).
  2. "constant ~20 ms hardware buffer regardless of rate" — 160/80/40 packets × 125 µs = 20/10/5 ms; only ≤48k is 20 ms.

Verification

host suite: 1217/1217 pass
static_asserts: derived values == previously hand-planted values

🤖 Generated with Claude Code

mrmidi and others added 30 commits February 25, 2026 04:51
SonarQube CRITICAL cpp:S5025 fired on IsochReceiveContext.cpp because
`new (std::nothrow)` was assigned to a raw pointer before being wrapped in
OSSharedPtr. Even with an immediate wrap, SonarQube flags the raw-pointer
assignment itself.

Introduce `ASFWDriver/Common/DriverKitUtils.hpp` with a
`MakeOSObject<T>(Args&&...)` factory template that centralises the
unavoidable DriverKit allocation pattern:

  - Allocates with `new (std::nothrow)` (single NOSONAR suppression here)
  - Null-checks the result
  - Immediately transfers ownership into `OSSharedPtr<T>(raw, OSNoRetain)`
  - Returns an empty OSSharedPtr on allocation failure
  - `static_assert(std::is_base_of_v<OSObject, T>)` enforces correct usage
    at compile time
  - `[[nodiscard]]` prevents silent discard; `noexcept` since placement-new
    never throws and DriverKit disables exceptions

Update `IsochReceiveContext::Create()` to use the new helper:

  Before:
    IsochReceiveContext* rawCtx = new (std::nothrow) IsochReceiveContext();
    if (!rawCtx) return nullptr;
    rawCtx->hardware_ = hw;
    rawCtx->dmaMemory_ = std::move(dmaMemory);
    if (!rawCtx->init()) {
        rawCtx->release();   // manual, easy to miss on new paths
        return nullptr;
    }
    return OSSharedPtr<IsochReceiveContext>(rawCtx, OSNoRetain);

  After:
    auto ctx = ASFW::Common::MakeOSObject<IsochReceiveContext>();
    if (!ctx) return nullptr;
    ctx->hardware_ = hw;
    ctx->dmaMemory_ = std::move(dmaMemory);
    if (!ctx->init()) return nullptr;  // OSSharedPtr destructor calls release()
    return ctx;

The manual `rawCtx->release()` on the init-failure path is eliminated —
the OSSharedPtr destructor handles it automatically. No change to the
function signature, return type, or caller behaviour.

MakeOSObject is variadic and reusable for any other OSObject factory in
the driver (e.g. ASFWProtocolBooleanControl::Create()). All 306 C++ unit
tests pass.
strncpy does not guarantee NUL-termination when the source is exactly as
long as the destination buffer, requiring an explicit
`buf[sizeof(buf)-1] = '\0'` guard after every call — a pattern that is
easy to omit on new code paths and was already inconsistent across the
codebase (ControllerMetrics::Reset() never set the sentinel).

Replace every occurrence with strlcpy, which always NUL-terminates and
takes the full buffer size rather than size-1, making the intent clear
and eliminating the manual sentinel writes.

Files updated:
  - ASFWDriver/Debug/BusResetPacketCapture.cpp
  - ASFWDriver/Diagnostics/ControllerMetrics.cpp
  - ASFWDriver/Diagnostics/StatusPublisher.cpp
  - ASFWDriver/UserClient/Handlers/BusResetHandler.cpp
  - ASFWDriver/UserClient/Handlers/StatusHandler.cpp
  - ASFWDriver/UserClient/Handlers/TopologyHandler.cpp

DriverVersionInfo.hpp additionally replaces the manual default constructor
(which called memset) with C++ default member initialisers on each field.
The memset-based constructor was redundant: aggregate initialisation with
`{}` already zero-initialises all members, and the explicit constructor
was only needed to paper over the missing in-class initialisers. The
change also migrates the six strlcpy calls in the static factory method.
…ead rule-of-5

The ROMScanner constructor was refactored in 314a182 to accept a
ROMScannerParams struct, but the call-site in ASFWDriver::Start() was
not updated, leaving the driver calling the old three-argument overload.
Pass a default-constructed ROMScannerParams{} to match the new signature.

The test helper struct PendingRead stores an
InterfaceCompletionCallback (a std::function), which is non-trivially
copyable. Storing PendingRead values in a std::vector<PendingRead> and
calling operations like push_back or emplace_back can trigger implicit
copy/move construction. Without explicit rule-of-5 declarations the
compiler may suppress the move constructor when a non-trivial copy
constructor is present, causing unnecessary copies or outright
compilation errors depending on the C++ version and optimisation level.
Explicitly default all five special members on PendingRead to restore
the expected copy+move semantics.
… capture (S1048)

Two bugs in ObserverGuard<T>:

1. Destructor could terminate on throwing Unregister (S1048)
   The destructor called unregister_() — a std::function storing a
   virtual Unregister* call — with no exception guard. The destructor
   is already marked noexcept, so any exception thrown by the callee
   would invoke std::terminate rather than propagate. Added a
   try/catch(...) to swallow any exception explicitly, making the
   intent clear and satisfying SonarQube cpp:S1048.

2. IUnitObserver branch captured registry by reference
   The lambda stored in unregister_ captured `registry` by reference
   (i.e. a reference-to-reference bound into a std::function that
   outlives the constructor). If the registry was destroyed before the
   ObserverGuard, the destructor would call through a dangling
   reference — UB with no diagnostic.

   The IDeviceObserver branch already handled this correctly by taking
   &registry as a typed pointer and capturing that pointer by value.
   Apply the same pattern to IUnitObserver: cast to IUnitRegistry*
   and capture the pointer, matching the existing convention.
   static_cast is correct here (dynamic_cast is only needed in the
   IDeviceObserver branch where RegistryType may not implement
   IDeviceManager).
The branchless CRC32 inner loop used `-(crc & 1u)` to produce an
all-zeros or all-ones uint32_t mask. While well-defined (unsigned
wraparound), SonarQube cpp:S876 flags unary minus on unsigned types as
error-prone — the result is a large positive value, not a negative one,
which is surprising to readers.

Replace with an explicit ternary that makes the all-zeros / all-ones
intent clear without relying on unsigned wrap semantics.
…lication

Group A (19 issues): Add NOSONAR(cpp:S3923) to if/else blocks where both
branches contain only ASFW_LOG* calls with different format strings.
os_log requires compile-time literal format strings, so branches cannot
be merged with a ternary — the structural identity is a false positive.

Files: AsyncSubsystem.cpp, Tracking.hpp, BusResetCoordinator.cpp,
SelfIDCapture.cpp, DeviceRegistry.cpp, IsochAudioTxPipeline.cpp,
LogConfig.cpp, AVCUnit.cpp, DICETransaction.cpp, SPro24DspProtocol.cpp,
IsochHandler.cpp

Group B (1 real fix): MusicSubunit.cpp — the first and third branches of
the plug-direction if/else both assigned kInput. Extract it as the default
before the conditional; keep the inner if/else only to override kOutput
and log the out-of-range warning. No behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace raw IOBufferMemoryDescriptor* pointers with OSSharedPtr<> in
StartIsochReceive, StartIsochTransmit (ASFWDriver.cpp) and
StartStreaming (AVCAudioBackend.cpp, DiceAudioBackend.cpp).

Use .attach() to receive the pointer from CopyRxQueueMemory /
CopyTransmitQueueMemory and .detach() to transfer ownership into
StartReceive / StartTransmit. This eliminates all manual ->release()
calls on error paths and ensures the buffer is released automatically
if an early-return path is added in future.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… channel caps

TxBufferProfiles.hpp:
- Replace ASFW_TX_PROFILE_A/B/C #defines with enum class TxProfileId : uint8_t
- ASFW_TX_TUNING_PROFILE default changed to plain integer (0=A, 1=B, 2=C)
- static_assert bounds-checks the macro value at compile time
- Replace #if/#elif/#error profile selection with constexpr SelectTxProfile()
- Add GetActiveTxProfile() / SetActiveTxProfile() for future runtime toggling

AudioRingBuffer.hpp:
- Remove #ifndef ASFW_MAX_SUPPORTED_CHANNELS guard and NOSONAR
- Add static constexpr kMaxSupportedChannels = 16 inside the class template

StreamProcessor.hpp:
- Remove #ifndef ASFW_MAX_SUPPORTED_CHANNELS guard and NOSONAR
- kMaxSupportedPcmChannels inlined to 16 directly

tools/calc_buffer_sizes.py:
- Update profile selection to use plain integer map {"0":"A","1":"B","2":"C"}
- Remove endswith("_A/B/C") branches and ASFW_TX_PROFILE_A/B/C reverse lookup

All 306 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Centralize audio caps/sizing in AudioConstants (PCM cap 32, DBS cap 32)

- Rename Tx/Rx profile headers to AudioTx/AudioRx; remove RX macro selection

- Wire Config constants through Encoding/TX/RX/Shared queue and update tooling/tests
- Add .clang-format and .clang-tidy, plus tools/quality/{format,tidy,analyze,all}.sh (tidy exits non-zero on warnings; host + DriverKit passes).
- Rewrite ConfigROM into Common/Parse/Store/Remote/Local with facade headers; remove legacy poll/event-bus/FSM plumbing.
- Introduce Local/MemoryMapView.hpp to centralize IOMemoryMap::GetAddress() integer->ptr conversions.
- Implement callback-driven ROMScanner::Start/Abort backed by ROMScanSession serial executor, bounded inflight, speed fallback, EnsurePrefix, and optional IRM read+CAS verification (marks bad IRM via TopologyManager).
- Update ControllerCore + UserClient ConfigROMHandler to the new scanner API; delete obsolete discovery polling/manual trigger paths.
- Update tests (parser/scanner/store) + CMake/build wiring and docs; isolate register enum casts in Register32FromOffsetUnchecked() for analyzer.
- Split ROMScanSession into core/details/IRM translation units

- Convert ConfigROMParser APIs to std::expected and surface CRCStatus

- Add ConfigROM offset/count unit types for bytes/quadlets

- Run IRM verify even for minimal ROM; allow Complete from IRM states

- Refactor details discovery to use ParseDirectory + async state helpers

- Add host tests: CRC mismatch warning, IRM verify minimal ROM, abort ignores late callbacks, EnsurePrefix cap
- Fix Doxygen/README inaccuracies (keyId vs key byte, CRC/endianness wording)

- Clarify ConfigROMBuilder ImageBE/ImageNative and ConfigROMStager staging/restore sequence

- Reduce clang-tidy noise: designated init, explicit math parentheses, move descriptor-dir fetch state out of function
- Replace ROMScanSession details discovery callback maze with a per-node DetailsDiscovery state machine.

- Introduce TextDescriptorFetcher to unify leaf vs descriptor-directory textual descriptor fetch (best-effort, never fails node).

- Add runtime-gated verbose traces (ASFW_LOG_V3(ConfigROM, ...)) at step boundaries and failure/skip points.

- Add host tests for vendor/model leaf extraction, descriptor-dir fallback, and unit-dir modelName parsing.
- Document IFireWireBusOps contract + buffer lifetimes (no reentrancy; generation guard; cancel semantics).

- FireWireBusImpl: reject stale FW::Generation at boundary, post kStaleGeneration completion async, return AsyncHandle{0}.

- AsyncSubsystem: implement Cancel() for transaction handles and ReadWithRetry queued handles; ensure cancel completions are posted via PostToWorkloop (never inline).

- Tracking: map kIOReturnAborted to AsyncStatus::kAborted.

- Tests: add AsyncBusContractTests + host-only PostToWorkloop deferral hooks.

Quality: tools/quality/tidy.sh clean; ./build.sh --test-only --no-bump pass; tools/quality/analyze.sh run (existing analyzer issues remain outside Async/ConfigROM).
Milestone 2: invert dependencies so protocol layers consume IFireWireBus ports instead of AsyncSubsystem.

- Added ASFWDriver/Protocols/Ports/FireWireBusPort.hpp aliases for IFireWireBusOps/IFireWireBusInfo.
- Refactored protocol implementations (DICE, Oxford/Apogee, AV/C FCP/PCR) to take bus ops/info ports and FW::NodeId, dropping AsyncSubsystem includes.
- Updated discovery wiring: DeviceRegistry/ControllerCore now pass bus ports into DeviceProtocolFactory; AV/C discovery + FCPResponseRouter are constructed from ControllerCore::Bus() and wired to PacketRouter in ASFWDriver::Start.
- Added ControllerCore setters for AV/C dependencies and a const Bus() accessor.
- Host/test build fixes: Logging.hpp now includes LogConfig.hpp; HostDriverKitStubs pulls DriverKit/IOLib.h stubs; AVCCommand.hpp includes <algorithm> for std::fill.

Quality: host tests pass; clang-tidy clean; xcodebuild analyze succeeds.
- Change Discovery::Generation from uint16_t to ASFW::FW::Generation.\n- Update key packing/logging/call sites to use .value or Generation{...} at boundaries.\n- Fix UserClient discovery/ConfigROM handlers to marshal Generation to wire types.\n- Add static_asserts for Generation trivial copy + 32-bit size.\n- Update host tests for the strong type.
Mechanical refactor only; no behavior changes intended.

AsyncSubsystem: split into Lifecycle/Interrupts/BusReset/CommandQueue/Diagnostics translation units.

ControllerCore: split into Lifecycle/Interrupts/Discovery/Facades translation units (ControllerCore.cpp kept as a thin stub TU).

BusResetCoordinator: split into FSM/Actions/DiscoveryDelay translation units.

Xcode: project uses file-system synchronized groups, so new .cpp files are picked up automatically.

Quality: tools/quality/tidy.sh, ./build.sh --test-only --no-bump, tools/quality/analyze.sh.
Introduce a protocol-facing BlockWriteRequestView + disposition so AV/C routing no longer includes Async/Rx PacketRouter, PacketHelpers, or ResponseCode headers.

PacketRouter/ARPacketView adaptation now lives in ASFWDriver.cpp (composition root), keeping protocol code independent of Async Rx internals.
Checkpoint current async boundary cleanup, UserClient runtime-state work, FireWire IOReturn layer updates, and bus-reset recovery hardening.

Includes typed Self-ID/topology validation, selfIDComplete2 handling, 2s software-reset holdoff, updated docs, and new host tests.

Validation: build.sh --test-only --no-bump passes, tidy clean, analyze succeeds with existing unrelated analyzer findings outside this milestone.
Make bus-reset gap handling transactional with confirmed and in-flight state.

Keep corrective reset dispatch fail-closed so invalid topology or failed PHY/reset operations never publish stale bus state.

Tighten the coordinator reset-cycle state and merge logic, expand host tests for gap policy and failure paths, and stop tracking CLEAN_CODE.md while leaving it local-only.
Bus: finalize reset recovery and gap optimization
Fix analysis findings and prep local Sonar runs
mrmidi and others added 29 commits June 8, 2026 10:05
Replace the old txScheduledSampleFrame_ TX timeline with a phase-driven model
and fix the bugs that made it ship continuous silence after ~1s.

Model:
- TxOutputPhaseLoop is a free-running, unwrapped int64 phase accumulator plus a
  device-drift monitor. The AMDTP assembler owns the wire DATA/NO-DATA cadence;
  the loop advances its phase on the assembler's isData and never decides cadence
  itself. Glitch/lead are evaluated in the 1-second offset domain (extOffsetDiff1s)
  so the per-second transmit-cycle wrap is not mistaken for a discontinuity.
- OutputPhaseToAudioMap converts the unwrapped phase to an absolute audio frame by
  plain subtraction (no modular wrap, no 4s ceiling), anchored once at
  reportedPlayhead - PacketLeadFrames via TimingCursorPolicy, re-anchored on a
  device discontinuity.
- SYT generation is delegated to the existing Encoding::SYTGenerator
  (transmit-cycle anchored, advances by the fixed packet step, RX-cadence nudged
  via nudgeOffsetTicks) instead of being re-derived in the phase loop.

Bugs fixed vs the in-progress phase-loop refactor:
- deadband rebase fired every cycle, collapsing the 3:1 cadence to all-DATA and
  overrunning 48k;
- the device phase was a 1-second sawtooth that tripped the glitch detector every
  second and made the derived audio frame non-monotonic;
- the map was anchored at writtenEnd with no safety margin (packets landed in the
  future, uncovered) and wrapped after +/-4s;
- the phase loop and the assembler were two independent cadence sources;
- txScheduledSampleFrame was published unconditionally (0 on NO-DATA);
- PhaseToFrame could underflow on a negative delta.

Tests:
- TxOutputPhaseLoop / OutputPhaseToAudioMap unit tests rewritten for the new
  semantics, incl. per-second-wrap-is-not-a-glitch, unwrapped large-span and
  negative-delta clamp; RealisticSteadyState48k now holds the 3:1 cadence.
- New loop+map integration test drives a 1-second-wrapping device phase and an
  advancing writtenEnd for 3 simulated seconds and asserts coverage >=99% with no
  future-misses and no glitches (mirrors the tx_sim.py contract).
- IsochAudioTxPipelineTimelineTests migrated to the new model: a recovered-clock
  bridge is established so the frame map can anchor, with two contract updates
  (a pre-write packet stays startup silence; an uncovered future range now ships
  silence instead of deferring).

Add the TX timeline simulators (tools/debug/asfw_saffire_tx_sim.py and _sim2.py)
that capture the contract this implementation follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fn, decouple populate

Items from validated review:

1. Add PreparedTxSlotState::SilenceFallback
   - Distinct from InitialSilence (startup/pre-anchor) and PcmPrepared (real PCM).
   - PreparePayload now branches on 'covered': PcmPrepared only when the packet ring
     slot was actually populated; SilenceFallback for coverage misses.
   - Increments control->counters.txSilenceFallback on the prepare path (previously
     the counter was declared but never wired on this path).
   - PreparePayloads skip condition includes SilenceFallback so covered-miss slots
     are not re-prepared on subsequent passes.

2. Gate consumed-frame publication in OnTransmitSlotCompleted
   - lastCompletedAudioFrame_ / txCompletedSampleFrame / PublishDirectTxConsumedEndFrame
     now only advance for PcmPrepared slots with audioFrame != 0.
   - Pre-anchor DATA (audioFrame==0) goes to txCompletedStartupSilenceSlots.
   - SilenceFallback goes to txSilenceFallback + txSilenceSubstitutions (not to the
     PCM completion path), preventing bogus consumed-frame values during warm-up.
   - txNoPhaseSilencePackets / txValidPhaseSilencePackets accounting moved exclusively
     to the SilenceFallback branch where it belongs.

3. Rename TryGetRecoveredDevicePhaseTicks -> TryBuildTransmitCycleDeviceSubphase
   - Old name implied true device-phase recovery. The function actually grafts the
     recovered device sub-cycle onto the local TX bus cycle base; long-term whole-cycle
     drift is handled by SYTGenerator::nudgeOffsetTicks(), not here.
   - Comment block on the definition documents what is and isn't computed.
   - Updated stale test comment to match.

4. Decouple packet ring population from DoPrepareOnce
   - PopulateClipStyleTxRingFromWrittenRange moved from DoPrepareOnce (per slot-prepare
     iteration) to top of DrainPreparationRequests (once per drain pass).
   - DoPrepareOnce is now a pure PreparePayloads caller.
   - Honest transitional comment: still on isoch queue, not true audio-side-safe yet.
Add RebaseReason (InitialSeed/Glitch/LeadReject) to
TxOutputPhaseLoop::CycleResult so a recurring re-seed can be told
apart from the expected one-time start-of-stream seed. Diagnostics
gains matching per-reason counters (rebases == sum of the three).

Wire the previously-dead AudioRtCounters::txPhaseRebases counter and
add a rate-limited "TX PHASE RESET reason=..." log at the rebase site,
so a hardware run can show definitively whether LeadReject (phase loop
not converging) is firing repeatedly vs. a one-off InitialSeed/Glitch.
Also rate-limit the "txPhaseMap anchored" and "Armed transmit-cycle
anchor" logs, which can otherwise repeat every cycle when unstable.

Pure additive instrumentation -- no timing/behavior change. Extends
SaffirePhaseLoopTests with reason/counter assertions and a new
LeadRejectRecovery test driving that path without a device glitch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…adence-authoritative

Replaces the single combined TxPhaseEvent with independent ContinuityEvent and
EmissionEvent classifications, fixing two issues with the prior model:

- InitialSeed/OneCycleSlipCompensated no longer get coerced into NO-DATA outcomes
  (Saffire ships DATA in the same callback it seeds/absorbs a slip in).
- Emission is now a pure lead-HEALTH read ("was the lead in range when we shipped
  or withheld this cycle?"), decoupled from the wire cadence decision. The wire
  DATA/NO-DATA cadence stays where it belongs -- PacketAssembler's spec-mandated
  CIP/DBC sequencing -- and the loop tracks it via dataCandidate (emitData mirrors
  it exactly, phase advances in lockstep with it) rather than re-deriving it.

Only kInitialSeed/kTimingDiscontinuity may reset the phase map / re-arm the SYT
anchor now; recurring lead-health reads never do, eliminating the "TX PHASE RESET
reason=Glitch" spam that fired on ordinary cadence.

Adds a Python reference model (tools/debug/tx_phase_loop_model.py) with JSON
fixtures pinning the per-cycle event sequence, and rewrites the C++ test suites
(SaffirePhaseLoopTests, OutputPhaseToAudioMapTests, IsochAudioTxPipeline call site)
to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…atch

The continuity predictor's cycleDelta was a raw uint32_t subtraction of two
wrapping 13-bit OHCI bus-cycle fields. At the 7999->0 boundary that produced
~2^32 - 7999 (~4.29B ticks), which the detector then classified as a giant
device-phase jump and re-fired kTimingDiscontinuity exactly once per second --
a recurring "TX PHASE RESET reason=Glitch" anchor reset, in production, on
ordinary cadence.

Replaced the subtraction with the mod-kCyclesPerSecond form, added the
kCyclesPerSecond constant + wrap-aware docstring to the class, and added a
TxOutputPhaseLoop.TransmitCycleWrapIsNotDiscontinuity regression test that
drives the loop through the 7999->0 boundary with exactly +1 cycle of device
phase per call and asserts the wrap reads as kNominal (verified the test
catches the bug by temporarily restoring the old form).

Also adds a defensive log in IsochAudioTxPipeline::NextTransmitPacket for
dataCandidate / pkt.isData drift: the phase loop tracks PacketAssembler's
cadence decision via dataCandidate, so a silent disagreement means the loop's
phase tracking has diverged from the wire. Treated as a hard inconsistency
signal, not a recoverable warning.

No change to the Python reference model -- it already takes cycleDelta
directly, so the bug was purely in the C++ derivation. JSON fixtures remain
valid contract pins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… decompile

Tighten the safety limit kSafetyLimitTicks from 12287 to 7620 in ExternalSyncDiscipline48k.hpp to align with Saffire's hardware rejection threshold. Saffire decompilation reveals that the hardware drops packets (emits SYT=0xFFFF) when the phase lead is >= 7620 ticks. The 12287 threshold is purely an advisory, log-escalation warning. Clarified comments in TxOutputPhaseLoop.hpp and SYTGenerator.hpp to document these boundaries accurately.
Introduce the closed, hardware-free AudioDriverKit lab simulation environment to test and validate the driver's AMDTP/DICE transmit protocol stack without real FireWire hardware.
This includes:
- Protocols/ (portable AMDTP/DICE/IEC61883 implementations)
- Core/ (audio IO loop and device controller orchestration)
- Ports/ (IAmdtpTxSlotProvider interface)
- Lab/ (FakeIsochTxSlotProvider mock)
- Driver/ (AudioDriverKit dext glue)
- Tests/ (test harness and unit tests passing 8,341 checks)
- project.yml (relocated and optimized project configuration)
- Updated .gitignore to exclude generated .xcodeproj folders.
Ensure the ignore rule targets only the generated ADKVirtualAudioLab/ADKVirtualAudioLab.xcodeproj/ so that we don't accidentally ignore new file additions inside the main ASFW.xcodeproj, which is tracked.
Implements the Milestone 1 payoff step per the lab README:

- Ports/IDiagSink: the RT-safe sticky-counter seam (increment-only, no
  logging, no allocation); Lab/StickyCounterSink as its fixed-array
  relaxed-atomic implementation for tests and the future dext dump.
- Lab/VerifyingSlotProvider: decorator over any IAmdtpTxSlotProvider
  asserting P1-P4 on every PublishSlot — cadence window (6000/8000) and
  N,D,D,D run shape, DBC continuity (no-data carries unchanged), CIP
  bit-exactness re-parsed from the published wire image (Q0/Q1 fields,
  8-byte no-data, byteCount vs frames*dbs), and gapless frame tiling.
  Observer not gate: packets always forward; violations resync so one
  fault counts once. Structural constants are asserted, stream constants
  (SID, frames-per-data) are learned from the wire — the verifier shares
  no code with the packetizer it judges.
- Step 6 seams (additive only, no behavior change): controller
  BindLabSlotProvider() to interpose Verifying(Fake) between engine and
  ring, and PayloadCounters()/PayloadWriterCounters() accessors to read
  the writer's miss buckets from scenarios.
- Tests/VerifyingSlotProviderTests: instrument self-tests — a hand-rolled
  golden driver (independent of AmdtpTxPacketizer) plus one corruption
  case per violation kind, each proving the targeted counter fires
  exactly once with no cross-talk.
- Tests/VerifierScenarioTests: the scenario pump (controller -> engine ->
  Verifying(Fake)) — regular 512-frame soak past 1e6 cycles with zero
  violations, irregular frame counts, skipped callback (silence in
  structurally valid packets, never corruption), sample-time jumps
  landing in framesWithoutPacket/framesOutsidePacket, and stream restart.

Suite: 17440 checks, 0 failures (was 8341). Dext target still compiles.
Milestone 1 exit criteria are now executable checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ovider

lab: complete Step 6 — VerifyingSlotProvider, IDiagSink, scenario pump
…real pacing

Implements the Milestone 3 dext clock model per the lab README:

- IOTimerDispatchSource on the work queue fires once per ZTS period
  (512 frames @ 48k) in kIOTimerClockMachAbsoluteTime; each fire anchors
  UpdateCurrentZeroTimestamp(n*period, fire_time) with raw values — the
  deadline chain is nominal (derived from the period index via
  mach_timebase_info, drift-free), host times are actual fire times, the
  host smooths via the clock algorithm. No driver-side extrapolation.
- The same fire exposes the next period's packets through the controller
  (the lab analog of an IT-ring refill interrupt): hardware requests data
  on its interrupt; WriteEnd only fills PCM into already-exposed packets.
- Step 6 Verifying(Fake) + StickyCounterSink run for the whole IO session;
  StopIO dumps verifier counters, payload miss buckets, and the O/C
  instrumentation via IOLog (ADKLab[dump] prefix, never from the IO path).
- O/C instrumentation: anchors before first WriteEnd (C1), WriteEnd shape
  stats — min/max io size, sample-time continuity, first-callback tuple
  (C3), io/timer fires after StopIO counted not crashed (O2, logged at
  free), other-op counter, prepare-failure counter.
- SetTransportType(FireWire) — the contract ASFW will live under.
- Output ring widened to 8 ZTS periods (4096 frames, matches the host
  scenario pump); a 1-period ring left the HAL zero headroom (C4 stays
  observable by varying kRingPeriods).
- Info.plist: match on IOUserResources + IOResourceMatch IOKit (a
  hardware-free dext cannot match IOUserService as provider — it would
  never start); non-empty OSBundleUsageDescription.

All APIs verified against the DriverKit 25.2 SDK headers (IOLib.h
mach_timebase_info/mach_absolute_time, IOTimerDispatchSource.iig,
IOUserAudioClockDevice.iig). Dext + host tool targets build; host suite
17440 checks, 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ADKLabHost: minimal SwiftUI host that embeds the dext (xcodegen embeds
  driver-extension dependencies into Contents/Library/SystemExtensions)
  and submits OSSystemExtensionRequest activate/deactivate. The dext
  identifier is discovered from the embedded bundle at launch so signing
  lanes that override bundle ids need no code edits.
- Entitlements files for both targets (system-extension install; driverkit
  + family.audio) — referenced by the signing lanes, signing itself stays
  OFF by default exactly as before.
- BENCH.md: the M3 procedure — both signing lanes (ad-hoc SIP-off bench /
  provisioned SIP-on), activation, driving audio, reading the ADKLab[dump]
  output, and the counter -> O1-O3/C1-C4 mapping with the M3 exit criteria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lab: Milestone 3 dext bring-up — ZTS timer chain, activation host, bench runbook
… verification

Implements the M2 scope per the lab README, with every constant taken from
the DICE tree's Saffire-decompile ground truth (SYTGenerator.hpp,
TxOutputPhaseLoop.hpp, tools/debug/tx_phase_loop_model.py):

- Ports/ICycleTimeline: time as an explicit port (unwrapped 24.576 MHz
  ticks). Lab/SimulatedCycleTimeline synthesizes it from audio frame
  position (6 frames per cycle at 48 kHz; epoch + jitter knobs are test
  inputs, per the README's adversarial-schedule design).
- Core/TxTimingModel: the frame->cycle->SYT unit under research. Seeds at
  now + presentation delay (4096), grafts the device's constant sub-cycle
  (phase - phase%3072 + 0x0B0, the reference-model formula), advances by
  the fixed 4096-tick blocking step in the 16-cycle (49152) SYT domain,
  and reports transmit-lead health: tight <=3071, accept <7620 (the real
  rejection threshold), gate >=7620, escalate >=12287 (advisory only).
  WHO DECIDES THE WIRE: not the model — cadence stays authoritative;
  callers Peek the SYT, the packetizer decides data/no-data, and only
  emitted data packets Commit (the model tracks, it never decides),
  mirroring TxOutputPhaseLoop's contract.
- Verifier P5 (config-gated): data SYTs must step by exactly 4096 in the
  16-cycle domain and sit on the graft lattice (offset = 0x0B0 mod 1024);
  data packets carrying 0xFFFF in a timing-valid run are violations.
  Self-tests prove each P5 counter fires exactly once per injected fault.
- Controller: EnableLabTiming/PrepareLabPacketTimed/TimingCounters — the
  timed prepare path peeks, prepares, commits-on-data, and records lead
  health telemetry (seeds counted at peek: the seeding peek can land on a
  no-data cycle).
- Lab/WriteEndTraceReplayer: allocation-free trace-text parser
  (sample_time host_time frames; comments; malformed lines counted, never
  absorbed) + Replay — the regression harness Milestone 3's captured
  trace will drop into. Scenario H replays a trace through the full
  timed pipeline.
- Dext wiring: timing + P5 enabled in VirtualAudioDevice; the simulated
  bus rides the exposure cursor (the packet's projected transmit
  position, per Saffire FillFirewireBuffers); StopIO dump grows p5 and
  timing lines.

Unit tests pin the contract exactly: seed SYT 0x10B0 at lead 3248, the
+1,+1,+2 cycle pattern with offsets {0x0B0,0x4B0,0x8B0}, the 12-packet
16-cycle wrap, all five lead-health boundaries, graft-disabled mode, the
monotonic guard, and re-arm/reseed. The timed pump soak holds P1-P5 at
zero violations with the lead pinned at the seed value across ~65k data
packets.

Suite: 18010 checks, 0 failures (was 17440). All three targets build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lab: Milestone 2 — SYT realism: TxTimingModel, simulated timeline, P5 verification (stacked on mrmidi#24)
…, and increase ZTS exposure lead to 3 periods
- Implement seqlock/generation-check snapshotting for PacketTimelineSlot on the RT IO thread (SnapshotSlotForAudioFrame) to prevent reading stale range limits or partially overwritten slot fields.
- Use PacketSlotSnapshot copies in AmdtpPayloadWriter to guarantee that the destination pointer calculations do not underflow when a slot is concurrently reused by the ZTS timer thread on the work queue.
- Move IOMemoryMap mapping creation to VirtualAudioDevice::init and cache the ringBase pointer, avoiding calls to GetAddress/GetOffset in the real-time IO path.
- Keep the outputMemoryMap alive in StopIO until free() to safely handle late RT callbacks.
- Add unit tests validating the seqlock snapshot behavior and mid-rewrite guard.
- Add LabDiagUserClient, a diagnostic user client supporting the LDBG connection type to retrieve PacketDumpBlobs via structureOutput.
- Implement BuildPacketDumpBlob, capturing consistent packet metadata, timeline slot states, and raw packet bytes on the dext work queue.
- Add Swift class PacketDumpClient for querying and parsing the little-endian PacketDumpBlob wire format.
- Add PacketInspectorView, a SwiftUI visual inspector for packet structures, CIP headers, and raw hex and floating point PCM payloads.
- Integrate PacketDumpBlobTests into the test suite and verify window clamping, evicted slots, and header fields.
- Set kPacketDumpMaxRecords to 6 and default to 4 in PacketDumpBlob.hpp to keep the output structure size under the 4096-byte IOKit inline limit.
- Update PacketDumpClient maxRecords constant and dump() default to match.
- Adjust PacketInspectorView picker to options [2, 4, 6] and default to 4.
- Update PacketDumpBlobTests to use 6 records and adjust index/field assertions to match the new window boundaries.
- Resolves the 0xe00002bc (kIOReturnBadArgument) error returned by IOConnectCallMethod.
- Root Cause Analysis:
  The CoreAudio HAL wraps stream writes at the zero timestamp period
  (512 frames), writing float PCM only to offsets 0..511 of the shared
  memory. The driver was configured with a ring buffer size of 4096
  frames (8 * 512), causing it to read silence (all zeroes) in offsets
  512..4095. This resulted in sporadic audio payload data with a
  mathematically precise 12.5% non-zero sample ratio.

- Resolution:
  Aligned the driver's ring buffer frame count (ringFrames) directly
  with the zero timestamp period (in_zero_timestamp_period = 512),
  synchronizing wrapping boundaries between CoreAudio and the driver.

- Additional Telemetry & Diagnostics:
  * Expanded AmdtpPayloadWriter with atomic counters for non-zero frames,
    non-zero slots, and maximum absolute sample amplitude.
  * Bumped PacketDumpBlob to version 2 and expanded the header struct
    to expose payload committed cursors and signal level telemetry.
  * Updated the Host SwiftUI App (PacketDumpClient, PacketInspectorView)
    to parse and display the new telemetry metrics (Non-Zero ratio, Max Amp)
    and clarify the committed payload boundary in the inspector view.
  * Added a wrap mismatch simulation test in PayloadWriterTests.cpp to
    assert the 12.5% non-zero ratio.
  * Cleaned up the unused kRingPeriods constant in VirtualAudioDevice.cpp
    to ensure warning-free compilation.
… geometry

- PcmSlotCodec: RawSigned24In32BE no longer masks to 24 bits — negative
  samples sign-extend into the top byte (capture shows 0xFFxxxxxx, e.g.
  0xFFFC9F0C; zero padding would flip negatives into large positive
  values for an int32 receiver). LE variant follows via byte swap.
  Production RawPcm24In32Encoder already did this; the lab now agrees.
- FocusriteSaffireProfile: capture-confirmed stream shapes — host→device
  dbs 9 (8 audio + 1 MIDI, 296 B), device→host dbs 17 (16 AM824 audio +
  1 MIDI, 552 B); empty MIDI slots carry 0x80000000 both directions.
- VirtualAudioDeviceController: new GetOutputDeviceCaps() so the HAL
  shape (rate, PCM channels) comes from the selected profile; MIDI slots
  stay wire-only.
- VirtualAudioDevice: publish format/channel layout from profile caps
  instead of hardcoded 8-channel constants; refuse non-48k profiles the
  lab clock math cannot honor.
- Tests: bench-shape profile expectations, byte-exact fixtures pinned to
  an explicit stereo config, Saffire end-to-end asserts dbs 9 + MIDI
  placeholder per frame, codec sign-extension vectors, and the verifier
  gap-silence scenario now defines "silent" as PCM-slots-zero (the MIDI
  placeholder rides along even in silence, as on the wire).
  18784 checks, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aligns kAudioRingBufferFrames and kAudioOutputRingFrames with the 512-frame zero timestamp period (ZTS) to prevent wrapping mismatches and audio payload silence.

Adjusts the output consumer lead to 384 frames and the cursor resync deadband to 64 frames to remain within the 512-frame buffer constraints. Updates unit tests to reflect the updated capacity, lead limits, and resync deadbands.
…mingCursorPolicy

Redo of the misplaced PR mrmidi#26 — this belongs in the real driver, not the
lab (the lab is the reference).

- Audio/Runtime/SaffireIsochLatency.hpp: the decompile trace
  (Saffire::UpdateIsochBufferParams, 0xf506) as a constexpr unit — 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), derived delay-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
  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 the
  "constant ~20 ms DMA buffer" is 20/10/5 ms by packet time.
- TimingCursorPolicy: the hand-planted Saffire literals now derive from
  the table at mode kLow — CursorOffsetFrames (48/128) and the
  Snapshot's outputDelayPackets/inputDelayPackets (6/16). Zero behavior
  change, proven by static_asserts pinning the old values. The
  bench-tuned ADK values (ReportedLatencyFrames 29, SafetyOffsetFrames
  8) are deliberately untouched — they sit on top of the cursor offset
  and their mapping is still a bench question, now documented at the
  call sites.
- tests/audio/SaffireIsochLatencyTests: every table cell pinned, rate
  bumps on both columns, depth packet-time math, the quoted frame
  figures, unsupported-rate rejection, and the policy-derives-from-table
  single-sourcing check.

Host suite: 1217/1217 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants