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).
|
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
|
|
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.
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. |
…ESP32-C5 raw-IQ receiver
…etract the RMS lead
|
Following the ESP32-as-SDR suggestion in this thread, I ran the measurement with an Method. Burst captures of 819 us at 20 MSa/s with an 11 MHz analog filter and 2.4 GHz, phone at 30-50 cm, only the codec changed
The Adaptive passes scatter and overlap the silence passes, which is what a null One pitfall worth flagging. Raw RMS includes the front end's DC/leakage term,
The mean power spectrum matters here: a wideband emission raises it just as a 5 GHz. This branch needed its own control, because the narrowband detector is On transmitting. ESP-SDR is receive-only by design ("we are not providing a Limits. The burst detector is deliberately narrowband (the mean power spectrum Raw data, scripts and the write-ups: research/aptx-adaptive-qemu/STATUS.md sections |
… the sink does decode
…hypothesis still untested
…pothesis has no lever
|
Thanks for the recipe. The source-type observation is the most useful lead this Setup: Sennheiser MOMENTUM 5 (an R2.2 sink) from an Intel AX210, stock BlueZ Your three points, as environment switches (all off by default, so the deployed
Your point 3 we already satisfied: our configuration carries 0x64 in the TTP What we measured (btmon, counting
So source type 2 does get the sink decoding: we hear short, meaningless pops, I also tried to change the packet period, since your HQ example is 672 samples
So the period hypothesis is untested rather than disproved: in this build the Two questions if you still have your notes. What did your Px7 advertise for Raw data and write-ups: research/aptx-adaptive-qemu/STATUS.md sections 5, 6 and |
|
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 |
Thanks for your contribution so much! I have successfully got aptx adaptive on my computor. |
|
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 Also confirmed on the sink side: Bose QC Ultra 1st gen advertises Practical decode recipe and the full evidence table are in #8 (comment). 48 kHz LL + HQ is what ships from our side. |
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
src/aptx-adaptive-stream.c,include/aptxadaptive.htest/aptx-adaptive-stream.cresearch/aptx-adaptive-qemu/aptx-lossless-helper.cresearch/aptx-adaptive-qemu/compat.cresearch/aptx-adaptive-qemu/preflight.shaptx_adaptiveand the headset is the default sinkresearch/aptx-adaptive-qemu/stream_check.pyresearch/aptx-adaptive-qemu/cie_check.pyresearch/aptx-adaptive-qemu/run_ad_test.shresearch/aptx-adaptive-qemu/STATUS.mdresearch/aptx-adaptive-qemu/HANDOFF.mdresearch/aptx-adaptive-qemu/README.md,TOOLS.mdThe 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
stereo, 440.0 Hz FFT peak for a 440 Hz input; 44.1 kHz stereo, 440.1 Hz peak
for a live capture);
SET_CONFIGis byte-identical to a working Android source towards the sameheadset:
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;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 phonesends, apart from TTP -- and the measured packet interval is 25.00 ms;
byte, continuously (30.8 s, no gap above 60 ms);
55 kB/s).
Protocol findings
[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}.[20..12] msand[960..576]samplesat 48 kHz, indexed by quality level.
AVS_ENCODER_PARAM_ID_BIT_RATE_LEVEL_MAPtakes kbit/s, snapped to262/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.
0xaf(R2.2), belowthat
0xae(R2).c0=44.1 kHz stereo,
d0= 48 kHz stereo, ...).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.pyunpacked the OTA header withperiodas a 16-bit field, whichfolded the packet-type byte into it (
0x64 0x01read as356) and reportedevery following field one byte out of step: the value it printed as
ptypewasreally
channel_mode. That produced a phantom "declared 23.73 ms against ameasured 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,
0xaeand0xaf, the ordinary R2form 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:
The BT11 measurements are operator-anchored. Four 130 s captures around a power
cycle give 0 hits for
0x6A8FCCin both dongle-absent runs and 74.3 and 59.1hits/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 continuouslyand 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
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) acontroller 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.