diff --git a/ASFWDriver/Audio/Core/AudioCoordinator.cpp b/ASFWDriver/Audio/Core/AudioCoordinator.cpp index b98bca4bc..8f99a2459 100644 --- a/ASFWDriver/Audio/Core/AudioCoordinator.cpp +++ b/ASFWDriver/Audio/Core/AudioCoordinator.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project #include "AudioCoordinator.hpp" @@ -60,7 +61,28 @@ void AudioCoordinator::OnDeviceAdded(std::shared_ptr device remoteLostGuids_.erase(guid); IOLockUnlock(lock_); } - if (BackendForGuid(guid) == &dice_) { + const auto record = registry_.SnapshotByGuid(guid); + if (record.has_value() && + record->vendorId == DeviceProfiles::Audio::kMotuVendorId && + record->modelId == DeviceProfiles::Audio::kMotuUltraliteSwVersion) { + Model::ASFWAudioDevice config{}; + config.guid = record->guid; + config.vendorId = record->vendorId; + config.modelId = record->modelId; + config.deviceName = "MOTU UltraLite"; + config.channelCount = 14; + config.inputChannelCount = 14; + config.outputChannelCount = 14; + config.sampleRates = {48000}; + config.currentSampleRate = 48000; + config.inputPlugName = "UltraLite Input"; + config.outputPlugName = "UltraLite Output"; + config.streamMode = Model::StreamMode::kBlocking; + if (auto endpoint = runtime_.EnsureEndpointRuntime(guid)) { + endpoint->UpdateConfig(config); + } + avc_.OnAudioConfigurationReady(guid, config); + } else if (BackendForGuid(guid) == &dice_) { dice_.OnDeviceRecordUpdated(guid); } } @@ -230,6 +252,9 @@ IAudioBackend* AudioCoordinator::BackendForGuid(uint64_t guid) noexcept { const auto integration = DeviceProtocolFactory::LookupIntegrationMode(record->vendorId, record->modelId); if (integration == DeviceIntegrationMode::kHardcodedNub) { + if (record->vendorId == DeviceProfiles::Audio::kMotuVendorId) { + return &avc_; + } return &dice_; } diff --git a/ASFWDriver/Audio/Core/AudioRuntimeRegistry.cpp b/ASFWDriver/Audio/Core/AudioRuntimeRegistry.cpp index cb10f7381..9847c82dc 100644 --- a/ASFWDriver/Audio/Core/AudioRuntimeRegistry.cpp +++ b/ASFWDriver/Audio/Core/AudioRuntimeRegistry.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project #include "AudioRuntimeRegistry.hpp" @@ -148,9 +149,9 @@ std::shared_ptr AudioRuntimeRegistry::EnsureForDevice( // mode). No protocol is created, and nothing is logged, for unknown devices. auto created = DeviceProtocolFactory::Create( record.vendorId, record.modelId, *busOps, *busInfo, routeRegistry, - *route, - irmClient, - cmpClient_, timerScheduler_); + *route, irmClient, cmpClient_, timerScheduler_, + DeviceProtocolFactory::UnitIdentity{.specId = record.unitSpecId.value_or(0U), + .swVersion = record.unitSwVersion.value_or(0U)}); if (!created) { return nullptr; } diff --git a/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp b/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp index 4137d4bd3..11eb4c55c 100644 --- a/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp +++ b/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // // ASFWAudioDevice.cpp // ASFWDriver @@ -595,6 +596,15 @@ kern_return_t ASFWAudioDevice::StopIO(IOUserAudioStartStopFlags in_flags) { control->counters.txPackets.load(std::memory_order_relaxed), control->counters.txSilenceSubstitutions.load(std::memory_order_relaxed), control->counters.txUnderruns.load(std::memory_order_relaxed)); + const auto& payload = + ivars.runtime.txStreamEngine.PayloadWriterCounters(); + ASFW_LOG(DirectAudio, + "ADK DBG STOPPAYLOAD visited=%llu written=%llu nonZeroFrames=%llu nonZeroSlots=%llu maxAbsBits=0x%08x", + payload.framesVisited.load(std::memory_order_relaxed), + payload.framesWritten.load(std::memory_order_relaxed), + payload.framesNonZero.load(std::memory_order_relaxed), + payload.slotsNonZero.load(std::memory_order_relaxed), + payload.maxAbsSampleBits.load(std::memory_order_relaxed)); } if (ivars.device.audioNub) { diff --git a/ASFWDriver/Audio/DriverKit/ASFWAudioDriverDirect.cpp b/ASFWDriver/Audio/DriverKit/ASFWAudioDriverDirect.cpp index 188160cc5..7bff08c75 100644 --- a/ASFWDriver/Audio/DriverKit/ASFWAudioDriverDirect.cpp +++ b/ASFWDriver/Audio/DriverKit/ASFWAudioDriverDirect.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // // ASFWAudioDriverDirect.cpp // ASFWDriver @@ -299,6 +300,8 @@ bool BindDirectAudioSkeleton(ASFWAudioDriver_IVars& ivars, ivars.device.vendorId, ivars.device.modelId, ivars.device.guid)) { if (profile->TxWireFormat() == ASFW::Encoding::AudioWireFormat::kRawPcm24In32) { wireFormat = ASFW::Audio::Runtime::AudioWireFormat::kRawPcm24In32; + } else if (profile->TxWireFormat() == ASFW::Encoding::AudioWireFormat::kMotuV2) { + wireFormat = ASFW::Audio::Runtime::AudioWireFormat::kMotuV2; } } diff --git a/ASFWDriver/Audio/DriverKit/ASFWAudioDriverGraph.cpp b/ASFWDriver/Audio/DriverKit/ASFWAudioDriverGraph.cpp index e2e4efc1f..c5574be73 100644 --- a/ASFWDriver/Audio/DriverKit/ASFWAudioDriverGraph.cpp +++ b/ASFWDriver/Audio/DriverKit/ASFWAudioDriverGraph.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // // ASFWAudioDriverGraph.cpp // ASFWDriver @@ -142,12 +143,14 @@ kern_return_t BuildAudioGraph(ASFWAudioDriver& driver, // Set once a resolved profile supplies its advertised sample-rate set, so the // bring-up single-format policy below is skipped for profiled devices. bool profileProvidedSampleRates = false; + ASFW::Isoch::Audio::PreferredStereoChannels preferredOutputStereo{}; // Resolve audio profile registry on startup if (const auto* profile = ASFW::Isoch::Audio::AudioProfileRegistry::FindProfile( parsedConfig.vendorId, parsedConfig.modelId, parsedConfig.guid)) { ASFW_LOG(Audio, "ASFWAudioDriver: Resolved profile '%{public}s'", profile->Name()); strlcpy(parsedConfig.deviceName, profile->Name(), sizeof(parsedConfig.deviceName)); + preferredOutputStereo = profile->PreferredOutputStereoChannels(); const uint32_t rxChannels = profile->RxChannelCount(); const uint32_t txChannels = profile->TxChannelCount(); @@ -590,6 +593,32 @@ kern_return_t BuildAudioGraph(ASFWAudioDriver& driver, state.outputStreamAdded = true; } + if (preferredOutputStereo.left != 0 || preferredOutputStereo.right != 0) { + if (preferredOutputStereo.left == 0 || + preferredOutputStereo.right == 0 || + preferredOutputStereo.left == preferredOutputStereo.right || + preferredOutputStereo.left > ivars.device.outputChannelCount || + preferredOutputStereo.right > ivars.device.outputChannelCount) { + ASFW_LOG(Audio, + "ADK FATAL GRAPH invalid preferred stereo pair left=%u right=%u outputChannels=%u", + preferredOutputStereo.left, + preferredOutputStereo.right, + ivars.device.outputChannelCount); + return kIOReturnBadArgument; + } + if (!requireAdkSuccess( + "device.SetPreferredChannelsForStereo", + ivars.audioDevice->SetPreferredChannelsForStereo( + preferredOutputStereo.left, + preferredOutputStereo.right))) { + return error; + } + ASFW_LOG(Audio, + "ASFWAudioDriver: Preferred stereo output channels left=%u right=%u", + preferredOutputStereo.left, + preferredOutputStereo.right); + } + // Install the RT handler only after every exposed stream is fully // configured and attached. The direct transport buffers remain duplex even // for playback-only CoreAudio devices. diff --git a/ASFWDriver/Audio/DriverKit/ASFWAudioDriverZts.cpp b/ASFWDriver/Audio/DriverKit/ASFWAudioDriverZts.cpp index ff824d28b..74724085c 100644 --- a/ASFWDriver/Audio/DriverKit/ASFWAudioDriverZts.cpp +++ b/ASFWDriver/Audio/DriverKit/ASFWAudioDriverZts.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // // ASFWAudioDriverZts.cpp // ASFWDriver @@ -97,6 +98,9 @@ uint32_t PrepareTransmitSlots(ASFWAudioDriver_IVars& ivars, if (directControl == nullptr) { return 0; } + const bool isMotu = + ivars.runtime.directAudioGraph.hostToDeviceWireFormat == + ASFW::Audio::Runtime::AudioWireFormat::kMotuV2; uint64_t nextPacketToPrepare = startPacketIndex; uint32_t preparedCount = 0; @@ -298,6 +302,11 @@ uint32_t PrepareTransmitSlots(ASFWAudioDriver_IVars& ivars, directControl->txReplayEntries.fetch_add( 1, std::memory_order_relaxed); timing.replayDataBlocks = replay.dataBlocks; + timing.packetCycleTicks = packetAnchorTicks; + if (isMotu) { + timing.motuSphCount = replay.motuSphCount; + timing.motuSphOffsets = replay.motuSphOffsets; + } } else { const int64_t replayDistance = replayDiagnostic.readerCursor >= replayDiagnostic.producerCursor @@ -378,6 +387,46 @@ uint32_t PrepareTransmitSlots(ASFWAudioDriver_IVars& ivars, } if (replay.dataBlocks != 0) { + if (isMotu) { + if ((replay.flags & + ASFW::Audio::Runtime::RxSequenceFlags:: + kValidMotuSph) == 0 || + replay.motuSphCount != replay.dataBlocks) { + failProducer( + ASFW::Audio::Runtime::TxProducerFaultStage:: + kReplaySytValidation, + ASFW::Audio::Runtime::TxProducerFaultReason:: + kInvalidReplaySyt, + ASFW::Audio::Runtime::FatalStreamReason:: + TxReplayInvalidSyt, + nextPacketToPrepare); + break; + } + timing.txClockValid = true; + timing.disposition = + ASFW::Protocols::Audio::AMDTP:: + AmdtpPacketDisposition::Data; + + const int64_t sourceTicks = + ASFW::Timing::encodedTstampToOffsets( + replay.sourceCycleTimer); + const int64_t deltaTicks = + ASFW::Timing::extOffsetDiff( + ASFW::Timing::normalizeOffsetDomain( + packetAnchorTicks), + ASFW::Timing::normalizeOffsetDomain(sourceTicks)); + if (deltaTicks >= 0) { + const auto& txConfig = + ivars.runtime.txStreamEngine.StreamConfig(); + const uint64_t projectedFrame = + replay.firstAudioFrame + + (static_cast(deltaTicks) * + txConfig.sampleRate) / + ASFW::Timing::kTicksPerSecond; + (void)ivars.runtime.txStreamEngine + .AlignFrameCursorOnce(projectedFrame); + } + } else { if (replay.sytOffset == ASFW::Audio::Runtime:: RxSequenceReplayState::kNoInfo || @@ -497,6 +546,7 @@ uint32_t PrepareTransmitSlots(ASFWAudioDriver_IVars& ivars, txConfig.sampleRate); } } + } } } diff --git a/ASFWDriver/Audio/DriverKit/Config/AudioProfileRegistry.cpp b/ASFWDriver/Audio/DriverKit/Config/AudioProfileRegistry.cpp index c6b360b41..0645315b1 100644 --- a/ASFWDriver/Audio/DriverKit/Config/AudioProfileRegistry.cpp +++ b/ASFWDriver/Audio/DriverKit/Config/AudioProfileRegistry.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // AudioProfileRegistry.cpp @@ -9,6 +10,7 @@ #include "AVC/BeBoBProfile.hpp" #include "AVC/Phase88Profile.hpp" #include "DICE/DiceProfileRegistry.hpp" +#include "MOTU/MotuUltraLiteProfile.hpp" #include "../../../Audio/Protocols/BeBoB/BeBoBPlug0StreamDiscovery.hpp" #include "../../../DeviceProfiles/Audio/AudioDeviceIds.hpp" @@ -23,6 +25,12 @@ std::unordered_map>& AudioProfile const IAudioDeviceProfile* AudioProfileRegistry::FindProfile(uint32_t vendorId, uint32_t modelId, uint64_t guid) noexcept { + static MOTU::MotuUltraLiteProfile motuUltraLiteProfile{}; + if (vendorId == DeviceProfiles::Audio::kMotuVendorId && + modelId == DeviceProfiles::Audio::kMotuUltraliteSwVersion) { + return &motuUltraLiteProfile; + } + // Curated static profiles win over everything else: a discovery-derived // per-GUID profile must never shadow a hand-validated device profile // (curated name, warm-up/idle-packet policy, rate set). Shadowing Phase88 diff --git a/ASFWDriver/Audio/DriverKit/Config/AudioStreamProfile.hpp b/ASFWDriver/Audio/DriverKit/Config/AudioStreamProfile.hpp index 341551701..1cfe7f7f9 100644 --- a/ASFWDriver/Audio/DriverKit/Config/AudioStreamProfile.hpp +++ b/ASFWDriver/Audio/DriverKit/Config/AudioStreamProfile.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // AudioStreamProfile.hpp - Protocol-neutral ADK stream geometry contract. @@ -7,6 +8,7 @@ #include "IAudioDeviceProfile.hpp" +#include #include namespace ASFW::Isoch::Audio { @@ -28,6 +30,7 @@ struct AudioStreamConfig final { uint8_t fdf{0x02}; uint8_t fmt{0x10}; uint8_t sourceChannelOffset{0}; + bool sph{false}; }; struct AudioStreamTxPolicy final { @@ -37,6 +40,10 @@ struct AudioStreamTxPolicy final { bool initializeNonAudioSlots{true}; bool preserveFdfInNoDataPackets{false}; bool emptyPacketsDuringIdle{false}; + // Optional per-stream map from each wire PCM slot to a zero-based host + // channel. Values are relative to sourceChannelOffset. + std::array sourceChannelForWireSlot{}; + bool sourceChannelMapEnabled{false}; }; // ADK packet allocation and AMDTP encoding are shared by multiple protocol diff --git a/ASFWDriver/Audio/DriverKit/Config/IAudioDeviceProfile.hpp b/ASFWDriver/Audio/DriverKit/Config/IAudioDeviceProfile.hpp index aa648a768..f893dbc15 100644 --- a/ASFWDriver/Audio/DriverKit/Config/IAudioDeviceProfile.hpp +++ b/ASFWDriver/Audio/DriverKit/Config/IAudioDeviceProfile.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // IAudioDeviceProfile.hpp @@ -12,6 +13,13 @@ namespace ASFW::Isoch::Audio { +/// One-based Core Audio channel numbers for a device's default stereo route. +/// A zero value means the profile has no hardware-specific preference. +struct PreferredStereoChannels { + uint32_t left{0}; + uint32_t right{0}; +}; + /// Protocol-agnostic device profile interface for ADK Dext configuration. /// Since the driver is compiled with -fno-rtti, all polymorphism is statically /// declared as virtual interface methods. @@ -66,6 +74,12 @@ class IAudioDeviceProfile { return {48000u}; } + /// Preferred output pair for ordinary stereo clients. Multichannel clients + /// still retain direct access to every advertised output channel. + [[nodiscard]] virtual PreferredStereoChannels PreferredOutputStereoChannels() const noexcept { + return {}; + } + /// IEC 61883-6 presentation delay removed from received SYT before replay. [[nodiscard]] virtual uint32_t RxTransferDelayTicks(double sampleRate) const noexcept { (void)sampleRate; diff --git a/ASFWDriver/Audio/DriverKit/Config/MOTU/MotuUltraLiteProfile.hpp b/ASFWDriver/Audio/DriverKit/Config/MOTU/MotuUltraLiteProfile.hpp new file mode 100644 index 000000000..3bbe8290c --- /dev/null +++ b/ASFWDriver/Audio/DriverKit/Config/MOTU/MotuUltraLiteProfile.hpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project + +#pragma once + +#include "../AudioStreamProfile.hpp" +#include "../../../Wire/MOTU/MotuBlockLayout.hpp" + +namespace ASFW::Isoch::Audio::MOTU { + +class MotuUltraLiteProfile final : public IAudioStreamProfile { +public: + [[nodiscard]] const char* Name() const noexcept override { return "MOTU UltraLite"; } + [[nodiscard]] Encoding::AudioWireFormat TxWireFormat() const noexcept override { + return Encoding::AudioWireFormat::kMotuV2; + } + [[nodiscard]] Encoding::AudioWireFormat RxWireFormat() const noexcept override { + return Encoding::AudioWireFormat::kMotuV2; + } + + [[nodiscard]] bool BuildDefaultTxStreamConfig( + AudioStreamConfig& out) const noexcept override { + Fill(out, AudioStreamDirection::HostToDevice); + return true; + } + [[nodiscard]] bool BuildDefaultRxStreamConfig( + AudioStreamConfig& out) const noexcept override { + Fill(out, AudioStreamDirection::DeviceToHost); + return true; + } + + [[nodiscard]] uint32_t TxSafetyOffsetFrames(double) const noexcept override { return 256; } + [[nodiscard]] uint32_t RxSafetyOffsetFrames(double) const noexcept override { return 256; } + [[nodiscard]] uint32_t TxReportedLatencyFrames(double) const noexcept override { return 512; } + [[nodiscard]] uint32_t RxReportedLatencyFrames(double) const noexcept override { return 512; } + [[nodiscard]] std::vector SupportedSampleRates() const override { + return {48000u}; + } + + [[nodiscard]] PreferredStereoChannels PreferredOutputStereoChannels() const noexcept override { + return {.left = 1, .right = 2}; + } + + [[nodiscard]] AudioStreamTxPolicy TxStreamPolicy() const noexcept override { + return AudioStreamTxPolicy{ + .hostToDevicePcmEncoding = Encoding::AudioWireFormat::kMotuV2, + .variableDbs = false, + .defaultNonAudioSlotWord = 0, + .initializeNonAudioSlots = false, + .preserveFdfInNoDataPackets = true, + .emptyPacketsDuringIdle = false, + // Match the stream order created by MOTU's original kext: Main, + // Analog 1-8, S/PDIF, Phones. + .sourceChannelForWireSlot = + Encoding::Motu::kUltraLiteOutputSourceForWireChannel, + .sourceChannelMapEnabled = true, + }; + } + +private: + static void Fill(AudioStreamConfig& out, AudioStreamDirection direction) noexcept { + out = {}; + out.direction = direction; + out.sampleRate = 48000; + out.streamMode = Encoding::StreamMode::kBlocking; + out.sid = 0; + out.pcmChannels = 14; + // MOTU uses 13 quadlets per data block: one SPH quadlet followed by + // two 3-byte message chunks and fourteen packed 24-bit PCM chunks. + out.dbs = 13; + out.midiSlots = 0; + // The UltraLite's 48 kHz receive stream uses the 8/8/8/0 blocking + // cadence. TX replays that physical cadence, so the slot geometry must + // accept the largest observed packet rather than the six-frame average. + out.framesPerDataPacket = 8; + out.fdf = 0x22; + out.fmt = 0x02; + out.sph = true; + } +}; + +} // namespace ASFW::Isoch::Audio::MOTU diff --git a/ASFWDriver/Audio/DriverKit/Runtime/AudioGraphBinding.hpp b/ASFWDriver/Audio/DriverKit/Runtime/AudioGraphBinding.hpp index 824df24ac..f10aa44fd 100644 --- a/ASFWDriver/Audio/DriverKit/Runtime/AudioGraphBinding.hpp +++ b/ASFWDriver/Audio/DriverKit/Runtime/AudioGraphBinding.hpp @@ -1,4 +1,5 @@ #pragma once +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "AudioStreamMemory.hpp" #include "AudioTransportControlBlock.hpp" @@ -19,6 +20,7 @@ enum class AudioWireFormat : uint32_t { kUnknown = 0, kAM824 = 1, kRawPcm24In32 = 2, + kMotuV2 = 3, }; struct AudioGraphBinding final { diff --git a/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.cpp b/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.cpp index 59c466b34..f5f506b94 100644 --- a/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.cpp +++ b/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project #include "DirectAudioReceiveConsumer.hpp" @@ -6,6 +7,7 @@ #include "../../../../Common/TimingUtils.hpp" #include "../../../../Logging/Logging.hpp" #include "../../../../Shared/Isoch/AudioTimingGeometry.hpp" +#include "../../../Wire/MOTU/MotuSph.hpp" #include @@ -175,7 +177,10 @@ void DirectAudioReceiveConsumer::ConsumePacket( const auto result = processor_.ProcessPacket( packet.payload.data(), packet.payload.size(), absoluteFrameCursor_, channels, inputView_.deviceToHostAm824Slots, configuration_.wireFormat, - configuration_.channelOffset, !configuration_.isSecondary); + configuration_.channelOffset, !configuration_.isSecondary, + configuration_.hostChannelMapEnabled + ? &configuration_.wireChannelForHostChannel + : nullptr); // Attribute every decoded packet before the reject branch returns; the // master stream only, so a second slice cannot double-count. if (!configuration_.isSecondary && inputView_.control) { @@ -310,11 +315,35 @@ void DirectAudioReceiveConsumer::ConsumePacket( inputView_.control->rxTransferDelayTicks.load(std::memory_order_relaxed)); replayEntry.flags |= ::ASFW::Audio::Runtime::RxSequenceFlags::kValidSyt; } + const bool isMotu = + configuration_.wireFormat == ::ASFW::Encoding::AudioWireFormat::kMotuV2; + if (isMotu && result.motuSphCount == result.framesDecoded && + result.motuSphCount <= + ::ASFW::Audio::Runtime::RxSequenceEntry::kMaxSphPerPacket) { + const uint32_t baseTick = + cycleFields.cycle * ::ASFW::Encoding::Motu::kTicksPerCycle; + replayEntry.motuSphCount = result.motuSphCount; + for (uint32_t i = 0; i < result.motuSphCount; ++i) { + replayEntry.motuSphOffsets[i] = + ::ASFW::Encoding::Motu::TickOffsetFromBase( + result.motuSph[i], baseTick); + } + replayEntry.flags |= + ::ASFW::Audio::Runtime::RxSequenceFlags::kValidMotuSph; + } inputView_.control->rxSequenceReplay.Publish(replayEntry); inputView_.control->rxReplayEntries.fetch_add(1, std::memory_order_relaxed); ::ASFW::Driver::RxSytCadence::Snapshot cadence{}; - if (inputView_.control->rxSytCadence.TrySnapshot(cadence) && cadence.established) { + const bool standardCadenceEstablished = + inputView_.control->rxSytCadence.TrySnapshot(cadence) && + cadence.established; + const bool timingEstablished = + isMotu + ? inputView_.control->rxSequenceReplay.ProducerCursor() >= + ::ASFW::Audio::Runtime::RxSequenceReplayState::kReadDelay + : standardCadenceEstablished; + if (timingEstablished) { if (!inputView_.control->rxSequenceReplay.IsEstablished()) { (void)inputView_.control->rxSequenceReplay.MarkEstablished(); } @@ -350,7 +379,7 @@ void DirectAudioReceiveConsumer::ConsumePacket( static_cast((1'000'000'000ULL << 8) / inputView_.sampleRateHz); if (kZtsPeriodFrames != 0 && result.framesDecoded != 0 && (packetFirstFrame % kZtsPeriodFrames) == 0 && packetHostTicks != 0 && - nanosPerSampleQ8 != 0 && clockPublisher_.IsBound() && cadence.established) { + nanosPerSampleQ8 != 0 && clockPublisher_.IsBound() && timingEstablished) { const auto publish = clockPublisher_.Publish(packetFirstFrame, packetHostTicks, nanosPerSampleQ8); if (!publish.accepted) { diff --git a/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.hpp b/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.hpp index a7fcdc3c5..28300f653 100644 --- a/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.hpp +++ b/ASFWDriver/Audio/Engine/Direct/Rx/DirectAudioReceiveConsumer.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project #pragma once @@ -27,6 +28,8 @@ class DirectAudioReceiveConsumer final : public ::ASFW::Isoch::IIsochReceiveCons uint32_t am824Slots{0}; uint32_t channelOffset{0}; uint32_t streamChannels{0}; + std::array wireChannelForHostChannel{}; + bool hostChannelMapEnabled{false}; bool isSecondary{false}; }; diff --git a/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.cpp b/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.cpp index d10a60462..c71fa155c 100644 --- a/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.cpp +++ b/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.cpp @@ -1,6 +1,9 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "RxAudioPacketProcessor.hpp" #include "DirectRxPacketDecoder.hpp" #include "../../../Wire/CIP/CIPHeader.hpp" +#include "../../../Wire/MOTU/MotuBlockCodec.hpp" +#include "../../../Wire/MOTU/MotuBlockLayout.hpp" #include "../../../../Isoch/Receive/IsochRxTiming.hpp" #include @@ -17,7 +20,9 @@ RxAudioPacketProcessorResult RxAudioPacketProcessor::ProcessPacket(const uint8_t uint32_t am824Slots, ASFW::Encoding::AudioWireFormat format, uint32_t channelOffset, - bool publishTimeline) noexcept { + bool publishTimeline, + const std::array* + wireChannelForHostChannel) noexcept { RxAudioPacketProcessorResult result{}; if (length < kIsochHeaderSize + 8) { @@ -46,7 +51,13 @@ RxAudioPacketProcessorResult RxAudioPacketProcessor::ProcessPacket(const uint8_t result.dbc = cip->dataBlockCounter; const size_t payloadBytes = length - kIsochHeaderSize - 8; - const size_t dbsBytes = static_cast(cip->dataBlockSize) * 4u; + const bool isMotu = + format == ASFW::Encoding::AudioWireFormat::kMotuV2; + // Original UltraLite capture packets report an unreliable CIP DBS. The + // configured MOTU block geometry is authoritative for this wire format. + const size_t dbsBytes = isMotu + ? static_cast(am824Slots) * 4u + : static_cast(cip->dataBlockSize) * 4u; if (dbsBytes == 0) { result.status = DirectRxWriteStatus::kZeroDataBlockSize; return result; @@ -68,11 +79,23 @@ RxAudioPacketProcessorResult RxAudioPacketProcessor::ProcessPacket(const uint8_t // Geometry validation if (channels == 0 || - cip->dataBlockSize < channels || - am824Slots != cip->dataBlockSize) { + (!isMotu && + (cip->dataBlockSize < channels || + am824Slots != cip->dataBlockSize)) || + (isMotu && + (am824Slots != ASFW::Encoding::Motu::DataBlockQuadlets(channels) || + channels != 14))) { result.status = DirectRxWriteStatus::kGeometryMismatch; return result; } + if (wireChannelForHostChannel) { + for (uint32_t hostChannel = 0; hostChannel < channels; ++hostChannel) { + if ((*wireChannelForHostChannel)[hostChannel] >= channels) { + result.status = DirectRxWriteStatus::kGeometryMismatch; + return result; + } + } + } // If armed: decode quadlets directly to ADK input memory const uint32_t* dataBlocks = &quadlets[2]; @@ -83,11 +106,30 @@ RxAudioPacketProcessorResult RxAudioPacketProcessor::ProcessPacket(const uint8_t return result; } - const uint32_t* frameIn = dataBlocks + (i * cip->dataBlockSize); - // Write this stream's slice at its channel offset into the interleaved - // frame; the writer stride covers the buffer's full channel width. - DecodeDirectRxFrame(frameIn, channels, cip->dataBlockSize, format, - frameOut + channelOffset); + if (isMotu) { + const auto* blockBytes = + reinterpret_cast(dataBlocks) + i * dbsBytes; + const std::span block(blockBytes, dbsBytes); + if (i < result.motuSph.size()) { + result.motuSph[i] = ASFW::Encoding::Motu::ReadSph(block); + ++result.motuSphCount; + } + for (uint32_t hostChannel = 0; hostChannel < channels; ++hostChannel) { + const uint32_t wireChannel = wireChannelForHostChannel + ? (*wireChannelForHostChannel)[hostChannel] + : hostChannel; + const int32_t sample = + ASFW::Encoding::Motu::ReadPcmChannel(block, wireChannel) >> 8; + frameOut[channelOffset + hostChannel] = + Detail::Signed24ToFloat32(sample); + } + } else { + const uint32_t* frameIn = dataBlocks + (i * cip->dataBlockSize); + // Write this stream's slice at its channel offset into the interleaved + // frame; the writer stride covers the buffer's full channel width. + DecodeDirectRxFrame(frameIn, channels, cip->dataBlockSize, format, + frameOut + channelOffset); + } } // Only the master stream advances the producer cursor/frame counters; a diff --git a/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.hpp b/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.hpp index fa3a69c69..202765495 100644 --- a/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.hpp +++ b/ASFWDriver/Audio/Engine/Direct/Rx/RxAudioPacketProcessor.hpp @@ -1,4 +1,5 @@ #pragma once +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "../DirectInputWriter.hpp" #include "DirectRxTypes.hpp" @@ -6,6 +7,7 @@ #include #include +#include namespace ASFW::AudioEngine::Direct::Rx { @@ -19,6 +21,8 @@ struct RxAudioPacketProcessorResult final { uint8_t fdf{0}; uint8_t dbs{0}; uint8_t dbc{0}; + std::array motuSph{}; + uint8_t motuSphCount{0}; }; class RxAudioPacketProcessor final { @@ -39,7 +43,9 @@ class RxAudioPacketProcessor final { uint32_t am824Slots, ASFW::Encoding::AudioWireFormat format, uint32_t channelOffset = 0, - bool publishTimeline = true) noexcept; + bool publishTimeline = true, + const std::array* + wireChannelForHostChannel = nullptr) noexcept; private: DirectInputWriter& writer_; diff --git a/ASFWDriver/Audio/Engine/Direct/Tx/DiceTxStreamEngine.cpp b/ASFWDriver/Audio/Engine/Direct/Tx/DiceTxStreamEngine.cpp index 0b0aa340d..5b7860083 100644 --- a/ASFWDriver/Audio/Engine/Direct/Tx/DiceTxStreamEngine.cpp +++ b/ASFWDriver/Audio/Engine/Direct/Tx/DiceTxStreamEngine.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "DiceTxStreamEngine.hpp" namespace ASFW::Protocols::Audio::DICE { @@ -15,6 +16,7 @@ AMDTP::AmdtpStreamConfig DiceStreamConfigMapper::ToAmdtpConfig( config.midiSlots = streamConfig.midiSlots; config.fmt = streamConfig.fmt; config.fdf = streamConfig.fdf; + config.sph = streamConfig.sph; config.framesPerDataPacket = streamConfig.framesPerDataPacket; config.sourceChannelOffset = streamConfig.sourceChannelOffset; // Compute the true max packet size: CIP headers (8 bytes) + frames × DBS × 4 bytes/slot. @@ -32,6 +34,13 @@ bool DiceTxStreamEngine::Configure(const ASFW::Isoch::Audio::IAudioStreamProfile } const ASFW::Isoch::Audio::AudioStreamTxPolicy txPolicy = profile.TxStreamPolicy(); + if (txPolicy.sourceChannelMapEnabled) { + for (uint32_t wireSlot = 0; wireSlot < txConfig.pcmChannels; ++wireSlot) { + if (txPolicy.sourceChannelForWireSlot[wireSlot] >= txConfig.pcmChannels) { + return false; + } + } + } const AMDTP::AmdtpStreamConfig amdtpConfig = DiceStreamConfigMapper::ToAmdtpConfig(txConfig); const AMDTP::AmdtpTxPolicy policy = BuildTxPolicy(txPolicy); @@ -147,9 +156,14 @@ DiceTxStreamEngine::PayloadWriterCounters() const noexcept { AMDTP::AmdtpTxPolicy DiceTxStreamEngine::BuildTxPolicy( const ASFW::Isoch::Audio::AudioStreamTxPolicy& streamPolicy) const noexcept { AMDTP::AmdtpTxPolicy policy{}; - policy.hostToDevicePcmEncoding = (streamPolicy.hostToDevicePcmEncoding == ASFW::Encoding::AudioWireFormat::kRawPcm24In32) - ? AMDTP::PcmSlotEncoding::RawSigned24In32BE - : AMDTP::PcmSlotEncoding::Am824MBLA; + if (streamPolicy.hostToDevicePcmEncoding == ASFW::Encoding::AudioWireFormat::kMotuV2) { + policy.hostToDevicePcmEncoding = AMDTP::PcmSlotEncoding::MotuV2Packed24; + } else if (streamPolicy.hostToDevicePcmEncoding == + ASFW::Encoding::AudioWireFormat::kRawPcm24In32) { + policy.hostToDevicePcmEncoding = AMDTP::PcmSlotEncoding::RawSigned24In32BE; + } else { + policy.hostToDevicePcmEncoding = AMDTP::PcmSlotEncoding::Am824MBLA; + } policy.dbsPolicy = streamPolicy.variableDbs ? AMDTP::DbsPolicy::VariablePerPacket : AMDTP::DbsPolicy::Constant; @@ -157,6 +171,8 @@ AMDTP::AmdtpTxPolicy DiceTxStreamEngine::BuildTxPolicy( policy.initializeNonAudioSlots = streamPolicy.initializeNonAudioSlots; policy.preserveFdfInNoDataPackets = streamPolicy.preserveFdfInNoDataPackets; policy.emptyPacketsDuringIdle = streamPolicy.emptyPacketsDuringIdle; + policy.sourceChannelForWireSlot = streamPolicy.sourceChannelForWireSlot; + policy.sourceChannelMapEnabled = streamPolicy.sourceChannelMapEnabled; policy.clearPayloadBeforeExposure = true; return policy; } diff --git a/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp b/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp index 4cac49bf6..9c04e0322 100644 --- a/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp +++ b/ASFWDriver/Audio/Protocols/Backends/AudioDuplexCoordinator.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project #include "AudioDuplexCoordinator.hpp" @@ -35,6 +36,24 @@ using ASFW::Audio::DICE::HasRestartIntent; constexpr uint32_t kClockRequestWaitTimeoutMs = 15000; constexpr uint32_t kDuetFixedSampleRateHz = 48000U; +[[nodiscard]] constexpr bool IsMotuUltraLite( + const Discovery::DeviceRecord& record) noexcept { + return record.vendorId == DeviceProfiles::Audio::kMotuVendorId && + record.modelId == DeviceProfiles::Audio::kMotuUltraliteSwVersion; +} + +[[nodiscard]] bool IsDirectMotuUltraLiteBus( + const Discovery::DeviceRecord& record, + Driver::HardwareInterface& hardware) noexcept { + // On a two-node 1394 bus the root receives physical ID 1 and the only + // remote node receives ID 0. This exact check keeps the no-IRM channel and + // cycle-master fallback scoped to the directly attached UltraLite setup it + // was designed for; it must never enable cycle-master while another node + // is root. + return IsMotuUltraLite(record) && record.nodeId == 0 && + (hardware.ReadNodeID() & 0x3fu) == 1; +} + // The Duet format-control path is deliberately start-time only for now. Do // not resurrect a rate retained in a restart session: the host geometry and // the device's unit-plug formation must enter the start transaction together @@ -1036,6 +1055,9 @@ IOReturn DuplexStartTransaction::Run(const StartRequest& request) noexcept { session.runtimeCaps = prepare.value.runtimeCaps; DuplexStreamProfile streamProfile = DuplexStreamProfileResolver::Resolve(record, session.runtimeCaps, channels); + const bool directMotuUltraLiteBus = + IsDirectMotuUltraLiteBus(record, hardware_); + bool usedDirectMotuNoIrmFallback = false; SetSessionPhase(session, DuplexRestartPhase::kPrepared); StoreSession(session); @@ -1055,9 +1077,26 @@ IOReturn DuplexStartTransaction::Run(const StartRequest& request) noexcept { guid, *irmClient, geometry.allowedIsoChannels, geometry.bandwidthUnits, assignedChannel); if (reservePlaybackStatus != kIOReturnSuccess) { - return rollbackToFailure(reservePlaybackStatus, - DuplexRestartPhase::kReservingPlaybackResources, - DuplexRestartFailureCause::kReservePlayback); + // A direct Mac <-> UltraLite bus can have no contender-capable node, + // hence no IRM. There is no competing initiator on that two-node + // topology, and MOTU protocol v2 already defines the provisional + // point-to-point channels (playback 0, capture 1). Preserve those + // channels only for the exact UltraLite profile and only for the + // explicit "IRM not found" result. Bandwidth exhaustion, timeouts, + // and every other device profile retain strict IRM failure handling. + if (reservePlaybackStatus == kIOReturnNoDevice && + directMotuUltraLiteBus && geometry.isoChannel <= 0x3f) { + assignedChannel = geometry.isoChannel; + usedDirectMotuNoIrmFallback = true; + ASFW_LOG(Audio, + "AudioDuplexCoordinator: no IRM on direct UltraLite bus; " + "using fixed playback channel %u GUID=%llx", + assignedChannel, guid); + } else { + return rollbackToFailure(reservePlaybackStatus, + DuplexRestartPhase::kReservingPlaybackResources, + DuplexRestartFailureCause::kReservePlayback); + } } channels.playbackIsoChannels[i] = assignedChannel; if (i == 0) { @@ -1084,9 +1123,19 @@ IOReturn DuplexStartTransaction::Run(const StartRequest& request) noexcept { guid, *irmClient, geometry.allowedIsoChannels, geometry.bandwidthUnits, assignedChannel); if (reserveCaptureStatus != kIOReturnSuccess) { - return rollbackToFailure(reserveCaptureStatus, - DuplexRestartPhase::kReservingCaptureResources, - DuplexRestartFailureCause::kReserveCapture); + if (reserveCaptureStatus == kIOReturnNoDevice && + directMotuUltraLiteBus && geometry.isoChannel <= 0x3f) { + assignedChannel = geometry.isoChannel; + usedDirectMotuNoIrmFallback = true; + ASFW_LOG(Audio, + "AudioDuplexCoordinator: no IRM on direct UltraLite bus; " + "using fixed capture channel %u GUID=%llx", + assignedChannel, guid); + } else { + return rollbackToFailure(reserveCaptureStatus, + DuplexRestartPhase::kReservingCaptureResources, + DuplexRestartFailureCause::kReserveCapture); + } } channels.captureIsoChannels[i] = assignedChannel; if (i == 0) { @@ -1111,6 +1160,27 @@ IOReturn DuplexStartTransaction::Run(const StartRequest& request) noexcept { streamProfile = DuplexStreamProfileResolver::Resolve(record, session.runtimeCaps, channels); StoreSession(session); + // A direct UltraLite bus has no contender-capable IRM and therefore no bus + // manager to start cycle packets. The Mac is already the verified root in + // this exact two-node topology, so enable its OHCI cycle master before + // starting either isochronous DMA context. Without cycle-start packets the + // contexts remain ACTIVE at their first descriptor and never interrupt. + if (usedDirectMotuNoIrmFallback && + !hardware_.IsLocalCycleMasterEnabled()) { + if (!hardware_.SetLocalCycleMasterEnabled(true)) { + ASFW_LOG_ERROR( + Audio, + "AudioDuplexCoordinator: failed to enable cycle master for direct UltraLite GUID=%llx", + guid); + return rollbackToFailure(kIOReturnNotReady, + DuplexRestartPhase::kStartingHostReceive, + DuplexRestartFailureCause::kStartReceive); + } + ASFW_LOG(Audio, + "AudioDuplexCoordinator: enabled local cycle master for direct UltraLite GUID=%llx", + guid); + } + ASFW_LOG(Audio, "AUDIO DUPLEX START guid=0x%016llx ir=%u it=%u inCh=%u outCh=%u inSlots=%u outSlots=%u " "mode=blocking rxFmt=%u txFmt=%u", diff --git a/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp b/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp index 6465d69cc..4a8a8e891 100644 --- a/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp +++ b/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // DuplexStreamProfile.hpp - Resolved stream geometry and host-start recipe for duplex audio @@ -202,6 +203,12 @@ class DuplexStreamProfileResolver final { return DeviceProfiles::Audio::BeBoB::IsBeBoBDevice(record.vendorId, record.modelId); } + [[nodiscard]] static constexpr bool + IsMotuUltraLite(const Discovery::DeviceRecord& record) noexcept { + return record.vendorId == DeviceProfiles::Audio::kMotuVendorId && + record.modelId == DeviceProfiles::Audio::kMotuUltraliteSwVersion; + } + [[nodiscard]] static AudioDuplexChannels ResolveChannels(const Discovery::DeviceRecord& record, const AudioStreamRuntimeCaps& caps) noexcept { @@ -279,7 +286,8 @@ class DuplexStreamProfileResolver final { geometry.am824Slots, caps.sampleRateHz, record.link.localToNode); // CMP (including BridgeCo/BeBoB) does not own a fixed channel; // IRM selects one, which is then committed back to its PCR. - geometry.allowedIsoChannels = (IsApogeeDuet(record) || IsBeBoB(record)) + geometry.allowedIsoChannels = (IsApogeeDuet(record) || IsBeBoB(record) || + IsMotuUltraLite(record)) ? kAllIsoChannels : FixedChannelMask(geometry.isoChannel); captureChannelOffset += geometry.pcmChannels; @@ -297,7 +305,8 @@ class DuplexStreamProfileResolver final { : (i == 0 ? caps.hostToDeviceAm824Slots : 0U); geometry.bandwidthUnits = AmdtpBandwidthUnits( geometry.am824Slots, caps.sampleRateHz, record.link.localToNode); - geometry.allowedIsoChannels = (IsApogeeDuet(record) || IsBeBoB(record)) + geometry.allowedIsoChannels = (IsApogeeDuet(record) || IsBeBoB(record) || + IsMotuUltraLite(record)) ? kAllIsoChannels : FixedChannelMask(geometry.isoChannel); } @@ -321,6 +330,17 @@ class DuplexStreamProfileResolver final { profile.stopOrder .disconnectPlaybackThenStopTransmitThenDisconnectCaptureThenStopReceive = true; } + if (IsMotuUltraLite(record)) { + profile.captureWireFormat = Encoding::AudioWireFormat::kMotuV2; + profile.playbackWireFormat = Encoding::AudioWireFormat::kMotuV2; + profile.startOrder.startReceiveBeforeDeviceRx = true; + profile.startOrder.requiresPreStreamClockLock = false; + profile.startOrder.startOrder = { + DuplexHostDirection::kReceive, + DuplexHostDirection::kTransmit, + }; + profile.startOrder.postDeviceEnableDelayMs = 0; + } if (IsBeBoB(record)) { // Linux BeBoB reserves both CMP resources, establishes remote // iPCR then oPCR, and only then starts the AMDTP domain (RX before diff --git a/ASFWDriver/Audio/Protocols/Backends/IsochDuplexHostTransport.cpp b/ASFWDriver/Audio/Protocols/Backends/IsochDuplexHostTransport.cpp index ceba15a30..01d2851fc 100644 --- a/ASFWDriver/Audio/Protocols/Backends/IsochDuplexHostTransport.cpp +++ b/ASFWDriver/Audio/Protocols/Backends/IsochDuplexHostTransport.cpp @@ -1,10 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project #include "IsochDuplexHostTransport.hpp" #include "../../../Common/DriverKitOwnership.hpp" #include "../../../Logging/Logging.hpp" +#include "../../Wire/MOTU/MotuBlockLayout.hpp" #include #include #include @@ -27,6 +29,11 @@ kern_return_t IsochDuplexHostTransport::AttachReceiveConsumer( .streamChannels = streamChannels, .isSecondary = isSecondary, }; + if (wireFormat == Encoding::AudioWireFormat::kMotuV2) { + configuration.wireChannelForHostChannel = + Encoding::Motu::kUltraLiteInputWireChannelForHostChannel; + configuration.hostChannelMapEnabled = true; + } // This is a DriverKit `noexcept` boundary: report allocation failure instead // of allowing std::make_unique to terminate the driver process. auto consumer = std::unique_ptr(new (std::nothrow) Consumer(bindingSource, configuration)); diff --git a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp index 95620e663..fab73354e 100644 --- a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp +++ b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2024 ASFireWire Project // // DeviceProtocolFactory.cpp - Factory for creating device-specific protocol handlers @@ -6,6 +7,7 @@ #include "DeviceProtocolFactory.hpp" #include "DICE/Focusrite/SPro24DspProtocol.hpp" #include "DICE/TCAT/DICETcatProtocol.hpp" +#include "MOTU/MotuV2Protocol.hpp" #include "Oxford/Apogee/ApogeeDuetProtocol.hpp" #include "BeBoB/Phase88Protocol.hpp" #include "BeBoB/GenericBeBoBProtocol.hpp" @@ -23,12 +25,29 @@ std::unique_ptr DeviceProtocolFactory::Create( const Discovery::DeviceRouteToken& route, IRM::IRMClient* irmClient, CMP::CMPClient* cmpClient, - Scheduling::ITimerScheduler* timerScheduler + Scheduling::ITimerScheduler* timerScheduler, + UnitIdentity unit ) { if (!route) { return nullptr; } const uint16_t nodeId = route.nodeId; + + // MOTU first: it is the one family that model_id cannot discriminate (the root + // directory publishes model_id 0), so it is matched on the unit directory instead. + if (DeviceProfiles::Audio::Motu::LookupAudioProfile( + DeviceProfiles::DeviceProfileQuery{.vendorId = vendorId, + .modelId = modelId, + .unitSpecId = unit.specId, + .unitSwVersion = unit.swVersion}) + .has_value()) { + ASFW_LOG(Audio, + "Creating MotuV2Protocol vendor=0x%06x version=0x%06x node=0x%04x", + vendorId, unit.swVersion, nodeId); + return std::make_unique( + busOps, busInfo, routeRegistry, route, irmClient, unit.swVersion); + } + if (vendorId == kFocusriteVendorId) { if (modelId == kSPro24DspModelId) { ASFW_LOG(DICE, "Creating SPro24DspProtocol for 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..3fad760c6 100644 --- a/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.hpp +++ b/ASFWDriver/Audio/Protocols/DeviceProtocolFactory.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2024 ASFireWire Project // // DeviceProtocolFactory.hpp - Factory for creating device-specific protocol handlers @@ -40,6 +41,16 @@ namespace ASFW::Audio { /// DeviceProfiles owns the data. using DeviceIntegrationMode = DeviceProfiles::Audio::AudioIntegrationMode; +/// Unit directory identity, for families that model_id cannot discriminate. +/// Defaulted at every call site so existing (vendorId, modelId) callers are unaffected; +/// MOTU is the first family that requires it (model_id is 0, the version carries the +/// model). Namespace-scope rather than nested in DeviceProtocolFactory so it can be used +/// as a defaulted argument in that class's own member declarations. +struct DeviceUnitIdentity { + uint32_t specId{0}; + uint32_t swVersion{0}; +}; + /// Factory for creating device-specific protocol handlers /// /// Call Create() during device discovery to instantiate the appropriate @@ -103,6 +114,8 @@ class DeviceProtocolFactory { static constexpr const char* kStudioLive1602ModelName = DeviceProfiles::Audio::kStudioLive1602ModelName; + using UnitIdentity = DeviceUnitIdentity; + struct KnownIdentity { uint32_t vendorId{0}; uint32_t modelId{0}; @@ -122,9 +135,13 @@ class DeviceProtocolFactory { /// Resolve a known device identity by vendor/model. Delegates to DeviceProfiles. static constexpr std::optional LookupKnownIdentity( uint32_t vendorId, - uint32_t modelId + uint32_t modelId, + UnitIdentity unit = {} ) noexcept { - return Combine(DeviceProfiles::DeviceProfileQuery{.vendorId = vendorId, .modelId = modelId}); + return Combine(DeviceProfiles::DeviceProfileQuery{.vendorId = vendorId, + .modelId = modelId, + .unitSpecId = unit.specId, + .unitSwVersion = unit.swVersion}); } // Focusrite DICE devices encode the board model in GUID bits [27:22]. The legacy @@ -144,16 +161,22 @@ class DeviceProtocolFactory { /// Resolve integration mode for a known vendor/model pair. Delegates to DeviceProfiles. static constexpr DeviceIntegrationMode LookupIntegrationMode( uint32_t vendorId, - uint32_t modelId + uint32_t modelId, + UnitIdentity unit = {} ) noexcept { const auto profile = DeviceProfiles::Audio::AudioProfileRegistry{}.LookupBestAudioProfile( - DeviceProfiles::DeviceProfileQuery{.vendorId = vendorId, .modelId = modelId}); + DeviceProfiles::DeviceProfileQuery{.vendorId = vendorId, + .modelId = modelId, + .unitSpecId = unit.specId, + .unitSwVersion = unit.swVersion}); return profile.has_value() ? profile->mode : DeviceIntegrationMode::kNone; } /// Check if a device identity is recognized. - static constexpr bool IsKnownDevice(uint32_t vendorId, uint32_t modelId) noexcept { - return LookupKnownIdentity(vendorId, modelId).has_value(); + static constexpr bool IsKnownDevice(uint32_t vendorId, + uint32_t modelId, + UnitIdentity unit = {}) noexcept { + return LookupKnownIdentity(vendorId, modelId, unit).has_value(); } /// Create a protocol handler for the given vendor/model @@ -162,6 +185,7 @@ class DeviceProtocolFactory { /// @param busOps FireWire bus operations port /// @param busInfo FireWire bus info port /// @param route Current, registry-issued route token + /// @param unit Unit directory identity, for families model_id cannot match /// @return Protocol handler, or nullptr if device is not recognized static std::unique_ptr Create( uint32_t vendorId, @@ -172,7 +196,8 @@ class DeviceProtocolFactory { const Discovery::DeviceRouteToken& route, ::ASFW::IRM::IRMClient* irmClient = nullptr, ::ASFW::CMP::CMPClient* cmpClient = nullptr, - Scheduling::ITimerScheduler* timerScheduler = nullptr + Scheduling::ITimerScheduler* timerScheduler = nullptr, + UnitIdentity unit = {} ); private: diff --git a/ASFWDriver/Audio/Protocols/MOTU/MotuV2Protocol.cpp b/ASFWDriver/Audio/Protocols/MOTU/MotuV2Protocol.cpp new file mode 100644 index 000000000..bd2ced66a --- /dev/null +++ b/ASFWDriver/Audio/Protocols/MOTU/MotuV2Protocol.cpp @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuV2Protocol.cpp - MOTU protocol-v2 register device protocol. +// +// Register semantics cross-validated with Linux sound/firewire/motu +// (motu-protocol-v2.c, motu-transaction.c) and confirmed against an 828mkII on +// 2026-07-26: clock status read back 0x00000008 (48 kHz, internal) matching the +// device's own front panel, and a rate write moved the hardware rate display. + +#include "MotuV2Protocol.hpp" + +#include "../../../DeviceProfiles/Audio/AudioDeviceIds.hpp" +#include "../../../DeviceProfiles/Audio/Vendors/MotuAudioProfiles.hpp" +#include "../../../Logging/Logging.hpp" + +namespace ASFW::Audio::Motu { + +namespace { +constexpr uint16_t kAddrHi = static_cast(kAddrBase >> 32); +constexpr uint32_t kAddrLo = static_cast(kAddrBase); +} // namespace + +Async::FWAddress MotuV2Protocol::AddressOf(Reg reg) noexcept { + return Async::FWAddress(Async::FWAddress::AddressParts{ + .addressHi = kAddrHi, + .addressLo = kAddrLo + static_cast(reg)}); +} + +MotuV2Protocol::MotuV2Protocol(Protocols::Ports::FireWireBusOps& busOps, + Protocols::Ports::FireWireBusInfo& busInfo, + Discovery::DeviceRegistry& routeRegistry, + const Discovery::DeviceRouteToken& route, + ::ASFW::IRM::IRMClient* irmClient, + uint32_t unitSwVersion) + : io_(busOps, busInfo, routeRegistry, route) + , irmClient_(irmClient) + , route_(route) + , unitSwVersion_(unitSwVersion) {} + +const char* MotuV2Protocol::GetName() const { + const char* const model = + DeviceProfiles::Audio::Motu::ModelNameForSwVersion(unitSwVersion_); + return model != nullptr ? model : "MOTU (protocol v2)"; +} + +IOReturn MotuV2Protocol::Initialize() { + initialized_ = true; + + // Prime the cached clock state. Best-effort: a failure here leaves the cache at 0 + // ("not yet known") rather than failing device creation, since every consumer reads + // the register on demand anyway. + ReadClockStatus([](IOReturn status, ClockStatus clock) { + if (status != kIOReturnSuccess) { + ASFW_LOG(Audio, "MotuV2Protocol: initial clock read failed: 0x%x", status); + return; + } + ASFW_LOG(Audio, + "MotuV2Protocol: clock status raw=0x%08x rate=%uHz source=%u", + clock.raw, + clock.sampleRateHz, + clock.source.has_value() ? static_cast(*clock.source) : 0xFFFFFFFFu); + }); + + return kIOReturnSuccess; +} + +IOReturn MotuV2Protocol::Shutdown() { + (void)StopDuplex(); + initialized_ = false; + cachedSampleRateHz_.store(0, std::memory_order_release); + + // Hand the device back to its front panel. Fire-and-forget: Shutdown is synchronous, + // and a device that has already gone away cannot be released anyway. + if (asyncAddressRegistered_.load(std::memory_order_acquire)) { + ReleaseAsyncMessageAddress([](IOReturn status) { + if (status != kIOReturnSuccess) { + ASFW_LOG(Audio, "MotuV2Protocol: async address release failed: 0x%x", status); + } + }); + } + + return kIOReturnSuccess; +} + +void MotuV2Protocol::UpdateRuntimeContext(const Discovery::DeviceRouteToken& route, + Protocols::AVC::FCPTransport* transport) { + (void)transport; // MOTU v2 is register-based; no AV/C transport. + io_.UpdateRoute(route); + route_ = route; +} + +bool MotuV2Protocol::GetRuntimeAudioStreamCaps( + AudioStreamRuntimeCaps& outCaps) const { + outCaps = {}; + outCaps.hostInputPcmChannels = 14; + outCaps.hostOutputPcmChannels = 14; + // This field is the generic wire data-block width. MOTU carries 13 + // quadlets per block while exposing 14 packed PCM channels. + outCaps.deviceToHostAm824Slots = Encoding::Motu::DataBlockQuadlets(14); + outCaps.hostToDeviceAm824Slots = Encoding::Motu::DataBlockQuadlets(14); + outCaps.sampleRateHz = + cachedSampleRateHz_.load(std::memory_order_acquire); + if (outCaps.sampleRateHz == 0) { + outCaps.sampleRateHz = 48000; + } + outCaps.deviceToHostStreamCount = 1; + outCaps.hostToDeviceStreamCount = 1; + outCaps.deviceToHostStreams[0] = { + .isoChannel = AudioStreamWireInfo::kInvalidIsoChannel, + .pcmChannels = 14, + .am824Slots = + static_cast(Encoding::Motu::DataBlockQuadlets(14)), + .midiPorts = 1, + }; + outCaps.hostToDeviceStreams[0] = + outCaps.deviceToHostStreams[0]; + return true; +} + +void MotuV2Protocol::SetAssignedChannels( + const AudioDuplexChannels& channels) noexcept { + assignedChannels_ = channels; +} + +void MotuV2Protocol::PrepareDuplex( + const AudioDuplexChannels& channels, + const AudioClockConfig& desiredClock, + PrepareCallback callback) { + if (!initialized_ || irmClient_ == nullptr) { + callback(kIOReturnNotReady, {}); + return; + } + if (desiredClock.sampleRateHz != 0 && + desiredClock.sampleRateHz != 48000) { + callback(kIOReturnUnsupported, {}); + return; + } + assignedChannels_ = channels; + SetSampleRate(48000, + [this, callback = std::move(callback)](IOReturn rateStatus) mutable { + if (rateStatus != kIOReturnSuccess) { + callback(rateStatus, {}); + return; + } + (void)io_.ReadQuadBE( + AddressOf(Reg::InOutConfV2), + [this, callback = std::move(callback)]( + Async::AsyncStatus ioStatus, uint32_t value) mutable { + const IOReturn status = + Protocols::Ports::MapAsyncStatusToIOReturn(ioStatus); + if (status != kIOReturnSuccess) { + callback(status, {}); + return; + } + if (const auto optical = + DecodeOptIfaceConfig(value); + optical.has_value()) { + txOnlyFixedChunks_ = + optical->input != OptIfaceMode::Adat; + rxOnlyFixedChunks_ = + optical->output != OptIfaceMode::Adat; + } + AudioStreamRuntimeCaps caps{}; + (void)GetRuntimeAudioStreamCaps(caps); + callback(kIOReturnSuccess, + DuplexPrepareResult{ + .generation = route_.generation, + .channels = assignedChannels_, + .appliedClock = + AudioClockConfig{.sampleRateHz = 48000}, + .runtimeCaps = caps, + }); + }); + }); +} + +void MotuV2Protocol::ProgramRx(StageCallback callback) { + AudioStreamRuntimeCaps caps{}; + (void)GetRuntimeAudioStreamCaps(caps); + callback(kIOReturnSuccess, + DuplexStageResult{ + .generation = route_.generation, + .channels = assignedChannels_, + .runtimeCaps = caps, + }); +} + +void MotuV2Protocol::ProgramTxAndEnableDuplex(StageCallback callback) { + if (!initialized_) { + callback(kIOReturnNotReady, {}); + return; + } + (void)io_.ReadQuadBE( + AddressOf(Reg::PacketFormat), + [this, callback = std::move(callback)]( + Async::AsyncStatus readStatus, uint32_t packetFormat) mutable { + IOReturn status = + Protocols::Ports::MapAsyncStatusToIOReturn(readStatus); + if (status != kIOReturnSuccess) { + callback(status, {}); + return; + } + const uint32_t encoded = EncodePacketFormat( + packetFormat, txOnlyFixedChunks_, rxOnlyFixedChunks_, + /*S400=*/2); + (void)io_.WriteQuadBE( + AddressOf(Reg::PacketFormat), encoded, + [this, callback = std::move(callback)]( + Async::AsyncStatus writeStatus) mutable { + const IOReturn status = + Protocols::Ports::MapAsyncStatusToIOReturn(writeStatus); + if (status != kIOReturnSuccess) { + callback(status, {}); + return; + } + (void)io_.ReadQuadBE( + AddressOf(Reg::IsocCommControl), + [this, callback = std::move(callback)]( + Async::AsyncStatus readIsoStatus, + uint32_t isoControl) mutable { + const IOReturn readResult = + Protocols::Ports::MapAsyncStatusToIOReturn( + readIsoStatus); + if (readResult != kIOReturnSuccess) { + callback(readResult, {}); + return; + } + const uint32_t start = EncodeIsoCommStart( + isoControl, + assignedChannels_.hostToDeviceIsoChannel, + assignedChannels_.deviceToHostIsoChannel); + (void)io_.WriteQuadBE( + AddressOf(Reg::IsocCommControl), start, + [this, callback = std::move(callback)]( + Async::AsyncStatus startStatus) mutable { + const IOReturn result = + Protocols::Ports:: + MapAsyncStatusToIOReturn( + startStatus); + AudioStreamRuntimeCaps caps{}; + (void)GetRuntimeAudioStreamCaps(caps); + callback( + result, + DuplexStageResult{ + .generation = route_.generation, + .channels = assignedChannels_, + .runtimeCaps = caps, + }); + }); + }); + }); + }); +} + +void MotuV2Protocol::ConfirmDuplexStart(ConfirmCallback callback) { + (void)io_.ReadQuadBE( + AddressOf(Reg::ClockStatusV2), + [this, callback = std::move(callback)]( + Async::AsyncStatus readStatus, uint32_t clock) mutable { + const IOReturn status = + Protocols::Ports::MapAsyncStatusToIOReturn(readStatus); + if (status != kIOReturnSuccess) { + callback(status, {}); + return; + } + (void)io_.WriteQuadBE( + AddressOf(Reg::ClockStatusV2), + EncodeFetchingModeV2(clock, true), + [this, callback = std::move(callback)]( + Async::AsyncStatus writeStatus) mutable { + const IOReturn result = + Protocols::Ports::MapAsyncStatusToIOReturn( + writeStatus); + AudioStreamRuntimeCaps caps{}; + (void)GetRuntimeAudioStreamCaps(caps); + callback( + result, + DuplexConfirmResult{ + .generation = route_.generation, + .channels = assignedChannels_, + .appliedClock = + AudioClockConfig{.sampleRateHz = 48000}, + .runtimeCaps = caps, + }); + }); + }); +} + +void MotuV2Protocol::ApplyClockConfig( + const AudioClockConfig& desiredClock, + ClockApplyCallback callback) { + if (desiredClock.sampleRateHz != 48000) { + callback(kIOReturnUnsupported, {}); + return; + } + SetSampleRate(48000, + [this, callback = std::move(callback)](IOReturn status) mutable { + AudioStreamRuntimeCaps caps{}; + (void)GetRuntimeAudioStreamCaps(caps); + callback(status, + ClockApplyResult{ + .generation = route_.generation, + .appliedClock = + AudioClockConfig{.sampleRateHz = 48000}, + .runtimeCaps = caps, + }); + }); +} + +void MotuV2Protocol::ReadDuplexHealth(HealthCallback callback) { + ReadClockStatus( + [this, callback = std::move(callback)]( + IOReturn status, ClockStatus clock) mutable { + AudioStreamRuntimeCaps caps{}; + (void)GetRuntimeAudioStreamCaps(caps); + callback(status, + DuplexHealthResult{ + .generation = route_.generation, + .appliedClock = + AudioClockConfig{ + .sampleRateHz = clock.sampleRateHz}, + .runtimeCaps = caps, + .sourceLocked = status == kIOReturnSuccess, + .clockReferenceHealthy = + status == kIOReturnSuccess, + .nominalRateHz = clock.sampleRateHz, + }); + }); +} + +IOReturn MotuV2Protocol::StopDuplex() { + if (!initialized_) { + return kIOReturnSuccess; + } + (void)io_.ReadQuadBE( + AddressOf(Reg::ClockStatusV2), + [this](Async::AsyncStatus status, uint32_t clock) { + if (status == Async::AsyncStatus::kSuccess) { + (void)io_.WriteQuadBE( + AddressOf(Reg::ClockStatusV2), + EncodeFetchingModeV2(clock, false), + [](Async::AsyncStatus) {}); + } + }); + (void)io_.ReadQuadBE( + AddressOf(Reg::IsocCommControl), + [this](Async::AsyncStatus status, uint32_t control) { + if (status == Async::AsyncStatus::kSuccess) { + (void)io_.WriteQuadBE( + AddressOf(Reg::IsocCommControl), + EncodeIsoCommStop(control), + [](Async::AsyncStatus) {}); + } + }); + return kIOReturnSuccess; +} + +void MotuV2Protocol::ReadClockStatus(ClockStatusCallback callback) { + (void)io_.ReadQuadBE( + AddressOf(Reg::ClockStatusV2), + [this, callback = std::move(callback)](Async::AsyncStatus status, uint32_t value) mutable { + const IOReturn result = Protocols::Ports::MapAsyncStatusToIOReturn(status); + if (result != kIOReturnSuccess) { + if (callback) { + callback(result, ClockStatus{}); + } + return; + } + + ClockStatus clock{}; + clock.raw = value; + clock.sampleRateHz = DecodeRateV2(value).value_or(0U); + clock.source = DecodeClockSourceV2(value); + cachedSampleRateHz_.store(clock.sampleRateHz, std::memory_order_release); + + if (callback) { + callback(kIOReturnSuccess, clock); + } + }); +} + +void MotuV2Protocol::SetSampleRate(uint32_t rateHz, CompletionCallback callback) { + // Read-modify-write: the rate lives in bits [5:3] and must not disturb the clock + // source or any other field (motu-protocol-v2.c:59-86). + ReadClockStatus([this, rateHz, callback = std::move(callback)]( + IOReturn status, ClockStatus clock) mutable { + if (status != kIOReturnSuccess) { + if (callback) { + callback(status); + } + return; + } + + const auto encoded = EncodeRateV2(clock.raw, rateHz); + if (!encoded.has_value()) { + ASFW_LOG(Audio, "MotuV2Protocol: unsupported sample rate %u", rateHz); + if (callback) { + callback(kIOReturnUnsupported); + } + return; + } + + if (*encoded == clock.raw) { + if (callback) { + callback(kIOReturnSuccess); + } + return; + } + + (void)io_.WriteQuadBE( + AddressOf(Reg::ClockStatusV2), + *encoded, + [this, rateHz, callback = std::move(callback)](Async::AsyncStatus writeStatus) mutable { + const IOReturn result = Protocols::Ports::MapAsyncStatusToIOReturn(writeStatus); + if (result == kIOReturnSuccess) { + cachedSampleRateHz_.store(rateHz, std::memory_order_release); + } + if (callback) { + callback(result); + } + }); + }); +} + +void MotuV2Protocol::WriteAsyncAddrPair(AsyncAddrValues values, + bool registered, + CompletionCallback callback) { + (void)io_.WriteQuadBE( + AddressOf(Reg::AsyncAddrHi), + values.hi, + [this, values, registered, callback = std::move(callback)]( + Async::AsyncStatus hiStatus) mutable { + const IOReturn hiResult = Protocols::Ports::MapAsyncStatusToIOReturn(hiStatus); + if (hiResult != kIOReturnSuccess) { + if (callback) { + callback(hiResult); + } + return; + } + + (void)io_.WriteQuadBE( + AddressOf(Reg::AsyncAddrLo), + values.lo, + [this, registered, callback = std::move(callback)]( + Async::AsyncStatus loStatus) mutable { + const IOReturn loResult = + Protocols::Ports::MapAsyncStatusToIOReturn(loStatus); + if (loResult == kIOReturnSuccess) { + asyncAddressRegistered_.store(registered, std::memory_order_release); + } + if (callback) { + callback(loResult); + } + }); + }); +} + +void MotuV2Protocol::RegisterAsyncMessageAddress(uint16_t hostNodeId, + uint64_t hostAddress, + CompletionCallback callback) { + if (hostAddress < kAsyncMessageRegionStart || hostAddress > kAsyncMessageRegionEnd) { + ASFW_LOG(Audio, + "MotuV2Protocol: async address 0x%012llx outside the device's accepted region", + hostAddress); + if (callback) { + callback(kIOReturnBadArgument); + } + return; + } + + WriteAsyncAddrPair(EncodeAsyncAddr(hostNodeId, hostAddress), true, std::move(callback)); +} + +void MotuV2Protocol::ReleaseAsyncMessageAddress(CompletionCallback callback) { + WriteAsyncAddrPair(AsyncAddrValues{.hi = 0U, .lo = 0U}, false, std::move(callback)); +} + +} // namespace ASFW::Audio::Motu diff --git a/ASFWDriver/Audio/Protocols/MOTU/MotuV2Protocol.hpp b/ASFWDriver/Audio/Protocols/MOTU/MotuV2Protocol.hpp new file mode 100644 index 000000000..884833b93 --- /dev/null +++ b/ASFWDriver/Audio/Protocols/MOTU/MotuV2Protocol.hpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuV2Protocol.hpp - Device protocol for MOTU protocol-v2 register devices (828mkII). +// +// Thin adapter: all wire encoding/decoding lives in the pure codecs of +// MotuV2Registers.hpp; this class owns only transport (async register IO against +// kAddrBase + offset) and the cached device state those reads produce. +// +// The protocol participates in the shared duplex lifecycle. Payload conversion +// and capture-to-playback SPH replay remain in the wire/engine layers. + +#pragma once + +#include "MotuV2Registers.hpp" +#include "../IDeviceProtocol.hpp" +#include "../Duplex/IDuplexDeviceControl.hpp" +#include "../../../Protocols/Ports/ProtocolRegisterIO.hpp" + +#include +#include +#include +#include + +namespace ASFW::Audio::Motu { + +/// Device clock state as reported by the clock status register (0x0b14). +struct ClockStatus { + uint32_t raw{0}; + uint32_t sampleRateHz{0}; ///< 0 when the rate index is unknown. + std::optional source{}; ///< nullopt for reserved source codes. +}; + +class MotuV2Protocol final : public IDeviceProtocol, + public IDuplexDeviceControl { +public: + using ClockStatusCallback = std::function; + using CompletionCallback = std::function; + + MotuV2Protocol(Protocols::Ports::FireWireBusOps& busOps, + Protocols::Ports::FireWireBusInfo& busInfo, + Discovery::DeviceRegistry& routeRegistry, + const Discovery::DeviceRouteToken& route, + ::ASFW::IRM::IRMClient* irmClient, + uint32_t unitSwVersion); + + IOReturn Initialize() override; + IOReturn Shutdown() override; + const char* GetName() const override; + IDuplexDeviceControl* AsDuplexDeviceControl() noexcept override { return this; } + const IDuplexDeviceControl* AsDuplexDeviceControl() const noexcept override { + return this; + } + bool GetRuntimeAudioStreamCaps(AudioStreamRuntimeCaps& outCaps) const override; + + void PrepareDuplex(const AudioDuplexChannels& channels, + const AudioClockConfig& desiredClock, + PrepareCallback callback) override; + void SetAssignedChannels(const AudioDuplexChannels& channels) noexcept override; + void ProgramRx(StageCallback callback) override; + void ProgramTxAndEnableDuplex(StageCallback callback) override; + void ConfirmDuplexStart(ConfirmCallback callback) override; + void ApplyClockConfig(const AudioClockConfig& desiredClock, + ClockApplyCallback callback) override; + void ReadDuplexHealth(HealthCallback callback) override; + IOReturn StopDuplex() override; + ::ASFW::IRM::IRMClient* GetIRMClient() const override { return irmClient_; } + + void UpdateRuntimeContext(const Discovery::DeviceRouteToken& route, + Protocols::AVC::FCPTransport* transport) override; + + /// Read and decode the clock status register. + void ReadClockStatus(ClockStatusCallback callback); + + /// Read-modify-write the sample rate, preserving the clock source and all other bits. + /// Fails with kIOReturnUnsupported for a rate the device does not implement. + void SetSampleRate(uint32_t rateHz, CompletionCallback callback); + + /// Tell the device where to post its 4-byte async notifications. The caller owns the + /// host address range (it must already be allocated and listening) and is responsible + /// for re-registering after a bus reset, which clears the device-side value. + /// `hostAddress` must lie inside [kAsyncMessageRegionStart, kAsyncMessageRegionEnd]; + /// anything else fails with kIOReturnBadArgument without touching the device. + void RegisterAsyncMessageAddress(uint16_t hostNodeId, + uint64_t hostAddress, + CompletionCallback callback); + + /// Clear the notification address, releasing the device back to front-panel control + /// (motu-transaction.c:121-133 writes zero to both halves on teardown). + void ReleaseAsyncMessageAddress(CompletionCallback callback); + + /// True once a registration write has succeeded and not yet been released. + [[nodiscard]] bool HasRegisteredAsyncAddress() const noexcept { + return asyncAddressRegistered_.load(std::memory_order_acquire); + } + + /// Last successfully decoded sample rate, or 0 before the first read completes. + [[nodiscard]] uint32_t CachedSampleRateHz() const noexcept { + return cachedSampleRateHz_.load(std::memory_order_acquire); + } + + [[nodiscard]] uint32_t UnitSwVersion() const noexcept { return unitSwVersion_; } + +private: + [[nodiscard]] static Async::FWAddress AddressOf(Reg reg) noexcept; + + /// Write the address-hi/address-lo pair as one logical operation. Both halves must + /// land: the device only acts on a complete address, so a failed second write leaves + /// it holding a half-updated value and the caller sees the failure. + void WriteAsyncAddrPair(AsyncAddrValues values, + bool registered, + CompletionCallback callback); + + Protocols::Ports::ProtocolRegisterIO io_; + ::ASFW::IRM::IRMClient* irmClient_{nullptr}; + Discovery::DeviceRouteToken route_{}; + const uint32_t unitSwVersion_; + AudioDuplexChannels assignedChannels_{}; + bool txOnlyFixedChunks_{true}; + bool rxOnlyFixedChunks_{true}; + std::atomic cachedSampleRateHz_{0}; + std::atomic asyncAddressRegistered_{false}; + bool initialized_{false}; +}; + +} // namespace ASFW::Audio::Motu diff --git a/ASFWDriver/Audio/Protocols/MOTU/MotuV2Registers.hpp b/ASFWDriver/Audio/Protocols/MOTU/MotuV2Registers.hpp new file mode 100644 index 000000000..326459b5b --- /dev/null +++ b/ASFWDriver/Audio/Protocols/MOTU/MotuV2Registers.hpp @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuV2Registers.hpp - Register plane for MOTU FireWire protocol-v2 devices +// (828mk2 first; family covers Traveler, UltraLite, 896HD, 8pre). +// +// Pure value codecs; no transport. The eventual IDeviceProtocol issues these +// as async quadlet transactions at kAddrBase + offset (big-endian wire). +// +// Wire truth cross-validated with Linux sound/firewire/motu: +// - motu-transaction.c:11-13 (address base, async message registers) +// - motu-protocol-v2.c:10-32 (clock status and optical config registers) +// - motu-stream.c:12-26 (iso comm control and packet format registers) +// - motu-protocol-v2.c:190-225 (fetching mode; 828mk2/896HD are no-ops) +// - motu.c:16-26 (clock rate table) + +#pragma once + +#include "../../Wire/MOTU/MotuBlockLayout.hpp" +#include +#include + +namespace ASFW::Audio::Motu { + +inline constexpr uint64_t kAddrBase = 0xfffff0000000ull; + +enum class Reg : uint32_t { + IsocCommControl = 0x0b00, + AsyncAddrHi = 0x0b04, + AsyncAddrLo = 0x0b08, + PacketFormat = 0x0b10, + ClockStatusV2 = 0x0b14, + InOutConfV2 = 0x0c04, +}; + +inline constexpr uint32_t kClockFetchEnable = 0x02000000u; +inline constexpr uint32_t kClockModelSpecific = 0x04000000u; + +//============================================================================== +// Clock status register (0x0b14): rate index in bits [5:3], source in [2:0] +// (motu-protocol-v2.c:10-23). +//============================================================================== + +/// Raw v2 clock source codes (motu-protocol-v2.c:15-21). SPDIF refinement +/// (coax vs optical, AES/EBU on 896HD) additionally consults the optical +/// config register; that model-specific mapping lives with the protocol. +enum class ClockSourceV2 : uint32_t { + Internal = 0, + AdatOnOpt = 1, + Spdif = 2, + Sph = 3, ///< Sync to source packet headers (bus clock). + WordOnBnc = 4, + AdatOnDsub = 5, + AesEbuOnXlr = 7, +}; + +[[nodiscard]] constexpr std::optional DecodeRateV2( + uint32_t data) noexcept { + const uint32_t index = (data & 0x00000038u) >> 3; + if (index >= Encoding::Motu::kClockRateCount) { + return std::nullopt; + } + return Encoding::Motu::kClockRates[index]; +} + +/// Read-modify-write value for a rate change; all other bits preserved +/// (motu-protocol-v2.c:59-86). +[[nodiscard]] constexpr std::optional EncodeRateV2( + uint32_t currentData, uint32_t rate) noexcept { + const int32_t index = Encoding::Motu::RateToIndex(rate); + if (index < 0) { + return std::nullopt; + } + return (currentData & ~0x00000038u) | + (static_cast(index) << 3); +} + +[[nodiscard]] constexpr std::optional DecodeClockSourceV2( + uint32_t data) noexcept { + switch (data & 0x00000007u) { + case 0: return ClockSourceV2::Internal; + case 1: return ClockSourceV2::AdatOnOpt; + case 2: return ClockSourceV2::Spdif; + case 3: return ClockSourceV2::Sph; + case 4: return ClockSourceV2::WordOnBnc; + case 5: return ClockSourceV2::AdatOnDsub; + case 7: return ClockSourceV2::AesEbuOnXlr; + default: return std::nullopt; + } +} + +[[nodiscard]] constexpr uint32_t EncodeFetchingModeV2( + uint32_t currentData, bool enable) noexcept { + uint32_t data = + currentData & ~(kClockFetchEnable | kClockModelSpecific); + if (enable) { + data |= kClockFetchEnable; + const auto rate = DecodeRateV2(currentData); + const auto source = DecodeClockSourceV2(currentData); + if (rate.has_value() && *rate > 48000 && + source == ClockSourceV2::Sph) { + data |= kClockModelSpecific; + } + } + return data; +} + +//============================================================================== +// Optical interface config (0x0c04): out mode bits [11:10], in mode [9:8] +// (motu-protocol-v2.c:25-32). +//============================================================================== + +enum class OptIfaceMode : uint32_t { + None = 0, + Adat = 1, + Spdif = 2, +}; + +struct OptIfaceConfig { + OptIfaceMode input; + OptIfaceMode output; +}; + +[[nodiscard]] constexpr std::optional DecodeOptIfaceConfig( + uint32_t data) noexcept { + const uint32_t in = (data & 0x00000300u) >> 8; + const uint32_t out = (data & 0x00000c00u) >> 10; + if (in > 2u || out > 2u) { + return std::nullopt; + } + return OptIfaceConfig{static_cast(in), + static_cast(out)}; +} + +//============================================================================== +// Iso communication control (0x0b00): channel numbers + activation for both +// directions in one write (motu-stream.c:12-21,62-107). Device-relative +// naming: RX = host->device (playback), TX = device->host (capture). +//============================================================================== + +inline constexpr uint32_t kIsoCommControlMask = 0xffff0000u; +inline constexpr uint32_t kChangeRxState = 0x80000000u; +inline constexpr uint32_t kRxActivated = 0x40000000u; +inline constexpr uint32_t kChangeTxState = 0x00800000u; +inline constexpr uint32_t kTxActivated = 0x00400000u; + +/// Activate both streams with their iso channels; low 16 bits preserved +/// (motu-stream.c:62-83). +[[nodiscard]] constexpr uint32_t EncodeIsoCommStart(uint32_t currentData, + uint32_t deviceRxChannel, + uint32_t deviceTxChannel) noexcept { + uint32_t data = currentData & ~kIsoCommControlMask; + data |= kChangeRxState | kRxActivated | ((deviceRxChannel & 0x3fu) << 24); + data |= kChangeTxState | kTxActivated | ((deviceTxChannel & 0x3fu) << 16); + return data; +} + +/// Deactivate both streams, leaving channel fields intact +/// (motu-stream.c:85-107). +[[nodiscard]] constexpr uint32_t EncodeIsoCommStop(uint32_t currentData) noexcept { + uint32_t data = currentData; + data &= ~(kRxActivated | kTxActivated); + data |= kChangeRxState | kChangeTxState; + return data; +} + +struct IsoCommState { + bool rxActivated; + uint32_t rxChannel; + bool txActivated; + uint32_t txChannel; +}; + +[[nodiscard]] constexpr IsoCommState DecodeIsoCommState(uint32_t data) noexcept { + return IsoCommState{ + (data & kRxActivated) != 0, + (data >> 24) & 0x3fu, + (data & kTxActivated) != 0, + (data >> 16) & 0x3fu, + }; +} + +//============================================================================== +// Packet format register (0x0b10) (motu-stream.c:23-26,201-225) +//============================================================================== + +inline constexpr uint32_t kTxExcludeDifferedChunks = 0x00000080u; +inline constexpr uint32_t kRxExcludeDifferedChunks = 0x00000040u; +inline constexpr uint32_t kTxSpeedMask = 0x0000000fu; + +/// Read-modify-write for the packet format: exclude-differed-chunks flags are +/// set when the direction runs only its fixed chunks (optical not in ADAT +/// mode); speed is the FireWire speed code (motu-stream.c:201-225). +[[nodiscard]] constexpr uint32_t EncodePacketFormat(uint32_t currentData, + bool txOnlyFixedChunks, + bool rxOnlyFixedChunks, + uint32_t speedCode) noexcept { + uint32_t data = currentData & + ~(kTxExcludeDifferedChunks | kRxExcludeDifferedChunks | + kTxSpeedMask); + if (txOnlyFixedChunks) { + data |= kTxExcludeDifferedChunks; + } + if (rxOnlyFixedChunks) { + data |= kRxExcludeDifferedChunks; + } + data |= speedCode & kTxSpeedMask; + return data; +} + +//============================================================================== +// Async message address registration (0x0b04/0x0b08): the device writes +// 4-byte notifications to this host address; re-register after every bus +// reset (motu-transaction.c:74-121). +//============================================================================== + +struct AsyncAddrValues { + uint32_t hi; ///< (host nodeID << 16) | (address >> 32) + uint32_t lo; ///< low 32 bits of address +}; + +[[nodiscard]] constexpr AsyncAddrValues EncodeAsyncAddr( + uint16_t hostNodeId, uint64_t hostAddress) noexcept { + return AsyncAddrValues{ + (static_cast(hostNodeId) << 16) | + static_cast(hostAddress >> 32), + static_cast(hostAddress), + }; +} + +/// Device-claimed host address region for notifications +/// (motu-transaction.c:98-101). +inline constexpr uint64_t kAsyncMessageRegionStart = 0xffffe0000000ull; +inline constexpr uint64_t kAsyncMessageRegionEnd = 0xffffe000ffffull; + +/// 828mk2 and 896HD require no fetching-mode write around streaming; other +/// v2 models do (motu-protocol-v2.c:190-225). Encoded here as a capability +/// flag for the protocol layer. +inline constexpr bool k828mk2NeedsFetchingModeWrite = false; + +} // namespace ASFW::Audio::Motu diff --git a/ASFWDriver/Audio/Wire/AMDTP/AmdtpPayloadWriter.cpp b/ASFWDriver/Audio/Wire/AMDTP/AmdtpPayloadWriter.cpp index 3865a6724..222e24551 100644 --- a/ASFWDriver/Audio/Wire/AMDTP/AmdtpPayloadWriter.cpp +++ b/ASFWDriver/Audio/Wire/AMDTP/AmdtpPayloadWriter.cpp @@ -1,6 +1,8 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "AmdtpPayloadWriter.hpp" #include "PcmSlotCodec.hpp" +#include "../MOTU/MotuBlockCodec.hpp" #include #include @@ -126,22 +128,44 @@ void AmdtpPayloadWriter::WriteFloat32Interleaved( uint8_t* dest = snap.packetBytes + kCipHeaderBytes + frameInPacket * snap.dbs * kBytesPerSlot; - const uint32_t pcmSlots = (streamConfig_.pcmChannels < snap.dbs) + const bool isMotu = + txPolicy_.hostToDevicePcmEncoding == + PcmSlotEncoding::MotuV2Packed24; + const uint32_t pcmSlots = isMotu + ? streamConfig_.pcmChannels + : (streamConfig_.pcmChannels < snap.dbs) ? streamConfig_.pcmChannels : snap.dbs; - // This stream encodes the host channels [sourceChannelOffset, - // sourceChannelOffset + pcmChannels) of the shared interleaved buffer — - // the de-interleave that mirrors the RX side's channelOffset. Host - // channels outside the buffer encode PCM zero. + // This stream normally encodes the host channels + // [sourceChannelOffset, sourceChannelOffset + pcmChannels). A device + // profile may instead map each wire PCM slot to a host channel, such + // as presenting the UltraLite's Main Out pair first. Host channels + // outside the buffer encode PCM zero. const uint32_t srcOffset = streamConfig_.sourceChannelOffset; bool frameNonZero = false; for (uint32_t ch = 0; ch < pcmSlots; ++ch) { - const uint32_t srcCh = srcOffset + ch; + const uint32_t relativeSrcCh = + txPolicy_.sourceChannelMapEnabled + ? txPolicy_.sourceChannelForWireSlot[ch] + : ch; + const uint32_t srcCh = srcOffset + relativeSrcCh; const float sample = (srcCh < hostBuffer.channels) ? source[srcCh] : 0.0f; - WriteBE32(dest + ch * kBytesPerSlot, - PcmSlotCodec::EncodeFloat32( - sample, txPolicy_.hostToDevicePcmEncoding)); + if (isMotu) { + const int32_t signed24 = + PcmSlotCodec::Float32ToSigned24(sample); + ASFW::Encoding::Motu::WritePcmSample( + std::span( + dest + ASFW::Encoding::Motu::kPcmByteOffset + + ch * ASFW::Encoding::Motu::kBytesPerChunk, + ASFW::Encoding::Motu::kBytesPerChunk), + static_cast( + static_cast(signed24) << 8)); + } else { + WriteBE32(dest + ch * kBytesPerSlot, + PcmSlotCodec::EncodeFloat32( + sample, txPolicy_.hostToDevicePcmEncoding)); + } if (sample != 0.0f) { frameNonZero = true; ++nonZeroSlots; diff --git a/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp b/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp index 194a08024..cfa676d10 100644 --- a/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp +++ b/ASFWDriver/Audio/Wire/AMDTP/AmdtpTxPacketizer.cpp @@ -1,7 +1,11 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "AmdtpTxPacketizer.hpp" #include "AmdtpRateGeometry.hpp" +#include "AmdtpTiming.hpp" #include "../IEC61883/Syt.hpp" +#include "../MOTU/MotuBlockCodec.hpp" +#include "../MOTU/MotuSph.hpp" namespace ASFW::Protocols::Audio::AMDTP { @@ -58,7 +62,9 @@ bool AmdtpTxPacketizer::Configure(const AmdtpStreamConfig& streamConfig, AmdtpStreamConfig config = streamConfig; // FDF (AM824 SFC) must match the actual rate, not whatever the profile // defaulted (profiles hardcode the 48 kHz SFC 0x02). - config.fdf = geometry->fdf; + if (!config.sph) { + config.fdf = geometry->fdf; + } if (config.dbs == 0) { config.dbs = static_cast(config.pcmChannels + config.midiSlots); } @@ -81,7 +87,7 @@ bool AmdtpTxPacketizer::Configure(const AmdtpStreamConfig& streamConfig, cipConfig.dbs = config.dbs; cipConfig.fn = 0; cipConfig.qpc = 0; - cipConfig.sph = false; + cipConfig.sph = config.sph; cipConfig.fmt = config.fmt; cipConfig.fdf = config.fdf; cipConfig.noDataFdf = @@ -166,6 +172,11 @@ bool AmdtpTxPacketizer::PrepareNextPacket(TxPacketSlotView slot, if (frames > streamConfig_.framesPerDataPacket) { return false; } + if (isData && streamConfig_.sph && + (timing.motuSphCount != frames || + timing.motuSphCount > timing.motuSphOffsets.size())) { + return false; + } const uint32_t payloadBytes = static_cast(frames) * streamConfig_.dbs * kBytesPerSlot; @@ -179,23 +190,43 @@ bool AmdtpTxPacketizer::PrepareNextPacket(TxPacketSlotView slot, } const uint8_t dbc = dbcCounter_.ValueForNextPacket(); + // MOTU host-to-device packets label DBC with the end event rather than + // the first event. + const uint8_t wireDbc = + streamConfig_.sph ? static_cast(dbc + frames) : dbc; outPacket = PreparedTxPacket{}; outPacket.packetIndex = slot.packetIndex; outPacket.byteCount = byteCount; outPacket.isData = isData; - outPacket.dbc = dbc; + outPacket.dbc = wireDbc; outPacket.dbs = streamConfig_.dbs; outPacket.firstAudioFrame = nextAudioFrame_; outPacket.framesInPacket = isData ? frames : 0; if (isData) { - outPacket.syt = timing.txClockValid + outPacket.syt = streamConfig_.sph + ? IEC61883::SytFormatter::kNoInfo + : (timing.txClockValid ? timing.nextDataSyt - : IEC61883::SytFormatter::kNoInfo; + : IEC61883::SytFormatter::kNoInfo); - WriteCipHeader(slot.bytes, cipBuilder_.BuildData(dbc, outPacket.syt)); + WriteCipHeader(slot.bytes, cipBuilder_.BuildData(wireDbc, outPacket.syt)); WriteDataPacketDefaults(slot.bytes, slot.capacityBytes, payloadBytes); + if (streamConfig_.sph) { + const uint32_t baseTick = static_cast( + ASFW::Timing::normalizeOffsetDomain(timing.packetCycleTicks) % + ASFW::Encoding::Motu::kTicksPerSecond); + uint8_t* payload = slot.bytes + kCipHeaderBytes; + const uint32_t blockBytes = + static_cast(streamConfig_.dbs) * kBytesPerSlot; + for (uint32_t i = 0; i < frames; ++i) { + ASFW::Encoding::Motu::WriteSph( + std::span(payload + i * blockBytes, blockBytes), + ASFW::Encoding::Motu::ReplaySph( + timing.motuSphOffsets[i], baseTick)); + } + } if (!timeline_->ExposeDataPacket(outPacket, slot.bytes, slot.capacityBytes)) { diff --git a/ASFWDriver/Audio/Wire/AMDTP/AmdtpTypes.hpp b/ASFWDriver/Audio/Wire/AMDTP/AmdtpTypes.hpp index b59cd4bad..8f0094a6a 100644 --- a/ASFWDriver/Audio/Wire/AMDTP/AmdtpTypes.hpp +++ b/ASFWDriver/Audio/Wire/AMDTP/AmdtpTypes.hpp @@ -1,6 +1,8 @@ #pragma once +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include +#include namespace ASFW::Protocols::Audio::AMDTP { @@ -24,6 +26,7 @@ enum class PcmSlotEncoding : uint8_t { Am824MBLA = 0, RawSigned24In32BE = 1, RawSigned24In32LE = 2, + MotuV2Packed24 = 3, }; enum class DbsPolicy : uint8_t { @@ -42,6 +45,7 @@ struct AmdtpStreamConfig final { uint8_t fmt{0x10}; uint8_t fdf{0x02}; + bool sph{false}; uint8_t framesPerDataPacket{8}; uint32_t maxPacketBytes{512}; @@ -62,6 +66,8 @@ struct AmdtpTxPolicy final { bool initializeNonAudioSlots{true}; bool preserveFdfInNoDataPackets{false}; bool emptyPacketsDuringIdle{false}; + std::array sourceChannelForWireSlot{}; + bool sourceChannelMapEnabled{false}; }; struct HostAudioBufferView final { @@ -108,6 +114,9 @@ struct AmdtpTimingState final { uint16_t replayDataBlocks{0}; bool replayValid{false}; uint64_t nextAudioFrame{0}; + int64_t packetCycleTicks{0}; + std::array motuSphOffsets{}; + uint8_t motuSphCount{0}; }; } // namespace ASFW::Protocols::Audio::AMDTP @@ -122,6 +131,7 @@ enum class StreamMode : uint8_t { enum class AudioWireFormat : uint8_t { kAM824 = 0, kRawPcm24In32 = 1, + kMotuV2 = 2, }; } // namespace ASFW::Encoding diff --git a/ASFWDriver/Audio/Wire/AMDTP/PcmSlotCodec.cpp b/ASFWDriver/Audio/Wire/AMDTP/PcmSlotCodec.cpp index 0b5e1d726..c013ec67d 100644 --- a/ASFWDriver/Audio/Wire/AMDTP/PcmSlotCodec.cpp +++ b/ASFWDriver/Audio/Wire/AMDTP/PcmSlotCodec.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "PcmSlotCodec.hpp" namespace ASFW::Protocols::Audio::AMDTP { @@ -57,6 +58,8 @@ uint32_t PcmSlotCodec::EncodeFloat32(float sample, PcmSlotEncoding encoding) noe return EncodeRawSigned24In32BE(sample); case PcmSlotEncoding::RawSigned24In32LE: return EncodeRawSigned24In32LE(sample); + case PcmSlotEncoding::MotuV2Packed24: + return EncodeRawSigned24In32BE(sample); case PcmSlotEncoding::Am824MBLA: break; } @@ -77,6 +80,8 @@ uint32_t PcmSlotCodec::EncodeInt32( return static_cast(signed24); case PcmSlotEncoding::RawSigned24In32LE: return ByteSwap32(static_cast(signed24)); + case PcmSlotEncoding::MotuV2Packed24: + return static_cast(signed24); case PcmSlotEncoding::Am824MBLA: break; } diff --git a/ASFWDriver/Audio/Wire/AMDTP/RxSequenceReplay.hpp b/ASFWDriver/Audio/Wire/AMDTP/RxSequenceReplay.hpp index 9c0b45c4c..8a274d0da 100644 --- a/ASFWDriver/Audio/Wire/AMDTP/RxSequenceReplay.hpp +++ b/ASFWDriver/Audio/Wire/AMDTP/RxSequenceReplay.hpp @@ -1,4 +1,5 @@ #pragma once +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "AmdtpTiming.hpp" @@ -9,18 +10,22 @@ namespace ASFW::Audio::Runtime { struct RxSequenceEntry final { + static constexpr uint32_t kMaxSphPerPacket = 8; uint64_t firstAudioFrame{0}; uint32_t sourceCycleTimer{0}; uint32_t sytOffset{UINT32_MAX}; uint16_t dataBlocks{0}; uint8_t dbc{0}; uint8_t flags{0}; + std::array motuSphOffsets{}; + uint8_t motuSphCount{0}; }; namespace RxSequenceFlags { inline constexpr uint8_t kValidCip = 1u << 0; inline constexpr uint8_t kValidSyt = 1u << 1; inline constexpr uint8_t kDiscontinuity = 1u << 2; +inline constexpr uint8_t kValidMotuSph = 1u << 3; } // namespace RxSequenceFlags // A false replay read is recoverable, but its cause is not interchangeable: @@ -148,6 +153,9 @@ class RxSequenceReplayState final { std::atomic dataBlocks{0}; std::atomic dbc{0}; std::atomic flags{0}; + std::array, RxSequenceEntry::kMaxSphPerPacket> + motuSphOffsets{}; + std::atomic motuSphCount{0}; }; void Reset() noexcept { @@ -172,6 +180,12 @@ class RxSequenceReplayState final { slot.dataBlocks.store(entry.dataBlocks, std::memory_order_relaxed); slot.dbc.store(entry.dbc, std::memory_order_relaxed); slot.flags.store(entry.flags, std::memory_order_relaxed); + for (uint32_t i = 0; i < RxSequenceEntry::kMaxSphPerPacket; ++i) { + slot.motuSphOffsets[i].store(entry.motuSphOffsets[i], + std::memory_order_relaxed); + } + slot.motuSphCount.store(entry.motuSphCount, + std::memory_order_relaxed); slot.sequence.store(cursor + 1, std::memory_order_release); producerCursor_.store(cursor + 1, std::memory_order_release); } @@ -238,6 +252,12 @@ class RxSequenceReplayState final { slot.dataBlocks.load(std::memory_order_relaxed); entry.dbc = slot.dbc.load(std::memory_order_relaxed); entry.flags = slot.flags.load(std::memory_order_relaxed); + for (uint32_t i = 0; i < RxSequenceEntry::kMaxSphPerPacket; ++i) { + entry.motuSphOffsets[i] = + slot.motuSphOffsets[i].load(std::memory_order_relaxed); + } + entry.motuSphCount = + slot.motuSphCount.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire); const uint64_t finalSequence = slot.sequence.load(std::memory_order_relaxed); diff --git a/ASFWDriver/Audio/Wire/MOTU/MotuBlockCodec.hpp b/ASFWDriver/Audio/Wire/MOTU/MotuBlockCodec.hpp new file mode 100644 index 000000000..61105885d --- /dev/null +++ b/ASFWDriver/Audio/Wire/MOTU/MotuBlockCodec.hpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuBlockCodec.hpp - Encode/decode of MOTU v2 data-block payloads. +// +// A data block is byte-addressed past its leading SPH quadlet: message bytes +// at offsets 4..9, then 24-bit big-endian PCM samples packed 3 bytes per +// channel from byte offset 10, zero-padded to quadlet alignment. Host samples +// use the 24-significant-MSBs-of-int32 convention. Cross-validated with Linux +// amdtp-motu.c:93-187 (PCM), :211-254 (MIDI), :373-393 (SPH write). + +#pragma once + +#include "MotuBlockLayout.hpp" +#include "MotuSph.hpp" +#include +#include +#include + +namespace ASFW::Encoding::Motu { + +/// Write the SPH quadlet at the head of a data block (big-endian on the wire). +inline void WriteSph(std::span block, uint32_t sph) noexcept { + block[0] = static_cast(sph >> 24); + block[1] = static_cast(sph >> 16); + block[2] = static_cast(sph >> 8); + block[3] = static_cast(sph); +} + +[[nodiscard]] constexpr uint32_t ReadSph( + std::span block) noexcept { + return (static_cast(block[0]) << 24) | + (static_cast(block[1]) << 16) | + (static_cast(block[2]) << 8) | + static_cast(block[3]); +} + +/// Pack one host sample (24 significant MSBs of int32) into its 3-byte +/// big-endian chunk (amdtp-motu.c:150-156). +inline void WritePcmSample(std::span chunk, int32_t sample) noexcept { + const auto v = static_cast(sample); + chunk[0] = static_cast(v >> 24); + chunk[1] = static_cast(v >> 16); + chunk[2] = static_cast(v >> 8); +} + +/// Unpack one 3-byte chunk to a host sample (amdtp-motu.c:116-120). +[[nodiscard]] constexpr int32_t ReadPcmSample( + std::span chunk) noexcept { + return static_cast((static_cast(chunk[0]) << 24) | + (static_cast(chunk[1]) << 16) | + (static_cast(chunk[2]) << 8)); +} + +/// Write one frame's PCM into a data block (channels in order from byte +/// offset 10). `block` must span the whole data block. +inline void WritePcmFrame(std::span block, + std::span samples) noexcept { + size_t offset = kPcmByteOffset; + for (const int32_t sample : samples) { + WritePcmSample(block.subspan(offset, kBytesPerChunk), sample); + offset += kBytesPerChunk; + } +} + +inline void WritePcmSilence(std::span block, + uint32_t pcmChunks) noexcept { + size_t offset = kPcmByteOffset; + for (uint32_t c = 0; c < pcmChunks; ++c) { + block[offset] = 0; + block[offset + 1] = 0; + block[offset + 2] = 0; + offset += kBytesPerChunk; + } +} + +[[nodiscard]] inline int32_t ReadPcmChannel(std::span block, + uint32_t channel) noexcept { + return ReadPcmSample( + block.subspan(kPcmByteOffset + channel * kBytesPerChunk, + kBytesPerChunk)); +} + +/// Place one MIDI byte in a data block's second-quadlet slot, or mark the +/// slot empty (amdtp-motu.c:219-234). +inline void WriteMidi2ndQ(std::span block, + std::optional midiByte) noexcept { + if (midiByte.has_value()) { + block[kMidiFlagByteOffset2ndQ] = kMidiFlagPresent; + block[kMidiByteOffset2ndQ] = *midiByte; + } else { + block[kMidiFlagByteOffset2ndQ] = 0; + block[kMidiByteOffset2ndQ] = 0; + } +} + +/// Extract the MIDI byte from a data block, if flagged present +/// (amdtp-motu.c:245-251). +[[nodiscard]] inline std::optional ReadMidi2ndQ( + std::span block) noexcept { + if ((block[kMidiFlagByteOffset2ndQ] & kMidiFlagPresent) == 0) { + return std::nullopt; + } + return block[kMidiByteOffset2ndQ]; +} + +} // namespace ASFW::Encoding::Motu diff --git a/ASFWDriver/Audio/Wire/MOTU/MotuBlockLayout.hpp b/ASFWDriver/Audio/Wire/MOTU/MotuBlockLayout.hpp new file mode 100644 index 000000000..3a0bb3db4 --- /dev/null +++ b/ASFWDriver/Audio/Wire/MOTU/MotuBlockLayout.hpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuBlockLayout.hpp - Data-block geometry for MOTU FireWire protocol-v2 +// devices (828mk2 first; the family covers Traveler, UltraLite, 896HD, 8pre). +// +// MOTU v2 streams carry standard IEC 61883-1 CIP headers (fmt 0x02, +// FDF 0x22, SPH bit set); only the payload framing below is vendor-specific. +// Wire truth cross-validated with Linux sound/firewire/motu: +// - amdtp-motu.c:69-90 (SPH quadlet + 3-byte chunks + quadlet padding) +// - motu-protocol-v2.c:227-272 (pcm_byte_offset 10, 2 msg chunks, ADAT adds) +// - motu-protocol-v2.c:274-282 (828mk2 fixed chunks {14,14,0}) +// - motu-stream.c:117-131 (MIDI flag/byte offsets for the *_MIDI_2ND_Q flavor) +// - motu.c:16-26 (rate table; mode = rate index >> 1) + +#pragma once + +#include +#include + +namespace ASFW::Encoding::Motu { + +/// Sampling rates by clock-rate index. Mode = index >> 1 selects the +/// per-mode PCM chunk count. +inline constexpr uint32_t kClockRates[] = {44100, 48000, 88200, 96000, + 176400, 192000}; +inline constexpr uint32_t kClockRateCount = 6; +inline constexpr uint32_t kModeCount = 3; + +/// v2 payload framing constants (motu-protocol-v2.c:234-238). +inline constexpr uint32_t kMsgChunks = 2; +inline constexpr uint32_t kPcmByteOffset = 10; +inline constexpr uint32_t kBytesPerChunk = 3; + +/// MIDI slot within a data block, "second quadlet" flavor used by the 828mk2 +/// in both directions (motu-stream.c:117-131): flag byte at offset 4, +/// MIDI byte at offset 6, flag value 0x01. +inline constexpr uint32_t kMidiFlagByteOffset2ndQ = 4; +inline constexpr uint32_t kMidiByteOffset2ndQ = 6; +inline constexpr uint8_t kMidiFlagPresent = 0x01; + +/// MIDI pacing: at most one byte per data block, throttled to physical MIDI +/// wire rate with margin (amdtp-motu.c:27-31,87-88). +inline constexpr uint32_t kMidiBytesPerSecond = 3093; + +/// Per-mode fixed PCM chunk counts for the 828mk2 (motu-protocol-v2.c:274-282). +/// Mode 2 (176.4/192k) is unsupported by this model. +inline constexpr uint32_t k828mk2FixedPcmChunks[kModeCount] = {14, 14, 0}; + +// The original UltraLite kext creates its Core Audio streams from these raw +// packet positions. Output order on the wire is Phones, Analog 1-8, Main, +// S/PDIF; the host-facing order is Main, Analog 1-8, S/PDIF, Phones. +inline constexpr std::array kUltraLiteOutputSourceForWireChannel{ + 12, 13, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 10, 11, +}; + +// Input order on the wire is Mix1 return, the eight analog inputs, S/PDIF, +// then two transport-only chunks. Preserve the old driver's host-facing order: +// analog 1-8, S/PDIF 9-10, Mix1 return 11-12, transport slots 13-14. +// Values are indexed by host channel and select a zero-based wire PCM channel. +inline constexpr std::array kUltraLiteInputWireChannelForHostChannel{ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 12, 13, +}; + +[[nodiscard]] constexpr int32_t RateToIndex(uint32_t rate) noexcept { + for (uint32_t i = 0; i < kClockRateCount; ++i) { + if (kClockRates[i] == rate) { + return static_cast(i); + } + } + return -1; +} + +[[nodiscard]] constexpr uint32_t IndexToMode(uint32_t rateIndex) noexcept { + return rateIndex >> 1; +} + +/// Extra PCM chunks contributed by an optical port in ADAT mode +/// (motu-protocol-v2.c:253-269). Single-optical-port models (828mk2): +8 at +/// mode 0, +4 at mode 1. (The dual-port 8pre differs; out of scope here.) +[[nodiscard]] constexpr uint32_t AdatExtraChunks(uint32_t mode) noexcept { + if (mode == 0) { + return 8; + } + if (mode == 1) { + return 4; + } + return 0; +} + +/// Data block size: one SPH quadlet, then (msg + pcm) 3-byte chunks padded to +/// quadlet alignment (amdtp-motu.c:69-74). +[[nodiscard]] constexpr uint32_t DataBlockQuadlets(uint32_t pcmChunks) noexcept { + const uint32_t chunkBytes = (kMsgChunks + pcmChunks) * kBytesPerChunk; + return 1 + (chunkBytes + 3) / 4; +} + +[[nodiscard]] constexpr uint32_t DataBlockBytes(uint32_t pcmChunks) noexcept { + return DataBlockQuadlets(pcmChunks) * 4; +} + +/// Data blocks between MIDI-carrying blocks at a given rate +/// (amdtp-motu.c:87-88). +[[nodiscard]] constexpr uint32_t MidiDataBlockInterval(uint32_t rate) noexcept { + return rate / kMidiBytesPerSecond; +} + +} // namespace ASFW::Encoding::Motu diff --git a/ASFWDriver/Audio/Wire/MOTU/MotuSph.hpp b/ASFWDriver/Audio/Wire/MOTU/MotuSph.hpp new file mode 100644 index 000000000..f79b75d50 --- /dev/null +++ b/ASFWDriver/Audio/Wire/MOTU/MotuSph.hpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuSph.hpp - Source-packet-header timestamp math for MOTU streams. +// +// Every MOTU data block begins with a 32-bit SPH quadlet expressing a +// presentation time on the 24.576 MHz cycle timeline: a 13-bit cycle count +// (0..7999, wrapping each second) and a 12-bit intra-cycle tick offset +// (0..3071). The host must capture device SPH timing and later replay it +// (rebased) on the transmit stream; the pure arithmetic for both directions +// lives here. Cross-validated with Linux amdtp-motu.c:19-25,303-393. + +#pragma once + +#include + +namespace ASFW::Encoding::Motu { + +inline constexpr uint32_t kTicksPerCycle = 3072; +inline constexpr uint32_t kCyclesPerSecond = 8000; +inline constexpr uint32_t kTicksPerSecond = kTicksPerCycle * kCyclesPerSecond; + +inline constexpr uint32_t kSphCycleShift = 12; +inline constexpr uint32_t kSphCycleMask = 0x01fff000; +inline constexpr uint32_t kSphOffsetMask = 0x00000fff; + +/// Compose an SPH quadlet (host order) from an absolute tick on the +/// one-second timeline (amdtp-motu.c:384). +[[nodiscard]] constexpr uint32_t SphFromTick(uint32_t tick) noexcept { + const uint32_t wrapped = tick % kTicksPerSecond; + return ((wrapped / kTicksPerCycle) << kSphCycleShift) | + (wrapped % kTicksPerCycle); +} + +/// Absolute tick on the one-second timeline from an SPH quadlet +/// (amdtp-motu.c:316-317). +[[nodiscard]] constexpr uint32_t TickFromSph(uint32_t sph) noexcept { + return ((sph & kSphCycleMask) >> kSphCycleShift) * kTicksPerCycle + + (sph & kSphOffsetMask); +} + +/// Offset of an SPH presentation time relative to a base tick, accounting for +/// wrap at the one-second boundary (amdtp-motu.c:312-321). Used when caching +/// receive-side timing for later transmit replay. +[[nodiscard]] constexpr uint32_t TickOffsetFromBase(uint32_t sph, + uint32_t baseTick) noexcept { + uint32_t tick = TickFromSph(sph); + if (tick < baseTick) { + tick += kTicksPerSecond; + } + return tick - baseTick; +} + +/// Rebase a cached tick offset onto a new base for transmit replay +/// (amdtp-motu.c:382-384). +[[nodiscard]] constexpr uint32_t ReplaySph(uint32_t cachedOffset, + uint32_t baseTick) noexcept { + return SphFromTick((baseTick + cachedOffset) % kTicksPerSecond); +} + +} // namespace ASFW::Encoding::Motu diff --git a/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp b/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp index db81bf39e..9581e0735 100644 --- a/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp +++ b/ASFWDriver/DeviceProfiles/Audio/AudioDeviceIds.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // AudioDeviceIds.hpp - Canonical IEEE OUI vendor IDs, model IDs and display names for @@ -72,6 +73,20 @@ inline constexpr uint32_t kStudioLive1642ModelId = 0x000010; inline constexpr uint32_t kStudioLive2442ModelId = 0x000012; inline constexpr uint32_t kStudioLive3242ModelId = 0x000014; +// ---- MOTU (vendor-specific register protocol) ---- +// MOTU does not use model_id: the root directory publishes model_id 0 and the model is +// identified by Unit_Sw_Version, with Unit_Spec_Id equal to the OUI. Version values from +// Linux sound/firewire/motu/motu.c:162-181. Only the 828mk2 is hardware-verified here +// (config ROM captured 2026-07-26: vendor 0x0001f2, spec 0x0001f2, version 0x000003); +// the protocol-v2 siblings are recognized by name but not audio-enabled until their +// chunk layouts are confirmed against real hardware. +inline constexpr uint32_t kMotuVendorId = 0x0001f2; +inline constexpr uint32_t kMotu828mk2SwVersion = 0x000003; +inline constexpr uint32_t kMotu896hdSwVersion = 0x000005; +inline constexpr uint32_t kMotuTravelerSwVersion = 0x000009; +inline constexpr uint32_t kMotuUltraliteSwVersion = 0x00000d; +inline constexpr uint32_t kMotu8preSwVersion = 0x00000f; + // ---- Display names ---- inline constexpr const char* kFocusriteVendorName = "Focusrite"; inline constexpr const char* kSPro40ModelName = "Saffire Pro 40"; @@ -104,5 +119,11 @@ 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"; inline constexpr const char* kStudioLive3242ModelName = "StudioLive 32.4.2"; +inline constexpr const char* kMotuVendorName = "MOTU"; +inline constexpr const char* kMotu828mk2ModelName = "828mkII"; +inline constexpr const char* kMotu896hdModelName = "896HD"; +inline constexpr const char* kMotuTravelerModelName = "Traveler"; +inline constexpr const char* kMotuUltraliteModelName = "UltraLite"; +inline constexpr const char* kMotu8preModelName = "8pre"; } // namespace ASFW::DeviceProfiles::Audio diff --git a/ASFWDriver/DeviceProfiles/Audio/AudioProfileRegistry.hpp b/ASFWDriver/DeviceProfiles/Audio/AudioProfileRegistry.hpp index c1ea043bd..e865717f8 100644 --- a/ASFWDriver/DeviceProfiles/Audio/AudioProfileRegistry.hpp +++ b/ASFWDriver/DeviceProfiles/Audio/AudioProfileRegistry.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // AudioProfileRegistry.hpp - Aggregates the per-vendor audio profile providers into one @@ -19,6 +20,7 @@ #include "Vendors/BeBoBDeviceProfiles.hpp" #include "Vendors/FocusriteAudioProfiles.hpp" #include "Vendors/MidasAudioProfiles.hpp" +#include "Vendors/MotuAudioProfiles.hpp" #include "Vendors/PreSonusAudioProfiles.hpp" #include "Vendors/TerraTecAudioProfiles.hpp" #include "Vendors/WeissAudioProfiles.hpp" @@ -38,6 +40,7 @@ class AudioProfileRegistry final { if (auto hint = Apogee::LookupIdentity(query)) { return hint; } if (auto hint = Alesis::LookupIdentity(query)) { return hint; } if (auto hint = Midas::LookupIdentity(query)) { return hint; } + if (auto hint = Motu::LookupIdentity(query)) { return hint; } if (auto hint = PreSonus::LookupIdentity(query)) { return hint; } if (auto hint = TerraTec::LookupIdentity(query)) { return hint; } if (auto hint = BeBoB::LookupIdentity(query)) { return hint; } @@ -55,6 +58,7 @@ class AudioProfileRegistry final { if (auto hint = Apogee::LookupAudioProfile(query)) { return hint; } if (auto hint = Alesis::LookupAudioProfile(query)) { return hint; } if (auto hint = Midas::LookupAudioProfile(query)) { return hint; } + if (auto hint = Motu::LookupAudioProfile(query)) { return hint; } if (auto hint = PreSonus::LookupAudioProfile(query)) { return hint; } if (auto hint = TerraTec::LookupAudioProfile(query)) { return hint; } if (auto hint = BeBoB::LookupAudioProfile(query)) { return hint; } diff --git a/ASFWDriver/DeviceProfiles/Audio/AudioProfileTypes.hpp b/ASFWDriver/DeviceProfiles/Audio/AudioProfileTypes.hpp index f594802d6..dd768e812 100644 --- a/ASFWDriver/DeviceProfiles/Audio/AudioProfileTypes.hpp +++ b/ASFWDriver/DeviceProfiles/Audio/AudioProfileTypes.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // AudioProfileTypes.hpp - Metadata hints describing which audio family/profile a @@ -19,6 +20,7 @@ namespace ASFW::DeviceProfiles::Audio { /// a using-alias of this type so existing audio-internal call sites are unaffected. enum class AudioIntegrationMode : uint8_t { kNone = 0, // Recognized but not driven (deferred multistream models). + kProbeOnly, // Create the control protocol for diagnostics; publish no audio nub. kHardcodedNub, // Vendor-specific audio backend (DICE/TCAT), no AV/C. kAVCDriven, // AV/C discovery drives topology; vendor protocol adds extra controls. }; diff --git a/ASFWDriver/DeviceProfiles/Audio/Vendors/MotuAudioProfiles.hpp b/ASFWDriver/DeviceProfiles/Audio/Vendors/MotuAudioProfiles.hpp new file mode 100644 index 000000000..12aa44d35 --- /dev/null +++ b/ASFWDriver/DeviceProfiles/Audio/Vendors/MotuAudioProfiles.hpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// Copyright (c) 2026 ASFireWire Project +// +// MotuAudioProfiles.hpp - MOTU FireWire audio device knowledge (vendor register protocol). +// Knows ONLY MOTU devices; performs no runtime protocol construction. +// +// Unlike every other provider here, MOTU cannot be matched on (vendor_id, model_id): the +// root directory carries model_id 0. Identity comes from the unit directory's +// Unit_Spec_Id (== the OUI) plus Unit_Sw_Version, matching Linux +// sound/firewire/motu/motu.c:151-181 (IEEE1394_MATCH_VENDOR_ID | SPECIFIER_ID | VERSION). + +#pragma once + +#include "../../Common/DeviceProfileTypes.hpp" +#include "../AudioDeviceIds.hpp" +#include "../AudioProfileTypes.hpp" + +#include + +namespace ASFW::DeviceProfiles::Audio::Motu { + +/// True when the query carries a MOTU unit directory (OUI in both vendor and specifier). +[[nodiscard]] constexpr bool IsMotuUnit(const DeviceProfileQuery& query) noexcept { + return query.vendorId == kMotuVendorId && query.unitSpecId == kMotuVendorId; +} + +[[nodiscard]] constexpr const char* ModelNameForSwVersion(uint32_t swVersion) noexcept { + switch (swVersion) { + case kMotu828mk2SwVersion: return kMotu828mk2ModelName; + case kMotu896hdSwVersion: return kMotu896hdModelName; + case kMotuTravelerSwVersion: return kMotuTravelerModelName; + case kMotuUltraliteSwVersion: return kMotuUltraliteModelName; + case kMotu8preSwVersion: return kMotu8preModelName; + default: return nullptr; + } +} + +[[nodiscard]] constexpr std::optional +LookupIdentity(const DeviceProfileQuery& query) noexcept { + if (!IsMotuUnit(query)) { + return std::nullopt; + } + const char* const modelName = ModelNameForSwVersion(query.unitSwVersion); + if (modelName == nullptr) { + return std::nullopt; + } + // modelId is reported as the software version: it is the only stable model + // discriminator MOTU publishes, and downstream identity is keyed on it. + return DeviceIdentityHint{.vendorId = query.vendorId, + .modelId = query.unitSwVersion, + .vendorName = kMotuVendorName, + .modelName = modelName, + .source = MatchSource::ConfigROM}; +} + +[[nodiscard]] constexpr std::optional +LookupAudioProfile(const DeviceProfileQuery& query) noexcept { + uint32_t swVersion = 0; + if (IsMotuUnit(query)) { + swVersion = query.unitSwVersion; + } else if (query.vendorId == kMotuVendorId && + ModelNameForSwVersion(query.modelId) != nullptr) { + // Discovery canonicalizes a MOTU unit's software version into modelId after + // parsing its unit directory. Accept that internal query shape as well. + swVersion = query.modelId; + } else { + return std::nullopt; + } + // Audio integration is enabled only for models whose register plane and stream + // geometry have been confirmed. Other protocol-v2 siblings resolve an identity + // above but stay kNone. + if (swVersion == kMotu828mk2SwVersion) { + return AudioProfileHint{.family = AudioProtocolFamily::VendorSpecific, + .mode = AudioIntegrationMode::kHardcodedNub, + .source = MatchSource::ConfigROM}; + } + // Original UltraLite protocol-v2 hardware uses the same register plane and + // a fixed 14-channel packed-PCM stream in both directions. + if (swVersion == kMotuUltraliteSwVersion) { + return AudioProfileHint{.family = AudioProtocolFamily::VendorSpecific, + .mode = AudioIntegrationMode::kHardcodedNub, + .source = MatchSource::ConfigROM}; + } + return std::nullopt; +} + +} // namespace ASFW::DeviceProfiles::Audio::Motu diff --git a/ASFWDriver/DeviceProfiles/Common/DeviceProfileTypes.hpp b/ASFWDriver/DeviceProfiles/Common/DeviceProfileTypes.hpp index 54a4ab7d1..353020f47 100644 --- a/ASFWDriver/DeviceProfiles/Common/DeviceProfileTypes.hpp +++ b/ASFWDriver/DeviceProfiles/Common/DeviceProfileTypes.hpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // DeviceProfileTypes.hpp - Neutral, metadata-only inputs for device profile matching. @@ -28,6 +29,13 @@ struct DeviceProfileQuery { uint64_t guid{0}; uint32_t vendorId{0}; uint32_t modelId{0}; + + // Unit directory identity. Some families are not discriminated by model_id at all: + // MOTU publishes model_id 0 in the root directory and identifies the model solely by + // Unit_Sw_Version (cross-validated with Linux sound/firewire/motu/motu.c:151-181, + // which matches on VENDOR_ID | SPECIFIER_ID | VERSION). Zero means "not supplied". + uint32_t unitSpecId{0}; + uint32_t unitSwVersion{0}; }; } // namespace ASFW::DeviceProfiles diff --git a/ASFWDriver/Discovery/DeviceRegistry.cpp b/ASFWDriver/Discovery/DeviceRegistry.cpp index c57c024cc..a6f290840 100644 --- a/ASFWDriver/Discovery/DeviceRegistry.cpp +++ b/ASFWDriver/Discovery/DeviceRegistry.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "DeviceRegistry.hpp" #include #include @@ -46,7 +47,11 @@ void PopulateDeviceIdentity(DeviceRecord& device, const ConfigROM& rom) { void MaybeInferKnownIdentityFromGuid(DeviceRecord& device, Guid64 guid) { const DeviceProfiles::DeviceProfileQuery query{ - .guid = guid, .vendorId = device.vendorId, .modelId = device.modelId}; + .guid = guid, + .vendorId = device.vendorId, + .modelId = device.modelId, + .unitSpecId = device.unitSpecId.value_or(0U), + .unitSwVersion = device.unitSwVersion.value_or(0U)}; const auto identity = DeviceProfiles::Audio::AudioProfileRegistry::LookupIdentity(query); if (!identity.has_value()) { @@ -140,10 +145,15 @@ DeviceRecord DeviceRegistry::UpsertFromROM(const ConfigROM& rom, const LinkPolic MaybeInferKnownIdentityFromGuid(device, guid); // Known device profiles can choose their integration mode: + // - kProbeOnly: construct a control protocol for diagnostics, no audio nub. // - kHardcodedNub: vendor-specific audio backend (DICE/TCAT, no AV/C). // - kAVCDriven: AV/C discovery drives audio topology; vendor protocol is for extra controls only. const auto audioProfile = DeviceProfiles::Audio::AudioProfileRegistry::LookupBestAudioProfile( - DeviceProfiles::DeviceProfileQuery{.vendorId = device.vendorId, .modelId = device.modelId}); + DeviceProfiles::DeviceProfileQuery{ + .vendorId = device.vendorId, + .modelId = device.modelId, + .unitSpecId = device.unitSpecId.value_or(0U), + .unitSwVersion = device.unitSwVersion.value_or(0U)}); const auto integrationMode = audioProfile.has_value() ? audioProfile->mode : DeviceProfiles::Audio::AudioIntegrationMode::kNone; diff --git a/MOTU_ULTRALITE.md b/MOTU_ULTRALITE.md new file mode 100644 index 000000000..6a14f5554 --- /dev/null +++ b/MOTU_ULTRALITE.md @@ -0,0 +1,47 @@ +# Original MOTU UltraLite support + +Copyright 2026 Rafal Zalech. Licensed under the Apache License 2.0 together +with the ASFireWire project. See [LICENSE](LICENSE) and [NOTICE](NOTICE). + +This branch adds audio support for the original FireWire-only MOTU UltraLite +(protocol v2) to ASFireWire. It was developed from public protocol +documentation, the MOTU UltraLite user manual, and independently observed +device and wire behavior. Linux FireWire audio sources and Apple's legacy +driver interfaces were consulted only as behavioral references; no GPL- or +APSL-licensed implementation code was copied. + +## Current scope + +- Exact original UltraLite discovery and protocol-v2 register setup +- 48 kHz, 14-channel Core Audio input and output endpoint +- MOTU data-block encoding, decoding, source-packet-header timing, and stream + startup +- Logical output routing: + - 1-2: Main Out 1-2 + - 3-10: analog outputs 1-8 + - 11-12: S/PDIF + - 13-14: Phones +- Logical input routing: + - 1-8: analog inputs + - 9-10: S/PDIF + - 11-12: CueMix Mix1 return + - 13-14: transport padding, not exposed as physical inputs + +MIDI and additional sample rates are outside the current scope. + +## Validation + +The implementation was exercised with an original MOTU UltraLite connected +through the Apple Thunderbolt-to-FireWire adapter chain on Apple silicon and +macOS Tahoe. Playback and capture routing were checked channel by channel in +Max/MSP. The ASFireWire C++ test suite also covers the MOTU register plane, +profile matching, packet/block codec, timing, and transport adapter behavior. + +Behavioral references: + +- MOTU UltraLite manual: + https://cdn-data.motu.com/manuals/firewire-usb-audio/UltraLite_Manual_Mac.pdf +- Linux MOTU protocol-v2 description: + https://github.com/torvalds/linux/blob/master/sound/firewire/motu/motu-protocol-v2.c +- Linux MOTU AMDTP format description: + https://github.com/torvalds/linux/blob/master/sound/firewire/motu/amdtp-motu.c diff --git a/NOTICE b/NOTICE index 037de0860..01b546c06 100644 --- a/NOTICE +++ b/NOTICE @@ -18,3 +18,13 @@ following works, used strictly as behavioral references (no code copied): - Apple IOFireWireFamily, IOFireWireAVC, IOFireWireSBP2 (APSL-2.0) - libffado (GPL-2.0/GPL-3.0) - snd-firewire-ctl-services (GPL-3.0) + +--- + +Original MOTU UltraLite support modifications +Copyright 2026 Rafal Zalech + +These modifications add MOTU FireWire protocol-v2 audio support. Public +documentation and independently observed wire behavior were used as behavioral +references. No implementation code from GPL- or APSL-licensed projects is +included in these modifications. diff --git a/tests/audio/AmdtpDirectTxTests.cpp b/tests/audio/AmdtpDirectTxTests.cpp index 13af7716d..f984eba5f 100644 --- a/tests/audio/AmdtpDirectTxTests.cpp +++ b/tests/audio/AmdtpDirectTxTests.cpp @@ -1,9 +1,12 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include "Audio/Engine/Direct/Tx/DiceTxStreamEngine.hpp" #include "Audio/Ports/IAmdtpTxSlotProvider.hpp" #include "Audio/Wire/AMDTP/AmdtpPacketTimeline.hpp" #include "Audio/Wire/AMDTP/AmdtpPayloadWriter.hpp" #include "Audio/Wire/AMDTP/AmdtpTxPacketizer.hpp" #include "Audio/Wire/AMDTP/PcmSlotCodec.hpp" +#include "Audio/Wire/MOTU/MotuBlockCodec.hpp" +#include "Audio/Wire/MOTU/MotuSph.hpp" #include @@ -58,6 +61,20 @@ AmdtpStreamConfig BlockingStereoConfig() { return config; } +AmdtpStreamConfig MotuUltraLiteConfig() { + AmdtpStreamConfig config{}; + config.sampleRate = 48000; + config.streamMode = StreamMode::Blocking; + config.dbs = 13; + config.pcmChannels = 14; + config.fmt = 0x02; + config.fdf = 0x22; + config.sph = true; + config.framesPerDataPacket = 8; + config.maxPacketBytes = 424; + return config; +} + TEST(AmdtpDirectTxTests, Int32EncodingUsesHighSigned24Bits) { EXPECT_EQ(PcmSlotCodec::EncodeInt32( INT32_MAX, PcmSlotEncoding::RawSigned24In32BE), @@ -180,6 +197,99 @@ TEST(AmdtpDirectTxTests, ReplayOverridesLocalCadencePerPhysicalCycle) { EXPECT_EQ(packet.dbc, 8U); } +TEST(AmdtpDirectTxTests, MotuPacketUsesEndDbcNoInfoSytAndReplayedSph) { + AmdtpPacketTimeline timeline{}; + std::array timelineSlots{}; + ASSERT_TRUE(timeline.AttachSlots(timelineSlots.data(), timelineSlots.size())); + + AmdtpTxPacketizer packetizer{}; + packetizer.BindTimeline(&timeline); + ASSERT_TRUE(packetizer.Configure(MotuUltraLiteConfig(), AmdtpTxPolicy{})); + + std::array bytes{}; + AmdtpTimingState timing{}; + timing.disposition = AmdtpPacketDisposition::Data; + timing.replayValid = true; + timing.replayDataBlocks = 6; + timing.packetCycleTicks = 100 * ASFW::Encoding::Motu::kTicksPerCycle; + timing.motuSphCount = 6; + timing.motuSphOffsets = {10, 567, 1124, 1681, 2238, 2795, 0, 0}; + + PreparedTxPacket packet{}; + ASSERT_TRUE(packetizer.PrepareNextPacket( + {0, bytes.data(), bytes.size()}, timing, packet)); + EXPECT_TRUE(packet.isData); + EXPECT_EQ(packet.byteCount, 320U); + EXPECT_EQ(packet.framesInPacket, 6U); + EXPECT_EQ(packet.dbc, 6U); + EXPECT_EQ(packet.syt, 0xFFFFU); + + // DBS=13, SPH=1 and end-event DBC=6 in CIP Q0; MOTU fmt/fdf and + // SYT_NO_INFO in Q1. + EXPECT_EQ(bytes[1], 13U); + EXPECT_EQ(bytes[2] & 0x04U, 0x04U); + EXPECT_EQ(bytes[3], 6U); + EXPECT_EQ(bytes[4], 0x82U); + EXPECT_EQ(bytes[5], 0x22U); + EXPECT_EQ(bytes[6], 0xFFU); + EXPECT_EQ(bytes[7], 0xFFU); + + constexpr uint32_t kBlockBytes = 13U * 4U; + for (uint32_t i = 0; i < 6; ++i) { + const std::span block( + bytes.data() + 8 + i * kBlockBytes, kBlockBytes); + const uint32_t expected = ASFW::Encoding::Motu::ReplaySph( + timing.motuSphOffsets[i], + static_cast(timing.packetCycleTicks)); + EXPECT_EQ(ASFW::Encoding::Motu::ReadSph(block), expected); + } +} + +TEST(AmdtpDirectTxTests, MotuPacketRejectsMissingSphReplayData) { + AmdtpPacketTimeline timeline{}; + std::array timelineSlots{}; + ASSERT_TRUE(timeline.AttachSlots(timelineSlots.data(), timelineSlots.size())); + AmdtpTxPacketizer packetizer{}; + packetizer.BindTimeline(&timeline); + ASSERT_TRUE(packetizer.Configure(MotuUltraLiteConfig(), AmdtpTxPolicy{})); + + std::array bytes{}; + AmdtpTimingState timing{}; + timing.disposition = AmdtpPacketDisposition::Data; + timing.replayValid = true; + timing.replayDataBlocks = 6; + timing.motuSphCount = 5; + PreparedTxPacket packet{}; + EXPECT_FALSE(packetizer.PrepareNextPacket( + {0, bytes.data(), bytes.size()}, timing, packet)); +} + +TEST(AmdtpDirectTxTests, MotuPacketAcceptsLargestReplayedCadencePacket) { + AmdtpPacketTimeline timeline{}; + std::array timelineSlots{}; + ASSERT_TRUE(timeline.AttachSlots(timelineSlots.data(), timelineSlots.size())); + AmdtpTxPacketizer packetizer{}; + packetizer.BindTimeline(&timeline); + ASSERT_TRUE(packetizer.Configure(MotuUltraLiteConfig(), AmdtpTxPolicy{})); + + std::array bytes{}; + AmdtpTimingState timing{}; + timing.disposition = AmdtpPacketDisposition::Data; + timing.replayValid = true; + timing.replayDataBlocks = 8; + timing.packetCycleTicks = 200 * ASFW::Encoding::Motu::kTicksPerCycle; + timing.motuSphCount = 8; + timing.motuSphOffsets = {10, 567, 1124, 1681, 2238, 2795, 3352, 3909}; + + PreparedTxPacket packet{}; + ASSERT_TRUE(packetizer.PrepareNextPacket( + {0, bytes.data(), bytes.size()}, timing, packet)); + EXPECT_TRUE(packet.isData); + EXPECT_EQ(packet.byteCount, 424U); + EXPECT_EQ(packet.framesInPacket, 8U); + EXPECT_EQ(packet.dbc, 8U); +} + TEST(AmdtpDirectTxTests, PayloadWriterReadsMappedInt32RingDirectly) { AmdtpPacketTimeline timeline{}; std::array timelineSlots{}; @@ -225,6 +335,53 @@ TEST(AmdtpDirectTxTests, PayloadWriterReadsMappedInt32RingDirectly) { EXPECT_EQ(dataBytes[15], 0x01); } +TEST(AmdtpDirectTxTests, MotuPayloadWriterMapsLogicalMainOutToWireSlotsElevenAndTwelve) { + AmdtpPacketTimeline timeline{}; + std::array timelineSlots{}; + ASSERT_TRUE(timeline.AttachSlots(timelineSlots.data(), timelineSlots.size())); + + auto config = MotuUltraLiteConfig(); + AmdtpTxPolicy policy{}; + policy.hostToDevicePcmEncoding = PcmSlotEncoding::MotuV2Packed24; + policy.sourceChannelMapEnabled = true; + policy.sourceChannelForWireSlot = { + 12, 13, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 10, 11, + }; + + AmdtpTxPacketizer packetizer{}; + packetizer.BindTimeline(&timeline); + ASSERT_TRUE(packetizer.Configure(config, policy)); + + std::array packetBytes{}; + AmdtpTimingState timing{}; + timing.disposition = AmdtpPacketDisposition::Data; + timing.replayValid = true; + timing.replayDataBlocks = 6; + timing.motuSphCount = 6; + PreparedTxPacket packet{}; + ASSERT_TRUE(packetizer.PrepareNextPacket( + {0, packetBytes.data(), packetBytes.size()}, timing, packet)); + ASSERT_TRUE(packet.isData); + + AmdtpPayloadWriter writer{}; + writer.Configure(config, policy); + writer.BindTimeline(&timeline); + std::array hostRing{}; + for (uint32_t frame = 0; frame < 6; ++frame) { + hostRing[frame * 14] = 1.0f; // logical Main Out left + hostRing[frame * 14 + 10] = -1.0f; // logical channel 11 + } + writer.WriteFloat32Interleaved( + {hostRing.data(), 0, 6, 6, 14}, 0); + + const std::span firstBlock( + packetBytes.data() + 8, ASFW::Encoding::Motu::DataBlockBytes(14)); + EXPECT_EQ(ASFW::Encoding::Motu::ReadPcmChannel(firstBlock, 12), + static_cast(0x80000100)); + EXPECT_EQ(ASFW::Encoding::Motu::ReadPcmChannel(firstBlock, 10), + 0x7FFFFF00); +} + TEST(AmdtpDirectTxTests, PayloadWriterCountsUnderExposureAtCallBoundary) { AmdtpPacketTimeline timeline{}; std::array timelineSlots{}; diff --git a/tests/audio/AudioEngineDirect/RxAudioPacketProcessorTests.cpp b/tests/audio/AudioEngineDirect/RxAudioPacketProcessorTests.cpp index 1968aee8b..a4ccba356 100644 --- a/tests/audio/AudioEngineDirect/RxAudioPacketProcessorTests.cpp +++ b/tests/audio/AudioEngineDirect/RxAudioPacketProcessorTests.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // RxAudioPacketProcessorTests.cpp @@ -13,6 +14,7 @@ #include "Audio/DriverKit/Runtime/AudioGraphBinding.hpp" #include "Audio/Engine/Direct/DirectInputWriter.hpp" #include "Audio/Engine/Direct/Rx/RxAudioPacketProcessor.hpp" +#include "Audio/Wire/MOTU/MotuBlockCodec.hpp" #include @@ -48,8 +50,10 @@ constexpr uint32_t MakeQuadlet0(uint8_t dbs, uint8_t dbc = 0) noexcept { } /// CIP quadlet 1: EOH_1 must be 1. FMT 0x10 = AM824. SYT at [15:0]. -constexpr uint32_t MakeQuadlet1(uint16_t syt, uint8_t fdf = 0x02) noexcept { - return (1U << 31) | (0x10U << 24) | (static_cast(fdf) << 16) | syt; +constexpr uint32_t MakeQuadlet1(uint16_t syt, uint8_t fdf = 0x02, + uint8_t fmt = 0x10) noexcept { + return (1U << 31) | (static_cast(fmt) << 24) | + (static_cast(fdf) << 16) | syt; } /// Build one isoch packet: 8-byte isoch header, two CIP quadlets, then @@ -184,6 +188,92 @@ TEST(RxAudioPacketProcessorTests, DataPacketDecodesAndReportsItsSyt) { EXPECT_EQ(result.framesDecoded, 3u); } +TEST(RxAudioPacketProcessorTests, MotuPacketUsesConfiguredWidthAndDecodesPackedPcmAndSph) { + AudioTransportControlBlock control{}; + std::array inputBuffer{}; + AudioGraphBinding binding{ + .guid = 0x0001f20000085f25ULL, + .sampleRateHz = 48000, + .memory = AudioStreamMemory{ + .inputBase = inputBuffer.data(), + .inputFrameCapacity = 32, + .inputChannels = 14, + }, + .control = &control, + .deviceToHostAm824Slots = 13, + }; + DirectInputWriter writer{}; + writer.Bind(&binding); + RxAudioPacketProcessor processor(writer); + + constexpr uint32_t kDbs = 13; + constexpr uint32_t kFrames = 6; + // Real MOTU v2 capture can report zero in CIP DBS. The profile's fixed + // 13-quadlet geometry must remain authoritative. + auto packet = MakePacket(MakeQuadlet0(0), + MakeQuadlet1(0xFFFF, 0x22, 0x02), + kDbs, kFrames); + for (uint32_t frame = 0; frame < kFrames; ++frame) { + auto* bytes = packet.data() + kIsochHeaderBytes + 8 + frame * kDbs * 4; + std::span block(bytes, kDbs * 4); + ASFW::Encoding::Motu::WriteSph(block, (100U + frame) << 12); + ASFW::Encoding::Motu::WritePcmSample( + block.subspan(ASFW::Encoding::Motu::kPcmByteOffset, 3), + frame == 0 ? 0x40000000 : 0); + } + + const auto result = processor.ProcessPacket( + packet.data(), packet.size(), 0, 14, 13, + ASFW::Encoding::AudioWireFormat::kMotuV2, 0, true); + EXPECT_EQ(result.status, DirectRxWriteStatus::kAvailable); + EXPECT_EQ(result.framesDecoded, kFrames); + EXPECT_EQ(result.motuSphCount, kFrames); + EXPECT_EQ(result.motuSph[0], 100U << 12); + EXPECT_NEAR(inputBuffer[0], 0.5F, 0.000001F); + EXPECT_EQ(inputBuffer[1], 0.0F); +} + +TEST(RxAudioPacketProcessorTests, MotuUltraLiteMapsPhysicalInputsAheadOfMixReturn) { + AudioTransportControlBlock control{}; + std::array inputBuffer{}; + AudioGraphBinding binding{ + .guid = 0x0001f20000085f25ULL, + .sampleRateHz = 48000, + .memory = AudioStreamMemory{ + .inputBase = inputBuffer.data(), + .inputFrameCapacity = 8, + .inputChannels = 14, + }, + .control = &control, + .deviceToHostAm824Slots = 13, + }; + DirectInputWriter writer{}; + writer.Bind(&binding); + RxAudioPacketProcessor processor(writer); + + constexpr uint32_t kDbs = 13; + auto packet = MakePacket(MakeQuadlet0(0), + MakeQuadlet1(0xFFFF, 0x22, 0x02), + kDbs, 1); + auto* bytes = packet.data() + kIsochHeaderBytes + 8; + std::span block(bytes, kDbs * 4); + ASFW::Encoding::Motu::WritePcmSample( + block.subspan(ASFW::Encoding::Motu::kPcmByteOffset, 3), + 0x20000000); // raw channel 1: Mix1 return + ASFW::Encoding::Motu::WritePcmSample( + block.subspan(ASFW::Encoding::Motu::kPcmByteOffset + 2 * 3, 3), + 0x40000000); // raw channel 3: physical analog input 1 + + const auto result = processor.ProcessPacket( + packet.data(), packet.size(), 0, 14, 13, + ASFW::Encoding::AudioWireFormat::kMotuV2, 0, true, + &ASFW::Encoding::Motu::kUltraLiteInputWireChannelForHostChannel); + + ASSERT_EQ(result.status, DirectRxWriteStatus::kAvailable); + EXPECT_NEAR(inputBuffer[0], 0.5F, 0.000001F); + EXPECT_NEAR(inputBuffer[10], 0.25F, 0.000001F); +} + TEST(RxAudioPacketProcessorTests, EveryRejectStatusIsDistinct) { // The whole point of the split: no two faults share a value. If someone // collapses these again, the bring-up diagnosis stops working. diff --git a/tests/audio/CMakeLists.txt b/tests/audio/CMakeLists.txt index 5698ac883..1acba2df3 100644 --- a/tests/audio/CMakeLists.txt +++ b/tests/audio/CMakeLists.txt @@ -1,3 +1,4 @@ +# Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. # tests/audio/CMakeLists.txt macro(add_audio_test TARGET_NAME) @@ -55,6 +56,28 @@ add_audio_test(CIPHeaderBuilderTests CIPHeaderBuilderTests.cpp ) +# MOTU Block Codec Tests +add_audio_test(MotuBlockCodecTests + MotuBlockCodecTests.cpp +) + +# MOTU v2 Register Plane Tests +add_audio_test(MotuV2RegisterTests + MotuV2RegisterTests.cpp +) + +# MOTU Device Profile / Factory Identity Tests +add_audio_test(MotuV2ProfileTests + MotuV2ProfileTests.cpp +) + +# MOTU v2 Protocol Adapter Tests +add_audio_test(MotuV2ProtocolTests + MotuV2ProtocolTests.cpp + "${ASFW_DRIVER_DIR}/Audio/Protocols/MOTU/MotuV2Protocol.cpp" + "${ASFW_DRIVER_DIR}/Discovery/DeviceRegistry.cpp" +) + # Blocking Cadence Tests add_audio_test(BlockingCadenceTests BlockingCadenceTests.cpp diff --git a/tests/audio/DiceProfileTests.cpp b/tests/audio/DiceProfileTests.cpp index f81eb543d..e572a7986 100644 --- a/tests/audio/DiceProfileTests.cpp +++ b/tests/audio/DiceProfileTests.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. // Copyright (c) 2026 ASFireWire Project // // DiceProfileTests.cpp @@ -105,6 +106,30 @@ TEST(DiceProfileTests, ResolvesGenericDiceProfileForUnknownDevices) { EXPECT_FALSE(diceProfile->Quirks().tx.preserveFdfInNoDataPackets); } +TEST(DiceProfileTests, MotuUltraLitePresentsMainOutAsLogicalStereoOneAndTwo) { + constexpr uint32_t kMotuVendorId = 0x0001F2; + constexpr uint32_t kMotuUltraLiteModelId = 0x00000D; + const auto* profile = + AudioProfileRegistry::FindProfile(kMotuVendorId, kMotuUltraLiteModelId, 0); + + ASSERT_NE(profile, nullptr); + EXPECT_STREQ(profile->Name(), "MOTU UltraLite"); + EXPECT_EQ(profile->TxChannelCount(), 14U); + const auto preferred = profile->PreferredOutputStereoChannels(); + EXPECT_EQ(preferred.left, 1U); + EXPECT_EQ(preferred.right, 2U); + + const auto* streamProfile = static_cast(profile); + const auto policy = streamProfile->TxStreamPolicy(); + ASSERT_TRUE(policy.sourceChannelMapEnabled); + EXPECT_EQ(policy.sourceChannelForWireSlot[0], 12U); + EXPECT_EQ(policy.sourceChannelForWireSlot[1], 13U); + EXPECT_EQ(policy.sourceChannelForWireSlot[10], 0U); + EXPECT_EQ(policy.sourceChannelForWireSlot[11], 1U); + EXPECT_EQ(policy.sourceChannelForWireSlot[12], 10U); + EXPECT_EQ(policy.sourceChannelForWireSlot[13], 11U); +} + TEST(DiceProfileTests, WeissIntProfileKeepsDuplexWireShapeButHidesCaptureFromCoreAudio) { constexpr uint32_t kWeissVendorId = 0x001c6a; constexpr uint32_t kInt202ModelId = 0x000006; diff --git a/tests/audio/MotuBlockCodecTests.cpp b/tests/audio/MotuBlockCodecTests.cpp new file mode 100644 index 000000000..df793515a --- /dev/null +++ b/tests/audio/MotuBlockCodecTests.cpp @@ -0,0 +1,196 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// MotuBlockCodecTests.cpp +// ASFW - MOTU v2 data-block layout, SPH math, and codec tests +// +// Golden values cross-validated with Linux sound/firewire/motu +// (amdtp-motu.c, motu-protocol-v2.c, motu-stream.c, motu.c). + +#include +#include "Audio/Wire/MOTU/MotuBlockLayout.hpp" +#include "Audio/Wire/MOTU/MotuSph.hpp" +#include "Audio/Wire/MOTU/MotuBlockCodec.hpp" +#include +#include + +using namespace ASFW::Encoding::Motu; + +//============================================================================== +// Layout / geometry (motu-protocol-v2.c, amdtp-motu.c:69-90) +//============================================================================== + +TEST(MotuBlockLayoutTests, RateIndexAndModeMapping) { + EXPECT_EQ(RateToIndex(44100u), 0); + EXPECT_EQ(RateToIndex(48000u), 1); + EXPECT_EQ(RateToIndex(96000u), 3); + EXPECT_EQ(RateToIndex(192000u), 5); + EXPECT_EQ(RateToIndex(22050u), -1); + + EXPECT_EQ(IndexToMode(0u), 0u); // 44.1k -> mode 0 + EXPECT_EQ(IndexToMode(1u), 0u); // 48k -> mode 0 + EXPECT_EQ(IndexToMode(2u), 1u); // 88.2k -> mode 1 + EXPECT_EQ(IndexToMode(3u), 1u); // 96k -> mode 1 +} + +TEST(MotuBlockLayoutTests, Motu828mk2ChunkCounts) { + // Fixed {14, 14, 0} (motu-protocol-v2.c:274-282); optical ADAT adds + // +8 at mode 0, +4 at mode 1 (motu-protocol-v2.c:253-269). + EXPECT_EQ(k828mk2FixedPcmChunks[0], 14u); + EXPECT_EQ(k828mk2FixedPcmChunks[1], 14u); + EXPECT_EQ(k828mk2FixedPcmChunks[0] + AdatExtraChunks(0), 22u); + EXPECT_EQ(k828mk2FixedPcmChunks[1] + AdatExtraChunks(1), 18u); +} + +TEST(MotuBlockLayoutTests, DataBlockQuadletMath) { + // quadlets = 1 + ceil((2 + pcm) * 3 / 4) (amdtp-motu.c:69-74) + EXPECT_EQ(DataBlockQuadlets(14u), 13u); // 828mk2, mode 0, optical off + EXPECT_EQ(DataBlockQuadlets(22u), 19u); // 828mk2, mode 0, ADAT on + EXPECT_EQ(DataBlockQuadlets(18u), 16u); // 828mk2, mode 1, ADAT on + EXPECT_EQ(DataBlockBytes(14u), 52u); +} + +TEST(MotuBlockLayoutTests, MidiPacingInterval) { + // rate / 3093 (amdtp-motu.c:31,87-88) + EXPECT_EQ(MidiDataBlockInterval(44100u), 14u); + EXPECT_EQ(MidiDataBlockInterval(48000u), 15u); + EXPECT_EQ(MidiDataBlockInterval(96000u), 31u); +} + +//============================================================================== +// SPH math (amdtp-motu.c:19-25,303-393) +//============================================================================== + +TEST(MotuSphTests, ComposesAndDecomposesSph) { + // cycle 5, offset 100 -> (5 << 12) | 100 + EXPECT_EQ(SphFromTick(5u * kTicksPerCycle + 100u), (5u << 12) | 100u); + EXPECT_EQ(TickFromSph((5u << 12) | 100u), 5u * kTicksPerCycle + 100u); +} + +TEST(MotuSphTests, RoundTripsAtTimelineEdges) { + const uint32_t ticks[] = {0u, kTicksPerCycle - 1, kTicksPerCycle, + kTicksPerSecond - 1}; + for (uint32_t t : ticks) { + EXPECT_EQ(TickFromSph(SphFromTick(t)), t) << "tick " << t; + } + // One full second wraps to zero. + EXPECT_EQ(SphFromTick(kTicksPerSecond), 0u); +} + +TEST(MotuSphTests, OffsetFromBaseHandlesSecondWrap) { + // Presentation just after the wrap, base just before it + // (amdtp-motu.c:319-320: tick < base -> += one second). + const uint32_t baseTick = kTicksPerSecond - 50u; + const uint32_t sph = SphFromTick(25u); + EXPECT_EQ(TickOffsetFromBase(sph, baseTick), 75u); +} + +TEST(MotuSphTests, OffsetFromBaseWithoutWrap) { + const uint32_t baseTick = 1000u; + const uint32_t sph = SphFromTick(1500u); + EXPECT_EQ(TickOffsetFromBase(sph, baseTick), 500u); +} + +TEST(MotuSphTests, ReplayRebasesCachedOffsets) { + // Offsets cached on the receive side are preserved relative to whatever + // base the transmit side rebases onto (amdtp-motu.c:373-393). + const uint32_t offsets[] = {0u, 7u, 557u, 1114u}; + const uint32_t txBase = 400u * kTicksPerCycle; + + for (uint32_t cached : offsets) { + const uint32_t sph = ReplaySph(cached, txBase); + EXPECT_EQ(TickFromSph(sph), (txBase + cached) % kTicksPerSecond); + } +} + +TEST(MotuSphTests, ReplayWrapsOneSecondTimeline) { + const uint32_t txBase = kTicksPerSecond - 10u; + EXPECT_EQ(TickFromSph(ReplaySph(30u, txBase)), 20u); +} + +//============================================================================== +// PCM codec (amdtp-motu.c:93-187) +//============================================================================== + +TEST(MotuBlockCodecTests, PacksSampleBigEndian3Bytes) { + std::array chunk{}; + WritePcmSample(chunk, 0x12345600); // 24-bit sample 0x123456, MSB-aligned + EXPECT_EQ(chunk[0], 0x12u); + EXPECT_EQ(chunk[1], 0x34u); + EXPECT_EQ(chunk[2], 0x56u); +} + +TEST(MotuBlockCodecTests, SampleRoundTrips) { + const int32_t samples[] = {0, 0x7FFFFF00, static_cast(0x80000000), + static_cast(0xFFFFFF00), 0x00000100}; + std::array chunk{}; + for (int32_t s : samples) { + WritePcmSample(chunk, s); + EXPECT_EQ(ReadPcmSample(chunk), s); + } +} + +TEST(MotuBlockCodecTests, WritesFrameAtPcmByteOffset) { + std::vector block(DataBlockBytes(14u), 0xEE); + const std::array frame{0x01020300, 0x0A0B0C00}; + + WritePcmFrame(block, frame); + + // Channel 0 lands at byte offset 10 (motu-protocol-v2.c:234). + EXPECT_EQ(block[10], 0x01u); + EXPECT_EQ(block[11], 0x02u); + EXPECT_EQ(block[12], 0x03u); + // Channel 1 packs immediately after, 3-byte stride. + EXPECT_EQ(block[13], 0x0Au); + EXPECT_EQ(ReadPcmChannel(block, 1u), 0x0A0B0C00); + // SPH and message bytes untouched. + EXPECT_EQ(block[0], 0xEEu); + EXPECT_EQ(block[9], 0xEEu); +} + +TEST(MotuBlockCodecTests, SilenceZeroesOnlyPcmChunks) { + std::vector block(DataBlockBytes(14u), 0xEE); + WritePcmSilence(block, 14u); + + for (uint32_t c = 0; c < 14u; ++c) { + EXPECT_EQ(ReadPcmChannel(block, c), 0) << "channel " << c; + } + EXPECT_EQ(block[kMidiFlagByteOffset2ndQ], 0xEEu); // msg bytes untouched +} + +//============================================================================== +// SPH quadlet placement +//============================================================================== + +TEST(MotuBlockCodecTests, SphOccupiesLeadingQuadletBigEndian) { + std::vector block(DataBlockBytes(14u), 0x00); + const uint32_t sph = (1234u << 12) | 567u; + WriteSph(block, sph); + + EXPECT_EQ(block[0], static_cast(sph >> 24)); + EXPECT_EQ(block[3], static_cast(sph & 0xFF)); + EXPECT_EQ(ReadSph(block), sph); +} + +//============================================================================== +// MIDI slot, second-quadlet flavor (motu-stream.c:117-131, amdtp-motu.c:211-254) +//============================================================================== + +TEST(MotuBlockCodecTests, WritesMidiByteWithFlag) { + std::vector block(DataBlockBytes(14u), 0x00); + WriteMidi2ndQ(block, 0x90u); + + EXPECT_EQ(block[4], 0x01u); // flag byte offset 4 + EXPECT_EQ(block[6], 0x90u); // MIDI byte offset 6 + + const auto read = ReadMidi2ndQ(block); + ASSERT_TRUE(read.has_value()); + EXPECT_EQ(*read, 0x90u); +} + +TEST(MotuBlockCodecTests, EmptyMidiSlotClearsFlagAndByte) { + std::vector block(DataBlockBytes(14u), 0xFF); + WriteMidi2ndQ(block, std::nullopt); + + EXPECT_EQ(block[4], 0x00u); + EXPECT_EQ(block[6], 0x00u); + EXPECT_FALSE(ReadMidi2ndQ(block).has_value()); +} diff --git a/tests/audio/MotuV2ProfileTests.cpp b/tests/audio/MotuV2ProfileTests.cpp new file mode 100644 index 000000000..82aa8c45a --- /dev/null +++ b/tests/audio/MotuV2ProfileTests.cpp @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// +// MOTU device-profile and factory-identity matching tests. +// +// MOTU is the one audio family that (vendor_id, model_id) cannot discriminate: the root +// directory publishes model_id 0 and the model lives in the unit directory's +// Unit_Sw_Version (Linux sound/firewire/motu/motu.c:151-181). +// +// Identity values below are from a real 828mkII config ROM captured 2026-07-26: +// vendor 0x0001F2, Unit_Spec_Id 0x0001F2, Unit_Sw_Version 0x000003. + +#include + +#include "Audio/Protocols/DeviceProtocolFactory.hpp" +#include "DeviceProfiles/Audio/AudioProfileRegistry.hpp" + +namespace { + +using ASFW::Audio::DeviceIntegrationMode; +using ASFW::Audio::DeviceProtocolFactory; +using ASFW::DeviceProfiles::MatchSource; +using ASFW::DeviceProfiles::DeviceProfileQuery; +using ASFW::DeviceProfiles::Audio::AudioIntegrationMode; +using ASFW::DeviceProfiles::Audio::AudioProfileRegistry; +using ASFW::DeviceProfiles::Audio::AudioProtocolFamily; +using ASFW::DeviceProfiles::Audio::kMotu828mk2SwVersion; +using ASFW::DeviceProfiles::Audio::kMotu896hdSwVersion; +using ASFW::DeviceProfiles::Audio::kMotuUltraliteSwVersion; +using ASFW::DeviceProfiles::Audio::kMotuVendorId; + +constexpr DeviceProfileQuery Make828mk2Query() { + return DeviceProfileQuery{.vendorId = kMotuVendorId, + .modelId = 0x000000U, // MOTU publishes no root model_id + .unitSpecId = kMotuVendorId, + .unitSwVersion = kMotu828mk2SwVersion}; +} + +//============================================================================== +// Profile registry +//============================================================================== + +TEST(MotuProfileTests, Identifies828mk2FromUnitDirectory) { + const auto identity = AudioProfileRegistry::LookupIdentity(Make828mk2Query()); + ASSERT_TRUE(identity.has_value()); + EXPECT_EQ(identity->vendorId, kMotuVendorId); + EXPECT_STREQ(identity->vendorName, "MOTU"); + EXPECT_STREQ(identity->modelName, "828mkII"); + // The software version is the only stable model discriminator MOTU publishes. + EXPECT_EQ(identity->modelId, kMotu828mk2SwVersion); + EXPECT_EQ(identity->source, MatchSource::ConfigROM); +} + +TEST(MotuProfileTests, Enables828mk2AudioIntegration) { + const auto profile = AudioProfileRegistry::LookupBestAudioProfile(Make828mk2Query()); + ASSERT_TRUE(profile.has_value()); + EXPECT_EQ(profile->family, AudioProtocolFamily::VendorSpecific); + EXPECT_EQ(profile->mode, AudioIntegrationMode::kHardcodedNub); +} + +TEST(MotuProfileTests, EnablesOriginalUltraLiteAudioIntegration) { + auto query = Make828mk2Query(); + query.unitSwVersion = kMotuUltraliteSwVersion; + + const auto identity = AudioProfileRegistry::LookupIdentity(query); + ASSERT_TRUE(identity.has_value()); + EXPECT_STREQ(identity->modelName, "UltraLite"); + + const auto profile = AudioProfileRegistry::LookupBestAudioProfile(query); + ASSERT_TRUE(profile.has_value()); + EXPECT_EQ(profile->family, AudioProtocolFamily::VendorSpecific); + EXPECT_EQ(profile->mode, AudioIntegrationMode::kHardcodedNub); +} + +TEST(MotuProfileTests, ResolvesCanonicalUltraLiteProfileAfterDiscovery) { + const DeviceProfileQuery query{.vendorId = kMotuVendorId, + .modelId = kMotuUltraliteSwVersion}; + const auto profile = AudioProfileRegistry::LookupBestAudioProfile(query); + ASSERT_TRUE(profile.has_value()); + EXPECT_EQ(profile->mode, AudioIntegrationMode::kHardcodedNub); +} + +TEST(MotuProfileTests, NamesUnverifiedSiblingsWithoutEnablingThem) { + auto query = Make828mk2Query(); + query.unitSwVersion = kMotu896hdSwVersion; + + const auto identity = AudioProfileRegistry::LookupIdentity(query); + ASSERT_TRUE(identity.has_value()); + EXPECT_STREQ(identity->modelName, "896HD"); + + // Recognized by name, but no protocol is constructed until the chunk layout is + // confirmed on real hardware. + EXPECT_FALSE(AudioProfileRegistry::LookupBestAudioProfile(query).has_value()); +} + +TEST(MotuProfileTests, RejectsUnknownSwVersion) { + auto query = Make828mk2Query(); + query.unitSwVersion = 0x0000ffU; + EXPECT_FALSE(AudioProfileRegistry::LookupIdentity(query).has_value()); + EXPECT_FALSE(AudioProfileRegistry::LookupBestAudioProfile(query).has_value()); +} + +TEST(MotuProfileTests, RequiresMotuSpecifierNotJustVendor) { + // Vendor OUI alone must not match: the specifier ID must also be the MOTU OUI, + // mirroring IEEE1394_MATCH_SPECIFIER_ID in the Linux id table. + auto query = Make828mk2Query(); + query.unitSpecId = 0x00a02dU; // generic 1394TA audio specifier + EXPECT_FALSE(AudioProfileRegistry::LookupIdentity(query).has_value()); +} + +TEST(MotuProfileTests, IgnoresQueriesWithoutUnitIdentity) { + // A bare (vendor, model) query — the shape every pre-MOTU call site uses — must not + // accidentally resolve to a MOTU device. + const DeviceProfileQuery query{.vendorId = kMotuVendorId, .modelId = 0x000003U}; + EXPECT_FALSE(AudioProfileRegistry::LookupIdentity(query).has_value()); +} + +//============================================================================== +// Factory identity surface +//============================================================================== + +TEST(MotuFactoryTests, ResolvesIntegrationModeFromUnitIdentity) { + EXPECT_EQ(DeviceProtocolFactory::LookupIntegrationMode( + kMotuVendorId, + 0x000000U, + DeviceProtocolFactory::UnitIdentity{.specId = kMotuVendorId, + .swVersion = kMotu828mk2SwVersion}), + DeviceIntegrationMode::kHardcodedNub); +} + +TEST(MotuFactoryTests, ResolvesUltraLiteAudioModeFromUnitIdentity) { + EXPECT_EQ(DeviceProtocolFactory::LookupIntegrationMode( + kMotuVendorId, + 0x000000U, + DeviceProtocolFactory::UnitIdentity{.specId = kMotuVendorId, + .swVersion = kMotuUltraliteSwVersion}), + DeviceIntegrationMode::kHardcodedNub); +} + +TEST(MotuFactoryTests, UnitIdentityDefaultsLeaveExistingLookupsUnchanged) { + // The defaulted UnitIdentity parameter must not change the answer for any + // model_id-matched family. + EXPECT_EQ(DeviceProtocolFactory::LookupIntegrationMode( + DeviceProtocolFactory::kAlesisVendorId, + DeviceProtocolFactory::kAlesisMultiMixModelId), + DeviceIntegrationMode::kHardcodedNub); + + EXPECT_EQ(DeviceProtocolFactory::LookupIntegrationMode(kMotuVendorId, 0x000000U), + DeviceIntegrationMode::kNone); +} + +TEST(MotuFactoryTests, RecognizesKnownDeviceViaUnitIdentity) { + EXPECT_TRUE(DeviceProtocolFactory::IsKnownDevice( + kMotuVendorId, + 0x000000U, + DeviceProtocolFactory::UnitIdentity{.specId = kMotuVendorId, + .swVersion = kMotu828mk2SwVersion})); + + EXPECT_FALSE(DeviceProtocolFactory::IsKnownDevice(kMotuVendorId, 0x000000U)); +} + +} // namespace diff --git a/tests/audio/MotuV2ProtocolTests.cpp b/tests/audio/MotuV2ProtocolTests.cpp new file mode 100644 index 000000000..b7332041c --- /dev/null +++ b/tests/audio/MotuV2ProtocolTests.cpp @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// +// MotuV2Protocol transport-adapter tests. +// +// These pin the exact register traffic the protocol emits. The wire values were +// confirmed against a real 828mkII on 2026-07-26: a clock status read returned +// 0x00000008 (48 kHz, internal), matching the device's own front panel, and writing +// 0x00000000 moved its hardware rate display to 44.1 kHz. +// +// Async address register semantics cross-validated with Linux +// sound/firewire/motu/motu-transaction.c:74-133. + +#include + +#include "Audio/Protocols/MOTU/MotuV2Protocol.hpp" +#include "Discovery/DeviceRegistry.hpp" + +#include +#include + +namespace { + +using ASFW::Async::AsyncHandle; +using ASFW::Async::AsyncStatus; +using ASFW::Async::FWAddress; +using ASFW::Audio::Motu::ClockStatus; +using ASFW::Audio::Motu::kAddrBase; +using ASFW::Audio::Motu::kAsyncMessageRegionStart; +using ASFW::Audio::Motu::ClockSourceV2; +using ASFW::Audio::Motu::MotuV2Protocol; +using ASFW::Audio::Motu::Reg; +using ASFW::Discovery::CfgKey; +using ASFW::Discovery::DeviceKind; +using ASFW::Discovery::RomEntry; +using ASFW::Discovery::UnitDirectory; + +constexpr uint32_t k828mk2SwVersion = 0x000003U; +constexpr uint16_t kNodeId = 0x0001U; + +/// Records every transaction and replays programmed quadlet values. Completions fire +/// inline, which keeps the tests free of any scheduling assumptions. +class RecordingBus final : public ASFW::Async::IFireWireBusOps, + public ASFW::Async::IFireWireBusInfo { +public: + struct Write { + uint32_t addressLo{0}; + uint32_t value{0}; + }; + + std::vector writes; + std::vector reads; + std::optional readValue; + AsyncStatus readStatus{AsyncStatus::kSuccess}; + AsyncStatus writeStatus{AsyncStatus::kSuccess}; + AsyncStatus failWritesAfterFirst{AsyncStatus::kSuccess}; + + AsyncHandle ReadBlock(ASFW::FW::Generation, ASFW::FW::NodeId, FWAddress address, uint32_t, + ASFW::FW::FwSpeed, + ASFW::Async::InterfaceCompletionCallback callback) override { + reads.push_back(address.addressLo); + if (readStatus != AsyncStatus::kSuccess || !readValue.has_value()) { + callback(readStatus, {}); + return AsyncHandle{1}; + } + const uint32_t value = *readValue; + const uint8_t payload[4] = {static_cast(value >> 24), + static_cast(value >> 16), + static_cast(value >> 8), + static_cast(value)}; + callback(AsyncStatus::kSuccess, std::span(payload, 4)); + return AsyncHandle{1}; + } + + AsyncHandle WriteBlock(ASFW::FW::Generation, ASFW::FW::NodeId, FWAddress address, + std::span data, ASFW::FW::FwSpeed, + ASFW::Async::InterfaceCompletionCallback callback) override { + uint32_t value = 0; + for (const uint8_t byte : data) { + value = (value << 8) | byte; + } + const bool isFirstWrite = writes.empty(); + writes.push_back(Write{address.addressLo, value}); + + const AsyncStatus status = + (!isFirstWrite && failWritesAfterFirst != AsyncStatus::kSuccess) + ? failWritesAfterFirst + : writeStatus; + callback(status, {}); + return AsyncHandle{1}; + } + + AsyncHandle Lock(ASFW::FW::Generation, ASFW::FW::NodeId, FWAddress, ASFW::FW::LockOp, + std::span, uint32_t, ASFW::FW::FwSpeed, + ASFW::Async::InterfaceCompletionCallback callback) override { + callback(AsyncStatus::kSuccess, {}); + return AsyncHandle{1}; + } + + bool Cancel(AsyncHandle) override { return false; } + + ASFW::FW::FwSpeed GetSpeed(ASFW::FW::NodeId) const override { + return ASFW::FW::FwSpeed::S400; + } + uint32_t HopCount(ASFW::FW::NodeId, ASFW::FW::NodeId) const override { return 1; } + ASFW::FW::Generation GetGeneration() const override { return ASFW::FW::Generation{2}; } + ASFW::FW::NodeId GetLocalNodeID() const override { return ASFW::FW::NodeId{0}; } +}; + +struct MotuProtocolHarness { + explicit MotuProtocolHarness(RecordingBus& bus) + : route(MakeRoute()) + , protocol(bus, bus, registry, route, nullptr, k828mk2SwVersion) {} + + ASFW::Discovery::DeviceRouteToken MakeRoute() { + ASFW::Discovery::ConfigROM rom{}; + rom.bib.guid = 0x0001f20000000003ULL; + rom.gen = ASFW::FW::Generation{2}; + rom.nodeId = kNodeId; + (void)registry.UpsertFromROM(rom, ASFW::Discovery::LinkPolicy{}); + return *registry.CurrentRoute(rom.bib.guid); + } + + ASFW::Discovery::DeviceRegistry registry; + ASFW::Discovery::DeviceRouteToken route{}; + MotuV2Protocol protocol; +}; + +constexpr uint32_t LowOf(Reg reg) { + return static_cast(kAddrBase) + static_cast(reg); +} + +TEST(MotuV2DiscoveryTests, RegistryClassifiesUnitDirectoryIdentityAsAudio) { + ASFW::Discovery::DeviceRegistry registry; + ASFW::Discovery::ConfigROM rom{}; + rom.bib.guid = 0x0001f20000085f25ULL; + rom.gen = ASFW::FW::Generation{2}; + rom.nodeId = 0; + rom.rootDirMinimal = { + RomEntry{.key = CfgKey::VendorId, .value = 0x0001f2U}, + RomEntry{.key = CfgKey::ModelId, .value = 0U}, + }; + rom.unitDirectories = { + UnitDirectory{.unitSpecId = 0x0001f2U, + .unitSwVersion = 0x00000dU}, + }; + + const auto record = registry.UpsertFromROM(rom, ASFW::Discovery::LinkPolicy{}); + EXPECT_EQ(record.vendorId, 0x0001f2U); + EXPECT_EQ(record.modelId, 0x00000dU); + EXPECT_EQ(record.kind, DeviceKind::VendorSpecificAudio); + EXPECT_TRUE(record.isAudioCandidate); +} + +//============================================================================== +// Clock status +//============================================================================== + +TEST(MotuV2ProtocolTests, DecodesTheClockStatusObservedOnHardware) { + RecordingBus bus; + bus.readValue = 0x00000008U; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional observed; + protocol.ReadClockStatus([&](IOReturn status, ClockStatus clock) { + EXPECT_EQ(status, kIOReturnSuccess); + observed = clock; + }); + + ASSERT_TRUE(observed.has_value()); + EXPECT_EQ(observed->raw, 0x00000008U); + EXPECT_EQ(observed->sampleRateHz, 48000U); + ASSERT_TRUE(observed->source.has_value()); + EXPECT_EQ(*observed->source, ClockSourceV2::Internal); + + ASSERT_EQ(bus.reads.size(), 1U); + EXPECT_EQ(bus.reads[0], LowOf(Reg::ClockStatusV2)); + EXPECT_EQ(protocol.CachedSampleRateHz(), 48000U); +} + +TEST(MotuV2ProtocolTests, ReportsReadFailureAndLeavesCacheUnset) { + RecordingBus bus; + bus.readStatus = AsyncStatus::kTimeout; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.ReadClockStatus([&](IOReturn s, ClockStatus) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnTimeout); + EXPECT_EQ(protocol.CachedSampleRateHz(), 0U); +} + +//============================================================================== +// Sample rate write (read-modify-write) +//============================================================================== + +TEST(MotuV2ProtocolTests, WritesRateWhilePreservingClockSource) { + RecordingBus bus; + // 48 kHz on an external ADAT clock: rate bits change, source bits must not. + bus.readValue = 0x00000009U; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.SetSampleRate(44100U, [&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnSuccess); + ASSERT_EQ(bus.writes.size(), 1U); + EXPECT_EQ(bus.writes[0].addressLo, LowOf(Reg::ClockStatusV2)); + EXPECT_EQ(bus.writes[0].value, 0x00000001U); // rate index 0, source 1 preserved + EXPECT_EQ(protocol.CachedSampleRateHz(), 44100U); +} + +TEST(MotuV2ProtocolTests, SkipsTheWriteWhenTheRateAlreadyMatches) { + RecordingBus bus; + bus.readValue = 0x00000008U; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.SetSampleRate(48000U, [&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnSuccess); + EXPECT_TRUE(bus.writes.empty()); +} + +TEST(MotuV2ProtocolTests, RejectsAnUnsupportedRateWithoutTouchingTheDevice) { + RecordingBus bus; + bus.readValue = 0x00000008U; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.SetSampleRate(32000U, [&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnUnsupported); + EXPECT_TRUE(bus.writes.empty()); +} + +//============================================================================== +// Async message address registration / release +//============================================================================== + +TEST(MotuV2ProtocolTests, RegistersAsyncAddressAsHiLoPair) { + RecordingBus bus; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + const uint64_t hostAddress = kAsyncMessageRegionStart + 0x20U; + std::optional status; + protocol.RegisterAsyncMessageAddress(0xffc0U, hostAddress, [&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnSuccess); + ASSERT_EQ(bus.writes.size(), 2U); + EXPECT_EQ(bus.writes[0].addressLo, LowOf(Reg::AsyncAddrHi)); + EXPECT_EQ(bus.writes[0].value, (0xffc0U << 16) | 0x0000ffffU); + EXPECT_EQ(bus.writes[1].addressLo, LowOf(Reg::AsyncAddrLo)); + EXPECT_EQ(bus.writes[1].value, 0xe0000020U); + EXPECT_TRUE(protocol.HasRegisteredAsyncAddress()); +} + +TEST(MotuV2ProtocolTests, RejectsAddressOutsideTheDeviceAcceptedRegion) { + RecordingBus bus; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.RegisterAsyncMessageAddress(0xffc0U, 0x0000'1000'0000ULL, + [&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnBadArgument); + EXPECT_TRUE(bus.writes.empty()); + EXPECT_FALSE(protocol.HasRegisteredAsyncAddress()); +} + +TEST(MotuV2ProtocolTests, ReleaseZeroesBothHalves) { + RecordingBus bus; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.ReleaseAsyncMessageAddress([&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnSuccess); + ASSERT_EQ(bus.writes.size(), 2U); + EXPECT_EQ(bus.writes[0].addressLo, LowOf(Reg::AsyncAddrHi)); + EXPECT_EQ(bus.writes[0].value, 0U); + EXPECT_EQ(bus.writes[1].addressLo, LowOf(Reg::AsyncAddrLo)); + EXPECT_EQ(bus.writes[1].value, 0U); + EXPECT_FALSE(protocol.HasRegisteredAsyncAddress()); +} + +TEST(MotuV2ProtocolTests, DoesNotClaimRegistrationWhenTheSecondWriteFails) { + RecordingBus bus; + bus.failWritesAfterFirst = AsyncStatus::kTimeout; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + std::optional status; + protocol.RegisterAsyncMessageAddress(0xffc0U, kAsyncMessageRegionStart, + [&](IOReturn s) { status = s; }); + + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(*status, kIOReturnTimeout); + EXPECT_FALSE(protocol.HasRegisteredAsyncAddress()); +} + +TEST(MotuV2ProtocolTests, ShutdownReleasesOnlyWhenRegistered) { + RecordingBus bus; + MotuProtocolHarness harness(bus); + auto& protocol = harness.protocol; + + // Nothing registered: shutdown must not poke the device. + EXPECT_EQ(protocol.Shutdown(), kIOReturnSuccess); + EXPECT_TRUE(bus.writes.empty()); + + protocol.RegisterAsyncMessageAddress(0xffc0U, kAsyncMessageRegionStart, nullptr); + ASSERT_EQ(bus.writes.size(), 2U); + ASSERT_TRUE(protocol.HasRegisteredAsyncAddress()); + + EXPECT_EQ(protocol.Shutdown(), kIOReturnSuccess); + ASSERT_EQ(bus.writes.size(), 4U); + EXPECT_EQ(bus.writes[2].value, 0U); + EXPECT_EQ(bus.writes[3].value, 0U); + EXPECT_FALSE(protocol.HasRegisteredAsyncAddress()); +} + +} // namespace diff --git a/tests/audio/MotuV2RegisterTests.cpp b/tests/audio/MotuV2RegisterTests.cpp new file mode 100644 index 000000000..d4f7695fe --- /dev/null +++ b/tests/audio/MotuV2RegisterTests.cpp @@ -0,0 +1,148 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. +// MotuV2RegisterTests.cpp +// ASFW - MOTU protocol-v2 register-plane codec tests +// +// Golden values cross-validated with Linux sound/firewire/motu +// (motu-protocol-v2.c, motu-stream.c, motu-transaction.c, motu.c). + +#include +#include "Audio/Protocols/MOTU/MotuV2Registers.hpp" + +using namespace ASFW::Audio::Motu; + +//============================================================================== +// Clock status register (motu-protocol-v2.c:10-23, motu.c:16-26) +//============================================================================== + +TEST(MotuV2ClockTests, DecodesRateIndex) { + EXPECT_EQ(DecodeRateV2(0x00000000u).value(), 44100u); // index 0 + EXPECT_EQ(DecodeRateV2(0x00000008u).value(), 48000u); // index 1 + EXPECT_EQ(DecodeRateV2(0x00000018u).value(), 96000u); // index 3 + EXPECT_EQ(DecodeRateV2(0x00000028u).value(), 192000u); // index 5 +} + +TEST(MotuV2ClockTests, DecodeIgnoresUnrelatedBits) { + EXPECT_EQ(DecodeRateV2(0xFFFFFF0Fu).value(), 48000u); // index 1 amid noise +} + +TEST(MotuV2ClockTests, EncodeRatePreservesOtherBits) { + // Current: internal clock (source 0), 48k (index 1), some high bits set. + const auto encoded = EncodeRateV2(0x02000009u, 96000u); + ASSERT_TRUE(encoded.has_value()); + // Rate bits become index 3 (0x18); source bit 0x01 and 0x02000000 kept. + EXPECT_EQ(*encoded, 0x02000019u); +} + +TEST(MotuV2ClockTests, EncodeRejectsUnsupportedRate) { + EXPECT_FALSE(EncodeRateV2(0u, 32000u).has_value()); +} + +TEST(MotuV2ClockTests, DecodesClockSources) { + EXPECT_EQ(DecodeClockSourceV2(0x00000000u).value(), ClockSourceV2::Internal); + EXPECT_EQ(DecodeClockSourceV2(0x00000001u).value(), ClockSourceV2::AdatOnOpt); + EXPECT_EQ(DecodeClockSourceV2(0x00000003u).value(), ClockSourceV2::Sph); + EXPECT_EQ(DecodeClockSourceV2(0x00000005u).value(), ClockSourceV2::AdatOnDsub); + EXPECT_EQ(DecodeClockSourceV2(0x00000007u).value(), ClockSourceV2::AesEbuOnXlr); + EXPECT_FALSE(DecodeClockSourceV2(0x00000006u).has_value()); +} + +//============================================================================== +// Optical interface config (motu-protocol-v2.c:25-32) +//============================================================================== + +TEST(MotuV2OpticalTests, DecodesInAndOutModes) { + // in = ADAT (bits [9:8] = 1), out = SPDIF (bits [11:10] = 2). + const auto cfg = DecodeOptIfaceConfig(0x00000900u); + ASSERT_TRUE(cfg.has_value()); + EXPECT_EQ(cfg->input, OptIfaceMode::Adat); + EXPECT_EQ(cfg->output, OptIfaceMode::Spdif); +} + +TEST(MotuV2OpticalTests, DecodesNoneModes) { + const auto cfg = DecodeOptIfaceConfig(0x00000000u); + ASSERT_TRUE(cfg.has_value()); + EXPECT_EQ(cfg->input, OptIfaceMode::None); + EXPECT_EQ(cfg->output, OptIfaceMode::None); +} + +TEST(MotuV2OpticalTests, RejectsReservedModeValues) { + EXPECT_FALSE(DecodeOptIfaceConfig(0x00000300u).has_value()); // in = 3 +} + +//============================================================================== +// Iso comm control (motu-stream.c:12-21,62-107) +//============================================================================== + +TEST(MotuV2IsoCommTests, StartActivatesBothDirectionsWithChannels) { + // rx channel 5, tx channel 9; low 16 bits of current value preserved. + const uint32_t encoded = EncodeIsoCommStart(0x00001234u, 5u, 9u); + + EXPECT_EQ(encoded & 0x0000ffffu, 0x1234u); + EXPECT_TRUE(encoded & kChangeRxState); + EXPECT_TRUE(encoded & kRxActivated); + EXPECT_TRUE(encoded & kChangeTxState); + EXPECT_TRUE(encoded & kTxActivated); + + const auto state = DecodeIsoCommState(encoded); + EXPECT_TRUE(state.rxActivated); + EXPECT_EQ(state.rxChannel, 5u); + EXPECT_TRUE(state.txActivated); + EXPECT_EQ(state.txChannel, 9u); +} + +TEST(MotuV2IsoCommTests, StopClearsActivationKeepsChannels) { + const uint32_t running = EncodeIsoCommStart(0u, 5u, 9u); + const uint32_t stopped = EncodeIsoCommStop(running); + + EXPECT_TRUE(stopped & kChangeRxState); + EXPECT_TRUE(stopped & kChangeTxState); + + const auto state = DecodeIsoCommState(stopped); + EXPECT_FALSE(state.rxActivated); + EXPECT_FALSE(state.txActivated); + EXPECT_EQ(state.rxChannel, 5u); + EXPECT_EQ(state.txChannel, 9u); +} + +TEST(MotuV2IsoCommTests, ChannelFieldsMaskTo6Bits) { + const auto state = DecodeIsoCommState(EncodeIsoCommStart(0u, 0x7fu, 0x41u)); + EXPECT_EQ(state.rxChannel, 0x3fu); + EXPECT_EQ(state.txChannel, 0x01u); +} + +//============================================================================== +// Packet format (motu-stream.c:23-26,201-225) +//============================================================================== + +TEST(MotuV2PacketFormatTests, SetsExcludeFlagsAndSpeed) { + // Optical off in both directions at S400 (speed code 2). + const uint32_t encoded = EncodePacketFormat(0xFFFFFF00u, true, true, 2u); + EXPECT_TRUE(encoded & kTxExcludeDifferedChunks); + EXPECT_TRUE(encoded & kRxExcludeDifferedChunks); + EXPECT_EQ(encoded & kTxSpeedMask, 2u); + // Unrelated high bits preserved. + EXPECT_EQ(encoded & 0xFFFFFF00u & ~(kTxExcludeDifferedChunks | kRxExcludeDifferedChunks), + 0xFFFFFF00u & ~(kTxExcludeDifferedChunks | kRxExcludeDifferedChunks)); +} + +TEST(MotuV2PacketFormatTests, ClearsFlagsWhenAdatActive) { + const uint32_t prior = EncodePacketFormat(0u, true, true, 2u); + const uint32_t encoded = EncodePacketFormat(prior, false, false, 2u); + EXPECT_FALSE(encoded & kTxExcludeDifferedChunks); + EXPECT_FALSE(encoded & kRxExcludeDifferedChunks); +} + +//============================================================================== +// Async message registration (motu-transaction.c:74-121) +//============================================================================== + +TEST(MotuV2AsyncTests, EncodesNodeAndAddressSplit) { + const auto values = EncodeAsyncAddr(0xffc0u, 0xffffe0000020ull); + EXPECT_EQ(values.hi, (0xffc0u << 16) | 0x0000ffffu); + EXPECT_EQ(values.lo, 0xe0000020u); +} + +TEST(MotuV2AsyncTests, MessageRegionMatchesReference) { + EXPECT_EQ(kAsyncMessageRegionStart, 0xffffe0000000ull); + EXPECT_EQ(kAsyncMessageRegionEnd, 0xffffe000ffffull); +} diff --git a/tests/devices/AudioDuplexCoordinatorTests.cpp b/tests/devices/AudioDuplexCoordinatorTests.cpp index 46e92d632..939593c83 100644 --- a/tests/devices/AudioDuplexCoordinatorTests.cpp +++ b/tests/devices/AudioDuplexCoordinatorTests.cpp @@ -1,3 +1,4 @@ +// Modified in 2026 by Rafal Zalech to add original MOTU UltraLite support. #include #include "Async/Interfaces/IFireWireBus.hpp" @@ -9,6 +10,7 @@ #include "Audio/Protocols/DeviceProtocolFactory.hpp" #include "Audio/Protocols/IDeviceProtocol.hpp" #include "Bus/IRM/IRMClient.hpp" +#include "DeviceProfiles/Audio/AudioDeviceIds.hpp" #include "Discovery/DeviceRegistry.hpp" #include "Hardware/HardwareInterface.hpp" #include "Testing/HostDriverKitStubs.hpp" @@ -67,11 +69,14 @@ using ASFW::FW::NodeId; using ASFW::IRM::IRMClient; constexpr uint64_t kTestGuid = 0x00130E0402004713ULL; +constexpr uint64_t kMotuTestGuid = 0x0001F20000085F25ULL; constexpr uint32_t kQueueBytes = 4096; constexpr uint32_t kFocusriteVendorId = ASFW::Audio::DeviceProtocolFactory::kFocusriteVendorId; constexpr uint32_t kSPro24DspModelId = ASFW::Audio::DeviceProtocolFactory::kSPro24DspModelId; constexpr uint32_t kApogeeVendorId = ASFW::Audio::DeviceProtocolFactory::kApogeeVendorId; constexpr uint32_t kApogeeDuetModelId = ASFW::Audio::DeviceProtocolFactory::kApogeeDuetModelId; +constexpr uint32_t kMotuVendorId = ASFW::DeviceProfiles::Audio::kMotuVendorId; +constexpr uint32_t kMotuUltraLiteModelId = ASFW::DeviceProfiles::Audio::kMotuUltraliteSwVersion; constexpr AudioClockConfig kSupportedClock{ .sampleRateHz = 48000U, }; @@ -796,6 +801,62 @@ TEST_F(AudioDuplexCoordinatorTests, EXPECT_EQ(protocol_->stopCalls, 0); } +TEST_F(AudioDuplexCoordinatorTests, + MotuUltraLiteUsesFixedChannelsWhenDirectBusHasNoIrm) { + registry_.Clear(); + auto rom = MakeConfigRom(kMotuTestGuid, kMotuVendorId, kMotuUltraLiteModelId); + rom.nodeId = 0; + (void)registry_.UpsertFromROM(rom, LinkPolicy{}); + hardware_.SetTestRegister(Register32::kNodeID, 1); + runtime_.Insert(kMotuTestGuid, protocol_); + const auto record = registry_.SnapshotByGuid(kMotuTestGuid); + ASSERT_TRUE(record.has_value()); + ASSERT_EQ(record->vendorId, kMotuVendorId); + ASSERT_EQ(record->modelId, kMotuUltraLiteModelId); + hostTransport_.reservePlaybackStatus = kIOReturnNoDevice; + hostTransport_.reserveCaptureStatus = kIOReturnNoDevice; + + ASSERT_EQ(coordinator_.StartStreaming(kMotuTestGuid), kIOReturnSuccess); + + EXPECT_EQ(hostTransport_.reservePlaybackCalls, 1); + EXPECT_EQ(hostTransport_.reserveCaptureCalls, 1); + EXPECT_EQ(protocol_->LastChannels().hostToDeviceIsoChannel, 0U); + EXPECT_EQ(protocol_->LastChannels().deviceToHostIsoChannel, 1U); + EXPECT_EQ(hostTransport_.lastTransmitChannel, 0U); + EXPECT_EQ(hostTransport_.lastReceiveChannel, 1U); + EXPECT_NE(hardware_.ReadLinkControl() & ASFW::Driver::LinkControlBits::kCycleMaster, 0U); +} + +TEST_F(AudioDuplexCoordinatorTests, + MotuUltraLiteDoesNotUseNoIrmFallbackUnlessBusIsDirect) { + registry_.Clear(); + (void)registry_.UpsertFromROM( + MakeConfigRom(kMotuTestGuid, kMotuVendorId, kMotuUltraLiteModelId), LinkPolicy{}); + runtime_.Insert(kMotuTestGuid, protocol_); + hostTransport_.reservePlaybackStatus = kIOReturnNoDevice; + hostTransport_.reserveCaptureStatus = kIOReturnNoDevice; + + EXPECT_EQ(coordinator_.StartStreaming(kMotuTestGuid), kIOReturnNoDevice); + EXPECT_EQ(hostTransport_.reservePlaybackCalls, 1); + EXPECT_EQ(hostTransport_.reserveCaptureCalls, 0); + EXPECT_EQ(hardware_.ReadLinkControl() & ASFW::Driver::LinkControlBits::kCycleMaster, 0U); +} + +TEST_F(AudioDuplexCoordinatorTests, + MotuUltraLiteDoesNotBypassAnIrmBandwidthFailure) { + registry_.Clear(); + (void)registry_.UpsertFromROM( + MakeConfigRom(kMotuTestGuid, kMotuVendorId, kMotuUltraLiteModelId), LinkPolicy{}); + runtime_.Insert(kMotuTestGuid, protocol_); + hostTransport_.reservePlaybackStatus = kIOReturnNoResources; + + EXPECT_EQ(coordinator_.StartStreaming(kMotuTestGuid), kIOReturnNoResources); + EXPECT_EQ(hostTransport_.reservePlaybackCalls, 1); + EXPECT_EQ(hostTransport_.reserveCaptureCalls, 0); + EXPECT_EQ(hostTransport_.prepareReceiveCalls, 0); + EXPECT_EQ(hostTransport_.prepareTransmitCalls, 0); +} + TEST_F(AudioDuplexCoordinatorTests, ApogeeDuetStartForces48kBeforeDevicePreparationEvenAfterPersistedRate) { (void)registry_.UpsertFromROM(