Skip to content

Volume slider gain uses raw scalar as amplitude, giving uneven loudness per keypress #10

Description

@ccheng555

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)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions