Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions ADKVirtualAudioLab/Core/TxTimingModel.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#include "TxTimingModel.hpp"

#include "../Protocols/Audio/IEC61883/Syt.hpp"

namespace ASFW::Driver {

// Design decisions (see TxTimingModel.hpp and the README, Milestone 2):
//
// 1. Seed-on-peek: the first PeekNextDataSyt after arming derives the phase
// from the timeline (transmit-cycle anchoring), so the anchor lands on the
// moment the first data packet is actually being stamped — mirroring
// SYTGenerator's armTransmitCycleAnchor/computeDataSYT split.
// 2. The graft follows the reference model literally:
// phase - (phase % kTicksPerCycle) + deviceSubCycleTicks. Because the
// graft replaces the sub-cycle offset, it can move the phase backward by
// up to one cycle; a bounded guard then re-adds whole cycles until the
// lead is positive again — the graft fixes the sub-cycle, the cycle count
// is free.
// 3. Commit is separate from Peek and advances exactly one packet step:
// cadence owns the wire, the model tracks emitted data packets only.
// 4. Lead health thresholds are compared the way the Saffire decompile
// behaves: accept is exclusive (< 7620 ships), tight and escalate are
// advisory bands, kLate covers a phase already in the past.

using Protocols::Audio::IEC61883::SytFormatter;

void TxTimingModel::Configure(const Config& config) noexcept {
config_ = config;
Reset();
}

const TxTimingModel::Config& TxTimingModel::GetConfig() const noexcept {
return config_;
}

void TxTimingModel::Reset() noexcept {
phaseTicks_ = 0;
seeded_ = false;
anchorArmed_ = true;
}

void TxTimingModel::ArmTransmitCycleAnchor() noexcept {
anchorArmed_ = true;
}

bool TxTimingModel::IsSeeded() const noexcept {
return seeded_;
}

TxTimingModel::Decision TxTimingModel::PeekNextDataSyt(
const Ports::ICycleTimeline& timeline) noexcept {
Decision decision{};
const int64_t now = timeline.NowTicks();

if (anchorArmed_ || !seeded_) {
int64_t phase = now + config_.presentationDelayTicks;
if (config_.graftEnabled) {
phase = phase - (phase % kTicksPerCycle) +
static_cast<int64_t>(config_.deviceSubCycleTicks);
// The graft may have stepped back past "now"; whole cycles are
// free, the sub-cycle is not. Bounded by construction (the graft
// moves at most one cycle back).
while (phase <= now) {
phase += kTicksPerCycle;
}
}
phaseTicks_ = phase;
seeded_ = true;
anchorArmed_ = false;
decision.seededThisCall = true;
}

decision.syt = SytForPhase(phaseTicks_);
decision.leadTicks = phaseTicks_ - now;
decision.health = HealthForLead(decision.leadTicks);
return decision;
}

void TxTimingModel::CommitDataPacket() noexcept {
if (seeded_) {
phaseTicks_ += kPacketStepTicks;
}
}

void TxTimingModel::NudgeOffsetTicks(int32_t deltaTicks) noexcept {
if (seeded_) {
phaseTicks_ += deltaTicks;
}
}

int64_t TxTimingModel::OutputPhaseTicks() const noexcept {
return phaseTicks_;
}

uint16_t TxTimingModel::SytForPhase(int64_t phaseTicks) const noexcept {
int64_t domainTick = phaseTicks % kSytDomainTicks;
if (domainTick < 0) {
domainTick += kSytDomainTicks;
}
const uint32_t cycle = static_cast<uint32_t>(domainTick / kTicksPerCycle);
const uint32_t offset = static_cast<uint32_t>(domainTick % kTicksPerCycle);
return SytFormatter::EncodeCycleOffset(cycle, offset);
}

TxTimingModel::LeadHealth TxTimingModel::HealthForLead(
int64_t leadTicks) const noexcept {
if (!seeded_) {
return LeadHealth::kNotSeeded;
}
if (leadTicks < 0) {
return LeadHealth::kLate;
}
if (leadTicks <= config_.tightLeadTicks - 1) {
return LeadHealth::kTightWarn;
}
if (leadTicks < config_.acceptLeadTicks) {
return LeadHealth::kAccepted;
}
if (leadTicks < config_.escalateLeadTicks) {
return LeadHealth::kGate;
}
return LeadHealth::kEscalate;
}

} // namespace ASFW::Driver
100 changes: 100 additions & 0 deletions ADKVirtualAudioLab/Core/TxTimingModel.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#pragma once

#include "../Ports/ICycleTimeline.hpp"

#include <cstdint>

namespace ASFW::Driver {

// Frame -> cycle -> SYT mapping: the unit under research (README, target
// layout). Mirrors the contract of ASFW's Encoding::SYTGenerator and the
// authoritative reference model (tools/debug/tx_phase_loop_model.py in the
// DICE tree), constants from the Saffire decompile:
//
// - output phase is an UNWRAPPED int64 in the 24.576 MHz tick domain
// - seed: phase = now + presentation delay, then the constant device
// sub-cycle graft: phase = phase - (phase % 3072) + 0x0B0
// (keep the cycle, replace the sub-cycle offset — Saffire Pro24's
// recovered constant)
// - data packets advance the phase by the fixed blocking step
// 8 frames x 512 ticks = 4096; no-data packets advance nothing
// - SYT = cycle[3:0] ‖ offset[11:0] of the phase in the 16-cycle
// (49152-tick) SYT domain
// - transmit-lead health (P5): lead = phase - now; warn at <= 3071 (under
// one cycle, near-underrun), healthy while < 7620, gate at >= 7620 (the
// device's actual rejection threshold), >= 12287 is log-escalation only
//
// WHO DECIDES THE WIRE: not this model. The cadence's DATA/NO-DATA pattern is
// the device-validated sequencing (see TxOutputPhaseLoop's contract in the
// DICE tree); lead health here is pure telemetry that may freely co-occur
// with any cadence outcome. Callers Peek the SYT a data packet would carry,
// let the packetizer decide the wire, then Commit only what was actually
// emitted — the model tracks, it never decides.
class TxTimingModel final {
public:
struct Config final {
int64_t presentationDelayTicks{4096}; // SYTGenerator's anchor lead
uint16_t deviceSubCycleTicks{0x0B0}; // Saffire Pro24 constant graft
bool graftEnabled{true};
int64_t tightLeadTicks{3072}; // below: near-underrun warning
int64_t acceptLeadTicks{7620}; // at/above: device rejects (gate)
int64_t escalateLeadTicks{12287}; // advisory log-escalation only
};

enum class LeadHealth : uint8_t {
kNotSeeded = 0,
kAccepted, // tight < lead < accept: the safe operating range
kTightWarn, // 0 <= lead <= tight: shipping while thin
kLate, // lead < 0: presentation time already passed
kGate, // accept <= lead < escalate: device would reject
kEscalate, // lead >= escalate: drifted very far (advisory)
};

struct Decision final {
uint16_t syt{0xFFFF};
int64_t leadTicks{0};
LeadHealth health{LeadHealth::kNotSeeded};
bool seededThisCall{false};
};

static constexpr int64_t kTicksPerCycle = 3072;
static constexpr int64_t kTicksPerFrame48k = 512;
static constexpr int64_t kPacketStepTicks = 4096; // 8 frames x 512
static constexpr int64_t kSytDomainTicks = 49152; // 16 cycles

TxTimingModel() noexcept = default;

void Configure(const Config& config) noexcept;
[[nodiscard]] const Config& GetConfig() const noexcept;

// Un-seeds and re-arms the transmit anchor (call on stream start).
void Reset() noexcept;

void ArmTransmitCycleAnchor() noexcept;
[[nodiscard]] bool IsSeeded() const noexcept;

// The SYT the NEXT data packet would carry. Seeds from the timeline on
// the first call after arming; otherwise pure (no state advances until
// CommitDataPacket). Lead/health are measured against timeline now.
[[nodiscard]] Decision PeekNextDataSyt(
const Ports::ICycleTimeline& timeline) noexcept;

// The packetizer actually emitted a data packet: advance one step.
void CommitDataPacket() noexcept;

// Discipline rehearsal parity with SYTGenerator::nudgeOffsetTicks.
void NudgeOffsetTicks(int32_t deltaTicks) noexcept;

[[nodiscard]] int64_t OutputPhaseTicks() const noexcept;

private:
[[nodiscard]] uint16_t SytForPhase(int64_t phaseTicks) const noexcept;
[[nodiscard]] LeadHealth HealthForLead(int64_t leadTicks) const noexcept;

Config config_{};
int64_t phaseTicks_{0};
bool seeded_{false};
bool anchorArmed_{true};
};

} // namespace ASFW::Driver
73 changes: 73 additions & 0 deletions ADKVirtualAudioLab/Core/VirtualAudioDeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ bool VirtualAudioDeviceController::ConfigureOutputStream(
void VirtualAudioDeviceController::ResetTransportLab(
uint8_t initialDbc, uint64_t initialAudioFrame) noexcept {
txEngine_.ResetForStart(initialDbc, initialAudioFrame);
if (labTimingEnabled_) {
timingModel_.Reset();
simulatedTimeline_.Reset(timelineEpochTicks_);
timingCounters_ = TxTimingLabCounters{};
}
}

bool VirtualAudioDeviceController::PrepareLabPacket(uint32_t packetIndex,
Expand All @@ -76,6 +81,74 @@ void VirtualAudioDeviceController::SubmitWriteEnd(
audioIOPath_.HandleWriteEnd(output);
}

void VirtualAudioDeviceController::EnableLabTiming(
const TxTimingModel::Config& config, int64_t timelineEpochTicks) noexcept {
timingModel_.Configure(config);
timelineEpochTicks_ = timelineEpochTicks;
simulatedTimeline_.Reset(timelineEpochTicks);
timingCounters_ = TxTimingLabCounters{};
labTimingEnabled_ = true;
}

void VirtualAudioDeviceController::DisableLabTiming() noexcept {
labTimingEnabled_ = false;
}

bool VirtualAudioDeviceController::LabTimingEnabled() const noexcept {
return labTimingEnabled_;
}

void VirtualAudioDeviceController::AdvanceLabTimelineToFrame(
uint64_t absoluteFrame) noexcept {
simulatedTimeline_.AdvanceToFrame(absoluteFrame);
}

bool VirtualAudioDeviceController::PrepareLabPacketTimed(
uint32_t packetIndex) noexcept {
if (!labTimingEnabled_) {
return PrepareLabPacket(packetIndex, 0xFFFF, false);
}

const auto decision = timingModel_.PeekNextDataSyt(simulatedTimeline_);
if (decision.seededThisCall) {
++timingCounters_.seeds; // the seeding peek may land on a no-data cycle
}
if (!PrepareLabPacket(packetIndex, decision.syt, true)) {
return false;
}

// Cadence decided the wire; the model tracks only what was emitted.
const auto* published = fakeSlotProvider_.PublishedPacket(packetIndex);
if (published != nullptr && published->packetIndex == packetIndex &&
published->isData) {
timingModel_.CommitDataPacket();
++timingCounters_.dataPackets;
timingCounters_.lastLeadTicks = decision.leadTicks;
switch (decision.health) {
case TxTimingModel::LeadHealth::kTightWarn:
++timingCounters_.tightWarn;
break;
case TxTimingModel::LeadHealth::kLate:
++timingCounters_.late;
break;
case TxTimingModel::LeadHealth::kGate:
++timingCounters_.gate;
break;
case TxTimingModel::LeadHealth::kEscalate:
++timingCounters_.escalate;
break;
default:
break;
}
}
return true;
}

const TxTimingLabCounters&
VirtualAudioDeviceController::TimingCounters() const noexcept {
return timingCounters_;
}

void VirtualAudioDeviceController::BindLabSlotProvider(
Protocols::Audio::AMDTP::IAmdtpTxSlotProvider* provider) noexcept {
txEngine_.BindSlotProvider(
Expand Down
34 changes: 34 additions & 0 deletions ADKVirtualAudioLab/Core/VirtualAudioDeviceController.hpp
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
#pragma once

#include "../Lab/FakeIsochTxSlotProvider.hpp"
#include "../Lab/SimulatedCycleTimeline.hpp"
#include "../Protocols/Audio/DICE/DiceProfileRegistry.hpp"
#include "../Protocols/Audio/DICE/DiceTxStreamEngine.hpp"
#include "../Protocols/Audio/DICE/Profiles/FocusriteSaffireProfile.hpp"
#include "AudioIOPath.hpp"
#include "TxTimingModel.hpp"

#include <cstdint>

namespace ASFW::Driver {

// Lead-health bookkeeping for the timed prepare path (work-queue confined,
// like the rest of the controller's lab state).
struct TxTimingLabCounters final {
uint64_t dataPackets{0};
uint64_t seeds{0};
uint64_t tightWarn{0};
uint64_t late{0};
uint64_t gate{0};
uint64_t escalate{0};
int64_t lastLeadTicks{0};
};

class VirtualAudioDeviceController final {
public:
VirtualAudioDeviceController() noexcept = default;
Expand All @@ -29,6 +43,20 @@ class VirtualAudioDeviceController final {
uint16_t syt,
bool timingValid) noexcept;

// Milestone 2 — SYT realism. EnableLabTiming binds a TxTimingModel and a
// SimulatedCycleTimeline (advanced by the caller from the frame cursor);
// PrepareLabPacketTimed then stamps real SYTs: peek the model, let the
// packetizer's cadence decide the wire, commit only if a data packet was
// actually published (the model tracks, it never decides). Lead health is
// recorded as telemetry per data packet.
void EnableLabTiming(const TxTimingModel::Config& config,
int64_t timelineEpochTicks = 0) noexcept;
void DisableLabTiming() noexcept;
[[nodiscard]] bool LabTimingEnabled() const noexcept;
void AdvanceLabTimelineToFrame(uint64_t absoluteFrame) noexcept;
bool PrepareLabPacketTimed(uint32_t packetIndex) noexcept;
[[nodiscard]] const TxTimingLabCounters& TimingCounters() const noexcept;

void SubmitWriteEnd(const Protocols::Audio::AMDTP::HostAudioBufferView& output) noexcept;

// Step 6 seam: interpose a decorator (Verifying(Fake)) between the engine
Expand All @@ -55,6 +83,12 @@ class VirtualAudioDeviceController final {
Lab::FakeIsochTxSlotProvider fakeSlotProvider_{};

AudioIOPath audioIOPath_{};

bool labTimingEnabled_{false};
int64_t timelineEpochTicks_{0};
TxTimingModel timingModel_{};
Lab::SimulatedCycleTimeline simulatedTimeline_{};
TxTimingLabCounters timingCounters_{};
};

} // namespace ASFW::Driver
Loading
Loading