diff --git a/ASFWDriver/Audio/Core/AudioCoordinator.cpp b/ASFWDriver/Audio/Core/AudioCoordinator.cpp index b98bca4bc..1fbb3fc1f 100644 --- a/ASFWDriver/Audio/Core/AudioCoordinator.cpp +++ b/ASFWDriver/Audio/Core/AudioCoordinator.cpp @@ -82,7 +82,8 @@ void AudioCoordinator::OnDeviceResumed(std::shared_ptr devi bool recoverActiveStream = false; if (lock_) { IOLockLock(lock_); - recoverActiveStream = (activeGuid_ == guid); + recoverActiveStream = streamReservation_.Guid() == guid && + !streamReservation_.BlocksNewWork(guid); IOLockUnlock(lock_); } @@ -109,7 +110,8 @@ void AudioCoordinator::OnDeviceSuspended(std::shared_ptr de bool suspendedActiveStream = false; if (lock_) { IOLockLock(lock_); - suspendedActiveStream = (activeGuid_ == guid); + suspendedActiveStream = streamReservation_.Guid() == guid && + !streamReservation_.BlocksNewWork(guid); IOLockUnlock(lock_); } @@ -132,9 +134,9 @@ void AudioCoordinator::OnDeviceRemoved(Discovery::Guid64 guid) { if (lock_) { IOLockLock(lock_); firstRemoval = remoteLostGuids_.insert(guid).second; - wasActive = (activeGuid_ == guid); + wasActive = (streamReservation_.Guid() == guid); if (wasActive) { - activeGuid_ = 0; + streamReservation_.Clear(guid); } IOLockUnlock(lock_); } @@ -194,7 +196,8 @@ void AudioCoordinator::HandleCycleInconsistent() noexcept { uint64_t guid = 0; if (lock_) { IOLockLock(lock_); - guid = activeGuid_; + guid = streamReservation_.Guid(); + if (streamReservation_.BlocksNewWork(guid)) guid = 0; IOLockUnlock(lock_); } @@ -238,61 +241,48 @@ IAudioBackend* AudioCoordinator::BackendForGuid(uint64_t guid) noexcept { IOReturn AudioCoordinator::StartStreaming(uint64_t guid) noexcept { if (guid == 0) return kIOReturnBadArgument; - - bool setActive = false; - if (lock_) { - IOLockLock(lock_); - if (remoteLostGuids_.contains(guid)) { - IOLockUnlock(lock_); - return kIOReturnNoDevice; - } - if (activeGuid_ == 0) { - activeGuid_ = guid; - setActive = true; - } else if (activeGuid_ == guid) { - IOLockUnlock(lock_); - // Idempotent start: avoid reconfiguring already-running IR/IT contexts. - return kIOReturnSuccess; - } else { - const uint64_t active = activeGuid_; - IOLockUnlock(lock_); - - ASFW_LOG_WARNING(Audio, - "AudioCoordinator: StartStreaming busy requested=0x%016llx active=0x%016llx", - guid, - active); - // TODO(ASFW-MULTIDEVICE): Multi-device streaming is not implemented. - // This is the explicit v1 multi-device boundary: multiple GUIDs may - // publish nubs/runtimes, but only one GUID may own isoch transport. - // Simultaneous streaming starts here and requires per-GUID IR/IT - // contexts, timing bridge, IRM/channel allocation, and backend sessions. - return kIOReturnBusy; - } + if (teardownRequested_.load(std::memory_order_acquire)) return kIOReturnAborted; + if (!lock_) return kIOReturnNoResources; + IOLockLock(lock_); + if (remoteLostGuids_.contains(guid)) { IOLockUnlock(lock_); + return kIOReturnNoDevice; + } + const auto decision = streamReservation_.BeginStart(guid); + const uint64_t reservedGuid = streamReservation_.Guid(); + IOLockUnlock(lock_); + if (decision.admission == AudioStreamReservation::Admission::AlreadyRunning) { + return kIOReturnSuccess; + } + if (decision.admission != AudioStreamReservation::Admission::Begin) { + const bool cleanupFailed = decision.admission == AudioStreamReservation::Admission::CleanupFailed; + ASFW_LOG_WARNING(Audio, + "AudioCoordinator: StartStreaming refused requested=0x%016llx reserved=0x%016llx cleanupFailed=%u", + guid, reservedGuid, cleanupFailed ? 1U : 0U); + return cleanupFailed ? kIOReturnNotReady : kIOReturnBusy; } auto* backend = BackendForGuid(guid); if (!backend) { - if (setActive && lock_) { - IOLockLock(lock_); - if (activeGuid_ == guid) activeGuid_ = 0; - IOLockUnlock(lock_); - } + IOLockLock(lock_); + streamReservation_.CompleteStart(decision.token, false); + IOLockUnlock(lock_); return kIOReturnNotReady; } const IOReturn kr = backend->StartStreaming(guid); + IOLockLock(lock_); + const bool completionAccepted = streamReservation_.CompleteStart(decision.token, kr == kIOReturnSuccess); + IOLockUnlock(lock_); + // Removal, teardown, or a superseding stop invalidated this operation. + // Never report a late backend success as a newly running stream. + if (!completionAccepted) return kIOReturnAborted; if (kr != kIOReturnSuccess) { ASFW_LOG_ERROR(Audio, "AudioCoordinator: StartStreaming failed backend=%{public}s GUID=0x%016llx kr=0x%x", backend->Name(), guid, kr); - if (setActive && lock_) { - IOLockLock(lock_); - if (activeGuid_ == guid) activeGuid_ = 0; - IOLockUnlock(lock_); - } return kr; } @@ -305,29 +295,37 @@ IOReturn AudioCoordinator::StartStreaming(uint64_t guid) noexcept { IOReturn AudioCoordinator::StopStreaming(uint64_t guid) noexcept { if (guid == 0) return kIOReturnBadArgument; - - if (lock_) { - IOLockLock(lock_); - if (remoteLostGuids_.contains(guid)) { - IOLockUnlock(lock_); - return kIOReturnSuccess; - } - if (activeGuid_ != 0 && activeGuid_ != guid) { - const uint64_t active = activeGuid_; - IOLockUnlock(lock_); - ASFW_LOG_WARNING(Audio, - "AudioCoordinator: StopStreaming busy requested=0x%016llx active=0x%016llx", - guid, - active); - return kIOReturnBusy; - } + if (teardownRequested_.load(std::memory_order_acquire)) return kIOReturnAborted; + if (!lock_) return kIOReturnNoResources; + IOLockLock(lock_); + if (remoteLostGuids_.contains(guid)) { IOLockUnlock(lock_); + return kIOReturnSuccess; + } + const auto decision = streamReservation_.BeginStop(guid); + const uint64_t reservedGuid = streamReservation_.Guid(); + IOLockUnlock(lock_); + if (decision.admission != AudioStreamReservation::Admission::Begin) { + const bool cleanupFailed = decision.admission == AudioStreamReservation::Admission::CleanupFailed; + ASFW_LOG_WARNING(Audio, + "AudioCoordinator: StopStreaming refused requested=0x%016llx reserved=0x%016llx cleanupFailed=%u", + guid, reservedGuid, cleanupFailed ? 1U : 0U); + return cleanupFailed ? kIOReturnNotReady : kIOReturnBusy; } auto* backend = BackendForGuid(guid); - if (!backend) return kIOReturnNotReady; + if (!backend) { + IOLockLock(lock_); + streamReservation_.CompleteStop(decision.token, false); + IOLockUnlock(lock_); + return kIOReturnNotReady; + } const IOReturn kr = backend->StopStreaming(guid); + IOLockLock(lock_); + const bool completionAccepted = streamReservation_.CompleteStop(decision.token, kr == kIOReturnSuccess); + IOLockUnlock(lock_); + if (!completionAccepted) return kIOReturnAborted; if (kr != kIOReturnSuccess) { ASFW_LOG_ERROR(Audio, "AudioCoordinator: StopStreaming failed backend=%{public}s GUID=0x%016llx kr=0x%x", @@ -337,12 +335,6 @@ IOReturn AudioCoordinator::StopStreaming(uint64_t guid) noexcept { return kr; } - if (lock_) { - IOLockLock(lock_); - if (activeGuid_ == guid) activeGuid_ = 0; - IOLockUnlock(lock_); - } - ASFW_LOG(Audio, "AudioCoordinator: StopStreaming ok backend=%{public}s GUID=0x%016llx", backend->Name(), @@ -357,20 +349,21 @@ IOReturn AudioCoordinator::RequestClockConfig( if (guid == 0) { return kIOReturnBadArgument; } + if (teardownRequested_.load(std::memory_order_acquire)) return kIOReturnAborted; + if (!lock_) return kIOReturnNoResources; - if (lock_) { - IOLockLock(lock_); - if (activeGuid_ != 0 && activeGuid_ != guid) { - const uint64_t active = activeGuid_; - IOLockUnlock(lock_); - ASFW_LOG_WARNING(Audio, - "AudioCoordinator: RequestClockConfig busy requested=0x%016llx active=0x%016llx", - guid, - active); - return kIOReturnBusy; - } + IOLockLock(lock_); + if ((streamReservation_.Guid() != 0 && streamReservation_.Guid() != guid) || + streamReservation_.BlocksNewWork(guid)) { + const uint64_t active = streamReservation_.Guid(); IOLockUnlock(lock_); + ASFW_LOG_WARNING(Audio, + "AudioCoordinator: RequestClockConfig busy requested=0x%016llx active=0x%016llx", + guid, + active); + return kIOReturnBusy; } + IOLockUnlock(lock_); const auto record = registry_.SnapshotByGuid(guid); if (!record.has_value()) { @@ -427,7 +420,7 @@ void AudioCoordinator::BeginTeardown() noexcept { if (lock_) { IOLockLock(lock_); - activeGuid_ = 0; + streamReservation_.ClearAll(); IOLockUnlock(lock_); } } @@ -448,8 +441,9 @@ void AudioCoordinator::HandleHostTimingLoss(uint64_t guid) noexcept { if (lock_) { IOLockLock(lock_); const bool remoteLost = remoteLostGuids_.contains(guid); + const bool cleanupBlocked = streamReservation_.BlocksNewWork(guid); IOLockUnlock(lock_); - if (remoteLost) { + if (remoteLost || cleanupBlocked) { return; } } diff --git a/ASFWDriver/Audio/Core/AudioCoordinator.hpp b/ASFWDriver/Audio/Core/AudioCoordinator.hpp index bc8744620..f63e6b534 100644 --- a/ASFWDriver/Audio/Core/AudioCoordinator.hpp +++ b/ASFWDriver/Audio/Core/AudioCoordinator.hpp @@ -9,6 +9,7 @@ #include "IAVCAudioConfigListener.hpp" #include "AudioNubPublisher.hpp" +#include "AudioStreamReservation.hpp" #include "../Protocols/Backends/AVCAudioBackend.hpp" #include "../Protocols/Backends/DiceAudioBackend.hpp" #include "../Protocols/Backends/IsochDuplexHostTransport.hpp" @@ -89,7 +90,7 @@ class AudioCoordinator final : public Discovery::IDeviceObserver, AVCAudioBackend avc_; IOLock* lock_{nullptr}; - uint64_t activeGuid_{0}; + AudioStreamReservation streamReservation_; // A CoreAudio StopIO can arrive after discovery has retired the GUID. Keep // that callback from re-entering a backend that now has no remote device. std::unordered_set remoteLostGuids_{}; diff --git a/ASFWDriver/Audio/Core/AudioStreamReservation.hpp b/ASFWDriver/Audio/Core/AudioStreamReservation.hpp new file mode 100644 index 000000000..960cc2e64 --- /dev/null +++ b/ASFWDriver/Audio/Core/AudioStreamReservation.hpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace ASFW::Audio { + +// Controller-global host-stream reservation. The caller serializes every +// access with its control-plane lock; backend work runs outside that lock. +// Failed cleanup keeps ownership but must never be mistaken for running audio. +class AudioStreamReservation final { +public: + enum class State { Idle, Starting, Running, Stopping, CleanupFailed }; + enum class Admission { Begin, AlreadyRunning, Busy, CleanupFailed }; + + struct Token { + uint64_t guid{0}; + uint64_t epoch{0}; + }; + struct Decision { + Admission admission{Admission::Busy}; + Token token{}; + }; + + [[nodiscard]] Decision BeginStart(uint64_t guid) noexcept { + if (guid == 0) return {}; + if (guid_ == 0) return Begin(guid, State::Starting); + if (guid_ != guid) return {}; + if (state_ == State::Running) return {Admission::AlreadyRunning, {}}; + if (state_ == State::CleanupFailed) return {Admission::CleanupFailed, {}}; + return {}; + } + + [[nodiscard]] Decision BeginStop(uint64_t guid) noexcept { + if (guid == 0 || (guid_ != 0 && guid_ != guid)) return {}; + if (state_ == State::CleanupFailed) return {Admission::CleanupFailed, {}}; + if (state_ == State::Stopping) return {}; + // A stop may supersede an in-flight start. Its new epoch prevents a + // late start completion from publishing Running over the stop state. + return Begin(guid, State::Stopping); + } + + bool CompleteStart(Token token, bool success) noexcept { + if (!Matches(token, State::Starting)) return false; + if (success) state_ = State::Running; + else Clear(token.guid); + return true; + } + + bool CompleteStop(Token token, bool success) noexcept { + if (!Matches(token, State::Stopping)) return false; + if (success) Clear(token.guid); + else state_ = State::CleanupFailed; + return true; + } + + // Only confirmed removal or service teardown clears failed cleanup. A bus + // reset/resume alone does not prove outstanding device operations finished. + void Clear(uint64_t guid) noexcept { + if (guid_ != guid) return; + guid_ = 0; + state_ = State::Idle; + ++epoch_; + } + void ClearAll() noexcept { Clear(guid_); } + + [[nodiscard]] uint64_t Guid() const noexcept { return guid_; } + [[nodiscard]] State GetState() const noexcept { return state_; } + [[nodiscard]] bool BlocksNewWork(uint64_t guid) const noexcept { + return guid_ == guid && + (state_ == State::Stopping || state_ == State::CleanupFailed); + } + +private: + [[nodiscard]] Decision Begin(uint64_t guid, State state) noexcept { + guid_ = guid; + state_ = state; + return {Admission::Begin, {guid_, ++epoch_}}; + } + [[nodiscard]] bool Matches(Token token, State state) const noexcept { + return token.guid != 0 && token.guid == guid_ && token.epoch == epoch_ && state_ == state; + } + + uint64_t guid_{0}; + uint64_t epoch_{0}; + State state_{State::Idle}; +}; + +} // namespace ASFW::Audio diff --git a/ASFWDriver/Audio/DriverKit/Config/DICE/DiceProfileRegistry.cpp b/ASFWDriver/Audio/DriverKit/Config/DICE/DiceProfileRegistry.cpp index 8a448f64e..6e00cb014 100644 --- a/ASFWDriver/Audio/DriverKit/Config/DICE/DiceProfileRegistry.cpp +++ b/ASFWDriver/Audio/DriverKit/Config/DICE/DiceProfileRegistry.cpp @@ -9,6 +9,7 @@ #include "Isoch/Profiles/FocusriteSaffireProfile.hpp" #include "Isoch/Profiles/GenericDiceProfile.hpp" #include "Isoch/Profiles/MidasVeniceProfile.hpp" +#include "Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp" #include "Isoch/Profiles/PreSonusStudioLiveProfile.hpp" #include "Isoch/Profiles/WeissIntProfile.hpp" @@ -18,6 +19,7 @@ namespace { Profiles::GenericDiceProfile gGenericProfile{}; Profiles::FocusriteSaffireProfile gFocusriteProfile{}; Profiles::MidasVeniceProfile gMidasVeniceProfile{}; +Profiles::PreSonusFireStudioProjectProfile gFireStudioProjectProfile{}; Profiles::PreSonusStudioLiveProfile gPreSonusStudioLiveProfile{}; Profiles::AlesisMultiMixProfile gAlesisMultiMixProfile{}; Profiles::WeissIntProfile gWeissIntProfile{}; @@ -27,6 +29,7 @@ DiceProfileRegistry::DiceProfileRegistry() noexcept { (void)RegisterProfile(&gFocusriteProfile); (void)RegisterProfile(&gMidasVeniceProfile); (void)RegisterProfile(&gPreSonusStudioLiveProfile); + (void)RegisterProfile(&gFireStudioProjectProfile); (void)RegisterProfile(&gAlesisMultiMixProfile); (void)RegisterProfile(&gWeissIntProfile); } diff --git a/ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.cpp b/ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.cpp new file mode 100644 index 000000000..2171e5ca9 --- /dev/null +++ b/ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "PreSonusFireStudioProjectProfile.hpp" + +#include "../../../../../../DeviceProfiles/Audio/AudioDeviceIds.hpp" + +namespace ASFW::Isoch::Audio::DICE::Profiles { +namespace { + +void FillStreamConfig(DiceStreamConfig& out, DiceStreamDirection direction) noexcept { + using Profile = PreSonusFireStudioProjectProfile; + out = DiceStreamConfig{}; + out.direction = direction; + out.sampleRate = Profile::kSampleRateHz; + out.pcmChannels = Profile::kPcmChannels; + out.midiSlots = Profile::kMidiSlots; + out.dbs = Profile::kDbs; + out.streamMode = Encoding::StreamMode::kBlocking; + out.framesPerDataPacket = 8; + out.fdf = 0x02; + out.fmt = 0x10; +} + +} // namespace + +const char* PreSonusFireStudioProjectProfile::Name() const noexcept { + return "PreSonus FireStudio Project (DICE)"; +} + +bool PreSonusFireStudioProjectProfile::Matches(const DiceDeviceIdentity& identity) const noexcept { + using namespace ASFW::DeviceProfiles::Audio; + return identity.vendorId == kPreSonusVendorId && identity.modelId == kFireStudioProjectModelId; +} + +DiceDeviceQuirks PreSonusFireStudioProjectProfile::Quirks() const noexcept { + // The maintainer's inspection of the original PreSonus KEXT reports + // raw sign-extended 24-in-32 playback PCM and zeroed unwritten samples: + // https://github.com/mrmidi/ASFireWire/pull/105#issuecomment-5581934008 + // Build 9 passed bounded raw-PCM playback tests with zeroed silence; + // see captures/presonus-firestudio-project/README.md for evidence and limits. + // These listening tests do not establish bit-perfect vendor-format parity. + // Keep capture decoding, MIDI defaults and NO-DATA framing unchanged. + DiceDeviceQuirks quirks{}; + quirks.tx.hostToDevicePcmEncoding = Encoding::AudioWireFormat::kRawPcm24In32; + return quirks; +} + +std::vector PreSonusFireStudioProjectProfile::SupportedSampleRates() const { + // Both rates use the captured low-rate 10 PCM + 1 MIDI geometry. Linux + // dice-stream.c:19-30 groups 44.1/48 kHz in mode 0; the existing generic + // blocking AM824 path derives cadence and FDF from the selected rate. + return {kSupportedSampleRatesHz.begin(), kSupportedSampleRatesHz.end()}; +} + +bool PreSonusFireStudioProjectProfile::BuildDefaultTxStreamConfig(DiceStreamConfig& out) const noexcept { + FillStreamConfig(out, DiceStreamDirection::HostToDevice); + return true; +} + +bool PreSonusFireStudioProjectProfile::BuildDefaultRxStreamConfig(DiceStreamConfig& out) const noexcept { + FillStreamConfig(out, DiceStreamDirection::DeviceToHost); + return true; +} + +} // namespace ASFW::Isoch::Audio::DICE::Profiles diff --git a/ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp b/ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp new file mode 100644 index 000000000..ba2ccff04 --- /dev/null +++ b/ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "../../DiceDeviceProfile.hpp" + +#include + +namespace ASFW::Isoch::Audio::DICE::Profiles { + +// Experimental 44.1/48 kHz profile using geometry read from a real Project. +// Build 9 raw-PCM trials verified bounded playback and rate switching on one +// unit; earlier labelled-AM824 trials also covered capture. Adapter-reconnect +// timing remains unfixed; calibrated latency and long-run stability are unvalidated. +// See captures/presonus-firestudio-project/. +class PreSonusFireStudioProjectProfile final : public IDiceDeviceProfile { +public: + static constexpr uint32_t kSampleRateHz = 48000; + static constexpr std::array kSupportedSampleRatesHz{44100, 48000}; + static constexpr uint16_t kPcmChannels = 10; + static constexpr uint16_t kMidiPorts = 1; + static constexpr uint8_t kMidiSlots = 1; + static constexpr uint8_t kDbs = 11; + static constexpr uint32_t kStreamCount = 1; + + [[nodiscard]] const char* Name() const noexcept override; + [[nodiscard]] bool Matches(const DiceDeviceIdentity& identity) const noexcept override; + [[nodiscard]] DiceDeviceQuirks Quirks() const noexcept override; + [[nodiscard]] std::vector SupportedSampleRates() const override; + [[nodiscard]] bool BuildDefaultTxStreamConfig(DiceStreamConfig& out) const noexcept override; + [[nodiscard]] bool BuildDefaultRxStreamConfig(DiceStreamConfig& out) const noexcept override; +}; + +} // namespace ASFW::Isoch::Audio::DICE::Profiles diff --git a/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp b/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp index 4cac49bf6..0c3215785 100644 --- a/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp +++ b/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp @@ -1627,6 +1627,7 @@ IOReturn DuplexStartTransaction::ApplyIdleClock(const IdleClockApplyRequest& req return kIOReturnNotReady; } + const AudioClockConfig previousDesiredClock = session.desiredClock; const uint64_t restartId = AllocateRestartId(); session.guid = guid; session.restartId = restartId; @@ -1648,6 +1649,11 @@ IOReturn DuplexStartTransaction::ApplyIdleClock(const IdleClockApplyRequest& req if (apply.status == kIOReturnAborted && TeardownRequested()) { RecordTeardownAbort("IdleClockApply", guid); } + // The HAL keeps its previous rate when this request fails. Retain that + // selection for the next StartIO; otherwise a transient failure can + // make a later start apply the rejected rate beneath the old host format. + // Keep the attempted rate in the clock-request completion diagnostics. + session.desiredClock = previousDesiredClock; session.terminalError = apply.status; RecordIssue(session, session.lastFailure, DuplexRestartPhase::kPreparingDevice, DuplexRestartErrorClass::kStageFailure, DuplexRestartFailureCause::kIdleClockApply, @@ -1659,6 +1665,7 @@ IOReturn DuplexStartTransaction::ApplyIdleClock(const IdleClockApplyRequest& req return apply.status; } if (!IsRestartEpochCurrent(guid, restartId, *route)) { + session.desiredClock = previousDesiredClock; session.terminalError = kIOReturnSuccess; RecordIssue(session, session.lastInvalidation, DuplexRestartPhase::kPreparingDevice, IsStopRequested(guid) ? DuplexRestartErrorClass::kStopIntent diff --git a/ASFWDriver/Audio/Protocols/Backends/DiceAudioBackend.cpp b/ASFWDriver/Audio/Protocols/Backends/DiceAudioBackend.cpp index 2d05a746c..086e5535c 100644 --- a/ASFWDriver/Audio/Protocols/Backends/DiceAudioBackend.cpp +++ b/ASFWDriver/Audio/Protocols/Backends/DiceAudioBackend.cpp @@ -607,22 +607,30 @@ void DiceAudioBackend::EnsureNubForGuid(uint64_t guid) noexcept { // input == device TX, host output == device RX (see AudioTypes.hpp), which // is exactly how GetChannelLabels reports them. auto finish = [this, guid](Model::ASFWAudioDevice dev, - const std::shared_ptr& protocol) { + const std::shared_ptr& protocol, + bool geometryReadSucceeded) { if (stopping_.load(std::memory_order_acquire)) { return; } + AudioStreamRuntimeCaps caps{}; + const bool hasRuntimeCaps = protocol && protocol->GetRuntimeAudioStreamCaps(caps); + const auto publication = PrepareDiceDeviceConfigForPublication( + hasRuntimeCaps ? &caps : nullptr, geometryReadSucceeded, dev); + if (publication == DicePublicationConfigResult::kDefer) { + ASFW_LOG(Audio, + "DiceAudioBackend::EnsureNubForGuid: deferring FireStudio Project publication without usable runtime geometry GUID=0x%016llx", + guid); + return; + } + if (publication == DicePublicationConfigResult::kRuntimeGeometry) { + ASFW_LOG(Audio, + "DiceAudioBackend::EnsureNubForGuid: applied runtime geometry rate=%u in=%u out=%u (GUID=0x%016llx)", + dev.currentSampleRate, + dev.inputChannelCount, + dev.outputChannelCount, + guid); + } if (protocol) { - AudioStreamRuntimeCaps caps{}; - if (protocol->GetRuntimeAudioStreamCaps(caps) && - ApplyDiceRuntimeCapsToDeviceConfig(caps, dev)) { - ASFW_LOG(Audio, - "DiceAudioBackend::EnsureNubForGuid: applied runtime geometry rate=%u in=%u out=%u (GUID=0x%016llx)", - dev.currentSampleRate, - dev.inputChannelCount, - dev.outputChannelCount, - guid); - } - std::vector inNames; std::vector outNames; if (protocol->GetChannelLabels(inNames, outNames)) { @@ -645,17 +653,17 @@ void DiceAudioBackend::EnsureNubForGuid(uint64_t guid) noexcept { // Channel labels live in the TCAT stream-format name sections, cached only // once runtime caps load (during the first stream discovery). Load them - // once before the first publish so CoreAudio shows the real names from the - // start. The load early-returns if caps are already cached; publish happens - // regardless of outcome (names fall back to synthesized " N"). + // once before the first publish. As in the established DICE path, finish + // even on failure so ordinary profiles retain their publication fallback. + // The FireStudio Project's stricter policy is applied centrally in finish. if (auto* dice = protocol ? protocol->AsDuplexDeviceControl() : nullptr) { dice->EnsureRuntimeStreamGeometry( - [finish, dev, protocol](IOReturn /*status*/) mutable { - finish(std::move(dev), protocol); + [finish, dev, protocol](IOReturn status) mutable { + finish(std::move(dev), protocol, status == kIOReturnSuccess); }); return; } - finish(std::move(dev), protocol); + finish(std::move(dev), protocol, false); } IOReturn DiceAudioBackend::StartStreaming(uint64_t guid) noexcept { diff --git a/ASFWDriver/Audio/Protocols/Backends/DiceRuntimeDeviceConfig.hpp b/ASFWDriver/Audio/Protocols/Backends/DiceRuntimeDeviceConfig.hpp index 9b3134fe2..c7b4f46c0 100644 --- a/ASFWDriver/Audio/Protocols/Backends/DiceRuntimeDeviceConfig.hpp +++ b/ASFWDriver/Audio/Protocols/Backends/DiceRuntimeDeviceConfig.hpp @@ -7,6 +7,7 @@ #include "../../Model/ASFWAudioDevice.hpp" #include "../AudioTypes.hpp" +#include "../../../DeviceProfiles/Audio/AudioDeviceIds.hpp" #include @@ -55,4 +56,39 @@ namespace ASFW::Audio { return true; } +enum class DicePublicationConfigResult { + kDefer, + kProfileFallback, + kRuntimeGeometry, +}; + +// Preserve established DICE publication on missing/failed discovery: available +// runtime caps enrich the profile, but a transient read failure must not prevent +// other DICE devices from publishing. Only the exact FireStudio Project profile +// requires successfully discovered wire geometry before its first publication. +// This prepares a candidate config; deferring does not remove an existing nub. +[[nodiscard]] inline DicePublicationConfigResult PrepareDiceDeviceConfigForPublication( + const AudioStreamRuntimeCaps* caps, + bool geometryReadSucceeded, + Model::ASFWAudioDevice& config) { + const bool requiresRuntimeGeometry = + config.vendorId == DeviceProfiles::Audio::kPreSonusVendorId && + config.modelId == DeviceProfiles::Audio::kFireStudioProjectModelId; + if (requiresRuntimeGeometry && + (!geometryReadSucceeded || !caps || + caps->deviceToHostAm824Slots == 0 || caps->hostToDeviceAm824Slots == 0 || + caps->deviceToHostStreamCount == 0 || + caps->deviceToHostStreamCount > kMaxAudioStreamsPerDirection || + caps->hostToDeviceStreamCount == 0 || + caps->hostToDeviceStreamCount > kMaxAudioStreamsPerDirection)) { + return DicePublicationConfigResult::kDefer; + } + + if (caps && ApplyDiceRuntimeCapsToDeviceConfig(*caps, config)) { + return DicePublicationConfigResult::kRuntimeGeometry; + } + return requiresRuntimeGeometry ? DicePublicationConfigResult::kDefer + : DicePublicationConfigResult::kProfileFallback; +} + } // namespace ASFW::Audio diff --git a/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.cpp b/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.cpp index d73295a15..19cc4a66a 100644 --- a/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.cpp +++ b/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.cpp @@ -1251,6 +1251,10 @@ void DICEDuplexBringupController::RefreshRuntimeCaps(VoidCallback cb) { }); } +void DICEDuplexBringupController::AbortDuplex(IOReturn error, VoidCallback callback) { + DoRollback(error, std::move(callback)); +} + void DICEDuplexBringupController::DoRollback(IOReturn error, VoidCallback cb) { CancelScheduledRetry(); restartSession_.phase = DiceRestartPhase::kFailed; diff --git a/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.hpp b/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.hpp index 3bdad9a5a..75fb708ac 100644 --- a/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.hpp +++ b/ASFWDriver/Audio/Protocols/DICE/Core/DICEDuplexBringupController.hpp @@ -78,6 +78,9 @@ class DICEDuplexBringupController { void ConfirmDuplex48kStart(VoidCallback callback); [[nodiscard]] IOReturn StopDuplex(); // stays sync — pure writes, no HW wait void ReleaseOwner(VoidCallback callback); + // Reuse the normal rollback sequence when an outer policy rejects a + // successfully prepared/refreshed stream topology. + void AbortDuplex(IOReturn error, VoidCallback callback); [[nodiscard]] bool IsPrepared() const noexcept { return restartSession_.devicePrepared; } [[nodiscard]] bool IsArmed() const noexcept { return restartSession_.deviceTxArmed; } diff --git a/ASFWDriver/Audio/Protocols/DICE/Core/DICETransaction.cpp b/ASFWDriver/Audio/Protocols/DICE/Core/DICETransaction.cpp index 5f2842615..8bb1a020b 100644 --- a/ASFWDriver/Audio/Protocols/DICE/Core/DICETransaction.cpp +++ b/ASFWDriver/Audio/Protocols/DICE/Core/DICETransaction.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -68,9 +69,9 @@ void LogSectionPreview(const char* label, const uint8_t* data, size_t size) { // the Venice F32 needs 8 + 2*280 = 568 bytes, and stream 1's 256-byte label // blob starts at byte 304 — past a single 512-byte read. Chain fixed-size // chunk reads into one buffer so ParseStreamConfig sees every stream's labels. -// A failure after the first chunk delivers the partial buffer (the parser -// guards each stream's core/label region against the buffer size), matching -// the old best-effort behavior; only a failed first chunk is a hard error. +// A failure after the first chunk delivers the partial buffer. The parser +// requires every declared stream's core; missing labels or unused trailing +// descriptor capacity remain optional. A failed first chunk is a hard error. constexpr size_t kSectionReadChunkBytes = 512; constexpr size_t kMaxSectionReadBytes = 4096; @@ -295,10 +296,6 @@ void CopyLabelBlob(char (&dst)[256], const uint8_t* src, size_t bytesAvailable) dst[copyBytes] = '\0'; } -uint32_t ClampStreamCount(uint32_t count) noexcept { - return (count > 4u) ? 4u : count; -} - StreamConfig ParseStreamConfig(const uint8_t* data, size_t size, bool isRxLayout) { StreamConfig config; config.isRxLayout = isRxLayout; @@ -309,9 +306,14 @@ StreamConfig ParseStreamConfig(const uint8_t* data, size_t size, bool isRxLayout const uint32_t reportedStreams = ReadBE32(data); const uint32_t entryQuadlets = ReadBE32(data + 4); - config.numStreams = ClampStreamCount(reportedStreams); config.entrySizeBytes = entryQuadlets * 4u; config.parsedEntrySizeBytes = config.entrySizeBytes; + if (reportedStreams > std::size(config.streams)) { + ASFW_LOG(DICE, "DICE %{public}s stream format: unsupported stream count %u", + isRxLayout ? "RX" : "TX", reportedStreams); + return config; + } + config.numStreams = reportedStreams; if (config.entrySizeBytes < kStreamEntryMinCoreBytes) { ASFW_LOG(DICE, "DICE %{public}s stream format: invalid entry size %u bytes (reported streams=%u)", @@ -355,14 +357,15 @@ StreamConfig ParseStreamConfig(const uint8_t* data, size_t size, bool isRxLayout if (parsedCount < config.numStreams) { ASFW_LOG(DICE, - "DICE %{public}s stream format truncated: reported=%u clamped=%u parsed=%u readSize=%zu entrySize=%u", + "DICE %{public}s stream cores truncated: reported=%u parsed=%u readSize=%zu entrySize=%u", isRxLayout ? "RX" : "TX", reportedStreams, - ClampStreamCount(reportedStreams), parsedCount, size, config.entrySizeBytes); - config.numStreams = parsedCount; + // Never reinterpret partial geometry as a smaller, supported device. + // Counts of zero make the whole direction unusable to runtime policy. + config.numStreams = 0; } return config; diff --git a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp index b5430c049..0ec4de259 100644 --- a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp +++ b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp @@ -22,7 +22,11 @@ namespace { return caps.sampleRateHz != 0 && caps.hostOutputPcmChannels != 0 && caps.deviceToHostAm824Slots != 0 && - caps.hostToDeviceAm824Slots != 0; + caps.hostToDeviceAm824Slots != 0 && + caps.deviceToHostStreamCount != 0 && + caps.deviceToHostStreamCount <= kMaxAudioStreamsPerDirection && + caps.hostToDeviceStreamCount != 0 && + caps.hostToDeviceStreamCount <= kMaxAudioStreamsPerDirection; } void LogRuntimeCaps(const char* source, const AudioStreamRuntimeCaps& caps) { @@ -84,10 +88,20 @@ DICETcatProtocol::DICETcatProtocol(Protocols::Ports::FireWireBusOps& busOps, , io_(busOps, busInfo, routeRegistry, route) , diceReader_(io_) , timerScheduler_(timerScheduler) - , runtimePolicy_(runtimePolicy) { + , runtimePolicy_(runtimePolicy) + , discoveryLock_(IOLockAlloc()) { +} + +DICETcatProtocol::~DICETcatProtocol() { + if (discoveryLock_) { + IOLockFree(discoveryLock_); + } } IOReturn DICETcatProtocol::Initialize() { + if (!discoveryLock_) { + return kIOReturnNoMemory; + } if (!duplexCtrl_) { duplexCtrl_.emplace(diceReader_, io_, busInfo_, nullptr /*workQueue*/, GeneralSections{}, timerScheduler_, @@ -133,15 +147,18 @@ bool DICETcatProtocol::GetRuntimeAudioStreamCaps(AudioStreamRuntimeCaps& outCaps return false; } - outCaps.sampleRateHz = runtimeSampleRateHz_.load(std::memory_order_relaxed); + const uint32_t liveRate = runtimeSampleRateHz_.load(std::memory_order_acquire); + outCaps.sampleRateHz = liveRate != 0 ? liveRate : discoverySampleRateHz_; outCaps.hostInputPcmChannels = hostInputPcmChannels_.load(std::memory_order_relaxed); outCaps.hostOutputPcmChannels = hostOutputPcmChannels_.load(std::memory_order_relaxed); outCaps.deviceToHostAm824Slots = deviceToHostAm824Slots_.load(std::memory_order_relaxed); outCaps.hostToDeviceAm824Slots = hostToDeviceAm824Slots_.load(std::memory_order_relaxed); - outCaps.deviceToHostIsoChannel = - static_cast(deviceToHostIsoChannel_.load(std::memory_order_relaxed)); - outCaps.hostToDeviceIsoChannel = - static_cast(hostToDeviceIsoChannel_.load(std::memory_order_relaxed)); + outCaps.deviceToHostIsoChannel = liveRate != 0 + ? static_cast(deviceToHostIsoChannel_.load(std::memory_order_relaxed)) + : discoveryDeviceToHostIsoChannel_; + outCaps.hostToDeviceIsoChannel = liveRate != 0 + ? static_cast(hostToDeviceIsoChannel_.load(std::memory_order_relaxed)) + : discoveryHostToDeviceIsoChannel_; // Per-stream geometry: the runtimeCapsValid_ acquire-load above establishes // happens-before with the writer's release-store, so the plain arrays are @@ -151,6 +168,12 @@ bool DICETcatProtocol::GetRuntimeAudioStreamCaps(AudioStreamRuntimeCaps& outCaps for (uint32_t i = 0; i < kMaxAudioStreamsPerDirection; ++i) { outCaps.deviceToHostStreams[i] = deviceToHostStreams_[i]; outCaps.hostToDeviceStreams[i] = hostToDeviceStreams_[i]; + if (liveRate != 0) { + outCaps.deviceToHostStreams[i].isoChannel = + deviceToHostStreamIsoChannels_[i].load(std::memory_order_relaxed); + outCaps.hostToDeviceStreams[i].isoChannel = + hostToDeviceStreamIsoChannels_[i].load(std::memory_order_relaxed); + } } return true; } @@ -175,23 +198,28 @@ void DICETcatProtocol::PrepareDuplex(const AudioDuplexChannels& channels, } DiceClockConfiguration diceClock{}; - if (!MakeDiceClockConfiguration(desiredClock, diceClock)) { + if (!MakeDiceClockConfiguration(desiredClock, diceClock) || + !SampleRateMatchesPolicy(desiredClock.sampleRateHz)) { callback(kIOReturnUnsupported, {}); return; } - // Remember the live clock so a later per-StartIO PrepareDuplex48k targets it - // rather than reverting the device to 48 kHz (see selectedClock_). - if (desiredClock.sampleRateHz != 0) { - selectedClock_ = desiredClock; - } - duplexCtrl_->PrepareDuplex( channels, diceClock, [this, callback = std::move(callback)](IOReturn status, DiceDuplexPrepareResult result) mutable { + if (status == kIOReturnSuccess && !UpdateOperationalCaps(result.runtimeCaps)) { + duplexCtrl_->AbortDuplex(kIOReturnUnsupported, + [callback = std::move(callback)](IOReturn rollbackStatus) mutable { + callback(rollbackStatus, {}); + }); + return; + } if (status == kIOReturnSuccess) { - CacheRuntimeCaps(result.runtimeCaps); + // Only a completed, geometry-validated preparation may change + // the next legacy StartIO target. A failed request is not a + // selected device rate, even if it reached CLOCK_SELECT. + selectedClock_ = result.appliedClock; } callback(status, result); }); @@ -223,8 +251,12 @@ void DICETcatProtocol::ConfirmDuplexStart(ConfirmCallback callback) { duplexCtrl_->ConfirmDuplexStart( [this, callback = std::move(callback)](IOReturn status, DiceDuplexConfirmResult result) mutable { - if (status == kIOReturnSuccess) { - CacheRuntimeCaps(result.runtimeCaps); + if (status == kIOReturnSuccess && !UpdateOperationalCaps(result.runtimeCaps)) { + duplexCtrl_->AbortDuplex(kIOReturnUnsupported, + [callback = std::move(callback)](IOReturn rollbackStatus) mutable { + callback(rollbackStatus, {}); + }); + return; } callback(status, result); }); @@ -238,23 +270,27 @@ void DICETcatProtocol::ApplyClockConfig(const AudioClockConfig& desiredClock, } DiceClockConfiguration diceClock{}; - if (!MakeDiceClockConfiguration(desiredClock, diceClock)) { + if (!MakeDiceClockConfiguration(desiredClock, diceClock) || + !SampleRateMatchesPolicy(desiredClock.sampleRateHz)) { callback(kIOReturnUnsupported, {}); return; } - // An idle sample-rate change lands here (RunIdleClockApply). Remember it so - // the next StartIO's PrepareDuplex48k keeps the device at this rate instead - // of rewriting CLOCK_SELECT back to 48 kHz (see selectedClock_). - if (desiredClock.sampleRateHz != 0) { - selectedClock_ = desiredClock; - } - duplexCtrl_->ApplyClockConfig( diceClock, [this, callback = std::move(callback)](IOReturn status, DiceClockApplyResult result) mutable { + if (status == kIOReturnSuccess && !UpdateOperationalCaps(result.runtimeCaps)) { + duplexCtrl_->AbortDuplex(kIOReturnUnsupported, + [callback = std::move(callback)](IOReturn rollbackStatus) mutable { + callback(rollbackStatus, {}); + }); + return; + } if (status == kIOReturnSuccess) { - CacheRuntimeCaps(result.runtimeCaps); + // Preserve the last successful rate on read/clock/geometry + // failure; otherwise StartIO would silently retry a failed + // idle rate change after its caller has already rejected it. + selectedClock_ = result.appliedClock; } callback(status, result); }); @@ -362,7 +398,10 @@ void DICETcatProtocol::EnsureSectionsLoaded(VoidCallback callback) { return; } - if (sectionsLoaded_) { + IOLockLock(discoveryLock_); + const bool loaded = sectionsLoaded_; + IOLockUnlock(discoveryLock_); + if (loaded) { callback(kIOReturnSuccess); return; } @@ -374,8 +413,12 @@ void DICETcatProtocol::EnsureSectionsLoaded(VoidCallback callback) { return; } - sections_ = sections; - sectionsLoaded_ = true; + IOLockLock(discoveryLock_); + if (!sectionsLoaded_) { + sections_ = sections; + sectionsLoaded_ = true; + } + IOLockUnlock(discoveryLock_); ASFW_LOG(DICE, "DICETcatProtocol: loaded sections global=%u/%u tx=%u/%u rx=%u/%u ext=%u/%u", sections_.global.offset, @@ -453,14 +496,13 @@ void DICETcatProtocol::EnsureRuntimeCapsLoaded(VoidCallback callback) { state->rx = rx; LogStreamConfigSummary("RX", state->rx); - CacheRuntimeCaps(state->global, state->tx, state->rx); + if (!CacheRuntimeCaps(state->global, state->tx, state->rx)) { + callback(kIOReturnUnsupported); + return; + } AudioStreamRuntimeCaps caps{}; (void)GetRuntimeAudioStreamCaps(caps); LogRuntimeCaps("standard-dice", caps); - if (!HasUsableRuntimeCaps(caps)) { - ASFW_LOG(DICE, - "DICETcatProtocol: standard DICE discovery produced zero or partial caps; audio publication should fail closed"); - } callback(kIOReturnSuccess); }); }); @@ -468,7 +510,7 @@ void DICETcatProtocol::EnsureRuntimeCapsLoaded(VoidCallback callback) { }); } -void DICETcatProtocol::CacheRuntimeCaps(const GlobalState& global, +bool DICETcatProtocol::CacheRuntimeCaps(const GlobalState& global, const StreamConfig& tx, const StreamConfig& rx) noexcept { AudioStreamRuntimeCaps caps{ @@ -506,8 +548,20 @@ void DICETcatProtocol::CacheRuntimeCaps(const GlobalState& global, fillPerStream(tx, caps.deviceToHostStreamCount, caps.deviceToHostStreams); fillPerStream(rx, caps.hostToDeviceStreamCount, caps.hostToDeviceStreams); + if (!RuntimeCapsMatchPolicy(caps)) { + ASFW_LOG(DICE, "DICETcatProtocol: rejecting unusable or unexpected discovery geometry"); + LogRuntimeCaps("rejected", caps); + return false; + } + + IOLockLock(discoveryLock_); + if (runtimeCapsValid_.load(std::memory_order_acquire)) { + IOLockUnlock(discoveryLock_); + return true; + } + // Per-channel device labels from the DICE TX/RX name sections, flattened - // across streams in channel order. Written BEFORE CacheRuntimeCaps(caps)'s + // across streams in channel order. Written BEFORE initial publication's // release-store so GetChannelLabels readers see a consistent snapshot. // Host input == device TX, host output == device RX (AudioTypes.hpp). auto fillLabels = [](const StreamConfig& sc, @@ -534,7 +588,25 @@ void DICETcatProtocol::CacheRuntimeCaps(const GlobalState& global, fillLabels(tx, inputChannelLabelCount_, inputChannelLabels_); fillLabels(rx, outputChannelLabelCount_, outputChannelLabels_); - CacheRuntimeCaps(caps); + const uint32_t exposedInputChannels = runtimePolicy_.exposeDeviceToHostToCoreAudio + ? caps.hostInputPcmChannels : 0; + hostInputPcmChannels_.store(exposedInputChannels, std::memory_order_relaxed); + deviceToHostAm824Slots_.store(caps.deviceToHostAm824Slots, std::memory_order_relaxed); + hostOutputPcmChannels_.store(caps.hostOutputPcmChannels, std::memory_order_relaxed); + hostToDeviceAm824Slots_.store(caps.hostToDeviceAm824Slots, std::memory_order_relaxed); + deviceToHostStreamCount_.store(caps.deviceToHostStreamCount, std::memory_order_relaxed); + hostToDeviceStreamCount_.store(caps.hostToDeviceStreamCount, std::memory_order_relaxed); + for (uint32_t i = 0; i < kMaxAudioStreamsPerDirection; ++i) { + deviceToHostStreams_[i] = caps.deviceToHostStreams[i]; + hostToDeviceStreams_[i] = caps.hostToDeviceStreams[i]; + } + discoverySampleRateHz_ = caps.sampleRateHz; + discoveryDeviceToHostIsoChannel_ = caps.deviceToHostIsoChannel; + discoveryHostToDeviceIsoChannel_ = caps.hostToDeviceIsoChannel; + runtimeCapsValid_.store(true, std::memory_order_release); + IOLockUnlock(discoveryLock_); + LogRuntimeCaps("discovery-cache", caps); + return true; } bool DICETcatProtocol::GetChannelLabels(std::vector& inNames, @@ -557,35 +629,83 @@ bool DICETcatProtocol::GetChannelLabels(std::vector& inNames, return inCount > 0 || outCount > 0; } -void DICETcatProtocol::CacheRuntimeCaps(const AudioStreamRuntimeCaps& caps) noexcept { - const uint32_t exposedInputChannels = runtimePolicy_.exposeDeviceToHostToCoreAudio - ? caps.hostInputPcmChannels - : 0; - hostInputPcmChannels_.store(exposedInputChannels, std::memory_order_relaxed); - deviceToHostAm824Slots_.store(caps.deviceToHostAm824Slots, std::memory_order_relaxed); - hostOutputPcmChannels_.store(caps.hostOutputPcmChannels, std::memory_order_relaxed); - hostToDeviceAm824Slots_.store(caps.hostToDeviceAm824Slots, std::memory_order_relaxed); - runtimeSampleRateHz_.store(caps.sampleRateHz, std::memory_order_relaxed); +bool DICETcatProtocol::SampleRateMatchesPolicy(uint32_t sampleRateHz) const noexcept { + if (sampleRateHz == 0) { + return false; + } + bool hasAllowedRate = false; + for (const uint32_t allowedRate : runtimePolicy_.allowedSampleRatesHz) { + if (allowedRate == sampleRateHz) { + return true; + } + hasAllowedRate |= allowedRate != 0; + } + return !hasAllowedRate && + (!runtimePolicy_.requiredRuntimeGeometry || + sampleRateHz == runtimePolicy_.requiredRuntimeGeometry->sampleRateHz); +} + +bool DICETcatProtocol::RuntimeCapsMatchPolicy(const AudioStreamRuntimeCaps& caps) const noexcept { + if (!HasUsableRuntimeCaps(caps) || !SampleRateMatchesPolicy(caps.sampleRateHz)) { + return false; + } + if (!runtimePolicy_.requiredRuntimeGeometry) { + return true; + } + const auto& expected = *runtimePolicy_.requiredRuntimeGeometry; + if ((runtimePolicy_.exposeDeviceToHostToCoreAudio && + caps.hostInputPcmChannels != expected.hostInputPcmChannels) || + caps.hostOutputPcmChannels != expected.hostOutputPcmChannels || + caps.deviceToHostAm824Slots != expected.deviceToHostAm824Slots || + caps.hostToDeviceAm824Slots != expected.hostToDeviceAm824Slots || + caps.deviceToHostStreamCount != expected.deviceToHostStreamCount || + caps.hostToDeviceStreamCount != expected.hostToDeviceStreamCount) { + return false; + } + auto sameStreams = [](const AudioStreamWireInfo* observed, + const AudioStreamWireInfo* required, uint32_t count) { + for (uint32_t i = 0; i < count; ++i) { + if (observed[i].pcmChannels != required[i].pcmChannels || + observed[i].midiPorts != required[i].midiPorts || + observed[i].am824Slots != required[i].am824Slots) { + return false; + } + } + return true; + }; + return sameStreams(caps.deviceToHostStreams, expected.deviceToHostStreams, + caps.deviceToHostStreamCount) && + sameStreams(caps.hostToDeviceStreams, expected.hostToDeviceStreams, + caps.hostToDeviceStreamCount); +} + +bool DICETcatProtocol::UpdateOperationalCaps(const AudioStreamRuntimeCaps& caps) noexcept { + if (!RuntimeCapsMatchPolicy(caps)) { + ASFW_LOG(DICE, "DICETcatProtocol: rejecting unusable or unexpected runtime geometry"); + LogRuntimeCaps("rejected", caps); + return false; + } + // The result has been validated independently of discovery. Clock and ISO + // assignments are live state; topology, labels and validity are not. deviceToHostIsoChannel_.store(caps.deviceToHostIsoChannel, std::memory_order_relaxed); hostToDeviceIsoChannel_.store(caps.hostToDeviceIsoChannel, std::memory_order_relaxed); - - // Per-stream geometry: write the plain arrays + counts BEFORE the - // release-store of runtimeCapsValid_ so readers that pass the acquire-load - // observe a consistent snapshot. - deviceToHostStreamCount_.store(caps.deviceToHostStreamCount, std::memory_order_relaxed); - hostToDeviceStreamCount_.store(caps.hostToDeviceStreamCount, std::memory_order_relaxed); for (uint32_t i = 0; i < kMaxAudioStreamsPerDirection; ++i) { - deviceToHostStreams_[i] = caps.deviceToHostStreams[i]; - hostToDeviceStreams_[i] = caps.hostToDeviceStreams[i]; + deviceToHostStreamIsoChannels_[i].store(caps.deviceToHostStreams[i].isoChannel, + std::memory_order_relaxed); + hostToDeviceStreamIsoChannels_[i].store(caps.hostToDeviceStreams[i].isoChannel, + std::memory_order_relaxed); } - - runtimeCapsValid_.store(true, std::memory_order_release); - LogRuntimeCaps("cache", caps); + runtimeSampleRateHz_.store(caps.sampleRateHz, std::memory_order_release); + return true; } void DICETcatProtocol::ResetRuntimeCaps() noexcept { + // Shutdown is called only after dependent readers and callbacks quiesce. runtimeCapsValid_.store(false, std::memory_order_release); runtimeSampleRateHz_.store(0, std::memory_order_relaxed); + discoverySampleRateHz_ = 0; + discoveryDeviceToHostIsoChannel_ = AudioStreamRuntimeCaps::kInvalidIsoChannel; + discoveryHostToDeviceIsoChannel_ = AudioStreamRuntimeCaps::kInvalidIsoChannel; hostInputPcmChannels_.store(0, std::memory_order_relaxed); hostOutputPcmChannels_.store(0, std::memory_order_relaxed); deviceToHostAm824Slots_.store(0, std::memory_order_relaxed); @@ -597,6 +717,10 @@ void DICETcatProtocol::ResetRuntimeCaps() noexcept { for (uint32_t i = 0; i < kMaxAudioStreamsPerDirection; ++i) { deviceToHostStreams_[i] = AudioStreamWireInfo{}; hostToDeviceStreams_[i] = AudioStreamWireInfo{}; + deviceToHostStreamIsoChannels_[i].store(AudioStreamWireInfo::kInvalidIsoChannel, + std::memory_order_relaxed); + hostToDeviceStreamIsoChannels_[i].store(AudioStreamWireInfo::kInvalidIsoChannel, + std::memory_order_relaxed); } inputChannelLabelCount_.store(0, std::memory_order_relaxed); outputChannelLabelCount_.store(0, std::memory_order_relaxed); diff --git a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp index f9a865448..f8c8a7d60 100644 --- a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp +++ b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp @@ -12,6 +12,8 @@ #include "../../IDeviceProtocol.hpp" #include "../../../../Protocols/Ports/ProtocolRegisterIO.hpp" +#include +#include #include #include #include @@ -37,6 +39,14 @@ struct DICETcatRuntimePolicy final { // are flowing; it does not select ARX1 as a clock source. bool requireSourceLockBeforeStreamEnable{true}; bool requireSourceLockAtConfirm{true}; + // Optional captured wire geometry. Isochronous channel assignments are not + // compared; PCM/MIDI widths, slot totals and stream counts remain exact. + std::optional requiredRuntimeGeometry{}; + // Optional bounded rate allowlist (seven standard DICE rates maximum). + // Zero entries are unused. With no nonzero entries, preserve the captured + // geometry's exact rate, or the generic rate behavior when unconstrained. + // An allowlist changes only the rate check, never the required wire shape. + std::array allowedSampleRatesHz{}; }; class DICETcatProtocol final : public Audio::IDeviceProtocol, @@ -57,6 +67,8 @@ class DICETcatProtocol final : public Audio::IDeviceProtocol, ::ASFW::Scheduling::ITimerScheduler* timerScheduler = nullptr, DICETcatRuntimePolicy runtimePolicy = {}); + ~DICETcatProtocol() override; + IOReturn Initialize() override; IOReturn Shutdown() override; const char* GetName() const override { return "TCAT DICE"; } @@ -99,10 +111,12 @@ class DICETcatProtocol final : public Audio::IDeviceProtocol, DiceClockConfiguration& out) noexcept; void EnsureSectionsLoaded(VoidCallback callback); void EnsureRuntimeCapsLoaded(VoidCallback callback); - void CacheRuntimeCaps(const GlobalState& global, + [[nodiscard]] bool SampleRateMatchesPolicy(uint32_t sampleRateHz) const noexcept; + [[nodiscard]] bool RuntimeCapsMatchPolicy(const AudioStreamRuntimeCaps& caps) const noexcept; + [[nodiscard]] bool CacheRuntimeCaps(const GlobalState& global, const StreamConfig& tx, const StreamConfig& rx) noexcept; - void CacheRuntimeCaps(const AudioStreamRuntimeCaps& caps) noexcept; + [[nodiscard]] bool UpdateOperationalCaps(const AudioStreamRuntimeCaps& caps) noexcept; void ResetRuntimeCaps() noexcept; Protocols::Ports::FireWireBusInfo& busInfo_; @@ -116,17 +130,25 @@ class DICETcatProtocol final : public Audio::IDeviceProtocol, GeneralSections sections_{}; bool initialized_{false}; bool sectionsLoaded_{false}; + // Serializes initial discovery publication only. Never held during I/O or + // client callbacks, and never acquired by stream-capability readers. + IOLock* discoveryLock_{nullptr}; // The user-selected device clock, remembered across StartIO cycles so the // per-StartIO bring-up (PrepareDuplex48k) targets the live rate instead of a - // hardcoded 48 kHz. Updated whenever a real clock is applied (ApplyClockConfig - // for idle rate changes, PrepareDuplex for restarts). Default {0} means + // hardcoded 48 kHz. Updated after a successful, geometry-validated clock + // apply or preparation; failed requests retain the last selection. Default {0} means // "nothing selected yet" → PrepareDuplex48k falls back to 48 kHz. Without this // every StartIO rewrites CLOCK_SELECT back to 48 kHz and fights a 44.1 kHz // selection, flapping the device PLL and starving audio. AudioClockConfig selectedClock_{}; + // Live state is written only by successful operational requests. Until the + // first one completes, getters use the immutable discovery values below. std::atomic runtimeSampleRateHz_{0}; + uint32_t discoverySampleRateHz_{0}; + uint8_t discoveryDeviceToHostIsoChannel_{AudioStreamRuntimeCaps::kInvalidIsoChannel}; + uint8_t discoveryHostToDeviceIsoChannel_{AudioStreamRuntimeCaps::kInvalidIsoChannel}; std::atomic hostInputPcmChannels_{0}; std::atomic hostOutputPcmChannels_{0}; std::atomic deviceToHostAm824Slots_{0}; @@ -135,19 +157,22 @@ class DICETcatProtocol final : public Audio::IDeviceProtocol, std::atomic hostToDeviceIsoChannel_{AudioStreamRuntimeCaps::kInvalidIsoChannel}; // Per-stream wire geometry (DICE TX_NUMBER/RX_NUMBER + per-stream channels). - // Counts are atomic; the arrays are plain and published through the - // runtimeCapsValid_ release/acquire fence (written before the release-store, - // read after the acquire-load), mirroring the scalar fields above. + // Written once during discovery and published by runtimeCapsValid_. The + // plain arrays must remain immutable until quiesced Shutdown: storing true + // again does not protect readers that already passed the acquire-load. + // Live ISO assignments are atomic overlays, separate from static topology. std::atomic deviceToHostStreamCount_{0}; std::atomic hostToDeviceStreamCount_{0}; AudioStreamWireInfo deviceToHostStreams_[kMaxAudioStreamsPerDirection]{}; AudioStreamWireInfo hostToDeviceStreams_[kMaxAudioStreamsPerDirection]{}; + std::atomic deviceToHostStreamIsoChannels_[kMaxAudioStreamsPerDirection]{}; + std::atomic hostToDeviceStreamIsoChannels_[kMaxAudioStreamsPerDirection]{}; // Per-channel device labels, flattened across this direction's streams in // channel order (input == device TX, output == device RX). Published // through the runtimeCapsValid_ release/acquire fence like the arrays above; - // only the (global, tx, rx) cache path fills them (the caps-only overload - // leaves them intact). Covers the widest supported interface (32x32). + // only initial discovery fills them. Runtime operations and later discovery + // requests leave them intact. Covers the widest supported interface (32x32). static constexpr uint32_t kMaxChannelLabels = 32; std::atomic inputChannelLabelCount_{0}; std::atomic outputChannelLabelCount_{0}; diff --git a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp index 72aa48777..0d994131a 100644 --- a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp +++ b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp @@ -6,6 +6,7 @@ #include "DeviceProtocolFactory.hpp" #include "DICE/Focusrite/SPro24DspProtocol.hpp" #include "DICE/TCAT/DICETcatProtocol.hpp" +#include "../DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp" #include "Oxford/Apogee/ApogeeDuetProtocol.hpp" #include "BeBoB/Phase88Protocol.hpp" #include "BeBoB/GenericBeBoBProtocol.hpp" @@ -94,6 +95,39 @@ std::unique_ptr DeviceProtocolFactory::Create( route, irmClient, timerScheduler); } + if (vendorId == kPreSonusVendorId && modelId == kFireStudioProjectModelId) { + using Profile = ASFW::Isoch::Audio::DICE::Profiles::PreSonusFireStudioProjectProfile; + // The captured low-rate layout is the only one this profile can drive. + // Validate physical wire geometry, including MIDI, before publication + // and after clock preparation. ISO channels are assigned at runtime. + AudioStreamRuntimeCaps expected{}; + expected.sampleRateHz = Profile::kSampleRateHz; + expected.hostInputPcmChannels = Profile::kPcmChannels; + expected.hostOutputPcmChannels = Profile::kPcmChannels; + expected.deviceToHostAm824Slots = Profile::kDbs; + expected.hostToDeviceAm824Slots = Profile::kDbs; + expected.deviceToHostStreamCount = Profile::kStreamCount; + expected.hostToDeviceStreamCount = Profile::kStreamCount; + const AudioStreamWireInfo stream{ + .pcmChannels = Profile::kPcmChannels, + .am824Slots = Profile::kDbs, + .midiPorts = Profile::kMidiPorts, + }; + expected.deviceToHostStreams[0] = stream; + expected.hostToDeviceStreams[0] = stream; + DICE::TCAT::DICETcatRuntimePolicy policy{.requiredRuntimeGeometry = expected}; + static_assert(Profile::kSupportedSampleRatesHz.size() <= policy.allowedSampleRatesHz.size()); + for (size_t index = 0; index < Profile::kSupportedSampleRatesHz.size(); ++index) { + policy.allowedSampleRatesHz[index] = Profile::kSupportedSampleRatesHz[index]; + } + ASFW_LOG(DICE, + "Creating experimental 44.1/48k FireStudio Project TCAT protocol node=0x%04x", + nodeId); + return std::make_unique( + busOps, busInfo, routeRegistry, route, irmClient, timerScheduler, + policy); + } + if (vendorId == kPreSonusVendorId && modelId == kStudioLive1602ModelId) { ASFW_LOG(DICE, "Creating generic DICETcatProtocol for PreSonus StudioLive 16.0.2 vendor=0x%06x model=0x%06x node=0x%04x", diff --git a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.hpp b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.hpp index 4db259df7..4d8d8e135 100644 --- a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.hpp +++ b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.hpp @@ -75,6 +75,7 @@ class DeviceProtocolFactory { static constexpr uint32_t kMidasVendorId = DeviceProfiles::Audio::kMidasVendorId; static constexpr uint32_t kMidasVeniceModelId = DeviceProfiles::Audio::kMidasVeniceModelId; static constexpr uint32_t kPreSonusVendorId = DeviceProfiles::Audio::kPreSonusVendorId; + static constexpr uint32_t kFireStudioProjectModelId = DeviceProfiles::Audio::kFireStudioProjectModelId; static constexpr uint32_t kStudioLive1602ModelId = DeviceProfiles::Audio::kStudioLive1602ModelId; static constexpr uint32_t kFocusriteGuidModelSPro40Tcd3070 = diff --git a/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp b/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp index 194a08024..84059c8ef 100644 --- a/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp +++ b/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp @@ -7,8 +7,8 @@ namespace ASFW::Protocols::Audio::AMDTP { // Design decisions (see ../../../README.md, Step 3): // -// 1. Configure() selects the cadence from streamMode + sampleRate and rejects -// anything but 48 kHz — honest failure over an untested rate path. +// 1. Configure() selects the cadence from streamMode + sampleRate. Blocking +// uses the supported rate geometry; non-blocking remains 48 kHz only. // 2. packetIndex comes from the caller's TxPacketSlotView; the packetizer owns // no cycle numbering. // 3. Slot bytes are wire-order (big-endian); this is the single diff --git a/ASFWDriver/Controller/ControllerCoreInterrupts.cpp b/ASFWDriver/Controller/ControllerCoreInterrupts.cpp index b3c78544e..a73db6858 100644 --- a/ASFWDriver/Controller/ControllerCoreInterrupts.cpp +++ b/ASFWDriver/Controller/ControllerCoreInterrupts.cpp @@ -135,8 +135,8 @@ void ControllerCore::HandleInterrupt(const InterruptSnapshot& snapshot) { if (toAck != 0U) { hw.ClearIntEvents(toAck); } - hw.ClearIsoXmitEvents(snapshot.isoXmitEvent); - hw.ClearIsoRecvEvents(snapshot.isoRecvEvent); + // InterruptDispatcher owns the fresh per-context read/ack after this + // global acknowledgement. Do not clear saved context bits here. } void ControllerCore::LogInterruptContext(const InterruptSnapshot& snapshot, diff --git a/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp b/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp index db81bf39e..50745861a 100644 --- a/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp +++ b/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp @@ -67,6 +67,9 @@ inline constexpr uint32_t kMidasVeniceModelId = 0x000001; // hardware-verified — the siblings are recognized by name but not audio-enabled // until their stream geometry is captured from real hardware. inline constexpr uint32_t kPreSonusVendorId = 0x000a92; +// Config-ROM vendor/model confirmed on FireStudio Project hardware (2026-09-07), +// GUID 0x000A920402D07FAC. See captures/presonus-firestudio-project/. +inline constexpr uint32_t kFireStudioProjectModelId = 0x00000b; inline constexpr uint32_t kStudioLive1602ModelId = 0x000013; inline constexpr uint32_t kStudioLive1642ModelId = 0x000010; inline constexpr uint32_t kStudioLive2442ModelId = 0x000012; @@ -100,6 +103,7 @@ inline constexpr const char* kAlesisMultiMixModelName = "MultiMix FireWire"; inline constexpr const char* kMidasVendorName = "Midas"; inline constexpr const char* kMidasVeniceModelName = "Venice F32"; inline constexpr const char* kPreSonusVendorName = "PreSonus"; +inline constexpr const char* kFireStudioProjectModelName = "FireStudio Project"; inline constexpr const char* kStudioLive1602ModelName = "StudioLive 16.0.2"; inline constexpr const char* kStudioLive1642ModelName = "StudioLive 16.4.2"; inline constexpr const char* kStudioLive2442ModelName = "StudioLive 24.4.2"; diff --git a/ASFWDriver/DeviceProfiles/Audio/Vendors/PreSonusAudioProfiles.hpp b/ASFWDriver/DeviceProfiles/Audio/Vendors/PreSonusAudioProfiles.hpp index b3b2c9fd4..d408cc5fa 100644 --- a/ASFWDriver/DeviceProfiles/Audio/Vendors/PreSonusAudioProfiles.hpp +++ b/ASFWDriver/DeviceProfiles/Audio/Vendors/PreSonusAudioProfiles.hpp @@ -16,10 +16,10 @@ LookupIdentity(const DeviceProfileQuery& query) noexcept { if (query.vendorId != kPreSonusVendorId) { return std::nullopt; } - // StudioLive siblings are recognized by name only until their stream geometry - // is captured from hardware — see LookupAudioProfile. + // Identity recognition and audio integration are separate policies. const char* modelName = nullptr; switch (query.modelId) { + case kFireStudioProjectModelId: modelName = kFireStudioProjectModelName; break; case kStudioLive1602ModelId: modelName = kStudioLive1602ModelName; break; case kStudioLive1642ModelId: modelName = kStudioLive1642ModelName; break; case kStudioLive2442ModelId: modelName = kStudioLive2442ModelName; break; @@ -35,6 +35,14 @@ LookupIdentity(const DeviceProfileQuery& query) noexcept { [[nodiscard]] constexpr std::optional LookupAudioProfile(const DeviceProfileQuery& query) noexcept { + if (query.vendorId == kPreSonusVendorId && query.modelId == kFireStudioProjectModelId) { + // Captured 2026-09-07: one stream/direction, 10 PCM + 1 MIDI, + // captured at 48 kHz internal. The protocol enforces this geometry; + // the experimental stream profile allows 44.1 and 48 kHz. + return AudioProfileHint{.family = AudioProtocolFamily::DICE, + .mode = AudioIntegrationMode::kHardcodedNub, + .source = MatchSource::VendorModel}; + } if (query.vendorId == kPreSonusVendorId && query.modelId == kStudioLive1602ModelId) { // Identity captured live from the hardware (2026-07-08): GUID // 0x000A920404FE2011, unit directory specifier 0x000A92 version 0x000001. diff --git a/ASFWDriver/Hardware/HardwareInterface.cpp b/ASFWDriver/Hardware/HardwareInterface.cpp index a94859f9b..b75df6672 100644 --- a/ASFWDriver/Hardware/HardwareInterface.cpp +++ b/ASFWDriver/Hardware/HardwareInterface.cpp @@ -336,8 +336,39 @@ InterruptSnapshot HardwareInterface::CaptureInterruptSnapshot(uint64_t timestamp if (!access) return snapshot; snapshot.intEvent = access.Read(Register32::kIntEvent); snapshot.intMask = 0; - snapshot.isoXmitEvent = access.Read(Register32::kIsoXmitEvent); - snapshot.isoRecvEvent = access.Read(Register32::kIsoRecvEvent); + // Per-context events must be sampled after the global acknowledgement, + // not carried through the controller's async/reset processing as stale bits. + return snapshot; +} + +InterruptSnapshot HardwareInterface::CaptureAndAcknowledgeIsochInterrupts( + const InterruptSnapshot& globalSnapshot) noexcept { + InterruptSnapshot snapshot = globalSnapshot; + snapshot.isoXmitEvent = 0; + snapshot.isoRecvEvent = 0; + if ((snapshot.intEvent & (IntEventBits::kIsochRx | IntEventBits::kIsochTx)) == 0) { + return snapshot; + } + + auto access = TryBeginAccess(); + if (!access) return snapshot; + + // Linux drivers/firewire/ohci.c:2067-2109 (28924df2a08f) acknowledges + // global events, then reads and clears each signalled context mask once. + // Keep the fresh read/clear together; a later second clear of saved bits + // can erase a new completion for the same context. + if ((snapshot.intEvent & IntEventBits::kIsochRx) != 0) { + snapshot.isoRecvEvent = access.Read(Register32::kIsoRecvIntEventClear); + if (snapshot.isoRecvEvent != 0) { + access.WriteAndFlush(Register32::kIsoRecvIntEventClear, snapshot.isoRecvEvent); + } + } + if ((snapshot.intEvent & IntEventBits::kIsochTx) != 0) { + snapshot.isoXmitEvent = access.Read(Register32::kIsoXmitIntEventClear); + if (snapshot.isoXmitEvent != 0) { + access.WriteAndFlush(Register32::kIsoXmitIntEventClear, snapshot.isoXmitEvent); + } + } return snapshot; } diff --git a/ASFWDriver/Hardware/HardwareInterface.hpp b/ASFWDriver/Hardware/HardwareInterface.hpp index 28a031458..ec5f833a1 100644 --- a/ASFWDriver/Hardware/HardwareInterface.hpp +++ b/ASFWDriver/Hardware/HardwareInterface.hpp @@ -90,6 +90,10 @@ class HardwareInterface { void SetInterruptMask(uint32_t mask, bool enable); [[nodiscard]] InterruptSnapshot CaptureInterruptSnapshot(uint64_t timestamp) const noexcept; + // Call after ControllerCore has acknowledged the global interrupt events. + // Per-context events are read fresh and acknowledged once before dispatch. + [[nodiscard]] InterruptSnapshot CaptureAndAcknowledgeIsochInterrupts( + const InterruptSnapshot& globalSnapshot) noexcept; void SetLinkControlBits(uint32_t bits); void ClearLinkControlBits(uint32_t bits); void ClearIntEvents(uint32_t mask); diff --git a/ASFWDriver/Hardware/InterruptDispatcher.cpp b/ASFWDriver/Hardware/InterruptDispatcher.cpp index 94a06258e..a76d3af2f 100644 --- a/ASFWDriver/Hardware/InterruptDispatcher.cpp +++ b/ASFWDriver/Hardware/InterruptDispatcher.cpp @@ -16,13 +16,12 @@ void InterruptDispatcher::HandleSnapshot(const InterruptSnapshot& snap, Controll IsochService& isoch, StatusPublisher& statusPublisher, ASFW::Async::IAsyncSubsystemPort* asyncSubsystem) { controller.HandleInterrupt(snap); + const auto isochSnapshot = hardware.CaptureAndAcknowledgeIsochInterrupts(snap); // ===== ISOCHRONOUS RECEIVE INTERRUPT ===== // Per OHCI §9.1: kIsochRx (bit 7) indicates one or more IR contexts have completed descriptors. - // We read isoRecvEvent to determine which contexts, clear it, then dispatch processing. - if ((snap.intEvent & IntEventBits::kIsochRx) && snap.isoRecvEvent != 0) { - // Clear the per-context event bits to acknowledge - hardware.ClearIsoRecvEvents(snap.isoRecvEvent); + // The post-global snapshot already acknowledged these context events once. + if ((isochSnapshot.intEvent & IntEventBits::kIsochRx) && isochSnapshot.isoRecvEvent != 0) { // One OHCI IR context backs each capture stream (contextIndex == // streamIndex). A multi-stream DICE device (Venice F32 = 2×16) runs a @@ -32,7 +31,7 @@ void InterruptDispatcher::HandleSnapshot(const InterruptSnapshot& snap, Controll // its channel slice (e.g. 17–32) would never reach the input buffer. // Poll the master first so the producer timeline is published before the // secondary slices anchor to it. - const uint32_t recvEvent = snap.isoRecvEvent; + const uint32_t recvEvent = isochSnapshot.isoRecvEvent; workQueue.DispatchAsync(^{ for (uint32_t ctxIdx = 0; ctxIdx < IsochService::kMaxStreamsPerDirection; ++ctxIdx) { if ((recvEvent & (1u << ctxIdx)) == 0) { @@ -47,25 +46,22 @@ void InterruptDispatcher::HandleSnapshot(const InterruptSnapshot& snap, Controll // ===== ISOCHRONOUS TRANSMIT INTERRUPT ===== // Per OHCI §9.2: kIsochTx (bit 6) indicates IT context completion. - // Similar to IR, we read IsoXmitEvent, clear it, and process. - if ((snap.intEvent & IntEventBits::kIsochTx) && snap.isoXmitEvent != 0) { + // As with IR, dispatch only the freshly read and acknowledged context mask. + if ((isochSnapshot.intEvent & IntEventBits::kIsochTx) && isochSnapshot.isoXmitEvent != 0) { // DEBUG: Sample interrupt rate static uint32_t txIrqCtr = 0; if ((++txIrqCtr % 100) == 0) { ASFW_LOG_V3(Controller, "[IRQ] IsoTx Fired! Count=%u IsoTxEvent=0x%08x", txIrqCtr, - snap.isoXmitEvent); + isochSnapshot.isoXmitEvent); } - // Clear event bits to acknowledge - hardware.ClearIsoXmitEvents(snap.isoXmitEvent); - // One OHCI IT context backs each playback stream (contextIndex == // streamIndex). A multi-stream DICE device (Venice F32 = 2×16) runs a // master (context 0) plus secondary contexts (event bit 1 << contextIndex). // Process every signalled context directly in ISR for lowest latency // (IT RefillRing is fast; DispatchAsync would add underrun-prone latency). for (uint32_t ctxIdx = 0; ctxIdx < IsochService::kMaxStreamsPerDirection; ++ctxIdx) { - if ((snap.isoXmitEvent & (1u << ctxIdx)) == 0) { + if ((isochSnapshot.isoXmitEvent & (1u << ctxIdx)) == 0) { continue; } if (auto* tx = isoch.TransmitContext(ctxIdx)) { diff --git a/ASFWDriver/Isoch/IsochService.cpp b/ASFWDriver/Isoch/IsochService.cpp index 5a441d15c..569d3ed4b 100644 --- a/ASFWDriver/Isoch/IsochService.cpp +++ b/ASFWDriver/Isoch/IsochService.cpp @@ -548,6 +548,11 @@ kern_return_t IsochService::AllocateTxIsochResources(uint32_t streamIndex, uint3 *outMetadataRing = nullptr; *outControlBlock = nullptr; + if (const auto* context = TransmitContext(streamIndex); + context && context->NeedsQuiesce()) { + return kIOReturnBusy; + } + // Free only this stream's prior resources; other streams keep theirs. txPayloadSlab_[streamIndex] = nullptr; txMetadataRing_[streamIndex] = nullptr; @@ -608,6 +613,14 @@ kern_return_t IsochService::AllocateTxIsochResources(uint32_t streamIndex, uint3 } kern_return_t IsochService::FreeTxIsochResources() { + // AudioDriverKit cleanup can request this even when its preceding stop + // failed. Retain every stream's resources until all contexts are quiesced. + for (uint32_t i = 0; i < kMaxStreamsPerDirection; ++i) { + if (const auto* context = TransmitContext(i); + context && context->NeedsQuiesce()) { + return kIOReturnBusy; + } + } for (uint32_t i = 0; i < kMaxStreamsPerDirection; ++i) { txPayloadSlab_[i] = nullptr; txMetadataRing_[i] = nullptr; @@ -646,11 +659,11 @@ void IsochService::UpdateStreamingActiveState() noexcept { active = true; } } - if (isochTransmitContext_ && isochTransmitContext_->GetState() == ITState::Running) { + if (isochTransmitContext_ && isochTransmitContext_->NeedsQuiesce()) { active = true; } for (auto& ctx : secondaryTransmitContexts_) { - if (ctx && ctx->GetState() == ITState::Running) { + if (ctx && ctx->NeedsQuiesce()) { active = true; } } diff --git a/ASFWDriver/Isoch/Transmit/IsochTransmitContext.cpp b/ASFWDriver/Isoch/Transmit/IsochTransmitContext.cpp index f1c0fca9b..88344e9f5 100644 --- a/ASFWDriver/Isoch/Transmit/IsochTransmitContext.cpp +++ b/ASFWDriver/Isoch/Transmit/IsochTransmitContext.cpp @@ -30,6 +30,8 @@ const char* TxStateName(ITState state) noexcept { return "running"; case ITState::Stopped: return "stopped"; + case ITState::Faulted: + return "faulted"; } return "unknown"; } @@ -85,6 +87,9 @@ kern_return_t IsochTransmitContext::SetSharedMemoryDescriptors( if (!payloadSlab || !metadataRing || !controlBlock) { return kIOReturnBadArgument; } + if (NeedsQuiesce()) { + return kIOReturnBusy; + } // Unmap any existing maps first payloadMap_ = nullptr; @@ -350,7 +355,7 @@ kern_return_t IsochTransmitContext::Start() noexcept { } kern_return_t IsochTransmitContext::Stop() noexcept { - if (state_ == State::Running && hardware_) { + if (NeedsQuiesce() && hardware_) { // This gate also covers watchdog Poll(). Acquire it before clearing // RUN so an already-dispatched refill cannot retain a direct-audio // mapping past the point this function reports quiesced. @@ -505,7 +510,7 @@ void IsochTransmitContext::SetTxPreparationCallback( } void IsochTransmitContext::StopImmediatelyForTxFault() noexcept { - if (state_ == State::Stopped) { + if (state_ != State::Running) { return; } if (hardware_) { @@ -523,8 +528,11 @@ void IsochTransmitContext::StopImmediatelyForTxFault() noexcept { controlBlock_->statusWord.store(IsochTxQueueStatus::kDeadContext, std::memory_order_release); } } - state_ = State::Stopped; - ASFW_LOG(Isoch, "IT FATAL STOP: RUN cleared and interrupt masked"); + // Clearing RUN suppresses new work, but is not an ACTIVE-clear barrier. + // Keep the existing Stop() quiesce path mandatory before DMA bindings may + // be released or this context reconfigured. + state_ = State::Faulted; + ASFW_LOG(Isoch, "IT FATAL STOP: stop requested; awaiting normal context quiesce"); } void IsochTransmitContext::Poll() noexcept { @@ -562,10 +570,21 @@ void IsochTransmitContext::Poll() noexcept { // interrupt path died mid-session and the watchdog fed the // wire for 35 minutes of corrupt audio). Sustained interrupt // silence is a transport fault, not jitter. - auto access = hardware_ ? hardware_->TryBeginAccess() : Driver::HardwareAccessScope{}; - const uint32_t ctrl = access ? access.Read(static_cast( - DMAContextHelpers::IsoXmitContextControl(contextIndex_))) : 0; - const uint32_t latchedIntEvents = access ? access.Read(Register32::kIntEvent) : 0; + if (refillInProgress_.test_and_set(std::memory_order_acq_rel)) { + return; + } + if (state_ != State::Running) { + refillInProgress_.clear(std::memory_order_release); + return; + } + uint32_t ctrl = 0; + uint32_t latchedIntEvents = 0; + { + auto access = hardware_ ? hardware_->TryBeginAccess() : Driver::HardwareAccessScope{}; + ctrl = access ? access.Read(static_cast( + DMAContextHelpers::IsoXmitContextControl(contextIndex_))) : 0; + latchedIntEvents = access ? access.Read(Register32::kIntEvent) : 0; + } ASFW_LOG(Isoch, "IT FATAL: interrupt path silent across %u " "consecutive watchdog kicks; stopping context " @@ -573,7 +592,10 @@ void IsochTransmitContext::Poll() noexcept { irqSilentKickStreak_, ctrl, latchedIntEvents); + // The diagnostic scope must end before the stop helper takes + // its own revocable MMIO scope; HardwareAccessGate is not recursive. StopImmediatelyForTxFault(); + refillInProgress_.clear(std::memory_order_release); return; } if (!refillInProgress_.test_and_set(std::memory_order_acq_rel)) { diff --git a/ASFWDriver/Isoch/Transmit/IsochTransmitContext.hpp b/ASFWDriver/Isoch/Transmit/IsochTransmitContext.hpp index cc0378e08..b51f1d83c 100644 --- a/ASFWDriver/Isoch/Transmit/IsochTransmitContext.hpp +++ b/ASFWDriver/Isoch/Transmit/IsochTransmitContext.hpp @@ -39,7 +39,10 @@ enum class ITState { Unconfigured, Configured, Running, - Stopped + Stopped, + // RUN has been cleared after a transport fault, but DMA may still be + // ACTIVE. Only Stop() may declare this context quiesced and reusable. + Faulted }; /** @@ -94,6 +97,11 @@ class IsochTransmitContext final { void SetTxPreparationCallback(TxPreparationCallback callback) noexcept; State GetState() const noexcept { return state_; } + // Faulted suppresses new refill work while retaining the same DMA-release + // barrier as a running context. + [[nodiscard]] bool NeedsQuiesce() const noexcept { + return state_ == State::Running || state_ == State::Faulted; + } uint64_t PacketsAssembled() const noexcept { return packetsAssembled_; } @@ -103,6 +111,7 @@ class IsochTransmitContext final { private: void WakeHardware() noexcept; void DoRefillOnce(uint64_t eventHostTicks, bool publishTimingEvent) noexcept; + // Caller holds refillInProgress_, with no HardwareAccessScope outstanding. void StopImmediatelyForTxFault() noexcept; // ========================================================================== diff --git a/README.md b/README.md index e5fa5781e..2d5b9c67d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ What is real today: - AV/C FCP and CMP plumbing exists and is working on the main test rig. - Audio publication and experimental streaming paths exist in-tree. - Audio hardware tested by the maintainer: the Apogee Duet FireWire path, Terratec PHASE 88 Rack, and Focusrite Saffire Pro 24 DSP. Contributors have additionally verified the PreSonus StudioLive 16.0.2 (full duplex 16-in/16-out streaming) and the Midas Venice F32 (full duplex 32-in/32-out streaming). -- Experimental DICE support is now enabled in-tree for Focusrite Saffire Pro 14, Saffire Pro 24, Saffire Pro 24 DSP, PreSonus StudioLive 16.0.2, and the Midas Venice F32. +- Experimental DICE support is now enabled in-tree for Focusrite Saffire Pro 14, Saffire Pro 24, Saffire Pro 24 DSP, PreSonus StudioLive 16.0.2, PreSonus FireStudio Project (44.1/48 kHz), and the Midas Venice F32. +- FireStudio Project raw-PCM build 9 passed short silent start/stop checks across 44.1/48 kHz and 44.1 kHz S/PDIF tones with quiet gaps through a Roland VM-3100. The contributor also reported over two minutes of GarageBand playback without issues; its complete 411.647-second driver session, including any silence, ended with zero TX underruns and clean resource release. A Thunderbolt-adapter reconnect can leave the Mac root with its cycle master disabled; this remains unfixed, although a FireStudio power cycle restored operation in the controlled test. Earlier labelled-AM824 recording/input/analogue-output results have not been repeated with build 9. See the [capture provenance and validation limits](captures/presonus-firestudio-project/README.md). - **Multi-stream DICE now works.** The Midas Venice F32 runs two isochronous streams per direction (2×16 channels = 32×32 total duplex). - **Host-controlled sample-rate switching is implemented**, including 44.1 kHz alongside 48 kHz. The driver decodes the device's advertised clock capabilities and drives DICE `CLOCK_SELECT`, so a rate change in the host (e.g. Logic) reprograms the device live without a reconnect. Switching rates on a CoreAudio aggregate device whose clock master is the FireWire interface is supported. - **Per-channel names** (device nickname plus per-channel TX/RX labels) are read from DICE devices and surfaced to CoreAudio. @@ -66,6 +67,7 @@ Please test these currently enabled DICE devices: - Focusrite Saffire Pro 24 - Focusrite Saffire Pro 24 DSP - PreSonus StudioLive 16.0.2 (contributor-verified on one unit; broader validation welcome) +- PreSonus FireStudio Project (44.1/48 kHz; bounded raw-PCM S/PDIF and GarageBand playback validation; known adapter-reconnect timing issue; [validation limits](captures/presonus-firestudio-project/README.md)) - Midas Venice F32 (contributor-verified; broader validation welcome) StudioLive 16.4.2 / 24.4.2 / 32.4.2 owners can help too: the driver recognizes these mixers but does not enable audio yet because their stream layout has not been captured from hardware. If you own one, open an issue — a short register capture using the ASFW app is all that is needed to add support. @@ -114,6 +116,7 @@ Audio-device support in tree today: - Focusrite Saffire Pro 24 - Focusrite Saffire Pro 24 DSP - PreSonus StudioLive 16.0.2 +- PreSonus FireStudio Project (experimental, 44.1/48 kHz) - Midas Venice F32 (multi-stream DICE, 32-in/32-out) - Terratec PHASE 88 Rack - Weiss INT202 and INT203 (DICE 2-channel layout; wired up but **never run against real hardware**) @@ -127,6 +130,7 @@ Personally tested with working audio (hardware owned by the maintainer): Verified working by contributors on their own hardware: - PreSonus StudioLive 16.0.2 (full duplex 16-in/16-out) — [@klochowicz](https://github.com/klochowicz) +- PreSonus FireStudio Project (raw-PCM build 9: 44.1 kHz S/PDIF and GarageBand playback, short 44.1/48 kHz lifecycle/rate-switch checks; earlier labelled-AM824 builds: 48 kHz guitar inputs 1/2, stereo headphones and reported GarageBand recording; adapter-reconnect issue remains unfixed; [evidence and limits](captures/presonus-firestudio-project/README.md)) — [@seeward](https://github.com/seeward) - Midas Venice F32 (32×32 full duplex, 44.1 kHz and 48 kHz, live host-driven rate switching) — [@alicankaralar](https://github.com/alicankaralar) - Nikon Coolscan 9000 and Coolscan 4000 — SBP-2/SCSI film scanners, plug and play — [@mhellevang](https://github.com/mhellevang) - Panasonic MiniDV camcorder — DV capture and tape transport — [@hoffmabc](https://github.com/hoffmabc) diff --git a/captures/presonus-firestudio-project/README.md b/captures/presonus-firestudio-project/README.md new file mode 100644 index 000000000..a7e4d8838 --- /dev/null +++ b/captures/presonus-firestudio-project/README.md @@ -0,0 +1,142 @@ +# PreSonus FireStudio Project: capture provenance and validation limits + +The experimental profile targets Config ROM vendor/model `0x000a92:0x00000b` +(`FIRESTUDIO_PROJECT`) and accepts 44.1/48 kHz with a 48 kHz default. Its captured +layout is one stream per direction, each with 10 PCM channels and one MIDI slot. +Other rates remain outside this profile's supported scope. + +## Retained device report + +[`dice-report.txt`](dice-report.txt) is the unchanged latest read-only app export, +captured **2026-09-08 at 09:13:36 UTC**, renamed from +`ASFW_DICE_Report_44100_before.txt`. SHA-256: +`82e769836bff9fd9aa71c5999515fbfa78f455cebc80686a7d3b310fbd8ec00c`. + +- FireStudio Project GUID `0x000A920402D07FAC`; TCD2210 / DICE Mini. +- MacBookPro18,3 / Apple M1 Pro; macOS 26.6.2 build 25G83. +- Apple Thunderbolt 3-to-2 and Thunderbolt-to-FireWire adapters, then FW800-to-FW400. +- ASFW 0.3.0 local build 9, built on `458673e` with the then-uncommitted changes + now committed through `6b054aa`. The report retains its original source label. +- Internal clock locked at selected, nominal and measured 44,100 Hz. +- No owner, streaming disabled, both ISO channels disabled; 10 PCM + 1 MIDI in + each direction. The earlier 48 kHz report shows the same stream geometry. + +This export contains decoded registers, routing and a rounded mixer matrix; +it is not a raw Config ROM, coefficient or isochronous-packet capture. Its +embedded build timestamp is reproduced as reported. The report's TCAT product +number is derived from the GUID, so the archived Config ROM screenshot supplies +independent vendor/model evidence. Inactive rate tables do not establish support +for additional modes. + +## Current raw-PCM hardware validation + +On September 8, **build 9** used raw sign-extended 24-in-32 playback PCM and +standard zero-filled silence (`0x00000000`). The installed running driver was +verified against SHA-256 +`cccfb901044ab6fc31e951fc79914c6e9111a78abeb6223f2e1ab9fb2fa83443`. +Local build counters and generated version metadata are excluded from this PR; +this hash identifies the tested binary, not every rebuild of the same source. + +- Five three-second silent runs passed at 48, 44.1, 44.1, 48 and 44.1 kHz, in + that order, including idle rate changes. Client and driver checks showed + contiguous output timestamps, successful start/stop and zero TX underruns. +- The contributor confirmed digital clock lock on Roland VM-3100 DIGITAL IN A + (DIN-A), connected to the FireStudio's coaxial S/PDIF OUT at 44.1 kHz. +- A 24-second sequence alternated quiet 440/880 Hz tones at -36 dBFS with silent + gaps. It completed all 1,058,400 sequence frames over 2,069 callbacks with + contiguous output timestamps, 193,158 assembled packets, 31,516 transmit + interrupts, zero TX underruns and successful shutdown. No driver fault was + found in the reviewed run log. +- Asked whether both tones were clear and the gaps quiet, with no clicks, buzz, + distortion or dropouts, the contributor replied: **“Both clear; gaps quiet”.** +- After the device power-cycle recovery described below, the contributor selected + 44.1 kHz in Audio MIDI Setup without a snap-back and reported GarageBand + playback: **“played over 2 mins no issues”**. Core Audio remained at 44.1 kHz; + Roland lock was not separately reconfirmed for this session. +- The complete GarageBand driver session lasted **411.647 seconds** (about + 6 minutes 52 seconds), including any silence while the app held the stream. + Its final counters were 3,292,813 assembled packets, 548,004 transmit interrupts + and zero TX underruns. Stop completed in 44 ms, reached Idle and released TX + resources. No watchdog, fatal, async-timeout, failed start/stop or payload + anomaly appeared in the complete session log. This is not a claim of audible + music throughout the session; no reopen-after-quit test was performed. + +The repository owner reports that the original PreSonus vendor KEXT uses this +raw PCM format and zero-based silence. That remains attributed vendor-binary +evidence; the build 9 run establishes bounded audible playback and quiet gaps +on this unit, not an independent capture of the vendor's wire format. + +## Historical labelled-AM824 results + +The earlier builds below used labelled AM824 with PCM silence `0x40000000`. +Their recording, input and analogue-output results have not been repeated with +build 9; GarageBand playback has the separate current-build result above. + +- September 7, build 5, 48 kHz: two short silent start/stop checks, stereo + headphone playback and guitar inputs 1/2 passed. The contributor also reported + GarageBand recording/playback; no take or project metadata was exported. +- September 8, build 8: six three-second silent runs passed across 44.1/48 kHz, + including idle rate changes. Two 24-second tone runs completed with transport + progress and successful shutdown; the contributor reported clock lock and + audible S/PDIF test tones through the Roland at 44.1 kHz. + +## Known Thunderbolt-adapter reconnect issue — unfixed + +The same installed build 9 produced these controlled observations on September 8: + +| Physical sequence | Settled result | +| --- | --- | +| A: power on FireStudio with adapter present | FireStudio became root/IRM with remote cycles observed; three-second silent 48 kHz start/stop passed, zero TX underruns, TX resources released. | +| B: reconnect the complete Thunderbolt adapter while FireStudio stays powered | Mac remained root, local cycle master was disabled and no remote cycle continuity was observed. Preflight stopped without starting audio. | +| C: power-cycle FireStudio only, retaining the adapter | FireStudio again became root/IRM with remote cycles; matching silent 48 kHz start/stop passed, zero TX underruns, TX resources released. | + +B's state persisted through a diagnostics snapshot 106 seconds after Self-ID: +Client Only / Observe Only, no bus manager, cycle timer enabled, cycle-master +activation suppressed as `SuppressedNotBMOrFallbackIRM`. An earlier actual +stream start after adapter reconnect had failed with a watchdog/timestamp stall +and cleanup failures; B reproduced its timing state without repeating that start. +The A/B/C comparison points to missing bus timing when the Mac becomes root, +but does not establish the code fix or show that every reconnect fails. + +C created a **new TCAT protocol and discovery cache**. Its successful recovery +therefore does not verify preservation/recovery of an existing protocol's cache +across bus reset. The full power-cycle transition also logged a transient +boot/discovery timeout and a device-removal teardown IPC error; only the settled +stream trial was clean. The later successful user-selected 44.1 kHz and GarageBand +playback results above do not resolve the adapter-reconnect issue. No runtime +reconnect fix is included in this contribution. + +## Remaining validation limits + +The saved mixer sends the same mix to both S/PDIF channels, including playback +1/2. No router, mixer coefficient or flash changes were made. Independent digital +left/right routing, digital capture, bit-perfect transfer, input waveform quality, +MIDI, inputs 3–8 individually, physical Main/line jacks, other rates, sleep/wake, +long-run stability and calibrated latency remain unverified. The capture-derived +DBS of 11 was not observed on the wire. Mixer controls and headphone-mix management +remain outside this contribution. + +## Software checks + +The revised PR passed 247 targeted host tests and all 38 tests in the TCAT +executable under ThreadSanitizer without diagnostics. The installed raw-PCM +build 9 Release app/driver passed signature, entitlement and arm64e checks. +Host tests use DriverKit stubs and do not establish bus-reset recovery on hardware +or coverage of other DICE devices. + +## Archived evidence + +The initial report, screenshot, raw meter/probe logs, lifecycle excerpts and +longer summaries remain accessible at immutable commit +[`458673e2`](https://github.com/mrmidi/ASFireWire/tree/458673e250d3cea67470a9b350e15eaf98d15c8a/captures/presonus-firestudio-project). +In particular: + +- [Initial September 7 report](https://github.com/mrmidi/ASFireWire/blob/458673e250d3cea67470a9b350e15eaf98d15c8a/captures/presonus-firestudio-project/2026-09-07-dice-report.txt), + SHA-256 `2b0c0bd55b6bd55e322bf5ba6cf5b5ebc7262d1e3ee38f8394928631d2beba3e`. +- [Config ROM identity screenshot](https://github.com/mrmidi/ASFireWire/blob/458673e250d3cea67470a9b350e15eaf98d15c8a/captures/presonus-firestudio-project/2026-09-07-device-properties.png). +- [September 7 results and references](https://github.com/mrmidi/ASFireWire/blob/458673e250d3cea67470a9b350e15eaf98d15c8a/captures/presonus-firestudio-project/README.md#september-7-hardware-validation-build-5). +- [September 8 validation and earlier fault history](https://github.com/mrmidi/ASFireWire/blob/458673e250d3cea67470a9b350e15eaf98d15c8a/captures/presonus-firestudio-project/2026-09-08-validation.md). + +These archived notes describe their historical candidates, including superseded +publication/cache and labelled-silence behavior. This directory retains only the +latest device report and this scope summary. diff --git a/captures/presonus-firestudio-project/dice-report.txt b/captures/presonus-firestudio-project/dice-report.txt new file mode 100644 index 000000000..291b43bfe --- /dev/null +++ b/captures/presonus-firestudio-project/dice-report.txt @@ -0,0 +1,465 @@ +ASFW DICE DEVICE REPORT +======================= +Generated: 2026-09-08T09:13:36Z +Report app: 0.3.0 (build 9) +Driver: 0.3.0 (458673e on pr/presonus-firestudio-project, dirty) built 2026-09-08T08:40:12Z +Rate mode: low (32-48k) + +This is a read-only dump of the device's DICE register spaces. +Paste it whole into the issue; do not trim sections. + +IDENTITY +-------- +GUID: 0x000A920402D07FAC +Vendor: PreSonus +Model: FireStudio Project +Node / gen: 1 / 2 +TCAT vendor: 0x000A92 +TCAT category: 0x04 (standard DICE) +TCAT product: 0x00B +TCAT serial: 1081260 +ASIC: TCD2210 (DICE Mini) + +SECTION TABLES (offsets and sizes in quadlets) +----------------------------------------------- +general space @ 0xFFFFE0000000 + global offset=0x0000A (0x000028 B) size=0x0005A (360 B) + tx offset=0x00064 (0x000190 B) size=0x0008E (568 B) + rx offset=0x000F2 (0x0003C8 B) size=0x0011A (1128 B) + ext_sync offset=0x0020C (0x000830 B) size=0x00004 (16 B) + unused2 offset=0x00000 (0x000000 B) size=0x00000 (0 B) + +extension space @ 0xFFFFE0200000 + caps offset=0x00013 (0x00004C B) size=0x00004 (16 B) + cmd offset=0x00017 (0x00005C B) size=0x00002 (8 B) + mixer offset=0x00019 (0x000064 B) size=0x00121 (1156 B) + peak offset=0x0013A (0x0004E8 B) size=0x00080 (512 B) + router offset=0x001BA (0x0006E8 B) size=0x00081 (516 B) + stream_format offset=0x0023B (0x0008EC B) size=0x0010E (1080 B) + current_config offset=0x00349 (0x000D24 B) size=0x01800 (24576 B) + standalone offset=0x01B49 (0x006D24 B) size=0x00010 (64 B) + application offset=0x01B59 (0x006D64 B) size=0x00008 (32 B) + +GLOBAL +-------- +OWNER = 0xFFFF000000000000 (no owner) +NOTIFICATION = 0x00000040 EXT_STATUS +NICK_NAME = 'FireStudio Project' +CLOCK_SELECT = 0x0000010C source=12 (internal) rate=1 (44100) +ENABLE = 0x00000000 streaming=no +STATUS = 0x00000101 locked=yes nominal=44100 +EXTENDED_STATUS = 0x00000000 + locked : - + slipped : - + NOTE: slip bits are read-to-clear and fluctuate without notification; + this is an instantaneous sample, not a stable value. +SAMPLE_RATE = 44100 Hz (measured) +VERSION = 0x01000400 (1.0.4.0) +CLOCK_CAPABILITIES = 0x1102001F + rates : 32000 44100 48000 88200 96000 + sources : aes2 arx1 internal +CLOCK_SOURCE_NAMES: + 0 aes1 'AES12' + 1 aes2 'SPDIF' + 2 aes3 'AES56' + 3 aes4 'AES78' + 4 aes_any 'AES_ANY' + 5 adat 'ADAT' + 6 tdif 'ADAT_AUX' + 7 wc 'Word Clock' + 8 arx1 'Unused' + 9 arx2 'Unused' + 10 arx3 'Unused' + 11 arx4 'Unused' + 12 internal 'Internal' + +TX STREAMS (device transmits -> host capture) +---------------------------------------------- +NUMBER = 1 SIZE = 70 quadlets (280 bytes) + + [stream 0] + ISOCHRONOUS = -1 (disabled) + PCM channels = 10 + MIDI ports = 1 + SPEED = S400 + AC3_CAPS = + NAMES: + 0 'Mic 1' + 1 'Mic 2' + 2 'Mic 3' + 3 'Mic 4' + 4 'Mic 5' + 5 'Mic 6' + 6 'Mic 7' + 7 'Mic 8' + 8 'SPDIF L' + 9 'SPDIF R' + +RX STREAMS (device receives <- host playback) +---------------------------------------------- +NUMBER = 1 SIZE = 70 quadlets (280 bytes) + + [stream 0] + ISOCHRONOUS = -1 (disabled) + SEQ_START = 0 + PCM channels = 10 + MIDI ports = 1 + AC3_CAPS = + NAMES: + 0 'daw rt.1' + 1 'daw rt.2' + 2 'daw rt.3' + 3 'daw rt.4' + 4 'daw rt.5' + 5 'daw rt.6' + 6 'daw rt.7' + 7 'daw rt.8' + 8 'daw rt.9' + 9 'daw rt.10' + +EXT_SYNC +-------- +CLOCK_SOURCE = 12 (internal) +LOCKED = yes +RATE = 1 (44100) +ADAT_USER_DATA = no-data + +EAP CAPABILITIES +---------------- +Router : exposed=true readOnly=false storable=true maxEntries=128 +Mixer : exposed=true readOnly=false storable=true inDevId=2 outDevId=2 inputs=18 outputs=16 +General: dynamicStreamFormat=true storage=true peak=true + maxTxStreams=1 maxRxStreams=1 formatStorable=true + asic=TCD2210 (DICE Mini) + +EAP STREAM FORMAT STAGING AREA (written, then LOADed — not the live config) +---------------------------------------------------------------------------- + + +EAP CURRENT CONFIG — STREAM FORMATS PER RATE MODE +------------------------------------------------- +These describe the layout at EVERY rate mode. The plain TX/RX +registers above only describe the mode the device is in now. + +[low (32-48k)] + tx 0: pcm=10 midi=1 ac3=0x00000000 + names: Mic 1, Mic 2, Mic 3, Mic 4, Mic 5, Mic 6, Mic 7, Mic 8, SPDIF L, SPDIF R + rx 0: pcm=10 midi=1 ac3=0x00000000 + names: daw rt.1, daw rt.2, daw rt.3, daw rt.4, daw rt.5, daw rt.6, daw rt.7, daw rt.8, daw rt.9, daw rt.10 + +[middle (88.2-96k)] + tx 0: pcm=10 midi=1 ac3=0x00000000 + names: Mic 1, Mic 2, Mic 3, Mic 4, Mic 5, Mic 6, Mic 7, Mic 8, SPDIF L, SPDIF R + rx 0: pcm=10 midi=1 ac3=0x00000000 + names: daw rt.1, daw rt.2, daw rt.3, daw rt.4, daw rt.5, daw rt.6, daw rt.7, daw rt.8, daw rt.9, daw rt.10 + +[high (176.4-192k)] + tx 0: pcm=8 midi=1 ac3=0x000000FF + names: AES1, AES2, AES3, AES4, AES5, AES6, AES7, AES8 + rx 0: pcm=8 midi=1 ac3=0x000000FF + names: AES1, AES2, AES3, AES4, AES5, AES6, AES7, AES8 + +EAP STANDALONE (behaviour with no host attached) +------------------------------------------------- +clockSource = 0 (aes1) +aesHighRate = false +adatMode = Normal +wordClockMode = Normal rate=1/1 +internalRate = 32000 + +EAP ROUTER STAGING AREA (written, then LOADed — not the live config) +--------------------------------------------------------------------- + + +EAP CURRENT ROUTER — low (32-48k) (82 entries) [ACTIVE] +--------------------------------------------------------- + 0 Avs0:0 <- Ins0:0 + 1 Avs0:1 <- Ins0:1 + 2 Avs0:2 <- Ins0:2 + 3 Avs0:3 <- Ins0:3 + 4 Avs0:4 <- Ins0:4 + 5 Avs0:5 <- Ins0:5 + 6 Avs0:6 <- Ins0:6 + 7 Avs0:7 <- Ins0:7 + 8 Avs0:8 <- AES:2 + 9 Avs0:9 <- AES:3 + 10 AES:0 <- AES:0 + 11 AES:0 <- AES:0 + 12 AES:0 <- AES:0 + 13 AES:0 <- AES:0 + 14 AES:0 <- AES:0 + 15 AES:0 <- AES:0 + 16 AES:0 <- AES:0 + 17 AES:0 <- AES:0 + 18 AES:0 <- AES:0 + 19 AES:0 <- AES:0 + 20 AES:0 <- AES:0 + 21 AES:0 <- AES:0 + 22 AES:0 <- AES:0 + 23 AES:0 <- AES:0 + 24 AES:0 <- AES:0 + 25 AES:0 <- AES:0 + 26 AES:0 <- AES:0 + 27 AES:0 <- AES:0 + 28 AES:0 <- AES:0 + 29 AES:0 <- AES:0 + 30 AES:0 <- AES:0 + 31 AES:0 <- AES:0 + 32 MixerTx0:0 <- Ins0:0 + 33 MixerTx0:1 <- Ins0:1 + 34 MixerTx0:2 <- Ins0:2 + 35 MixerTx0:3 <- Ins0:3 + 36 MixerTx0:4 <- Ins0:4 + 37 MixerTx0:5 <- Ins0:5 + 38 MixerTx0:6 <- Ins0:6 + 39 MixerTx0:7 <- Ins0:7 + 40 MixerTx0:8 <- AES:2 + 41 MixerTx0:9 <- AES:3 + 42 MixerTx0:10 <- Avs0:0 + 43 MixerTx0:11 <- Avs0:1 + 44 MixerTx0:12 <- Avs0:2 + 45 MixerTx0:13 <- Avs0:3 + 46 MixerTx0:14 <- Avs0:4 + 47 MixerTx0:15 <- Avs0:5 + 48 MixerTx1:0 <- Avs0:6 + 49 MixerTx1:1 <- Avs0:7 + 50 AES:0 <- AES:0 + 51 AES:0 <- AES:0 + 52 AES:0 <- AES:0 + 53 AES:0 <- AES:0 + 54 AES:0 <- AES:0 + 55 AES:0 <- AES:0 + 56 AES:0 <- AES:0 + 57 AES:0 <- AES:0 + 58 AES:0 <- AES:0 + 59 AES:0 <- AES:0 + 60 AES:0 <- AES:0 + 61 AES:0 <- AES:0 + 62 AES:0 <- AES:0 + 63 AES:0 <- AES:0 + 64 Ins0:0 <- Mixer:0 + 65 Ins0:1 <- Mixer:1 + 66 Ins0:2 <- Avs0:2 + 67 Ins0:3 <- Avs0:3 + 68 Ins0:4 <- Avs0:4 + 69 Ins0:5 <- Avs0:5 + 70 Ins0:6 <- Avs0:6 + 71 Ins0:7 <- Avs0:7 + 72 AES:2 <- Mixer:8 + 73 AES:3 <- Mixer:9 + 74 AES:0 <- AES:0 + 75 AES:0 <- AES:0 + 76 AES:0 <- AES:0 + 77 AES:0 <- AES:0 + 78 AES:0 <- AES:0 + 79 AES:0 <- AES:0 + 80 AES:0 <- AES:0 + 81 AES:0 <- AES:0 + +EAP CURRENT ROUTER — middle (88.2-96k) (82 entries) +---------------------------------------------------- + 0 Avs0:0 <- Ins0:0 + 1 Avs0:1 <- Ins0:1 + 2 Avs0:2 <- Ins0:2 + 3 Avs0:3 <- Ins0:3 + 4 Avs0:4 <- Ins0:4 + 5 Avs0:5 <- Ins0:5 + 6 Avs0:6 <- Ins0:6 + 7 Avs0:7 <- Ins0:7 + 8 Avs0:8 <- AES:2 + 9 Avs0:9 <- AES:3 + 10 AES:0 <- AES:0 + 11 AES:0 <- AES:0 + 12 AES:0 <- AES:0 + 13 AES:0 <- AES:0 + 14 AES:0 <- AES:0 + 15 AES:0 <- AES:0 + 16 AES:0 <- AES:0 + 17 AES:0 <- AES:0 + 18 AES:0 <- AES:0 + 19 AES:0 <- AES:0 + 20 AES:0 <- AES:0 + 21 AES:0 <- AES:0 + 22 AES:0 <- AES:0 + 23 AES:0 <- AES:0 + 24 AES:0 <- AES:0 + 25 AES:0 <- AES:0 + 26 AES:0 <- AES:0 + 27 AES:0 <- AES:0 + 28 AES:0 <- AES:0 + 29 AES:0 <- AES:0 + 30 AES:0 <- AES:0 + 31 AES:0 <- AES:0 + 32 MixerTx0:0 <- Ins0:0 + 33 MixerTx0:1 <- Ins0:1 + 34 MixerTx0:2 <- Ins0:2 + 35 MixerTx0:3 <- Ins0:3 + 36 MixerTx0:4 <- Ins0:4 + 37 MixerTx0:5 <- Ins0:5 + 38 MixerTx0:6 <- Ins0:6 + 39 MixerTx0:7 <- Ins0:7 + 40 MixerTx0:8 <- AES:2 + 41 MixerTx0:9 <- AES:3 + 42 MixerTx0:10 <- Avs0:0 + 43 MixerTx0:11 <- Avs0:1 + 44 MixerTx0:12 <- Avs0:2 + 45 MixerTx0:13 <- Avs0:3 + 46 MixerTx0:14 <- Avs0:4 + 47 MixerTx0:15 <- Avs0:5 + 48 MixerTx1:0 <- Avs0:6 + 49 MixerTx1:1 <- Avs0:7 + 50 AES:0 <- AES:0 + 51 AES:0 <- AES:0 + 52 AES:0 <- AES:0 + 53 AES:0 <- AES:0 + 54 AES:0 <- AES:0 + 55 AES:0 <- AES:0 + 56 AES:0 <- AES:0 + 57 AES:0 <- AES:0 + 58 AES:0 <- AES:0 + 59 AES:0 <- AES:0 + 60 AES:0 <- AES:0 + 61 AES:0 <- AES:0 + 62 AES:0 <- AES:0 + 63 AES:0 <- AES:0 + 64 Ins0:0 <- Mixer:0 + 65 Ins0:1 <- Mixer:1 + 66 Ins0:2 <- Avs0:2 + 67 Ins0:3 <- Avs0:3 + 68 Ins0:4 <- Avs0:4 + 69 Ins0:5 <- Avs0:5 + 70 Ins0:6 <- Avs0:6 + 71 Ins0:7 <- Avs0:7 + 72 AES:2 <- Mixer:8 + 73 AES:3 <- Mixer:9 + 74 AES:0 <- AES:0 + 75 AES:0 <- AES:0 + 76 AES:0 <- AES:0 + 77 AES:0 <- AES:0 + 78 AES:0 <- AES:0 + 79 AES:0 <- AES:0 + 80 AES:0 <- AES:0 + 81 AES:0 <- AES:0 + +EAP CURRENT ROUTER — high (176.4-192k) (2 entries) +--------------------------------------------------- + 0 ADAT:2 <- Avs1:6 + 1 ADAT:3 <- Avs1:7 + +EAP PEAK (82 of 128 entries carry data, instantaneous) +------------------------------------------------------- +Peak is 12-bit: full scale = 4095. dBFS = 20*log10(peak/4095). +Reading it as 16-bit would understate every level by 24 dB. + 0 Avs0:0 <- Ins0:0 peak= 148 + 1 Avs0:1 <- Ins0:1 peak= 2671 + 2 Avs0:2 <- Ins0:2 peak= 1796 + 3 Avs0:3 <- Ins0:3 peak= 3713 + 4 Avs0:4 <- Ins0:4 peak= 1026 + 5 Avs0:5 <- Ins0:5 peak= 1542 + 6 Avs0:6 <- Ins0:6 peak= 3077 + 7 Avs0:7 <- Ins0:7 peak= 111 + 8 Avs0:8 <- AES:2 peak= 0 + 9 Avs0:9 <- AES:3 peak= 0 + 10 AES:0 <- AES:0 peak= 0 + 11 AES:0 <- AES:0 peak= 0 + 12 AES:0 <- AES:0 peak= 0 + 13 AES:0 <- AES:0 peak= 0 + 14 AES:0 <- AES:0 peak= 0 + 15 AES:0 <- AES:0 peak= 0 + 16 AES:0 <- AES:0 peak= 0 + 17 AES:0 <- AES:0 peak= 0 + 18 AES:0 <- AES:0 peak= 0 + 19 AES:0 <- AES:0 peak= 0 + 20 AES:0 <- AES:0 peak= 0 + 21 AES:0 <- AES:0 peak= 0 + 22 AES:0 <- AES:0 peak= 0 + 23 AES:0 <- AES:0 peak= 0 + 24 AES:0 <- AES:0 peak= 0 + 25 AES:0 <- AES:0 peak= 0 + 26 AES:0 <- AES:0 peak= 0 + 27 AES:0 <- AES:0 peak= 0 + 28 AES:0 <- AES:0 peak= 0 + 29 AES:0 <- AES:0 peak= 0 + 30 AES:0 <- AES:0 peak= 0 + 31 AES:0 <- AES:0 peak= 0 + 32 MixerTx0:0 <- Ins0:0 peak= 0 + 33 MixerTx0:1 <- Ins0:1 peak= 1 + 34 MixerTx0:2 <- Ins0:2 peak= 0 + 35 MixerTx0:3 <- Ins0:3 peak= 1 + 36 MixerTx0:4 <- Ins0:4 peak= 1 + 37 MixerTx0:5 <- Ins0:5 peak= 0 + 38 MixerTx0:6 <- Ins0:6 peak= 0 + 39 MixerTx0:7 <- Ins0:7 peak= 1 + 40 MixerTx0:8 <- AES:2 peak= 0 + 41 MixerTx0:9 <- AES:3 peak= 0 + 42 MixerTx0:10 <- Avs0:0 peak= 0 + 43 MixerTx0:11 <- Avs0:1 peak= 0 + 44 MixerTx0:12 <- Avs0:2 peak= 0 + 45 MixerTx0:13 <- Avs0:3 peak= 0 + 46 MixerTx0:14 <- Avs0:4 peak= 0 + 47 MixerTx0:15 <- Avs0:5 peak= 0 + 48 MixerTx1:0 <- Avs0:6 peak= 0 + 49 MixerTx1:1 <- Avs0:7 peak= 0 + 50 AES:0 <- AES:0 peak= 0 + 51 AES:0 <- AES:0 peak= 0 + 52 AES:0 <- AES:0 peak= 0 + 53 AES:0 <- AES:0 peak= 0 + 54 AES:0 <- AES:0 peak= 0 + 55 AES:0 <- AES:0 peak= 0 + 56 AES:0 <- AES:0 peak= 0 + 57 AES:0 <- AES:0 peak= 0 + 58 AES:0 <- AES:0 peak= 0 + 59 AES:0 <- AES:0 peak= 0 + 60 AES:0 <- AES:0 peak= 0 + 61 AES:0 <- AES:0 peak= 0 + 62 AES:0 <- AES:0 peak= 0 + 63 AES:0 <- AES:0 peak= 0 + 64 Ins0:0 <- Mixer:0 peak= 5 + 65 Ins0:1 <- Mixer:1 peak= 5 + 66 Ins0:2 <- Avs0:2 peak= 0 + 67 Ins0:3 <- Avs0:3 peak= 0 + 68 Ins0:4 <- Avs0:4 peak= 0 + 69 Ins0:5 <- Avs0:5 peak= 0 + 70 Ins0:6 <- Avs0:6 peak= 0 + 71 Ins0:7 <- Avs0:7 peak= 0 + 72 AES:2 <- Mixer:8 peak= 5 + 73 AES:3 <- Mixer:9 peak= 5 + 74 AES:0 <- AES:0 peak= 0 + 75 AES:0 <- AES:0 peak= 0 + 76 AES:0 <- AES:0 peak= 0 + 77 AES:0 <- AES:0 peak= 0 + 78 AES:0 <- AES:0 peak= 0 + 79 AES:0 <- AES:0 peak= 0 + 80 AES:0 <- AES:0 peak= 0 + 81 AES:0 <- AES:0 peak= 0 + (46 further slots are beyond the active router and hold uninitialised data — omitted) + +EAP MIXER (16 outputs x 18 inputs) +----------------------------------- +Gains are dB relative to unity (0.0 dB); mute is a zero coefficient. + Values are 2:14 fixed-point internally and rounded to 0.1 dB here. + Maximum gain is +12.0 dB. +saturation = 0x000003FF (bit n set = output n clipped) + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 + 0 -3.0 -12.7 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -9.9 mute -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 1 -3.0 -12.7 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 mute -10.2 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 2 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 3 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 4 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 5 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 6 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 7 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 8 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 9 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 -3.0 + 10 mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute + 11 mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute + 12 mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute + 13 mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute + 14 mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute + 15 mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute mute + +NOTES +-------- +- TX section is allocated for 2 stream block(s) but NUMBER reports 1. +- RX section is allocated for 4 stream block(s) but NUMBER reports 1. +- EAP application section is vendor-specific (32 bytes at offset 0x006D64) and is not decoded. diff --git a/project.yml b/project.yml index d743c64af..0ef1e682b 100644 --- a/project.yml +++ b/project.yml @@ -109,6 +109,11 @@ targets: # so generation gives the same pbxproj whether or not it exists # (fresh checkouts / CI don't have it). - "Version/DriverVersion.hpp" + # Listed explicitly below so the captured experimental profile is + # included once and remains visible in the generated build graph. + - "Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.*" + - path: ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp + - path: ASFWDriver/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.cpp preBuildScripts: - name: Bump Version shell: /bin/sh diff --git a/tests/audio/AmdtpDirectTxTests.cpp b/tests/audio/AmdtpDirectTxTests.cpp index 13af7716d..206c05dee 100644 --- a/tests/audio/AmdtpDirectTxTests.cpp +++ b/tests/audio/AmdtpDirectTxTests.cpp @@ -58,6 +58,227 @@ AmdtpStreamConfig BlockingStereoConfig() { return config; } +class AmdtpPacketDefaultsTests : public testing::TestWithParam { +protected: + void SetUp() override { + config_.pcmChannels = 10; + config_.midiSlots = 1; + config_.dbs = 11; + policy_.hostToDevicePcmEncoding = GetParam(); + ASSERT_TRUE(timeline_.AttachSlots(timelineSlots_.data(), timelineSlots_.size())); + packetizer_.BindTimeline(&timeline_); + ASSERT_TRUE(packetizer_.Configure(config_, policy_)); + bytes_.fill(0xA5); + } + + bool PrepareData(uint32_t packetIndex) { + AmdtpTimingState timing{}; + timing.txClockValid = true; + timing.disposition = AmdtpPacketDisposition::Data; + timing.nextDataSyt = 0x1234; + timing.replayValid = true; + timing.replayDataBlocks = 8; + return packetizer_.PrepareNextPacket( + {packetIndex, bytes_.data(), static_cast(bytes_.size())}, + timing, packet_); + } + + void ExpectSilentPayload() { + ASSERT_TRUE(packet_.isData); + ASSERT_EQ(packet_.byteCount, 360U); + ASSERT_EQ(packet_.framesInPacket, 8U); + ASSERT_EQ(packet_.dbs, 11U); + for (uint32_t frame = 0; frame < 8; ++frame) { + for (uint32_t channel = 0; channel < 11; ++channel) { + SCOPED_TRACE(testing::Message() << "frame=" << frame << " channel=" << channel); + const uint32_t offset = 8 + (frame * 11 + channel) * 4; + EXPECT_EQ(bytes_[offset], channel < 10 ? 0 : 0x80); + EXPECT_EQ(bytes_[offset + 1], 0); + EXPECT_EQ(bytes_[offset + 2], 0); + EXPECT_EQ(bytes_[offset + 3], 0); + } + } + for (uint32_t offset = packet_.byteCount; offset < bytes_.size(); ++offset) { + EXPECT_EQ(bytes_[offset], 0xA5) << "beyond packet at " << offset; + } + } + + AmdtpStreamConfig config_{}; + AmdtpTxPolicy policy_{}; + AmdtpPacketTimeline timeline_{}; + std::array timelineSlots_{}; + AmdtpTxPacketizer packetizer_{}; + std::array bytes_{}; + PreparedTxPacket packet_{}; +}; + +TEST_P(AmdtpPacketDefaultsTests, UnwrittenPcmIsZeroedWithoutEncodingTraversal) { + ASSERT_TRUE(PrepareData(0)); + ExpectSilentPayload(); + EXPECT_EQ(bytes_[1], 11); // Constant DBS includes the MIDI slot. + EXPECT_EQ(bytes_[4], 0x90); + EXPECT_EQ(bytes_[5], 0x02); + EXPECT_EQ(bytes_[6], 0x12); + EXPECT_EQ(bytes_[7], 0x34); +} + +TEST_P(AmdtpPacketDefaultsTests, ReusedDataPacketRestoresSilenceAfterHostAudio) { + ASSERT_TRUE(PrepareData(0)); + AmdtpPayloadWriter writer{}; + writer.Configure(config_, policy_); + writer.BindTimeline(&timeline_); + std::array hostFrame{0.5f, -0.5f}; + writer.WriteFloat32Interleaved({hostFrame.data(), 0, 1, 1, 10}, 0); + + // Golden wire bytes: both PCM polarities retain the selected encoding. + std::array expected{}; + switch (GetParam()) { + case PcmSlotEncoding::Am824MBLA: + expected = {0x40, 0x40, 0, 0, 0x40, 0xC0, 0, 0}; + break; + case PcmSlotEncoding::RawSigned24In32BE: + expected = {0, 0x40, 0, 0, 0xFF, 0xC0, 0, 0}; + break; + case PcmSlotEncoding::RawSigned24In32LE: + expected = {0, 0, 0x40, 0, 0, 0, 0xC0, 0xFF}; + break; + } + for (uint32_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(bytes_[8 + i], expected[i]) << "PCM byte " << i; + } + EXPECT_EQ(bytes_[8 + 10 * 4], 0x80); // PCM writes leave MIDI alone. + + // A subsequent packet in the same memory must not replay the old audio + // when the host writer misses its opportunity to fill the new packet. + ASSERT_TRUE(PrepareData(1)); + EXPECT_EQ(packet_.dbc, 8); + EXPECT_EQ(packet_.firstAudioFrame, 8U); + ExpectSilentPayload(); +} + +TEST_P(AmdtpPacketDefaultsTests, NoDataRemainsHeaderOnlyAndDoesNotTouchPayload) { + AmdtpTimingState timing{}; + timing.disposition = AmdtpPacketDisposition::NoData; + ASSERT_TRUE(packetizer_.PrepareNextPacket( + {0, bytes_.data(), static_cast(bytes_.size())}, timing, packet_)); + EXPECT_FALSE(packet_.isData); + EXPECT_EQ(packet_.byteCount, 8U); + EXPECT_EQ(packet_.framesInPacket, 0U); + EXPECT_EQ(packet_.dbc, 0); + EXPECT_EQ(bytes_[1], 11); + EXPECT_EQ(bytes_[4], 0x90); + EXPECT_EQ(bytes_[5], 0xFF); + EXPECT_EQ(bytes_[6], 0xFF); + EXPECT_EQ(bytes_[7], 0xFF); + for (uint32_t offset = 8; offset < bytes_.size(); ++offset) { + EXPECT_EQ(bytes_[offset], 0xA5) << "payload byte " << offset; + } +} + +INSTANTIATE_TEST_SUITE_P( + PcmEncodings, AmdtpPacketDefaultsTests, + testing::Values(PcmSlotEncoding::Am824MBLA, + PcmSlotEncoding::RawSigned24In32BE, + PcmSlotEncoding::RawSigned24In32LE)); + +class AmdtpProjectRateTests : public testing::TestWithParam {}; + +TEST_P(AmdtpProjectRateTests, TenDistinctPcmLanesPreserveMidiAndRateAcrossPacketReuse) { + // Project's measured geometry is 10 PCM + 1 MIDI. Linux amdtp-stream.c + // uses an eight-frame SYT interval at both 44.1 and 48 kHz; dice-stream.c + // starts blocking duplex with sequence replay. Exercise that replay path. + AmdtpStreamConfig config{}; + config.sampleRate = GetParam(); + config.pcmChannels = 10; + config.midiSlots = 1; + config.dbs = 11; + AmdtpTxPolicy policy{}; + policy.hostToDevicePcmEncoding = PcmSlotEncoding::RawSigned24In32BE; + AmdtpPacketTimeline timeline{}; + std::array slots{}; + ASSERT_TRUE(timeline.AttachSlots(slots.data(), slots.size())); + AmdtpTxPacketizer packetizer{}; + packetizer.BindTimeline(&timeline); + ASSERT_TRUE(packetizer.Configure(config, policy)); + std::array bytes{}; + bytes.fill(0xA5); + AmdtpTimingState timing{}; + timing.txClockValid = true; + timing.disposition = AmdtpPacketDisposition::Data; + timing.replayValid = true; + timing.replayDataBlocks = 8; + timing.nextDataSyt = 0x1234; + PreparedTxPacket packet{}; + ASSERT_TRUE(packetizer.PrepareNextPacket({0, bytes.data(), bytes.size()}, timing, packet)); + ASSERT_TRUE(packet.isData); + ASSERT_EQ(packet.byteCount, 360U); + EXPECT_EQ(bytes[1], 11U); + EXPECT_EQ(bytes[5], GetParam() == 44100 ? 0x01 : 0x02); + + constexpr std::array samples{ + 0.03125f, -0.03125f, 0.0625f, -0.0625f, 0.125f, + -0.125f, 0.25f, -0.25f, 0.5f, -0.5f}; + constexpr std::array signed24{ + 0x040000, -0x040000, 0x080000, -0x080000, 0x100000, + -0x100000, 0x200000, -0x200000, 0x400000, -0x400000}; + std::array host{}; + for (uint32_t frame = 0; frame < 8; ++frame) { + for (uint32_t channel = 0; channel < 10; ++channel) { + host[frame * 10 + channel] = samples[channel] * (frame % 2 ? -1.0f : 1.0f); + } + } + AmdtpPayloadWriter writer{}; + writer.Configure(config, policy); + writer.BindTimeline(&timeline); + writer.WriteFloat32Interleaved({host.data(), 0, 8, 8, 10}, 0); + for (uint32_t frame = 0; frame < 8; ++frame) { + for (uint32_t channel = 0; channel < 11; ++channel) { + SCOPED_TRACE(testing::Message() << "frame=" << frame << " channel=" << channel); + uint32_t expected = 0x80000000U; + if (channel < 10) { + const int32_t sample = signed24[channel] * (frame % 2 ? -1 : 1); + // Raw playback keeps the signed sample's sign extension in + // the high byte instead of inserting an AM824 PCM label. + expected = static_cast(sample); + } + const uint32_t offset = 8 + (frame * 11 + channel) * 4; + for (uint32_t byte = 0; byte < 4; ++byte) { + EXPECT_EQ(bytes[offset + byte], (expected >> (24 - 8 * byte)) & 0xFFU); + } + } + } + // Header-only NO-DATA must not consume audio frames or leak the old payload. + bytes.fill(0xA5); + timing.disposition = AmdtpPacketDisposition::NoData; + timing.replayDataBlocks = 0; + ASSERT_TRUE(packetizer.PrepareNextPacket({1, bytes.data(), bytes.size()}, timing, packet)); + ASSERT_FALSE(packet.isData); + EXPECT_EQ(packet.byteCount, 8U); + EXPECT_EQ(packet.dbc, 8U); + EXPECT_EQ(bytes[5], 0xFFU); + for (uint32_t offset = 8; offset < bytes.size(); ++offset) EXPECT_EQ(bytes[offset], 0xA5U); + + timing.disposition = AmdtpPacketDisposition::Data; + timing.replayDataBlocks = 8; + ASSERT_TRUE(packetizer.PrepareNextPacket({2, bytes.data(), bytes.size()}, timing, packet)); + ASSERT_TRUE(packet.isData); + EXPECT_EQ(packet.dbc, 8U); + EXPECT_EQ(packet.firstAudioFrame, 8U); + EXPECT_EQ(bytes[5], GetParam() == 44100 ? 0x01 : 0x02); + for (uint32_t frame = 0; frame < 8; ++frame) { + for (uint32_t channel = 0; channel < 11; ++channel) { + const uint32_t offset = 8 + (frame * 11 + channel) * 4; + EXPECT_EQ(bytes[offset], channel < 10 ? 0U : 0x80U); + EXPECT_EQ(bytes[offset + 1], 0U); + EXPECT_EQ(bytes[offset + 2], 0U); + EXPECT_EQ(bytes[offset + 3], 0U); + } + } + for (uint32_t offset = packet.byteCount; offset < bytes.size(); ++offset) EXPECT_EQ(bytes[offset], 0xA5U); +} + +INSTANTIATE_TEST_SUITE_P(FireStudioRates, AmdtpProjectRateTests, testing::Values(44100U, 48000U)); + TEST(AmdtpDirectTxTests, Int32EncodingUsesHighSigned24Bits) { EXPECT_EQ(PcmSlotCodec::EncodeInt32( INT32_MAX, PcmSlotEncoding::RawSigned24In32BE), diff --git a/tests/audio/AudioProfileRegistryTests.cpp b/tests/audio/AudioProfileRegistryTests.cpp index 884971f34..542b7ae66 100644 --- a/tests/audio/AudioProfileRegistryTests.cpp +++ b/tests/audio/AudioProfileRegistryTests.cpp @@ -167,6 +167,55 @@ TEST(AudioProfileRegistryTests, RecognizesPreSonusStudioLive1602DiceProfile) { EXPECT_EQ(profile->family, AudioProtocolFamily::DICE); } +TEST(AudioProfileRegistryTests, RecognizesCapturedFireStudioProjectAsDice) { + // Config-ROM identity confirmed on hardware on 2026-09-07. The matching + // DICE report describes the stream geometry; framing remains experimental. + const DeviceProfileQuery query{.guid = 0x000A920402D07FACULL, + .vendorId = 0x000A92, + .modelId = 0x00000B}; + const auto identity = AudioProfileRegistry::LookupIdentity(query); + ASSERT_TRUE(identity.has_value()); + EXPECT_EQ(identity->vendorId, ids::kPreSonusVendorId); + EXPECT_EQ(identity->modelId, ids::kFireStudioProjectModelId); + EXPECT_STREQ(identity->vendorName, "PreSonus"); + EXPECT_STREQ(identity->modelName, "FireStudio Project"); + EXPECT_EQ(identity->source, ASFW::DeviceProfiles::MatchSource::VendorModel); + const auto audio = AudioProfileRegistry::LookupBestAudioProfile(query); + ASSERT_TRUE(audio.has_value()); + EXPECT_EQ(audio->family, AudioProtocolFamily::DICE); + EXPECT_EQ(audio->mode, AudioIntegrationMode::kHardcodedNub); + + // Recognition is by vendor/model, not by this unit's serial number. + EXPECT_TRUE(AudioProfileRegistry::LookupIdentity( + ByVendorModel(ids::kPreSonusVendorId, ids::kFireStudioProjectModelId)) + .has_value()); +} + +TEST(AudioProfileRegistryTests, DoesNotInferFireStudioProjectFromGuid) { + const DeviceProfileQuery guidOnly{.guid = 0x000A920402D07FACULL}; + EXPECT_FALSE(AudioProfileRegistry::LookupIdentity(guidOnly).has_value()); + EXPECT_FALSE(AudioProfileRegistry::LookupBestAudioProfile(guidOnly).has_value()); + + const DeviceProfileQuery missingModel{.guid = guidOnly.guid, + .vendorId = ids::kPreSonusVendorId}; + EXPECT_FALSE(AudioProfileRegistry::LookupIdentity(missingModel).has_value()); + EXPECT_FALSE(AudioProfileRegistry::LookupBestAudioProfile(missingModel).has_value()); +} + +TEST(AudioProfileRegistryTests, FireStudioProjectRequiresExactVendorAndModel) { + for (const DeviceProfileQuery query : { + DeviceProfileQuery{.guid = 0x000A920402D07FACULL, + .vendorId = 0x00ABCDEF, + .modelId = ids::kFireStudioProjectModelId}, + DeviceProfileQuery{.guid = 0x000A920402D07FACULL, + .vendorId = ids::kPreSonusVendorId, + .modelId = 0x000008}}) { + EXPECT_FALSE(AudioProfileRegistry::LookupIdentity(query).has_value()); + EXPECT_FALSE(AudioProfileRegistry::LookupBestAudioProfile(query).has_value()); + EXPECT_EQ(ModeFor(query.vendorId, query.modelId), AudioIntegrationMode::kNone); + } +} + TEST(AudioProfileRegistryTests, RejectsOtherPreSonusModels) { // PreSonus BeBoB devices (FireBox/FP10/Inspire) and the DICE FireStudio share // the OUI but must not resolve to the StudioLive profile. diff --git a/tests/audio/CMakeLists.txt b/tests/audio/CMakeLists.txt index 5698ac883..a3c048a5a 100644 --- a/tests/audio/CMakeLists.txt +++ b/tests/audio/CMakeLists.txt @@ -191,6 +191,7 @@ add_audio_test(DiceProfileTests "${ASFW_DRIVER_DIR}/Audio/DriverKit/Config/DICE/Isoch/Profiles/FocusriteSaffireProfile.cpp" "${ASFW_DRIVER_DIR}/Audio/DriverKit/Config/DICE/Isoch/Profiles/MidasVeniceProfile.cpp" "${ASFW_DRIVER_DIR}/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusStudioLiveProfile.cpp" + "${ASFW_DRIVER_DIR}/Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.cpp" "${ASFW_DRIVER_DIR}/Audio/DriverKit/Config/DICE/Isoch/Profiles/AlesisMultiMixProfile.cpp" "${ASFW_DRIVER_DIR}/Audio/DriverKit/Config/DICE/Isoch/Profiles/WeissIntProfile.cpp" ) diff --git a/tests/audio/DiceProfileTests.cpp b/tests/audio/DiceProfileTests.cpp index 88c6de855..1d81a1cc9 100644 --- a/tests/audio/DiceProfileTests.cpp +++ b/tests/audio/DiceProfileTests.cpp @@ -15,6 +15,7 @@ #include "Audio/DriverKit/Config/DICE/Isoch/Profiles/GenericDiceProfile.hpp" #include "Audio/DriverKit/Config/DICE/Isoch/Profiles/MidasVeniceProfile.hpp" #include "Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusStudioLiveProfile.hpp" +#include "Audio/DriverKit/Config/DICE/Isoch/Profiles/PreSonusFireStudioProjectProfile.hpp" #include "Audio/DriverKit/Config/DICE/Isoch/Profiles/WeissIntProfile.hpp" #include "Audio/DriverKit/Config/AVC/ApogeeDuetProfile.hpp" #include "Audio/DriverKit/Config/AVC/Phase88Profile.hpp" @@ -234,6 +235,58 @@ TEST(DiceProfileTests, PreSonusStudioLiveSafetyOffsetsAndLatencies) { EXPECT_EQ(profile->RxReportedLatencyFrames(48000.0), 29); } +TEST(DiceProfileTests, FireStudioProjectUsesCapturedLowRateGeometryAndDefaultsTo48k) { + // Captured active TX/RX and low/middle EAP descriptors, 2026-09-07: + // 10 PCM, one MIDI port, one stream per direction. Extra allocated + // descriptor blocks are not extra streams. DBS is standard-AM824 derived. + const auto* base = AudioProfileRegistry::FindProfile( + 0x000a92, 0x00000b, 0x000A920402D07FACULL); + ASSERT_NE(base, nullptr); + EXPECT_STREQ(base->Name(), "PreSonus FireStudio Project (DICE)"); + EXPECT_EQ(base->SupportedSampleRates(), (std::vector{44100, 48000})); + EXPECT_EQ(base->TxChannelCount(), 10U); + EXPECT_EQ(base->RxChannelCount(), 10U); + EXPECT_EQ(base->TxDbs(), 11U); + EXPECT_EQ(base->RxDbs(), 11U); + EXPECT_EQ(base->TxWireFormat(), ASFW::Encoding::AudioWireFormat::kRawPcm24In32); + EXPECT_EQ(base->RxWireFormat(), ASFW::Encoding::AudioWireFormat::kAM824); + + const auto* profile = static_cast(base); + EXPECT_EQ(profile->TxStreamCount(), 1U); + EXPECT_EQ(profile->RxStreamCount(), 1U); + AudioStreamConfig tx{}, rx{}; + ASSERT_TRUE(profile->BuildDefaultTxStreamConfig(tx)); + ASSERT_TRUE(profile->BuildDefaultRxStreamConfig(rx)); + EXPECT_EQ(tx.direction, AudioStreamDirection::HostToDevice); + EXPECT_EQ(rx.direction, AudioStreamDirection::DeviceToHost); + for (const auto& config : {tx, rx}) { + EXPECT_EQ(config.sampleRate, 48000U); + EXPECT_EQ(config.pcmChannels, 10U); + EXPECT_EQ(config.midiSlots, 1U); + EXPECT_EQ(config.dbs, 11U); + EXPECT_EQ(config.framesPerDataPacket, 8U); + EXPECT_EQ(config.streamMode, ASFW::Encoding::StreamMode::kBlocking); + EXPECT_EQ(config.fmt, 0x10U); + EXPECT_EQ(config.fdf, 0x02U); + EXPECT_EQ(8U + config.framesPerDataPacket * config.dbs * 4U, 360U); + } + EXPECT_EQ(profile->TxStreamPolicy().defaultNonAudioSlotWord, 0x80000000U); + EXPECT_TRUE(profile->TxStreamPolicy().initializeNonAudioSlots); + EXPECT_FALSE(profile->TxStreamPolicy().preserveFdfInNoDataPackets); +} + +TEST(DiceProfileTests, FireStudioProjectNeverMatchesOtherPreSonusDevicesOrVendors) { + Profiles::PreSonusFireStudioProjectProfile profile; + EXPECT_TRUE(profile.Matches({.vendorId = 0x000a92, .modelId = 0x00000b})); + for (const uint32_t other : {0x000008U, 0x00000cU, 0x000011U, 0x000013U}) { + EXPECT_FALSE(profile.Matches({.vendorId = 0x000a92, .modelId = other})); + } + EXPECT_FALSE(profile.Matches({.vendorId = 0x00130e, .modelId = 0x00000b})); + // The captured unit's GUID cannot override an incorrect vendor/model pair. + EXPECT_FALSE(profile.Matches({.guid = 0x000A920402D07FACULL, + .vendorId = 0x00130e, .modelId = 0x00000b})); +} + TEST(DiceProfileTests, PreSonusVendorWithWrongModelDoesNotMatchStudioLiveProfile) { // PreSonus also shipped BeBoB-era devices (FireBox/FP10/Inspire), the DICE // FireStudio (0x000008), and the StudioLive siblings 16.4.2/24.4.2/32.4.2 diff --git a/tests/audio/IsochServiceTxPreparationTests.cpp b/tests/audio/IsochServiceTxPreparationTests.cpp index c14e974d6..d00d0a8f5 100644 --- a/tests/audio/IsochServiceTxPreparationTests.cpp +++ b/tests/audio/IsochServiceTxPreparationTests.cpp @@ -151,6 +151,106 @@ TEST(IsochServiceTxPreparation, ActiveTransmitStopRetainsQueueUntilHardwareQuies EXPECT_EQ(context->GetState(), ASFW::Isoch::ITState::Stopped); } +TEST(IsochServiceTxPreparation, InterruptSilenceFaultStopsWithoutRecursiveHardwareAccess) { + // HardwareInterface's host access gate uses a nonrecursive mutex, matching + // the production gate. Poll must release its diagnostic read scope before + // the immediate-fault stop requests its own hardware access. + HardwareInterface hardware; + IsochService service; + IOMemoryDescriptor* payloadDescriptor = nullptr; + IOMemoryDescriptor* metadataDescriptor = nullptr; + IOMemoryDescriptor* controlDescriptor = nullptr; + ASSERT_EQ(service.AllocateTxIsochResources( + 0, AudioTimingGeometry::kTxSharedSlotPackets, 512, + AudioTimingGeometry::kTxPacketsPerGroup, &payloadDescriptor, + &metadataDescriptor, &controlDescriptor), + kIOReturnSuccess); + + IOAddressSegment metadataRange{}; + ASSERT_EQ(metadataDescriptor->GetAddressRange(&metadataRange), kIOReturnSuccess); + auto* metadata = reinterpret_cast(metadataRange.address); + for (uint64_t packetIndex = 0; + packetIndex < AudioTimingGeometry::kTxSharedSlotPackets; ++packetIndex) { + auto& meta = metadata[packetIndex]; + meta.packetIndex = packetIndex; + meta.payloadLength = 8; + meta.commitGeneration.store( + ExpectedTxCommitGeneration(packetIndex, AudioTimingGeometry::kTxSharedSlotPackets), + std::memory_order_release); + } + IOAddressSegment controlRange{}; + ASSERT_EQ(controlDescriptor->GetAddressRange(&controlRange), kIOReturnSuccess); + auto* queue = reinterpret_cast(controlRange.address); + queue->ResetProducerForStart(); + queue->committedEnd.store(AudioTimingGeometry::kTxPreparationLeadPackets, + std::memory_order_release); + ASSERT_EQ(service.StartTransmit(3, hardware, 0x3f), kIOReturnSuccess); + auto* context = service.TransmitContext(); + ASSERT_NE(context, nullptr); + + // No interrupts and a stationary command pointer: watchdog refill sees no + // new completions, then reaches the fatal silent-interrupt threshold. + for (uint32_t poll = 0; poll < 100 && + context->GetState() == ASFW::Isoch::ITState::Running; ++poll) { + context->Poll(); + } + + EXPECT_EQ(context->GetState(), ASFW::Isoch::ITState::Faulted); + EXPECT_EQ(queue->statusWord.load(std::memory_order_acquire), + ASFW::Isoch::IsochTxQueueStatus::kDeadContext); + const Register32 controlClear = static_cast( + DMAContextHelpers::IsoXmitContextControlClear(0)); + EXPECT_EQ(hardware.GetTestRegister(controlClear), ASFW::Driver::ContextControl::kRun); + EXPECT_EQ(hardware.GetTestRegister(Register32::kIsoXmitIntMaskClear), 1U); + EXPECT_TRUE(hardware.TryBeginAccess()); + + // A fault requests a stop; it does not prove OHCI has stopped DMA. Even + // after the immediate RUN-clear, an ACTIVE context must retain its queue + // until the normal quiesce path succeeds. + const Register32 controlSet = static_cast( + DMAContextHelpers::IsoXmitContextControlSet(0)); + hardware.SetTestRegister(controlSet, ASFW::Driver::ContextControl::kActive); + EXPECT_EQ(service.StopAll(), kIOReturnTimeout); + EXPECT_EQ(service.TransmitContext(), context); + EXPECT_EQ(context->GetState(), ASFW::Isoch::ITState::Faulted); + EXPECT_TRUE(context->NeedsQuiesce()); + EXPECT_TRUE(hardware.IsIsochStreamingActive()); + EXPECT_EQ(queue->statusWord.load(std::memory_order_acquire), + ASFW::Isoch::IsochTxQueueStatus::kDeadContext); + EXPECT_EQ(context->Configure(3, 0x3f), kIOReturnBusy); + EXPECT_EQ(context->Start(), kIOReturnNotReady); + EXPECT_EQ(context->SetSharedMemoryDescriptors( + payloadDescriptor, metadataDescriptor, controlDescriptor, + AudioTimingGeometry::kTxPacketsPerGroup), + kIOReturnBusy); + EXPECT_EQ(service.FreeTxIsochResources(), kIOReturnBusy); + IOMemoryDescriptor* replacementPayload = nullptr; + IOMemoryDescriptor* replacementMetadata = nullptr; + IOMemoryDescriptor* replacementControl = nullptr; + EXPECT_EQ(service.AllocateTxIsochResources( + 0, AudioTimingGeometry::kTxSharedSlotPackets, 512, + AudioTimingGeometry::kTxPacketsPerGroup, &replacementPayload, + &replacementMetadata, &replacementControl), + kIOReturnBusy); + EXPECT_EQ(replacementPayload, nullptr); + EXPECT_EQ(replacementMetadata, nullptr); + EXPECT_EQ(replacementControl, nullptr); + + const auto operationsAfterFault = hardware.CopyTestOperations(); + context->Poll(); + context->HandleInterrupt(); + EXPECT_EQ(hardware.CopyTestOperations(), operationsAfterFault); + + hardware.SetTestRegister(controlSet, 0); + EXPECT_EQ(service.StopAll(), kIOReturnSuccess); + EXPECT_EQ(context->GetState(), ASFW::Isoch::ITState::Stopped); + EXPECT_FALSE(context->NeedsQuiesce()); + EXPECT_FALSE(hardware.IsIsochStreamingActive()); + EXPECT_EQ(queue->statusWord.load(std::memory_order_acquire), + ASFW::Isoch::IsochTxQueueStatus::kStopped); + EXPECT_EQ(service.FreeTxIsochResources(), kIOReturnSuccess); +} + // Secondary-stream container: a multi-stream DICE device (Venice F32 = 2×16) // needs IsochService to manage a second IR and second IT context on their own // OHCI context indices, while the master (stream 0) is untouched. This pass only diff --git a/tests/core/HardwareInterfaceOrderTests.cpp b/tests/core/HardwareInterfaceOrderTests.cpp index 66bcc86f3..07bcbada0 100644 --- a/tests/core/HardwareInterfaceOrderTests.cpp +++ b/tests/core/HardwareInterfaceOrderTests.cpp @@ -62,6 +62,101 @@ class HardwareInterfaceOrderTests : public ::testing::Test { namespace { +TEST_F(HardwareInterfaceOrderTests, InitialInterruptSnapshotDefersIsochContextReads) { + const uint32_t events = IntEventBits::kIsochRx | IntEventBits::kIsochTx; + EXPECT_CALL(*mockDevice_, MemoryRead32(0, static_cast(Register32::kIntEvent), _)) + .WillOnce([events](uint8_t, uint64_t, uint32_t* value) { *value = events; }); + EXPECT_CALL(*mockDevice_, MemoryRead32(0, static_cast(Register32::kIsoXmitEvent), _)) + .Times(0); + EXPECT_CALL(*mockDevice_, MemoryRead32(0, static_cast(Register32::kIsoRecvEvent), _)) + .Times(0); + EXPECT_CALL(*mockDevice_, MemoryWrite32(_, _, _)).Times(0); + + const auto snapshot = hardware_.CaptureInterruptSnapshot(12345); + EXPECT_EQ(snapshot.intEvent, events); + EXPECT_EQ(snapshot.timestamp, 12345U); + EXPECT_EQ(snapshot.isoXmitEvent, 0U); + EXPECT_EQ(snapshot.isoRecvEvent, 0U); +} + +TEST_F(HardwareInterfaceOrderTests, IsochAcknowledgementUsesFreshEventsAfterGlobalClear) { + const uint32_t events = IntEventBits::kIsochRx | IntEventBits::kIsochTx; + InterruptSnapshot stale{}; + stale.intEvent = events; + stale.isoRecvEvent = 1; + stale.isoXmitEvent = 1; + stale.timestamp = 12345; + + InSequence sequence; + EXPECT_CALL(*mockDevice_, MemoryWrite32( + 0, static_cast(Register32::kIntEventClear), events)); + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kHCControl), _)); + // Contexts 2 and 3 completed after the initial snapshot. The saved bit 0 + // must not select the contexts to acknowledge or subsequently dispatch. + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kIsoRecvIntEventClear), _)) + .WillOnce([](uint8_t, uint64_t, uint32_t* value) { *value = 4; }); + EXPECT_CALL(*mockDevice_, MemoryWrite32( + 0, static_cast(Register32::kIsoRecvIntEventClear), 4)); + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kHCControl), _)); + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kIsoXmitIntEventClear), _)) + .WillOnce([](uint8_t, uint64_t, uint32_t* value) { *value = 8; }); + EXPECT_CALL(*mockDevice_, MemoryWrite32( + 0, static_cast(Register32::kIsoXmitIntEventClear), 8)); + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kHCControl), _)); + + hardware_.ClearIntEvents(events); + const auto dispatch = hardware_.CaptureAndAcknowledgeIsochInterrupts(stale); + EXPECT_EQ(dispatch.intEvent, events); + EXPECT_EQ(dispatch.timestamp, stale.timestamp); + EXPECT_EQ(dispatch.isoRecvEvent, 4U); + EXPECT_EQ(dispatch.isoXmitEvent, 8U); +} + +TEST_F(HardwareInterfaceOrderTests, CompletionArrivingAfterIsochAckRemainsLatched) { + InterruptSnapshot snapshot{}; + snapshot.intEvent = IntEventBits::kIsochTx; + uint32_t pending = 1; + + InSequence sequence; + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kIsoXmitIntEventClear), _)) + .WillOnce([&](uint8_t, uint64_t, uint32_t* value) { *value = pending; }); + EXPECT_CALL(*mockDevice_, MemoryWrite32( + 0, static_cast(Register32::kIsoXmitIntEventClear), 1)) + .WillOnce([&](uint8_t, uint64_t, uint32_t value) { pending &= ~value; }); + EXPECT_CALL(*mockDevice_, MemoryRead32( + 0, static_cast(Register32::kHCControl), _)) + .WillOnce([&](uint8_t, uint64_t, uint32_t* value) { + // A second completion for context 0 arrives after its first ack. + pending |= 1; + *value = 0; + }); + + const auto dispatch = hardware_.CaptureAndAcknowledgeIsochInterrupts(snapshot); + EXPECT_EQ(dispatch.isoXmitEvent, 1U); + EXPECT_EQ(dispatch.isoRecvEvent, 0U); + EXPECT_EQ(pending, 1U); // No second clear of the saved mask consumed it. +} + +TEST_F(HardwareInterfaceOrderTests, RevokedIsochAckDoesNotDispatchStaleContextBits) { + InterruptSnapshot stale{}; + stale.intEvent = IntEventBits::kIsochRx | IntEventBits::kIsochTx; + stale.isoRecvEvent = 1; + stale.isoXmitEvent = 1; + hardware_.LatchProviderRevokedAndDrain(); + EXPECT_CALL(*mockDevice_, MemoryRead32(_, _, _)).Times(0); + EXPECT_CALL(*mockDevice_, MemoryWrite32(_, _, _)).Times(0); + + const auto dispatch = hardware_.CaptureAndAcknowledgeIsochInterrupts(stale); + EXPECT_EQ(dispatch.isoRecvEvent, 0U); + EXPECT_EQ(dispatch.isoXmitEvent, 0U); +} + TEST_F(HardwareInterfaceOrderTests, CompareSwapLocalIRMResource_WritesDataCompareControlInOrder) { InSequence seq; diff --git a/tests/devices/AudioDuplexCoordinatorTests.cpp b/tests/devices/AudioDuplexCoordinatorTests.cpp index 46e92d632..1ab5ed87b 100644 --- a/tests/devices/AudioDuplexCoordinatorTests.cpp +++ b/tests/devices/AudioDuplexCoordinatorTests.cpp @@ -2,6 +2,7 @@ #include "Async/Interfaces/IFireWireBus.hpp" #include "Audio/Core/AudioRuntimeRegistry.hpp" +#include "Audio/Core/AudioStreamReservation.hpp" #include "Audio/DriverKit/Runtime/DirectAudioBindingSource.hpp" #include "Audio/Protocols/Backends/AudioDuplexCoordinator.hpp" #include "Audio/Protocols/DICE/Core/DICETypes.hpp" @@ -930,6 +931,68 @@ TEST_F(AudioDuplexCoordinatorTests, IdleClockApplyUsesDeviceOnlyPathAndReturnsTo EXPECT_EQ(LogSnapshot(), (std::vector{"device.apply_clock"})); } +TEST_F(AudioDuplexCoordinatorTests, FailedInitialIdleClockApplyKeepsDefaultClockForNextStart) { + protocol_->applyClockStatus = kIOReturnTimeout; + ASSERT_EQ(coordinator_.RequestClockConfig( + kTestGuid, AudioClockConfig{.sampleRateHz = 44100U}, + DiceRestartReason::kSampleRateChange), + kIOReturnTimeout); + + const auto failed = GetSession(); + ASSERT_TRUE(failed.has_value()); + EXPECT_EQ(failed->phase, DiceRestartPhase::kFailed); + EXPECT_EQ(failed->desiredClock.sampleRateHz, 0U); + ASSERT_TRUE(failed->lastClockCompletion.has_value()); + EXPECT_EQ(failed->lastClockCompletion->outcome, DiceClockRequestOutcome::kFailed); + EXPECT_EQ(failed->lastClockCompletion->desiredClock.sampleRateHz, 44100U); + EXPECT_EQ(hostTransport_.beginCalls, 0); + + // CoreAudio rejected the change and will configure TX at the old 48 kHz + // default. A later successful prepare must use that same rate. + ASSERT_EQ(coordinator_.StartStreaming(kTestGuid), kIOReturnSuccess); + EXPECT_EQ(protocol_->LastDesiredClock().sampleRateHz, 48000U); + const auto running = GetSession(); + ASSERT_TRUE(running.has_value()); + EXPECT_EQ(running->desiredClock.sampleRateHz, 48000U); + EXPECT_EQ(running->appliedClock.sampleRateHz, 48000U); +} + +TEST_F(AudioDuplexCoordinatorTests, FailedIdleClockApplyKeepsPreviouslySelectedClockForNextStart) { + constexpr AudioClockConfig previousClock{.sampleRateHz = 44100U}; + protocol_->applyCaps_.sampleRateHz = previousClock.sampleRateHz; + ASSERT_EQ(coordinator_.RequestClockConfig(kTestGuid, previousClock, + DiceRestartReason::kSampleRateChange), + kIOReturnSuccess); + + protocol_->applyClockStatus = kIOReturnError; + ASSERT_EQ(coordinator_.RequestClockConfig(kTestGuid, kSupportedClock, + DiceRestartReason::kSampleRateChange), + kIOReturnError); + + const auto failed = GetSession(); + ASSERT_TRUE(failed.has_value()); + EXPECT_EQ(failed->desiredClock.sampleRateHz, previousClock.sampleRateHz); + EXPECT_EQ(failed->appliedClock.sampleRateHz, previousClock.sampleRateHz); + EXPECT_EQ(failed->runtimeCaps.sampleRateHz, previousClock.sampleRateHz); + ASSERT_TRUE(failed->lastClockCompletion.has_value()); + EXPECT_EQ(failed->lastClockCompletion->outcome, DiceClockRequestOutcome::kFailed); + EXPECT_EQ(failed->lastClockCompletion->desiredClock.sampleRateHz, 48000U); + EXPECT_EQ(hostTransport_.beginCalls, 0); + + protocol_->prepareCaps_.sampleRateHz = previousClock.sampleRateHz; + protocol_->confirmCaps_.sampleRateHz = previousClock.sampleRateHz; + protocol_->healthStatusValue = + ASFW::Audio::DICE::StatusBits::kSourceLocked | + (ASFW::Audio::DICE::ClockRateIndex::k44100 + << ASFW::Audio::DICE::StatusBits::kNominalRateShift); + ASSERT_EQ(coordinator_.StartStreaming(kTestGuid), kIOReturnSuccess); + EXPECT_EQ(protocol_->LastDesiredClock().sampleRateHz, previousClock.sampleRateHz); + const auto running = GetSession(); + ASSERT_TRUE(running.has_value()); + EXPECT_EQ(running->desiredClock.sampleRateHz, previousClock.sampleRateHz); + EXPECT_EQ(running->appliedClock.sampleRateHz, previousClock.sampleRateHz); +} + TEST_F(AudioDuplexCoordinatorTests, RunningClockRequestPerformsFullStopAndRestart) { ASSERT_EQ(coordinator_.StartStreaming(kTestGuid), kIOReturnSuccess); ClearLog(); @@ -1360,4 +1423,130 @@ TEST_F(AudioDuplexCoordinatorTests, NonRetryableFailedSessionDoesNotRestartOnRec EXPECT_EQ(hostTransport_.stopCalls, 1); } +// Tests the exact reservation state machine used by AudioCoordinator without +// instantiating its DriverKit publisher and concrete hardware backends. The +// counters stand in for backend dispatch after a Begin admission. +struct StreamReservationHarness { + using Reservation = ASFW::Audio::AudioStreamReservation; + using Admission = Reservation::Admission; + Reservation reservation; + unsigned backendStarts{0}; + unsigned backendStops{0}; + + Admission Start(uint64_t guid, bool backendSuccess = true) { + const auto decision = reservation.BeginStart(guid); + if (decision.admission == Admission::Begin) { + ++backendStarts; + reservation.CompleteStart(decision.token, backendSuccess); + } + return decision.admission; + } + Admission Stop(uint64_t guid, bool backendSuccess = true) { + const auto decision = reservation.BeginStop(guid); + if (decision.admission == Admission::Begin) { + ++backendStops; + reservation.CompleteStop(decision.token, backendSuccess); + } + return decision.admission; + } +}; + +TEST(AudioStreamReservationTests, FailedStopReservesGuidWithoutFalseStartOrOverlappingCleanup) { + StreamReservationHarness harness; + using Admission = StreamReservationHarness::Admission; + using State = StreamReservationHarness::Reservation::State; + ASSERT_EQ(harness.Start(kTestGuid), Admission::Begin); + ASSERT_EQ(harness.Stop(kTestGuid, false), Admission::Begin); + ASSERT_EQ(harness.reservation.GetState(), State::CleanupFailed); + EXPECT_EQ(harness.reservation.Guid(), kTestGuid); + + EXPECT_EQ(harness.Start(kTestGuid), Admission::CleanupFailed); + EXPECT_EQ(harness.Stop(kTestGuid), Admission::CleanupFailed); + EXPECT_EQ(harness.Start(kTestGuid + 1), Admission::Busy); + EXPECT_EQ(harness.Stop(kTestGuid + 1), Admission::Busy); + EXPECT_TRUE(harness.reservation.BlocksNewWork(kTestGuid)); + EXPECT_EQ(harness.backendStarts, 1U); + EXPECT_EQ(harness.backendStops, 1U); +} + +TEST(AudioStreamReservationTests, CleanStartStopRetainsIdempotentRunningAndAllowsRestart) { + StreamReservationHarness harness; + using Admission = StreamReservationHarness::Admission; + ASSERT_EQ(harness.Start(kTestGuid), Admission::Begin); + EXPECT_EQ(harness.Start(kTestGuid), Admission::AlreadyRunning); + EXPECT_FALSE(harness.reservation.BlocksNewWork(kTestGuid)); + EXPECT_EQ(harness.backendStarts, 1U); + EXPECT_EQ(harness.Stop(kTestGuid), Admission::Begin); + EXPECT_EQ(harness.reservation.Guid(), 0U); + EXPECT_EQ(harness.Start(kTestGuid), Admission::Begin); + EXPECT_EQ(harness.backendStarts, 2U); + EXPECT_EQ(harness.backendStops, 1U); +} + +TEST(AudioStreamReservationTests, InFlightStopRejectsDuplicateStopAndStart) { + StreamReservationHarness harness; + using Admission = StreamReservationHarness::Admission; + ASSERT_EQ(harness.Start(kTestGuid), Admission::Begin); + const auto stop = harness.reservation.BeginStop(kTestGuid); + ASSERT_EQ(stop.admission, Admission::Begin); + EXPECT_TRUE(harness.reservation.BlocksNewWork(kTestGuid)); + EXPECT_EQ(harness.Stop(kTestGuid), Admission::Busy); + EXPECT_EQ(harness.Start(kTestGuid), Admission::Busy); + EXPECT_EQ(harness.backendStops, 0U); + EXPECT_EQ(harness.backendStarts, 1U); + harness.reservation.CompleteStop(stop.token, true); + EXPECT_EQ(harness.Start(kTestGuid), Admission::Begin); +} + +TEST(AudioStreamReservationTests, StopSupersedesStartWithoutLateCompletionPublishingRunning) { + using Reservation = ASFW::Audio::AudioStreamReservation; + Reservation reservation; + const auto start = reservation.BeginStart(kTestGuid); + ASSERT_EQ(start.admission, Reservation::Admission::Begin); + EXPECT_EQ(reservation.BeginStart(kTestGuid).admission, Reservation::Admission::Busy); + const auto stop = reservation.BeginStop(kTestGuid); + ASSERT_EQ(stop.admission, Reservation::Admission::Begin); + EXPECT_FALSE(reservation.CompleteStart(start.token, true)); + EXPECT_EQ(reservation.GetState(), Reservation::State::Stopping); + EXPECT_TRUE(reservation.CompleteStop(stop.token, false)); + EXPECT_FALSE(reservation.CompleteStart(start.token, false)); + EXPECT_EQ(reservation.GetState(), Reservation::State::CleanupFailed); + EXPECT_EQ(reservation.Guid(), kTestGuid); +} + +TEST(AudioStreamReservationTests, ConfirmedRemovalClearsFailureAndFencesStaleCompletions) { + using Reservation = ASFW::Audio::AudioStreamReservation; + Reservation reservation; + const auto oldStart = reservation.BeginStart(kTestGuid); + reservation.CompleteStart(oldStart.token, true); + const auto oldStop = reservation.BeginStop(kTestGuid); + reservation.CompleteStop(oldStop.token, false); + reservation.Clear(kTestGuid + 1); // Another device's removal is irrelevant. + EXPECT_EQ(reservation.GetState(), Reservation::State::CleanupFailed); + reservation.Clear(kTestGuid); + const auto newStart = reservation.BeginStart(kTestGuid); + ASSERT_EQ(newStart.admission, Reservation::Admission::Begin); + EXPECT_FALSE(reservation.CompleteStop(oldStop.token, true)); + EXPECT_FALSE(reservation.CompleteStart(oldStart.token, false)); + EXPECT_EQ(reservation.GetState(), Reservation::State::Starting); + EXPECT_TRUE(reservation.CompleteStart(newStart.token, true)); + EXPECT_EQ(reservation.GetState(), Reservation::State::Running); + reservation.ClearAll(); // Service teardown also invalidates the epoch. + EXPECT_FALSE(reservation.CompleteStart(newStart.token, true)); + EXPECT_EQ(reservation.GetState(), Reservation::State::Idle); + EXPECT_EQ(reservation.Guid(), 0U); +} + +TEST(AudioStreamReservationTests, FailedStartReleasesReservationAndInvalidGuidCannotReserve) { + StreamReservationHarness harness; + using Admission = StreamReservationHarness::Admission; + EXPECT_EQ(harness.Start(0), Admission::Busy); + EXPECT_EQ(harness.Stop(0), Admission::Busy); + EXPECT_EQ(harness.backendStarts, 0U); + EXPECT_EQ(harness.backendStops, 0U); + EXPECT_EQ(harness.Start(kTestGuid, false), Admission::Begin); + EXPECT_EQ(harness.reservation.Guid(), 0U); + EXPECT_EQ(harness.Start(kTestGuid + 1), Admission::Begin); +} + } // namespace diff --git a/tests/devices/DICETcatProtocolTests.cpp b/tests/devices/DICETcatProtocolTests.cpp index 3ea876f2b..90e0575c9 100644 --- a/tests/devices/DICETcatProtocolTests.cpp +++ b/tests/devices/DICETcatProtocolTests.cpp @@ -10,6 +10,8 @@ #include "Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp" #include +#include +#include #include #include #include @@ -21,11 +23,22 @@ namespace ASFW::Audio::DICE::TCAT { class DICETcatProtocolTestPeer { public: - static void CacheRuntimeCaps(DICETcatProtocol& protocol, + static bool CacheRuntimeCaps(DICETcatProtocol& protocol, const GlobalState& global, const StreamConfig& tx, const StreamConfig& rx) { - protocol.CacheRuntimeCaps(global, tx, rx); + return protocol.CacheRuntimeCaps(global, tx, rx); + } + + static bool UpdateOperationalCaps(DICETcatProtocol& protocol, + const AudioStreamRuntimeCaps& caps) { + return protocol.UpdateOperationalCaps(caps); + } + + static bool HasDuplexState(const DICETcatProtocol& protocol) { + return protocol.duplexCtrl_ && + (protocol.duplexCtrl_->IsPrepared() || protocol.duplexCtrl_->IsArmed() || + protocol.duplexCtrl_->IsRunning() || protocol.duplexCtrl_->IsOwnerClaimed()); } static bool MakeDiceClockConfiguration(const AudioClockConfig& requested, @@ -83,6 +96,10 @@ constexpr uint32_t kGlobalBaseLo = static_cast( constexpr uint32_t kAppSectionQuadletOffset = 0x1FU; constexpr uint32_t kAppSectionBaseLo = kExtensionBaseLo + (kAppSectionQuadletOffset * 4U); constexpr uint32_t kGlobalReadBytes = 104U; +constexpr uint32_t kGlobalSectionBytes = 0x5FU * 4U; +constexpr uint32_t kTxBaseLo = kDiceBaseLo + 0x69U * 4U; +constexpr uint32_t kRxBaseLo = kDiceBaseLo + 0xF7U * 4U; +constexpr uint32_t kStreamSectionBytes = 70U * 4U; constexpr uint32_t kClockSelect48kInternal = (ASFW::Audio::DICE::ClockRateIndex::k48000 << ASFW::Audio::DICE::ClockSelect::kRateShift) | static_cast(ClockSource::Internal); @@ -94,6 +111,44 @@ void PutBe32(uint8_t* dst, uint32_t value) { ASFW::FW::WriteBE32(dst, value); } +std::vector MakeStreamSectionWire( + bool isRx, uint32_t count, uint32_t pcm, uint32_t midi, uint32_t iso, + uint32_t entryQuadlets = 70, uint32_t sectionBytes = kStreamSectionBytes, + const char* labels = "") { + std::vector bytes(sectionBytes, 0); + PutBe32(bytes.data(), count); + PutBe32(bytes.data() + 4, entryQuadlets); + PutBe32(bytes.data() + 8, iso); + PutBe32(bytes.data() + 12, isRx ? 0 : pcm); + PutBe32(bytes.data() + 16, isRx ? pcm : midi); + PutBe32(bytes.data() + 20, isRx ? midi : 2); + // TX_NAMES/RX_NAMES begin after the header and four stream quadlets. + for (size_t i = 0; labels[i] != '\0' && 24 + i < bytes.size(); ++i) { + bytes[24 + (i & ~size_t{3}) + (3 - (i & 3))] = labels[i]; + } + return bytes; +} + +AudioStreamRuntimeCaps RequiredTenChannelGeometry() { + AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 10, + .hostOutputPcmChannels = 10, + .deviceToHostAm824Slots = 11, + .hostToDeviceAm824Slots = 11, + .sampleRateHz = 48000, + .deviceToHostStreamCount = 1, + .hostToDeviceStreamCount = 1, + }; + caps.deviceToHostStreams[0] = {.pcmChannels = 10, .am824Slots = 11, .midiPorts = 1}; + caps.hostToDeviceStreams[0] = {.pcmChannels = 10, .am824Slots = 11, .midiPorts = 1}; + return caps; +} + +DICETcatRuntimePolicy LowRateTenChannelPolicy() { + return {.requiredRuntimeGeometry = RequiredTenChannelGeometry(), + .allowedSampleRatesHz = {44100, 48000}}; +} + std::array MakeExtensionSectionsWire() { std::array bytes{}; const std::array quadlets{ @@ -156,18 +211,40 @@ class CountingFireWireBus final : public IFireWireBus { callback(AsyncStatus::kStaleGeneration, {}); return NextHandle(); } + if (failedReadAddress_ && address.addressLo == *failedReadAddress_) { + callback(AsyncStatus::kTimeout, {}); + return NextHandle(); + } std::vector payload(length, 0); if (address.addressHi == 0xFFFFU && address.addressLo == kDiceBaseLo && length >= GeneralSections::kWireSize) { ++generalReadCount; - const auto bytes = MakeGeneralSectionsWire(); + auto bytes = MakeGeneralSectionsWire(); + PutBe32(bytes.data() + 0x14, rxSectionBytes_ / 4); payload.assign(bytes.begin(), bytes.end()); - } else if (address.addressHi == 0xFFFFU && address.addressLo == kGlobalBaseLo && - length >= kGlobalReadBytes) { + } else if (address.addressHi == 0xFFFFU && address.addressLo >= kGlobalBaseLo && + address.addressLo + length <= kGlobalBaseLo + kGlobalSectionBytes) { ++globalReadCount; - const auto bytes = MakeGlobalStateWire(clockSelect_, status_, extStatus_, sampleRate_, notification_); - payload.assign(bytes.begin(), bytes.end()); + const auto core = MakeGlobalStateWire(clockSelect_, status_, extStatus_, sampleRate_, notification_); + std::vector bytes(kGlobalSectionBytes, 0); + std::copy(core.begin(), core.end(), bytes.begin()); + ASFW::FW::WriteBE64(bytes.data(), owner_); + PutBe32(bytes.data() + ASFW::Audio::DICE::GlobalOffset::kEnable, enable_); + const auto offset = address.addressLo - kGlobalBaseLo; + payload.assign(bytes.begin() + offset, bytes.begin() + offset + length); + } else if (address.addressHi == 0xFFFFU && address.addressLo >= kTxBaseLo && + address.addressLo + length <= kTxBaseLo + kStreamSectionBytes) { + const auto bytes = MakeStreamSectionWire(false, txCount_, txPcm_, txMidi_, txIso_, + 70, kStreamSectionBytes, "Input 1\\Input 2\\\\"); + const auto offset = address.addressLo - kTxBaseLo; + payload.assign(bytes.begin() + offset, bytes.begin() + offset + length); + } else if (address.addressHi == 0xFFFFU && address.addressLo >= kRxBaseLo && + address.addressLo + length <= kRxBaseLo + rxSectionBytes_) { + const auto bytes = MakeStreamSectionWire(true, rxCount_, rxPcm_, rxMidi_, rxIso_, + rxEntryQuadlets_, rxSectionBytes_, "Output 1\\Output 2\\\\"); + const auto offset = address.addressLo - kRxBaseLo; + payload.assign(bytes.begin() + offset, bytes.begin() + offset + length); } else if (address.addressHi == 0xFFFFU && address.addressLo == kExtensionBaseLo && length >= ExtensionSections::kWireSize) { ++extensionReadCount; @@ -181,7 +258,14 @@ class CountingFireWireBus final : public IFireWireBus { PutBe32(payload.data(), 0U); } - callback(AsyncStatus::kSuccess, std::span(payload.data(), payload.size())); + if (deferredReadAddress_ && address.addressLo == *deferredReadAddress_) { + deferredReadAddress_.reset(); + completeRead_ = [callback = std::move(callback), payload = std::move(payload)] { + callback(AsyncStatus::kSuccess, payload); + }; + } else { + callback(AsyncStatus::kSuccess, std::span(payload.data(), payload.size())); + } return NextHandle(); } @@ -197,6 +281,30 @@ class CountingFireWireBus final : public IFireWireBus { (void)data; (void)speed; ++writeCount; + if (address.addressHi == 0xFFFFU && data.size() == 4) { + const uint32_t value = ASFW::FW::ReadBE32(data.data()); + if (address.addressLo == kGlobalBaseLo + ASFW::Audio::DICE::GlobalOffset::kEnable) { + enable_ = value; + if (value != 0) ++enableWriteCount_; + } else if (address.addressLo == kTxBaseLo + 8) { + txIso_ = value; + if (value != 0xFFFFFFFFU) ++activeIsoWriteCount_; + } else if (address.addressLo == kRxBaseLo + 8) { + rxIso_ = value; + if (value != 0xFFFFFFFFU) ++activeIsoWriteCount_; + } else if (address.addressLo == kGlobalBaseLo + ASFW::Audio::DICE::GlobalOffset::kClockSelect) { + clockWrites_.push_back(value); + if (applyClockWrites_) { + clockSelect_ = value; + const uint32_t rateIndex = (value >> 8) & 0xff; + if (rateIndex == 1 || rateIndex == 2) { + sampleRate_ = rateIndex == 1 ? 44100 : 48000; + status_ = 1U | (rateIndex << 8); // Locked, nominal rate. + notification_ = 0x20; // CLOCK_ACCEPTED. + } + } + } + } callback(AsyncStatus::kSuccess, {}); return NextHandle(); } @@ -217,6 +325,13 @@ class CountingFireWireBus final : public IFireWireBus { (void)speed; ++lockCount; std::vector payload(responseLength, 0); + if (address.addressHi == 0xFFFFU && address.addressLo == kGlobalBaseLo && + lockOp == LockOp::kCompareSwap && operand.size() == 16 && responseLength == 8) { + ASFW::FW::WriteBE64(payload.data(), owner_); + if (ASFW::FW::ReadBE64(operand.data()) == owner_) { + owner_ = ASFW::FW::ReadBE64(operand.data() + 8); + } + } callback(AsyncStatus::kSuccess, std::span(payload.data(), payload.size())); return NextHandle(); } @@ -240,6 +355,9 @@ class CountingFireWireBus final : public IFireWireBus { Generation GetGeneration() const override { return generation_; } NodeId GetLocalNodeID() const override { return localNodeId_; } + void SetGeneration(Generation generation) { generation_ = generation; } + std::optional deferredReadAddress_; + std::function completeRead_; int readCount{0}; int writeCount{0}; int lockCount{0}; @@ -252,6 +370,23 @@ class CountingFireWireBus final : public IFireWireBus { uint32_t extStatus_{0}; uint32_t sampleRate_{48000}; uint32_t notification_{0x20}; + bool applyClockWrites_{false}; + std::vector clockWrites_; + std::optional failedReadAddress_{}; + uint32_t txCount_{1}; + uint32_t rxCount_{1}; + uint32_t rxSectionBytes_{kStreamSectionBytes}; + uint32_t rxEntryQuadlets_{70}; + uint32_t txPcm_{10}; + uint32_t rxPcm_{10}; + uint32_t txMidi_{1}; + uint32_t rxMidi_{1}; + uint32_t txIso_{0xFFFFFFFFU}; + uint32_t rxIso_{0xFFFFFFFFU}; + uint64_t owner_{ASFW::Audio::DICE::kOwnerNoOwner}; + uint32_t enable_{0}; + uint32_t enableWriteCount_{0}; + uint32_t activeIsoWriteCount_{0}; private: AsyncHandle NextHandle() { @@ -277,6 +412,797 @@ TEST(DICETcatProtocolTests, InitializeIsSideEffectFree) { EXPECT_EQ(bus.lockCount, 0); } +TEST(DICETcatProtocolTests, RuntimeDiscoveryAcceptsCompleteInactiveStreamsWithoutWrites) { + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.requiredRuntimeGeometry = RequiredTenChannelGeometry()}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + ASSERT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostInputPcmChannels, 10U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); + EXPECT_EQ(caps.deviceToHostStreams[0].midiPorts, 1U); + EXPECT_EQ(caps.hostToDeviceStreams[0].am824Slots, 11U); + EXPECT_EQ(caps.deviceToHostIsoChannel, AudioStreamRuntimeCaps::kInvalidIsoChannel); + const int reads = bus.readCount; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 2); + EXPECT_EQ(bus.readCount, reads); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); +} + +TEST(DICETcatProtocolTests, RuntimeDiscoveryReadErrorsLeaveNoUsableCaps) { + for (const uint32_t failedAddress : {kDiceBaseLo, kGlobalBaseLo, kTxBaseLo, kRxBaseLo}) { + SCOPED_TRACE(failedAddress); + CountingFireWireBus bus; + bus.failedReadAddress_ = failedAddress; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); + } +} + +TEST(DICETcatProtocolTests, RequiredGeometryRejectsMissingDeclaredStreamCoreAfterReadFailure) { + CountingFireWireBus bus; + bus.rxCount_ = 2; + bus.rxEntryQuadlets_ = 128; // Core 2 begins at byte 520, beyond the first chunk. + bus.rxSectionBytes_ = 8 + 2 * 512; + bus.failedReadAddress_ = kRxBaseLo + 512; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.requiredRuntimeGeometry = RequiredTenChannelGeometry()}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); +} + +TEST(DICETcatProtocolTests, RequiredGeometryAllowsMissingLabelsAndUnusedSectionTail) { + for (bool missingLabelTail : {false, true}) { + SCOPED_TRACE(missingLabelTail); + CountingFireWireBus bus; + // Only one stream is declared. Its core is complete in either case: + // a section ending before labels, or a failed unused trailing chunk. + bus.rxSectionBytes_ = missingLabelTail ? 24 : 1032; + if (!missingLabelTail) bus.failedReadAddress_ = kRxBaseLo + 512; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.requiredRuntimeGeometry = RequiredTenChannelGeometry()}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostToDeviceStreamCount, 1U); + EXPECT_EQ(caps.hostToDeviceStreams[0].pcmChannels, 10U); + EXPECT_EQ(caps.hostToDeviceStreams[0].midiPorts, 1U); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); + } +} + +TEST(DICETcatProtocolTests, RuntimeDiscoveryRejectsStreamCountBeyondParserCapacity) { + CountingFireWireBus bus; + bus.rxCount_ = 5; + bus.rxEntryQuadlets_ = 4; // All five cores fit; truncation is not the reason to reject. + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); +} + +TEST(DICETcatProtocolTests, RuntimeDiscoveryRejectsZeroAndPartialGeometryAndCanRetry) { + for (uint32_t failure = 0; failure < 4; ++failure) { + SCOPED_TRACE(failure); + CountingFireWireBus bus; + if (failure == 0) bus.sampleRate_ = 0; + if (failure == 1) bus.txCount_ = 0; + if (failure == 2) bus.rxCount_ = 0; + if (failure == 3) bus.rxPcm_ = 0; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + + bus.sampleRate_ = 48000; + bus.txCount_ = bus.rxCount_ = 1; + bus.rxPcm_ = 10; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 2); + EXPECT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); + } +} + +TEST(DICETcatProtocolTests, RequiredGeometryRejectsRatePcmMidiAndStreamCountDifferences) { + for (uint32_t failure = 0; failure < 6; ++failure) { + SCOPED_TRACE(failure); + CountingFireWireBus bus; + auto required = RequiredTenChannelGeometry(); + if (failure == 0) bus.sampleRate_ = 44100; + if (failure == 1) bus.txPcm_ = 9; + if (failure == 2) bus.rxPcm_ = 9; + // Two MIDI ports still occupy one wire slot, so this checks per-stream + // metadata rather than only comparing aggregate PCM/DBS totals. + if (failure == 3) bus.txMidi_ = 2; + if (failure == 4) bus.rxMidi_ = 2; + if (failure == 5) required.deviceToHostStreamCount = 2; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.requiredRuntimeGeometry = required}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnUnsupported); + }); + EXPECT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); + } +} + +TEST(DICETcatProtocolTests, PrepareGeometryMismatchRollsBackOwnerBeforeCompletingOnce) { + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.requiredRuntimeGeometry = RequiredTenChannelGeometry()}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + bus.txPcm_ = 9; // A subsequent prepare must not trust the earlier capture. + const uint64_t originalOwner = bus.owner_; + int completions = 0; + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 48000}, + [&](IOReturn status, ASFW::Audio::DICE::DiceDuplexPrepareResult) { + ++completions; + EXPECT_EQ(status, kIOReturnUnsupported); + EXPECT_EQ(bus.owner_, originalOwner); + EXPECT_FALSE(ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer::HasDuplexState(protocol)); + }); + ASSERT_EQ(completions, 1); + EXPECT_EQ(bus.lockCount, 2); // Claim and rollback compare/swap. + EXPECT_EQ(bus.enable_, 0U); + EXPECT_EQ(bus.enableWriteCount_, 0U); + EXPECT_EQ(bus.activeIsoWriteCount_, 0U); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostInputPcmChannels, 10U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); + + const int writes = bus.writeCount; + int programCompletions = 0; + protocol.ProgramRx([&](IOReturn status, ASFW::Audio::DICE::DiceDuplexStageResult) { + ++programCompletions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(programCompletions, 1); + EXPECT_EQ(bus.writeCount, writes); +} + +TEST(DICETcatProtocolTests, RequiredGeometryRejectsOtherRateRequestsBeforeBusAccess) { + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.requiredRuntimeGeometry = RequiredTenChannelGeometry()}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 44100}, + [&](IOReturn status, ASFW::Audio::DICE::DiceDuplexPrepareResult) { + ++completions; + EXPECT_EQ(status, kIOReturnUnsupported); + }); + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = 44100}, + [&](IOReturn status, ASFW::Audio::DICE::DiceClockApplyResult) { + ++completions; + EXPECT_EQ(status, kIOReturnUnsupported); + }); + EXPECT_EQ(completions, 2); + EXPECT_EQ(bus.readCount, 0); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); +} + +TEST(DICETcatProtocolTests, RateAllowlistAcceptsBothLowRatesWithExactTenChannelGeometry) { + for (const uint32_t rate : {44100U, 48000U}) { + SCOPED_TRACE(rate); + CountingFireWireBus bus; + bus.sampleRate_ = rate; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, LowRateTenChannelPolicy()); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + ASSERT_EQ(completions, 1); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.sampleRateHz, rate); + EXPECT_EQ(caps.hostInputPcmChannels, 10U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); + EXPECT_EQ(caps.deviceToHostAm824Slots, 11U); + EXPECT_EQ(caps.hostToDeviceAm824Slots, 11U); + EXPECT_EQ(caps.deviceToHostStreamCount, 1U); + EXPECT_EQ(caps.hostToDeviceStreamCount, 1U); + EXPECT_EQ(caps.deviceToHostStreams[0].midiPorts, 1U); + EXPECT_EQ(caps.hostToDeviceStreams[0].midiPorts, 1U); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); + } +} + +TEST(DICETcatProtocolTests, RateAllowlistRejectsOtherRequestedAndObservedRates) { + for (const uint32_t rate : {0U, 32000U, 88200U, 96000U, 176400U, 192000U}) { + SCOPED_TRACE(rate); + CountingFireWireBus bus; + bus.sampleRate_ = rate; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, LowRateTenChannelPolicy()); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = rate}, + [&](IOReturn status, ASFW::Audio::DICE::DiceDuplexPrepareResult) { + ++completions; + EXPECT_EQ(status, kIOReturnUnsupported); + }); + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = rate}, + [&](IOReturn status, ASFW::Audio::DICE::DiceClockApplyResult) { + ++completions; + EXPECT_EQ(status, kIOReturnUnsupported); + }); + EXPECT_EQ(completions, 2); + EXPECT_EQ(bus.readCount, 0); + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 3); + AudioStreamRuntimeCaps caps{}; + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); + } +} + +TEST(DICETcatProtocolTests, RateAllowlistKeeps44100AfterIdleClockChangeAndRestores48000) { + CountingFireWireBus bus; + bus.applyClockWrites_ = true; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, LowRateTenChannelPolicy()); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + int completions = 0; + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = 44100}, + [&](IOReturn status, ASFW::Audio::DICE::DiceClockApplyResult result) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + EXPECT_EQ(result.appliedClock.sampleRateHz, 44100U); + EXPECT_EQ(result.runtimeCaps.sampleRateHz, 44100U); + }); + ASSERT_EQ(completions, 1); + EXPECT_EQ(bus.sampleRate_, 44100U); + EXPECT_EQ(bus.owner_, ASFW::Audio::DICE::kOwnerNoOwner); + ASSERT_FALSE(bus.clockWrites_.empty()); + EXPECT_EQ(bus.clockWrites_.back(), 0x0000010cU); // 44.1 kHz, Internal. + + // The legacy StartIO method name must not restore its old 48 kHz default. + protocol.PrepareDuplex48k({}, [&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + ASSERT_EQ(completions, 2); + EXPECT_EQ(bus.sampleRate_, 44100U); + EXPECT_EQ(bus.clockWrites_.back(), 0x0000010cU); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.sampleRateHz, 44100U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); + ASSERT_EQ(protocol.StopDuplex(), kIOReturnSuccess); + EXPECT_EQ(bus.owner_, ASFW::Audio::DICE::kOwnerNoOwner); + + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = 48000}, + [&](IOReturn status, ASFW::Audio::DICE::DiceClockApplyResult result) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + EXPECT_EQ(result.runtimeCaps.sampleRateHz, 48000U); + }); + EXPECT_EQ(completions, 3); + EXPECT_EQ(bus.clockWrites_.back(), kClockSelect48kInternal); + EXPECT_EQ(bus.sampleRate_, 48000U); + EXPECT_EQ(bus.owner_, ASFW::Audio::DICE::kOwnerNoOwner); + EXPECT_EQ(bus.enableWriteCount_, 0U); + EXPECT_EQ(bus.activeIsoWriteCount_, 0U); +} + +TEST(DICETcatProtocolTests, RateAllowlistStillRollsBackChangedWireGeometryAt44100) { + for (uint32_t failure = 0; failure < 6; ++failure) { + SCOPED_TRACE(failure); + CountingFireWireBus bus; + bus.sampleRate_ = 44100; + bus.clockSelect_ = 0x0000010c; + bus.status_ = 0x00000101; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, LowRateTenChannelPolicy()); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + if (failure == 0) bus.txPcm_ = 9; + if (failure == 1) bus.rxPcm_ = 9; + if (failure == 2) bus.txMidi_ = 2; + if (failure == 3) bus.rxMidi_ = 2; + if (failure == 4) bus.txCount_ = 2; + if (failure == 5) bus.rxCount_ = 2; + const uint64_t originalOwner = bus.owner_; + int completions = 0; + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 44100}, + [&](IOReturn status, ASFW::Audio::DICE::DiceDuplexPrepareResult) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + EXPECT_EQ(bus.owner_, originalOwner); + EXPECT_FALSE(ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer::HasDuplexState(protocol)); + }); + ASSERT_EQ(completions, 1); + EXPECT_EQ(bus.lockCount, 2); // Claim and rollback compare/swap. + EXPECT_EQ(bus.enable_, 0U); + EXPECT_EQ(bus.enableWriteCount_, 0U); + EXPECT_EQ(bus.activeIsoWriteCount_, 0U); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostInputPcmChannels, 10U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); + const int writes = bus.writeCount; + protocol.ProgramRx([&](IOReturn status, ASFW::Audio::DICE::DiceDuplexStageResult) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 2); + EXPECT_EQ(bus.writeCount, writes); + } +} + +TEST(DICETcatProtocolTests, FailedClockOperationsDoNotChangeLegacyStartRate) { + for (const bool prepareFailure : {false, true}) { + for (const bool geometryFailure : {false, true}) { + SCOPED_TRACE(testing::Message() << "prepare=" << prepareFailure + << " geometry=" << geometryFailure); + CountingFireWireBus bus; + bus.applyClockWrites_ = true; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, LowRateTenChannelPolicy()); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + int completions = 0; + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = 48000}, + [&](IOReturn status, ASFW::Audio::DICE::DiceClockApplyResult) { + ++completions; + ASSERT_EQ(status, kIOReturnSuccess); + }); + ASSERT_EQ(completions, 1); + if (geometryFailure) { + bus.txPcm_ = 9; // Failure after the hardware clock changes. + } else { + // Failure before any clock programming or owner claim. + bus.failedReadAddress_ = kGlobalBaseLo + ASFW::Audio::DICE::GlobalOffset::kStatus; + } + if (prepareFailure) { + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 44100}, + [&](IOReturn status, ASFW::Audio::DICE::DiceDuplexPrepareResult) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + } else { + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = 44100}, + [&](IOReturn status, ASFW::Audio::DICE::DiceClockApplyResult) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + } + ASSERT_EQ(completions, 2); + EXPECT_EQ(bus.sampleRate_, geometryFailure ? 44100U : 48000U); + EXPECT_EQ(bus.owner_, ASFW::Audio::DICE::kOwnerNoOwner); + const size_t clockWritesAfterFailure = bus.clockWrites_.size(); + bus.failedReadAddress_.reset(); + bus.txPcm_ = 10; + + // A later StartIO must use the prior successful selection, not + // silently retry the failed request for 44.1 kHz. + protocol.PrepareDuplex48k({}, [&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + ASSERT_EQ(completions, 3); + EXPECT_EQ(bus.sampleRate_, 48000U); + EXPECT_EQ(bus.clockSelect_, kClockSelect48kInternal); + // A read failure left 48 kHz selected, so no redundant write is + // needed. A post-clock geometry failure requires one return write. + EXPECT_EQ(bus.clockWrites_.size(), clockWritesAfterFailure + (geometryFailure ? 1U : 0U)); + if (geometryFailure) { + ASSERT_FALSE(bus.clockWrites_.empty()); + EXPECT_EQ(bus.clockWrites_.back(), kClockSelect48kInternal); + } + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.sampleRateHz, 48000U); + EXPECT_EQ(protocol.StopDuplex(), kIOReturnSuccess); + EXPECT_EQ(bus.owner_, ASFW::Audio::DICE::kOwnerNoOwner); + } + } +} + +TEST(DICETcatProtocolTests, FailedPrepareRetainsEarlierSuccessfulDiscovery) { + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + bus.failedReadAddress_ = kDiceBaseLo; + int completions = 0; + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 48000}, + [&](IOReturn status, ASFW::Audio::DICE::DiceDuplexPrepareResult) { + ++completions; + EXPECT_NE(status, kIOReturnSuccess); + }); + EXPECT_EQ(completions, 1); + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostInputPcmChannels, 10U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); + std::vector inNames, outNames; + ASSERT_TRUE(protocol.GetChannelLabels(inNames, outNames)); + EXPECT_EQ(inNames, (std::vector{"Input 1", "Input 2"})); + EXPECT_EQ(outNames, (std::vector{"Output 1", "Output 2"})); + const int reads = bus.readCount; + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + EXPECT_EQ(status, kIOReturnSuccess); + }); + EXPECT_EQ(bus.readCount, reads); + EXPECT_EQ(bus.owner_, ASFW::Audio::DICE::kOwnerNoOwner); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); +} + +TEST(DICETcatProtocolTests, TransientStageFailuresPreserveDiscoveryAndRecoverAfterBusReset) { + for (uint32_t stage = 0; stage < 3; ++stage) { + SCOPED_TRACE(stage); + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, LowRateTenChannelPolicy()); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + if (stage == 1) { + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 48000}, + [](IOReturn status, auto) { ASSERT_EQ(status, kIOReturnSuccess); }); + protocol.ProgramRx([](IOReturn status, auto) { ASSERT_EQ(status, kIOReturnSuccess); }); + protocol.ProgramTxAndEnableDuplex( + [](IOReturn status, auto) { ASSERT_EQ(status, kIOReturnSuccess); }); + } + bus.failedReadAddress_ = stage == 0 ? kDiceBaseLo + : kGlobalBaseLo + ASFW::Audio::DICE::GlobalOffset::kStatus; + int failedCompletions = 0; + auto failed = [&](IOReturn status, auto) { + ++failedCompletions; + EXPECT_NE(status, kIOReturnSuccess); + }; + if (stage == 0) { + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 48000}, failed); + } else if (stage == 1) { + protocol.ConfirmDuplexStart(failed); + } else { + protocol.ApplyClockConfig(AudioClockConfig{.sampleRateHz = 44100}, failed); + } + ASSERT_EQ(failedCompletions, 1); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.sampleRateHz, 48000U); + EXPECT_EQ(caps.deviceToHostStreams[0].pcmChannels, 10U); + EXPECT_EQ(caps.hostToDeviceStreams[0].midiPorts, 1U); + std::vector inputs, outputs; + ASSERT_TRUE(protocol.GetChannelLabels(inputs, outputs)); + EXPECT_EQ(inputs, (std::vector{"Input 1", "Input 2"})); + EXPECT_EQ(outputs, (std::vector{"Output 1", "Output 2"})); + + // Bus reset supplies a new route; static discovery remains available + // even while transaction reads still fail during recovery. + ASFW::Discovery::ConfigROM rom{}; + rom.bib.guid = 0xD1CE000000000002ULL; + rom.gen = Generation{2}; + rom.nodeId = 3; + (void)routeState.registry.UpsertFromROM(rom, ASFW::Discovery::LinkPolicy{}); + bus.SetGeneration(rom.gen); + protocol.UpdateRuntimeContext(*routeState.registry.CurrentRoute(rom.bib.guid), nullptr); + const int reads = bus.readCount; + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + EXPECT_EQ(status, kIOReturnSuccess); + }); + EXPECT_EQ(bus.readCount, reads); + bus.failedReadAddress_.reset(); + bus.owner_ = ASFW::Audio::DICE::kOwnerNoOwner; + bus.enable_ = 0; + bus.txIso_ = bus.rxIso_ = 0xFFFFFFFFU; + protocol.PrepareDuplex({}, AudioClockConfig{.sampleRateHz = 48000}, + [](IOReturn status, auto) { EXPECT_EQ(status, kIOReturnSuccess); }); + EXPECT_EQ(protocol.StopDuplex(), kIOReturnSuccess); + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.deviceToHostStreams[0].pcmChannels, 10U); + ASSERT_TRUE(protocol.GetChannelLabels(inputs, outputs)); + EXPECT_EQ(outputs.front(), "Output 1"); + } +} + +TEST(DICETcatProtocolTests, OverlappingDiscoveryRequestsPreserveFirstPublishedSnapshot) { + CountingFireWireBus bus; + bus.deferredReadAddress_ = kDiceBaseLo; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + auto complete = [&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }; + protocol.EnsureRuntimeStreamGeometry(complete); + protocol.EnsureRuntimeStreamGeometry(complete); + // The second discovery completes while the first read is still in flight. + EXPECT_EQ(completions, 1); + ASSERT_TRUE(bus.completeRead_); + bus.rxPcm_ = 8; + auto finishRead = std::move(bus.completeRead_); + finishRead(); + EXPECT_EQ(completions, 2); + EXPECT_EQ(bus.generalReadCount, 2); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); +} + +TEST(DICETcatProtocolTests, RuntimeUpdatesAndLateDiscoveryCannotRewritePublishedTopologyOrLabels) { + using Peer = ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer; + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + AudioStreamRuntimeCaps live = RequiredTenChannelGeometry(); + live.sampleRateHz = 44100; + live.deviceToHostIsoChannel = 5; + live.hostToDeviceIsoChannel = 6; + live.deviceToHostStreams[0].isoChannel = 5; + live.hostToDeviceStreams[0].isoChannel = 6; + // Unconstrained profiles may observe changed runtime geometry. That result + // must not mutate topology already published to readers or Core Audio. + live.hostInputPcmChannels = live.deviceToHostStreams[0].pcmChannels = 9; + ASSERT_TRUE(Peer::UpdateOperationalCaps(protocol, live)); + ASFW::Audio::DICE::GlobalState global{}; + global.sampleRate = 48000; + ASFW::Audio::DICE::StreamConfig tx{}, rx{}; + tx.numStreams = rx.numStreams = 1; + tx.streams[0].pcmChannels = rx.streams[0].pcmChannels = 2; + strlcpy(tx.streams[0].labels, "Late Input\\\\", sizeof(tx.streams[0].labels)); + strlcpy(rx.streams[0].labels, "Late Output\\\\", sizeof(rx.streams[0].labels)); + ASSERT_TRUE(Peer::CacheRuntimeCaps(protocol, global, tx, rx)); + AudioStreamRuntimeCaps observed{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(observed)); + EXPECT_EQ(observed.sampleRateHz, 44100U); + EXPECT_EQ(observed.deviceToHostIsoChannel, 5U); + EXPECT_EQ(observed.hostToDeviceStreams[0].isoChannel, 6U); + EXPECT_EQ(observed.hostInputPcmChannels, 10U); + EXPECT_EQ(observed.deviceToHostStreams[0].pcmChannels, 10U); + EXPECT_EQ(observed.hostToDeviceStreams[0].pcmChannels, 10U); + std::vector inputs, outputs; + ASSERT_TRUE(protocol.GetChannelLabels(inputs, outputs)); + EXPECT_EQ(inputs.front(), "Input 1"); + EXPECT_EQ(outputs.front(), "Output 1"); +} + +TEST(DICETcatProtocolTests, ConcurrentRuntimeReadersRetainImmutableDiscoverySnapshot) { + using Peer = ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer; + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + std::atomic reading{false}; + std::atomic done{false}; + std::atomic consistent{true}; + std::thread reader([&] { + reading.store(true, std::memory_order_release); + do { + AudioStreamRuntimeCaps caps{}; + std::vector inputs, outputs; + if (!protocol.GetRuntimeAudioStreamCaps(caps) || + caps.hostInputPcmChannels != 10 || + caps.deviceToHostStreams[0].pcmChannels != 10 || + caps.hostToDeviceStreams[0].midiPorts != 1 || + !protocol.GetChannelLabels(inputs, outputs) || + inputs != std::vector{"Input 1", "Input 2"} || + outputs != std::vector{"Output 1", "Output 2"}) { + consistent.store(false, std::memory_order_relaxed); + } + } while (!done.load(std::memory_order_acquire)); + }); + while (!reading.load(std::memory_order_acquire)) { std::this_thread::yield(); } + for (uint32_t i = 0; i < 500; ++i) { + auto caps = RequiredTenChannelGeometry(); + caps.sampleRateHz = i % 2 == 0 ? 44100 : 48000; + caps.hostInputPcmChannels = caps.deviceToHostStreams[0].pcmChannels = 9; + EXPECT_TRUE(Peer::UpdateOperationalCaps(protocol, caps)); + caps.sampleRateHz = 0; // Rejected operational result must not reset discovery. + EXPECT_FALSE(Peer::UpdateOperationalCaps(protocol, caps)); + ASFW::Audio::DICE::GlobalState global{}; + global.sampleRate = 48000; + ASFW::Audio::DICE::StreamConfig tx{}, rx{}; + tx.numStreams = rx.numStreams = 1; + tx.streams[0].pcmChannels = rx.streams[0].pcmChannels = 2; + strlcpy(tx.streams[0].labels, "Late\\\\", sizeof(tx.streams[0].labels)); + EXPECT_TRUE(Peer::CacheRuntimeCaps(protocol, global, tx, rx)); + } + done.store(true, std::memory_order_release); + reader.join(); + EXPECT_TRUE(consistent.load()); +} + +TEST(DICETcatProtocolTests, ConcurrentDiscoveryPublishesOneCompleteSnapshot) { + using Peer = ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer; + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASFW::Audio::DICE::GlobalState global{}; + global.sampleRate = 48000; + ASFW::Audio::DICE::StreamConfig first{}, second{}; + first.numStreams = second.numStreams = 1; + first.streams[0].pcmChannels = 2; + second.streams[0].pcmChannels = 4; + strlcpy(first.streams[0].labels, "First\\\\", sizeof(first.streams[0].labels)); + strlcpy(second.streams[0].labels, "Second\\\\", sizeof(second.streams[0].labels)); + std::atomic start{false}; + auto publish = [&](const auto& config) { + while (!start.load(std::memory_order_acquire)) { std::this_thread::yield(); } + EXPECT_TRUE(Peer::CacheRuntimeCaps(protocol, global, config, config)); + }; + std::thread a([&] { publish(first); }); + std::thread b([&] { publish(second); }); + start.store(true, std::memory_order_release); + a.join(); + b.join(); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + std::vector inputs, outputs; + ASSERT_TRUE(protocol.GetChannelLabels(inputs, outputs)); + const bool firstWon = caps.hostInputPcmChannels == 2; + EXPECT_EQ(caps.hostOutputPcmChannels, firstWon ? 2U : 4U); + EXPECT_EQ(caps.deviceToHostStreams[0].pcmChannels, firstWon ? 2U : 4U); + EXPECT_EQ(caps.hostToDeviceStreams[0].pcmChannels, firstWon ? 2U : 4U); + EXPECT_EQ(inputs, (std::vector{firstWon ? "First" : "Second"})); + EXPECT_EQ(outputs, inputs); +} + +TEST(DICETcatProtocolTests, LateInitialDiscoveryDoesNotOverwriteSuccessfulClockAndIsoState) { + using Peer = ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer; + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + auto caps = RequiredTenChannelGeometry(); + caps.sampleRateHz = 44100; + caps.deviceToHostIsoChannel = 5; + caps.hostToDeviceIsoChannel = 6; + caps.deviceToHostStreams[0].isoChannel = 5; + caps.hostToDeviceStreams[0].isoChannel = 6; + ASSERT_TRUE(Peer::UpdateOperationalCaps(protocol, caps)); + EXPECT_FALSE(protocol.GetRuntimeAudioStreamCaps(caps)); + protocol.EnsureRuntimeStreamGeometry([](IOReturn status) { + ASSERT_EQ(status, kIOReturnSuccess); + }); + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.sampleRateHz, 44100U); + EXPECT_EQ(caps.deviceToHostIsoChannel, 5U); + EXPECT_EQ(caps.hostToDeviceIsoChannel, 6U); + EXPECT_EQ(caps.deviceToHostStreams[0].isoChannel, 5U); + EXPECT_EQ(caps.hostToDeviceStreams[0].isoChannel, 6U); + EXPECT_EQ(caps.hostOutputPcmChannels, 10U); +} + +TEST(DICETcatProtocolTests, RequiredWireGeometryAllowsHiddenCoreAudioCapture) { + CountingFireWireBus bus; + RouteState routeState; + DICETcatProtocol protocol(bus, bus, routeState.registry, routeState.route, nullptr, + nullptr, {.exposeDeviceToHostToCoreAudio = false, + .requiredRuntimeGeometry = RequiredTenChannelGeometry()}); + ASSERT_EQ(protocol.Initialize(), kIOReturnSuccess); + int completions = 0; + for (uint32_t attempt = 0; attempt < 2; ++attempt) { + protocol.EnsureRuntimeStreamGeometry([&](IOReturn status) { + ++completions; + EXPECT_EQ(status, kIOReturnSuccess); + }); + } + EXPECT_EQ(completions, 2); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(protocol.GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostInputPcmChannels, 0U); + EXPECT_EQ(caps.deviceToHostStreams[0].pcmChannels, 10U); + EXPECT_EQ(caps.deviceToHostStreams[0].am824Slots, 11U); + EXPECT_EQ(bus.writeCount, 0); + EXPECT_EQ(bus.lockCount, 0); +} + TEST(DICETcatProtocolTests, NeutralClockRequestMapsToDiceClockSelectInsideAdapter) { DiceClockConfiguration mapped{}; EXPECT_TRUE(ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer::MakeDiceClockConfiguration( @@ -397,12 +1323,15 @@ TEST(DICETcatProtocolTests, ChannelLabelsFlattenAcrossStreamsInChannelOrder) { // Host input == device TX; two streams, names concatenated in stream order. ASFW::Audio::DICE::StreamConfig tx{}; tx.numStreams = 2; + tx.streams[0].pcmChannels = 2; + tx.streams[1].pcmChannels = 2; strlcpy(tx.streams[0].labels, "Mic 1\\Mic 2\\\\", sizeof(tx.streams[0].labels)); strlcpy(tx.streams[1].labels, "Line 3\\Line 4\\\\", sizeof(tx.streams[1].labels)); // Host output == device RX. ASFW::Audio::DICE::StreamConfig rx{}; rx.numStreams = 1; + rx.streams[0].pcmChannels = 2; strlcpy(rx.streams[0].labels, "Main L\\Main R\\\\", sizeof(rx.streams[0].labels)); ASFW::Audio::DICE::TCAT::DICETcatProtocolTestPeer::CacheRuntimeCaps(protocol, global, tx, rx); diff --git a/tests/devices/DiceRuntimeDeviceConfigTests.cpp b/tests/devices/DiceRuntimeDeviceConfigTests.cpp index 9b2afd720..681084393 100644 --- a/tests/devices/DiceRuntimeDeviceConfigTests.cpp +++ b/tests/devices/DiceRuntimeDeviceConfigTests.cpp @@ -6,7 +6,10 @@ namespace { using ASFW::Audio::ApplyDiceRuntimeCapsToDeviceConfig; using ASFW::Audio::AudioStreamRuntimeCaps; +using ASFW::Audio::DicePublicationConfigResult; using ASFW::Audio::Model::ASFWAudioDevice; +using ASFW::Audio::PrepareDiceDeviceConfigForPublication; +namespace DeviceIds = ASFW::DeviceProfiles::Audio; TEST(DiceRuntimeDeviceConfigTests, AppliesDiscoveredPcmGeometryWithoutChannelTable) { ASFWAudioDevice config{}; @@ -88,6 +91,180 @@ TEST(DiceRuntimeDeviceConfigTests, RejectsPartialCapsWithoutChangingFallbackConf EXPECT_EQ(config.sampleRates, before.sampleRates); } +TEST(DiceRuntimeDeviceConfigTests, ProjectDefersMissingWireGeometryWithoutMutatingCandidateConfig) { + for (uint32_t failure = 0; failure < 7; ++failure) { + SCOPED_TRACE(failure); + ASFWAudioDevice config{}; + config.vendorId = DeviceIds::kPreSonusVendorId; + config.modelId = DeviceIds::kFireStudioProjectModelId; + config.inputChannelCount = 16; + config.outputChannelCount = 8; + config.channelCount = 16; + config.currentSampleRate = 48000; + config.sampleRates = {48000}; + const ASFWAudioDevice before = config; + AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 10, + .hostOutputPcmChannels = 10, + .deviceToHostAm824Slots = 11, + .hostToDeviceAm824Slots = 11, + .sampleRateHz = 48000, + .deviceToHostStreamCount = 1, + .hostToDeviceStreamCount = 1, + }; + if (failure == 0) caps.sampleRateHz = 0; + if (failure == 1) caps.deviceToHostAm824Slots = 0; + if (failure == 2) caps.hostToDeviceAm824Slots = 0; + if (failure == 3) caps.deviceToHostStreamCount = 0; + if (failure == 4) caps.hostToDeviceStreamCount = 0; + if (failure == 5) caps.deviceToHostStreamCount = 5; + if (failure == 6) caps.hostToDeviceStreamCount = 5; + + EXPECT_EQ(PrepareDiceDeviceConfigForPublication(&caps, true, config), + DicePublicationConfigResult::kDefer); + EXPECT_EQ(config.inputChannelCount, before.inputChannelCount); + EXPECT_EQ(config.outputChannelCount, before.outputChannelCount); + EXPECT_EQ(config.channelCount, before.channelCount); + EXPECT_EQ(config.currentSampleRate, before.currentSampleRate); + EXPECT_EQ(config.sampleRates, before.sampleRates); + } +} + +TEST(DiceRuntimeDeviceConfigTests, PublicationFailurePolicyIsScopedToExactProjectIdentity) { + const struct { + const char* name; + uint32_t vendor; + uint32_t model; + bool isProject; + } devices[] = { + {"FireStudio Project", DeviceIds::kPreSonusVendorId, + DeviceIds::kFireStudioProjectModelId, true}, + {"StudioLive sibling", DeviceIds::kPreSonusVendorId, + DeviceIds::kStudioLive1602ModelId, false}, + {"Other PreSonus model", DeviceIds::kPreSonusVendorId, 0x000008, false}, + {"Same model ID from another vendor", DeviceIds::kWeissVendorId, + DeviceIds::kFireStudioProjectModelId, false}, + {"Focusrite", DeviceIds::kFocusriteVendorId, DeviceIds::kSPro40ModelId, false}, + }; + const struct { + const char* name; + bool geometryReadSucceeded; + bool providePartialCaps; + } failures[] = { + {"No protocol or geometry read failed", false, false}, + {"Read succeeded but cached caps unavailable", true, false}, + {"Read failed with incomplete caps", false, true}, + {"Read succeeded with incomplete caps", true, true}, + }; + const AudioStreamRuntimeCaps partial{ + .hostInputPcmChannels = 10, + .hostOutputPcmChannels = 0, + .sampleRateHz = 44100, + }; + + for (const auto& device : devices) { + for (const auto& failure : failures) { + SCOPED_TRACE(device.name); + SCOPED_TRACE(failure.name); + ASFWAudioDevice config{}; + config.vendorId = device.vendor; + config.modelId = device.model; + config.inputChannelCount = 2; + config.outputChannelCount = 2; + config.channelCount = 2; + config.currentSampleRate = 48000; + config.sampleRates = {44100, 48000}; + const ASFWAudioDevice before = config; + + EXPECT_EQ(PrepareDiceDeviceConfigForPublication( + failure.providePartialCaps ? &partial : nullptr, + failure.geometryReadSucceeded, config), + device.isProject ? DicePublicationConfigResult::kDefer + : DicePublicationConfigResult::kProfileFallback); + EXPECT_EQ(config.inputChannelCount, before.inputChannelCount); + EXPECT_EQ(config.outputChannelCount, before.outputChannelCount); + EXPECT_EQ(config.channelCount, before.channelCount); + EXPECT_EQ(config.currentSampleRate, before.currentSampleRate); + EXPECT_EQ(config.sampleRates, before.sampleRates); + } + } +} + +TEST(DiceRuntimeDeviceConfigTests, ProjectPublishesDiscoveredGeometryAtBothSupportedRates) { + for (const uint32_t rate : {44100U, 48000U}) { + SCOPED_TRACE(rate); + ASFWAudioDevice config{}; + config.vendorId = DeviceIds::kPreSonusVendorId; + config.modelId = DeviceIds::kFireStudioProjectModelId; + config.inputChannelCount = 2; + config.outputChannelCount = 2; + config.sampleRates = {44100U, 48000U}; + config.currentSampleRate = 48000U; + const AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 10, + .hostOutputPcmChannels = 10, + .deviceToHostAm824Slots = 11, + .hostToDeviceAm824Slots = 11, + .sampleRateHz = rate, + .deviceToHostStreamCount = 1, + .hostToDeviceStreamCount = 1, + }; + + EXPECT_EQ(PrepareDiceDeviceConfigForPublication(&caps, true, config), + DicePublicationConfigResult::kRuntimeGeometry); + EXPECT_EQ(config.inputChannelCount, 10U); + EXPECT_EQ(config.outputChannelCount, 10U); + EXPECT_EQ(config.channelCount, 10U); + EXPECT_EQ(config.currentSampleRate, 48000U); + EXPECT_EQ(config.sampleRates, (std::vector{44100U, 48000U})); + } +} + +TEST(DiceRuntimeDeviceConfigTests, LegacyPublicationStillEnrichesFromAvailableCapsAfterReadFailure) { + ASFWAudioDevice config{}; + config.vendorId = DeviceIds::kFocusriteVendorId; + config.modelId = DeviceIds::kSPro40ModelId; + config.inputChannelCount = 2; + config.outputChannelCount = 2; + config.sampleRates = {44100U, 48000U}; + config.currentSampleRate = 48000U; + // The established enrichment path accepts HAL-facing channel counts + // without requiring the additional Project-only wire geometry fields. + const AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 16, + .hostOutputPcmChannels = 8, + .sampleRateHz = 48000, + }; + + EXPECT_EQ(PrepareDiceDeviceConfigForPublication(&caps, false, config), + DicePublicationConfigResult::kRuntimeGeometry); + EXPECT_EQ(config.inputChannelCount, 16U); + EXPECT_EQ(config.outputChannelCount, 8U); + EXPECT_EQ(config.channelCount, 16U); +} + +TEST(DiceRuntimeDeviceConfigTests, ProjectDoesNotPublishAfterFailedGeometryReadEvenWithCaps) { + ASFWAudioDevice config{}; + config.vendorId = DeviceIds::kPreSonusVendorId; + config.modelId = DeviceIds::kFireStudioProjectModelId; + config.inputChannelCount = 2; + config.outputChannelCount = 2; + const AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 10, + .hostOutputPcmChannels = 10, + .deviceToHostAm824Slots = 11, + .hostToDeviceAm824Slots = 11, + .sampleRateHz = 44100, + .deviceToHostStreamCount = 1, + .hostToDeviceStreamCount = 1, + }; + + EXPECT_EQ(PrepareDiceDeviceConfigForPublication(&caps, false, config), + DicePublicationConfigResult::kDefer); + EXPECT_EQ(config.inputChannelCount, 2U); + EXPECT_EQ(config.outputChannelCount, 2U); +} + TEST(DiceRuntimeDeviceConfigTests, AppliesPlaybackOnlyCoreAudioGeometryWithDuplexWireCaps) { ASFWAudioDevice config{}; config.sampleRates = {44100U, 48000U};