research: aptX Adaptive/Lossless status on a non-Qualcomm Linux host - #15
research: aptX Adaptive/Lossless status on a non-Qualcomm Linux host#15baizhu945 wants to merge 47 commits into
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).
…nalysed - Faking the adapter Class of Device as a smartphone does not help (reverted). - The live stream decodes to a real 440 Hz tone (RMS -31 dBFS), so we are not sending silence; there is a ~24 dB gain difference vs the source to chase. - New phone logs (pulled over adb) show the phone's own aptX Adaptive source capability (f0 3e ... 0x0f000017) and that it RECONFIGURES the headphone stream to 48 kHz stereo mid-session. - The module's own TTP is effectively frozen (4 units per 25 ms); the helper's host-clock override is correct. - The phone's A2DP sink profile is disabled and cannot be enabled without root, so the planned 'phone as sink' validation is not available.
The branch carried its own 'Fix backend destroy guards' and 'Fix pkg-config paths' work, which upstream has since merged as PR arkq#13 and arkq#14. The merge resolves test/aptx-lifecycle.c in favour of the upstream version (SPDX header, no assert) and removes the duplicated aptx-lifecycle-test target that the auto-merge produced in test/CMakeLists.txt.
The previous report pointed at the encoder payload as the likely cause. That is now disproven: the host's SET_CONFIG, RTP header, OTA header and codec frames are byte-identical to a working Android source, and replaying the source's own captured frames is silent as well. STATUS.md now records: - the OTA header layout, the payload-size table and the module's period/PCM tables (extracted from the module image); - that the OTA version byte is driven by the encoder level (>= 6 -> 0xaf) and that the bitrate-level map values are kbit/s, not bit/s; - the R2.2 wrapper pinning the process period to 25 ms / 656 bytes while the inner codec is configured for 14 ms / 364 kbps; - the eight host-side bugs found and fixed; - the twelve hypotheses that have been ruled out by measurement, each with the experiment that ruled it out; - the remaining hypothesis (controller/link layer or sink firmware) and the cheapest experiment that would discriminate between them; - the tooling and a reproduction recipe. Also add SPDX headers to the new sources, matching the upstream style, and refresh the two README statements that no longer matched the measurements.
- Add a .markdownlintignore for the two raw working logs (HANDOFF.md and gen-cntr-process-loop.md); STATUS.md, README.md and TOOLS.md are now lint-clean under the repository's default markdownlint rules (line length, fence languages, blank lines around headings/lists/fences). - Add the research directory to REUSE.toml so every file carries copyright and license information; 'reuse lint' now reports 95/95.
|
Updated with a further round of measurements. Two things changed materially
The remaining gap is outside what the host can observe: the sink accepts the |
… kHz The early sections of the handoff report read the A2DP sampling-frequency field through Qualcomm's bthost_ipc.h DSP payload numbering and concluded that 0x40 meant 192 kHz. That is wrong: 0x40 is 44.1 kHz and 0x10 is 48 kHz, as the user's own measurements of the phone confirm. Add an errata banner to the affected sections, mark the withdrawn reasoning, and record the fresh capture where the phone reconfigures an open aptX Adaptive stream to 48 kHz for 48 kHz/24-bit content and back to 44.1 kHz for 44.1 kHz content. Also record that the phone offloads the media path entirely (no AD frames in its HCI log) and that the sink never queries the source capabilities, which rules our advertised capability blob out as a cause of the silence.
…n chain The module exports a mode-2 CAPI entry point that the helper never used; driving it instead of the R2/R2.2 wrapper produces exactly the same 25 ms / 656 byte packets, so the fixed period is not a property of the wrapper. The module logs now show that the inner encoder selects 12 ms and 437 kbps (576 sample PCM interval) while PROCESS writes period 0x64, and 437 kbps x 12 ms / 8 = 656 bytes explains the payload size. Record that, together with the conclusion that the remaining difference is at the source-device level.
…packet shape The FIIO BT11 exposes HID plus UAC audio only, with no Wireless class interface, so it cannot serve as an HCI controller for this host. While verifying that, the probe runs showed that the earlier lossless attempts never actually reached the 2.2 shape: the helper silently downgrades a forced lossless mode unless APTX_ADAPTIVE_ALLOW_UNSTABLE_LOSSLESS is set, and the 768 byte / packet type 5 shape additionally needs a 16 bit source word size hint. With both fixed the bridge produces genuine 768 byte packets with the R3 frame header, and version 0xad or 0xaf via a new override. Record that, the two static-properties / profile bugs found in the R3 entry, and the live test plan.
Record that only the PipeWire plugin and the openaptx library are Nix built while the Hexagon helper is a runtime file, which is why transport schemes can be switched with a helper rebuild plus a user systemd drop-in. Also record the plain (non-Lossless) sweep: config bytes, RTP header, frame format, cadence/rate (217 kbps, matching the phone) and the OTA header all match the working source, and moving TTP into the audio clock domain still leaves the sink silent, so the remaining difference is at the controller/air interface.
Timeline the HCI commands around the phone's aptX Adaptive stream setup: Qualcomm vendor command 0xFC0A carries the complete A2DP codec configuration (vendor id, codec id, sampling frequency, channel mode and the whole parameter block, byte for byte the AVDTP SetConfiguration value) to the controller at the same millisecond as Start, together with 0xFD57 / 0xFD59 and the link policy / sniff changes. That is the A2DP offload handoff: the controller encodes and schedules the stream, which is also why the phone's own log never shows media. Record it as the direct evidence for why a host-side bridge cannot reproduce the working setup, and correct the hardware advice accordingly.
Replaying Android's A2DP link settings (BR-only packet type 0xcc18 plus link policy 0x0005) on our link leaves the stream healthy but the sink silent, so the link-layer configuration is not the gate either. Add air_analyse.py, which summarises an Ubertooth BR/EDR capture into the comparison the HCI log cannot provide (LMP opcodes, air packet types, direction split), and document the three-capture plan together with the LAP/UAP values needed to follow each piconet.
…code review The deployed helper was built from a /tmp source that no longer existed, so the runtime was not reproducible. Port the env-gated switches (APTX_FORCE_BITS, APTX_TTP_MODE/OFFSET, APTX_OTA_VERSION[_FILE]) into the tracked helper, fix the R3 static-properties lookup that made aptx_adaptive3_enc_init() fail with EIO, reject unknown OTA version bytes, read the version file only from a configured path, and default TTP to the audio-clock domain. Add CODE-REVIEW.md with the full bug list, the fixes, the residual risks and the verification performed, plus link_mode.sh for the Android link-setting comparison.
…mode tool The previous commit was created with -am, which does not stage untracked files; add the three new documents/tools that belong with it.
The plugin fix is deployed from fork rev b97eae8 (verified through the loaded .so path), the helper is rebuilt from the tracked source, the NixOS module no longer injects the three harmful leftovers and no longer exports the encoder environment system-wide, and the stale values had to be cleared from the user manager explicitly. End-to-end the stream is back to the reference shape with the OTA header intact.
CI runs markdownlint-cli 0.43.0, whose defaults differ from the newer local version: a leading HTML comment and wide tables are rejected there. Rewrite the document with an H1 as the first line and no tables, keeping every line at or below 80 columns.
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 R2.2 packet shape (768-byte packets,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.
Remaining gap
Everything observable from the host has been aligned with a working source, so
the remaining difference is either the controller/over-the-air behaviour (the
phone offloads aptX to a Qualcomm controller and the FiiO BT11 runs a QCC5181;
this host sends plain L2CAP ACL packets over an Intel AX210) or a dependence of
the sink firmware on a licensed Qualcomm source. The sink sends no reverse
ACL data at all during a 30 s stream, so it exposes no feedback or error path.
I would be very grateful for any pointer on the last point, and of course for
any correction to the protocol conclusions above. The negative results are
included deliberately: they should save anyone else from repeating them.
Thanks again for the project.