Skip to content

Fix Input Boost wrapping around instead of clipping - #3834

Open
mcfnord wants to merge 2 commits into
jamulussoftware:mainfrom
mcfnord:fix-input-boost-overflow
Open

Fix Input Boost wrapping around instead of clipping#3834
mcfnord wants to merge 2 commits into
jamulussoftware:mainfrom
mcfnord:fix-input-boost-overflow

Conversation

@mcfnord

@mcfnord mcfnord commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

Input Boost multiplies each sample and narrows the int result back to int16_t with a plain cast, so a boosted sample that passes full scale wraps to the opposite polarity instead of clipping.

// src/client.cpp, CClient::ProcessAudioDataIntern
vecsStereoSndCrd[j + 1] = static_cast<int16_t> ( iInputBoost * vecsStereoSndCrd[j + 1] );
vecsStereoSndCrd[j]     = static_cast<int16_t> ( iInputBoost * vecsStereoSndCrd[j] );

iInputBoost is 2..10 (the "2x".."10x" dropdown), so the product routinely exceeds int16_t. Wraparound is a full-amplitude sign flip, which is considerably more audible than the clipping a user would expect from a gain control.

The invariant this breaks is written down 20 lines below

The pan block in the same function documents exactly when the plain cast is safe:

// note that the gain is always <= 1, therefore a simple cast is
// ok since we never can get an overload

and the mono mixdown block, where sums can overload, says:

// note that we need the Float2Short for stereo pan mode

Input Boost is the only gain > 1 in the send path, and the only one of the three that does not saturate.

Measured

1 kHz sine at −10 dBFS, 4800 samples. "Sign-flipped" means the current cast returned a value of opposite polarity to the true product.

boost wraps above samples over full scale sign-flipped max error
2x −6.0 dBFS 0 / 4800 0 0
4x −12.0 dBFS 2200 / 4800 2200 (100%) 65424 LSB
10x −20.0 dBFS 3800 / 4800 2200 (the rest wrap twice) 63760 LSB

The threshold is 32767 / iInputBoost. At 10x — the setting a user with a quiet microphone is most likely to reach for — anything above −20 dBFS inverts.

Performance

This is a per-sample loop, so: x86-64, gcc 13.3, -O3, 128-sample frame (2.67 ms), best of 7 runs × 3M reps, loops in a separate translation unit, memcpy baseline subtracted.

variant ns/frame
current plain cast 108.6
Float2Short (this PR) 1111.7
integer std::min/std::max clamp 1102.3

That is +1000 ns on a 2 670 000 ns budget, or +0.037%, and only on the path where the user has actually enabled the setting (iInputBoost != 1; the default is 1, which skips the loop entirely). The ratio is large because the plain cast vectorizes to pmullw and neither clamped form reaches packssdw on gcc 13 — but the absolute cost is not perceptible.

I measured the integer clamp as the alternative and it comes out the same, so I chose Float2Short for consistency with the rest of the function rather than for speed.

Caveat: timings are x86-64 only; I have not measured ARM.

Side note, not fixed here

SignalLevelMeter.Update runs after the boost, and CStereoSignalLevelMeter::Update measures negative peaks only (a deliberate speed optimization, every third sample). A positive sample that wraps becomes a large negative and reads as a peak; a negative sample that wraps becomes positive and is missed. So the level meter is not a dependable warning for this, in either direction. That seemed like a separate discussion, so I left it alone.

History

Introduced with the feature in #1222 (2021-04-01, e5bc010) and untouched since except by clang-format. I could not find any existing issue, PR, or discussion about it.

Status

Working implementation, approved by @ann0see on 2026-09-01; the one open inline thread is outdated, its subject — the redundant static_cast<float> — dropped in ce288f3. No issue is linked, and none was found for this defect; #3844 proposes refactoring Input Boost into its own class and is not addressed here. No documentation change is needed: the setting is already documented as a gain control, and this makes it behave as one. On ce288f3 every GitHub check passes and clang-format leaves src/client.cpp untouched. Nothing else is known to block a merge; the ARM caveat above is the one measurement not made.

CHANGELOG: Bugfix: Input Boost now clips instead of wrapping around when the boosted signal exceeds full scale.

The Input Boost setting (2x..10x) multiplied each sample and narrowed the
int result back to int16_t with a plain cast, so any sample boosted past
full scale wrapped to the opposite polarity rather than clipping.

Use Float2Short, the same saturating helper already used twice in this
function, so boosted peaks clip instead of inverting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ann0see

ann0see commented Jul 25, 2026

Copy link
Copy Markdown
Member

Needs a proof that this bug actually existed.

@mcfnord

mcfnord commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

Fair request. The table in the PR body was computed from the expression, which is not the same as showing the bug happen. So here it is end-to-end, through the shipped code path, with no instrumented build and nothing patched except the fix itself.

Rig

A 1 kHz sine goes into a JACK dummy device, into a normal Jamulus client with Input Boost set from its ini, through opus and UDP, into a normal Jamulus server started with -R. The measurement is the server's own per-client WAV recording — so every sample measured came out of the encoder on the send path, not out of a test harness. Two builds: upstream 97799184 unmodified, and the same tree with this PR's two lines applied. Nothing else differs.

Result

0.5 s steady-state window (24 000 samples) from the middle of each take. "Tone vs everything else" is 1 kHz energy against all other energy above 40 Hz. "Steps over 32 768 LSB" counts adjacent samples differing by more than 32 768 LSB. A 1 kHz sine at 48 kHz cannot step by more than about 4 300 LSB even at full amplitude, so any such step is a discontinuity, not signal.

build boost input tone vs everything else steps over 32 768 LSB matches
main 1x −3 dBFS +61.9 dB 0 control, no overflow
main 10x −20 dBFS +57.1 dB 0 control, lands exactly at full scale
main 2x −3 dBFS −4.0 dB 2000 wraps
main 4x −10 dBFS −6.9 dB 2000 wraps
main 10x −14 dBFS −15.4 dB 1952 wraps
this PR 2x −3 dBFS +17.3 dB 0 clips
this PR 10x −14 dBFS +11.8 dB 0 clips

The row that matters is 2x at −3 dBFS: that is the first entry in the dropdown, on a signal below full scale, and what arrives at the server has the user's tone 4 dB below the noise the wraparound generates. With the patch the same take has the tone 17 dB above, and those steps go to zero.

I also correlated each recording against both candidate models, a hard-clipped boost and an int16_t-narrowed boost, aligned within 200 samples. On main the recordings match the narrowed model at 0.72 to 0.99 and the clipped model at 0.14 to 0.48. With the patch that reverses.

Two things I want to state rather than let them be inferred:

  • The two control rows are deliberate. −20 dBFS times 10 lands on exactly full scale and does not wrap, so it shows the rig producing a clean result when no overflow occurs. If the rig were manufacturing distortion, those rows would not read +57 and +62 dB.
  • The patched rows sit below the controls (+17 and +12 rather than +57). That is correct and expected: hard clipping genuinely adds harmonics. The claim is not that the fix is transparent, it is that a gain control which runs out of headroom should distort by clipping rather than by inverting.

Measured on x86-64 Linux, opus at default quality. I have not run this on ARM or macOS.

Reproducing it

Two traps cost me a run each, so they are worth writing down:

  1. Feedback detection has to be off. clientdlg.cpp:1100 mutes the channel and opens a modal box whenever an input meter pegs, which is exactly what an overflowing boost does. Set enablefeedbackdetection to 0 in the ini or you record silence.
  2. A headless client ignores Input Boost entirely — see below. The client has to be the GUI one.

The rig is four small files — a JACK sine generator, the run script, the analyzer, and a README with the full result table and the traps: https://gist.github.com/mcfnord/85390a43901f96391d0b41ce467ac008

The whole method, if you would rather not fetch anything:

client.ini:

<?xml version="1.0" encoding="UTF-8"?>
<client>
 <inputboost>2</inputboost>
 <enablefeedbackdetection>0</enablefeedbackdetection>
</client>
jackd -R -d dummy -r 48000 -p 128 &
./Jamulus -s -n -p 22124 -R rec/ &
./gen 1000 -3 Jamulus &                            # 1 kHz sine at -3 dBFS
./Jamulus -i client.ini -j -c 127.0.0.1:22124      # GUI client; xvfb-run works
# stop after about 15 s; the WAV is under rec/Jam-*/

Open the WAV and look at it, or measure the 1 kHz band against the rest. At 2x the wraparound is clearly visible in the waveform: the sine flips polarity near every peak.

One separate bug found while building this

A headless client silently ignores inputboost. CClientSettings::ReadSettingsFromXML parses the ini value at settings.cpp:419, but the only call to CClient::SetInputBoost outside the settings dialog is clientdlg.cpp:268, which only runs when the GUI is constructed. Measured on the same rig: identical ini, client started with -n, recorded peak 6542 LSB where the boosted path gives 32 767 (unboosted peak at −14 dBFS is 6528).

That is unrelated to this PR and I have not opened anything for it. It may not be the only setting in that position, so if it is worth reporting I would rather first check whether other ini keys are applied only from the dialog and report them together. Happy to do that if you want it.

Why this may never have been reported

Speculation, offered because it is testable rather than as part of the argument: the bug hides behind the feedback detector. Turn Input Boost up far enough to wrap and the input meter pegs, so clientdlg.cpp:1100 mutes your channel and tells you that audio feedback or a loud signal was detected. That is a plausible-sounding message which sends the user looking for a feedback loop, not for a gain control that inverts. It is also why the level meter is not a usable warning here, as noted in the PR body.

@pljones

pljones commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

A headless client silently ignores inputboost

Ugh, more "business logic" in the GUI.

Should be somewhere like CSound with CClient (or CSettings) owning the state, with the GUI (and JSONRPC) controlling the setting.

Separate issue for this...

@ann0see

ann0see commented Jul 28, 2026

Copy link
Copy Markdown
Member

Ok. Nothing in the PR where this was initially introduced we had a comment by someone that the input boost is wrong from theoretical standpoint point. Likely it should be dB based. I think it's worth re investigating this. #1222 (comment)

Comment thread src/client.cpp Outdated
Comment thread src/client.cpp Outdated
{
vecsStereoSndCrd[j + 1] = static_cast<int16_t> ( iInputBoost * vecsStereoSndCrd[j + 1] );
vecsStereoSndCrd[j] = static_cast<int16_t> ( iInputBoost * vecsStereoSndCrd[j] );
vecsStereoSndCrd[j + 1] = Float2Short ( static_cast<float> ( iInputBoost ) * vecsStereoSndCrd[j + 1] );

@pljones pljones Jul 29, 2026

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.

This is:

static_cast<float> ( <int> ) * <short>

(e.g. 1.0 * 1) Is that going to work or is * <short> going to coerce <float> to <short> anyway?

Then it says:

<short> = Float2Short ( <short> )

if I'm right on the first bit.

I'm assuming -- given the naming convention vecs.... is a std::vector<short> (or QVector).

@mcfnord mcfnord Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

MY LLM WROTE:

The promotion goes the other way. float * short never coerces the float down; C++'s usual arithmetic conversions take the wider type, so the short is integer-promoted to int and then converted to float. The expression's static type is float, and Float2Short() receives a real float, not an already-narrowed short.

Your assumption about the vector is right, by the way: ProcessAudioDataIntern ( CVector<short>& vecsStereoSndCrd ) (client.h:322), and CVector<short> derives from std::vector<short>, so vecsStereoSndCrd[j] is a short lvalue.

Confirmed rather than asserted:

static_assert ( std::is_same< decltype ( static_cast<float> ( iInputBoost ) * vecsStereoSndCrd[0] ),
                              float >::value, "product is not float" );

compiles, and the whole loop is clean under -Wall -Wconversion.

The values, boost = 2:

input sample old: static_cast<int16_t> ( int * short ) new: Float2Short ( float * short ) exact product
30000 −5536 32767 60000
−30000 5536 −32768 −60000
20000 −25536 32767 40000
32767 −2 32767 65534

So the old form is where the narrowing happened — the int product was truncated to 16 bits, which is the wraparound this PR fixes. The new form keeps full range until Float2Short() saturates it.

On precision: the largest magnitude reachable is 10 × 32768 = 327 680, far inside float's exactly-representable integer range (2^24), so nothing is lost to rounding before the clamp.

For what it's worth, this is the same idiom the pan path a few lines below already uses, unchanged from before this PR:

// client.cpp:1477
vecsStereoSndCrd[i] = Float2Short ( fGainL * vecsStereoSndCrd[j] + fGainR * vecsStereoSndCrd[j + 1] );

One thing you might legitimately want changed: the static_cast<float> is not load-bearing. iInputBoost * vecsStereoSndCrd[j] is int * intint, which cannot overflow at the maximum boost of 10, and it would convert to float at the call to Float2Short() anyway. I kept the cast because it makes "this multiply is done in float" visible at the point it happens rather than at the callee's signature. Happy to drop it if you prefer the shorter line.

@pljones pljones Aug 16, 2026

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.

I don't see why the static_cast<float> is needed. Doesn't the parameter type of Float2Short() coerce <int> * <short> to <float>, then return the <short>? (I see why Float2Short is needed.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 AI: It isn't. Float2Short takes a const float (util.h:91), so the int product converts at the call and the cast changed nothing. Dropped in ce288f3. Both forms were run over every int16_t sample × boost 1..10: 655360 cases, 0 differences. End-to-end through the product (JACK dummy driver → GUI client → opus → server-side WAV), the build without the cast still clips: 2x on a −3 dBFS sine gives 0 wrapped sample steps and a +17.3 dB fundamental-to-rest ratio, against 1947 wrapped steps and −4.1 dB on main.

@ann0see

ann0see commented Aug 15, 2026

Copy link
Copy Markdown
Member

Todo: some maintainer tests this.

@ann0see ann0see moved this from Triage to Waiting on Team in Tracking Sep 1, 2026
@ann0see ann0see added this to the Release 4.0.0 milestone Sep 1, 2026

@ann0see ann0see 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.

The difference is audible. I think it's good to get in. In future, we may want to refactor Float2Short to be more efficient. Probably with std::clamp once we moved the minimum C++ version up.

@pljones

pljones commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

The difference is audible. I think it's good to get in. In future, we may want to refactor Float2Short to be more efficient. Probably with std::clamp once we moved the minimum C++ version up.

We could probably have a templated std::clamp-alias defined that optimised down efficiently and have it swapped to the real version based on compiler level.

@ann0see

ann0see commented Sep 1, 2026

Copy link
Copy Markdown
Member

We could. However I think I we should bump up the c++ version in Jamulus 4 which would then allow std::clamp directly

Float2Short() takes a const float, so the int product converts at the
call; the cast changed nothing. Checked exhaustively (every int16_t
sample x boost 1..10: 655,360 cases, 0 differences) and end-to-end with
the rig (server-side WAV clips, 0 wrapped sample steps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8KACM96jgMTGCLbqqta9Z
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: QUIET

Plan: Advanced

Run ID: 4c064d7b-095d-4c7f-bfef-82b1f67661ea

📥 Commits

Reviewing files that changed from the base of the PR and between 9779918 and ce288f3.

📒 Files selected for processing (1)
  • src/client.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The client now converts input-boosted stereo audio samples with Float2Short instead of direct int16_t casts.

Changes

Input audio conversion

Layer / File(s) Summary
Boosted sample conversion
src/client.cpp
CClient::ProcessAudioDataIntern uses Float2Short for both boosted stereo channels.

Estimated code review effort: 1 (Trivial) | ~2 minutes

Merge Risk: ⚪ Minimal · up to ce288

Input Boost now clips over-range audio samples instead of wrapping their polarity. The updated stereo conversion follows the existing saturating conversion behavior, with no current merge-blocking risk identified.

Suggested reviewers: ann0see, softins

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing Input Boost from wrapping and making it clip instead.
Description check ✅ Passed The description is detailed and covers the change, context, documentation impact, status, testing, performance, changelog entry, and known limitations. It does not reproduce the template headings or c…
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@pljones pljones added the bug Something isn't working label Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI AI generated or potentially AI generated bug Something isn't working

Projects

Status: Waiting on Team

Development

Successfully merging this pull request may close these issues.

3 participants