Skip to content

aptX Adaptive from a non-Qualcomm Linux host: implementation, evidence, and where it stands - #16

Open
baizhu945 wants to merge 96 commits into
arkq:masterfrom
baizhu945:research/aptx-adaptive-final
Open

baizhu945 wants to merge 96 commits into
arkq:masterfrom
baizhu945:research/aptx-adaptive-final

Conversation

@baizhu945

Copy link
Copy Markdown
Contributor

aptX Adaptive from a non-Qualcomm Linux host: implementation, evidence, and where it stands

Final report on this line of research: a host-side aptX Adaptive implementation
on NixOS/PipeWire with an Intel AX210, driving the Qualcomm reference encoder
under QEMU, verified frame by frame against a working Android source -- plus the
measurement evidence for why the headset still stays silent.

This supersedes #15, whose description and comments had turned into a running
log with several mid-course corrections. What is worth keeping from that
discussion is folded in below and in the documents it points to.

What is in the PR

Path Content
src/aptx-adaptive-stream.c, include/aptxadaptive.h OTA transport-header parser and payload-size table
test/aptx-adaptive-stream.c its unit test
research/aptx-adaptive-qemu/aptx-lossless-helper.c the QEMU adapter around the reference encoder (R2 + R3)
research/aptx-adaptive-qemu/compat.c compatibility symbols the module needs
research/aptx-adaptive-qemu/preflight.sh mandatory gate: the card really is on aptx_adaptive and the headset is the default sink
research/aptx-adaptive-qemu/stream_check.py measures the wire shape and cross-checks it against the OTA header and the packet-type table
research/aptx-adaptive-qemu/cie_check.py compares the negotiated AVDTP element with the phone's
research/aptx-adaptive-qemu/run_ad_test.sh gate + playback + both checks in one command
research/aptx-adaptive-qemu/STATUS.md the status report (user-facing, lint-clean)
research/aptx-adaptive-qemu/HANDOFF.md the raw lab notebook, in Chinese, lint-ignored
research/aptx-adaptive-qemu/README.md, TOOLS.md build and diagnostic instructions

The Qualcomm module itself is a user-supplied proprietary blob and is not part
of the repository. The adapter is a diagnostic harness, not a clean-room
implementation, and is not suitable as a general-purpose audio backend without a
licensing and real-time review.

Verified working

  • the reference decoder decodes what this host actually transmits (48 kHz
    stereo, 440.0 Hz FFT peak for a 440 Hz input; 44.1 kHz stereo, 440.1 Hz peak
    for a live capture);
  • SET_CONFIG is byte-identical to a working Android source towards the same
    headset:
    d7 00 00 00 ad 00 40 02 50 64 64 64 ff ff 00 01 92 00 00 0f 02 03 03 03 00 aa;
  • the OTA header on the wire is 3c 0f 64 01 00 00 00 ae, i.e. period = 100
    (25.00 ms) and packet_type = 1 (656-byte frame) -- the same bytes the phone
    sends, apart from TTP -- and the measured packet interval is 25.00 ms;
  • replaying the phone's own captured records reproduces them 266/266 byte for
    byte
    , continuously (30.8 s, no gap above 60 ms);
  • on the same link and the same headset, aptX HD plays normally (894 B frames,
    55 kB/s).

Protocol findings

  • OTA header [TTP:2][period:1][packet_type:1][channel_mode:1][pad:2][version:1];
    the period byte is in 0.25 ms units, TTP in 1/15000 s, and the packet type
    indexes {348, 656, 140, 152, 560, 760, 960, 348, 980}.
  • The module's own period/PCM tables are [20..12] ms and [960..576] samples
    at 48 kHz, indexed by quality level.
  • AVS_ENCODER_PARAM_ID_BIT_RATE_LEVEL_MAP takes kbit/s, snapped to
    262/275/290/307/327/348/373/402/436. We had been sending bit/s, which
    collapses all five levels into one; fixed here. The observable stream did not
    change, so the Lossless form evidently does not consult that map.
  • The OTA version byte is level-driven: levels 6..15 emit 0xaf (R2.2), below
    that 0xae (R2).
  • The frame-type byte encodes the sample rate and the channel count (c0 =
    44.1 kHz stereo, d0 = 48 kHz stereo, ...).
  • One codec element was serving two purposes (peer-facing and encoder-facing).
    Splitting them is what makes the R2.2 capability advertiseable without pushing
    the encoder into a form a non-Qualcomm controller cannot feed
    (APTX_ADAPTIVE_ADVERTISE_R2_2).

A bug in our own tooling, found and fixed

stream_check.py unpacked the OTA header with period as a 16-bit field, which
folded the packet-type byte into it (0x64 0x01 read as 356) and reported
every following field one byte out of step: the value it printed as ptype was
really channel_mode. That produced a phantom "declared 23.73 ms against a
measured 25.00 ms" mismatch and would have failed a correct R2.2 stream on the
documented ptype gate. It now parses the header field by field and cross-checks
the frame size against the packet-type table -- the check that catches this
class of error. Two claims we had published earlier were withdrawn as a result.

Why the headset stays silent (final position)

Every host-controlled variable was put on the air behind the gate above and
verified from captures: 48 kHz and 44.1 kHz, 0xae and 0xaf, the ordinary R2
form and the R2.2 "Snapdragon Sound" form, the phone's own AVDTP element byte
for byte, the phone's own records replayed, and three time bases. All silent.

The decisive evidence is the comparison across sources:

source mode steady-state link audible
FiiO BT11 (QCC5181) aptX Lossless not on standard BR/EDR yes
FiiO BT11 ordinary Adaptive not on standard BR/EDR yes
phone (Snapdragon) ordinary Adaptive not on standard BR/EDR yes
this host (AX210) ordinary Adaptive standard EDR, visible no
this host (AX210) aptX HD standard EDR, visible yes

The BT11 measurements are operator-anchored. Four 130 s captures around a power
cycle give 0 hits for 0x6A8FCC in both dongle-absent runs and 74.3 and 59.1
hits/min at up to 0 dBm in both streaming runs. Two captures around a power
cycle show the mechanism: the headset's own address during paging for two
seconds, then the piconet visible for the ~31 s of connection setup (259 hits,
8.4 hits/s, 45 channels in Adaptive; 246 hits, 7.7 hits/s, 39 channels in
Lossless), then nothing while the media flows. A 304 s Lossless capture backs
the negative with a liveness control -- 535 packets from 32 piconets, several at
40 to 80 hits, and zero from the BT11. Cumulatively, 551 s of Lossless and 488 s
of Adaptive playback went by with the BT11's link absent, and the operator
confirmed the dongle was running the mode it claimed.

So every audible source keeps its steady-state media off standard BR/EDR, while
the only source whose Adaptive link is visible while it plays -- this host -- is
silent. Independently, the R2.2 experiment shows that the right stream is not
sufficient on this controller: the Snapdragon Sound shape (version 0xaf,
packet type 5, 760-byte frames, channel_mode 0xa0) went out continuously
and the headset stayed silent.

Conclusion: on this hardware the gap is at the link layer, not in the codec
stream. The candidates left are a controller able to drive that proprietary
high-speed link, or a condition under which this headset accepts a standard-EDR
Adaptive stream, which we have not found. Two instrumentation limits are worth
flagging for anyone repeating this: the sniffer cannot distinguish Adaptive from
Lossless on the air, and it cannot measure throughput (single-channel counting
recovers 1 to 2 percent of packets, calibrated against a link of known rate).

Reproducing

# gate: the card must be on aptx_adaptive and the headset must be the default sink
bash research/aptx-adaptive-qemu/preflight.sh --fix

# gate + playback + wire-shape and AVDTP checks in one command
bash research/aptx-adaptive-qemu/run_ad_test.sh

# read the shape of an existing capture, with the self-consistency checks
python3 research/aptx-adaptive-qemu/stream_check.py /tmp/cap.hci

What we would like from upstream

Any pointer on (a) a build of the encoder module that emits short (5-10 ms)
frames, (b) reaching R3 (0xad) without Qualcomm sideband feedback, or (c) a
controller that can drive the proprietary link -- or a plain statement that the
last one is out of scope for this project. We would rather be told it is a dead
end than keep guessing.

Add the eight-byte R3 OTA framing parser, packet-size and channel-mode mappings, and focused CTest coverage. This is transport framing only; it does not implement the proprietary aptX Adaptive codec.

The observations were cross-checked against the binary and strings notes in https://github.com/AndyCruz08/aptX-lossless-info. No proprietary library or generated binary is included.
Add opt-in stdin/stdout adapters for exercising user-supplied Qualcomm CAPI modules under qemu-hexagon, including the R3 profile-6 path. The adapters are research scaffolding, not an open-source codec implementation or a production PipeWire backend.

No proprietary libraries, firmware, sysroot, or generated streams are included. The framing and observations were cross-checked with https://github.com/AndyCruz08/aptX-lossless-info; see also openaptx issue arkq#8 for the original research context.
Use CMake full include and library directory variables so openaptx.pc remains valid when the install prefix is an absolute Nix store path. This is needed by fixed-output/package builds and does not change codec behavior.
The FFmpeg and libfreeaptx cleanup helpers had inverted NULL checks, so valid contexts were not released while a NULL argument could be dereferenced. Add a CTest regression covering NULL-safe destruction and normal decoder lifecycles.\n\nThis is independent of the research-only Adaptive work in PR arkq#12 and contains no proprietary codec files.
Three fixes measured on the standalone container:

- Clear the peer-only R2.2 capability bit when Lossless is disabled.
  With the bit set and no Lossless feedback the R2.2 wrapper stalled or
  faulted on the 44.1 kHz path; sanitising it makes 44.1 kHz work.
- Downgrade a forced Lossless request to ordinary aptX Adaptive unless
  APTX_ADAPTIVE_ALLOW_UNSTABLE_LOSSLESS=1 is set, and warn loudly that
  the QHS/Wi-Fi feedback is an assertion, not a measurement.
- Detect the R3 kernel's unwrapping per-channel cursors before they run
  past the module allocation.  The kernel previously died with SIGSEGV
  at call ~94; it now returns EOVERFLOW with an explanation.
- Call capi_aptx_adaptive_enc_get_static_properties() with the same init
  property list used for init(), size the module memory from
  CAPI_INIT_MEMORY_REQUIREMENT (116144 bytes for this build, kept at the
  1 MiB floor), and query CAPI_PORT_DATA_THRESHOLD after init.
- Report the module's framework extension (FWK_EXTN_BT_CODEC 0x132e4) and
  the DISABLE_PREBUFFER / KPPS_SCALE_FACTOR events under
  APTX_ADAPTIVE_VERBOSE=1.
- Document that the reference libgcc.so must export the 64-bit integer
  builtins: libaptXAdaptiveEnc3.so references __hexagon_{div,mod,udiv,
  umod}di3 and faults when they are missing.
- Record the measured R3 cursor behaviour in the research notes.
The R3 kernel's per-channel descriptors are {base, data_start, data_end,
limit, limit2}.  The adapter treated 0x6428 as the right read cursor and
set it to 0x6430, but 0x6428 is the ring BASE.  That advanced the base by
one frame per call until it reached the 32 KiB limit, after which the
kernel stopped producing packets (the 7-packet Lossless stall and the
R3 SIGSEGV/EOVERFLOW at call 89).

Mark only the data window empty (0x6418 = 0x641c, 0x642c = 0x6430) and
let the kernel compact back to the base itself, as
aptx_adaptive3_enc_process() does at 0xd1b0/0xd200.

Measured after the fix: R3 @48 kHz runs 2000 packets in 2000 calls and
R2.2 Lossless @44.1 kHz runs 1945 packets in 2000 calls, with no stall or
crash.  Remaining limitation documented: the R3/Lossless output settles
into a small set of repeated frames; the reference stream from the earlier
standalone R3 probe shows the same pattern.
Extract the process loop from Qualcomm's open-source AudioReach container
(gen_cntr, BSD-3-Clause) and migrate it into the standalone helper:

- loop module->process() until the external output buffer holds a complete
  frame, retrying on media-format / port-threshold / process-state events
  and stopping when nothing changed (1000-iteration runaway guard)
- handle the BT codec framework extension events (DISABLE_PREBUFFER 0x132e5,
  KPPS_SCALE_FACTOR 0x132e7) exactly as gen_cntr_bt_codec_fwk_ext.c does
- add per-port state (max_frames_per_buffer, num_frames_in_buf, buffer size,
  release flag) mirroring gen_cntr_ext_out_port_t
- document the extraction, gap analysis and migration in
  research/aptx-adaptive-qemu/gen-cntr-process-loop.md

All five verification suites pass with no regression; the lossy R2 path is
unchanged (168 packets, all unique, monotonic TTP).
…onvention

The proprietary module calls
    aptX3Encode(ctx, input_descriptor, output_descriptor)
(decompiled from deferred_rhs_process @0x9a20 -> 0x9bc0).  Earlier probes
passed NULL as the input descriptor and the ring as the output descriptor,
which is exactly why the encoder emitted a 4-byte stub.

direct-probe2.c drives it correctly and the encoder returns 0 and produces
real, input-dependent bitstreams:

  sine : 81 cc d0 7f fc 0f ff d1 8c 01 0e 7d fe f4 32 93 b5 ...  (696 nz)
  noise: 81 cc d0 7f c8 05 e0 08 4b 5f 13 83 a8 1f a4 1f e2 0f ...  (707 nz)

Requirements found empirically:
  - input window must hold >= 1344 samples (2 frames), otherwise 0xF015
  - output descriptor needs limit2 - limit > 63, otherwise 0xF014
  - output descriptor needs limit - end > 11, otherwise 0xF016
  - with a 328-byte output buffer (matching the module) it emits a full
    328-byte frame with 325/327/291 non-zero bytes

Also dump the module's live descriptors: they are well formed, so the
remaining module-side stub has another cause.  Document the next steps for
a direct encoding pipeline in the helper.
…streams)

Drive aptX3Encode() with the convention the proprietary module uses:

    aptX3Encode(ctx, input_descriptor, output_descriptor)

- r3_direct_setup(): allocate per-channel rings, build the input descriptor
  (limit == limit2) and the output descriptor (limit = base+328,
  limit2 = limit+64) for the R3 mode
- process_audio_direct_r3(): deinterleave and >>8 the CAPI input (matching
  the module's internal scaling), wait for two frames, call aptX3Encode,
  then build the OTA header (TTP step 375, period from me+0x248, type from
  me+0x230, version from me+0x231) with 328 bytes per channel
- advance/compact the input window and free the rings on reset

Measured on 48 kHz R3: 40 packets, 40 distinct payloads, dense bitstreams
(652/600/486 non-zero of 656), OTA version 0xad and monotonic TTP.
The lossy R2/R2.2 path is unchanged (168/182 packets, all suites pass).
…ple frame

- do not advance the R3 input descriptor's start manually: the encoder already
  does (measured 720 samples per call), and double-counting made the window
  drain
- call the encoder on every block and treat a non-zero result as 'still
  buffering' (EAGAIN) instead of a hard error
- document that the R3 frame is 720 samples (15 ms) at 44.1/48/96 kHz and that
  720-sample blocks give exactly one packet per call with a zero-length window
The R2 path advances 375 TTP units per 25 ms packet, i.e. 15 units/ms
(TTP unit = 1/15000 s).  The direct R3 pipeline reused 375 for a 15 ms frame,
so the sink's buffer drained and playback stopped.  Derive the step from the
real frame duration and the encoder rate instead.

Also document the MOMENTUM 5 bring-up: the three real-link fixes
(WirePlumber SPA path, MemoryDenyWriteExecute for the qemu JIT, headset
autoswitch), the peer's Lossless capability bit appearing after the app
switch, and the remaining A2DP transport instability.
Controlled experiment on the same link: aptX HD and SBC write fine, aptX
Adaptive with STEREO (0x02) fails with EAGAIN and stalls at 17.8 kB/s, while
JOINT_STEREO (0x08) writes fine and reaches 384 kB/s PCM / 44.8 kB/s codec.
The btmon capture showed the stream starts perfectly (61 packets at 15 ms
intervals, TTP +225/packet) and then degrades; the sink buffers ~1 s of audio
and stops pulling.  A fixed TTP base cannot align with the sink's playout
scheduler, so seed the TTP from CLOCK_MONOTONIC (unit 1/15000 s) and keep
advancing it by the real frame duration.
…host

Records the hardware, the verified CAPI/R3 behaviour, the five host-side bugs
fixed (source type, channel mode, PipeWire quantum, TTP, 44.1 kHz for
Lossless), the AVDTP SET_CONFIG captured from a working Android source, and
the remaining gap: the sink accepts the stream at L2CAP level but does not
decode it, with a constant 14-byte payload prefix and a bitrate below the
lowest documented Adaptive level.
Adds a detailed continuation document covering the current configuration, the
eight verified fixes, the capture-only sink used to obtain a reference
bitstream, the confirmed frame layout, and the single remaining difference
(frame header byte 2) together with the tooling needed to investigate it.
When APTX_ADAPTIVE_REPLAY points at a file of 664-byte OTA records
(eight-byte transport header + 656-byte codec frame), return those
records verbatim instead of encoding.  This pushes a captured reference
bitstream through the exact same PipeWire/BlueZ path, which separates an
encoder defect from a transport or negotiation defect.

Verified: the replay helper returns the captured frames byte-for-byte
and PipeWire transmits them on the wire unchanged.
…oot cause fixed

- The encoder output is now proven correct: the Qualcomm reference decoder
  (openaptx PR arkq#9 tools under Wine) decodes the stream actually sent over
  Bluetooth to 440.0 Hz at 48 kHz.
- Replaying the phone's captured frames through the bridge is byte-exact on
  the wire, so the transport layer is proven correct too.
- Root cause found: the Adaptive sampling-frequency bitmask was wrong
  (44100 was 0x40 = 192 kHz, 96000 was 0xa0).  Fixed in the PipeWire fork.
- Tested 48k / 96k / R2.2 combinations after the fix; still silent.  The
  headphone capability record only advertises 48k/88.2k/192k, and the phone
  selects 0x40, so a real 192 kHz encode path is the next step.
Measured against the CPH2749 module: the CAPI sampling-rate selector is
1=48 kHz, 2=44.1 kHz, 3=96 kHz; any other value falls back to 48 kHz.
The previous 96000 -> 0 mapping therefore produced 48 kHz frames while
the A2DP config claimed 96 kHz.  Verified with the reference decoder:
selector 3 output decodes as 96000 Hz with frame header 83 00 b0 a1,
matching the captured phone 96 kHz section.

Also forward HAP_debug/HAP_debug_v2 to stderr under APTX_HAP_LOG so the
module's own bitrate/period reports are no longer discarded.

HANDOFF section 12 records the round-2 findings: the Adaptive
sampling-rate bits are 0x40=44.1k / 0x10=48k / 0x20=96k, our SET_CONFIG
and RTP header now match the phone byte for byte, and the remaining
suspicion is the bitrate (our 48 kHz stream is 212 kbps, below the
279 kbps floor).
Eleven profile values (0..7, 0x1000, 0x2000, 0x4000) all produce 1200
samples per frame and 664-byte packets at 48 kHz, so the R2 wrapper's
frame length is not profile-controlled.  The 212 kbps wire rate therefore
matches the captured phone 48 kHz stream (RTP ts +1200, 656-byte
payload) and the bitrate is not the cause of the silence; the user's
50 kB/s observation corresponds to the 96 kHz section.
…es, HAP logs

- The deployed APTX_ADAPTIVE_STRIP_OTA=1 was a regression: the Android
  reference source sends RTP(12) + OTA(8) + frame(656) = 676 bytes.  With
  the strip removed our wire payload is byte-identical to the phone's
  (266/266 replay records).
- Decoded the OTA header layout (TTP/period/packet_type/channel_mode/
  version) and the payload-size table.
- Extracted the module's period table [20..12 ms] and PCM interval table
  [960..576] and the kbps thresholds of the bitrate-level map; the helper
  was sending bps, which collapsed every level.
- Found why the module logs never appeared: the HAP_debug_v2 shim's
  APTX_HAP_LOG gate.  With it removed the whole decision chain is visible:
  the codec wants 14 ms / 364 kbps but the R2.2 wrapper pins the process
  period to 100 (25 ms) / 656 B / 204 kbps.
- Ruled out (still silent): OTA strip, phone-exact SET_CONFIG, replaying
  the phone's own 664-byte records, and a 16 ms / 338 kbps stream.
- Reference measurement on the same link: aptX HD 55 kB/s (works) vs
  aptX Adaptive 27 kB/s (silent); the headphone sends no reverse traffic.
- Added the probe/analysis tooling and TOOLS.md.
…xaf above)

Disassembling encLevelHqStateMachine shows the R2.2 wrapper picks the OTA
version byte from the encoder level: levels 6..15 emit 0xaf (R2.2) and
everything below emits 0xae (R2).  The R2.2 level tables also use
packet_type 5 (760-byte payloads, 768-byte packets) and channel_mode 0xa0,
which is exactly the shape the lossless=AUTO run already produces - only
the version byte is wrong.

The link evidence lines up: the phone uses features 0x0f000092 (R2.2 bit)
towards the headphone and 0x0f000017 towards this host, and we advertise
0x92 while sending 0xae frames.  That is now the leading silence
hypothesis, ahead of the frame-rate one (a 16 ms / 338 kbps replay is also
silent).

Also recorded: the IMCL br_level is clamped to 3 inside the module even
when level 7 is sent.
…rt 260ms

- Reference decoder confirms both 0xb0 and 0xf0 frame types decode as
  96 kHz stereo, so the difference is not the sample rate.
- Selector map measured at encoder_rate=96000: 0/1->48k, 2->44.1k, 3->96k,
  4..7->48k.  The 96k wrapper emits one packet per two 1920-sample blocks
  (3840 samples, 40 ms, period 0xa0, ~133 kbps).
- The headphone reports a 260 ms delay for aptX Adaptive and 235 ms for
  aptX HD (25 ms = one AD frame); both are accepted.
- AVRCP playback status stays Stopped on our side while the phone reports
  Playing, but aptX HD plays in the same Stopped state, so it is not a gate.
…port/session

Replaying the phone's own frames is silent in four shapes (48k original OTA,
44.1k original TTP, 44.1k clean TTP, full R2.2 768-byte 0xaf/0xa0 packets).
Together with the earlier negatives (OTA header, SET_CONFIG, 16 ms/338 kbps,
96 kHz, version byte alone) this rules out the codec payload and format.

New evidence: the reference decoder shows frame-type byte 2 encodes rate AND
channel count (b0=96k mono, f0=96k stereo, c0=44.1k, d0=48k), so our 96k
frame type is not a bug; and the phone's own 44.1k stream is 656 B / 25 ms =
210 kbps, i.e. our rate is normal and the ~50 kB/s figure belongs to aptX HD
(measured 55 kB/s here).

The remaining differences are the RTP seq/ts base and the controller/link
layer (the phone offloads aptX to its Qualcomm controller; we see zero
reverse ACL traffic over a 30 s stream).
@AndyCruz08

AndyCruz08 commented Oct 7, 2026 •

Copy link
Copy Markdown

Upon further investigation, the aptX adaptive windows support is only on the Snapdragon Windows PCs

but, ESP32 can be used as a 2.4/5GHz SDR, TX is also possible

  • ESPARGOS/SoapyESPSDR (RX-only methinks)
  • jochenhammes/esp32-sdr-trx
    I would probably do this to figure out the PHY layer (i.e. modulation and whatnot) if i could get my hands on a ESP32-S31 and an aptX Lossless heatset, and ofc verify if the motorola i have is even capable of aptX adaptive

@theacrat

theacrat commented Oct 12, 2026 •

Copy link
Copy Markdown

Hey, stumbled upon this while seeing if I could have Claude clean-room implement for my personal usage. Also was having silence issues but managed to resolve them and get it running fully at least with my headset. I don't understand the technical details but from what I can tell you have to set the source type bit, and the other points are about stuttering that was introduced in the first attempt. I've left Claude's summary below.


Some findings that may explain the silence, from getting aptX Adaptive playing on a B&W Px7 S2e from a Linux host with a non-Qualcomm controller (Realtek RTL8922, stock BlueZ 5.87, PipeWire).

The link layer wasn't the problem, at least for this headset. We saw exactly what this PR describes: SetConfiguration, Open and Start all accepted, media flowing at the right rate, the headset silent, aptX HD fine, and even byte-for-byte replays of a Snapdragon phone's captured packets silent. It turned out to be three things in what the source sends. Fixing them gave clean audio on the same Realtek controller.

  1. Source type bits in codec config byte 6. Byte 6 carries the sample rate in bits 3..7 and a source type in bits 0..2. A Snapdragon phone sends 0x10 (48 kHz, source type 0). With source type 0 the sink seems to expect timing that only a Qualcomm controller provides, which it builds from the controller's link clock when it packetises the offloaded stream. From any other source it accepts the stream and plays nothing. Setting bit 0x02 (source type 2) makes the sink do its own timing, and audio came out straight away. For this sink family the rate bits are 0x08 = 44.1 kHz, 0x10 = 48 kHz, 0x20 = 96 kHz (some public headers have 44.1 and 96 wrong), so with source type 2 byte 6 is 0x0A / 0x12 / 0x22. This matches the public CodeAurora Android stack: its offload path uses source type 0 and its software-encoder path uses 2.

  2. The RTP timestamp isn't a clock with source type 2. A running timestamp, whether in samples or µs, gave crackly audio that was slow to start and stop, with the sink taking data at about a third of real time. What worked was a constant timestamp per packet:

    • Low 16 bits: samples per channel in that packet, at the negotiated rate. That's 672 = 0x02A0 for a 14 ms HQ packet at 48 kHz, and 480 = 0x01E0 for a 10 ms LL packet. Never send 0: the sink then treats the field as a time value.
    • High 16 bits: a signed latency adjustment in ms on top of the sink's target. Leave it at 0, because the sink handles clock drift itself.

    SSRC selects the latency mode: 0x000000A3 HQ, 0x000000A1 LL (Wi-Fi off), 0x000000A2 LL (Wi-Fi on), 0x000000A4 TWS. We've only tested HQ so far. Change it when switching mode; the sink re-syncs once. Layout on air: a plain 12-byte RTP header (PT 96), then the codec packet byte-swapped in 16-bit words. No extra header.

  3. Don't echo a zero target latency. The Px7 advertises HQ TTP byte (config byte 10) = 0x00. With source type 2 the sink takes that literally as a 0 ms target. It plays, but stutters after a minute or two as arrival jitter catches up. Sending 0x64 (200 ms, in 2 ms units) fixed it. Easy check in btmon: the sink's AVDTP Delay Report after Start reads 0.0 ms with 0x00 and 200.0 ms with 0x64.

Not needed: advertising A2DP 1.3 instead of BlueZ's 1.4 changes what the sink offers (it adds Delay Reporting to every SEP), but it doesn't affect playback.

Caveat: we only tested an R1 sink (no R2 extension in its caps), so the Momentum 5 / R2.2 case may differ. Point 1 is cheap to try there, though: byte 6 bit 0x02 in your SetConfiguration.

@baizhu945

Copy link
Copy Markdown
Contributor Author

Following the ESP32-as-SDR suggestion in this thread, I ran the measurement with an
ESP32-C5 running the ESP-SDR (ESPARGOS) raw-IQ firmware. It receives 2.4 and 5 GHz
without decoding any protocol, which removes the address-recognition assumption my
earlier sniffer result rested on, and it let me add the control that matters: the
same phone, at the same distance, playing the same track, with only the Bluetooth
codec changed.

Method. Burst captures of 819 us at 20 MSa/s with an 11 MHz analog filter and
a fixed manual gain, 200-300 captures per leg, at 2441 MHz for 2.4 GHz. Two
statistics per capture: the rise of a 1 MHz band over its own 8 MHz neighbourhood
("burst", a narrowband detector), and DC-removed power. A power-domain mean
spectrum is accumulated as well, since it responds to wideband energy too.

2.4 GHz, phone at 30-50 cm, only the codec changed

state captures with a burst >6 dB
phone, SBC (standard EDR) 43/200 = 21.5 %
phone, AAC 44.1 kHz (standard EDR) 23/200 = 11.5 %
this host, aptX HD at ~0.5 m (standard EDR) 32/200 = 16.0 %
phone, aptX Adaptive 6/200 = 3.0 % (repeat passes: 6.5 %, 9.5 %, 10.5 %, 4.3 %)
silence 5/200 = 2.5 % (repeat: 2.5 %, 6.7 %, 1.3 %)

The Adaptive passes scatter and overlap the silence passes, which is what a null
looks like once the room is allowed to drift; the standard-EDR rows sit clearly
above both. In a strictly paired run, taken back to back in one sitting with the
phone untouched, Adaptive read 4.3 % playing against 6.7 % paused.

One pitfall worth flagging. Raw RMS includes the front end's DC/leakage term,
and that term moves when a nearby transmitter is active, so it can look like signal
where there is none. With DC removed, a paired run gives:

metric Adaptive playing paused
burst >6 dB 7/200 = 3.5 % 8/200 = 4.0 %
raw RMS -37.0 dB -42.4 dB
DC-removed power -44.6 dB -44.4 dB
mean power spectrum, middle 16 MHz +0.36 dB, no structure -

The mean power spectrum matters here: a wideband emission raises it just as a
narrowband one raises a few bins, and it shows nothing. So within this window
Adaptive playback is not distinguishable from silence in burst rate, in
DC-removed power, or in spectral shape.

5 GHz. This branch needed its own control, because the narrowband detector is
blind to Wi-Fi-shaped energy. This host's Wi-Fi was associated on channel 44
(5220 MHz) a metre from the receiver; a sustained download moved that window from
-55.1 dB to -45.6 dB (+9.5 dB median, +12.8 dB at p90) and raised the mean power
spectrum by +12.4 dB, so an emission of that order at that range would have been
seen. During Adaptive playback, six 20 MHz windows across 5180-5825 MHz showed
nothing: 0.0 % bursts at every centre, power differences against idle between
+0.4 and -5.7 dB with the playing legs quieter, and a strictly paired repeat at
5500 and 5745 MHz disagreeing in sign (-4.3 dB and +1.9 dB). About 120 MHz of the
band was covered rather than all of it, and the headset has no 5 GHz radio to
begin with, so this closes a low-probability branch rather than a live one.

On transmitting. ESP-SDR is receive-only by design ("we are not providing a
transmitter implementation at this time"), and the ESP32 transmit work that does
exist is narrowband polar modulation (FM/SSB), not an arbitrary wideband waveform,
so an ESP32 cannot stand in for the vendor silicon that appears to be required at
the source end. What it can do, cheaply, is show what is actually on the air,
which is what I used it for.

Limits. The burst detector is deliberately narrowband (the mean power spectrum
covers the wideband case). One receive centre at 2.4 GHz, 819 us windows, so
roughly 20 of the 79 channels per window -- though standard-EDR codecs from the
same phone were visible in that same window, so the phone's channel map does
include it. At centimetre range the front end compresses and even SBC reads only
5 %, so comparisons have to be taken at 30-50 cm and in pairs, since the ambient
band itself drifts between roughly 1 % and 7 %.

Raw data, scripts and the write-ups: research/aptx-adaptive-qemu/STATUS.md sections
7.1-7.5 and HANDOFF.md sections 21.29-21.33, branch research/aptx-adaptive-final.

@baizhu945

Copy link
Copy Markdown
Contributor Author

Thanks for the recipe. The source-type observation is the most useful lead this
thread has had, so I spent today testing it end to end on a different sink. Short
version: on an R2.2 sink it does open the audio path, but the link dies within a
second or two. Details below in case they help you or anyone else.

Setup: Sennheiser MOMENTUM 5 (an R2.2 sink) from an Intel AX210, stock BlueZ
5.87 and PipeWire 1.7.0 with our bridge, which runs the Qualcomm CAPI encoder
under qemu-hexagon. The AVDTP configuration we send is
byte-identical to what a Snapdragon phone sends to this headset.

Your three points, as environment switches (all off by default, so the deployed
wire format is unchanged):

  • APTX_ADAPTIVE_SOURCE_TYPE (already existed): the low bits of config byte 6,
    0x00 or 0x02.
  • APTX_ADAPTIVE_TS_CONSTANT (new): the RTP timestamp becomes the packet's
    samples per channel (1200 for our 25 ms / 48 kHz frames), high 16 bits zero.
  • APTX_ADAPTIVE_SSRC (new): 0 by default, which is what the phone sends; 0xA3
    for HQ.

Your point 3 we already satisfied: our configuration carries 0x64 in the TTP
byte.

What we measured (btmon, counting < ACL: Handle 256; encoder throughput from the
helper's wchar counter):

configuration encoder air heard
source type 0 (baseline) 26.9 kB/s (40 pkt/s) 27.3 kB/s (40 pkt/s), sustained dead silence
source type 2 + constant timestamp 26.9 kB/s 19 pkt/s for the first second, then 1-2 pkt/s brief pops, no audio
the same plus SSRC=0xA3 26.9 kB/s 40 pkt/s for the first second, then collapse no audio

So source type 2 does get the sink decoding: we hear short, meaningless pops,
which source type 0 never produces. But the link collapses one to two seconds in.
During the collapsed phase a 10 s capture shows zero ACL packets on that
handle while the controller still reports Number of Completed Packets events,
i.e. the baseband stopped being acknowledged and the controller's queue is stuck.
The encoder never stops producing.

I also tried to change the packet period, since your HQ example is 672 samples
(14 ms) and ours is 25 ms / 676 bytes:

  • APTX_ADAPTIVE_CAP_EXT_VER=0 (new; forces a plain-R2 negotiation both in what
    we advertise and in the negotiated result): the frame on the air stayed 680
    bytes, i.e. still 25 ms.
  • AVDTP channel mode joint instead of stereo: same.

So the period hypothesis is untested rather than disproved: in this build the
period appears fixed at 25 ms whatever we do to ext-ver or channel mode.

Two questions if you still have your notes. What did your Px7 advertise for
cap_ext_ver_num, and what packet period did your stream use on the air? And when
you saw the sink "taking data at about a third of real time", did it ever collapse
completely, or did the constant timestamp cure the rate for you? If your sink is
R1 and this one is R2.2, the difference may be the wrapper state we cannot feed:
our R2.2 path waits for QHS/16-bit sideband feedback that a non-Qualcomm
controller cannot provide, and with Lossless disabled we deliberately clear that
capability bit so the wrapper does not enter that state.

Raw data and write-ups: research/aptx-adaptive-qemu/STATUS.md sections 5, 6 and
7.1-7.5, HANDOFF.md sections 21.34-21.38, branch research/aptx-adaptive-final.

@theacrat

theacrat commented Oct 15, 2026 •

Copy link
Copy Markdown

I've published my repo with my research and clean implementation. Only tested with my Px7 S2e. I don't care to support it further so do what you want with it.

Known compatible libaptXAdaptiveEnc.so from OnePlus 15 (CPH2749) hash 1ac881571cd1db4d256113d4448304016de73fedebc059824b1b2ed09599f0c9.

https://github.com/theacrat/aptx-linux

@baizhu945

Copy link
Copy Markdown
Contributor Author

I've published my repo with my research and clean implementation. Only tested with my Px7 S2e. I don't care to support it further so do what you want with it.

Known compatible libaptXAdaptiveEnc.so from OnePlus 15 (CPH2749) hash 1ac881571cd1db4d256113d4448304016de73fedebc059824b1b2ed09599f0c9.

https://github.com/theacrat/aptx-linux

Thanks for your contribution so much! I have successfully got aptx adaptive on my computor.

@AKS-Logic

Copy link
Copy Markdown

Update to this report: the bitstream question is now settled with vendor-decoder evidence.

Using Qualcomm's own reference decoders under a patched wibo PE loader, both 96 kHz candidate streams decode cleanly at 96 kHz: the R2 0xE 15 ms stream at corr 0.971 / SNR 12.1 dB (in-band quantisation noise, >20 kHz at −101 dB, no clicks) and the R3 2×328 B stream at corr 0.99996 / SNR 41.4 dB. Injected corruption controls (length code, frame type, single bit flip) produce the expected LOST SYNC / clipping / localised-error signatures, which do not occur in the real captures. So neither stream is corrupt; the failure observed on the headset is transport and sink rate authority, exactly as concluded here.

Also confirmed on the sink side: Bose QC Ultra 1st gen advertises 0x51 = 0x40|0x10|0x01 (no 0x20), rejects a forced 96 kHz SetConfiguration with AVDTP 0x29, and ADK 6.4 maps 0x40 to 192 kHz with no coded 192 kHz mode anywhere — so that unit cannot decode 96 kHz regardless of the host pipeline.

Practical decode recipe and the full evidence table are in #8 (comment). 48 kHz LL + HQ is what ships from our side.

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.

4 participants