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
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); |
|
} |
|
}
|
|
|