307 lines
16 KiB
C++
307 lines
16 KiB
C++
#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);
|
|
}
|
|
}
|