radio: add activation controls, live VAD meters and signal diagnostics

This commit is contained in:
evgeny committed 2026-10-09 16:11:11 +02:00
1 parent fddbba217d
commit 7f3247bfcc
23 files changed
+818 -182

No files matched your search

+159 -113
View File
@@ -15,7 +15,7 @@
#include <QFormLayout>
#include <QSpinBox>
#include <QCheckBox>
#include <QComboBox>
#include <QButtonGroup>
#include <QPainter>
#include <algorithm>
#include <QKeyEvent>
@@ -29,89 +29,181 @@ public:
explicit RadioLevelMeter(QWidget* parent) : QWidget(parent) { setMinimumSize(200, 34); }
float level = -120;
int on = -35, off = -45;
bool valid = false;
bool valid = false, vad = false;
protected:
void paintEvent(QPaintEvent*) override {
QPainter painter(this);
QRect bar(1, 1, width() - 2, 16);
painter.fillRect(bar, palette().color(QPalette::Mid));
auto x = [&](float db) { return bar.left() + int(std::clamp((db + 90) / 90, 0.0f, 1.0f) * bar.width()); };
auto x = [&](float value) { return bar.left() + int(std::clamp(vad ? value / 100 : (value + 90) / 90, 0.0f, 1.0f) * bar.width()); };
if (valid) painter.fillRect(QRect(bar.left(), bar.top(), x(level) - bar.left(), bar.height()), QColor("#27ae60"));
painter.setPen(palette().color(QPalette::Text));
painter.drawText(QRect(0, 18, width(), 16), Qt::AlignLeft, QStringLiteral("−90 dBFS"));
painter.drawText(QRect(0, 18, width(), 16), Qt::AlignRight, QStringLiteral("0"));
painter.drawText(QRect(0, 18, width(), 16), Qt::AlignLeft, vad ? QStringLiteral("0%") : QStringLiteral("−90 dBFS"));
painter.drawText(QRect(0, 18, width(), 16), Qt::AlignRight, vad ? QStringLiteral("100%") : QStringLiteral("0"));
for (int threshold : {off, on}) {
if (vad && threshold == off) continue;
painter.setPen(QPen(threshold == on ? QColor("#e67e22") : QColor("#3390ec"), 2));
painter.drawLine(x(threshold), 0, x(threshold), 18);
}
}
};
RadioActivationSettings::RadioActivationSettings(bool compact, QWidget* parent) : QWidget(parent), m_compact(compact) {
auto* form = new QFormLayout(this);
form->setContentsMargins(0, 0, 0, 8);
form->setRowWrapPolicy(QFormLayout::WrapLongRows);
form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
auto add = [&](const QString& label, const char* name, int min, int max, const QString& suffix) {
auto* spin = new QSpinBox(this);
spin->setObjectName(QLatin1String(name)); spin->setRange(min, max); spin->setSuffix(suffix);
auto* text = new QLabel(label, this); text->setWordWrap(true);
form->addRow(text, spin);
connect(spin, QOverload<int>::of(&QSpinBox::valueChanged), this, &RadioActivationSettings::updateControls);
return spin;
};
auto label = [&](const char* name) {
auto* text = new QLabel(this); text->setObjectName(QLatin1String(name)); text->setWordWrap(true);
form->addRow(text); return text;
};
auto* modes = new QWidget(this);
auto* row = new QHBoxLayout(modes); row->setContentsMargins(0, 0, 0, 0); row->setSpacing(0);
m_mode = new QButtonGroup(this);
for (int mode : {1, 2}) {
auto* button = new QPushButton(mode == 1 ? QStringLiteral("VAD") : QCoreApplication::translate("ChatGui", "By signal level"), modes);
button->setObjectName(mode == 1 ? QStringLiteral("radioModeVad") : QStringLiteral("radioModeLevel"));
button->setCheckable(true); button->setMinimumHeight(36);
button->setStyleSheet("QPushButton:checked { background: #3390EC; color: white; }");
m_mode->addButton(button, mode); row->addWidget(button, 1);
connect(button, &QPushButton::clicked, this, [this, mode]() {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio settings: select mode=%d compact=%d (pending Apply)", mode, m_compact);
updateControls();
});
}
m_mode->button(1)->setChecked(true);
form->addRow(QCoreApplication::translate("ChatGui", "Automatic transmission:"), modes);
m_levelText = label("radioLevelText");
m_levelMeter = new RadioLevelMeter(this); m_levelMeter->setObjectName(QStringLiteral("radioLevelMeter"));
form->addRow(m_levelMeter);
m_levelOn = add(QCoreApplication::translate("ChatGui", "Start threshold (orange):"), "radioLevelOn", -90, 0, QStringLiteral(" dBFS"));
m_levelOff = add(QCoreApplication::translate("ChatGui", "Stop threshold (blue):"), "radioLevelOff", -90, 0, QStringLiteral(" dBFS"));
m_vadText = label("radioVadText");
m_vadMeter = new RadioLevelMeter(this); m_vadMeter->vad = true; m_vadMeter->setObjectName(QStringLiteral("radioVadMeter"));
form->addRow(m_vadMeter);
m_threshold = add(QCoreApplication::translate("ChatGui", "VAD speech threshold:"), "radioVadThreshold", 10, 95, QStringLiteral(" %"));
m_threshold->setToolTip(QCoreApplication::translate("ChatGui",
"Speech probability threshold: lower is more sensitive. This is not a volume threshold."));
m_gainText = label("radioVadGainText");
m_vadGain = add(QCoreApplication::translate("ChatGui", "VAD max gain:"), "radioVadGain", 1, 60, QCoreApplication::translate("ChatGui", " dB"));
m_hangover = add(QCoreApplication::translate("ChatGui", "Silence before ending transmission:"), "radioHangover", 50, 2000,
QCoreApplication::translate("ChatGui", " ms"));
if (!compact) {
m_compressor = new QCheckBox(QCoreApplication::translate("ChatGui", "Transmission and VAD compressor"), this);
form->addRow(m_compressor);
connect(m_compressor, &QCheckBox::toggled, this, &RadioActivationSettings::updateControls);
m_txGain = add(QCoreApplication::translate("ChatGui", "Transmission max gain:"), "radioTxGain", 1, 60,
QCoreApplication::translate("ChatGui", " dB"));
label("radioActivationHint")->setText(QCoreApplication::translate("ChatGui",
"Enable automatic transmission using the radio panel button. Both modes include 0.6 s of prebuffer. "
"Signal level is measured after echo cancellation, before compression. Start requires 40 ms above the start threshold; "
"transmission ends after the configured silence below the stop threshold. The VAD compressor has a separate maximum gain."));
}
m_error = label("radioActivationError"); m_error->setStyleSheet(QStringLiteral("color: #e74c3c;"));
auto* engine = RadioAudioEngine::instance();
connect(engine, &RadioAudioEngine::stateChanged, this, [this, engine]() {
setEnabled(engine->settingsReady() && !engine->settingsBusy());
if (!m_loaded && engine->settingsReady()) loadSettings();
else updateControls();
});
connect(engine, &RadioAudioEngine::inputLevelChanged, this, &RadioActivationSettings::updateMeters);
loadSettings();
}
QMap<QString, int> RadioActivationSettings::values() const {
QMap<QString, int> result{{"radio_auto_mode", m_mode->checkedId()}, {"radio_level_on_dbfs", m_levelOn->value()},
{"radio_level_off_dbfs", m_levelOff->value()}, {"radio_vad_threshold", m_threshold->value()},
{"radio_auto_hangover_ms", m_hangover->value()}, {"radio_vad_compressor_max_gain_db", m_vadGain->value()}};
if (!m_compact) {
result.insert(QStringLiteral("radio_compressor_enabled"), m_compressor->isChecked() ? 1 : 0);
result.insert(QStringLiteral("radio_compressor_max_gain_db"), m_txGain->value());
}
return result;
}
bool RadioActivationSettings::canApply() const {
auto* engine = RadioAudioEngine::instance();
if (!m_loaded || !engine->settingsReady() || engine->settingsBusy() || m_levelOff->value() >= m_levelOn->value()) return false;
const auto draft = values();
for (auto it = draft.cbegin(); it != draft.cend(); ++it) if (engine->setting(it.key()) != it.value()) return true;
return false;
}
bool RadioActivationSettings::apply() {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio settings: apply compact=%d mode=%d on=%d off=%d vad=%d gain=%d hangover=%d",
m_compact, m_mode->checkedId(), m_levelOn->value(), m_levelOff->value(), m_threshold->value(),
m_vadGain->value(), m_hangover->value());
return RadioAudioEngine::instance()->applySettings(values());
}
void RadioActivationSettings::loadSettings() {
auto* engine = RadioAudioEngine::instance();
setEnabled(engine->settingsReady() && !engine->settingsBusy());
if (!engine->settingsReady()) return;
m_loaded = false;
m_mode->button(engine->autoMode())->setChecked(true);
m_levelOn->setValue(engine->setting(QStringLiteral("radio_level_on_dbfs")));
m_levelOff->setValue(engine->setting(QStringLiteral("radio_level_off_dbfs")));
m_threshold->setValue(engine->setting(QStringLiteral("radio_vad_threshold")));
m_hangover->setValue(engine->setting(QStringLiteral("radio_auto_hangover_ms")));
m_vadGain->setValue(engine->setting(QStringLiteral("radio_vad_compressor_max_gain_db")));
if (!m_compact) {
m_compressor->setChecked(engine->setting(QStringLiteral("radio_compressor_enabled")) != 0);
m_txGain->setValue(engine->setting(QStringLiteral("radio_compressor_max_gain_db")));
}
m_loaded = true;
updateControls();
}
void RadioActivationSettings::updateControls() {
if (!m_loaded) return;
QString error = m_levelOff->value() >= m_levelOn->value()
? QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold")
: RadioAudioEngine::instance()->settingsError();
m_error->setText(error); m_error->setVisible(!error.isEmpty());
m_levelMeter->on = m_levelOn->value(); m_levelMeter->off = m_levelOff->value();
m_vadMeter->on = m_threshold->value();
updateMeters();
emit changed();
}
void RadioActivationSettings::updateMeters() {
auto* engine = RadioAudioEngine::instance();
m_levelMeter->valid = engine->inputLevelValid(); m_levelMeter->level = engine->inputLevel(); m_levelMeter->update();
QString text;
if (!engine->isActive()) text = QCoreApplication::translate("ChatGui", "Enable radio to measure the microphone level");
else if (!engine->inputLevelValid()) text = QCoreApplication::translate("ChatGui", "Microphone level unavailable");
else text = QCoreApplication::translate("ChatGui", "Current level: %1 dBFS").arg(engine->inputLevel(), 0, 'f', 1);
m_levelText->setText(text); m_levelMeter->setAccessibleName(text);
m_vadMeter->valid = engine->vadValid(); m_vadMeter->level = engine->vadProbability() * 100; m_vadMeter->update();
if (engine->vadValid()) text = QCoreApplication::translate("ChatGui", "VAD speech probability: %1%").arg(engine->vadProbability() * 100, 0, 'f', 1);
else if (!engine->autoEnabled() || engine->autoMode() != 1)
text = QCoreApplication::translate("ChatGui", "Enable automatic transmission in VAD mode to see speech probability");
else if (engine->autoBlocked()) text = QCoreApplication::translate("ChatGui", "VAD measurement paused: channel busy");
else text = QCoreApplication::translate("ChatGui", "VAD speech probability unavailable");
m_vadText->setText(text); m_vadMeter->setAccessibleName(text);
if (!engine->vadValid()) text = QCoreApplication::translate("ChatGui", "Current VAD compressor gain: —");
else if (!engine->vadCompressorEnabled()) text = QCoreApplication::translate("ChatGui", "VAD compressor off (0 dB)");
else text = QCoreApplication::translate("ChatGui", "Current VAD compressor gain: %1 dB")
.arg((engine->vadGain() >= 0 ? QStringLiteral("+") : QString()) + QString::number(engine->vadGain(), 'f', 1));
m_gainText->setText(text);
}
RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) : QWidget(parent), m_db(db) {
auto* layout = new QVBoxLayout(this);
auto* title = new QLabel(QCoreApplication::translate("ChatGui", "Radio (PTT)"), this);
title->setStyleSheet("font-weight: bold; font-size: 13px;");
layout->addWidget(title);
m_audioSettings = new QWidget(this);
auto* form = new QFormLayout(m_audioSettings);
form->setContentsMargins(0, 0, 0, 12);
auto add = [&](const QString& label, int min, int max, const QString& suffix) {
auto* spin = new QSpinBox(m_audioSettings);
spin->setRange(min, max);
spin->setSuffix(suffix);
form->addRow(label, spin);
return spin;
};
m_mode = new QComboBox(m_audioSettings);
m_mode->setObjectName(QStringLiteral("radioAutoMode"));
m_mode->addItem(QStringLiteral("VAD"), 1);
m_mode->addItem(QCoreApplication::translate("ChatGui", "By signal level"), 2);
form->addRow(QCoreApplication::translate("ChatGui", "Automatic transmission:"), m_mode);
m_levelMeter = new RadioLevelMeter(m_audioSettings);
m_levelMeter->setObjectName(QStringLiteral("radioLevelMeter"));
form->addRow(QCoreApplication::translate("ChatGui", "Microphone level:"), m_levelMeter);
m_levelText = new QLabel(m_audioSettings);
m_levelText->setObjectName(QStringLiteral("radioLevelText"));
m_levelText->setWordWrap(true);
form->addRow(m_levelText);
m_levelOn = add(QCoreApplication::translate("ChatGui", "Start threshold (orange):"), -90, 0, QStringLiteral(" dBFS"));
m_levelOff = add(QCoreApplication::translate("ChatGui", "Stop threshold (blue):"), -90, 0, QStringLiteral(" dBFS"));
m_levelOn->setObjectName(QStringLiteral("radioLevelOn"));
m_levelOff->setObjectName(QStringLiteral("radioLevelOff"));
m_thresholdError = new QLabel(m_audioSettings);
m_thresholdError->setWordWrap(true);
m_thresholdError->setStyleSheet(QStringLiteral("color: #e74c3c;"));
m_thresholdError->setText(QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold"));
form->addRow(m_thresholdError);
m_threshold = add(QCoreApplication::translate("ChatGui", "VAD speech threshold:"), 10, 95, QStringLiteral(" %"));
m_threshold->setToolTip(QCoreApplication::translate("ChatGui",
"Speech probability threshold: lower is more sensitive. This is not a volume threshold."));
m_hangover = add(QCoreApplication::translate("ChatGui",
"Silence before ending transmission:"), 50, 2000, QCoreApplication::translate("ChatGui",
" ms"));
m_compressor = new QCheckBox(QCoreApplication::translate("ChatGui", "Transmission and VAD compressor"), m_audioSettings);
form->addRow(m_compressor);
m_txGain = add(QCoreApplication::translate("ChatGui", "Transmission max gain:"), 1, 60, QCoreApplication::translate("ChatGui", " dB"));
m_vadGain = add(QCoreApplication::translate("ChatGui", "VAD max gain:"), 1, 60, QCoreApplication::translate("ChatGui", " dB"));
auto* explanation = new QLabel(QCoreApplication::translate("ChatGui",
"Enable automatic transmission using the radio panel button. Both modes include 0.6 s of prebuffer. "
"Signal level is measured after echo cancellation, before compression. Start requires 40 ms above the start threshold; "
"transmission ends after the configured silence below the stop threshold. The VAD compressor has a separate maximum gain."), m_audioSettings);
explanation->setWordWrap(true);
form->addRow(explanation);
layout->addWidget(m_audioSettings);
connect(RadioAudioEngine::instance(), &RadioAudioEngine::stateChanged, this, [this]() {
auto* engine = RadioAudioEngine::instance();
m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy());
if (!m_audioSettingsLoaded && engine->settingsReady()) loadAudioSettings();
});
connect(m_mode, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this]() { updateAudioControls(); });
for (auto* spin : {m_levelOn, m_levelOff})
connect(spin, QOverload<int>::of(&QSpinBox::valueChanged), this, [this]() { updateAudioControls(); });
connect(RadioAudioEngine::instance(), &RadioAudioEngine::inputLevelChanged, this, &RadioSettingsPage::updateLevel);
connect(RadioAudioEngine::instance(), &RadioAudioEngine::stateChanged, this, &RadioSettingsPage::updateLevel);
loadAudioSettings();
updateAudioControls();
m_activation = new RadioActivationSettings(false, this);
layout->addWidget(m_activation);
if (PttHotkeyManager::usesPortal()) {
auto* hint = new QLabel(QCoreApplication::translate("ChatGui",
"Global shortcuts work from any application. Assign a regular key with Ctrl, Shift, Alt or "
@@ -267,7 +359,7 @@ void RadioSettingsPage::updateLabel(int index) {
}
void RadioSettingsPage::loadFromDb() {
loadAudioSettings();
m_activation->loadSettings();
if (m_portalStatus) { updatePortalLabels(); return; }
finishCapture(false);
QStringList values;
@@ -286,37 +378,13 @@ QStringList RadioSettingsPage::hotkeys() const {
}
bool RadioSettingsPage::apply() {
if (!RadioAudioEngine::instance()->applySettings({
{QStringLiteral("radio_auto_mode"), m_mode->currentData().toInt()},
{QStringLiteral("radio_level_on_dbfs"), m_levelOn->value()},
{QStringLiteral("radio_level_off_dbfs"), m_levelOff->value()},
{QStringLiteral("radio_vad_threshold"), m_threshold->value()},
{QStringLiteral("radio_auto_hangover_ms"), m_hangover->value()},
{QStringLiteral("radio_compressor_enabled"), m_compressor->isChecked() ? 1 : 0},
{QStringLiteral("radio_compressor_max_gain_db"), m_txGain->value()},
{QStringLiteral("radio_vad_compressor_max_gain_db"), m_vadGain->value()}})) return false;
if (!m_activation->apply()) return false;
if (m_portalStatus) return true;
finishCapture(false);
PttHotkeyManager::instance()->setHotkeys(hotkeys());
return true;
}
void RadioSettingsPage::loadAudioSettings() {
auto* engine = RadioAudioEngine::instance();
m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy());
if (!engine->settingsReady()) return;
m_mode->setCurrentIndex(m_mode->findData(engine->autoMode()));
m_levelOn->setValue(engine->setting(QStringLiteral("radio_level_on_dbfs")));
m_levelOff->setValue(engine->setting(QStringLiteral("radio_level_off_dbfs")));
m_threshold->setValue(engine->setting(QStringLiteral("radio_vad_threshold")));
m_hangover->setValue(engine->setting(QStringLiteral("radio_auto_hangover_ms")));
m_compressor->setChecked(engine->setting(QStringLiteral("radio_compressor_enabled")) != 0);
m_txGain->setValue(engine->setting(QStringLiteral("radio_compressor_max_gain_db")));
m_vadGain->setValue(engine->setting(QStringLiteral("radio_vad_compressor_max_gain_db")));
m_audioSettingsLoaded = true;
updateAudioControls();
}
void RadioSettingsPage::updatePortalLabels() {
auto* manager = PttHotkeyManager::instance();
const auto descriptions = manager->portalDescriptions();
@@ -329,25 +397,3 @@ void RadioSettingsPage::updatePortalLabels() {
m_portalStatus->setText(manager->portalStatus());
m_buttons[0]->setEnabled(!manager->portalBusy());
}
void RadioSettingsPage::updateAudioControls() {
bool level = m_mode->currentData().toInt() == 2;
m_levelOn->setEnabled(level); m_levelOff->setEnabled(level);
m_threshold->setEnabled(!level); m_vadGain->setEnabled(!level);
m_thresholdError->setVisible(m_levelOff->value() >= m_levelOn->value());
m_levelMeter->on = m_levelOn->value(); m_levelMeter->off = m_levelOff->value();
updateLevel();
}
void RadioSettingsPage::updateLevel() {
auto* engine = RadioAudioEngine::instance();
m_levelMeter->valid = engine->inputLevelValid();
m_levelMeter->level = engine->inputLevel();
m_levelMeter->update();
QString text;
if (!engine->isActive()) text = QCoreApplication::translate("ChatGui", "Enable radio to measure the microphone level");
else if (!engine->inputLevelValid()) text = QCoreApplication::translate("ChatGui", "Microphone level unavailable");
else text = QCoreApplication::translate("ChatGui", "Current level: %1 dBFS").arg(engine->inputLevel(), 0, 'f', 1);
m_levelText->setText(text);
m_levelMeter->setAccessibleName(text);
}