#include #include "radiosettingspage.h" #include "ptt_hotkey_manager.h" #include "radio_audio_engine.h" #include "../db/db_manager.h" #include "../../lib/debug_config.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_WIN #include #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::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 RadioActivationSettings::values() const { QMap 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(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(GetAsyncKeyState(vk)); unsigned queueState = static_cast(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()); }