Conversation
Forward-ports the MOTU v2 work from Dreambrother7/ASFireWire @ feature/motu-wire-codec (09b4441, 2026-07-26) onto main. The fork branched before the de-DICE and route-token refactors and is 84 commits behind, so this is a port rather than a rebase: the content layer is carried over unchanged and only the transport seam is adapted. Carried over unchanged (wire/content layer, header-only): Audio/Wire/MOTU/{MotuBlockCodec,MotuBlockLayout,MotuSph}.hpp DeviceProfiles/Audio/Vendors/MotuAudioProfiles.hpp Adapted to main's route-token model (the only substantive changes): - MotuV2Protocol now takes (DeviceRegistry&, DeviceRouteToken) instead of a bare nodeId; ProtocolRegisterIO's ctor gained the registry/route pair so it can validate route currency on every transaction. - UpdateRuntimeContext(uint16_t) -> UpdateRuntimeContext(const DeviceRouteToken&), using ProtocolRegisterIO::UpdateRoute(). SetNodeId() no longer exists. - DeviceProtocolFactory::Create() gained a defaulted UnitIdentity parameter appended to main's current signature; MOTU is constructed from routeRegistry/route with nodeId taken from route.nodeId. Identity plumbing (MOTU publishes model_id 0 in the root directory, so the model lives in Unit_Sw_Version): - DeviceProfileQuery carries unitSpecId/unitSwVersion. - AudioRuntimeRegistry forwards DeviceRecord's unit identity into Create(). - DeviceRegistry populates it during identity population. Tests: MotuV2ProtocolTests gains a registry-backed RouteState fixture mirroring tests/devices/DICETcatProtocolTests, and the target links DeviceRegistry.cpp since the protocol now needs a live route. Verified: dext Release build clean (0 errors); 51 MOTU tests across 13 suites pass; full host suite 1639/1641. The two failures (FCPTransportTests.RejectsResponseForInvalidatedRouteAfterRebind and RejectsWriteCompletionFromInvalidatedRoute, both SEGFAULT) reproduce on unmodified origin/main with DeviceRegistry.cpp reverted, so they are pre-existing and unrelated to this port. They are not recorded in BUGLIST.md. Streaming remains unimplemented for every MOTU model: MotuV2Protocol overrides none of the duplex hooks, so they fall through to IDeviceProtocol's kIOReturnUnsupported defaults. This commit restores register-level control (clock read, sample-rate set, async notification address) on current main; a MotuAudioBackend is still required for audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1 of MOTU audio: the device-side register choreography. MotuV2Protocol
previously overrode none of IDeviceProtocol's duplex hooks, so they all fell
through to the base class's kIOReturnUnsupported. It now drives the two
registers that bring the device's streams up.
Sequence follows Linux snd_motu_stream_start_duplex (motu-stream.c:376-401):
packet format first, then iso-comm.
PrepareDuplex48k
Latches the host-assigned iso channels, then reads the optical config
(0x0c04) BEFORE computing the packet format (0x0b10). The
exclude-differed-chunks bit per direction is only correct when that
direction carries just its fixed chunk count, and ADAT adds chunks on top
of the baseline (motu-protocol-v2.c:253-269) -- so optical mode has to be
known first. Capture (TX, device->host) follows the optical input,
playback (RX) the optical output. Link speed is ORed in from
busInfo.GetSpeed(); FW::Speed's enumerators are the IEEE 1394-1995 wire
codes, matching what Linux takes from max_speed (motu-stream.c:218).
A reserved optical encoding clears both bits rather than assuming the
fixed layout: claiming fixed when it is not truncates the stream, while
the conservative direction only forgoes an optimisation.
ProgramRxForDuplex48k
Successful no-op. v2 has no RX-only device step; both directions are
activated together by the single iso-comm write (motu-stream.c:62-83,
begin_session). Kept so the coordinator's RX-then-TX ordering still holds
for protocols that need it.
ProgramTxAndEnableDuplex48k
RMWs iso-comm (0x0b00) via EncodeIsoCommStart with both channels. Returns
kIOReturnNotReady if Prepare has not run -- without the latched channels
it would write 0/0 and point the device at unrelated iso channels.
ConfirmDuplex48kStart
Reads iso-comm back and requires both directions activated on the
channels we asked for. A completed write only proves the transaction was
accepted, not that the device honoured it.
StopDuplex
EncodeIsoCommStop, leaving the channel fields intact. Synchronous hook
over an async transport, so it dispatches and reports success -- the same
fire-and-forget contract Shutdown() uses for the async address.
Adds a ModifyRegister() read-modify-write helper (every duplex register has
reserved bits that must survive) and keeps a FireWireBusInfo& for speed
resolution, since ProtocolRegisterIO::ResolveSpeed is private.
Tests: 12 new MotuV2DuplexTests pinning the exact register traffic, including
the ADAT and reserved-optical cases, enable-before-prepare rejection, and
confirm-mismatch rejection. RecordingBus gains a per-address read map so a
sequence touching several registers can give each a distinct value.
Verified: dext Release build clean (0 errors); 37 MotuV2 tests pass; full host
suite 1651/1653, the two failures being the pre-existing FCPTransportTests
SEGFAULTs that reproduce unmodified on origin/main.
NOT audio yet. These hooks start the device's streams, but MOTU is
duplex-always and recovers its media clock from the host replaying the device's
own cadence -- both the data-blocks-per-packet sequence and the per-block SPH
presentation times (motu-stream.c:205-207). Wiring that to RxSequenceReplay,
and a MotuAudioBackend implementing IAudioBackend, is Stage 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DICE hooks
The duplex hooks added in a28e346f were implemented against
IDeviceProtocol::PrepareDuplex48k / ProgramRxForDuplex48k /
ProgramTxAndEnableDuplex48k / ConfirmDuplex48kStart. Those are DICE-internal:
the only implementations and callers are in DICEDuplexBringupController, which
invokes them on itself. Nothing dispatches through them generically, so the MOTU
implementations were correct, tested, and unreachable.
AudioDuplexCoordinator drives every protocol through
IDeviceProtocol::AsDuplexDeviceControl():
AudioDuplexCoordinator.cpp:1739 protocol->AsDuplexDeviceControl()
:1018 PrepareDuplex :1110 SetAssignedChannels :1265 ProgramRx
:1310 ProgramTxAndEnableDuplex :1390 ConfirmDuplexStart
:1443 ReadDuplexHealth :1568 StopDuplex
MotuV2Protocol did not override AsDuplexDeviceControl(), so it returned nullptr
and the coordinator could never have driven it -- a failure that would only have
shown up as silence on hardware.
MotuV2Protocol now also implements IDuplexDeviceControl and returns `this`. The
register sequences are unchanged in substance; what changed is the interface they
hang from and the result structs the coordinator consumes:
PrepareDuplex Validates the requested rate against the v2 chunk table
(mode 2 has no layout for these models) before touching the
device, resolves chunk geometry from the optical config,
and returns DuplexPrepareResult with populated runtimeCaps.
SetAssignedChannels New. IRM allocation can replace the provisional iso
channels after prepare; the committed values must reach the
device before ProgramTxAndEnableDuplex writes them into the
iso-comm register, or host and device use different
channels.
ProgramRx Successful no-op stage: v2 activates both directions in the
single iso-comm write (motu-stream.c:62-83).
ConfirmDuplexStart Reads iso-comm back; both directions must be activated on
the channels we asked for.
ApplyClockConfig New, over the existing rate read-modify-write.
ReadDuplexHealth New. v2 publishes no notification mailbox or lock register,
so the clock status word is the only evidence. Reports
locked only when rate and source both decode -- an
unreadable register stays unlocked so a needed recovery is
never suppressed on missing evidence.
GetIRMClient New; the factory now threads the IRM client through.
runtimeCaps reports MOTU's PCM chunk counts per direction (fixed baseline plus
ADAT extras where the optical interface adds them). The AM824 slot counts stay
zero: MOTU data blocks are 3-byte chunks past an SPH quadlet, not AM824 slots,
and nothing should mistake this for an AM824 stream.
Tests: 7 added (44 MotuV2 total), including ExposesItselfAsDuplexDeviceControl --
the one assertion that would have caught the original mistake, now guarding the
whole family -- plus chunk-geometry reporting, ADAT asymmetry, unsupported-rate
rejection without device traffic, health lock/unlock, and SetAssignedChannels
overriding the provisional channels.
Verified: full host suite 1660/1660; dext Release build clean, x86_64 arm64e.
Still not audio: the coordinator can now drive MOTU's device-side bring-up, but
the host payload path remains AM824/AMDTP. MOTU needs its own payload writer --
its 3-byte chunks at byte offset 10 behind a per-block SPH quadlet cannot be
expressed as a PcmSlotEncoding variant, which assumes one uint32 slot per
channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MOTU cannot reuse the AMDTP payload path. PcmSlotCodec's interleaved helper
writes uint32 slots (`uint32_t* destinationSlots`, one 32-bit slot per channel),
while a MOTU data block carries 3-byte PCM chunks starting at byte offset 10,
behind a 32-bit SPH quadlet and two message chunks:
AMDTP/AM824 MOTU v2
----------- -------
4-byte quadlet slot 3-byte chunk
slot index * 4 kPcmByteOffset (10) + chunk index * 3
(no per-block head) SPH quadlet at block offset 0
The chunks are neither quadlet-sized nor quadlet-aligned, so this is a
structural difference rather than a PcmSlotEncoding variant. Layout
cross-validated with Linux amdtp-motu.c:93-187.
MotuPayloadWriter mirrors AmdtpPayloadWriter and shares the format-neutral half
of its machinery -- the packet timeline, slot snapshots, frame-to-packet mapping
and retired-slot accounting -- diverging only in intra-block addressing.
Deliberate boundaries:
SPH is not written here. It carries presentation time replayed from the
device's own capture stream, so it belongs with the packetizer that owns
per-packet timing (MotuSph.hpp has the arithmetic, RxSequenceReplay.hpp the
source). A test asserts block bytes 0..9 survive untouched, so a later reach
into the SPH or MIDI slots from this writer fails loudly.
Chunks beyond the host buffer encode silence. MOTU's fixed chunk count always
covers every physical port, including ones CoreAudio is not driving, and those
must carry zeros rather than stale bytes.
A block too short for the configured chunk count is refused, not truncated:
the frame is counted in framesTruncated and nothing is written. Writing anyway
would run past the block and corrupt the *next* block's SPH, which would
present as a timing fault rather than the layout fault it is.
Counters keep "no packet owns this frame yet" (normal lead) separate from "the
slot was retired or re-used under us", since only the second indicates a stall.
Tests: 7, pinning chunk offsets, the 24-significant-MSB sample convention, the
sourceChannelOffset split used by multi-stream devices, silence for undriven
ports, the short-block refusal, and the unconfigured/unbound no-ops.
Verified: full host suite 1667/1667; dext Release build clean, x86_64 arm64e.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other 61883-6 family this driver speaks carries presentation time in the CIP header's SYT field, which DirectAudioReceiveConsumer converts into RxSequenceEntry::sytOffset via ComputeReplaySytOffset(). MOTU does not: it sets the CIP SPH bit and puts a 32-bit source packet header at the head of every data block instead (amdtp-motu.c:19-25,303-393). The replay contract needs no change for this. RxSequenceEntry::sytOffset is a presentation offset relative to the packet's arrival, whatever produced it, so MOTU populates the same field from a different source and the existing TX replay path (ASFWAudioDriverZts.cpp) consumes it unmodified -- no new field, no new plumbing. MotuRxTiming is the pure arithmetic for that conversion: no I/O, no state, so the wire truth is pinnable by host tests. Rebasing for transmit already lives in MotuSph.hpp (ReplaySph). Two behaviours worth their tests: One-second wrap. MOTU's SPH timeline is one second wide. A packet arriving in the last cycle whose presentation lands just past the boundary must decode as two cycles forward, not almost a second backward; a naive subtraction underflows into an offset that would present as a catastrophic timing fault. The cycle timer's seconds field is deliberately dropped when computing the base tick, precisely because the SPH domain wraps each second. A test asserts the same cycle/offset in different seconds maps to the same tick, so the field is not "restored" later by someone reading it as a bug. A payload too short to hold a whole first data block is rejected rather than read past: a truncated packet must not yield a plausible-looking offset from whatever bytes follow. Only the first block is consulted -- the replay cache stores one presentation offset per packet and the remaining blocks are evenly spaced by construction. Tests: 8, covering the wrap, the seconds-independence, zero offset, raw SPH retention for diagnostics, short-payload and zero-dbs rejection, and that a poisoned second block cannot influence the result. Verified: full host suite 1675/1675; dext Release build clean. Not yet wired: teaching DirectAudioReceiveConsumer to accept MOTU packets is a separate integration with the RX decode path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the pure-arithmetic half of MOTU's timing chain. MOTU is duplex-always
and recovers its media clock from the host replaying the device's own timing,
needing both the data-blocks-per-packet sequence and the per-data-block source
packet header as presentation time (motu-stream.c:205-207). MotuRxTiming captures
the offsets; this stamps them back out.
Algorithm follows Linux write_sph() (amdtp-motu.c:373-393):
base_tick = rx_cycle_count * TICKS_PER_CYCLE
per data block: tick = (base_tick + event_offsets[head]) % TICKS_PER_SECOND
head advances per block
rx_cycle_count advances one cycle per packet
GRANULARITY MISMATCH -- the reason this takes offsets explicitly.
Linux caches one presentation offset per *data block*; ASFW's RxSequenceReplay
caches one sytOffset per *packet* (DirectAudioReceiveConsumer publishes a single
RxSequenceEntry per received packet). At 48 kHz a MOTU stream carries 8 data
blocks per packet, so the existing cache is 8x coarser than this device's replay
expects.
That is deliberately not smoothed over by interpolating offsets within a packet.
Evenly spacing blocks the device timed unevenly is a different stream, and it
would fail as intermittent clock drift rather than an obvious fault --
CLAUDE.md's bar is "behaves like the reference stack", and "cleaner than the
reference" is untested behaviour. WritePacketSph therefore takes the per-block
sequence as a parameter: the arithmetic is correct and testable now, and the
question of where per-block offsets come from stays with the caller, documented
at the point of use.
Two candidate answers for that, when the integration happens:
A. A MOTU-specific per-block capture cache mirroring Linux's event_offsets[]
ring. Blast radius confined to MOTU.
B. Extend RxSequenceReplay to per-block granularity. Shared, but touches the
path DICE and Apogee depend on, which carries real scar tissue (the reclamp
and self-heal logic in ASFWAudioDriverZts.cpp).
A looks right: B risks a working family for one that does not stream yet.
An under-supplied offset sequence or a short payload stops early and reports
blocksStamped rather than inventing timing -- an unstamped block would otherwise
go out carrying a stale SPH.
Tests: 7, including per-block offset fidelity (blocks are NOT evenly spaced), the
one-second wrap, cycle-count advance across the second boundary, short-sequence
and short-payload behaviour, and a receive-to-transmit round trip that replays a
captured offset at a different cycle -- the replay contract in one assertion.
Verified: full host suite 1682/1682; dext Release build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the granularity mismatch documented in 74229ae7. MOTU needs the source packet header of every data block replayed, not one presentation time per packet (motu-stream.c:205-207), but RxSequenceReplay caches one sytOffset per packet -- 8x too coarse at 48 kHz. Rather than widening the shared cache, MOTU keeps its own ring. The shared path carries the TX reclamp and self-heal logic every currently-working family depends on (DICE, Apogee), including the fix for the all-zero-payload Duet regression; changing its shape to serve a family that does not stream yet risks the ones that do. This confines the blast radius to MOTU. Mirrors the amdtp_motu_cache pair (motu.h:41-48): capture <- cache_event_offsets() (amdtp-motu.c:303-329) playback -> write_sph() (amdtp-motu.c:373-393) Both walk one ring slot per data block and advance their own whole-cycle counter once per packet, so a packet's blocks are rebased together. Behaviour worth its tests: Take() is all-or-nothing. A partial fill leaves later blocks carrying whatever occupied that memory before, which reaches the device as a stale SPH and presents as clock drift. Refusing outright makes the shortfall visible to the caller instead. A run older than the ring is refused rather than served from overwritten history -- the same distinction RxSequenceReplayState draws with kHistoryOverwritten. A truncated packet contributes only the blocks it actually holds; the rest are not invented. Cursors are monotonic 64-bit counts folded to an index, so "how far behind is playback" stays answerable without wrap ambiguity. Counters are named from the host's perspective (Capture = device->host, Playback = host->device) rather than Linux's device-relative tx_/rx_cycle_count, matching every other cursor in this driver. The divergence is called out at the top of the header so it is not "corrected" back later. Capacity is fixed at 4096 (512 packets x 8 blocks at 48 kHz), the same history depth RxSequenceReplayState keeps, expressed per block. Linux sizes its ring dynamically from the ALSA period; a dext cannot, so this trades memory (16 KB) for a static allocation. Tests: 10, including an end-to-end capture-then-replay that stamps four unevenly spaced device offsets onto a transmit packet at a different cycle -- two of them one tick apart, which is precisely what interpolating within a packet would have destroyed. Verified: full host suite 1692/1692; dext Release build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The capture counterpart to MotuPayloadWriter, and MOTU's equivalent of DirectRxPacketDecoder::DecodeDirectRxFrame(). That decoder takes `const uint32_t*` because AM824 and raw-24-in-32 both put one sample in one quadlet slot; MOTU's samples are 3-byte chunks at byte offset 10, so they are read from a byte span. Same structural mismatch the transmit side had, same resolution. Normalisation deliberately matches DirectRxPacketDecoder's Signed24ToFloat32, including the clamp at the 24-bit minimum (which has no positive counterpart), so MOTU and the AMDTP families hand CoreAudio identically scaled audio rather than a family-dependent gain difference. Safety boundaries, each with a test: Chunks past the device's chunk count decode as silence rather than reading neighbouring memory. A block truncated mid-chunk decodes that chunk as silence rather than as whatever bytes follow the buffer. BlockAt() returns an empty span for an out-of-range index instead of a wild pointer, and DataBlocksInPayload() ignores a trailing partial block. Tests: 9, including a writer/reader round trip that pins the two halves to one scale -- if either side's convention drifts, capture and playback would diverge in gain and that test fails first. Verified: full host suite 1701/1701; dext Release build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First change in the MOTU effort that edits code DICE and Apogee execute at
runtime; everything before this was new files alongside. Kept additive and
branch-guarded so the existing families take byte-identical paths.
The blocking incompatibility was the geometry rule:
if (channels == 0 || cip->dataBlockSize < channels ||
am824Slots != cip->dataBlockSize)
return kGeometryMismatch;
That is correct for quadlet-slot families (one sample per 4-byte slot, so dbs
covers the channel count). MOTU packs 3-byte chunks from byte offset 10 behind an
SPH quadlet, so dbs is SMALLER than the channel count for anything wider than
about four channels -- a 14-chunk stream has dbs 13. The rule therefore rejected
every MOTU packet by construction.
MOTU now validates against its own block geometry instead: the chunk count must
be non-zero, the requested slice (channelOffset + channels) must fit inside it,
and the block must be large enough to hold kPcmByteOffset + chunks * 3 bytes.
Decoding then hands the block to DecodeMotuBlock as a byte span rather than a
quadlet pointer.
The chunk count arrives as a new trailing defaulted parameter rather than being
folded into am824Slots. MOTU reports zero AM824 slots on purpose (its samples are
not AM824 slots), so overloading that field would have made the two meanings
indistinguishable at every call site. AudioStreamRuntimeCaps gains matching
deviceToHostPcmChunks / hostToDevicePcmChunks for the same reason.
kMotuV2 added to both AudioWireFormat enums -- ASFW::Encoding's and
AudioGraphBinding's -- which are deliberately separate (the latter is part of the
shared control block's ABI and carries kUnknown). The mirroring requirement is
now noted in both.
Tests: 6 added to RxAudioPacketProcessorTests, alongside the existing 7 so both
paths are covered in one file. They pin the dbs-below-channel-count acceptance
that motivated the branch, actual chunk decoding into host frames, rejection of a
chunk count the block cannot hold (which would otherwise read into the next
block), zero chunks, a slice running past the chunk count, and -- guarding the
shared file -- that the AM824 path still accepts a good packet and still rejects
a slot-count disagreement exactly as before.
Verified: full host suite 1707/1707; dext Release build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the integration. Every piece built in isolation over the previous
commits is now connected, and the UltraLite is audio-enabled.
Receive path
DirectAudioReceiveConsumer gains a motuPcmChunks configuration field and
forwards it to the processor, and captures per-data-block SPH offsets into a
MotuEventOffsetCache it owns. MOTU deliberately leaves the packet-granular
sytOffset unset: one offset per packet cannot represent what this device timed
per block, and a half-truth there would surface as clock drift rather than an
obvious fault. The cache is exposed for the transmit side to drain.
Note the received payload begins with the 8-byte isoch header before the CIP
quadlets, so MOTU's first data block starts 16 bytes in -- not the 8 the pure
wire helpers default to, which count from the CIP header. Passed explicitly.
Transmit path
DiceTxStreamEngine selects MotuPayloadWriter when the policy asks for kMotuV2
and skips the AMDTP writer entirely (running both would overwrite the block
with slot-shaped samples). SPH is stamped between packetize and publish: an
unstamped block reaches the device carrying whatever the slot held before.
Take() being all-or-nothing means a cache that has not caught up leaves the
packet unstamped rather than half-timed, while the playback cycle counter still
advances so capture and playback stay in lockstep.
MotuAudioBackend
Much smaller than DiceAudioBackend, because most of that backend is DICE
machinery MOTU has no equivalent for: a notification mailbox, the
CLOCK_ACCEPTED handshake, clock-lock probing and the recovery state machine
those notifications drive. MOTU v2 publishes no notification register; its
clock status word is the only health evidence, already exposed through
ReadDuplexHealth().
The nub is built from the device's own runtime caps rather than a profile
registry -- MOTU publishes model_id 0, so there is nothing to key a table on.
If caps are not yet available the publish is deferred rather than emitting a
zero-channel nub, which CoreAudio would surface as a broken device the user has
to remove by hand.
UltraLite enablement
The profile gate now admits kMotuUltraliteSwVersion alongside the 828mk2: both
are {14,14,0} with 2nd-quadlet MIDI (snd_motu_spec_ultralite vs
snd_motu_spec_828mk2). The difference is the fetching-mode write -- the 828mk2
(Altera ACEX 1K) and 896HD skip it, while the UltraLite and 8pre implement a
Xilinx Spartan XC3S200 and need it (motu-protocol-v2.c:190-225).
EncodeFetchingMode implements the Spartan variant, which additionally sets the
model-specific bit only when slaved to source packet headers above 48 kHz; at
44.1/48k, or on an internal clock at any rate, it reduces to a plain
fetch-enable write.
896HD, Traveler and 8pre stay named-but-disabled: their layouts are unverified
and the 8pre has a second optical interface with a different ADAT chunk rule.
Tests: 121 MOTU tests total. New coverage pins the UltraLite gate, that the
unverified siblings remain disabled, that the UltraLite writes fetch-enable
without the model-specific bit at 48k internal, and that the 828mk2 does not
write the clock register at all during prepare.
Verified: full host suite 1711/1711; dext Release build clean, x86_64 arm64e,
31 MOTU symbols linked.
Not yet proven: nothing here has met real hardware. The device-side register
choreography, the SPH replay and the payload layout are each validated against
the Linux reference and unit-tested, but the first UltraLite connection is what
will say whether they are right together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend added in 004a32e was never constructed. AudioCoordinator owns `DiceAudioBackend dice_` and `AVCAudioBackend avc_`; nothing referenced MotuAudioBackend outside its own two files, so no nub was ever published and no CoreAudio device appeared -- which is exactly what the hardware showed: the UltraLite enumerated and was correctly named in Device Discovery, but Sound listed nothing. Two separate faults in BackendForGuid, both fatal on their own: LookupIntegrationMode(record->vendorId, record->modelId) MOTU publishes model_id 0, so this could never resolve and every MOTU device fell through to the AV/C backend, which cannot drive it. The lookup now passes the unit identity (Unit_Spec_Id + Unit_Sw_Version), which is the only discriminator MOTU offers. Even resolved, kHardcodedNub routed unconditionally to the DICE backend. MOTU now routes to its own, matched on the vendor OUI appearing in both the vendor and specifier fields. Wire-format plumbing, the second half of the same gap: DuplexStreamProfile now recognises MOTU and sets kMotuV2 for both directions, carrying per-direction PCM chunk counts taken from the device's own runtime caps rather than a profile table (there is nothing to key a table on). Those counts thread through PrepareReceive/PrepareReceiveStream into the receive consumer's configuration, so the decode branch added in fd3ea6c1 finally receives the geometry it needs -- without it the chunk count is zero and every packet is rejected as a geometry mismatch. Verified: full host suite 1711/1711; dext Release build clean with MotuAudioBackend linked (MOTU symbol count 31 -> 41). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…deadlock Two faults, both preventing the device from ever appearing in CoreAudio. The hardware showed the symptom precisely: the UltraLite enumerated and was named correctly in Device Discovery, and Sound listed nothing. 1. Nothing invoked the backend. AudioCoordinator::OnDeviceAdded/OnDeviceResumed called dice_.OnDeviceRecordUpdated(guid) and nothing else, so MOTU had no publish path at all. MotuAudioBackend gains the same hook and the coordinator now calls it. 2. The publish condition could never be satisfied. EnsureNubForGuid deferred when live runtime caps were unavailable -- reasonable in isolation, since a zero-channel nub is worse than none. But caps only exist after PrepareDuplex, PrepareDuplex only runs during streaming, CoreAudio only streams to a device it can see, and it can only see a published nub. The three conditions form a cycle with no entry point, so the nub could never be published no matter how many times discovery fired. The fix publishes with the model's known chunk geometry when live caps are absent: the v2 fixed-chunk models carry 14 PCM chunks per direction at 44.1/48 kHz (motu-protocol-v2.c:274-282). Live caps still win once streaming has run and the device has answered for itself. The fallback is not an optimisation -- it is the only way to break the cycle. Verified: full host suite 1711/1711. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nub published and CoreAudio showed a FireWire device -- named "Generic DICE"
with 14x14 channels. The channel counts were MOTU's (the DICE generic profile
advertises different ones), so the MOTU backend had published correctly; only the
name was wrong, and it came from a different layer.
ASFWAudioDriverGraph resolves an audio profile by (vendorId, modelId) and then
unconditionally copied that profile's name over the nub's:
strlcpy(parsedConfig.deviceName, profile->Name(), ...);
MOTU publishes model_id 0, so the lookup lands on the generic DICE profile and a
MOTU UltraLite was renamed accordingly. The nub's name comes from the protocol
that actually claimed the device, so it is the more specific answer; the profile
name is now only used when the nub supplied none, and a disagreement is logged
rather than silently resolved.
MotuV2Protocol also never overrode GetRuntimeAudioStreamCaps, so every caller got
the base default (false) and fell back to guessed geometry. It now answers, and
answers before PrepareDuplex has run -- from the model's fixed chunk table
(motu-protocol-v2.c:274-282), preferring the rate the device last reported over
assuming 48k. Failing before preparation would recreate the publish deadlock
fixed in 2ff6a6b7, since the nub has to exist before streaming can ever happen.
MakeRuntimeCaps now also fills the per-direction chunk counts, which the receive
path needs to validate MOTU block geometry.
Confirmed working from the device's own log:
[Audio] Creating MotuV2Protocol vendor=0x0001f2 version=0x00000d node=0x0000
[Audio] AudioRuntimeRegistry: protocol created: UltraLite for GUID=0x0001f20000083100
[Audio] MotuV2Protocol: clock status raw=0x00000000 rate=44100Hz source=0
The UltraLite answers register reads and reports 44.1 kHz on an internal clock.
Verified: full host suite 1711/1711.
Audio still hangs on playback -- that is the streaming path, not naming, and is
diagnosed separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stream
CoreAudio was reaching the device and being refused:
HALS_IOEngine2::_StartIO: _TellHardwareToStart returned error: 0x77686174
HALS_IOContext_Legacy_Impl::IOWorkLoopDeinit: stopping with error 2003329396
StartIOThread: the IO thread failed to start
0x77686174 is 'what' -- kAudioHardwareUnspecifiedError. Streaming was attempted
and rejected immediately, which is what YouTube surfaced as "Audio renderer
error".
ASFWAudioDevice::StartIO resolves a driver-side audio profile and calls
BuildDefaultTxStreamConfig on it to size the isochronous resources. That registry
(ASFW::Isoch::Audio::AudioProfileRegistry, distinct from the DeviceProfiles
identity table) matches Apogee and Phase88 by vendor/model, then the DICE
registry, then falls back to the generic DICE profile. MOTU matched nothing and
took the fallback -- so StartIO sized an AM824 stream for a device whose data
blocks are 3-byte chunks behind an SPH quadlet, and failed.
MotuV2Profile supplies the real geometry:
pcmChannels 14, the v2 fixed-chunk count at 44.1/48 kHz
(motu-protocol-v2.c:274-282)
dbs 13 quadlets -- SPH quadlet plus message and PCM chunks,
quadlet-padded. This is SMALLER than the channel count, which
is exactly the AM824 assumption that does not hold here.
fmt 0x02 / fdf 0x22 with the CIP SPH bit, not AM824's 0x10 / 0x02
(amdtp-motu.c:19-25)
midiSlots 0 MIDI rides the data block's message slot, not a dedicated
AM824 conformant block (motu-stream.c:117-131)
TxStreamPolicy hostToDevicePcmEncoding = kMotuV2, which selects
MotuPayloadWriter in DiceTxStreamEngine; and
initializeNonAudioSlots = false, since the AM824 labelled-slot
word has no meaning in a chunk layout.
Sample rates are restricted to 44.1/48 kHz. Mode 1 (88.2/96k) halves the ADAT
extras and mode 2 is unsupported by these models; advertising a rate the chunk
table cannot describe would let CoreAudio select a geometry the device never
accepts.
The registry matches MOTU on the vendor OUI alone, before the DICE lookup. It
only receives (vendorId, modelId, guid) and MOTU publishes model_id 0 -- which is
sufficient here, because ASFW only claims a MOTU device after DeviceProfiles has
already matched its unit directory.
Verified: full host suite 1711/1711; dext Release build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With a healthy dext finally logging, StartIO's failure is explicit:
ASFWAudioNub: deferring AV/C stream start until route is rebound
GUID=0x0001f20000083100 node=0
ASFWAudioDevice: StartAudioStreaming failed: 0xe00002d8 (kIOReturnNotReady)
ASFWAudioDevice: StartIO failed at StartAudioStreaming
ASFWAudioDriver: super::StartDevice failed
which is what CoreAudio was reporting as _TellHardwareToStart returning
0x77686174 ('what').
StartAudioStreaming called LookupIntegrationMode with two arguments, leaving
UnitIdentity defaulted to {}. MOTU is the one family (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 -- so the MOTU profile, which
gates on Unit_Sw_Version, never matched and the lookup returned kNone. Not
being kHardcodedNub then subjected the device to the AV/C route-rebind gate,
which protocol v2 can never satisfy: it is register-based and has no FCP
transport, so HasReadyAVCStartRoute() was false on every attempt and
streaming was deferred forever.
AudioCoordinator::BackendForGuid already carries a comment explaining exactly
this trap and passes the unit identity; the nub's start gate was simply missed
when that fix went in. Pass it here too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sample taken while audio was actually playing shows StartIO is not failing --
it is blocked. The whole chain is synchronous DispatchSync from coreaudiod's
StartDevice external method:
ASFWAudioDriver::StartDevice
-> ASFWAudioDevice::StartIO
-> ASFWAudioNub::StartAudioStreaming
-> MotuAudioBackend::StartStreaming
-> DuplexStartTransaction::Run
-> WaitForStableGlobalClock
-> usleep (407 of 816 samples parked here)
WaitForStableGlobalClock requires sourceLocked && nominalRateHz ==
desiredClock.sampleRateHz. The UltraLite reports its clock word as 0x00000000
-- 44.1 kHz, internal -- while the host negotiated 48 kHz, so the condition can
never hold and the wait burns its full 1000 ms budget on every attempt. That
overruns the HAL's StartDevice deadline, so CoreAudio abandons the call with
kIOReturnTimeout (0xe00002d6) and reports _TellHardwareToStart returning
0x77686174 ('what') -- with nothing logged by us, because we never got to
return anything.
The device is never moved to the requested rate: ApplyClockConfig is the only
hook that writes it, and RunDuplexStart does not call it -- it is reached only
from DuplexStartTransaction::ApplyIdleClock. Linux applies the rate in the
reserve stage instead, before caching packet formats and keeping iso resources
(motu-stream.c:143-164, snd_motu_stream_reserve_duplex). Do the same: apply it
at the top of PrepareDuplex, ahead of the optical-config read and the packet
format write.
SetSampleRate already skips a write when the encoded word is unchanged
(MotuV2Protocol.cpp:136), matching Linux's curr_rate != rate guard, so a device
already on the requested rate costs one extra read and no write.
Add PrepareMovesADeviceOffAMismatchedClockRate to pin the case that was broken,
and correct the existing prepare tests for the extra clock read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ZTS attribution counters answer the question directly:
initial hardware ZTS timed out after 2000 ms
rxSeen=18185 data=0 noData=18185 short=0 badCip=0 zeroDbs=0 geometry=0
The device is transmitting -- 18185 packets, matching transmit's 18234 -- and
nothing is rejected: no short packets, no bad CIP headers, no zero DBS, no
geometry mismatch. Every packet is simply classified as CIP NO-DATA, because
that attribution is syt == 0xffff and MOTU sets SYT to 0xFFFF on every packet
by design. Linux marks the family CIP_SYT_HAS_NO_MEANING and takes presentation
time from the per-data-block SPH quadlet instead (amdtp-motu.c:19-25).
The consumer already caches those SPH offsets, but two gates ask RxSytCadence
whether timing is established, and it establishes only by observing valid SYTs:
Observe() is correctly never called for MOTU, so cadence.established stays false
forever. That blocks rxSequenceReplay.MarkEstablished(), the replay-ready
callback, and -- the visible failure -- the clock anchor publish, so
PublishSharedZeroTimestampToHAL never has a generation to mirror and StartIO
times out at WaitForInitialHardwareZts.
Latch establishment from a successful SPH capture for MOTU and keep the SYT
cadence for every other family. Readable SPH offsets are the same evidence for
this device that a locked SYT cadence is for AM824: they are what the transmit
side replays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With receive timing established from SPH, StartIO now completes and CoreAudio
runs IO:
Core audio hardware ZTS ready guid=0x0001f20000083100 sampleFrame=0
ADK DBG StartIO super::StartIO ok
ADK DBG DUPLEX ready rxStarted=1 txStarted=1 bindValid=1 hasIn=1 hasOut=1
ASFWAudioDriver: Device started (transport via StartIO)
Receive stays healthy for the whole session ([Zts] UPD count=26 frame=192000),
but transmit dies 22 ms in:
[TxProducerFatal] stage=replay-syt-validation reason=invalid-replay-syt
IT: Refill failed reason=producer-fault-status
IT FATAL STOP: RUN cleared and interrupt masked
after which every [PayloadWriter] line shows pkt=0 prepared=0/930/930 with an
unbounded deficit: CoreAudio writes into a ring nothing drains, so the player
runs but the device is silent.
The producer faulted on any replayed entry with data blocks but no SYT offset.
That is corruption for an SYT-aware family, but it is the normal case for
MOTU: the capture side correctly never sets kValidSyt for it. Linux declares
the family CIP_UNAWARE_SYT (amdtp-motu.c:440) and treats exactly this entry as
an ordinary DATA packet -- the SYT goes out as CIP_SYT_NO_INFO and data_blocks
is replayed regardless (amdtp-stream.c:1033-1037; the capture cache stores
CIP_SYT_NO_INFO for these at :518-521). The real per-block timing is written
afterwards by StampMotuSph from the capture offset cache.
Four uses of the replayed SYT in this branch needed handling, not just the
validation:
- validation: fault only for SYT-aware families (new IsSytUnaware accessor)
- header SYT: CIP_SYT_NO_INFO when there is no replayed offset
- SYT trace: skipped, since it reconstructs a device SYT that does not exist
- frame-cursor alignment: RxSequenceReplayState::kNoInfo is UINT32_MAX, which
would have added ~4.3e9 ticks to the presentation delta. Contribute no
sub-cycle offset instead; at most one cycle of error, absorbed by the
framesPerDataPacket alignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transmit now streams cleanly -- no producer fault, no IT FATAL, the frame cursor aligns, and the data/no-data split is 36691/14111 (72%, against the 75% that blocking mode at 48 kHz implies) -- yet the UltraLite output nothing but a pop. Three defects on the SPH path, each checked against Linux: 1. The replay cache was never connected. BindMotuOffsetCache had no caller, so the transmit engine's cache pointer stayed null and StampMotuSph returned at its first line for every packet. No data block ever carried an SPH; they held the zeros left by the NO-DATA prefill, i.e. presentation at cycle 0, so the device dropped them. 2. Both counters started at 0. Linux seeds the capture and playback cycle counts from the stream's real start cycles (processing_cycle.tx_start/rx_start, amdtp-motu.c:340-341, :403-404). Ours started at 0 while the device stamps real bus time, so every captured offset was the device's absolute time -- thousands of cycles -- rather than the small in-cycle presentation offset. 3. Both counters skipped empty packets. Capture returned before advancing when a packet held no blocks, despite its own comment saying it advanced regardless; StampMotuSph advanced only for data packets. Linux advances once per packet, empty or not (write_sph is called for every packet, :421), so ours lost two cycles in every eight. Rather than re-seed and re-advance the counters, base each packet on its actual bus cycle, which is what Linux's counters approximate when seeded correctly: capture passes the cycle from the packet's own receive timestamp; transmit derives it from txExecutionTimeline's per-packet anchor, exactly as the SYT trace already derives outCycle, and carries it in AmdtpTimingState. The free-running counters are removed. The cache moves into AudioTransportControlBlock beside its AM824 counterpart rxSequenceReplay. The capture consumer is owned by IsochDuplexHostTransport and destroyed on stop, while the transmit engine belongs to ASFWAudioDriver, a separate service; a pointer from one into the other is the FW-60 cross-service class. The control block is the lifetime-owned seam both map, the producer rebinds on every pass, and the block's RX reset now clears the cache -- which the consumer never did, so offsets could leak from one session into the next. Take() also no longer fails permanently after an overrun. Linux never fails: write_sph reads its head unconditionally, which after an overrun is newer history. Ours returned false forever once playback fell a ring behind -- which one replay reclamp can cause ([TxReplay] reclamped appears in this capture) -- leaving the stream silently unstamped. It now resyncs to the newest complete run. Tests: replace the counter-semantics test with an explicit receive-cycle one, add OffsetIsTheInCyclePresentationOffsetNotTheBusTime to pin defect 2, and turn the overrun test into a resync test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With SPH stamping in place the UltraLite now produces sound, but crackles. The capture shows the stream running at 5/8 speed: STOPIO ... writtenEndFrame=127680 ... txUnderruns=0 127680 frames over 4.08 s is ~31.3 kHz against 48 kHz. [Zts] SEED ... dec=5 and [TxAlign] rxFirstFrame=5 show the first packet decoding 5 frames, and the previous run's anchor updates landed every 7680 frames -- lcm(5, 1536) -- where 8-frame packets would give every 1536. The receive decoder took DBS from the CIP header. The UltraLite reports a wrong DBS there: Linux sets CIP_WRONG_DBS for exactly the 8pre and the UltraLite (amdtp-motu.c:458-463) and divides the payload by the configured data_block_quadlets instead (amdtp-stream.c:1475-1476). Trusting the header turned each 8-block packet into 5 read at the wrong stride, so samples and the SPH timestamps were both read from misaligned offsets, capture ran at 5/8 rate, and transmit replayed 5-block packets to a device expecting 8. Size MOTU blocks from the configured geometry -- DataBlockQuadlets(motuPcmChunks), the value the MOTU profile already advertises. For well-behaved models it equals the header's value, so this applies to every MOTU stream rather than keying on model. The same Linux block sets CIP_DBC_IS_END_EVENT on every MOTU transmit stream (:465): the DBC field carries the count after the packet's blocks, advanced before writing (amdtp-stream.c:1040-1046), where IEC 61883-1 counts the first block. Add AmdtpTxPolicy::dbcIsEndEvent, set it for MOTU in BuildTxPolicy, and apply it in the packetizer. NO-DATA packets carry no blocks, so both conventions write the same value for them; every other family is unchanged. Tests: MotuIgnoresTheWrongDbsInTheUltraLiteHeader reproduces the 5-block split and checks every frame decodes at the configured stride; the old too-small-block rejection becomes MotuNeverReadsPastAPayloadTooShortForOneBlock, since sizing from configured geometry closes that hazard by construction; and a differential packetizer test pins end-event DBC against the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The UltraLite's wire order puts the headphone pair on playback chunks 0-1 and the CueMix return on capture chunks 0-1, so CoreAudio's default stereo pair played to the phones only and recorded the mixer instead of the mics. Add per-model port tables (name + chunk) in Wire/MOTU/MotuPortLayout.hpp, sourced from FFADO's PortGroups_ULTRALITE / PortGroups_828MKII (offsets assigned in array order, motu_avdevice.cpp:1839-1862). Host order puts Main L/R first on output and Mic 1/2 first on input, the same host-side reordering FFADO does with port_order; nothing on the wire changes. - MotuPayloadWriter/DecodeMotuBlock take the map (empty = wire order; channels past the table keep their chunk, so ADAT extras pass through; a table wider than the stream falls back to wire order). - TX gets the map via AudioStreamTxPolicy from MotuV2Profile; RX via DuplexStreamProfile -> coordinator -> host transport -> consumer. - MotuV2Protocol::GetChannelLabels publishes the port names, which MotuAudioBackend puts on the nub for SetElementName. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er used The Sound panel listed it as "UltraLite": the nub took the bare model name, since that is what the model table holds. MOTU's own driver named the device with the vendor in front, so qualify the display name with it. Only the CoreAudio-visible name changes. The model constants stay bare because DeviceIdentityHint keeps vendor and model in separate fields, and a device whose software version is not in the table keeps the protocol's own fallback name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ow the knob The macOS volume keys and Sound settings slider now set the UltraLite's own Main Out level (register 0x0c0c) instead of scaling samples on the host, and the front-panel knob moves the macOS slider. Built the way documentation/AUDIO_BACKENDS_CONTROLS.md lays out (FW-135): - DeviceControl.hpp: ControlKey (kind, class, scope, element) and ControlValue. IDeviceProtocol gains DescribeControl / ReadControl (cached, never waits on the wire) / WriteControl (fire-and-forget). The boolean path is untouched, pending FW-133. - ASFWAudioNub: Describe/Read/WriteProtocolControl. Unlike the boolean bridge they do not require an AV/C transport, so register-based families answer. - ASFWProtocolLevelControl mirrors ASFWProtocolBooleanControl. HandleChange* takes the value optimistically and queues it; scalar changes are converted with the control's own transfer function so the device's echo agrees with the slider. - MotuLevelWriter: one write in flight, latest value wins, 25 ms cooldown, so a slider drag reaches the device as under 40 writes/s and the final value of every burst is always sent. Failed writes are not retried on their own. - Knob -> macOS without polling the bus: register-DSP models report their state in each capture block's message chunk (Linux motu-register-dsp-message-parser.c). The capture consumer lifts MAIN_OUTPUT_PAIRED_VOLUME into the transport control block; a 10 Hz audio-driver timer, running only while IO runs, pushes it with SetDecibelValue. A 400 ms hold-off after host changes keeps our own in-flight write from snapping the slider back. - Range 0..0x80 = -64..0 dB in 0.5 dB steps (ctl-services register_dsp.rs, register_dsp_ctls.rs; FFADO motu_mixerdefs.cpp). The bare value is written, as ctl-services does; FFADO additionally sets bit 24. No mute control: the device has no hardware main mute (ctl-services mute_avail: false), and faking one is the host-side logic the note rules out. Only the Main Out pair is attenuated -- host channels 1-2 under the port map, the Mac's default output -- exactly what the physical knob does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MOTU v2 exposes an output level but no mute (ctl-services reports the register
DSP output volume with mute_avail: false), and macOS drives the mute key only
from a 'mute' control, so the key did nothing.
Publish one, backed by the level: mute writes the minimum level, unmute writes
the level the volume control still holds. Nothing separate is saved -- mute
never changes the control's own value, so the level to come back to cannot
drift out of step with the slider or the knob.
- Runtime/OutputMutePolicy.hpp holds the decisions as pure functions, with no
ADK or DriverKit in them, so they are host-tested and easy to delete if a
real mute register ever turns up.
- Turning the physical knob while muted clears the mute: the device is audible
again, so the control no longer reflects it.
- Moving the slider while muted unmutes, as it does elsewhere in macOS.
- The knob reconciler ignores the device echoing back our own silence.
- Stop() unmutes first: a driver that stopped while muted would otherwise leave
the device at its minimum level with no control left to raise it.
This is host-side behaviour standing in for a hardware parameter, which
documentation/AUDIO_BACKENDS_CONTROLS.md deliberately avoids ("backend =
control"). It is a deliberate deviation, confined to one header and one control
class, because a dead mute key is worse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything played far louder than the control asked for. The volume register is
0..0x80, and that value is linear in AMPLITUDE: dB = 20 * log10(raw / 128).
Reading it as 0.5 dB per step -- which the -6400..0 endpoints invite -- sends a
level far above the one requested:
raw 0x08 meant -60 dB actually -24 dB
raw 0x20 meant -48 dB actually -12 dB
raw 0x28 meant -44 dB actually -10 dB
so the lowest slider step still played at about -24 dB, and the level read back
from the device at startup was reported 34 dB too quiet.
snd-firewire-ctl-services publishes the register as an ALSA DB_LINEAR interval
(runtime/motu/src/register_dsp_ctls.rs:841-846), and DB_LINEAR means "the value
increases linearly, convert with 20 * log10(current / (maximum - minimum))"
(protocols/alsa-ctl-tlv-codec/src/items.rs:123-128). The -6400..0 endpoints are
the dB at the ends of the range, not a per-step scale.
- Convert properly, with exp2/log2 built from the float exponent plus a short
series, since DriverKit ships no libm. Checked against std::log10/std::pow:
worst error 0.00001 dB, and every raw step round-trips.
- The control's range is now -42.14..0 dB, the dB of raw 1 through raw 0x80.
Raw 0 is off, and the floor stands in for -inf, which a level control cannot
express.
- Mute writes a level far below the range (kSilenceDecibels) rather than the
range minimum, which on this register is the quietest audible step, not
silence.
- Log the raw value and dB of every write that reaches the bus. The coalescer
keeps these rare, and a wrong level can now be read straight out of the log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Protocol-backed volume and mute controls, driving the MOTU's own level register
Split out of #114 at your request: the controls subsystem on its own, stacked on the MOTU device PR.
Built to the shape
documentation/AUDIO_BACKENDS_CONTROLS.mdlays out (§5, FW-135), so the pieces should generalise to the Duet rather than being MOTU-only plumbing.Stacked on #114. It is a draft until that lands; the diff here is the controls work alone (32 files, +2056).
The generic half
Audio/Protocols/DeviceControl.hpp:ControlKey—(kind, class, scope, element), with the scope in the key so an input and an output'vlme'on element 0 cannot collide (§1) — andControlValue.IDeviceProtocol:DescribeControl/ReadControl/WriteControl, one typed accessor rather than three virtuals per control type (§5.2).ReadControlanswers from the protocol's cache and never waits on the wire;WriteControlis fire-and-forget.ASFWAudioNub:Describe/Read/WriteProtocolControl. Unlike the existing boolean bridge these need no AV/C transport, so register-based families can answer them.ASFWProtocolLevelControl: mirrorsASFWProtocolBooleanControl, as FW-135 suggests. Scalar changes convert through the control's own transfer function, so the device's echo agrees with the slider instead of fighting it.The MOTU half
0x0c0c(phones is0x0c10, not yet exposed).dB = 20*log10(raw/128), so0x40is −6 dB and0x08is −24 dB. ctl-services publishes it as an ALSADB_LINEARinterval (register_dsp_ctls.rs:841-846), andDB_LINEARis defined as "the value increases linearly, convert with 20 * log10(current / (maximum − minimum))" (alsa-ctl-tlv-codec/src/items.rs:123-128). I first read the −6400..0 endpoints as 0.5 dB per step; on hardware everything played far too loud, because raw 8 is −24 dB and not −60 dB. DriverKit has no libm, so the conversion is exp2/log2 from the float exponent plus a short series, checked againststd::log10/std::powto 0.00001 dB.HandleChange*takes the value optimistically and returns;MotuLevelWriterkeeps one write in flight, latest value wins, with a 25 ms cooldown. A slider drag reaches the device as under 40 writes/second and the final value of a burst is always sent. A failed write is not retried on its own.MAIN_OUTPUT_PAIRED_VOLUME(Linuxmotu-register-dsp-message-parser.c; the UltraLite carriesSND_MOTU_SPEC_REGISTER_DSP). The capture consumer lifts it into the transport control block; a 10 Hz driver timer, running only while IO runs, pushes it withSetDecibelValue(). A 400 ms hold-off after a host change stops our own in-flight write from snapping the slider back.One deliberate deviation — tell me if you'd rather not have it
The device has no mute register (ctl-services reports
mute_avail: false), and macOS drives the mute key only from a'mute'control, so the key did nothing. This publishes one backed by the level: mute writes below the register's range, which turns it off; unmute writes the level the volume control still holds, so nothing separate is stored and nothing can drift. Turning the knob while muted clears the mute, moving the slider while muted unmutes, andStop()unmutes first so a stopped driver never leaves the unit silent.That is host-side behaviour standing in for a hardware parameter, which the design note deliberately avoids ("backend = control"). I went ahead because a dead mute key is worse, and kept it to one header of pure decisions (
Runtime/OutputMutePolicy.hpp, host-tested) plus one control class, so it is easy to drop. It also settles §6.1 for this device.Testing
§6.1, whether the volume keys bind to a master'vlme'on element 0, is answered for this device: they do.🤖 Generated with Claude Code