You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

306 lines
16 KiB

#include <QCoreApplication>
#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 <QHBoxLayout>
#include <QToolButton>
#include <QPushButton>
#include <QLabel>
#include <QMenu>
#include <QActionGroup>
#include <QSignalBlocker>
#include <QStyle>
#include <QMouseEvent>
#include <QWidgetAction>
#include <QScrollArea>
#include <QVBoxLayout>
#include <QPainter>
#include <QScreen>
#include <QGuiApplication>
#include <cstring>
#include <algorithm>
/* Полоса показывает текущую вероятность речи, не установленный порог срабатывания. */
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<QMouseEvent*>(event)->button() == Qt::LeftButton) {
m_autoLongPressed = false; m_autoHold.start();
} else if (event->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent*>(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<QMouseEvent*>(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<QActionGroup*>();
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<save_ui_state_arg*>(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);
}
}