Skip to content

audio: protocol-backed volume and mute controls (MOTU hardware level) - #119

Draft
deweydb wants to merge 25 commits into
mrmidi:mainfrom
deweydb:feat/motu-controls
Draft

deweydb wants to merge 25 commits into
mrmidi:mainfrom
deweydb:feat/motu-controls

Conversation

@deweydb

@deweydb deweydb commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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.md lays 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) — and ControlValue.
  • IDeviceProtocol: DescribeControl / ReadControl / WriteControl, one typed accessor rather than three virtuals per control type (§5.2). ReadControl answers from the protocol's cache and never waits on the wire; WriteControl is 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: mirrors ASFWProtocolBooleanControl, 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 boolean path is untouched, pending FW-133.

The MOTU half

  • Register: main output volume 0x0c0c (phones is 0x0c10, not yet exposed).
  • It is linear in amplitude, not in dB: dB = 20*log10(raw/128), so 0x40 is −6 dB and 0x08 is −24 dB. ctl-services publishes it as an ALSA DB_LINEAR interval (register_dsp_ctls.rs:841-846), and DB_LINEAR is 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 against std::log10/std::pow to 0.00001 dB.
  • Flood control (§5.3): HandleChange* takes the value optimistically and returns; MotuLevelWriter keeps 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.
  • Knob tracking with no bus traffic: register-DSP models report their front-panel state in the message chunk of every capture data block, including MAIN_OUTPUT_PAIRED_VOLUME (Linux motu-register-dsp-message-parser.c; the UltraLite carries SND_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 with SetDecibelValue(). 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, and Stop() 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

  • 1785/1785 host tests pass; Release dext builds. New coverage: the amplitude↔dB conversion against libm, the coalescing writer (including in-flight replacement, cooldown spacing and no-retry-on-failure), the DSP message scan, and the mute policy.
  • Hardware: the control appears in the Sound panel, the device's level reads back at startup and no write has failed. Not yet re-checked by ear since the amplitude correction — I will confirm before marking this ready.
  • §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

deweydb and others added 25 commits September 16, 2026 14:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant