diff options
Diffstat (limited to 'pica-client/capturegain.cpp')
| -rw-r--r-- | pica-client/capturegain.cpp | 457 |
1 files changed, 457 insertions, 0 deletions
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; +} |
