Summary
The gain stage uses the raw volumeScalar directly as a linear amplitude multiplier. The comment says the scalar is already perceptually mapped, but it is not: macOS writes raw 0...1 slider travel, and the volume keys step it in 16 equal increments of 1/16. The result is a slider that barely changes loudness across most of its travel.
packages/host/Sources/SoundBridgeHost/Audio/AudioRenderer.swift:
// === Gain Stage (Linear Passthrough) ===
// macOS volumeScalar is already perceptually mapped (Weber-Fechner),
// so we use it directly as the physical gain — no additional curve.
let targetGain: Float = (isMuted || volumeScalar <= 0.0) ? 0.0 : volumeScalar
Why it feels broken
Using the scalar as amplitude gives:
| Slider |
Gain |
| 100% |
0 dB |
| 75% |
-2.5 dB |
| 50% |
-6 dB |
| 25% |
-12 dB |
| 10% |
-20 dB |
Perceived loudness roughly halves every -10 dB, so dragging from 100 down to 50 buys about one step of perceived loudness, and the top three quarters of the slider spans only 12 dB. On a fixed-volume DisplayPort monitor, which is the case this app exists to solve, that reads as "the slider does not do anything."
It is also uneven per keypress, which is how most people actually change volume. The macOS volume keys emit 16 equal steps in scalar, so with a linear amplitude law the first press down from full is -0.6 dB and the last press before mute is -6.0 dB, a 10x difference in perceptual size across the same slider.
Second, related problem: reported curve does not match applied gain
The driver reports a proper range to CoreAudio while the host applies something else. Measured on a live proxy device:
kAudioDevicePropertyVolumeRangeDecibels returns -96.00 .. 0.00 dB
- at scalar 0.3164,
kAudioDevicePropertyVolumeDecibels returns -42.00 dB (an exact fit for dB = -96 * (1 - sqrt(scalar)), the libASPL curve)
- the host actually applies 0.3164 linear, which is -10 dB
So anything reading the device's decibel value, including accessibility tools and other audio software, is told a number 32 dB away from what is really applied.
Suggested fix
A power law is not enough on its own. Cubing the scalar widens the range but leaves the per-keypress size uneven in the other direction (-1.7 dB for the top press, -18.1 dB for the bottom one). Mapping the scalar linearly onto decibels makes every keypress the same perceptual size:
// Constants.swift
/// Total span of the software volume taper, in decibels below unity.
static let volumeRangeDB: Float = 48.0
// AudioRenderer.swift
let targetGain: Float = (isMuted || volumeScalar <= 0.0)
? 0.0
: pow(10.0, (volumeScalar - 1.0) * SoundBridgeConfig.volumeRangeDB / 20.0)
With a 48 dB range that is exactly 3.0 dB per keypress at every position, 0 dB at the top and -45 dB on the last step before mute.
Both existing fast paths still hold: scalar 0 is guarded to hard mute, and at scalar 1.0 the exponent is zero so the gain is exactly 1.0 and the bit-perfect passthrough branch still triggers.
I have been running this locally for a few hours and the slider now behaves like a normal Mac volume control. Happy to open a PR.
Build note
On Swift 6.4, swift build fails in packages/host with:
error: Build input file cannot be found: '.../Products/Release/CSoundBridgeAudio.o'
CSoundBridgeAudio is a header-only C target, and the new default swiftbuild engine emits a link input for it that is never produced. swift build --build-system native works as a workaround. Adding a placeholder .c file to that target would fix it on the default engine.
Environment
- SoundBridge 1.1.2
- macOS 27.0, Apple Silicon
- Output device: DELL U4025QW over DisplayPort (fixed volume)
Summary
The gain stage uses the raw
volumeScalardirectly as a linear amplitude multiplier. The comment says the scalar is already perceptually mapped, but it is not: macOS writes raw 0...1 slider travel, and the volume keys step it in 16 equal increments of 1/16. The result is a slider that barely changes loudness across most of its travel.packages/host/Sources/SoundBridgeHost/Audio/AudioRenderer.swift:Why it feels broken
Using the scalar as amplitude gives:
Perceived loudness roughly halves every -10 dB, so dragging from 100 down to 50 buys about one step of perceived loudness, and the top three quarters of the slider spans only 12 dB. On a fixed-volume DisplayPort monitor, which is the case this app exists to solve, that reads as "the slider does not do anything."
It is also uneven per keypress, which is how most people actually change volume. The macOS volume keys emit 16 equal steps in scalar, so with a linear amplitude law the first press down from full is -0.6 dB and the last press before mute is -6.0 dB, a 10x difference in perceptual size across the same slider.
Second, related problem: reported curve does not match applied gain
The driver reports a proper range to CoreAudio while the host applies something else. Measured on a live proxy device:
kAudioDevicePropertyVolumeRangeDecibelsreturns -96.00 .. 0.00 dBkAudioDevicePropertyVolumeDecibelsreturns -42.00 dB (an exact fit fordB = -96 * (1 - sqrt(scalar)), the libASPL curve)So anything reading the device's decibel value, including accessibility tools and other audio software, is told a number 32 dB away from what is really applied.
Suggested fix
A power law is not enough on its own. Cubing the scalar widens the range but leaves the per-keypress size uneven in the other direction (-1.7 dB for the top press, -18.1 dB for the bottom one). Mapping the scalar linearly onto decibels makes every keypress the same perceptual size:
With a 48 dB range that is exactly 3.0 dB per keypress at every position, 0 dB at the top and -45 dB on the last step before mute.
Both existing fast paths still hold: scalar 0 is guarded to hard mute, and at scalar 1.0 the exponent is zero so the gain is exactly 1.0 and the bit-perfect passthrough branch still triggers.
I have been running this locally for a few hours and the slider now behaves like a normal Mac volume control. Happy to open a PR.
Build note
On Swift 6.4,
swift buildfails inpackages/hostwith:CSoundBridgeAudiois a header-only C target, and the new defaultswiftbuildengine emits a link input for it that is never produced.swift build --build-system nativeworks as a workaround. Adding a placeholder.cfile to that target would fix it on the default engine.Environment