Skip to content

usb_audio: apply the host's mute and volume to the samples - #11386

Open
peterbay wants to merge 1 commit into
adafruit:mainfrom
peterbay:usb-audio-host-mute-and-volume
Open

peterbay wants to merge 1 commit into
adafruit:mainfrom
peterbay:usb-audio-host-mute-and-volume

Conversation

@peterbay

@peterbay peterbay commented Sep 14, 2026

Copy link
Copy Markdown

Code written by Claude Code, guided and corrected by @peterbay.

Addresses #11302.

The problem

usb_audio tells the host it has a volume and a mute control — the feature unit in both the microphone and the speaker descriptor declares MUTE and VOLUME as read/write, and the control requests for them are answered — but nothing is ever done with the values. They are stored in usb_audio_mute[] and usb_audio_volume[] and read back to the host, and that is all. The samples pass through untouched.

Because the device claims those controls, Windows hands the whole job to it: it sends the stream at full scale and expects the device to attenuate. So the slider moves, the device dutifully reports back whatever it was told, and the audio does not change. Muting does nothing either.

What this does

The first version of this PR applied the host's gain to the samples. @tannewt and @FoamyGuy asked in #11302 and here for the host's setting to be exposed to CircuitPython rather than acted on automatically, so that is what it now does, with the automatic behaviour available as an opt-in.

  • The host's mute and volume are reported. usb_speaker and usb_microphone grow host_mute, host_volume (dB) and host_gain (linear, 0.01.0, mute folded in). host_gain is the same number the applying path uses, just divided, so what is reported and what would be applied cannot disagree. It can be assigned straight to an audiomixer.MixerVoice level.

  • Nothing is applied unless asked. apply_host_volume is per direction and False by default. Setting it to True gives the plain sound-card behaviour: the host's slider and mute work with no code of your own.

  • All three report the effective setting for the first channel, master and per-channel controls in series. This matters: Windows drives the per-channel volume and leaves the master at 0 dB, so an earlier version of this patch that reported the master alone read 0.0 dB and False however far the slider moved. Measured, not assumed.

  • Mute and volume are per feature unit. The single-direction functions have one; the headset has one for its speaker and one for its microphone, and they are now independent.

  • The volume range is now −60 dB to 0 dB, in 1 dB steps, instead of −90 dB to +90 dB. There is nothing above unity to give — the samples are already at full scale — so the top half of the host's slider could only ask for gain that does not exist. A volume from the host is clamped into the range it was given.

  • sample_rate now has to be at least 1000. The microphone hands the host one millisecond of audio at a time, computed as sample_rate / 1000, so anything below 1 kHz sized that buffer at zero. It was validated against a minimum of 1.

Cost on a Seeed XIAO nRF52840 Sense build: +992 bytes of flash, +4 bytes of RAM. No new translatable strings.

Why #11302 is not closed by this

Out of the box the behaviour of a speaker is unchanged: the host's slider still does nothing until the application does something with host_gain, or sets apply_host_volume = True. The issue is addressed in the sense that the information is now available and acting on it is one line, but a program that does neither behaves as before, so this does not close it.

Testing

Seeed XIAO nRF52840 Sense with an Adafruit Audio BFF, Windows 11. boot.py enables a speaker at 16 kHz stereo; code.py reports the peak amplitude arriving from the host together with the new properties. The host plays a steady 440 Hz tone whose own amplitude is 12000, so the peak the board reports is the gain that was applied, in absolute numbers. The endpoint volume (the taskbar slider, not a per-app session volume, which would be applied before USB and prove nothing) is stepped through the Core Audio API.

Default, apply_host_volume = False — the samples are untouched and the properties track the host:

Windows volume host dB board peak host_volume host_gain host_mute
maximum 0.0 12001 0.0 1.000 False
75 % −4.3 12001 −4.0 0.631 False
50 % −10.2 12001 −10.0 0.316 False
25 % −20.2 12001 −20.0 0.100 False
10 % −32.6 12001 −33.0 0.022 False
muted 12001 −33.0 0.000 True

apply_host_volume = True — the peak follows host_gain, and mute is digital silence:

Windows volume board peak expected (12000 × host_gain)
maximum 12001 12000
75 % 7573 7572
50 % 3795 3792
25 % 1200 1200
10 % 269 264
muted 0 0

The remaining difference is Q15 quantisation. The gap between the host's dB and host_volume is the 1 dB step the device declares in GET_RANGE.

A headset (microphone=True, speaker=True) was also checked on this board: it enumerates and Windows offers both a playback and a recording endpoint. The microphone side of the gain is the same code as the speaker side but is not in the tables — this board's PDM microphone was not wired up for it.

@peterbay

Copy link
Copy Markdown
Author

Testing and diagnostic script.
usb_audio_host_mute_and_volume.py

@tannewt

tannewt commented Sep 14, 2026

Copy link
Copy Markdown
Member

We hadn't settled on this behavior in #11302. @jbirchall-svg and @FoamyGuy what do you think?

@FoamyGuy

Copy link
Copy Markdown
Collaborator

I agree with Scott from the linked issue:

I'd be interested in adding a way to expose the desired volume as a state that could optionally be plugged into Mixer. I don't think it should be done automatically though.

I think this volume should not be applied automatically. It would be nice to have it exposed to the CircuitPython side so that it can be checked and responded to from there, but making it adjust automatically seems to me like it will increase chances for people to think it's not working when the volume is set on the host side and they don't realize it.

The feature unit declares MUTE and VOLUME as read/write and the control
requests for them are answered, but the values were only stored and read
back. Because the device claims those controls, the host sends the stream at
full scale and leaves the attenuation to it, so the slider moved and nothing
happened.

Each feature unit now keeps its own mute and volume and a Q15 gain worked out
from them. USBSpeaker and USBMicrophone report that as host_mute, host_volume
and host_gain, so an application can follow the host's level, for instance by
assigning host_gain to an audiomixer.MixerVoice level. The board does not
scale the samples itself unless apply_host_volume is set, which is per
direction and off by default: a device that claims the control is expected to
honour it, but doing so silently surprises an application that manages its own
levels, so the default leaves the choice to the application and the switch
gives the plain sound-card behaviour.

All three report the effective setting for the first channel, master and
per-channel controls in series. Windows drives the per-channel volume and
leaves the master at 0 dB, so reporting the master alone read unchanged
however the slider moved.

The range offered to the host was -90 dB to +90 dB; there is nothing above
unity to give, so it is now -60 dB to 0 dB, and a volume from the host is
clamped into it.

Mute and volume were single shared arrays, so a headset's two feature units
could not hold different settings.

sample_rate was validated against a minimum of 1, but the microphone sizes its
buffer as sample_rate / 1000, so anything below 1 kHz gave it a zero-length
one.

Addresses adafruit#11302: the host's setting is now available to the application,
which decides what to do with it. The default behaviour of a speaker is
unchanged, so the issue is not closed by this.
@peterbay
peterbay force-pushed the usb-audio-host-mute-and-volume branch from e421713 to 82f5a9d Compare September 18, 2026 06:13
@peterbay

Copy link
Copy Markdown
Author

Code and description updated

@jbirchall-svg

Copy link
Copy Markdown

Sorry for the delay.

I really like the approach taken in the latest request @peterbay .

For us, disconnecting the CircuitPython behaviour from the host PC is a feature, not a bug. We want to have full application and device control over the emitted volume regardless of what the PC OS wants to set it to be and the latest PR preserves that option. At the same time, default behaviour for most hobbyists is going to be that the PC volume slider "just works" with their code. The compromise is really nice and we appreciate the consideration.

The only minor comment would be to default it to reflect what the host volume indicates rather than default it to ignore it as it does currently. In general, backwards compatibility would be paramount, but the usb_audio module is brand new and now is the chance to set it up as intuitively for new comers as possible.

Nice work and thank you!

@peterbay

Copy link
Copy Markdown
Author

I'll leave the decision on the default state up to the CircuitPython developers. Feel free to change it as needed.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's simplify this a bunch and have one .volume or .gain that is a synthio.BlockInput that has the host_gain value. That way it can be assigned straight to a MixerVoice's level. That way only a mixer is used for volume and the code doesn't need to be repeated here.

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.

4 participants