Skip to content

Add support for external volume scripts with -w#258

Open
mr-manuel wants to merge 2 commits into
ralph-irving:masterfrom
mr-manuel:master
Open

Add support for external volume scripts with -w#258
mr-manuel wants to merge 2 commits into
ralph-irving:masterfrom
mr-manuel:master

Conversation

@mr-manuel

@mr-manuel mr-manuel commented Jan 16, 2026

Copy link
Copy Markdown

This is useful, if the client should have a fixed volume and an external amplifier should be controlled. This helps to prevent clipping and big volume differences when changing channel on the external amplifier.

Here a few cases:

  • You have connected a Raspberry Pi via HDMI to an AV receiver. The volume between Raspberry Pi and AV receiver should be fixed at an optimum that prevents clipping. The volume is changed directly on the AV receiver via CEC or HTTP API.
  • A Raspberry Pi is connected to an external custom made amplifier that can be controlled via Modbus.

Fixes #257

@mr-manuel mr-manuel force-pushed the master branch 2 times, most recently from 1c93147 to be96915 Compare January 23, 2026 14:43
@allmazz

allmazz commented Feb 24, 2026

Copy link
Copy Markdown

It's great, thank you very much!
As suggestion, I would throw an error if -w used with -U as you done it with -V

This is useful, if the client should have a fixed volume and an external
amplifier should be controlled.
@mr-manuel

Copy link
Copy Markdown
Author

@allmazz thanks for the suggestion. I added it.

@allmazz

allmazz commented Feb 24, 2026

Copy link
Copy Markdown

I have troubles with volume level < 25%, could you please take a look?

Volume level is 25% or higher, works as expected:

[19:59:04.846944] process_audg:445 audg gainL: 926 gainR: 926 adjust: 1
[19:59:04.846992] set_volume:260 left: 926, ldB:-36.997379 vol: 25

Then I set 24%, but in logs we can see 22%:

[20:00:13.475070] process_audg:445 audg gainL: 781 gainR: 781 adjust: 1
[20:00:13.475115] set_volume:260 left: 781, ldB:-38.476578 vol: 22

17%:

[20:02:44.284952] process_audg:445 audg gainL: 237 gainR: 237 adjust: 1
[20:02:44.285009] set_volume:260 left: 237, ldB:-48.834633 vol: 1

16% or lower:

[20:01:53.131445] process_audg:445 audg gainL: 200 gainR: 200 adjust: 1
[20:01:53.131488] set_volume:260 left: 200, ldB:-50.308998 vol: 0

I use latest LMS as the server

@patapovich

Copy link
Copy Markdown

Thanks for this feature — controlling external amplifiers via LMS volume is a genuine gap. A few issues worth addressing before merge:

Bug: volume mapping collapses below ~16% LMS

The dB_min = -49.510895 floor causes all LMS volumes at or below ~16% to map to vol=0, and values up to 25% are heavily compressed. This is the root cause of the issue @allmazz reported. The comment even says the LMS range is "-72 dB from the table's 1% value" but the code uses -49.5 dB — the constant doesn't match the intent.

The existing ALSA mixer path already has the correct constant: #define MINVOL_DB 72 in output_alsa.c. The -w logic should use the same mapping:

ldB = 20.0f * log10f((float)left / FIXED_ONE);
vol = (int)lroundf((ldB > -72.0f ? 72.0f + ldB : 0.0f) / 72.0f * 100.0f);

This mirrors exactly what set_mixer() does for the ALSA hardware volume path.

Code duplication

The identical ~40-line block is copy-pasted into output_alsa.c, output_pa.c, and output_pulse.c. A shared helper in output.c eliminates this:

// output.c
void call_volume_script(unsigned left) {
    char cmd[256];
    float ldB;
    int vol;

    ldB = 20.0f * log10f((float)left / FIXED_ONE);
    vol = (int)lroundf((ldB > -72.0f ? 72.0f + ldB : 0.0f) / 72.0f * 100.0f);

    LOG_DEBUG("volume script left: %u ldB: %.1f vol: %u", left, ldB, vol);

#if defined(_WIN32) || defined(_WIN64)
    snprintf(cmd, sizeof(cmd), "start /B %s %u", volume_script, vol);
#else
    snprintf(cmd, sizeof(cmd), "%s %u &", volume_script, vol);
#endif
    if (system(cmd) != 0) {
        LOG_ERROR("external volume script failed: %s", cmd);
    }

    LOCK;
    output.gainL = FIXED_ONE;
    output.gainR = FIXED_ONE;
    UNLOCK;
}

Each set_volume() then becomes:

if (volume_script) {
    call_volume_script(left);
    return;
}

Async inconsistency

output_alsa.c runs the script with & (async, comment says "to avoid blocking") but output_pa.c and output_pulse.c run it synchronously, blocking the audio thread. The shared helper above fixes this consistently.


Happy to open a PR on top of this one if that's easier than reworking it yourself.

@mr-manuel

Copy link
Copy Markdown
Author

@patapovich feel free to open a PR in my repo, so I merge it or copy my code and open a new PR, then I will close this.

@ralph-irving

Copy link
Copy Markdown
Owner

I would prefer to have separate PRs for the linear volume bug fix and the -w option. Collapsing the shared helper from the separate output_* files would be prefered to be including with the linear volume bug fix. Thanks.

Adds a -w <script> option that invokes an executable script whenever LMS
sends a volume command (AUDG packet).  The script receives a single integer
argument 0-100 mapped from the LMS dB range (-72..0 dB) using the same
linear scale that the ALSA mixer path already uses.  When -w is active the
internal software gain is held at unity (FIXED_ONE) so the script has full
control over output level.

The shared helper call_volume_script() is placed in output.c (always
compiled) to avoid duplicating the mapping arithmetic across the ALSA,
PortAudio and PulseAudio backends.  On POSIX the script is launched
asynchronously via a shell "&" so the audio thread is not blocked; on
Windows "start /B" is used instead.

-w is mutually exclusive with -V (ALSA hardware volume control).
@StillNotWorking

StillNotWorking commented Jun 10, 2026

Copy link
Copy Markdown

Love to see something like this be implemented. My setup use an amplifier that adjust volume by changing gain in the actual output stage.

Is it possible to implement this with replay gain? I.e. does the server calculate gain based on replay gain metadata. Or is replay gain always done in SW on server?

Edit: Ohh I see, Squeezelite have replay_gain information. Will this be included in the script volume value?
_output_frames:153 track start sample rate: 44100 replay_gain: 26368

Edit II:
To my understanding replay_gain is not updated from set_volume. But later on from.
c name=output.c url=

squeezelite/output.c

Lines 34 to 62 in 39fe3c8

u8_t *silencebuf;
#if DSD
u8_t *silencebuf_dsd;
#endif
bool user_rates = false;
#define LOCK mutex_lock(outputbuf->mutex)
#define UNLOCK mutex_unlock(outputbuf->mutex)
// functions starting _* are called with mutex locked
frames_t _output_frames(frames_t avail) {
frames_t frames, size;
bool silence;
u8_t flags = output.channels;
s32_t cross_gain_in = 0, cross_gain_out = 0; s32_t *cross_ptr = NULL;
s32_t gainL = output.current_replay_gain ? gain(output.gainL, output.current_replay_gain) : output.gainL;
s32_t gainR = output.current_replay_gain ? gain(output.gainR, output.current_replay_gain) : output.gainR;
if (output.invert) { gainL = -gainL; gainR = -gainR; }
frames = _buf_used(outputbuf) / BYTES_PER_FRAME;
silence = false;
// start when threshold met

s32_t gainL = output.current_replay_gain ? gain(output.gainL, output.current_replay_gain) : output.gainL;
s32_t gainR = output.current_replay_gain ? gain(output.gainR, output.current_replay_gain) : output.gainR;

Not sure if its enough to add similar code to the gain setting going to the script? Or if we also need a additional trigger when new track start?

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.

Volume control with script

5 participants