summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile.am4
-rw-r--r--pica-client/audiodevice.cpp64
-rw-r--r--pica-client/audiodevice.h36
-rw-r--r--pica-client/audiovideocallcontroller.cpp152
-rw-r--r--pica-client/audiovideocallcontroller.h29
-rw-r--r--pica-client/capturegain.cpp457
-rw-r--r--pica-client/capturegain.h69
-rw-r--r--pica-client/capturegain_coreaudio.cpp265
-rw-r--r--pica-client/capturegain_wasapi.cpp153
-rw-r--r--pica-client/dialogs/settingsdialog.cpp10
-rw-r--r--pica-client/dialogs/settingsdialog.h1
-rw-r--r--pica-client/echocanceller.cpp75
-rw-r--r--pica-client/echocanceller.h18
-rw-r--r--pica-client/pica-client.pro4
14 files changed, 1326 insertions, 11 deletions
diff --git a/Makefile.am b/Makefile.am
index c8b388a..b5613d7 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -110,6 +110,10 @@ pica-client/nativeaudio.cpp \
pica-client/nativeaudio.h \
pica-client/nativeaudio_wasapi.cpp \
pica-client/nativeaudio_coreaudio.cpp \
+pica-client/capturegain.cpp \
+pica-client/capturegain.h \
+pica-client/capturegain_wasapi.cpp \
+pica-client/capturegain_coreaudio.cpp \
pica-client/videodevice.cpp \
pica-client/videodevice.h \
pica-client/videodshow.cpp \
diff --git a/pica-client/audiodevice.cpp b/pica-client/audiodevice.cpp
index f0238e4..e04aca9 100644
--- a/pica-client/audiodevice.cpp
+++ b/pica-client/audiodevice.cpp
@@ -20,6 +20,7 @@
#include <QDebug>
#include <QMutexLocker>
#include <QVector>
+#include <cmath>
#include <cstring>
#ifdef Q_OS_LINUX
@@ -175,10 +176,47 @@ static AVSampleFormat chooseEncoderSampleFmt(const AVCodec *enc)
AudioDevice::AudioDevice(QObject *parent)
: QObject(parent), m_sampleRate(48000), m_abort(0),
+ m_clipCount(0), m_levelCount(0), m_levelPeak(0),
m_lastPlayedSeq(0), m_havePlayedSeq(false)
{
}
+void AudioDevice::resetCaptureLevel()
+{
+ m_clipCount = 0;
+ m_levelCount = 0;
+ m_levelPeak = 0;
+}
+
+void AudioDevice::checkCaptureLevel(const qint16 *pcm, int nsamples)
+{
+ for (int i = 0; i < nsamples; i++)
+ {
+ // qint16 promotes to int, so negating -32768 is well defined here.
+ int v = pcm[i] < 0 ? -(int)pcm[i] : (int)pcm[i];
+
+ if (v > m_levelPeak)
+ m_levelPeak = v;
+ if (v >= kClipThreshold)
+ m_clipCount++;
+ }
+
+ m_levelCount += nsamples;
+
+ if (m_levelCount < qint64(m_sampleRate) * kLevelReportSec)
+ return;
+
+ double pinned = 100.0 * double(m_clipCount) / double(m_levelCount);
+
+ if (pinned >= kClipWarnPercent)
+ {
+ double peakDb = m_levelPeak > 0 ? 20.0 * log10(double(m_levelPeak) / 32768.0) : -120.0;
+ emit captureClipping(pinned, peakDb);
+ }
+
+ resetCaptureLevel();
+}
+
AudioDevice::~AudioDevice()
{
Close();
@@ -294,6 +332,7 @@ bool AudioDevice::nextPacket(QByteArray *out)
void AudioDevice::Capture()
{
m_abort.storeRelaxed(0);
+ resetCaptureLevel();
const QString driver = AudioDevice::PlatformDriverName(CAPTURE);
@@ -519,6 +558,12 @@ void AudioDevice::captureFFmpeg(const QString &driver)
av_audio_fifo_read(fifo, (void **)enc_frame->data, frame_size);
+ // Both of these want interleaved 16 bit. That is what libopus
+ // takes, so it is the normal path; the native "opus" encoder
+ // fallback runs in planar float and gets neither.
+ if (enc_ctx->sample_fmt == AV_SAMPLE_FMT_S16)
+ checkCaptureLevel((const qint16 *)enc_frame->data[0], frame_size);
+
// Take the echo of what the playback direction has been
// feeding the speaker back out of the microphone signal,
// before it is encoded and sent.
@@ -677,6 +722,10 @@ void AudioDevice::captureNative(const QString &api)
memcpy(block.data(), pending.constData() + consumed, frame_size * sizeof(qint16));
consumed += frame_size;
+ // The backend always delivers interleaved 16 bit, so unlike the
+ // FFmpeg path this needs no format check.
+ checkCaptureLevel(block.constData(), frame_size);
+
if (ec)
ec->processNearEnd(block.data(), frame_size);
@@ -841,8 +890,12 @@ void AudioDevice::playFFmpeg(const QString &driver)
return;
}
+ // A rate mismatch is not a reason to drop it any more - pushFarEnd()
+ // resamples. The two directions genuinely can disagree: we capture at the
+ // rate AudioVideoCallController picked, but play back at whatever the
+ // peer announced.
EchoCancellerPtr ec = m_echoCanceller;
- if (ec && (!ec->isValid() || ec->sampleRate() != m_sampleRate))
+ if (ec && !ec->isValid())
ec.clear();
SwrContext *swr = nullptr;
@@ -923,7 +976,7 @@ void AudioDevice::playFFmpeg(const QString &driver)
// moment the sound card gets it, so the delay it has to
// model is only the device buffer plus the room.
if (ec)
- ec->pushFarEnd((const qint16 *)out_frame->data[0], converted_samples);
+ ec->pushFarEnd((const qint16 *)out_frame->data[0], converted_samples, m_sampleRate);
if (avcodec_send_frame(out_ctx, out_frame) == 0)
{
@@ -999,9 +1052,10 @@ void AudioDevice::playNative(const QString &api)
// On a platform that cancels for us, the far end signal it needs is the
// one it is rendering itself - there is nothing to hand to ours, and
- // nothing for ours to do.
+ // nothing for ours to do. A rate mismatch is handled by pushFarEnd()
+ // rather than by dropping the canceller, see playFFmpeg().
EchoCancellerPtr ec = m_echoCanceller;
- if (ec && (!ec->isValid() || ec->sampleRate() != m_sampleRate))
+ if (ec && !ec->isValid())
ec.clear();
SwrContext *swr = nullptr;
@@ -1070,7 +1124,7 @@ void AudioDevice::playNative(const QString &api)
if (converted > 0)
{
if (ec)
- ec->pushFarEnd(out.constData(), converted * kChannels);
+ ec->pushFarEnd(out.constData(), converted * kChannels, m_sampleRate);
if (backend->write(out.constData(), converted * kChannels, 100) < 0)
{
diff --git a/pica-client/audiodevice.h b/pica-client/audiodevice.h
index ef7e5b3..6b2db1c 100644
--- a/pica-client/audiodevice.h
+++ b/pica-client/audiodevice.h
@@ -99,6 +99,14 @@ signals:
// Counterpart of VideoDevice::accelerationInUse().
void deviceFormatInUse(QString description);
+ // The capture device is delivering samples pinned at full scale, with the
+ // share of them in the last window and the peak that was seen. Not an
+ // error in the errorOccurred() sense - the call carries on - but it wants
+ // acting on, because nothing downstream can undo it. Numbers rather than
+ // a message because the receiver drives the gain control with them; see
+ // AudioVideoCallController::capture_clipping().
+ void captureClipping(double pinnedPercent, double peakDb);
+
public:
virtual QList<MediaDeviceInfo> Enumerate(enum MediaDeviceStreamDirection dir);
@@ -155,6 +163,34 @@ private:
// joining either thread.
EchoCancellerPtr m_echoCanceller;
+ // Watches the microphone for samples pinned at the rail and warns once
+ // every kLevelReportSec of audio if too many of them are.
+ //
+ // Detection only - there is deliberately no gain applied here. Clipping
+ // happens in the converter, before we see anything, and scaling a clipped
+ // buffer down just produces a quieter clipped buffer: the flattened peaks,
+ // the harmonics they generate and the non-linearity that stops the echo
+ // canceller converging are all still in it. The only fix is less gain
+ // ahead of the ADC, which means the capture device's own mixer control.
+ void resetCaptureLevel();
+ void checkCaptureLevel(const qint16 *pcm, int nsamples);
+
+ qint64 m_clipCount;
+ qint64 m_levelCount;
+ int m_levelPeak;
+
+ // |sample| at or above this counts as pinned. 32512 is -0.13 dBFS: low
+ // enough to catch a converter that rails slightly short of 0x7fff, high
+ // enough that ordinary loud speech does not trip it.
+ static const int kClipThreshold = 32512;
+
+ // Fraction of pinned samples in a window that is worth complaining about,
+ // in percent. A stray sample at full scale is normal; a twentieth of a
+ // percent of them is not.
+ static constexpr double kClipWarnPercent = 0.05;
+
+ static const int kLevelReportSec = 2;
+
// The FFmpeg and the native halves of each direction. Which one runs is
// decided by PlatformDriverName().
void captureFFmpeg(const QString &driver);
diff --git a/pica-client/audiovideocallcontroller.cpp b/pica-client/audiovideocallcontroller.cpp
index 1f6e5ce..763f1b1 100644
--- a/pica-client/audiovideocallcontroller.cpp
+++ b/pica-client/audiovideocallcontroller.cpp
@@ -28,9 +28,29 @@
static const quint16 kVideoWidth = 640;
static const quint16 kVideoHeight = 480;
+// Rate we capture and encode at, announced to the peer in the 0x74 message.
+//
+// 16kHz rather than 48kHz because of the echo canceller. speexdsp's MDF is
+// tuned for 8/16kHz, and its cost is counted in taps: a 150ms tail is 7200
+// taps at 48kHz but only 2400 at 16kHz. Convergence time and steady state
+// misadjustment both scale with that length, so the 48kHz filter spent every
+// call chasing and never settling - visible in the AEC log as ERLE climbing
+// towards 20dB and being knocked back to zero by each doubletalk burst.
+//
+// Costs nothing worth having for speech: Opus codes this as wideband, an 8kHz
+// audio bandwidth, which is well past telephone quality and past where voice
+// intelligibility stops improving.
+//
+// The playback direction is NOT this constant - it follows whatever the peer
+// announces (see incoming_audio_params()), so a call with an older build that
+// still sends 48kHz keeps working.
+static const int kCallSampleRate = 16000;
+
AudioVideoCallController::AudioVideoCallController(QObject *parent)
- : QObject(parent), callwindow(0), ringdevice(0), is_active(false), m_audioSeq(0),
+ : QObject(parent), callwindow(0), ringdevice(0), is_active(false),
+ m_captureGain(nullptr), m_savedCaptureGain(0.0), m_haveSavedCaptureGain(false),
+ m_captureGainSteps(0), m_audioSeq(0),
m_videoSeq(0), m_videoFrameTimestamp(0), m_videoFrameStarted(false)
{
connect(skynet, SIGNAL(IncomingCall(QByteArray)), this, SLOT(call_from(QByteArray)));
@@ -66,6 +86,12 @@ AudioVideoCallController::AudioVideoCallController(QObject *parent)
microphone = new AudioDevice();
microphone->moveToThread(&microphone_thread);
connect(microphone, SIGNAL(packetReady(QByteArray)), this, SLOT(send_audio_packet(QByteArray)));
+
+ // Queued rather than direct: this crosses from the capture thread, and
+ // warn_capture_clipping() only logs today but is where a UI notice would
+ // go.
+ connect(microphone, SIGNAL(captureClipping(double,double)), this,
+ SLOT(capture_clipping(double,double)), Qt::QueuedConnection);
microphone_thread.start();
output = new AudioDevice();
@@ -166,7 +192,7 @@ void AudioVideoCallController::startAudioPipeline()
// - see AudioDevice::captureNative(), which drops it once the backend
// says the platform has the job in hand.
if (st.loadValue("audio.echo_cancel", 1).toBool())
- m_echoCanceller = EchoCancellerPtr(new EchoCanceller(48000));
+ m_echoCanceller = EchoCancellerPtr(new EchoCanceller(kCallSampleRate));
else
m_echoCanceller.clear();
@@ -178,14 +204,18 @@ void AudioVideoCallController::startAudioPipeline()
microphone->setEchoCanceller(m_echoCanceller);
output->setEchoCanceller(m_echoCanceller);
+ // Before Capture() starts, so the gain is already read and saved by the
+ // time the first clipping report can come back.
+ startCaptureGainControl(capDev);
+
// Declare our outgoing codec to the peer, then start capturing and
// encoding right away - the peer's decoder is only ready once it has
// processed this 0x74 message, but since the call is carried over TCP,
// packets sent immediately after are guaranteed to arrive after it.
- skynet->SendAudioParams(m_peer_id, QStringLiteral("opus"), 48000);
+ skynet->SendAudioParams(m_peer_id, QStringLiteral("opus"), kCallSampleRate);
QMetaObject::invokeMethod(microphone, "configureCapture", Qt::QueuedConnection,
- Q_ARG(QString, capDev), Q_ARG(QString, QStringLiteral("opus")), Q_ARG(int, 48000));
+ Q_ARG(QString, capDev), Q_ARG(QString, QStringLiteral("opus")), Q_ARG(int, kCallSampleRate));
QMetaObject::invokeMethod(microphone, "Capture", Qt::QueuedConnection);
// output is configured lazily, once the peer's own 0x74 (see
@@ -206,6 +236,12 @@ void AudioVideoCallController::stopAudioPipeline()
microphone->Close();
output->Close();
+ // Puts the user's gain back. Safe to do while the capture thread is still
+ // winding down: the control is only ever touched from this thread, and
+ // capture_clipping() finds m_captureGain null and does nothing if a last
+ // queued report arrives after this.
+ stopCaptureGainControl();
+
// Drop our reference. The two audio threads still hold theirs until they
// come out of Capture()/Play(), and the canceller goes away with the last
// of them.
@@ -430,6 +466,114 @@ void AudioVideoCallController::send_audio_packet(QByteArray data)
skynet->SendAudioPacket(m_peer_id, seq, timestamp, data);
}
+// Rate limited by AudioDevice, which reports at most once every couple of
+// seconds of audio, so this can log and act unconditionally.
+void AudioVideoCallController::capture_clipping(double pinnedPercent, double peakDb)
+{
+ qWarning("Microphone is clipping: %.2f%% of samples at full scale, peak %.1f dBFS",
+ pinnedPercent, peakDb);
+
+ // Either the setting is off, or nothing on this platform would let us at
+ // the control. The warning above is then all we can do - and it is still
+ // worth having, since the distortion cannot be undone anywhere else.
+ if (!m_captureGain)
+ return;
+
+ if (m_captureGainSteps >= kMaxCaptureGainSteps)
+ return;
+
+ double current = 0.0;
+ if (!m_captureGain->gain(&current))
+ return;
+
+ // Size the step to how badly it is clipping. A microphone left at maximum
+ // is 20-30dB too hot and wants covering quickly; one that only tips over
+ // on the loudest syllable wants nudging, not slamming.
+ double factor;
+ if (pinnedPercent >= 5.0)
+ factor = 0.70;
+ else if (pinnedPercent >= 0.5)
+ factor = 0.85;
+ else
+ factor = 0.95;
+
+ double next = current * factor;
+ if (next < kMinCaptureGain)
+ next = kMinCaptureGain;
+
+ if (next >= current)
+ return; // already at the floor, nothing left to give
+
+ if (!m_captureGain->setGain(next))
+ {
+ qWarning() << "Could not lower the capture gain - leaving it alone";
+ m_captureGainSteps = kMaxCaptureGainSteps;
+ return;
+ }
+
+ m_captureGainSteps++;
+ qDebug("Capture gain %.0f%% -> %.0f%% (step %d of %d)",
+ current * 100.0, next * 100.0, m_captureGainSteps, kMaxCaptureGainSteps);
+}
+
+void AudioVideoCallController::startCaptureGainControl(const QString &captureDevice)
+{
+ m_captureGain = nullptr;
+ m_savedCaptureGain = 0.0;
+ m_haveSavedCaptureGain = false;
+ m_captureGainSteps = 0;
+
+ Settings st(config_dbname);
+ if (!st.loadValue("audio.auto_capture_gain", 1).toBool())
+ return;
+
+ // Whichever driver the capture side is about to open the device with -
+ // the mixer control has to be the one belonging to that device.
+ const QString driver = AudioDevice::PlatformDriverName(CAPTURE);
+
+ m_captureGain = CaptureGainControl::create(driver, captureDevice);
+ if (!m_captureGain)
+ return;
+
+ if (m_captureGain->gain(&m_savedCaptureGain))
+ {
+ m_haveSavedCaptureGain = true;
+ qDebug() << "Automatic microphone gain using" << m_captureGain->description()
+ << "- currently at" << qRound(m_savedCaptureGain * 100.0) << "%";
+ }
+ else
+ {
+ // Without a reading there is nothing to put back afterwards, and
+ // moving a control we cannot restore is not a trade worth making.
+ qWarning() << "Could not read the capture gain - not taking it over";
+ delete m_captureGain;
+ m_captureGain = nullptr;
+ }
+}
+
+void AudioVideoCallController::stopCaptureGainControl()
+{
+ if (!m_captureGain)
+ return;
+
+ // Put the user's own setting back. Only bother if we actually moved it,
+ // so a call that never clipped cannot disturb a gain the user changed
+ // themselves while it was running.
+ if (m_haveSavedCaptureGain && m_captureGainSteps > 0)
+ {
+ if (m_captureGain->setGain(m_savedCaptureGain))
+ qDebug() << "Capture gain restored to" << qRound(m_savedCaptureGain * 100.0) << "%";
+ else
+ qWarning() << "Could not restore the capture gain to"
+ << qRound(m_savedCaptureGain * 100.0) << "% - it has been left turned down";
+ }
+
+ delete m_captureGain;
+ m_captureGain = nullptr;
+ m_haveSavedCaptureGain = false;
+ m_captureGainSteps = 0;
+}
+
void AudioVideoCallController::incoming_audio_params(QByteArray peer_id, QString codec, quint16 sample_rate)
{
if (!is_active || peer_id != m_peer_id)
diff --git a/pica-client/audiovideocallcontroller.h b/pica-client/audiovideocallcontroller.h
index 1dd9f20..e30f4c1 100644
--- a/pica-client/audiovideocallcontroller.h
+++ b/pica-client/audiovideocallcontroller.h
@@ -22,6 +22,7 @@
#include <QElapsedTimer>
#include "callwindow.h"
#include "audiodevice.h"
+#include "capturegain.h"
#include "videodevice.h"
#include "toneplayer/toneplayer.h"
@@ -78,6 +79,27 @@ private:
// speexdsp could not start one, or between calls.
EchoCancellerPtr m_echoCanceller;
+ // The capture device's mixer control, held for the duration of a call so
+ // a clipping microphone can be turned down. Null when the setting is off,
+ // when the platform has no reachable control, or between calls.
+ //
+ // m_savedCaptureGain is where the control was found at the start of the
+ // call, put back in stopAudioPipeline(): this is the user's own setting
+ // and we are only borrowing it. m_captureGainSteps bounds how far we will
+ // go, so a microphone that clips for some reason turning it down cannot
+ // fix - a broken preamp, someone shouting into it - ends up quiet rather
+ // than muted.
+ CaptureGainControl *m_captureGain;
+ double m_savedCaptureGain;
+ bool m_haveSavedCaptureGain;
+ int m_captureGainSteps;
+
+ static const int kMaxCaptureGainSteps = 12;
+
+ // Never go below this fraction of the control's travel; past it we are
+ // making the user inaudible to fix a problem they can hear about instead.
+ static constexpr double kMinCaptureGain = 0.05;
+
// Sequence number / capture-clock timestamp stamped onto outgoing
// 0x76 audio packets (see PICA_PROTO_CALL_PACKET_HDRSIZE).
quint16 m_audioSeq;
@@ -95,6 +117,12 @@ private:
// Reassembly of incoming 0x77 fragments into whole encoded frames.
VideoFrameAssembler m_videoAssembler;
+ // Take over the capture device's mixer gain for the duration of a call,
+ // and hand it back. Both are no-ops when "audio.auto_capture_gain" is off
+ // or the platform has no control we can reach.
+ void startCaptureGainControl(const QString &captureDevice);
+ void stopCaptureGainControl();
+
void playEarpieceTone(void (TonePlayer::*tone)());
void playRingTone();
void stopTones();
@@ -114,6 +142,7 @@ private slots:
void send_audio_packet(QByteArray data);
void incoming_audio_params(QByteArray peer_id, QString codec, quint16 sample_rate);
+ void capture_clipping(double pinnedPercent, double peakDb);
void incoming_audio_packet(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
void video_capture_started(QString codec, int width, int height);
diff --git a/pica-client/capturegain.cpp b/pica-client/capturegain.cpp
new file mode 100644
index 0000000..2f2ea2d
--- /dev/null
+++ b/pica-client/capturegain.cpp
@@ -0,0 +1,457 @@
+/*
+ (c) Copyright 2012 - 2026 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#include "capturegain.h"
+
+#include <QDebug>
+#include <QtGlobal>
+#include <cmath>
+
+#ifdef Q_OS_LINUX
+#include <alsa/asoundlib.h>
+#endif
+
+#ifdef HAVE_LIBPULSE
+#include <pulse/pulseaudio.h>
+#endif
+
+// Per platform factories, defined next to the implementations themselves -
+// same arrangement as NativeAudioBackend::create().
+#ifdef Q_OS_WIN
+CaptureGainControl *pica_create_wasapi_capture_gain(const QString &device);
+#endif
+
+#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
+CaptureGainControl *pica_create_coreaudio_capture_gain(const QString &device);
+#endif
+
+CaptureGainControl::~CaptureGainControl()
+{
+}
+
+static double clampGain(double v)
+{
+ if (v < 0.0)
+ return 0.0;
+ if (v > 1.0)
+ return 1.0;
+ return v;
+}
+
+// PulseAudio is the one backend whose control goes past its nominal maximum -
+// PA_VOLUME_NORM is 100% and the server will happily sit above it. Clamping a
+// reading to 1.0 there would mean handing a user who had their source at 130%
+// back at 100% when the call ended, so that path keeps whatever it read and
+// only guards the bottom of the range and a runaway top.
+static double clampGainAmplified(double v)
+{
+ if (v < 0.0)
+ return 0.0;
+ if (v > 2.5)
+ return 2.5;
+ return v;
+}
+
+#ifdef Q_OS_LINUX
+
+// The mixer lives on a card, but what we are handed is a PCM name. The two
+// namespaces overlap only by convention, so this covers the forms that
+// actually turn up in the device list and falls back to the default card for
+// anything else - which is right for plain "default" and no worse than
+// nothing for the exotic cases.
+static QString alsaCardFromPcm(const QString &pcm)
+{
+ int card = pcm.indexOf(QLatin1String("CARD="));
+ if (card >= 0)
+ {
+ QString name = pcm.mid(card + 5);
+ int comma = name.indexOf(QLatin1Char(','));
+ if (comma >= 0)
+ name = name.left(comma);
+ if (!name.isEmpty())
+ return QStringLiteral("hw:") + name;
+ }
+
+ // "hw:1,0", "plughw:CARD=PCH,DEV=0", "dsnoop:1" - everything before the
+ // first comma after the colon names the card.
+ int colon = pcm.indexOf(QLatin1Char(':'));
+ if (colon >= 0)
+ {
+ QString rest = pcm.mid(colon + 1);
+ int comma = rest.indexOf(QLatin1Char(','));
+ if (comma >= 0)
+ rest = rest.left(comma);
+ if (!rest.isEmpty())
+ return QStringLiteral("hw:") + rest;
+ }
+
+ return QStringLiteral("default");
+}
+
+class AlsaCaptureGain : public CaptureGainControl
+{
+public:
+ AlsaCaptureGain() : m_mixer(nullptr), m_elem(nullptr), m_min(0), m_max(0) {}
+
+ ~AlsaCaptureGain() override
+ {
+ if (m_mixer)
+ snd_mixer_close(m_mixer);
+ }
+
+ bool open(const QString &pcmName)
+ {
+ const QString card = alsaCardFromPcm(pcmName);
+ const QByteArray cardUtf8 = card.toUtf8();
+
+ if (snd_mixer_open(&m_mixer, 0) < 0)
+ {
+ m_mixer = nullptr;
+ return false;
+ }
+
+ if (snd_mixer_attach(m_mixer, cardUtf8.constData()) < 0 ||
+ snd_mixer_selem_register(m_mixer, nullptr, nullptr) < 0 ||
+ snd_mixer_load(m_mixer) < 0)
+ return false;
+
+ // "Capture" is what the recording gain is called on essentially every
+ // card that has one; "Mic" turns up on the ones that do not separate
+ // the input selector from the gain. Failing both, take the first
+ // element that has a capture volume at all.
+ m_elem = findNamed("Capture");
+ if (!m_elem)
+ m_elem = findNamed("Mic");
+ if (!m_elem)
+ {
+ for (snd_mixer_elem_t *e = snd_mixer_first_elem(m_mixer); e; e = snd_mixer_elem_next(e))
+ {
+ if (snd_mixer_selem_is_active(e) && snd_mixer_selem_has_capture_volume(e))
+ {
+ m_elem = e;
+ break;
+ }
+ }
+ }
+
+ if (!m_elem)
+ return false;
+
+ if (snd_mixer_selem_get_capture_volume_range(m_elem, &m_min, &m_max) < 0 || m_max <= m_min)
+ return false;
+
+ m_name = QString::fromUtf8(snd_mixer_selem_get_name(m_elem)) +
+ QStringLiteral(" on ") + card;
+ return true;
+ }
+
+ bool gain(double *out) override
+ {
+ if (!m_elem || !out)
+ return false;
+
+ // The kernel may have moved the control under us - another mixer app,
+ // or the user's own volume keys.
+ snd_mixer_handle_events(m_mixer);
+
+ long v = 0;
+ if (snd_mixer_selem_get_capture_volume(m_elem, SND_MIXER_SCHN_FRONT_LEFT, &v) < 0 &&
+ snd_mixer_selem_get_capture_volume(m_elem, SND_MIXER_SCHN_MONO, &v) < 0)
+ return false;
+
+ *out = clampGain(double(v - m_min) / double(m_max - m_min));
+ return true;
+ }
+
+ bool setGain(double value) override
+ {
+ if (!m_elem)
+ return false;
+
+ long v = m_min + (long)llround(clampGain(value) * double(m_max - m_min));
+ return snd_mixer_selem_set_capture_volume_all(m_elem, v) >= 0;
+ }
+
+ QString description() const override { return m_name; }
+
+private:
+ snd_mixer_elem_t *findNamed(const char *want)
+ {
+ for (snd_mixer_elem_t *e = snd_mixer_first_elem(m_mixer); e; e = snd_mixer_elem_next(e))
+ {
+ if (!snd_mixer_selem_is_active(e) || !snd_mixer_selem_has_capture_volume(e))
+ continue;
+ if (qstrcmp(snd_mixer_selem_get_name(e), want) == 0)
+ return e;
+ }
+ return nullptr;
+ }
+
+ snd_mixer_t *m_mixer;
+ snd_mixer_elem_t *m_elem;
+ long m_min;
+ long m_max;
+ QString m_name;
+};
+
+#endif // Q_OS_LINUX
+
+#ifdef HAVE_LIBPULSE
+
+// PulseAudio's introspection and its setters are both asynchronous, so this
+// keeps a connected context with a private mainloop and pumps it until each
+// operation has called back. Same shape as enumeratePulse() in
+// audiodevice.cpp, but long lived, since a call makes several of these.
+class PulseCaptureGain : public CaptureGainControl
+{
+public:
+ PulseCaptureGain()
+ : m_mainloop(nullptr), m_ctx(nullptr), m_channels(1),
+ m_pending(false), m_ok(false), m_value(0.0) {}
+
+ ~PulseCaptureGain() override
+ {
+ if (m_ctx)
+ {
+ pa_context_disconnect(m_ctx);
+ pa_context_unref(m_ctx);
+ }
+ if (m_mainloop)
+ pa_mainloop_free(m_mainloop);
+ }
+
+ bool open(const QString &device)
+ {
+ m_mainloop = pa_mainloop_new();
+ if (!m_mainloop)
+ return false;
+
+ m_ctx = pa_context_new(pa_mainloop_get_api(m_mainloop), "pica-client");
+ if (!m_ctx)
+ return false;
+
+ if (pa_context_connect(m_ctx, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0)
+ return false;
+
+ // Five seconds at 50ms a turn, so a server that never answers cannot
+ // wedge the call setup.
+ for (int i = 0; i < 100; i++)
+ {
+ pa_context_state_t st = pa_context_get_state(m_ctx);
+ if (st == PA_CONTEXT_READY)
+ break;
+ if (st == PA_CONTEXT_FAILED || st == PA_CONTEXT_TERMINATED)
+ return false;
+ if (!pump())
+ return false;
+ }
+
+ if (pa_context_get_state(m_ctx) != PA_CONTEXT_READY)
+ return false;
+
+ m_source = device;
+ if (m_source.isEmpty() || m_source == QLatin1String("default"))
+ {
+ if (!resolveDefaultSource())
+ return false;
+ }
+
+ // Also establishes the channel count, which setGain() needs.
+ double ignored;
+ return gain(&ignored);
+ }
+
+ bool gain(double *out) override
+ {
+ if (!m_ctx || m_source.isEmpty())
+ return false;
+
+ const QByteArray name = m_source.toUtf8();
+ m_pending = true;
+ m_ok = false;
+
+ pa_operation *op = pa_context_get_source_info_by_name(m_ctx, name.constData(),
+ sourceInfoCb, this);
+ if (!await(op))
+ return false;
+
+ if (m_ok && out)
+ *out = m_value;
+ return m_ok;
+ }
+
+ bool setGain(double value) override
+ {
+ if (!m_ctx || m_source.isEmpty())
+ return false;
+
+ pa_cvolume cv;
+ pa_cvolume_set(&cv, m_channels ? m_channels : 1,
+ (pa_volume_t)llround(clampGainAmplified(value) * double(PA_VOLUME_NORM)));
+
+ const QByteArray name = m_source.toUtf8();
+ m_pending = true;
+ m_ok = false;
+
+ pa_operation *op = pa_context_set_source_volume_by_name(m_ctx, name.constData(), &cv,
+ successCb, this);
+ if (!await(op))
+ return false;
+
+ return m_ok;
+ }
+
+ QString description() const override
+ {
+ return m_source + QStringLiteral(" (PulseAudio)");
+ }
+
+private:
+ bool pump()
+ {
+ if (pa_mainloop_prepare(m_mainloop, 50 * 1000) < 0)
+ return false;
+ if (pa_mainloop_poll(m_mainloop) < 0)
+ return false;
+ if (pa_mainloop_dispatch(m_mainloop) < 0)
+ return false;
+ return true;
+ }
+
+ // Runs the loop until the callback for op has fired, then reaps it.
+ bool await(pa_operation *op)
+ {
+ if (!op)
+ {
+ m_pending = false;
+ return false;
+ }
+
+ for (int i = 0; i < 100 && m_pending; i++)
+ {
+ if (!pump())
+ break;
+ }
+
+ bool finished = !m_pending;
+ m_pending = false;
+ pa_operation_unref(op);
+ return finished;
+ }
+
+ bool resolveDefaultSource()
+ {
+ m_pending = true;
+ m_ok = false;
+
+ pa_operation *op = pa_context_get_server_info(m_ctx, serverInfoCb, this);
+ if (!await(op))
+ return false;
+
+ return m_ok && !m_source.isEmpty();
+ }
+
+ static void serverInfoCb(pa_context *, const pa_server_info *info, void *userdata)
+ {
+ PulseCaptureGain *self = (PulseCaptureGain *)userdata;
+
+ if (info && info->default_source_name)
+ {
+ self->m_source = QString::fromUtf8(info->default_source_name);
+ self->m_ok = true;
+ }
+ self->m_pending = false;
+ }
+
+ static void sourceInfoCb(pa_context *, const pa_source_info *info, int eol, void *userdata)
+ {
+ PulseCaptureGain *self = (PulseCaptureGain *)userdata;
+
+ if (eol)
+ {
+ self->m_pending = false;
+ return;
+ }
+
+ if (!info)
+ return;
+
+ self->m_channels = info->volume.channels;
+ // The loudest channel, so a step down is a step down for all of them
+ // rather than only for whichever happens to be quietest.
+ self->m_value = clampGainAmplified(double(pa_cvolume_max(&info->volume)) / double(PA_VOLUME_NORM));
+ self->m_ok = true;
+ }
+
+ static void successCb(pa_context *, int success, void *userdata)
+ {
+ PulseCaptureGain *self = (PulseCaptureGain *)userdata;
+ self->m_ok = (success != 0);
+ self->m_pending = false;
+ }
+
+ pa_mainloop *m_mainloop;
+ pa_context *m_ctx;
+ QString m_source;
+ uint8_t m_channels;
+
+ bool m_pending;
+ bool m_ok;
+ double m_value;
+};
+
+#endif // HAVE_LIBPULSE
+
+CaptureGainControl *CaptureGainControl::create(const QString &driver, const QString &device)
+{
+#ifdef Q_OS_WIN
+ if (driver == QLatin1String("platform-wasapi"))
+ return pica_create_wasapi_capture_gain(device);
+#endif
+
+#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
+ if (driver == QLatin1String("platform-coreaudio"))
+ return pica_create_coreaudio_capture_gain(device);
+#endif
+
+#ifdef HAVE_LIBPULSE
+ if (driver == QLatin1String("pulse"))
+ {
+ PulseCaptureGain *g = new PulseCaptureGain();
+ if (g->open(device))
+ return g;
+ delete g;
+ qWarning() << "No PulseAudio volume control for capture source" << device;
+ return nullptr;
+ }
+#endif
+
+#ifdef Q_OS_LINUX
+ if (driver == QLatin1String("alsa"))
+ {
+ AlsaCaptureGain *g = new AlsaCaptureGain();
+ if (g->open(device))
+ return g;
+ delete g;
+ qWarning() << "No ALSA capture volume control for device" << device;
+ return nullptr;
+ }
+#endif
+
+ Q_UNUSED(driver)
+ Q_UNUSED(device)
+ return nullptr;
+}
diff --git a/pica-client/capturegain.h b/pica-client/capturegain.h
new file mode 100644
index 0000000..4d833f1
--- /dev/null
+++ b/pica-client/capturegain.h
@@ -0,0 +1,69 @@
+/*
+ (c) Copyright 2012 - 2026 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#ifndef CAPTUREGAIN_H
+#define CAPTUREGAIN_H
+
+#include <QString>
+
+// The capture device's own gain control - the mixer slider, not a multiplier
+// applied to samples we already have.
+//
+// The distinction is the entire point of this class. Clipping happens in the
+// converter, before any buffer reaches us; by then the peaks are flat and the
+// harmonics they generated are in the signal for good. Scaling that buffer
+// down produces a quieter clipped buffer and nothing else - in particular it
+// does not help the echo canceller, whose adaptive filter is linear and
+// therefore scale invariant. What defeats the filter is the non-linearity, and
+// the only place to prevent one is ahead of the ADC. That is this control.
+//
+// Gain is expressed as a 0.0 to 1.0 fraction of the control's travel, which is
+// what WASAPI and CoreAudio use natively and what ALSA and PulseAudio's ranges
+// map onto cleanly. The mapping from that fraction to decibels is the
+// platform's business and is not the same on all of them, so callers should
+// treat a step as "somewhat quieter" and close the loop by measuring again
+// rather than doing arithmetic in dB.
+//
+// Not thread safe, and deliberately so: AudioVideoCallController owns one of
+// these and touches it only from the GUI thread, driven by a queued signal
+// from the capture thread. Some of the implementations below need a message
+// loop or a COM apartment and have no business being called from an audio
+// callback.
+class CaptureGainControl
+{
+public:
+ virtual ~CaptureGainControl();
+
+ // Current position of the control, 0.0 to 1.0. False if it could not be
+ // read, in which case *out is untouched.
+ virtual bool gain(double *out) = 0;
+
+ // Moves the control. Values outside 0.0 to 1.0 are clamped.
+ virtual bool setGain(double value) = 0;
+
+ // What was actually found - the mixer element or endpoint name - for the
+ // log line that says the gain is being taken over.
+ virtual QString description() const = 0;
+
+ // driver is a name from AudioDevice::PlatformDriverName(), device the
+ // capture device string that goes with it. Returns null when this build
+ // or this platform has no way to reach the control, which is not an
+ // error: the caller carries on without automatic gain and the clipping
+ // warning still gets shown.
+ static CaptureGainControl *create(const QString &driver, const QString &device);
+};
+
+#endif // CAPTUREGAIN_H
diff --git a/pica-client/capturegain_coreaudio.cpp b/pica-client/capturegain_coreaudio.cpp
new file mode 100644
index 0000000..59b1ff5
--- /dev/null
+++ b/pica-client/capturegain_coreaudio.cpp
@@ -0,0 +1,265 @@
+/*
+ (c) Copyright 2012 - 2026 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#include "capturegain.h"
+
+#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
+
+#include <QDebug>
+#include <QVector>
+
+#include <CoreAudio/CoreAudio.h>
+#include <CoreFoundation/CoreFoundation.h>
+
+// Small local copies of the device lookup helpers in
+// nativeaudio_coreaudio.cpp. They are static over there and this is the only
+// other place that needs them; duplicating three short functions beats
+// exporting an internal API out of that file.
+static QVector<AudioDeviceID> allInputCapableDevices()
+{
+ QVector<AudioDeviceID> devices;
+
+ AudioObjectPropertyAddress addr;
+ addr.mSelector = kAudioHardwarePropertyDevices;
+ addr.mScope = kAudioObjectPropertyScopeGlobal;
+ addr.mElement = kAudioObjectPropertyElementMaster;
+
+ UInt32 size = 0;
+ if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &addr, 0, nullptr, &size) != noErr)
+ return devices;
+
+ devices.resize(size / sizeof(AudioDeviceID));
+ if (devices.isEmpty())
+ return devices;
+
+ if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, nullptr, &size,
+ devices.data()) != noErr)
+ devices.clear();
+
+ return devices;
+}
+
+static QString deviceUidOf(AudioDeviceID dev)
+{
+ AudioObjectPropertyAddress addr;
+ addr.mSelector = kAudioDevicePropertyDeviceUID;
+ addr.mScope = kAudioObjectPropertyScopeGlobal;
+ addr.mElement = kAudioObjectPropertyElementMaster;
+
+ CFStringRef uid = nullptr;
+ UInt32 size = sizeof(uid);
+ if (AudioObjectGetPropertyData(dev, &addr, 0, nullptr, &size, &uid) != noErr || !uid)
+ return QString();
+
+ char buf[512] = {0};
+ QString result;
+ if (CFStringGetCString(uid, buf, sizeof(buf), kCFStringEncodingUTF8))
+ result = QString::fromUtf8(buf);
+
+ CFRelease(uid);
+ return result;
+}
+
+static AudioDeviceID defaultInputDevice()
+{
+ AudioObjectPropertyAddress addr;
+ addr.mSelector = kAudioHardwarePropertyDefaultInputDevice;
+ addr.mScope = kAudioObjectPropertyScopeGlobal;
+ addr.mElement = kAudioObjectPropertyElementMaster;
+
+ AudioDeviceID dev = kAudioObjectUnknown;
+ UInt32 size = sizeof(dev);
+ if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, nullptr, &size, &dev) != noErr)
+ return kAudioObjectUnknown;
+
+ return dev;
+}
+
+// The input volume of a CoreAudio device.
+//
+// Which element carries it varies: some devices expose a master (element 0),
+// some only per channel controls, and plenty of built in microphones expose
+// none at all and are simply not adjustable. open() works that out once and
+// remembers the answer; a device with nothing settable fails to open, and the
+// caller falls back to warning the user.
+class CoreAudioCaptureGain : public CaptureGainControl
+{
+public:
+ CoreAudioCaptureGain() : m_dev(kAudioObjectUnknown), m_master(false) {}
+
+ bool open(const QString &device)
+ {
+ if (device.isEmpty() || device == QLatin1String("default"))
+ {
+ m_dev = defaultInputDevice();
+ }
+ else
+ {
+ QVector<AudioDeviceID> devices = allInputCapableDevices();
+ for (int i = 0; i < devices.size(); i++)
+ {
+ if (deviceUidOf(devices[i]) == device)
+ {
+ m_dev = devices[i];
+ break;
+ }
+ }
+
+ // Fall back rather than give up - the same thing the audio unit
+ // does when a remembered device has been unplugged.
+ if (m_dev == kAudioObjectUnknown)
+ m_dev = defaultInputDevice();
+ }
+
+ if (m_dev == kAudioObjectUnknown)
+ return false;
+
+ if (settable(0))
+ {
+ m_master = true;
+ m_channels.clear();
+ }
+ else
+ {
+ // Stereo inputs are the common case; anything past the second
+ // channel is rare enough on a microphone not to chase.
+ for (UInt32 ch = 1; ch <= 2; ch++)
+ {
+ if (settable(ch))
+ m_channels << ch;
+ }
+
+ if (m_channels.isEmpty())
+ {
+ qWarning() << "CoreAudio: input device has no adjustable volume";
+ return false;
+ }
+ }
+
+ m_device = device.isEmpty() ? QStringLiteral("default") : device;
+ return true;
+ }
+
+ bool gain(double *out) override
+ {
+ if (!out || m_dev == kAudioObjectUnknown)
+ return false;
+
+ // The loudest of the channels, so stepping down moves all of them.
+ Float32 best = -1.0f;
+
+ if (m_master)
+ {
+ if (!readElement(0, &best))
+ return false;
+ }
+ else
+ {
+ for (int i = 0; i < m_channels.size(); i++)
+ {
+ Float32 v = 0.0f;
+ if (readElement(m_channels[i], &v) && v > best)
+ best = v;
+ }
+ }
+
+ if (best < 0.0f)
+ return false;
+
+ *out = best;
+ return true;
+ }
+
+ bool setGain(double value) override
+ {
+ if (m_dev == kAudioObjectUnknown)
+ return false;
+
+ Float32 v = (Float32)(value < 0.0 ? 0.0 : (value > 1.0 ? 1.0 : value));
+
+ if (m_master)
+ return writeElement(0, v);
+
+ bool any = false;
+ for (int i = 0; i < m_channels.size(); i++)
+ {
+ if (writeElement(m_channels[i], v))
+ any = true;
+ }
+
+ return any;
+ }
+
+ QString description() const override
+ {
+ return m_device + QStringLiteral(" (CoreAudio input volume)");
+ }
+
+private:
+ static AudioObjectPropertyAddress addressFor(UInt32 element)
+ {
+ AudioObjectPropertyAddress addr;
+ addr.mSelector = kAudioDevicePropertyVolumeScalar;
+ addr.mScope = kAudioObjectPropertyScopeInput;
+ addr.mElement = element;
+ return addr;
+ }
+
+ bool settable(UInt32 element)
+ {
+ AudioObjectPropertyAddress addr = addressFor(element);
+
+ if (!AudioObjectHasProperty(m_dev, &addr))
+ return false;
+
+ Boolean writable = false;
+ if (AudioObjectIsPropertySettable(m_dev, &addr, &writable) != noErr)
+ return false;
+
+ return writable;
+ }
+
+ bool readElement(UInt32 element, Float32 *out)
+ {
+ AudioObjectPropertyAddress addr = addressFor(element);
+ UInt32 size = sizeof(*out);
+ return AudioObjectGetPropertyData(m_dev, &addr, 0, nullptr, &size, out) == noErr;
+ }
+
+ bool writeElement(UInt32 element, Float32 value)
+ {
+ AudioObjectPropertyAddress addr = addressFor(element);
+ return AudioObjectSetPropertyData(m_dev, &addr, 0, nullptr, sizeof(value), &value) == noErr;
+ }
+
+ AudioDeviceID m_dev;
+ bool m_master;
+ QVector<UInt32> m_channels;
+ QString m_device;
+};
+
+CaptureGainControl *pica_create_coreaudio_capture_gain(const QString &device)
+{
+ CoreAudioCaptureGain *g = new CoreAudioCaptureGain();
+
+ if (g->open(device))
+ return g;
+
+ delete g;
+ return nullptr;
+}
+
+#endif // Q_OS_MACOS
diff --git a/pica-client/capturegain_wasapi.cpp b/pica-client/capturegain_wasapi.cpp
new file mode 100644
index 0000000..15dec57
--- /dev/null
+++ b/pica-client/capturegain_wasapi.cpp
@@ -0,0 +1,153 @@
+/*
+ (c) Copyright 2012 - 2026 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#include "capturegain.h"
+
+#ifdef Q_OS_WIN
+
+#include <QDebug>
+
+// Deliberately NOT defining INITGUID here, unlike nativeaudio_wasapi.cpp: that
+// file already emits the definitions for the endpoint GUIDs, and a second copy
+// in this translation unit would collide at link time. The interface IDs below
+// come from __uuidof() instead, which mingw-w64 supports because its headers
+// declare these interfaces with __CRT_UUID_DECL.
+#include <windows.h>
+#include <objbase.h>
+#include <mmdeviceapi.h>
+#include <endpointvolume.h>
+
+static QString hrText(HRESULT hr)
+{
+ return QString("0x%1").arg((quint32)hr, 8, 16, QLatin1Char('0'));
+}
+
+// The endpoint volume, which is the slider under Settings > Sound > Input.
+//
+// Note this is not the same control as "Microphone Boost" - that one is a
+// driver specific APO with no uniform API, and is usually the thing actually
+// responsible when a mic clips at every level. Turning the endpoint volume
+// down still reduces what reaches the converter on the great majority of
+// devices, so it is the right control to reach for; it just may not have as
+// much travel as the ALSA case.
+class WasapiCaptureGain : public CaptureGainControl
+{
+public:
+ WasapiCaptureGain() : m_vol(nullptr), m_uninitCom(false) {}
+
+ ~WasapiCaptureGain() override
+ {
+ if (m_vol)
+ m_vol->Release();
+ if (m_uninitCom)
+ CoUninitialize();
+ }
+
+ bool open(const QString &device)
+ {
+ HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
+ // S_FALSE means this thread was already in an apartment, and
+ // RPC_E_CHANGED_MODE that it is in a different one - in both cases COM
+ // is usable and we must not be the one to tear it down.
+ if (SUCCEEDED(hr))
+ m_uninitCom = true;
+ else if (hr != RPC_E_CHANGED_MODE)
+ return false;
+
+ IMMDeviceEnumerator *devEnum = nullptr;
+ hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
+ __uuidof(IMMDeviceEnumerator), (void **)&devEnum);
+ if (FAILED(hr) || !devEnum)
+ return false;
+
+ IMMDevice *dev = nullptr;
+ if (device.isEmpty() || device == QLatin1String("default"))
+ {
+ // eCommunications to match what the capture stream itself opens in
+ // WasapiBackend::resolveDevice() - otherwise on a machine where the
+ // call endpoint and the console endpoint differ we would be turning
+ // down a device nobody is recording from.
+ hr = devEnum->GetDefaultAudioEndpoint(eCapture, eCommunications, &dev);
+ }
+ else
+ {
+ hr = devEnum->GetDevice((LPCWSTR)device.utf16(), &dev);
+ }
+
+ devEnum->Release();
+
+ if (FAILED(hr) || !dev)
+ return false;
+
+ hr = dev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, nullptr, (void **)&m_vol);
+ dev->Release();
+
+ if (FAILED(hr) || !m_vol)
+ {
+ qWarning() << "WASAPI: capture endpoint has no volume control:" << hrText(hr);
+ m_vol = nullptr;
+ return false;
+ }
+
+ m_device = device.isEmpty() ? QStringLiteral("default") : device;
+ return true;
+ }
+
+ bool gain(double *out) override
+ {
+ if (!m_vol || !out)
+ return false;
+
+ float level = 0.0f;
+ if (FAILED(m_vol->GetMasterVolumeLevelScalar(&level)))
+ return false;
+
+ *out = level;
+ return true;
+ }
+
+ bool setGain(double value) override
+ {
+ if (!m_vol)
+ return false;
+
+ float level = (float)(value < 0.0 ? 0.0 : (value > 1.0 ? 1.0 : value));
+ return SUCCEEDED(m_vol->SetMasterVolumeLevelScalar(level, nullptr));
+ }
+
+ QString description() const override
+ {
+ return m_device + QStringLiteral(" (WASAPI endpoint volume)");
+ }
+
+private:
+ IAudioEndpointVolume *m_vol;
+ bool m_uninitCom;
+ QString m_device;
+};
+
+CaptureGainControl *pica_create_wasapi_capture_gain(const QString &device)
+{
+ WasapiCaptureGain *g = new WasapiCaptureGain();
+
+ if (g->open(device))
+ return g;
+
+ delete g;
+ return nullptr;
+}
+
+#endif // Q_OS_WIN
diff --git a/pica-client/dialogs/settingsdialog.cpp b/pica-client/dialogs/settingsdialog.cpp
index a319f12..e1faf8d 100644
--- a/pica-client/dialogs/settingsdialog.cpp
+++ b/pica-client/dialogs/settingsdialog.cpp
@@ -201,6 +201,13 @@ SettingsDialog::SettingsDialog(QWidget *parent) :
"are on speakers. Has no effect where the operating system "
"already does it."));
+ cbAutoCaptureGain = new QCheckBox(tr("Automatic microphone gain"), this);
+ cbAutoCaptureGain->setToolTip(tr("Turns the microphone's own recording level down during a "
+ "call if it is loud enough to distort, and puts it back "
+ "afterwards. A microphone set too loud clips, and clipping "
+ "cannot be repaired later - it also stops echo cancellation "
+ "working."));
+
btRingTest = new QPushButton(tr("Test ring 🔔"), this);
connect(btRingTest, SIGNAL(clicked()), this, SLOT(toggleRingTest()));
@@ -218,6 +225,7 @@ SettingsDialog::SettingsDialog(QWidget *parent) :
audiodevlayout->addWidget(lbAudioPlaybackDev);
audiodevlayout->addWidget(audioPlaybackDev);
audiodevlayout->addWidget(cbEchoCancel);
+ audiodevlayout->addWidget(cbAutoCaptureGain);
audiodevlayout->addWidget(btAudioTest);
audiodevlayout->addWidget(audioTestStatus);
audiodevlayout->addWidget(lbAudioRingDev);
@@ -1054,6 +1062,7 @@ void SettingsDialog::loadSettings()
audioPlaybackDev->setCurrentIndex(audioPlaybackDevItem);
cbEchoCancel->setChecked(st.loadValue("audio.echo_cancel", 1).toBool());
+ cbAutoCaptureGain->setChecked(st.loadValue("audio.auto_capture_gain", 1).toBool());
QString audioRingDevVal = st.loadValue("audio.ring_device", "default").toString();
int audioRingDevItem = audioRingDev->findData(audioRingDevVal);
@@ -1108,6 +1117,7 @@ void SettingsDialog::storeSettings()
st.storeValue("audio.playback_device", audioPlaybackDev->itemData(audioPlaybackDev->currentIndex()).toString());
st.storeValue("audio.ring_device", audioRingDev->itemData(audioRingDev->currentIndex()).toString());
st.storeValue("audio.echo_cancel", cbEchoCancel->isChecked() ? "1" : "0");
+ st.storeValue("audio.auto_capture_gain", cbAutoCaptureGain->isChecked() ? "1" : "0");
if (audioDriver)
{
diff --git a/pica-client/dialogs/settingsdialog.h b/pica-client/dialogs/settingsdialog.h
index 424d43f..b879a74 100644
--- a/pica-client/dialogs/settingsdialog.h
+++ b/pica-client/dialogs/settingsdialog.h
@@ -81,6 +81,7 @@ private:
// Whether to run our own acoustic echo cancellation. Ignored when the
// platform provides one - see AudioDevice::PlatformDriverName().
QCheckBox *cbEchoCancel;
+ QCheckBox *cbAutoCaptureGain;
QComboBox *videoDev;
QPushButton *videoDevRefresh;
diff --git a/pica-client/echocanceller.cpp b/pica-client/echocanceller.cpp
index df151d6..2d7acf0 100644
--- a/pica-client/echocanceller.cpp
+++ b/pica-client/echocanceller.cpp
@@ -24,8 +24,21 @@
extern "C" {
#include <speex/speex_echo.h>
#include <speex/speex_preprocess.h>
+#include <libswresample/swresample.h>
+#include <libavutil/channel_layout.h>
+#include <libavutil/samplefmt.h>
+#include <libavutil/mathematics.h>
+#include <libavutil/opt.h>
}
+// Same split as audiodevice.cpp - FFmpeg 5.1 replaced the channel count and
+// layout fields with the AVChannelLayout API.
+#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 24, 100)
+# define EC_HAVE_CH_LAYOUT 1
+#else
+# define EC_HAVE_CH_LAYOUT 0
+#endif
+
EchoCanceller::EchoCanceller(int sampleRate, int frameMs, int tailMs)
: m_sampleRate(sampleRate > 0 ? sampleRate : 48000),
m_frameSize(0),
@@ -33,6 +46,8 @@ EchoCanceller::EchoCanceller(int sampleRate, int frameMs, int tailMs)
m_echo(nullptr),
m_preprocess(nullptr),
m_farHead(0),
+ m_farSwr(nullptr),
+ m_farSwrRate(0),
m_farCapacity(0),
m_nearEnergy(0.0),
m_outEnergy(0.0),
@@ -120,6 +135,8 @@ EchoCanceller::~EchoCanceller()
speex_preprocess_state_destroy(reinterpret_cast<SpeexPreprocessState *>(m_preprocess));
if (m_echo)
speex_echo_state_destroy(reinterpret_cast<SpeexEchoState *>(m_echo));
+ if (m_farSwr)
+ swr_free(&m_farSwr);
}
void EchoCanceller::reset()
@@ -146,13 +163,69 @@ void EchoCanceller::reset()
}
}
-void EchoCanceller::pushFarEnd(const qint16 *pcm, int nsamples)
+void EchoCanceller::pushFarEnd(const qint16 *pcm, int nsamples, int srcRate)
{
if (!m_echo || !pcm || nsamples <= 0)
return;
QMutexLocker locker(&m_farMutex);
+ if (srcRate > 0 && srcRate != m_sampleRate)
+ {
+ if (m_farSwr && m_farSwrRate != srcRate)
+ {
+ swr_free(&m_farSwr);
+ m_farSwrRate = 0;
+ }
+
+ if (!m_farSwr)
+ {
+#if EC_HAVE_CH_LAYOUT
+ AVChannelLayout mono;
+ av_channel_layout_default(&mono, 1);
+ int ret = swr_alloc_set_opts2(&m_farSwr, &mono, AV_SAMPLE_FMT_S16, m_sampleRate,
+ &mono, AV_SAMPLE_FMT_S16, srcRate, 0, nullptr);
+ av_channel_layout_uninit(&mono);
+ if (ret < 0)
+ m_farSwr = nullptr;
+#else
+ m_farSwr = swr_alloc_set_opts(nullptr, AV_CH_LAYOUT_MONO, AV_SAMPLE_FMT_S16, m_sampleRate,
+ AV_CH_LAYOUT_MONO, AV_SAMPLE_FMT_S16, srcRate, 0, nullptr);
+#endif
+ if (m_farSwr && swr_init(m_farSwr) < 0)
+ swr_free(&m_farSwr);
+
+ if (!m_farSwr)
+ {
+ qWarning() << "Echo canceller: cannot resample" << srcRate << "Hz far end to"
+ << m_sampleRate << "Hz - echo cancellation will not work";
+ return;
+ }
+
+ m_farSwrRate = srcRate;
+ qDebug() << "Echo canceller: far end is" << srcRate << "Hz, resampling to" << m_sampleRate << "Hz";
+ }
+
+ // Upper bound on what this many input samples can become, plus
+ // whatever the converter is still holding from last time.
+ int room = (int)av_rescale_rnd(swr_get_delay(m_farSwr, srcRate) + nsamples,
+ m_sampleRate, srcRate, AV_ROUND_UP);
+ if (room <= 0)
+ return;
+
+ m_farScratch.resize(room);
+
+ uint8_t *out[1] = { (uint8_t *)m_farScratch.data() };
+ const uint8_t *in[1] = { (const uint8_t *)pcm };
+
+ int got = swr_convert(m_farSwr, out, room, in, nsamples);
+ if (got <= 0)
+ return;
+
+ pcm = m_farScratch.constData();
+ nsamples = got;
+ }
+
// Reclaim the space already consumed rather than growing without bound;
// doing it only once the head has run past half the buffer keeps this
// from turning into a memmove on every call.
diff --git a/pica-client/echocanceller.h b/pica-client/echocanceller.h
index c49962d..b78a2f7 100644
--- a/pica-client/echocanceller.h
+++ b/pica-client/echocanceller.h
@@ -24,6 +24,7 @@
struct SpeexEchoState_;
struct SpeexPreprocessState_;
+struct SwrContext;
// Acoustic echo cancellation for a call, using speexdsp's frequency domain
// adaptive filter (MDF).
@@ -66,7 +67,15 @@ public:
void reset();
// Playback side: the audio about to be written to the output device.
- void pushFarEnd(const qint16 *pcm, int nsamples);
+ //
+ // srcRate is what pcm is sampled at, and may differ from sampleRate():
+ // we capture at a rate of our choosing but play back at whatever the peer
+ // announced, so a call with a build that negotiates a different rate ends
+ // up with the two directions disagreeing. Passing 0 means "same as ours".
+ // Anything else is resampled on the way into the FIFO - without this the
+ // playback side used to drop the canceller entirely on a rate mismatch,
+ // leaving the capture side cancelling against silence.
+ void pushFarEnd(const qint16 *pcm, int nsamples, int srcRate = 0);
// Capture side: removes the echo of what pushFarEnd() was given, in
// place. nsamples should be a whole number of frameSize() blocks; a
@@ -121,6 +130,13 @@ private:
QVector<qint16> m_farFifo;
int m_farHead;
+ // Rate conversion for a far end that is not running at our rate, built on
+ // first use and rebuilt if the rate changes. Only the playback thread
+ // touches these, under m_farMutex.
+ SwrContext *m_farSwr;
+ int m_farSwrRate;
+ QVector<qint16> m_farScratch;
+
// Upper bound on the FIFO, in samples. Reached only if the two sound
// cards' clocks drift apart or playback outruns capture; past this the
// oldest audio is dropped, since keeping it would mean the reference
diff --git a/pica-client/pica-client.pro b/pica-client/pica-client.pro
index 92430fe..9dcc1ab 100644
--- a/pica-client/pica-client.pro
+++ b/pica-client/pica-client.pro
@@ -16,6 +16,9 @@ SOURCES += main.cpp\
nativeaudio.cpp \
nativeaudio_wasapi.cpp \
nativeaudio_coreaudio.cpp \
+ capturegain.cpp \
+ capturegain_wasapi.cpp \
+ capturegain_coreaudio.cpp \
audiovideocallcontroller.cpp \
mainwindow.cpp \
chatwindow.cpp \
@@ -59,6 +62,7 @@ HEADERS += mainwindow.h \
audioring.h \
echocanceller.h \
nativeaudio.h \
+ capturegain.h \
audiovideocallcontroller.h \
chatwindow.h \
callwindow.h \