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

398 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_burstFrames = add(QCoreApplication::translate("ChatGui", "Burst size (Opus frames):"), "radioBurstFrames", 1, 10, QString());
m_burstFrames->setToolTip(QCoreApplication::translate("ChatGui",
"Applies to the next transmission. Each frame is 20 ms; larger batches reduce overhead but add delay. The final partial batch is sent immediately."));
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_burst_frames"), m_burstFrames->value());
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_burstFrames->setValue(engine->setting(QStringLiteral("radio_burst_frames")));
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);
m_doublePress = new QCheckBox(QCoreApplication::translate("ChatGui", "Lock on double press"), this);
m_doublePress->setObjectName(QStringLiteral("radioPttDoublePress"));
m_doublePress->setToolTip(QCoreApplication::translate("ChatGui",
"Applies to the shortcut only. The PTT button always supports double-click locking."));
layout->addWidget(m_doublePress);
if (PttHotkeyManager::usesPortal()) {
auto* hint = new QLabel(QCoreApplication::translate("ChatGui",
"The global shortcut works from any application. Hold it to talk. When double-press locking is enabled, "
"press twice to lock transmission, then press again to release. 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);
m_button = new QPushButton(QCoreApplication::translate("ChatGui", "Set PTT shortcut…"), this);
m_button->setObjectName(QStringLiteral("radioPttShortcut"));
layout->addWidget(m_button);
m_label = new QLabel(this);
m_label->setWordWrap(true); m_label->setTextFormat(Qt::PlainText);
layout->addWidget(m_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(m_button, &QPushButton::clicked, this, [this]() { PttHotkeyManager::instance()->configurePortal(this); });
connect(PttHotkeyManager::instance(), &PttHotkeyManager::portalChanged, this, &RadioSettingsPage::updatePortalLabels);
loadFromDb();
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. "
"When double-press locking is enabled, press twice to lock transmission, then press again to release. "
"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);
}
auto* row = new QHBoxLayout();
m_button = new QPushButton(QCoreApplication::translate("ChatGui", "Set PTT shortcut…"), this);
m_button->setObjectName(QStringLiteral("radioPttShortcut"));
m_button->setEnabled(PttHotkeyManager::isSupported());
auto* clear = new QPushButton(QCoreApplication::translate("ChatGui", "Clear"), this);
clear->setObjectName(QStringLiteral("radioPttClear"));
row->addWidget(m_button); row->addWidget(clear);
layout->addLayout(row);
m_label = new QLabel(this);
m_label->setWordWrap(true);
m_label->setTextFormat(Qt::PlainText);
m_label->setStyleSheet("font-size: 13px; padding-bottom: 12px;");
layout->addWidget(m_label);
connect(m_button, &QPushButton::clicked, this, [this]() {
if (m_capturing) finishCapture(false); else startCapture();
});
connect(clear, &QPushButton::clicked, this, [this]() {
finishCapture(false);
m_chord.clear(); updateLabel();
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: shortcut draft cleared");
});
connect(PttHotkeyManager::instance(), &PttHotkeyManager::keysChanged, this, [this](const PttKeyChord& held) {
if (!m_capturing) return;
if (m_capture.observe(held)) finishCapture(true);
else if (!held.isEmpty())
m_label->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() {
finishCapture(false);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: shortcut recording requested");
if (!PttHotkeyManager::instance()->beginCapture()) {
m_label->setText(QCoreApplication::translate("ChatGui", "Failed to start key recording. See the radio log for details."));
return;
}
m_capturing = true;
m_capture.candidate.clear();
m_button->setText(QCoreApplication::translate("ChatGui", "Cancel recording"));
m_label->setText(QCoreApplication::translate("ChatGui", "Press one or more keys together…"));
qApp->installEventFilter(this);
}
void RadioSettingsPage::finishCapture(bool save) {
if (!m_capturing) return;
if (save) {
m_chord = m_capture.candidate;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: captured shortcut=[%s] codes=[%s]",
qPrintable(PttHotkeyManager::instance()->describe(m_capture.candidate)), qPrintable(pttEncodeChord(m_capture.candidate)));
} else {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: recording cancelled candidate=[%s]",
qPrintable(pttEncodeChord(m_capture.candidate)));
}
m_capturing = false;
qApp->removeEventFilter(this);
PttHotkeyManager::instance()->endCapture();
m_button->setText(QCoreApplication::translate("ChatGui", "Set PTT shortcut…"));
updateLabel();
}
bool RadioSettingsPage::eventFilter(QObject* object, QEvent* event) {
if (m_capturing) {
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_RADIO, "PTT: capture window event=%s qt=0x%x native_vk=%u scan=%u modifiers=0x%x",
event->type() == QEvent::KeyPress ? "press" : "release",
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_RADIO, "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() {
m_label->setText(PttHotkeyManager::instance()->describe(m_chord));
}
void RadioSettingsPage::loadFromDb() {
m_activation->loadSettings();
m_doublePress->setChecked(!m_db || !m_db->isOpen() || m_db->getUiState("radio_ptt_double_press") != "0");
if (m_portalStatus) { updatePortalLabels(); return; }
finishCapture(false);
m_chord = m_db && m_db->isOpen() ? pttDecodeChord(m_db->getUiState("radio_ptt_hotkey")) : PttKeyChord();
updateLabel();
}
QString RadioSettingsPage::hotkey() const { return pttEncodeChord(m_chord); }
bool RadioSettingsPage::doublePressEnabled() const { return m_doublePress->isChecked(); }
bool RadioSettingsPage::apply() {
if (!m_activation->apply()) return false;
PttHotkeyManager::instance()->setDoublePressEnabled(doublePressEnabled());
if (m_portalStatus) return true;
finishCapture(false);
PttHotkeyManager::instance()->setHotkey(hotkey());
return true;
}
void RadioSettingsPage::updatePortalLabels() {
auto* manager = PttHotkeyManager::instance();
QString text = manager->portalDescription();
m_label->setText(QCoreApplication::translate("ChatGui", "PTT shortcut: %1")
.arg(text.isEmpty() ? QCoreApplication::translate("ChatGui", "Not set") : text));
m_portalStatus->setText(manager->portalStatus());
m_button->setEnabled(!manager->portalBusy());
}