ToothSpin

Technical overview

How audio gets from a phone into Music Assistant.

Alpha. Details below may change between releases.

Pipeline

Phone (A2DP source)
  ─Bluetooth─> hci0 ─> bluetoothd (host)
  ─D-Bus─>     PipeWire bluez5, A2DP sink (container)
  ─pw-record, 48 kHz s16─> capture ─> aiosendspin SourceCapture
  ─Sendspin source@v1 over WebSocket─> Music Assistant sendspin_source

Sendspin’s source role lets clients capture audio locally and stream it to a server. Music Assistant’s sendspin_source provider turns every paired source into a Live Input, and aiosendspin does the encoding and streaming. ToothSpin is the capture side: an A2DP sink.

Host vs. container

Only bluetoothd runs on the host: it needs the real HCI socket, which a rootless container can’t open. Everything else runs in one rootless container: the service plus its own PipeWire and WirePlumber, on Alpine. The container talks to bluetoothd over the bind-mounted system D-Bus socket. PipeWire registers the A2DP sink endpoints there and gets each phone’s transport as a file descriptor, so the container never opens a raw Bluetooth socket.

Bluetooth side

Music Assistant side

Volume

While a phone streams, its volume and the volume of the speaker (or group) it plays on are kept in sync both ways. ToothSpin polls the phone’s AVRCP absolute volume (MediaTransport1.Volume, 0–127) and Music Assistant’s player or group volume. When a phone starts streaming, the speaker wins: its volume is written to the phone (BlueZ forwards that to the phone while the transport is active), so a phone left at full volume doesn’t blast the room. After that, whichever side changes is copied to the other, ignoring the echo of its own last write for a few seconds.

Running confined

As a Home Assistant App

The App (toothspin/ in the repository) runs the same multi-arch image. Its options arrive as /data/options.json and are mapped onto the same environment variables the plain container uses. It reaches bluetoothd via host_dbus and runs without the host network: it reaches the Music Assistant App, which runs on the host network, at the host’s address on Home Assistant’s internal network (172.30.32.1). An optional port mapping in the App’s Network section serves the web UI to the LAN. Images are built and scanned in CI; each release ships SPDX and CycloneDX SBOMs.

The App asks Home Assistant’s Bluetooth integration (WebSocket config_entries/get and config_entries/flow/progress via homeassistant_api) which adapters it uses, reading each adapter’s address from its config entry title. The integration only scans for Bluetooth Low Energy, so sharing an adapter works, but ToothSpin recommends a dongle of its own when it shares one; BT_ADAPTER=auto prefers a free one. Setup problems are shown in the web UI and the log; blocking ones also as a persistent notification, re-checked until solved. On its first start the App puts itself in the sidebar (ingress_panel, once, so turning it off sticks).

Pairing with Music Assistant automatically

Pairing a phone over Bluetooth already is the user’s consent. With a token for Music Assistant’s API, ToothSpin answers MA’s “Pair the device” setup flow itself (config/players/setup, config/flows/submit): the phone’s source accepts a fresh pre-shared key, and MA receives it as a pairing token. MA offers token pairing only to devices without a PIN method, so with a token the sources offer just that. Removing a phone says goodbye for good (MA keeps devices that only restart) and then removes it from MA.

Pairing with a PIN

BT_PIN lets phones pair at any time with a fixed PIN instead of only in the pairing window. That’s legacy PIN pairing: phones only use it when the adapter has Secure Simple Pairing off, which only the host can switch (btmgmt ssp off after bluetoothd starts). Phones paired before keep working. Legacy pairing is weaker than SSP and has no retry limit, so the PIN must have at least 6 digits.

Standalone mode

As an alternative to Music Assistant, ToothSpin can run its own Sendspin server (MODE=standalone), built on aiosendspin’s server side, the library Music Assistant uses too:

Versions

source@v1 is young and still changing. ToothSpin pins aiosendspin to the version the stable Music Assistant ships: 10.2.1, as in 2.10.8 (2.10.7 has 10.2.0, which works the same).