Files
utun2/tools/chatgui/src/radiosettingspage.cpp
T

400 lines
21 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <QCoreApplication>
#include "radiosettingspage.h"
#include "ptt_hotkey_manager.h"
#include "radio_audio_engine.h"
#include "../db/db_manager.h"
#include "../../lib/debug_config.h"
#include <QApplication>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QHideEvent>
#include <QShowEvent>
#include <QFormLayout>
#include <QSpinBox>
#include <QCheckBox>
#include <QButtonGroup>
#include <QPainter>
#include <algorithm>
#include <QKeyEvent>
#ifdef Q_OS_WIN
#include <qt_windows.h>
#endif
/* Шкала показывает именно RMS детектора; отметки соответствуют редактируемым порогам. */
class RadioLevelMeter : public QWidget {
public:
explicit RadioLevelMeter(QWidget* parent) : QWidget(parent) { setMinimumSize(200, 34); }
float level = -120;
int on = -35, off = -45;
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 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, 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_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 "
"Win in the system dialog. Changes are saved by the system immediately. Keyboard "
"transmission is disabled during setup."), this);
hint->setWordWrap(true);
layout->addWidget(hint);
auto* button = new QPushButton(QCoreApplication::translate("ChatGui", "Set PTT shortcuts…"), this);
layout->addWidget(button);
m_buttons << button;
for (int i = 0; i < 3; i++) {
auto* label = new QLabel(this);
label->setWordWrap(true); label->setTextFormat(Qt::PlainText);
layout->addWidget(label); m_labels << label;
}
m_portalStatus = new QLabel(this);
m_portalStatus->setWordWrap(true);
m_portalStatus->setTextFormat(Qt::PlainText);
m_portalStatus->setStyleSheet("color: gray; font-size: 11px;");
layout->addWidget(m_portalStatus);
connect(button, &QPushButton::clicked, this, [this]() { PttHotkeyManager::instance()->configurePortal(this); });
connect(PttHotkeyManager::instance(), &PttHotkeyManager::portalChanged, this, &RadioSettingsPage::updatePortalLabels);
updatePortalLabels();
layout->addStretch(1);
return;
}
auto* hint = new QLabel(
QCoreApplication::translate("ChatGui",
"Hold a shortcut to transmit from any application, even when ChatGUI is not focused. "
"Channel radio must be enabled. To record a shortcut, hold the desired keys together and "
"release them. Left and right Ctrl, Shift, Alt and Win are distinct. Fn is available only "
"if the keyboard reports it to the OS."), this);
hint->setWordWrap(true);
hint->setStyleSheet("color: gray; font-size: 11px;");
layout->addWidget(hint);
if (!PttHotkeyManager::isSupported()) {
auto* warn = new QLabel(QCoreApplication::translate("ChatGui",
"Global shortcuts are available on Windows and Linux/X11. Shortcut recording is "
"unavailable on this platform."), this);
warn->setWordWrap(true);
warn->setStyleSheet("color: #e74c3c; font-size: 12px;");
layout->addWidget(warn);
}
for (int i = 0; i < 3; i++) {
auto* row = new QHBoxLayout();
auto* button = new QPushButton(QCoreApplication::translate("ChatGui", "Set PTT shortcut %1").arg(i + 1), this);
button->setEnabled(PttHotkeyManager::isSupported());
auto* clear = new QPushButton(QCoreApplication::translate("ChatGui", "Clear"), this);
row->addWidget(button); row->addWidget(clear);
layout->addLayout(row);
auto* label = new QLabel(this);
label->setWordWrap(true);
label->setTextFormat(Qt::PlainText);
label->setStyleSheet("font-size: 13px; padding-bottom: 12px;");
layout->addWidget(label);
m_buttons << button; m_labels << label; m_chords << PttKeyChord();
connect(button, &QPushButton::clicked, this, [this, i]() {
if (m_captureIndex == i) finishCapture(false);
else startCapture(i);
});
connect(clear, &QPushButton::clicked, this, [this, i]() {
finishCapture(false);
m_chords[i].clear(); updateLabel(i);
});
}
connect(PttHotkeyManager::instance(), &PttHotkeyManager::keysChanged, this, [this](const PttKeyChord& held) {
if (m_captureIndex < 0) return;
if (m_capture.observe(held)) finishCapture(true);
else if (!held.isEmpty())
m_labels[m_captureIndex]->setText(PttHotkeyManager::instance()->describe(m_capture.candidate));
});
layout->addStretch(1);
loadFromDb();
}
RadioSettingsPage::~RadioSettingsPage() {
finishCapture(false);
if (m_portalStatus && isVisible()) PttHotkeyManager::instance()->inhibitPortal(false);
}
void RadioSettingsPage::startCapture(int index) {
finishCapture(false);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: recording requested slot=%d", index + 1);
if (!PttHotkeyManager::instance()->beginCapture()) {
m_labels[index]->setText(QCoreApplication::translate("ChatGui", "Failed to start key recording. See the radio log for details."));
return;
}
m_captureIndex = index;
m_capture.candidate.clear();
m_buttons[index]->setText(QCoreApplication::translate("ChatGui", "Cancel recording"));
m_labels[index]->setText(QCoreApplication::translate("ChatGui", "Press one or more keys together…"));
qApp->installEventFilter(this);
}
void RadioSettingsPage::finishCapture(bool save) {
if (m_captureIndex < 0) return;
int index = m_captureIndex;
if (save) {
m_chords[index] = m_capture.candidate;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: captured slot=%d chord=[%s] codes=[%s]", index + 1,
qPrintable(PttHotkeyManager::instance()->describe(m_capture.candidate)), qPrintable(pttEncodeChord(m_capture.candidate)));
} else {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: recording cancelled slot=%d candidate=[%s]", index + 1,
qPrintable(pttEncodeChord(m_capture.candidate)));
}
m_captureIndex = -1;
qApp->removeEventFilter(this);
PttHotkeyManager::instance()->endCapture();
m_buttons[index]->setText(QCoreApplication::translate("ChatGui", "Set PTT shortcut %1").arg(index + 1));
updateLabel(index);
}
bool RadioSettingsPage::eventFilter(QObject* object, QEvent* event) {
if (m_captureIndex >= 0) {
if (event->type() == QEvent::ShortcutOverride) { event->accept(); return true; }
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
auto* key = static_cast<QKeyEvent*>(event);
// Только ручная запись: сравниваем доставку событий окну с независимым опросом ОС.
if (!key->isAutoRepeat()) {
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PTT: capture window event=%s slot=%d qt=0x%x native_vk=%u scan=%u modifiers=0x%x",
event->type() == QEvent::KeyPress ? "press" : "release", m_captureIndex + 1,
unsigned(key->key()), unsigned(key->nativeVirtualKey()), unsigned(key->nativeScanCode()),
unsigned(key->nativeModifiers()));
#ifdef Q_OS_WIN
int vk = int(key->nativeVirtualKey());
if (vk > 0 && vk < 256) {
unsigned asyncState = static_cast<unsigned short>(GetAsyncKeyState(vk));
unsigned queueState = static_cast<unsigned short>(GetKeyState(vk));
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PTT: capture Windows vk=%d async=0x%04x queue=0x%04x remote_session=%d",
vk, asyncState, queueState, GetSystemMetrics(SM_REMOTESESSION));
}
#endif
}
return true;
}
}
return QWidget::eventFilter(object, event);
}
void RadioSettingsPage::showEvent(QShowEvent* event) {
if (m_portalStatus) PttHotkeyManager::instance()->inhibitPortal(true);
QWidget::showEvent(event);
}
void RadioSettingsPage::hideEvent(QHideEvent* event) {
if (m_portalStatus) PttHotkeyManager::instance()->inhibitPortal(false);
finishCapture(false);
QWidget::hideEvent(event);
}
void RadioSettingsPage::updateLabel(int index) {
m_labels[index]->setText(PttHotkeyManager::instance()->describe(m_chords[index]));
}
void RadioSettingsPage::loadFromDb() {
m_activation->loadSettings();
if (m_portalStatus) { updatePortalLabels(); return; }
finishCapture(false);
QStringList values;
if (m_db && m_db->isOpen()) values = m_db->getUiState("radio_ptt_hotkeys").split('|', Qt::KeepEmptyParts);
for (int i = 0; i < m_chords.size(); i++) {
m_chords[i] = i < values.size() ? pttDecodeChord(values[i]) : PttKeyChord();
updateLabel(i);
}
}
QStringList RadioSettingsPage::hotkeys() const {
QStringList values;
// Пустые позиции тоже сохраняем, чтобы хоткей №3 не переезжал в №1 после открытия.
for (const auto& chord : m_chords) values << pttEncodeChord(chord);
return values;
}
bool RadioSettingsPage::apply() {
if (!m_activation->apply()) return false;
if (m_portalStatus) return true;
finishCapture(false);
PttHotkeyManager::instance()->setHotkeys(hotkeys());
return true;
}
void RadioSettingsPage::updatePortalLabels() {
auto* manager = PttHotkeyManager::instance();
const auto descriptions = manager->portalDescriptions();
for (int i = 0; i < m_labels.size(); i++) {
QString text = descriptions.value(i);
m_labels[i]->setText(QCoreApplication::translate("ChatGui",
"PTT shortcut %1: %2").arg(i + 1).arg(text.isEmpty() ? QCoreApplication::translate("ChatGui",
"Not set") : text));
}
m_portalStatus->setText(manager->portalStatus());
m_buttons[0]->setEnabled(!manager->portalBusy());
}