Skip to content

Send DSD files to Sendspin players as DoP (DSD over PCM) - #7429

Draft
manuel-alcocer wants to merge 4 commits into
music-assistant:devfrom
manuel-alcocer:feat/sendspin-dop
Draft

manuel-alcocer wants to merge 4 commits into
music-assistant:devfrom
manuel-alcocer:feat/sendspin-dop

Conversation

@manuel-alcocer

@manuel-alcocer manuel-alcocer commented Nov 7, 2026 •

Copy link
Copy Markdown

What does this implement/fix?

I have a DAC that plays DSD natively and a collection of DSD files (.dsf), mostly SACD and vinyl transfers. When I play them on a Sendspin player today, Music Assistant decodes them to PCM, because Sendspin only carries PCM, FLAC and Opus. So the DAC never gets to play the DSD itself.

DoP (DSD over PCM) is the usual way around this: the DSD bits are packed into 24-bit PCM frames with a marker byte, and a DAC that understands DoP recognises the markers and switches to native DSD playback. The catch is that it only works if every sample reaches the DAC exactly as it was sent. Any resampling, volume change, normalization or crossfade turns it into loud noise.

This PR adds an advanced, per-player option for Sendspin players, "Send DSD as DoP". It is off by default and only shown when the format is set to automatic. When it is on and the player is playing on its own (not in a group):

  • A flow that starts with a local DSF file runs at that file's DoP rate: 176.4 kHz for DSD64, 352.8 kHz for DSD128. The internal format stays F32, which holds every 24-bit DoP word exactly.
  • If the next DSD track needs a different DoP rate, the flow restarts, just like it already does for sample rate changes.
  • DSD tracks are read straight from the file and packed as DoP. They skip the audio buffer, volume normalization, fades and the crossfade prefetch, because all of those change the samples.
  • No audio buffer is created for a track that plays as DoP, neither when playback starts nor when the next track is warmed up. The buffer would hold decoded PCM that DoP never reads, and for DSD that is large: 705.6 kHz in 32-bit for DSD128, about 1.7 GB for a five minute track. If a DSD track ends up decoded after all, its buffer is created on demand as usual.
  • Crossfades into or out of a DSD track are turned off.
  • Groups are left alone: a group mixes or re-encodes the stream for its members, so DoP is never sent to one.
  • DoP is also skipped when anything else would change the samples: DSP enabled on the player, a non-stereo output channel mapping, or an active audio overlay on the queue.
  • The player has to receive the DoP rate as lossless stereo with at least 24 bits. For Sendspin that is checked against the format a session at that rate actually negotiates (automatic format, PCM or FLAC). A client that only takes 16 bits or Opus at that rate gets decoded PCM, because DoP would arrive truncated.
  • If a member joins the player while it streams DoP, the stream is stopped before the member is attached and the queue resumes at the same position as decoded PCM.
  • The DSF file is read through the descriptor the filesystem provider opened and checked, like ffmpeg inputs, so a path swapped after the stream details were resolved can not lead outside the source folder.
  • The DoP markers keep alternating across gapless DSD tracks, so the DAC does not drop out of DSD mode at a track boundary.

PCM tracks in the same queue keep playing exactly as before. numpy is only imported when a DoP stream actually starts, so server start-up is not affected.

On the player side, the Sendspin CLI needs to offer the DoP rates and leave DoP untouched by its software volume. That is in Sendspin/sendspin-python-cli#324. For playback to stay fully bit-exact over time, the client should also avoid drift correction, which is what --clock-source dac (Sendspin/sendspin-python-cli#318) is for.

Related issue (if applicable):

  • none

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • New music/player/metadata/plugin provider — new-provider
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — documentation
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Checklist

  • The code change is tested and works locally.
  • pre-commit run --all-files passes.
  • pytest passes, and tests have been added/updated under tests/ where applicable.
  • For changes to shared models, the companion PR in music-assistant/models is linked.
  • For changes affecting the UI, the companion PR in music-assistant/frontend is linked.
  • I have read and complied with the project's AI Policy for any AI-assisted contributions.
  • I have raised a PR against the documentation repository targeting the main or beta branch as appropriate.

Sendspin only carries PCM, FLAC and Opus, so a DSD file is always decoded
to PCM. DoP packs the raw DSD bits into 24-bit PCM frames, which a DAC that
supports DoP plays back as native DSD, as long as every sample arrives
untouched.

Add an advanced per-player option, "Send DSD as DoP" (Sendspin, automatic
format). When it is on and the player plays on its own:
- a flow that starts with a local DSF file runs at its DoP rate
  (176.4 kHz for DSD64, 352.8 kHz for DSD128) in F32, which holds every
  24-bit DoP word exactly
- the flow restarts whenever a DSD track needs a different DoP rate
- DSD tracks are streamed straight from the file as DoP, bypassing the
  audio buffer, volume normalization, fades and the crossfade prefetch
- crossfades into or out of a DSD track are disabled

numpy is only imported when a DoP stream starts, so start-up is unaffected.
@musicassistant-bot

musicassistant-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

✅ The title and description are good to go. Thanks!

@codspeed

codspeed Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 11 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing manuel-alcocer:feat/sendspin-dop (b14bd2f) with dev (24420cf)2

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on dev (16cc254) during the generation of this report, so 24420cf was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@Bot-MArley Bot-MArley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

This comment was written by Claude and posted by Bot-MArley on behalf of the Music Assistant maintainers.

Thanks for the DoP work. It sends DSD to a Sendspin player untouched and falls back to PCM otherwise, the new helper is tested, and nothing changes for players with the option off. One problem needs fixing before this can merge.

Requested changes

  1. [PROBLEM] dop_enabled_for_players turns DoP off for groups, but not for players where something else changes the samples: DSP enabled on the player, a non-stereo output_channels setting, or an active audio overlay. In those cases the stream goes through a filter or mix and the DAC plays the DoP data as full-scale noise. Please also require that none of these apply before enabling DoP, and add a test.

return bool(players) and all(
self.mass.config.get_raw_player_config_value(player.player_id, CONF_DSD_DOP, False)
is True
and len(player.group_members) <= 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[PROBLEM] This only rules out groups. DoP also has to be off when DSP is enabled on the player, when output_channels is not stereo, and while an audio overlay is mixed into the flow stream, because each of those alters the samples and turns DoP into noise on the DAC. Please add those checks here and cover them with a test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 46a511b: dop_enabled_for_players now also requires that DSP is off and output_channels is stereo for every player, and DoP is skipped while a queue audio overlay is active (in select_flow_pcm_format, in the flow stream and in queue_item_plays_as_dop). Covered by test_flow_format_ignores_dop_with_dsp, test_flow_format_ignores_dop_with_channel_mapping and test_flow_format_ignores_dop_with_an_overlay.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

This comment was written by Claude and posted by Bot-MArley on behalf of the Music Assistant maintainers.

Thanks, this looks resolved on the current head. DoP is now skipped when DSP is enabled or output_channels is left/right/mono (_player_processes_audio via dop_enabled_for_players), and while an overlay is active in the format selection, the flow stream and queue_item_plays_as_dop. The three new tests cover each case.

@Bot-MArley

Copy link
Copy Markdown
Collaborator

Note

This comment was written by Claude and posted by Bot-MArley on behalf of the Music Assistant maintainers.

Changes in this area must be tested on real hardware before they can be merged. Have you tested this on a real DSD-capable Sendspin player? If so, please say what you tested. If you have one, the Music Assistant DEV SERVER app is an easy way to try this PR with it: stop your Music Assistant app, install the DEV app, set its server_repo option to pr-7429 and start it (see its README).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical DoP flaws risk corrupted playback and loud DAC noise through incompatible output, audio processing, and unsafe group transitions, while direct file access bypasses source-folder boundary checks.

6 open issues
What changed in this PR

Adds opt-in DSD-over-PCM (DoP) playback for local DSF files on standalone Sendspin players, allowing compatible DACs to receive DSD without PCM decoding.

Changes:

  • Adds an advanced per-player DoP setting and explanatory text.
  • Implements DSF parsing and DoP packing, with flow-rate selection and processing bypasses.
  • Adds tests for packing accuracy, seeking, format selection, and flow restarts.
File Description
tests/​helpers/​test_dop.py Tests DSF parsing, markers, bit-exact packing, padding, and seeking.
tests/​controllers/​streams/​test_flow_dop.py Tests DoP rate selection, eligibility, and transitions.
music_assistant/​translations/​en.json Adds English DoP configuration text.
music_assistant/​providers/​sendspin/​strings.json Defines the DoP setting label and safety guidance.
music_assistant/​providers/​sendspin/​player.py Exposes the advanced DoP option for automatic format selection.
music_assistant/​helpers/​dop.py Implements local DSF reading and DoP encoding.
music_assistant/​controllers/​streams/​audio.py Integrates DoP into flow playback and crossfade handling.
music_assistant/​constants.py Defines the DoP configuration key.

Review effort: Balanced

Comment thread music_assistant/helpers/dop.py Resolved
@github-actions
github-actions Bot marked this pull request as draft November 7, 2026 16:24
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

🚧 Automated critical-issue gate — this PR has an unresolved [CRITICAL] finding from the automated review, so it is kept as a draft (a draft PR can't be merged) until that is resolved. Please address the [CRITICAL] thread(s), then mark it Ready for review.

Maintainers: add the override-critical label to bypass this.

Manuel Alcocer J added 3 commits November 8, 2026 07:42
A DoP track is read straight from its DSF file, but the queue loader still
created an AudioBuffer for the first track and the stream feeder warmed one
for the next. That buffer holds decoded PCM at the probed DSD rate / 8 in
32-bit: 705.6 kHz for DSD128, about 5.6 MB/s, so up to ~1.7 GB for a five
minute track that nothing ever reads. Two of them were enough to push the
server past a 4 GiB memory limit.

- add StreamsAudio.queue_item_plays_as_dop(), which checks the queue's
  active output protocol player (or a linked one before playback picks it)
- skip the start buffer in the queue loader and the next-track warm-up in
  the stream feeder for DoP tracks; a track that ends up decoded creates its
  buffer on demand, as before
- release any buffer already attached when the DoP stream starts
Review follow-up for the DoP path:

- Require a lossless stereo output of at least 24 bits at the DoP rate.
  New Player.supports_dop_output() (False by default); Sendspin answers it
  from the format a session at that rate actually negotiates (automatic
  format, top codec PCM/FLAC, stereo, >= 24 bits). A 16-bit-only or Opus
  client now gets decoded PCM instead of a truncated DoP payload.
- Never use DoP when the samples get processed: DSP enabled on the player,
  a non-stereo output_channels mapping, or an active queue audio overlay.
- Restart as decoded PCM when a member joins a player that is streaming
  DoP. The leader stops its session before the member is attached, so no
  DoP audio is backfilled to it, then resumes the queue at the same
  position; the grouped player no longer qualifies for DoP.
- Read the DSF through the descriptor the filesystem provider opened and
  checked (open_local_files), with positional reads only, so a path swapped
  after stream details were resolved can not lead outside the source folder.
- Keep the DoP markers alternating across gapless tracks: the marker phase
  is carried from one DoP item into the next within a flow and reset after
  decoded PCM.
@Hedda

Hedda commented Nov 9, 2026

Copy link
Copy Markdown

FYI, created an extended feature request discussion partially based on this pull request, asking for DSD pass-through support:

Please note that the linked feature request has a broader scope, also asking for native DSD support as well for MA's providers.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants