diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index 3a651e0..a389c01 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -167,6 +167,12 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { agc = it change { this.agc = it } } + if (agc) { + Level("Boost quiet speech", settings.agcBoostDb.toFloat(), 0f..Settings.MAX_AGC_BOOST_DB.toFloat(), + { "+${it.roundToInt()} dB" }, steps = Settings.MAX_AGC_BOOST_DB - 1) { + change { agcBoostDb = it.roundToInt() } + } + } Section("Playback") Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) { diff --git a/ts3-client/README.md b/ts3-client/README.md index 1c6b31d..511a511 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -38,6 +38,8 @@ Android app in [`../android`](../android) does. four levels) → **typing attenuation** (the transient suppressor, told about key presses) → **automatic gain control** (AGC2, WebRTC's successor to the AGC1 TS3 uses). Checked against WebRTC built from source. + - **Boost quiet speech** (0–20 dB) raises AGC2's cap on the amplified noise floor, which + otherwise holds back a distant voice that is barely above the background noise. - Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker reference signal and matters mainly for open speakers, not the typical headset. - **Push-to-Talk** — bind any key or mouse button as a global hotkey; transmits only while held. diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/CaptureVoiceInput.java b/ts3-client/core/src/main/java/com/ts3client/audio/CaptureVoiceInput.java index 584b981..10d3a46 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/CaptureVoiceInput.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/CaptureVoiceInput.java @@ -113,6 +113,7 @@ public final class CaptureVoiceInput implements VoiceInput { processor.setSuppressionLevel(SuppressionLevel.fromDenoiserLevel(settings.denoiserLevel)); processor.setTransientSuppression(settings.typingAttenuation); processor.setGainControl(settings.agc); + processor.setGainBoost(settings.agcBoostDb); } // ---- live configuration (safe to call from the UI thread) ---- @@ -169,6 +170,10 @@ public final class CaptureVoiceInput implements VoiceInput { processor.setGainControl(enabled); } + public void setAgcBoost(int db) { + processor.setGainBoost(db); + } + @Override public void keyPressed() { processor.keyPressed(); diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java index 83c963f..80e6133 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java @@ -60,6 +60,9 @@ public interface VoiceInput extends Microphone { /** Enables automatic gain control (normalise microphone loudness). */ void setAgc(boolean enabled); + /** Lets AGC raise the background noise this many dB more to bring up a quiet voice. */ + void setAgcBoost(int db); + /** Applies Opus encoder parameters, taking effect immediately if capturing. */ void setOpusParameters(OpusParameters parameters); @@ -94,6 +97,7 @@ public interface VoiceInput extends Microphone { setDenoiserLevel(settings.denoiserLevel); setTypingAttenuation(settings.typingAttenuation); setAgc(settings.agc); + setAgcBoost(settings.agcBoostDb); setOpusParameters(OpusParameters.from(settings)); } } diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/processing/AudioProcessor.java b/ts3-client/core/src/main/java/com/ts3client/audio/processing/AudioProcessor.java index d63d3cb..f13d807 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/processing/AudioProcessor.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/processing/AudioProcessor.java @@ -18,7 +18,9 @@ import java.util.concurrent.atomic.AtomicBoolean; * whenever noise suppression runs, so a 100 Hz high-pass comes with it; *
  • transient suppression ("Typing attenuation"), told about key presses;
  • *
  • automatic gain control. TS3 runs WebRTC's legacy AGC1 (adaptive digital, target - * -9 dBFS, 20 dB compression); this runs its successor, AGC2 adaptive digital.
  • + * -9 dBFS, 20 dB compression); this runs its successor, AGC2 adaptive digital. AGC2 won't + * amplify the noise floor above -50 dBFS, which also holds back a distant voice + * barely above the noise; {@link #setGainBoost} trades more noise for more of it. * * *

    One deliberate difference: APM only splits into bands for band-wise stages, so with noise @@ -39,6 +41,7 @@ public final class AudioProcessor { private volatile SuppressionLevel suppressionLevel = SuppressionLevel.DB_12; private volatile boolean transientSuppression; private volatile boolean gainControl; + private volatile float gainBoostDb; private final AtomicBoolean keyPressed = new AtomicBoolean(); private final ThreeBandFilterBank filterBank = new ThreeBandFilterBank(); @@ -66,6 +69,11 @@ public final class AudioProcessor { this.gainControl = enabled; } + /** Lets the gain control raise the noise floor this many dB higher to reach quiet speech. */ + public void setGainBoost(float db) { + this.gainBoostDb = db; + } + /** Reports a key press anywhere on the system; the transient suppressor only acts while typing. */ public void keyPressed() { keyPressed.set(true); @@ -146,5 +154,8 @@ public final class AudioProcessor { } else if (gainController == null) { gainController = new GainController2(AdaptiveDigitalConfig.DEFAULT); } + if (gainController != null) { + gainController.setMaxOutputNoiseLevelDbfs(AdaptiveDigitalConfig.DEFAULT.maxOutputNoiseLevelDbfs() + gainBoostDb); + } } } diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/AdaptiveDigitalGainController.java b/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/AdaptiveDigitalGainController.java index 78174aa..11e46c7 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/AdaptiveDigitalGainController.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/AdaptiveDigitalGainController.java @@ -21,6 +21,7 @@ final class AdaptiveDigitalGainController { private final AdaptiveDigitalConfig config; private final int adjacentSpeechFramesThreshold; private final float maxGainChangeDbPer10ms; + private float maxOutputNoiseLevelDbfs; private int framesToGainIncreaseAllowed; private float lastGainDb; @@ -31,6 +32,11 @@ final class AdaptiveDigitalGainController { this.maxGainChangeDbPer10ms = config.maxGainChangeDbPerSecond() * FRAME_DURATION_MS / 1000.0f; this.framesToGainIncreaseAllowed = adjacentSpeechFramesThreshold; this.lastGainDb = config.initialGainDb(); + this.maxOutputNoiseLevelDbfs = config.maxOutputNoiseLevelDbfs(); + } + + void setMaxOutputNoiseLevelDbfs(float dbfs) { + maxOutputNoiseLevelDbfs = dbfs; } void process(FrameInfo info, float[] frame, int samples) { @@ -80,7 +86,7 @@ final class AdaptiveDigitalGainController { } private float limitGainByNoise(float targetGainDb, float inputNoiseLevelDbfs) { - final float maxAllowedGainDb = config.maxOutputNoiseLevelDbfs() - inputNoiseLevelDbfs; + final float maxAllowedGainDb = maxOutputNoiseLevelDbfs - inputNoiseLevelDbfs; return Math.min(targetGainDb, Math.max(maxAllowedGainDb, 0.0f)); } diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/GainController2.java b/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/GainController2.java index e3fac82..bfbab9a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/GainController2.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/processing/agc2/GainController2.java @@ -16,6 +16,12 @@ import com.ts3client.audio.vad.RnnSpeechDetector; * WebRTC: it measures the speech level only on frames its RNN VAD calls speech, and caps * the gain so the measured noise floor stays below -50 dBFS. * + *

    It is also slower to adapt: the gain rises at most 6 dB/s, and only after 12 + * frames in a row that its VAD rates at 0.95 or more, holding still through pauses. A + * distant voice in a reverberant room (half its frames that confident) took about 9 s + * of talking to go from the initial 15 dB to the 26 dB it settled at. How fast + * AGC1 follows the same voice hasn't been measured. + * *

    Takes 10 ms mono frames at 48 kHz on the 16-bit scale, in place. */ public final class GainController2 { @@ -39,6 +45,14 @@ public final class GainController2 { this.adaptiveDigitalController = new AdaptiveDigitalGainController(config, ADJACENT_SPEECH_FRAMES_THRESHOLD); } + /** + * Moves the cap on the amplified noise floor away from the configured + * {@code maxOutputNoiseLevelDbfs}, keeping the rest of the controller's state. + */ + public void setMaxOutputNoiseLevelDbfs(float dbfs) { + adaptiveDigitalController.setMaxOutputNoiseLevelDbfs(dbfs); + } + public void process(float[] frame) { final float speechProbability = analyzeVad(frame); diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index dae6979..d1adc73 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -138,6 +138,9 @@ public final class Settings { public boolean typingAttenuation = true; /** Automatic gain control: normalise microphone loudness to a target level. */ public boolean agc = true; + public static final int MAX_AGC_BOOST_DB = 20; + /** How many dB louder AGC may make the background noise to bring up a quiet or distant voice. */ + public int agcBoostDb = 0; /** Beep when speech is picked up while the microphone is muted. */ public boolean mutedTalkWarning = true; @@ -257,6 +260,7 @@ public final class Settings { typingAttenuation = parseB(props.getProperty("typingAttenuation"), typingAttenuation); mutedTalkWarning = parseB(props.getProperty("mutedTalkWarning"), mutedTalkWarning); agc = parseB(props.getProperty("agc"), agc); + agcBoostDb = Math.max(0, Math.min(MAX_AGC_BOOST_DB, parseI(props.getProperty("agcBoostDb"), agcBoostDb))); soundPack = props.getProperty("soundPack", soundPack); soundVolume = parseD(props.getProperty("soundVolume"), soundVolume); soundPackDir = props.getProperty("soundPackDir", soundPackDir); @@ -309,6 +313,7 @@ public final class Settings { props.setProperty("typingAttenuation", Boolean.toString(typingAttenuation)); props.setProperty("mutedTalkWarning", Boolean.toString(mutedTalkWarning)); props.setProperty("agc", Boolean.toString(agc)); + props.setProperty("agcBoostDb", Integer.toString(agcBoostDb)); props.setProperty("soundPack", soundPack); props.setProperty("soundVolume", Double.toString(soundVolume)); props.setProperty("soundPackDir", soundPackDir); diff --git a/ts3-client/core/src/test/java/com/ts3client/audio/processing/AudioProcessorTest.java b/ts3-client/core/src/test/java/com/ts3client/audio/processing/AudioProcessorTest.java index eb45b17..f6283d0 100644 --- a/ts3-client/core/src/test/java/com/ts3client/audio/processing/AudioProcessorTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/audio/processing/AudioProcessorTest.java @@ -166,6 +166,26 @@ class AudioProcessorTest { "quiet speech should be raised by more than 12 dB"); } + @Test + void gainBoostLetsTheNoiseFloorRiseFurther() { + // -60 dBFS of noise alone: AGC2 starts at 15 dB of gain and, with no speech to + // raise it, can only lower it, down to what keeps the noise under its cap. + Random random = new Random(3); + float[] noise = new float[RATE * 3]; + for (int i = 0; i < noise.length; i++) noise[i] = (float) (random.nextGaussian() * 0.001); + + AudioProcessor plain = new AudioProcessor(); + plain.setGainControl(true); + AudioProcessor boosted = new AudioProcessor(); + boosted.setGainControl(true); + boosted.setGainBoost(20); + + double plainDb = 20 * Math.log10(rms(run(plain, noise, Set.of()), RATE * 2, RATE * 3)); + double boostedDb = 20 * Math.log10(rms(run(boosted, noise, Set.of()), RATE * 2, RATE * 3)); + assertEquals(-50, plainDb, 1.5, "the noise is held at AGC2's -50 dBFS cap"); + assertEquals(-45, boostedDb, 1.5, "with the cap 20 dB higher, the initial 15 dB of gain stays"); + } + private static double rms(float[] x, int from, int to) { double sum = 0; for (int i = from; i < to; i++) sum += x[i] * x[i]; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java index 2cb1429..9224f68 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java @@ -33,6 +33,7 @@ final class DevicesPanel extends FormPanel { private final JSlider denoiseLevel; private final JCheckBox typingCheck; private final JCheckBox agcCheck; + private final JSlider agcBoost; private final JCheckBox mutedWarningCheck; DevicesPanel(Settings settings, AudioIo io, VoiceOutput livePlayback, @@ -99,6 +100,12 @@ final class DevicesPanel extends FormPanel { agcCheck.setToolTipText("Automatic gain control normalises your " + "microphone loudness to a target level, boosting quiet mics and taming " + "loud ones."); + agcBoost = new JSlider(0, Settings.MAX_AGC_BOOST_DB, settings.agcBoostDb); + agcBoost.setMajorTickSpacing(5); + agcBoost.setPaintTicks(true); + limitWidth(agcBoost, SLIDER_WIDTH); + agcBoost.setToolTipText("Lets AGC bring up a quiet or distant voice further, " + + "at the cost of up to this many dB more background noise (0–20 dB)."); c.gridx = 0; c.gridy = row++; @@ -113,9 +120,11 @@ final class DevicesPanel extends FormPanel { c.gridy = row++; add(agcCheck, c); c.gridwidth = 1; + addRow(this, c, row++, new JLabel("Boost quiet speech:"), agcBoost); Runnable syncNoise = () -> { denoiseLevel.setEnabled(denoiseCheck.isSelected()); + agcBoost.setEnabled(agcCheck.isSelected()); applyLive.accept(m -> { m.setNoiseSuppression(denoiseCheck.isSelected()); m.setDenoiserLevel(denoiseLevel.getValue()); @@ -128,6 +137,8 @@ final class DevicesPanel extends FormPanel { agcCheck.addActionListener(e -> syncNoise.run()); denoiseLevel.addChangeListener(e -> applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue()))); + agcBoost.addChangeListener(e -> + applyLive.accept(m -> m.setAgcBoost(agcBoost.getValue()))); syncNoise.run(); mutedWarningCheck = new JCheckBox("Beep when talking while muted", settings.mutedTalkWarning); @@ -156,6 +167,7 @@ final class DevicesPanel extends FormPanel { target.denoiserLevel = denoiseLevel.getValue(); target.typingAttenuation = typingCheck.isSelected(); target.agc = agcCheck.isSelected(); + target.agcBoostDb = agcBoost.getValue(); target.mutedTalkWarning = mutedWarningCheck.isSelected(); }