diff options
| author | Anton <anton@picapica.im> | 2026-09-01 21:09:47 +0400 |
|---|---|---|
| committer | Anton <anton@picapica.im> | 2026-09-01 21:09:47 +0400 |
| commit | c6649895d58bcfab864bcc33b131db222e45bfef (patch) | |
| tree | 8f863865db8501dd436f1834d598ad9bdded43a9 | |
| parent | 6b01fe69c7125c01c9327a27f0d1bac0af244c17 (diff) | |
| download | pica-pica-c6649895d58bcfab864bcc33b131db222e45bfef.tar.gz pica-pica-c6649895d58bcfab864bcc33b131db222e45bfef.zip | |
pica-client: fix echo canceller and capture gain auto regulator
| -rw-r--r-- | Makefile.am | 4 | ||||
| -rw-r--r-- | pica-client/audiodevice.cpp | 64 | ||||
| -rw-r--r-- | pica-client/audiodevice.h | 36 | ||||
| -rw-r--r-- | pica-client/audiovideocallcontroller.cpp | 152 | ||||
| -rw-r--r-- | pica-client/audiovideocallcontroller.h | 29 | ||||
| -rw-r--r-- | pica-client/capturegain.cpp | 457 | ||||
| -rw-r--r-- | pica-client/capturegain.h | 69 | ||||
| -rw-r--r-- | pica-client/capturegain_coreaudio.cpp | 265 | ||||
| -rw-r--r-- | pica-client/capturegain_wasapi.cpp | 153 | ||||
| -rw-r--r-- | pica-client/dialogs/settingsdialog.cpp | 10 | ||||
| -rw-r--r-- | pica-client/dialogs/settingsdialog.h | 1 | ||||
| -rw-r--r-- | pica-client/echocanceller.cpp | 75 | ||||
| -rw-r--r-- | pica-client/echocanceller.h | 18 | ||||
| -rw-r--r-- | pica-client/pica-client.pro | 4 |
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(µphone_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(¤t)) + 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 \ |
