Repository navigation
Conversation
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).
Setting APTX_ADAPTIVE_LOSSLESS=auto to restore the R2.2 capability bit does not only change the advertisement: the encoder switches to the R2.2 form, the packet interval doubles to 50 ms and the bitrate halves to 13.5 kB/s (the user-visible 13.37 kB/s). With that setting our SET_CONFIG becomes byte-identical to the phone's, the headphone still stays silent, so the capability bit and the CIE are ruled out as the cause.
Add preflight.sh (adapter up, headset connected, A2DP/aptX Adaptive actually negotiated, MOMENTUM 5 is the default sink) and stream_check.py (measure the real packet size, interval, throughput and OTA header from a btmon capture and compare them with the 48 kHz R2 expectation). The Bluetooth card silently falls back to headset-head-unit/CVSD when the A2DP profile is not selectable, and an encoder mode switch can halve the bitrate without changing any host property, so both checks are required before a result means anything.
… field Every configuration was put on the air behind the new gate: the ordinary R2 form with version 0xae, the same form with 0xaf, and the R2.2 form. The stream checker now also compares the measured packet interval with the period the OTA header itself declares, which is what caught the halved cadence. The R2.2 experiment shows the live pipeline stalls after 55 packets (the module waits for sideband feedback that never arrives), so that path is incomplete rather than merely wrong, and at 48 kHz/24 bit -- what the phone actually streams -- every host-side variant is silent with a verified correct wire format. The remaining difference is the link layer: the phone runs Qualcomm QHS, the AX210 can only do standard EDR.
…periment The phone's own SetConfiguration carries sampling frequency 0x40, which the plugin header defines as 44.1 kHz (0x10 is 48 kHz), so every test before this one ran at a rate the working source never uses. At 44.1 kHz the ordinary R2 form is 676 B / 25 ms / 217 kbps with frame header 83 00 c0 a1, and with the R2.2 capability advertised the codec element becomes byte-identical to the phone's while the encoder stays on the ordinary path. Adds preflight.sh (codec and sink gate), stream_check.py (wire shape plus a self-consistency check against the OTA period field), cie_check.py (AVDTP element comparison) and run_ad_test.sh (the whole sequence), and records the 44.1 kHz matrix in HANDOFF section 21.11.
The byte-identical configuration -- 44.1 kHz like the phone, the R2.2 capability advertised, the ordinary R2 stream, every element matching the phone's SetConfiguration -- is still silent while the measured rate matches (26.73 kB/s observed against 27.07 kB/s on the wire). Every host-controlled variable has now been tested and each one is silent, so the remaining difference is the link layer: the phone runs Qualcomm QHS, the AX210 can only do standard EDR. Recorded with the next options (a Qualcomm M.2 controller, the external FiiO BT11 that already works, or dropping AD for the codecs that do play) and the restored baseline.
STATUS.md now records the Ubertooth investigation (the phone's aptX Adaptive audio never appears as standard BR/EDR traffic; the piconet is visible only while the link is being set up, and a locked hop-follower then hears nothing for three minutes, which points at Qualcomm's proprietary 6 Mbps QHS PHY), the three operator-anchored experiments that establish it, the survey caveat that cost time, the completed 44.1 kHz / 48 kHz matrix that ends with a byte identical codec element and still no audio, the two methodology corrections (rate slip, and LOSSLESS=auto changing the encoder form), the new test gate and its tooling, and the next experiment.
Round summary: air capture, and the host side is now exhaustedHardware used
Tooling added (all under
|
| # | action | observation |
|---|---|---|
| A | phone Bluetooth off for 40 s | 0x40fddd appears only as it returns |
| B | headset power-cycled | 0x40fddd appears only at reconnection |
| C | hop-follow across a reconnect | follower locks (clock trim), hears 3 packets, then 180 s of silence |
During steady-state playback three further follow attempts (100 s, 60 s, 90 s,
one with a relaxed access-code tolerance) hear zero packets, following the
headset's own LAP hears zero, and a 180 s survey does not find the phone at all
while the same capture finds other piconets 57 to 68 times.
One caveat cost real time and is recorded in the report: in survey mode the
detection of any given piconet recurs about every 60 seconds, so absence from a
survey is not evidence of absence. The load-bearing observation is experiment C.
Qualcomm High Speed (QHS) is a proprietary PHY rated up to 6 Mbps where
standard BR/EDR offers 1, 2 or 3 Mbps, enabled only when both ends support it,
and it must not disturb the LMP state machine. A link that has switched to QHS
is not demodulable by an Ubertooth. That fits every observation: the
standard-mode exchange happens while the link is being set up, and the audio
then rides a PHY the receiver cannot see.
The host side is exhausted
Every host-controlled variable has now been put on the air behind the gate
above, and each one is silent:
| variable | values tested |
|---|---|
| sample rate | 48 kHz and 44.1 kHz (the rate the phone actually negotiates) |
| OTA version byte | 0xae (R2) and 0xaf (R2.2) |
| OTA packet type | 0x00 (ordinary R2 form) |
| AVDTP codec element | byte-identical to the phone's, R2.2 capability included |
| stream content | the phone's own captured PDUs replayed (section 6 of the report) |
| TTP time base | wall clock, audio clock, frozen |
With the R2.2 capability advertised and the rate pinned to 44.1 kHz, our
element towards the headset is d7 00 00 00 ad 00 40 02 50 64 64 64 ff ff 00 01 92 00 00 0f ... — the same 26 bytes the phone sends — and the stream is
the ordinary R2 form measured at 676 B / 25 ms / 217 kbps with packet type
0x00. The operator's own link monitor read 26.73 kB/s against the 27.07 kB/s
measured on the wire, so the data is genuinely transmitted. The sink stays
silent.
Two corrections worth recording
- Several earlier runs negotiated 48 kHz although the phone negotiates
44.1 kHz with this headset (0x40in its SET_CONFIG; the plugin header
definesAPTX_ADAPTIVE_SAMPLING_FREQ_44100 = 0x40,_48000 = 0x10). The
"48 kHz" in the phone's developer options is a preference; the stream
follows the content. - An attempt to test only the capability bit via
APTX_ADAPTIVE_LOSSLESS=auto
also pushed the encoder into its R2.2 form (packet type0xa0, 50 ms
interval, half the bitrate), so that run was inconclusive. The bridge now
prepares the element sent to the peer and the element handed to the encoder
from separate copies (APTX_ADAPTIVE_ADVERTISE_R2_2in
baizhu945/pipewire8976dcf).
Hypothesis and next experiment
The two sources that do play are Qualcomm devices; the one that does not is
not, and the air evidence says the phone's audio does not travel as standard
BR/EDR. The remaining difference is therefore the link mode, not anything the
host can put into the stream.
The discriminating experiment is a Qualcomm controller in the machine (an M.2
module, so still internal hardware). If the headset then plays, the link was
the gate and the encoder work here stands as it is; if it stays silent, the
decoder depends on something else again. Caveat: BlueZ would still not drive
Qualcomm's aptX offload, so the host would keep sending ordinary A2DP payload
over that link — which is exactly the variable under test.
Full details: research/aptx-adaptive-qemu/STATUS.md, sections 6 and 7.
Raw chronological log: research/aptx-adaptive-qemu/HANDOFF.md, section 21.
Unplugging the FiiO BT11 for 40 s during a 300 s survey identifies its link: piconet 0x08064F has 103 hits (peak -30 dBm) before the unplug, is absent for the whole window and returns afterwards. That is the link which plays audio on this headset, and it is ordinary BR/EDR, so a proprietary PHY is not a prerequisite. QHS is an optional Qualcomm-to-Qualcomm optimisation, used by the phone and apparently not by the BT11. What remains is the frame shape: both working sources send the Snapdragon Sound shape (packet type 5, 760-byte frames) while this host has never put a type-5 stream on the air continuously. Making the R2.2/Lossless path survive the live pipeline is therefore now the critical path rather than an optional feature.
Correction: the headset does not require Qualcomm QHSMy previous comment concluded that the MOMENTUM 5 only plays aptX Adaptive on The experimentThe FiiO BT11 (QCC5181) is a source that plays on this headset. I identified Before the unplug the same piconet had 103 hits with a peak RSSI of -30 dBm. What that meansThe headset plays on a standard EDR link, so a proprietary PHY is not a A methodological note, since it cost time: hop-following is far too sparse to What is left
Both working sources send the Snapdragon Sound frame shape (packet type 5, Next steps, in order:
|
The 'the BT11 link carries only ~5 packets/s, so its audio is elsewhere' claim was an uncalibrated extrapolation. Calibrating the single-channel counting method against this host's own link -- whose rate is known from its HCI capture to be 40 packets/s -- shows it recovers 1 to 2 percent of the real packets and that the figure depends on RSSI, so no link's throughput can be measured this way, and EDR frame lengths are unavailable to the sniffer as well. Whether the BT11's Lossless is real therefore remains unverified; the headset's own codec report through Sennheiser's app is the practical check. Also recorded: the LE Audio alternative is ruled out for the phone from its own btsnoop logs (zero HCI ISO packets, no LE connection or CIS events, but the Qualcomm A2DP offload commands are present), so its audio really does travel over BR/EDR.
Follow-up: the sniffer cannot measure a link's throughputA direct question came up that I could not answer from the air, and it is worth Question: is the BT11's aptX Lossless real, or is it sending a lower rate Method and calibration. I camped the Ubertooth on a single channel and
So the method recovers 1 to 2 percent of the real packets, and the figure Consequences. My earlier statement that the BT11's visible link carries only One thing the same round did settle. I also explored the opposite
|
The BT11 can be switched between Lossless and ordinary Adaptive and plays in both, which turns it into an experiment the fixed sources cannot provide. A piconet LAP is the master's address, so the BT11-to-headset link can only be 0x08064F (BT11 master) or 0xB7163B (headset master). In Lossless mode 0x08064F carries 103 hits/min and disappears exactly for a 40 s unplug; in ordinary Adaptive mode both LAPs are at zero across two 300 s surveys while the audio plays. The whole piconet is therefore on a proprietary PHY in Adaptive mode, not merely its media. That restores the earlier reading for Adaptive and adds the useful half: the BT11's Lossless runs over standard EDR and is decoded, so the headset does not need a Qualcomm controller, it needs a stream shape it recognises. The R2.2/R3 path is consequently the critical one, and the project's original non-Lossless scope conflicts with what this headset decodes on an Intel controller.
Final result: ordinary Adaptive is decoded only over a proprietary linkThis supersedes my comment of a few hours ago ("the BT11 plays over standard The cleaner experiment. The operator can switch the BT11 between Lossless
In Adaptive mode the piconet is invisible as a whole -- the ACL signalling of a
What follows. Ordinary aptX Adaptive appears to be decoded only when it Two measurement limits worth knowing, both recorded in |
The helper sent the encoder's bitrate-level map in bit/s while the module snaps those values to kbit/s thresholds, so all five levels collapsed into one. That failure mode is already documented in STATUS.md 4.3 but the fix had never landed in the tracked source; it is applied now. It does not change the observable stream, so the lossless path evidently does not consult that map. The round also established that ABR=0 is what makes the R2.2 path continuous (1237 packets over 61.8 s, 780-byte records, type 0xa0, version 0xaf) while that same stream is still silent and runs at 46.5 ms per packet -- the encoder consumes 2204 samples per frame, and neither the profile, the bitrate level, the MTU nor the PCM block size moves it. cap_ext_ver_num = 0, the one alternative period in the notes, kills the helper outright. Also records the build environment repair (the toolchain's host libraries had been garbage collected) and the probe extension that made the offline sweeps possible.
Round report: R2.2 / Lossless path on the reference module, and the air captureThis comment closes out the most recent rounds. The PR description has been Everything below was collected on one machine:
1. The host-side matrix, all silent
The 44.1 kHz run is worth singling out: the AVDTP element the headset accepted 2. The R2.2 / Lossless path is rate-bound in this build
The reason is visible in the sample accounting: the encoder consumes 2204 The one alternative period in the notes -- Both sources that actually play use roughly a 10 ms cadence. That is the 3. A real bug found here (fixed in this PR)
4. What a passive receiver addsAn Ubertooth One was used while the phone played to the same headset.
5. Corrections to earlier statements in this threadFor the record, since some of these were asserted before being tested:
6. Current statusThe code in this PR is correct as far as it is measurable: it negotiates the What is missing is a frame cadence this module build will not produce (2204 |
Correction to the previous comment: the BT11's ordinary-Adaptive link is not visibleIn the comment above I wrote that the FiiO BT11's piconet "is clearly visible in A piconet LAP is the master's address, so the BT11 -> headset link can only be
So the BT11 switches PHY with the mode: Lossless runs on a visible standard That makes the summary table the crux of the whole investigation:
Two readings fit this table and the data does not yet separate them: either the I also want to retract the framing in item 5.1 of that comment: the QHS Everything else in the previous comment stands: the host-side matrix (all |
stream_check.py unpacked the OTA header with period as a 16-bit field, so the packet-type byte was folded into it (0x64 0x01 read as 356) and every following field was reported one byte out of step: the value the tool printed as ptype was really channel_mode, and its declared-period figure was meaningless. Raw bytes from the captures settle it. The host's ordinary stream is 3c 0f 64 01 00 00 00 ae, i.e. period = 100 (25.00 ms) and packet type 1 (656-byte frame) -- byte-identical to the phone's header apart from TTP -- and the measured interval is 25.00 ms. Two earlier statements are therefore withdrawn: there is no helper unit-conversion bug, and the ordinary stream's declared cadence does match the wire. The R2.2 form's declared-versus-measured mismatch is real but different from what was reported (36.00 ms declared against 46.48 ms measured; the first attempt declared 28.00 ms). The tool now parses [TTP:2][period:1][ptype:1][channel:1][pad:2][version:1], reads the period in 0.25 ms units, defaults --expect-ptype to 1, and cross-checks the frame size against the packet-type table, which is the check that would have caught the original error. That also sharpens the R2.2 result: the Snapdragon Sound form really is emitted on the air continuously (version 0xaf, packet type 5, 760-byte frames, channel_mode 0xa0, 938 packets over 46.8 s) and the headset stays silent, so "only the stream shape is missing" is weaker than it looked. What remains is the version byte (R3 0xad against our R2.2 0xaf), the ~46 ms cadence this build is pinned to, and the link itself.
Correction: our own wire-shape tool misread the OTA header; two claims withdrawnThe commit that just landed ( The bug. Raw bytes, dumped without the tool:
Two facts confirm the byte reading with no unit assumption at all: the module's Withdrawn:
What this changes at the receiving end. The R2.2 stream is not merely close
The tool now parses the header correctly, reads the period in 0.25 ms units One methodological lesson, for anyone writing similar tooling: an off-by-one |
The earlier "ordinary Adaptive is invisible" result was measured against 0x08064F and 0xB7163B. Neither appears in Adaptive mode at all: the BT11's Adaptive piconet is 0x6A8FCC, established with four 130 s captures (0 hits in both BT11-absent runs, 74.3 and 59.1 hits/min at up to 0 dBm in both streaming runs) and then confirmed in a single capture around an operator power-cycle, where the headset's own address 0xB7163B shows up during paging and 0x6A8FCC bursts for 31 s (259 hits, 8.4/s, 45 channels) before going quiet for 118 s while the music keeps playing. So the old verdict holds for steady state but not for its reason or its scope: the connection setup is plainly visible on standard BR/EDR, and only the streaming link disappears. The phone behaves the same way, so this is a property of ordinary Adaptive rather than of the BT11 or the headset. The same round also records the noise floor of the method: between two captures in an identical state, 0x73BB13 went from 21.2 hits/min to 0, so a single contrast proves nothing.
Re-verification round: the BT11's ordinary-Adaptive link, with an anchored captureWe re-tested our own claim that "in ordinary Adaptive mode the BT11's piconet is Setup. Ubertooth One (firmware 2020-12-R1), a FiiO BT11 (QCC5181) streaming Four 130 s captures, identity anchored by the operator unplugging the dongle.
So the BT11's Adaptive piconet is One capture around an operator power-cycle shows the mechanism directly.
Revised conclusion. The old verdict was right about steady state and wrong
One loose end, stated plainly: the earlier Lossless-mode anchor was
|
…rotocol Same protocol as the Adaptive re-check: a 129 s steady-state capture and a 199 s capture around an operator power-cycle, with the host confirming that the dongle's UAC sink was running and streaming 44.1 kHz. The two modes are indistinguishable on the air. The master LAP is 0x6A8FCC in both, the piconet is visible for the ~31 s after reconnection (246 hits, 7.7/s, 39 channels, up to 0 dBm, with the headset's own address during paging two seconds earlier), steady state is silent in both (118 s in the same capture plus 129 s), and 0x08064F never appears. That makes the earlier Lossless anchor (0x08064F, 103 hits/min in steady state) unreproducible. Either it was a mis-attribution, or the dongle did not really run Lossless today; the air cannot tell which, and the host feeds it 24-bit audio while Lossless may want 16-bit input. If the first reading holds, then every audible source measured today keeps its steady-state link off standard BR/EDR, which strengthens the proprietary-PHY reading and weakens "only the stream shape is missing".
Lossless re-check: the two modes are indistinguishable on the airWe ran the same anchored protocol with the dongle switched to Lossless, to Steady state, 129 s, audio playing: Around an operator power-cycle, 199 s, one capture: Side by side with the Adaptive round, same operator, same protocol:
What this settles and what it does not.
Next, in order of value: (1) an external indication of the codec the dongle
|
Sections 21.13 and 21.15 still presented 0x08064F as the BT11's link and "Lossless runs on standard EDR" as settled. Both rest on an anchor that the controlled power-cycle re-tests could not reproduce, so each section now opens with a pointer to 21.18/21.19 and names what changed: the Adaptive piconet is 0x6A8FCC, the connection setup is visible on standard BR/EDR while steady state is not, and 0x6A8FCC was never a survey artefact.
…changes The operator confirms the BT11 was running Lossless during the re-check, which picks the first reading of the previous round: 0x08064F was a mis-attribution, 0x6A8FCC is the BT11 in both modes, and Lossless leaves standard BR/EDR once the media flows just as Adaptive does. A 304 s capture in Lossless with audio playing backs the negative with a liveness control: 535 packets from 32 piconets, several at 40-80 hits, and zero from 0x6A8FCC, 0x08064F or the headset. Cumulatively that is 551 s of Lossless and 488 s of Adaptive playback with the BT11's link absent. So every audible source measured here keeps its steady-state link off standard BR/EDR, while the one visible-link source -- this host -- is silent. That makes the proprietary-PHY requirement the only reading the evidence supports, and removes the observation the rival "it only needs the right stream shape" reading rested on (Lossless running on standard EDR). The R2.2 experiment points the same way independently: the Snapdragon Sound form went out continuously and the headset stayed silent.
Follow-up: the dongle was confirmed to be in Lossless, so the conclusion changesIn the previous comment I left two readings open. The operator has now confirmed The steady-state negative now has a liveness control. A 304 s capture in
Cumulatively that is 551 s of Lossless and 488 s of Adaptive playback during Why this matters for the conclusion. Three audible sources have now been
The honest summary for anyone reading this PR for the codec work: the host-side The two methodology lessons from this round are in the docs and worth repeating: |
b204de1 to
45c67f6
Compare
|
Closing this in favour of #16. The work here is unchanged -- the branch content is the same, with the commits #16 restates the whole thing once, in its final form: what the code is, what is Thanks to anyone who read through this thread. |
aptX Adaptive / Lossless research on a non-Qualcomm Linux host
First of all, thank you for
openaptxand for keeping the reverse-engineeredapt-X documentation and tooling public. The protocol notes in this repository
were the starting point for the work below, and being able to compare against a
readable implementation of the classic aptX framing saved a lot of guesswork.
This PR is a research report plus the diagnostic helper that drives the Qualcomm
aptX Adaptive encoder module (extracted from a phone firmware image) under
QEMU/Hexagon on a host without a Qualcomm Bluetooth controller.
What is in the PR
research/aptx-adaptive-qemu/STATUS.mdresearch/aptx-adaptive-qemu/README.mdresearch/aptx-adaptive-qemu/TOOLS.mdresearch/aptx-adaptive-qemu/aptx-lossless-helper.cresearch/aptx-adaptive-qemu/compat.csrc/aptx-adaptive-stream.c,include/aptxadaptive.htest/aptx-adaptive-stream.cThe PR also contains a merge of the current
master, which resolves the previousconflict in
test/aptx-lifecycle.cin favour of the upstream version (thisbranch had carried an earlier version of the same fix, which upstream merged as
PR #14).
Current status
The encoder path is solved and verified; the sink still does not decode.
Verified by measurement:
over Bluetooth (48 kHz stereo, 440.0 Hz FFT peak for a 440 Hz sine input, and
44.1 kHz stereo with a 440.1 Hz peak for a live capture);
SET_CONFIG, RTP header, OTA header and codec frames arebyte-identical to a working Android source's towards the same headphone;
PipeWire/BlueZ path reproduces them 266/266 byte for byte in an HCI
capture, and the stream is continuous (30.8 s, no gap above 60 ms).
Ruled out — every one of these was tested and the headphone stayed silent:
RTP(12) + OTA(8) + frame(656), thesame 676 bytes the phone sends);
40 02 … 92string);62.5 fps, 338 kbps);
0xaf) and the R2.2 packet shape (760-byte frames in780-byte payloads,
channel_mode 0xa0);"smartphone"), AVRCP playback state, encoder silence.
Protocol findings
[TTP:2][period:1][packet_type:1][channel_mode:1][00 00][version:1],with the period in 0.25 ms units and
packet_typeindexing the payload-sizetable
{348, 656, 140, 152, 560, 760, 960, 348, 980}. This is implemented insrc/aptx-adaptive-stream.cand covered by a test.[20, 19, 18, 17, 16, 15, 14, 13, 12] msand
[960, 912, 864, 816, 768, 720, 672, 624, 576]samples at 48 kHz.AVS_ENCODER_PARAM_ID_BIT_RATE_LEVEL_MAP(0x000132e1) takes kbit/svalues, not bit/s; the thresholds are 262/275/290/307/327/348/373/402/436.
encLevelHqStateMachineemits0xaf(R2.2) for levels 6..15 and
0xae(R2) below.(
b0= 96 kHz mono,f0= 96 kHz stereo,c0= 44.1 kHz,d0= 48 kHz).even though the inner codec is configured for 14 ms / 364 kbps; the CAPI
profile, MTU, sink buffers, IMCL bitrate level and input block size do not
change that.
The module's own diagnostics (
HAP_debug_v2) are what made this visible; theyare hidden behind an environment check in
compat.c, andTOOLS.mddocumentsthe one-line change that re-enables them.
Update 2026-09-12: what the air capture and the R2.2 work established
The sections above are kept as the record of the earlier rounds. Later rounds
changed the conclusions; they are reported in full in the PR comments, with the
raw chronology in
HANDOFF.mdsection 21.The host side is exhausted. Every host-controlled variable has now been put
on the air behind a mandatory gate (the card must really be on
aptx_adaptiveand the headset must be the default sink, and the wire shape isverified from an HCI capture afterwards):
0xae(R2) and0xaf(R2.2)0x01(ordinary R2, 656-byte frame) and0x05(R2.2, 760-byte frame)The R2.2/Lossless path is rate-bound on this module build. With
lossless=force, a 16-bit source word and ABR off, the path produces acontinuous stream of 780-byte records -- version
0xaf, packet type0x05,760-byte frames,
channel_mode 0xa0, i.e. the Snapdragon Sound form itself(1237 packets over 61.8 s in the live run; 938 over 46.8 s in the capture that
was re-measured) -- and it is still silent. The encoder consumes
2204 input samples per frame, about 50 ms at 44.1 kHz (46.5 ms measured on
the wire, ~125 kbps), while both sources that do play run at roughly a 10 ms
cadence. Neither the encoder profile (nine values), the bitrate level (0..8),
the MTU (700..3000) nor the PCM block size moves it, and the one alternative
period recorded in the notes (
cap_ext_ver_num = 0, "16 ms") kills the helperprocess outright: the OTA fields change as documented and the stream dies after
24 packets.
A real bug was found on the way:
configure_bitrate_map()sent the map inbit/s while the module snaps those values to the kbit/s thresholds
262/275/290/307/327/348/373/402/436, which collapses all five levels into one.
The failure mode is documented in
STATUS.md4.3 but the fix had never landedin the tracked source; it is applied now. It does not change the observable
stream, so the Lossless path evidently does not consult that map -- reported for
completeness rather than as the answer.
A correction to our own instrumentation, found while preparing this update.
research/aptx-adaptive-qemu/stream_check.pyunpacked the OTA header withperiodas a 16-bit field. The header is[TTP:2][period:1][packet_type:1][channel_mode:1][pad:2][version:1], so thatfolded the packet-type byte into the period (
0x64 0x01read as356) andshifted every following field by one byte: what the tool printed as
ptypewasreally
channel_mode, and its declared-period figure was meaningless. Raw bytessettle it -- the host's own ordinary stream is
3c 0f 64 01 00 00 00 aeagainst the phone's
... 64 01 00 00 00 ae, i.e.period = 100(25.00 ms) andpacket_type = 1(656-byte frame) on both sides, and the measured interval is25.00 ms. Two earlier statements of ours are therefore withdrawn: there is no
helper unit-conversion bug, and the ordinary stream's declared cadence matches
the wire exactly. The tool is fixed and now cross-checks the frame size against
the packet-type table, which is what would have caught it.
HANDOFF.md21.17and
STATUS.md5.12 record it in full.What the air capture adds. An Ubertooth One was used as a passive receiver
while the phone played to the same headset:
hop-following locks onto its clock), so "aptX Adaptive cannot be sniffed" is
not true;
seen only while the link is being set up, and a follower locked onto it then
hears nothing for three minutes. The phone's own HCI log rules out LE Audio
(zero ISO packets, no LE connection or CIS events, while the Qualcomm A2DP
offload commands are all present), so its audio really does travel over
BR/EDR -- on a link a standard receiver cannot see;
plays in both modes and can be power-cycled on demand -- and it corrects us
twice. Its piconet is not
0x08064f/0xb7163bat all (neither ever appearsin either mode) but
0x6a8fcc: it is plainly visible while the link isbeing established -- 259 hits in 31 s in Adaptive and 246 in 31 s in
Lossless, 8.4 and 7.7 hits/s over 45 and 39 channels, up to 0 dBm, with the
headset's own address
0xb7163bappearing for two seconds just before, whichis the paging phase -- and then invisible while it streams: 0 hits over
the following 118 s of the same capture and over further captures, with audio
audibly playing. Four 130 s captures around an operator-anchored power cycle
pin the identity down (0 hits in both dongle-absent runs; 74.3 and 59.1
hits/min in both streaming runs), and a 304 s Lossless capture backs the
steady-state negative with a liveness control: 535 packets from 32 piconets,
several at 40-80 hits, and zero from the BT11,
0x08064for the headset.Cumulatively, 551 s of Lossless and 488 s of Adaptive playback went by without
the BT11's link ever appearing. The earlier "Lossless stays visible in steady
state (
0x08064f, 103 hits/min)" is therefore withdrawn: with the operatorconfirming the dongle really ran Lossless, that anchor was a mis-attribution,
and both modes leave standard BR/EDR once media flows.
Two measurement limits are worth recording for anyone repeating this: the
sniffer cannot measure throughput (single-channel counting recovers 1 to 2
percent of the real packets, calibrated against this host's own link at a known
40 packets/s), and a survey detects a low-traffic piconet only about once a
minute, so such LAPs show spurious 25 to 78 s gaps. What settled the link
questions was an operator-anchored power-cycle test together with the fact that
a piconet LAP is the master's address, plus the paged device's own address
showing up during the paging phase.
Where that leaves the question. The five-source table is the crux:
Host-side configuration is no longer the difference: the same host, on the same
link, plays aptX HD normally. Every audible source measured here -- the
phone's Adaptive, the BT11's Adaptive and the BT11's Lossless -- keeps its
steady-state link off standard BR/EDR, and the one source whose Adaptive link is
visible while it plays (this host) is silent. That leaves the proprietary-PHY
requirement as the reading the evidence supports, and it removes the
observation the rival reading rested on: the claim that Lossless runs over
standard EDR.
The negative is not merely a missing observation. The Snapdragon Sound form was
emitted continuously on standard EDR (packet type 5, 760-byte frames,
0xaf,channel_mode 0xa0) and the headset stayed silent, so producing the rightstream is not sufficient on this controller. What is left is a difference this
host cannot remove:
receiver cannot see, which is exactly what an Intel controller provides no
way to join;
0xad)while this module emits R2.2 (
0xaf) --APTX_OTA_VERSIONcan force thebyte, but the state machine's acceptance of R3 without Qualcomm sideband
feedback is untested, and by point 1 it would not be sufficient on its own;
(~46 ms here), ABR on stalls the pipeline after 55 packets, and the one
alternative period in the notes kills the helper outright.
Any pointer on a controller that can drive the proprietary high-speed link, on
a module build that emits short (5-10 ms) adaptive frames, or on whether R3
(
0xad) is reachable without Qualcomm sideband feedback, would be welcome. Wewould rather be told the last one is a dead end than keep guessing.