Skip to content

feat(audio): make device-vs-profile stream geometry one decision - #129

Merged
mrmidi merged 2 commits into
mainfrom
fix/stream-geometry-single-source
Sep 18, 2026
Merged

mrmidi merged 2 commits into
mainfrom
fix/stream-geometry-single-source

Conversation

@mrmidi

@mrmidi mrmidi commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Groundwork for consolidating the DICE path. Additive and reporting only — no
device's behaviour changes.

The problem

Two independent descriptions of the same isochronous streams exist, and they are
consumed by different layers:

device DICE RX regs ─► caps.hostToDeviceStreams[i] ─► DuplexStreamProfile ─► IRM bandwidth
profile constants ──► BuildTxStreamConfig(i) ───────► ASFWAudioDevice::StartIO ─► CIP framing
                      └──── nothing reconciled these ────┘

Two divergences, not one. The stream count is dual-sourced —
DuplexStreamProfile::ResolveChannels arms caps.hostToDeviceStreamCount while
StartIO's secondary-stream branch is gated on profile->TxStreamCount() — and so is
each stream's shape. A disagreement ships correctly-reserved bandwidth carrying
wrongly-framed packets, which fails as silence with nothing logged.
PreSonusStudioLive2442Profile.cpp already documented the hazard without closing it.

Why the device wins

Four TC Applied Technologies drivers were disassembled to settle the precedence rule
rather than assume it: PaeFireStudio.kext 4.2.1 (PreSonus, 14 devices),
MidasFW.kext 4.2.1 (Midas), Saffire.kext 4.1.4 (Focusrite, 7 devices) and
AlesisFirewire 3.5.6 (Alesis, DICE II, 2011).

  • None of them carries a single host-side per-stream geometry constant. All read
    RX_SIZE, RX_ISOCHRONOUS, RX_SEQ_START, RX_NUMBER_AUDIO and RX_NUMBER_MIDI
    per stream in PopulateRxStruct and frame from exactly that.
  • PreSonus and Midas are the same binary — identical function sizes and identical
    symbol sets, differing only by the mangled-name length prefix.
  • The layout is stable across chip generations: Alesis is a Dice II part on a 2011
    SDK and reads the same offsets.
  • All reject a device-reported stream count above 4 rather than clamping, which is why
    ResolveStreamCount refuses instead of truncating — silently dropping a stream the
    device still transmits on is the same class of defect.

Offsets cross-checked against references/linux-sound-firewire-stack/.../dice-interface.h.

What this adds

ASFWDriver/Audio/Protocols/StreamGeometryResolver.hpp — pure and DriverKit-free:
ResolveStreamCount and ResolveStreamGeometry. The device wins when it states a
geometry, the profile is the expectation it is checked against, a disagreement is
flagged rather than silently resolved, and nothing is invented when neither side
states a shape — unlike the current DuplexStreamProfile.hpp:333 fallback, which
substitutes a plausible number.

DiceAudioBackend::EnsureNubForGuid now compares the two and says so. It turned
out to be the one place both sides are already in scope, and because
IDiceDeviceProfile derives IAudioStreamProfile it calls exactly the
BuildTxStreamConfig(index, …) that StartIO calls — the comparison is against the
same object, not a reconstruction of it.

Agreement logs one line per stream naming the source; disagreement logs an error with
both shapes.

Scope

Deliberately reporting-only, so no working device can regress. Switching the framing
path to take its geometry from the caps is the follow-up, and it wants a real log from
a device first.

Verification

  • 1840/1840 host tests pass. 15 are new, covering both resolvers including the
    asymmetric multi-stream shapes no bench device here can produce.
  • ./build.sh --no-bump succeeds with no new warnings (9 pre-existing).

mrmidi and others added 2 commits September 18, 2026 21:06
Two descriptions of the same isochronous streams exist: the device's own
DICE TX_/RX_ registers, read into AudioStreamRuntimeCaps at bring-up, and
the profile's compiled-in constants. The transport reserved isoch
bandwidth from the first while the packetizer framed CIP from the second,
and nothing compared them, so a disagreement shipped correctly-reserved
bandwidth carrying wrongly-framed packets. That fails as silence with
nothing logged anywhere. PreSonusStudioLive2442Profile.cpp's header
comment already documented the hazard; this is the mechanism that ends it.

The precedence rule is the device's, and that is not a preference. Two
independent TC Applied Technologies drivers were disassembled to settle
it: PaeFireStudio.kext 4.2.1 and Saffire.kext 4.1.4 share the TC SDK, and
neither carries a single host-side per-stream geometry constant. Both
read RX_SIZE, RX_ISOCHRONOUS, RX_SEQ_START, RX_NUMBER_AUDIO and
RX_NUMBER_MIDI per stream in PopulateRxStruct and frame from exactly
that, across 14 and 7 devices respectively, with no per-model branch in
either. Offsets cross-checked against Linux dice-interface.h. Both also
reject a device-reported stream count above 4 outright rather than
clamping, which is why ResolveStreamCount refuses instead of truncating:
silently dropping a stream the device still transmits on is the same
class of defect.

Resolution covers both divergences, because there are two. The stream
COUNT is dual-sourced (ASFWAudioDevice arms profile->TxStreamCount()
while DuplexStreamProfile arms caps.hostToDeviceStreamCount), and so is
each stream's SHAPE. Nothing is invented when neither side states a
geometry: the result is unusable, which is louder and more honest than
substituting a plausible number the way the current
DuplexStreamProfile.hpp:333 fallback does.

Pure and free of DriverKit so the host suite reaches every branch,
including the asymmetric 16+10 playback of the StudioLive 24.4.2 from
issue #115 — a shape no bench device here can produce. That case is the
regression test: against the uniform profile default, stream 0 agrees and
stream 1 is caught.

Header and tests only; no call site is switched over yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DiceAudioBackend::EnsureNubForGuid is the first and only place where both
descriptions of the playback streams are in scope: it holds the runtime
caps read from the device's DICE RX registers and the IDiceDeviceProfile
whose constants ASFWAudioDevice::StartIO frames CIP from. It never
compared them. This makes it compare them and say so.

Both divergences are covered, because there are two of them. The stream
COUNT is dual-sourced -- DuplexStreamProfile::ResolveChannels arms
caps.hostToDeviceStreamCount while StartIO's secondary-stream branch is
gated on profile->TxStreamCount() -- and so is each stream's shape.

Reporting only, deliberately. Nothing here changes what gets programmed,
so no working device can regress: a disagreement becomes a logged error
where it was previously silence, and agreement becomes one line per
stream naming which side the geometry came from. Switching the framing
path to take its geometry from the caps is the follow-up, and it wants a
real log from a device first.

IDiceDeviceProfile derives IAudioStreamProfile, so the backend can call
exactly the BuildTxStreamConfig(index, ...) that StartIO calls -- the
comparison is against the same object StartIO will use, not against a
reconstruction of it.

1840/1840 host tests pass; dext builds clean with no new warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mrmidi
mrmidi merged commit 6d08a01 into main Sep 18, 2026
2 checks passed
@mrmidi
mrmidi deleted the fix/stream-geometry-single-source branch September 18, 2026 20:13
boggspa pushed a commit to boggspa/ASFireWire that referenced this pull request Sep 23, 2026
Stage 4 step 1. StreamGeometryResolver could already decide one stream; there
was no object holding the answer for a whole device, so DiceAudioBackend
computed decisions, logged them and dropped them while bandwidth reservation
kept reading the device and CIP framing kept reading the profile.

Adds ResolvedDirectionGeometry (stream count + per-stream decisions) and
ResolvedDeviceGeometry (capture + playback). Three things it can express that
the loose decisions could not:

  - TotalPcmChannels() sums the streams a direction actually carries, instead
    of stream 0 times the stream count.
  - FirstDisagreeingStream() names which stream is wrong, so a refusal can say
    so rather than logging per-stream and continuing.
  - Usable() is the refusal the resolver's own contract already required
    ("callers must refuse to start") and nobody implemented.

Directions are named for the host, not the device, because DICE register names
invert: DICE TX is host capture, DICE RX is host playback.

Tests are built from the recorded dumps in documentation/fixtures/DICE/, never
from a profile constant. They pin the defect this stage exists to remove:
AudioStreamProfile::TxChannelCount() returns pcmChannels * TxStreamCount(),
so the Venice F24's 16+8 aggregates to 32 where the device means 24, and
BuildTxStreamConfig(streamIndex,...) uses streamIndex only as a bounds check
before returning stream 0's config for every index. A Venice F32 is 16+16 both
ways and cannot catch either; that is asserted too, so the fixture choice is
not mistaken for arbitrary.

Not a forward port: the midi branch has no counterpart. Its
AudioGeometry{Policy,Resolver,Report} trio resolves timing geometry -- frames
per packet, safety offsets, latency as functions of rate -- not per-stream wire
shape, and predates the device-vs-profile conflict (mrmidi#129). Recorded in the
header so nobody hunts for a midi reference that does not exist.

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