#include #include "radiopanel.h" #include "radio_audio_engine.h" #include "radiosettingspage.h" #include "sound_manager.h" #include "../transport/gui_bridge.h" extern "C" { #include "chat/chat_core.h" #include "../../lib/debug_config.h" #include "../../lib/mem.h" } #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* Полоса показывает текущую вероятность речи, не установленный порог срабатывания. */ class RadioAutoButton final : public QPushButton { public: explicit RadioAutoButton(QWidget* parent) : QPushButton(QStringLiteral("VAD"), parent) {} protected: void paintEvent(QPaintEvent* event) override { QPushButton::paintEvent(event); auto* engine = RadioAudioEngine::instance(); if (engine->autoMode() != 1) return; QPainter painter(this); QRectF track(6, height() - 7, width() - 12, 3); painter.fillRect(track, isChecked() ? QColor(0, 0, 0, 65) : palette().color(QPalette::Mid)); if (!engine->vadValid()) return; track.setWidth(track.width() * std::clamp(engine->vadProbability(), 0.0f, 1.0f)); painter.fillRect(track, QColor("#4cdb88")); } }; RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) { setObjectName(QStringLiteral("radioPanel")); auto* row = new QHBoxLayout(this); row->setContentsMargins(10, 6, 10, 6); row->setSpacing(8); m_input = new QToolButton(this); m_output = new QToolButton(this); m_input->setText(QCoreApplication::translate("ChatGui", "Microphone")); m_output->setText(QCoreApplication::translate("ChatGui", "Audio output")); m_input->setObjectName(QStringLiteral("radioInputButton")); m_output->setObjectName(QStringLiteral("radioOutputButton")); for (auto* button : {m_input, m_output}) { button->setPopupMode(QToolButton::InstantPopup); button->setMenu(new QMenu(button)); auto* group = new QActionGroup(button->menu()); group->setExclusive(true); row->addWidget(button); connect(button->menu(), &QMenu::aboutToShow, this, [this, button]() { populateDevices(button, button == m_input); }); } m_status = new QLabel(this); m_status->setObjectName(QStringLiteral("radioStatus")); m_status->setMinimumWidth(0); m_status->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); row->addWidget(m_status, 1); m_auto = new RadioAutoButton(this); m_auto->setObjectName(QStringLiteral("radioAutoButton")); m_auto->setAccessibleName(QCoreApplication::translate("ChatGui", "Automatic voice transmission")); m_auto->setCheckable(true); m_auto->setMinimumWidth(72); m_auto->setFixedHeight(34); m_auto->installEventFilter(this); m_auto->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_auto, &QWidget::customContextMenuRequested, this, [this]() { showActivationSettings(); }); m_autoHold.setSingleShot(true); m_autoHold.setInterval(500); connect(&m_autoHold, &QTimer::timeout, this, [this]() { if (!m_auto->isDown() || !m_auto->isEnabled()) return; m_autoLongPressed = true; m_auto->setDown(false); showActivationSettings(); }); m_auto->setStyleSheet("QPushButton:checked { background: #3390EC; color: white; }"); row->addWidget(m_auto); m_ptt = new QPushButton(QStringLiteral("PTT"), this); m_ptt->setObjectName(QStringLiteral("radioPttButton")); m_ptt->setAccessibleName(QCoreApplication::translate("ChatGui", "Hold to talk")); m_ptt->installEventFilter(this); m_ptt->setFixedSize(64, 34); m_ptt->setStyleSheet("QPushButton[transmitting=\"true\"] { background: #27ae60; color: white; }"); row->addWidget(m_ptt); auto* engine = RadioAudioEngine::instance(); connect(m_ptt, &QPushButton::pressed, this, [this, engine]() { if (m_pttLatched) { setPttLatched(false, "button-pressed"); updateState(); } else engine->talkBegin(); }); connect(m_ptt, &QPushButton::released, this, [this, engine]() { if (!m_pttLatched) engine->talkEnd(); }); connect(m_auto, &QPushButton::clicked, this, [this](bool checked) { if (m_autoLongPressed) { updateState(); return; } RadioAudioEngine::instance()->applySettings({{QStringLiteral("radio_auto_enabled"), checked ? 1 : 0}}); updateState(); }); connect(engine, &RadioAudioEngine::stateChanged, this, &RadioPanel::updateState); connect(engine, &RadioAudioEngine::inputLevelChanged, m_auto, [this]() { m_auto->update(); }); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioPanel: VAD button meter uses live speech probability (0..100%%), refresh <=10Hz"); connect(SoundManager::instance(), &SoundManager::devicesChanged, this, &RadioPanel::updateDevices); updateState(); updateDevices(); } RadioPanel::~RadioPanel() { if (m_pttLatched) setPttLatched(false, "panel-destroyed"); } /* Второе нажатие Qt приходит как DoubleClick: не отдаём его обычному переключателю pressed. */ bool RadioPanel::eventFilter(QObject* watched, QEvent* event) { if (watched == m_auto) { if (event->type() == QEvent::MouseButtonPress && static_cast(event)->button() == Qt::LeftButton) { m_autoLongPressed = false; m_autoHold.start(); } else if (event->type() == QEvent::MouseButtonRelease && static_cast(event)->button() == Qt::LeftButton) { m_autoHold.stop(); if (m_autoLongPressed) { m_autoLongPressed = false; m_auto->setDown(false); return true; } } else if (event->type() == QEvent::Leave || event->type() == QEvent::Hide || event->type() == QEvent::WindowDeactivate) { m_autoHold.stop(); } else if (event->type() == QEvent::KeyPress) m_autoLongPressed = false; } if (watched == m_ptt && event->type() == QEvent::MouseButtonDblClick && m_ptt->isEnabled()) { auto* mouse = static_cast(event); if (mouse->button() == Qt::LeftButton && m_ptt->rect().contains(mouse->pos())) { setPttLatched(true, "double-click"); updateState(); return true; } } return QWidget::eventFilter(watched, event); } void RadioPanel::showActivationSettings() { if (!m_auto->isEnabled()) return; m_autoHold.stop(); m_auto->setDown(false); if (!m_activationMenu) { m_activationMenu = new QMenu(this); m_activationMenu->setObjectName(QStringLiteral("radioActivationPopup")); auto* action = new QWidgetAction(m_activationMenu); auto* contents = new QWidget(m_activationMenu); auto* layout = new QVBoxLayout(contents); auto* scroll = new QScrollArea(contents); scroll->setFrameShape(QFrame::NoFrame); scroll->setWidgetResizable(true); m_activationSettings = new RadioActivationSettings(true); scroll->setWidget(m_activationSettings); int height = QGuiApplication::primaryScreen()->availableGeometry().height(); int contentHeight = m_activationSettings->heightForWidth(420); if (contentHeight < 0) contentHeight = m_activationSettings->sizeHint().height(); scroll->setFixedSize(420, std::min(contentHeight + 24, std::min(480, std::max(160, height - 120)))); layout->addWidget(scroll); auto* apply = new QPushButton(QCoreApplication::translate("ChatGui", "Apply"), contents); apply->setObjectName(QStringLiteral("radioActivationApply")); layout->addWidget(apply); connect(apply, &QPushButton::clicked, m_activationSettings, &RadioActivationSettings::apply); connect(m_activationSettings, &RadioActivationSettings::changed, apply, [this, apply]() { apply->setEnabled(m_activationSettings->canApply()); }); action->setDefaultWidget(contents); m_activationMenu->addAction(action); connect(m_activationMenu, &QMenu::aboutToHide, this, [this]() { m_autoLongPressed = false; DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioPanel: close activation quick settings"); }); } m_activationSettings->loadSettings(); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioPanel: open activation quick settings mode=%d", RadioAudioEngine::instance()->autoMode()); m_activationMenu->popup(m_auto->mapToGlobal(QPoint(0, m_auto->height()))); } /* Фиксация владеет только кнопочным PTT; хоткей и автоматическая передача остаются независимыми. */ void RadioPanel::setPttLatched(bool latched, const char* reason) { if (m_pttLatched == latched) return; m_pttLatched = latched; auto* engine = RadioAudioEngine::instance(); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioPanel: PTT %s group=%016llx reason=%s", latched ? "latched (click to release)" : "latch released", (unsigned long long)engine->groupId(), reason); if (latched) engine->talkBegin(); else engine->talkEnd(); } void RadioPanel::updateState() { auto* engine = RadioAudioEngine::instance(); if (m_pttLatched && (!engine->isActive() || !engine->captureAvailable())) setPttLatched(false, engine->isActive() ? "microphone-unavailable" : "radio-stopped"); QSignalBlocker blocker(m_auto); m_auto->setText(engine->autoMode() == 2 ? QString() : QStringLiteral("VAD")); QIcon levelIcon; if (engine->autoMode() == 2) { QPixmap pixmap(24, 24); pixmap.fill(Qt::transparent); QPainter painter(&pixmap); painter.setPen(QPen(engine->autoEnabled() ? Qt::white : palette().color(QPalette::ButtonText), 3, Qt::SolidLine, Qt::RoundCap)); painter.drawLine(5, 10, 5, 19); painter.drawLine(12, 4, 12, 19); painter.drawLine(19, 7, 19, 19); painter.end(); levelIcon = QIcon(pixmap); } m_auto->setIcon(levelIcon); m_auto->setIconSize(QSize(24, 24)); m_auto->setChecked(engine->autoEnabled()); m_auto->setEnabled(engine->isActive() && engine->settingsReady() && !engine->settingsBusy()); if (!engine->isActive() && m_activationMenu) m_activationMenu->hide(); m_ptt->setEnabled(engine->isActive() && engine->captureAvailable()); m_ptt->setText(m_pttLatched ? QStringLiteral("PTT \U0001F512") : QStringLiteral("PTT")); m_ptt->setToolTip(m_pttLatched ? QCoreApplication::translate("ChatGui", "PTT locked. Click to release.") : QCoreApplication::translate("ChatGui", "Hold to talk, or double-click to lock PTT. Click again to release. Global shortcuts also work.")); m_ptt->setAccessibleName(m_pttLatched ? QCoreApplication::translate("ChatGui", "PTT locked. Click to release.") : QCoreApplication::translate("ChatGui", "Hold to talk")); bool transmitting = engine->isTransmitting(); if (m_ptt->property("transmitting").toBool() != transmitting || !m_ptt->property("transmitting").isValid()) { m_ptt->setProperty("transmitting", transmitting); m_ptt->style()->unpolish(m_ptt); m_ptt->style()->polish(m_ptt); m_ptt->update(); } QString state; if (!engine->settingsError().isEmpty()) state = QCoreApplication::translate("ChatGui", "Settings error"); else if (engine->settingsBusy()) state = QCoreApplication::translate("ChatGui", "Applying settings…"); else if (!engine->captureAvailable()) state = QCoreApplication::translate("ChatGui", "Microphone unavailable"); else if (engine->autoEnabled() && !engine->autoAvailable()) state = QCoreApplication::translate("ChatGui", "Automatic transmission unavailable"); else if (transmitting) state = QCoreApplication::translate("ChatGui", "Transmitting"); else if (engine->autoEnabled() && engine->autoBlocked()) state = QCoreApplication::translate("ChatGui", "Channel busy"); else if (!engine->autoEnabled()) state = QCoreApplication::translate("ChatGui", "Receiving"); else state = engine->autoMode() == 2 ? QCoreApplication::translate("ChatGui", "Waiting for signal") : QCoreApplication::translate("ChatGui", "VAD listening"); m_status->setText(state); m_status->setToolTip(engine->settingsError().isEmpty() ? state : engine->settingsError()); const QString toggle = engine->autoEnabled() ? QCoreApplication::translate("ChatGui", "Disable automatic voice transmission") : QCoreApplication::translate("ChatGui", "Enable automatic voice transmission"); const QString mode = engine->autoMode() == 2 ? QCoreApplication::translate("ChatGui", "By signal level") : QStringLiteral("VAD"); const QString hint = QCoreApplication::translate("ChatGui", "Hold or right-click to adjust activation mode and thresholds"); m_auto->setToolTip(mode + "\n" + toggle + "\n" + hint); m_auto->setAccessibleName(mode + ". " + toggle); m_auto->setAccessibleDescription(hint); m_auto->update(); } /* Список перечитывается при открытии меню: подключённые устройства видны без перезапуска. */ void RadioPanel::populateDevices(QToolButton* button, bool capture) { auto* menu = button->menu(); menu->clear(); auto* group = menu->findChild(); QString selected = capture ? SoundManager::instance()->captureDevice() : SoundManager::instance()->playbackDevice(); auto add = [&](const QString& name, const QString& id) { auto* action = menu->addAction(name); action->setCheckable(true); action->setChecked(id == selected); group->addAction(action); connect(action, &QAction::triggered, this, [this, id, capture]() { selectDevice(id, capture); }); }; add(QCoreApplication::translate("ChatGui", "Default (auto)"), QString()); auto devices = capture ? SoundManager::enumerateCaptureDevices() : SoundManager::enumeratePlaybackDevices(); for (const auto& device : devices) add(device.name, device.id); } void RadioPanel::selectDevice(const QString& id, bool capture) { auto* inst = gui_bridge_get_inst(); if (!inst || !gui_bridge_is_uasync_ready()) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: cannot save device: core unavailable"); m_status->setText(QCoreApplication::translate("ChatGui", "Core unavailable")); return; } const char* key = capture ? "audio_input_id" : "audio_output_id"; QByteArray value = id.toUtf8(); auto* request = static_cast(u_calloc(1, sizeof(save_ui_state_arg))); if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: device settings allocation failed"); m_status->setText(QCoreApplication::translate("ChatGui", "Failed to select device")); return; } size_t keyLen = strlen(key), valueLen = value.size(); if (keyLen + valueLen + 2 > sizeof(request->data)) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: device ID too long bytes=%zu", valueLen); u_free(request); m_status->setText(QCoreApplication::translate("ChatGui", "Failed to select device")); return; } request->inst = inst; memcpy(request->data, key, keyLen + 1); memcpy(request->data + keyLen + 1, value.constData(), valueLen + 1); gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, request); auto* manager = SoundManager::instance(); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioPanel: selected %s default=%d", capture ? "input" : "output", id.isEmpty()); manager->setDevices(capture ? manager->playbackDevice() : id, capture ? id : manager->captureDevice()); } void RadioPanel::updateDevices() { auto* manager = SoundManager::instance(); for (auto* button : {m_input, m_output}) { bool capture = button == m_input; QString id = capture ? manager->captureDevice() : manager->playbackDevice(); QString name = QCoreApplication::translate("ChatGui", "Default (auto)"); if (!id.isEmpty()) { name = QCoreApplication::translate("ChatGui", "Selected device unavailable; using an available device"); auto devices = capture ? manager->enumerateCaptureDevices() : manager->enumeratePlaybackDevices(); for (const auto& device : devices) if (device.id == id) { name = device.name; break; } } button->setToolTip(name); button->setAccessibleName(button->text() + ": " + name); } }