Skip to content

Add aptX Adaptive transport parser and Linux host documentation - #17

Open
baizhu945 wants to merge 7 commits into
arkq:masterfrom
baizhu945:aptx-adaptive-linux
Open

baizhu945 wants to merge 7 commits into
arkq:masterfrom
baizhu945:aptx-adaptive-linux

Conversation

@baizhu945

Copy link
Copy Markdown
Contributor

What this adds

Three commits on top of master:

  • include/aptxadaptive.h, src/aptx-adaptive-stream.c, test/ - a parser
    for the eight-byte aptX Adaptive transport header (time to play, period code,
    packet type, channel mode, revision marker) plus the observed packet-type to
    payload-size table. The test runs in Debug and Release, and it covers the R2,
    R3 and R2.2 headers, the whole table and the values that must be rejected.
  • research/aptx-adaptive-qemu/ - the record behind the results: the
    Hexagon helper that drives Qualcomm's CAPI encoder module through a small
    control protocol, the measurement tools (btmon and air analysis, a link
    preflight, a stream checker, an offline helper probe), the SPA codec ABI 17
    patch for the clean-room encoder, and the chronological handoff and status
    logs including every negative result. Nothing here is built by default; the
    helper needs vendor QDSP6 modules and a Hexagon emulator at run time.
  • aptx-adaptive.md, linked from the README - what a non-Qualcomm Linux
    host can do with an Adaptive sink today, the two host paths, the transport
    rules they depend on, and the open problems.

Why

Headsets negotiate aptX Adaptive, and a Linux host has had no usable source for
it. This PR ships what was actually established on the hardware: a working
source configuration, the transport facts it depends on, and the negative
results, so the next person does not have to repeat the search.

The finding that decides whether audio comes out

The eight-byte Qualcomm container header must not be part of the A2DP
payload.
With it, the link stalls one to two seconds in and the sink stops
acknowledging; without it the same encoder plays for minutes. Measured on a
Sennheiser MOMENTUM 5 (R2.2 sink):

payload on air L2CAP payload result
RTP + container + 656-byte frame 676 B 40 pkt/s for ~1 s, then collapse
RTP + 656-byte frame 668 B 40.0 pkt/s sustained, 26.9 kB/s

Two host paths work:

path what it is result
clean-room encoder aptx slot SPA codec plugin 427 kbps HQ / 12 ms, 270 kbps LL / 10 ms, adaptive bitrate
CAPI bridge Qualcomm's encoder module on qemu-hexagon 213 kbps, fixed 25 ms

The clean-room encoder is the recommended one. Its author has published it for
reuse, and aptx-adaptive.md carries the whole recipe including the ABI 17
patch needed for PipeWire 1.7.

How it was tested

  • CI matrix reproduced locally with CFLAGS and CXXFLAGS set to
    -Wall -Wextra -Werror -Wshadow: Debug and Release against both FFmpeg and
    libfreeaptx. All four configurations build without a single warning and
    ctest passes both tests in each one.
  • markdownlint over the tree: no findings. reuse lint: compliant
    (112/112 files).
  • The two host paths were measured on the machine that wrote them, with btmon
    captures and the encoder's own counters; the numbers are in the document and
    in STATUS.md.

What does not work: aptX Lossless

The sink supports it - a FiiO BT11 reports aptX Adaptive mode 19 (aptX Lossless) while connected to the same headset - but a non-Qualcomm host cannot
produce a stream it plays:

  • The vendor module's R2.2 wrapper only enters its Lossless candidate state with
    the AudioReach feedback loop, which a standalone container does not have. What
    it emits instead (packet type 5, channel byte 0xa0, 760-byte frames at
    50 ms, about 121 kbps) is rejected by the sink after 1.9 s, and Qualcomm's own
    reference toolchain does not accept it as a stream either.
  • The R3 kernel can be driven directly, but only in its lossy row (packet type
    1, 15 ms). SetBitRate, SetCompression, SetExtendedPacket and every
    profile its init accepts leave the header unchanged, and the output
    descriptor's end always equals the buffer limit, so the encoded length
    cannot be read back.
  • The reference tools from Add the aptX Classic/HD/Adaptive/Voice x86 executables to the archive #9 decode R2 payloads and independently confirm the
    container-header finding, but they contain no R3 air decoder. The R3 tool is
    a SlimBus converter and rejects OTA framing.
  • The framing could be settled by a capture of a real Lossless stream. None is
    available here: the BT11 exposes USB HID and UAC1.0 only, with no HCI and no
    stream read-back, and its host firmware partitions are not readable.

Unblocking it needs one of: a captured Lossless stream, a way into the vendor
host firmware, or an implementation of the AudioReach feedback container.

Relationship to #16

#16 is the earlier and longer-running record of this investigation from the same
branch. This PR is that material rebased onto current master, plus what came
after it (the container-header fix, the clean-room encoder, the Lossless wall),
with the lint and -Werror issues fixed. Happy to supersede #16 with this one,
or to drop the research/ part entirely if you would rather take only the
parser and the documentation.

Qualcomm's Adaptive encoder emits an eight-byte transport header in front of
each codec frame. The parser decodes it - time to play, period code, packet
type, channel mode and revision marker - and selects the payload size from the
observed packet-type table.

It also carries the control protocol constants of the research helper in
research/aptx-adaptive-qemu, which is its only consumer today.

The test covers the R2, R3 and R2.2 headers, the whole payload-size table and
the values that must be rejected.
Everything behind the findings in aptx-adaptive.md: the Hexagon helper that
drives Qualcomm's CAPI encoder module, its control channel, the btmon and
air-analysis tools, the ABI 17 patch for the clean-room encoder, and the
chronological handoff and status logs.

The helper is a research artifact. It needs the vendor QDSP6 encoder modules
and a Hexagon emulator at run time, so nothing here is built by default.
aptx-adaptive.md collects what a non-Qualcomm Linux host can do today: the
clean-room encoder in the aptx slot (427 kbps HQ with ABR, 270 kbps low
latency), the CAPI bridge and the container-header rule that decides whether
audio comes out at all, the transport facts both paths depend on, and the open
problems - aptX Lossless stays out of reach without the AudioReach feedback
container.
The encoder implements 44.1, 48 and 96 kHz, but the rate is negotiated rather
than configured: PipeWire offers one preferred rate through bluez5.default.rate
and the encoder intersects it with the rates the peer advertises, falling back
to 48 kHz.

Record the measured forms for 48 kHz (435 kbps, 12 ms) and 96 kHz (616-646
kbps, 8 ms, HQ only), and the reason 44.1 kHz does not survive with this
headset: the MOMENTUM 5 encodes 44.1 kHz as 0x40 in its rate byte while the
encoder only recognises 0x08 for it, so a 44100 request falls back to 48000.
The evidence is the peer's capability element; the bit is marked UNCERTAIN in
the public sources.
@baizhu945

Copy link
Copy Markdown
Contributor Author

Limitation found while testing sample rates: 44.1 kHz does not survive

Following up on this PR's documentation with a limitation that belongs in the
record. It is a property of the clean-room encoder this PR documents (and of
the current state of the art on a non-Qualcomm host), not of the transport
parser added here.

What works. The encoder implements 44 100, 48 000 and 96 000 Hz, and the
rate is negotiated rather than configured: PipeWire's bluez5 monitor offers one
preferred rate (bluez5.default.rate, 48 000 by default) and the encoder picks
it out of the intersection of that and the rates the peer advertises, falling
back to the first entry of its own table. Measured on the MOMENTUM 5 with that
setting changed, restarting WirePlumber and reconnecting the headset:

requested rate result on air
48 000 435 kbps, 12 ms packets
96 000 616-646 kbps, 8 ms packets (HQ only; the low-latency profile is refused at 96 kHz)
44 100 falls back to 48 000, unchanged 12 ms packets

Why 44.1 kHz fails. The sink advertises its sample rates in a single byte
of its capability element, and this headset's value is 0x71:

d7 00 00 00 ad 00 71 0a 37 6a c8 7d 64 7d 00 01 82 00 00 0f
                  ^^ rate byte: 0x40 | 0x20 (96 kHz) | 0x10 (48 kHz)

0x40 is how this headset encodes 44.1 kHz, while the encoder only recognises
0x08 for 44.1 kHz, so 44.1 kHz is never in the intersection and the link
falls back to 48 kHz. Qualcomm's own stack uses the 0x40 encoding, which is
why a phone offers all three rates against the same headset. Public sources
disagree on this bit - theacrat/aptx-linux marks it UNCERTAIN and lists a
variant that uses 0x40 - so this is offered as evidence rather than as a
conclusion, but it is reproducible from a capture of the AVDTP capability
exchange.

Where a fix belongs. In the encoder's rate table (accept 0x40 as 44.1 kHz
as well, or key the mapping off what the peer sends), i.e. in
theacrat/aptx-linux rather than in this repository. What this PR does is record
the limitation where users will meet it: aptx-adaptive.md now has a Sample
rates
section with the mechanism, the measured forms and this evidence, and the
host configuration keeps 48 kHz as its default because 96 kHz cannot run the
low-latency profile.

If it would be more useful there, I am happy to move this report to an issue on
the encoder's repository instead.

…dset limit

The rate bit is an encoding mismatch rather than an unsupported rate: the sink
sends 0x40 for 44.1 kHz while the encoder only knows 0x08, and the encoder also
excludes 44.1 kHz on a 2.2 sink because that is the reference's Lossless
configuration.

Accepting both encodings and letting the 2.2 branch offer the rate makes the
link negotiate 44.1 kHz: the outgoing element carries 0x42 and the stream ran
35.8 s at 11.97 ms, 668-byte packets and 443 kbps. Audible confirmation is
still pending, so the section records what was measured on the wire.
@baizhu945

Copy link
Copy Markdown
Contributor Author

Correction and follow-up: 44.1 kHz is reachable with a two-line encoder fix

My previous comment called 44.1 kHz unreachable with this headset. That was
wrong, and this corrects it. The rate bit is an encoding mismatch, not an
unsupported rate, and once it is handled the link does negotiate 44.1 kHz.

What was wrong. Two independent things kept 44.1 kHz out of the
intersection in the encoder:

  1. caps.rs recognises only 0x08 as 44.1 kHz. This headset sends 0x40 for
    it (0x71 = 0x40 | 0x20 | 0x10), and so does this repository's own CAPI
    codec (APTX_ADAPTIVE_SAMPLING_FREQ_44100 0x40 in
    spa/plugins/bluez5/a2dp-codec-caps.h). Qualcomm's stack evidently uses that
    encoding, which is why a phone offers all three rates.
  2. supported_rates() deliberately withholds 44.1 kHz from a 2.2 sink, because
    the reference implementation switches to the R3 (Lossless) encoder at that
    rate. The vendor's R2 kernel still encodes 44.1 kHz as config 3, so the
    exclusion is stricter than the encoder itself.

What was measured with both changed (local patch on a30bbd8, nothing
upstream):

const RATE_44K_ALT: u8 = 0x40;
const SUPPORTED_RATES: [(u32, u8); 4] = [
    (48_000, RATE_48K), (44_100, RATE_44K),
    (96_000, RATE_96K), (44_100, RATE_44K_ALT),
];
// and let the DecoderLevel::R2 + SINK_22_DECODER branch offer 44.1 kHz too
  • the outgoing configuration element becomes
    d7 00 00 00 ad 00 42 02 ..., i.e. 0x42 = 0x40 (44.1 kHz, the peer's own
    encoding) plus source type host, and the A2DP node reports 44100 Hz;
  • 35.8 s of playback produced 2969 packets at a steady 11.97 ms with
    668-byte L2CAP payloads and 443 kbps, and the adaptive bitrate walked
    270k -> 340k -> 427k. Nothing collapsed - the earlier lossless attempts died
    after about two seconds, so this is a different outcome.

Status. The result above is measured on the wire. Audible confirmation of
that configuration is still pending, so I am recording it as "negotiates and
streams" rather than "plays", and aptx-adaptive.md now says the same. If it
does play, the fix belongs in the encoder's rate table (theacrat/aptx-linux),
which is also where the UNCERTAIN note about this bit lives; I am happy to
open that report there with this evidence.

Playback at 44.1 kHz was confirmed audibly, so the rate is usable rather than
merely negotiated.  The change lives in a fork of the encoder because upstream
is read-only, and switching rates restarts the monitor, which briefly
interrupts audio.
@baizhu945

Copy link
Copy Markdown
Contributor Author

Confirmed by ear, and where the fix lives

Short closure on the 44.1 kHz thread: the configuration with the rate-bit fix
was played back and the audio was confirmed good, so 44.1 kHz is usable on this
hardware, not merely negotiated. aptx-adaptive.md now says that instead of
"audible confirmation pending".

The two-line change is not being sent upstream: theacrat/aptx-linux is read-only
and the author has said they are done with it, so the fix is carried in a fork
(baizhu945/aptx-linux, commit 1412f0b) and the host configuration builds the
encoder from there. Anyone who wants the same behaviour on another machine can
pull that fork, or apply the two edits to caps.rs described above.

All three rates are now reachable from the host configuration and switchable at
runtime:

rate measured on air
44100 12.00 ms, 668-byte packets, 418-443 kbps
48000 12.01 ms, 668-byte packets, 432-435 kbps
96000 8.01 ms, 668-byte packets, 616-646 kbps (HQ only)

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