Skip to content

Fix SCO over USB: align packets to the alternate setting, select it per SCO link - #1011

Merged
zxzxwu merged 4 commits into
google:mainfrom
zxzxwu:fix-usb-sco-out
Oct 22, 2026
Merged

zxzxwu merged 4 commits into
google:mainfrom
zxzxwu:fix-usb-sco-out

Conversation

@zxzxwu

@zxzxwu zxzxwu commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fix SCO over USB: align packets to the alternate setting, select it per SCO link

Problems

  1. Packets not aligned to the alternate setting. Each HCI SCO packet sent over the USB isochronous endpoint spans 3 USB frames, with the HCI header at the start of the first one (Core Spec Vol 4, Part B, 2.1.1): with alternate setting 2 (17-byte max packet size) that is 3 + 48 bytes, with alternate setting 1 (9 bytes) 3 + 24. UsbPacketSink sent each HCI SCO packet from the host as-is (e.g. 120 bytes of CVSD, or a 60-byte mSBC/LC3 frame), split into max-packet-size chunks. Intel BE200 drops such packets, so the remote side received silence.
  2. Gaps between transfers. Only one isochronous transfer at a time, shared with ACL data and commands. Even with aligned packets, SCO data went out at ~91% of the link rate, which distorted the audio (a 1 kHz tone was received at 933–990 Hz).
  3. sco=0 picked the alternate setting at open time. The setting with the largest max packet size was selected before any SCO link existed (alternate setting 6 on BE200). BE200 then neither sends nor receives any SCO data, in either direction.

Fix

  • Send SCO data on its own path, separate from ACL data and commands, with several (NUMBER_OF_SCO_OUT_TRANSFERS = 4) isochronous OUT transfers in flight.

  • Re-packetize the data of each connection handle into HCI SCO packets that exactly fill 3 isochronous packets (3 * max_packet_size - 3 data bytes), keeping the remainder for the next packet.

  • With alternate setting 6 (Core Spec Vol 4, Part B, Table 2.1), each HCI SCO packet (3 + 60 bytes, i.e. one 7.5 ms mSBC / LC3 SWB frame) is sent in a single USB frame, back to back (BE200 does not accept empty frames in between, like BTUSB_ALT6_CONTINUOUS_TX in Linux). Codec frames are then aligned to HCI SCO packets in both directions, so the host doesn't need to reassemble them.

  • With sco=0, select the alternate setting like Linux btusb: alternate setting 0 while there is no SCO link, then, when SCO links are established or closed (before the host gets the event):

    • transparent air mode: 6, or 1 if not available,
    • otherwise, 16-bit samples on HCI: 2, 4 or 5 for 1, 2 or 3 links; 8-bit samples: the number of links. The sample size is tracked from HCI_Write_Voice_Setting and HCI_Enhanced_Setup/Accept_Synchronous_Connection.

    An explicit alternate setting (sco=N) is still used as-is.

  • Drop empty SCO packets received around alternate setting changes.

  • find_endpoints() now returns the isochronous endpoints of the selected alternate setting, instead of those of the last setting it iterated over.

Testing

  • Unit tests (18 passed in tests/transport/usb_test.py, 1046 passed for the whole suite): re-packetization for alternate settings 1, 2 and 6 with multiple transfers in flight; alternate setting selection for transparent (with / without alternate setting 6) and CVSD (1–2 links, 16- and 8-bit samples); empty SCO packets dropped; find_endpoints() returning the selected setting's endpoints. The new tests fail on main (9 failures when the test file is run against the unfixed tree), so they do exercise the new behaviour.

  • Intel BE200 as HFP Hands-Free (usb:<path>+sco=0), Pixel phone as Audio Gateway, 1 kHz tone in both directions, unmodified test:

    Codec Selected alternate setting Packets from BE200 Phone → BE200 BE200 → phone
    CVSD 2 48 bytes 999.83 Hz 999.30 Hz
    mSBC 6 60 bytes (one frame each) 999.65 Hz 1005.24 Hz
    LC3 SWB 6 60 bytes (one frame each) 1000.09 Hz 995.98 Hz

    Before this change, with sco=0: no SCO data in either direction. With an explicit sco=1 / sco=2, the phone received silence.

  • Realtek (2x ASUS BT600, 0b05:1d70, usb:<path>+sco=0), the two dongles connected over the USB transport, legacy Setup Synchronous Connection only (this controller does not implement the enhanced commands). Every isochronous transfer bumble submitted was recorded through libusb, so the framing below is what actually went on the wire; the payload was a 1 kHz 16-bit PCM tone, and a self-identifying payload pattern for the byte-exactness check:

    Codec / air mode Selected alternate setting Isochronous OUT framing SCO data received
    transparent (same shape as mSBC / LC3 SWB: 3 + 60 bytes) 6 one 63-byte frame per SCO packet, back to back, 4 in flight 60-byte packets at the link rate in both directions; 320/320 packets byte-exact and in order, level preserved (rx RMS ~16.1k of 18.5k sent)
    CVSD, 16-bit samples 2 3 × 17 bytes per packet (48 data bytes) none reaches the host (see note below)
    CVSD, 8-bit samples 1 3 × 9 bytes per packet (24 data bytes) none

    The transparent path fixes the same failure as on BE200: with sco=0, main selects setting 6 before any SCO link exists and delivers no SCO data at all on this dongle, while with this change the setting is chosen once the link is established and the audio arrives intact. Disconnecting and re-establishing a link works as well (the setting goes back to 0, then to 6 for the new link).

    Notes from that run:

    • With an explicit sco=N on this dongle, no SCO data is received in either direction, on main and with this change: the setting has to be chosen after the link is established, which is now what sco=0 does.
    • The CVSD paths could not be exercised on this controller, on main or here: the negotiated link has a 60-byte air payload, while alternate setting 2 can only carry 48 bytes of HCI data per packet (and selecting 6 before the link delivers nothing either). main delivers no SCO data at all on this dongle, so this is not a regression, but CVSD stays unverified on Realtek.
    • A synthetic 1-byte isochronous endpoint still makes sco_packet_data_size 0 and on_sco_packet() loop forever, because configure_sco() only checks getMaxPacketSize() > 0 (unreachable on the controllers tested, whose settings are 9–63 bytes). The guard suggested in review (max_packet_size * USB_FRAMES_PER_SCO_PACKET > HCI_SCO_HEADER_SIZE) closes that hole.

Each HCI SCO packet sent over the USB isochronous endpoint spans 3 USB
frames, with the HCI header at the start of the first one (Core Spec Vol 4,
Part B, 2.1.1), e.g. 48 data bytes per packet with alternate setting 2. The
USB sink sent each HCI SCO packet as-is, split into max-packet-size chunks,
and only one isochronous transfer at a time, shared with ACL data and
commands:
- packets that don't fill exactly 3 USB frames are dropped by controllers
  such as Intel BE200, so nothing was heard on the remote side,
- one transfer at a time leaves gaps between transfers, so SCO data was
  sent at ~91% of the link rate, which distorts the audio.

Send SCO data on its own path instead: re-packetize the data of each
connection into HCI SCO packets that exactly fill 3 isochronous packets
(keeping the remainder for the next packet), and keep several isochronous
OUT transfers in flight.

Also make find_endpoints() return the isochronous endpoints of the selected
alternate setting rather than those of the last setting it iterated over.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors SCO data transmission over USB isochronous endpoints in bumble/transport/usb.py by implementing a multi-transfer queue to keep transfers in flight and introducing a re-packetization mechanism for SCO data. It also adds comprehensive unit tests in tests/transport/usb_test.py. The review feedback identifies a potential infinite loop in on_sco_packet if the calculated packet data size is zero, and suggests reducing the log level of cancelled transfers during shutdown from warning to debug to prevent noisy logs.

Comment thread bumble/transport/usb.py Resolved
Comment thread bumble/transport/usb.py Resolved
@zxzxwu zxzxwu changed the title Fix SCO over USB: align packets to the alternate setting, keep transfers in flight Fix SCO over USB: align packets to the alternate setting, select it per SCO link Oct 16, 2026
…shed

With sco=0, the alternate setting with the largest max packet size was
selected when the transport was opened (alternate setting 6 on Intel BE200),
before any SCO link existed. BE200 then neither sends nor receives any SCO
data.

Like Linux btusb, keep alternate setting 0 while there is no SCO link, and
select the alternate setting when SCO links are established or closed,
before the host gets the event (Core Spec Vol 4, Part B, Table 2.1):
- transparent air mode (mSBC, LC3 SWB): alternate setting 6, or 1 if not
  available,
- otherwise, 16-bit samples on HCI: 2, 4 or 5 for 1, 2 or 3 links; 8-bit
  samples: the number of links. The sample size is tracked from
  HCI_Write_Voice_Setting and HCI_Enhanced_Setup/Accept_Synchronous_Connection.

With alternate setting 6, each HCI SCO packet (3 + 60 bytes, one 7.5 ms codec
frame) is sent in a single USB frame, back to back, so codec frames are
aligned to HCI SCO packets in both directions. Empty SCO packets received
around alternate setting changes are dropped.

An explicit alternate setting (sco=N) is still used as-is.
- Don't set up SCO OUT transfers when the max packet size leaves no room for
  SCO data (or more than 255 bytes), which made on_sco_packet() loop forever.
- Log cancelled SCO OUT transfers (transport closed, alternate setting
  changed) at debug level, and don't reuse them: only transfers of the
  current alternate setting are returned to the free queue.
- Add ScoEndpoints (frozen dataclass) for the isochronous endpoints of a SCO
  alternate setting, and a _find_isochronous_endpoints() helper shared by
  find_endpoints() and find_sco_settings(), using max()/comprehensions
  instead of nested loops.
- Select the alternate setting with next(), and find the configuration
  with next() in open_usb_transport().
- Parse the HCI events and commands used for the dynamic alternate setting
  with HCI_Event/HCI_Command.from_bytes() and match statements, instead of
  hard-coded offsets.
- Factor out transfer cancellation and queue draining, and reuse
  start_sco() in UsbPacketSource.start().
- Annotate types (usb1 classes, queues, tasks) of the code added for SCO.
@zxzxwu
zxzxwu merged commit 2e9aa72 into google:main Oct 22, 2026
34 checks passed
@zxzxwu
zxzxwu deleted the fix-usb-sco-out branch October 22, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants