radio: add activation controls, live VAD meters and signal diagnostics
This commit is contained in:
1 parent
fddbba217d
commit
7f3247bfcc
23 files changed
+818
-182
No files matched your search
@@ -271,8 +271,12 @@ void RadioAudioEngine::updateState() {
|
||||
bool capture = m_active && m_capture.initialized() && m_voice.captureRunning() && ma_device_is_started(m_capture.device());
|
||||
auto input = radio_audio_get_input_status();
|
||||
bool valid = capture && input.valid;
|
||||
if (m_inputLevel != input.level_dbfs || m_inputLevelValid != valid) {
|
||||
bool vadValid = capture && input.vad_valid;
|
||||
if (m_inputLevel != input.level_dbfs || m_inputLevelValid != valid || m_vadProbability != input.vad_probability ||
|
||||
m_vadValid != vadValid || m_vadGain != input.vad_gain_db || m_vadCompressorEnabled != bool(input.vad_compressor_enabled)) {
|
||||
m_inputLevel = input.level_dbfs; m_inputLevelValid = valid;
|
||||
m_vadProbability = input.vad_probability; m_vadValid = vadValid;
|
||||
m_vadGain = input.vad_gain_db; m_vadCompressorEnabled = input.vad_compressor_enabled;
|
||||
emit inputLevelChanged();
|
||||
}
|
||||
bool automatic = capture && radio_audio_auto_mode() != RADIO_AUTO_OFF;
|
||||
|
||||
@@ -44,6 +44,10 @@ public:
|
||||
int autoMode() const { return m_settings[6]; }
|
||||
float inputLevel() const { return m_inputLevel; }
|
||||
bool inputLevelValid() const { return m_inputLevelValid; }
|
||||
float vadProbability() const { return m_vadProbability; }
|
||||
bool vadValid() const { return m_vadValid; }
|
||||
float vadGain() const { return m_vadGain; }
|
||||
bool vadCompressorEnabled() const { return m_vadCompressorEnabled; }
|
||||
bool autoBlocked() const { return m_autoBlocked; }
|
||||
bool autoAvailable() const { return m_autoAvailable; }
|
||||
bool captureAvailable() const { return m_captureAvailable; }
|
||||
@@ -80,6 +84,8 @@ private:
|
||||
|
||||
float m_inputLevel = -120;
|
||||
bool m_inputLevelValid = false, m_autoBlocked = false;
|
||||
float m_vadProbability = 0, m_vadGain = 0;
|
||||
bool m_vadValid = false, m_vadCompressorEnabled = false;
|
||||
|
||||
std::atomic<bool> m_active{false};
|
||||
std::atomic<bool> m_talking{false};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <QCoreApplication>
|
||||
#include "radiopanel.h"
|
||||
#include "radio_audio_engine.h"
|
||||
#include "radiosettingspage.h"
|
||||
#include "sound_manager.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
extern "C" {
|
||||
@@ -17,7 +18,32 @@ extern "C" {
|
||||
#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"));
|
||||
@@ -43,12 +69,21 @@ RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) {
|
||||
m_status->setMinimumWidth(0);
|
||||
m_status->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
row->addWidget(m_status, 1);
|
||||
m_auto = new QPushButton(QStringLiteral("VAD"), this);
|
||||
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);
|
||||
@@ -67,10 +102,13 @@ RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) {
|
||||
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();
|
||||
@@ -82,6 +120,16 @@ RadioPanel::~RadioPanel() {
|
||||
|
||||
/* Второе нажатие 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())) {
|
||||
@@ -93,6 +141,42 @@ bool RadioPanel::eventFilter(QObject* watched, QEvent* event) {
|
||||
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;
|
||||
@@ -109,9 +193,19 @@ void RadioPanel::updateState() {
|
||||
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 ? QCoreApplication::translate("ChatGui", "Level") : QStringLiteral("VAD"));
|
||||
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.")
|
||||
@@ -137,8 +231,13 @@ void RadioPanel::updateState() {
|
||||
: QCoreApplication::translate("ChatGui", "VAD listening");
|
||||
m_status->setText(state);
|
||||
m_status->setToolTip(engine->settingsError().isEmpty() ? state : engine->settingsError());
|
||||
m_auto->setToolTip(engine->autoEnabled() ? QCoreApplication::translate("ChatGui", "Disable automatic voice transmission")
|
||||
: QCoreApplication::translate("ChatGui", "Enable automatic voice transmission"));
|
||||
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();
|
||||
}
|
||||
|
||||
/* Список перечитывается при открытии меню: подключённые устройства видны без перезапуска. */
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
#include <QTimer>
|
||||
|
||||
class QToolButton;
|
||||
class QPushButton;
|
||||
class QLabel;
|
||||
class QMenu;
|
||||
class RadioActivationSettings;
|
||||
|
||||
/* Верхняя панель активной рации: общие устройства SoundManager, VAD и удерживаемая PTT.
|
||||
* Состояние передачи берётся из ядра; включение VAD хранится в конфигурации приложения. */
|
||||
@@ -17,6 +20,7 @@ private:
|
||||
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||
void setPttLatched(bool latched, const char* reason);
|
||||
void updateState();
|
||||
void showActivationSettings();
|
||||
void updateDevices();
|
||||
void populateDevices(QToolButton* button, bool capture);
|
||||
void selectDevice(const QString& id, bool capture);
|
||||
@@ -24,4 +28,8 @@ private:
|
||||
QPushButton *m_auto, *m_ptt;
|
||||
QLabel* m_status;
|
||||
bool m_pttLatched = false;
|
||||
QTimer m_autoHold;
|
||||
bool m_autoLongPressed = false;
|
||||
QMenu* m_activationMenu = nullptr;
|
||||
RadioActivationSettings* m_activationSettings = nullptr;
|
||||
};
|
||||
@@ -15,7 +15,7 @@
|
||||
#include <QFormLayout>
|
||||
#include <QSpinBox>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QButtonGroup>
|
||||
#include <QPainter>
|
||||
#include <algorithm>
|
||||
#include <QKeyEvent>
|
||||
@@ -29,89 +29,181 @@ public:
|
||||
explicit RadioLevelMeter(QWidget* parent) : QWidget(parent) { setMinimumSize(200, 34); }
|
||||
float level = -120;
|
||||
int on = -35, off = -45;
|
||||
bool valid = false;
|
||||
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 db) { return bar.left() + int(std::clamp((db + 90) / 90, 0.0f, 1.0f) * bar.width()); };
|
||||
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, QStringLiteral("−90 dBFS"));
|
||||
painter.drawText(QRect(0, 18, width(), 16), Qt::AlignRight, QStringLiteral("0"));
|
||||
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<int>::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<QString, int> RadioActivationSettings::values() const {
|
||||
QMap<QString, int> 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_audioSettings = new QWidget(this);
|
||||
auto* form = new QFormLayout(m_audioSettings);
|
||||
form->setContentsMargins(0, 0, 0, 12);
|
||||
auto add = [&](const QString& label, int min, int max, const QString& suffix) {
|
||||
auto* spin = new QSpinBox(m_audioSettings);
|
||||
spin->setRange(min, max);
|
||||
spin->setSuffix(suffix);
|
||||
form->addRow(label, spin);
|
||||
return spin;
|
||||
};
|
||||
m_mode = new QComboBox(m_audioSettings);
|
||||
m_mode->setObjectName(QStringLiteral("radioAutoMode"));
|
||||
m_mode->addItem(QStringLiteral("VAD"), 1);
|
||||
m_mode->addItem(QCoreApplication::translate("ChatGui", "By signal level"), 2);
|
||||
form->addRow(QCoreApplication::translate("ChatGui", "Automatic transmission:"), m_mode);
|
||||
m_levelMeter = new RadioLevelMeter(m_audioSettings);
|
||||
m_levelMeter->setObjectName(QStringLiteral("radioLevelMeter"));
|
||||
form->addRow(QCoreApplication::translate("ChatGui", "Microphone level:"), m_levelMeter);
|
||||
m_levelText = new QLabel(m_audioSettings);
|
||||
m_levelText->setObjectName(QStringLiteral("radioLevelText"));
|
||||
m_levelText->setWordWrap(true);
|
||||
form->addRow(m_levelText);
|
||||
m_levelOn = add(QCoreApplication::translate("ChatGui", "Start threshold (orange):"), -90, 0, QStringLiteral(" dBFS"));
|
||||
m_levelOff = add(QCoreApplication::translate("ChatGui", "Stop threshold (blue):"), -90, 0, QStringLiteral(" dBFS"));
|
||||
m_levelOn->setObjectName(QStringLiteral("radioLevelOn"));
|
||||
m_levelOff->setObjectName(QStringLiteral("radioLevelOff"));
|
||||
m_thresholdError = new QLabel(m_audioSettings);
|
||||
m_thresholdError->setWordWrap(true);
|
||||
m_thresholdError->setStyleSheet(QStringLiteral("color: #e74c3c;"));
|
||||
m_thresholdError->setText(QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold"));
|
||||
form->addRow(m_thresholdError);
|
||||
m_threshold = add(QCoreApplication::translate("ChatGui", "VAD speech threshold:"), 10, 95, QStringLiteral(" %"));
|
||||
m_threshold->setToolTip(QCoreApplication::translate("ChatGui",
|
||||
"Speech probability threshold: lower is more sensitive. This is not a volume threshold."));
|
||||
m_hangover = add(QCoreApplication::translate("ChatGui",
|
||||
"Silence before ending transmission:"), 50, 2000, QCoreApplication::translate("ChatGui",
|
||||
" ms"));
|
||||
m_compressor = new QCheckBox(QCoreApplication::translate("ChatGui", "Transmission and VAD compressor"), m_audioSettings);
|
||||
form->addRow(m_compressor);
|
||||
m_txGain = add(QCoreApplication::translate("ChatGui", "Transmission max gain:"), 1, 60, QCoreApplication::translate("ChatGui", " dB"));
|
||||
m_vadGain = add(QCoreApplication::translate("ChatGui", "VAD max gain:"), 1, 60, QCoreApplication::translate("ChatGui", " dB"));
|
||||
auto* explanation = new QLabel(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_audioSettings);
|
||||
explanation->setWordWrap(true);
|
||||
form->addRow(explanation);
|
||||
layout->addWidget(m_audioSettings);
|
||||
connect(RadioAudioEngine::instance(), &RadioAudioEngine::stateChanged, this, [this]() {
|
||||
auto* engine = RadioAudioEngine::instance();
|
||||
m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy());
|
||||
if (!m_audioSettingsLoaded && engine->settingsReady()) loadAudioSettings();
|
||||
});
|
||||
connect(m_mode, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this]() { updateAudioControls(); });
|
||||
for (auto* spin : {m_levelOn, m_levelOff})
|
||||
connect(spin, QOverload<int>::of(&QSpinBox::valueChanged), this, [this]() { updateAudioControls(); });
|
||||
connect(RadioAudioEngine::instance(), &RadioAudioEngine::inputLevelChanged, this, &RadioSettingsPage::updateLevel);
|
||||
connect(RadioAudioEngine::instance(), &RadioAudioEngine::stateChanged, this, &RadioSettingsPage::updateLevel);
|
||||
loadAudioSettings();
|
||||
updateAudioControls();
|
||||
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 "
|
||||
@@ -267,7 +359,7 @@ void RadioSettingsPage::updateLabel(int index) {
|
||||
}
|
||||
|
||||
void RadioSettingsPage::loadFromDb() {
|
||||
loadAudioSettings();
|
||||
m_activation->loadSettings();
|
||||
if (m_portalStatus) { updatePortalLabels(); return; }
|
||||
finishCapture(false);
|
||||
QStringList values;
|
||||
@@ -286,37 +378,13 @@ QStringList RadioSettingsPage::hotkeys() const {
|
||||
}
|
||||
|
||||
bool RadioSettingsPage::apply() {
|
||||
if (!RadioAudioEngine::instance()->applySettings({
|
||||
{QStringLiteral("radio_auto_mode"), m_mode->currentData().toInt()},
|
||||
{QStringLiteral("radio_level_on_dbfs"), m_levelOn->value()},
|
||||
{QStringLiteral("radio_level_off_dbfs"), m_levelOff->value()},
|
||||
{QStringLiteral("radio_vad_threshold"), m_threshold->value()},
|
||||
{QStringLiteral("radio_auto_hangover_ms"), m_hangover->value()},
|
||||
{QStringLiteral("radio_compressor_enabled"), m_compressor->isChecked() ? 1 : 0},
|
||||
{QStringLiteral("radio_compressor_max_gain_db"), m_txGain->value()},
|
||||
{QStringLiteral("radio_vad_compressor_max_gain_db"), m_vadGain->value()}})) return false;
|
||||
if (!m_activation->apply()) return false;
|
||||
if (m_portalStatus) return true;
|
||||
finishCapture(false);
|
||||
PttHotkeyManager::instance()->setHotkeys(hotkeys());
|
||||
return true;
|
||||
}
|
||||
|
||||
void RadioSettingsPage::loadAudioSettings() {
|
||||
auto* engine = RadioAudioEngine::instance();
|
||||
m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy());
|
||||
if (!engine->settingsReady()) return;
|
||||
m_mode->setCurrentIndex(m_mode->findData(engine->autoMode()));
|
||||
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_compressor->setChecked(engine->setting(QStringLiteral("radio_compressor_enabled")) != 0);
|
||||
m_txGain->setValue(engine->setting(QStringLiteral("radio_compressor_max_gain_db")));
|
||||
m_vadGain->setValue(engine->setting(QStringLiteral("radio_vad_compressor_max_gain_db")));
|
||||
m_audioSettingsLoaded = true;
|
||||
updateAudioControls();
|
||||
}
|
||||
|
||||
void RadioSettingsPage::updatePortalLabels() {
|
||||
auto* manager = PttHotkeyManager::instance();
|
||||
const auto descriptions = manager->portalDescriptions();
|
||||
@@ -329,25 +397,3 @@ void RadioSettingsPage::updatePortalLabels() {
|
||||
m_portalStatus->setText(manager->portalStatus());
|
||||
m_buttons[0]->setEnabled(!manager->portalBusy());
|
||||
}
|
||||
|
||||
void RadioSettingsPage::updateAudioControls() {
|
||||
bool level = m_mode->currentData().toInt() == 2;
|
||||
m_levelOn->setEnabled(level); m_levelOff->setEnabled(level);
|
||||
m_threshold->setEnabled(!level); m_vadGain->setEnabled(!level);
|
||||
m_thresholdError->setVisible(m_levelOff->value() >= m_levelOn->value());
|
||||
m_levelMeter->on = m_levelOn->value(); m_levelMeter->off = m_levelOff->value();
|
||||
updateLevel();
|
||||
}
|
||||
|
||||
void RadioSettingsPage::updateLevel() {
|
||||
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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ptt_key_chord.h"
|
||||
#include <QWidget>
|
||||
#include <QVector>
|
||||
#include <QMap>
|
||||
|
||||
class DbManager;
|
||||
class QPushButton;
|
||||
@@ -11,9 +12,31 @@ class QHideEvent;
|
||||
class QShowEvent;
|
||||
class QSpinBox;
|
||||
class QCheckBox;
|
||||
class QComboBox;
|
||||
class QButtonGroup;
|
||||
class RadioLevelMeter;
|
||||
|
||||
/* Общий редактор вкладки и мини-блока; метрики не перезаписывают редактируемые настройки. */
|
||||
class RadioActivationSettings : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit RadioActivationSettings(bool compact, QWidget* parent = nullptr);
|
||||
void loadSettings();
|
||||
bool apply();
|
||||
bool canApply() const;
|
||||
signals:
|
||||
void changed();
|
||||
private:
|
||||
QMap<QString, int> values() const;
|
||||
void updateControls();
|
||||
void updateMeters();
|
||||
bool m_compact, m_loaded = false;
|
||||
QButtonGroup* m_mode;
|
||||
QSpinBox *m_threshold, *m_hangover, *m_txGain = nullptr, *m_vadGain, *m_levelOn, *m_levelOff;
|
||||
QCheckBox* m_compressor = nullptr;
|
||||
RadioLevelMeter *m_levelMeter, *m_vadMeter;
|
||||
QLabel *m_levelText, *m_vadText, *m_gainText, *m_error;
|
||||
};
|
||||
|
||||
// Запись глобальных сочетаний PTT, включая отдельные левые/правые модификаторы.
|
||||
class RadioSettingsPage : public QWidget {
|
||||
Q_OBJECT
|
||||
@@ -31,17 +54,7 @@ protected:
|
||||
|
||||
private:
|
||||
void updatePortalLabels();
|
||||
void loadAudioSettings();
|
||||
void updateAudioControls();
|
||||
void updateLevel();
|
||||
QWidget* m_audioSettings = nullptr;
|
||||
QSpinBox *m_threshold, *m_hangover, *m_txGain, *m_vadGain;
|
||||
QCheckBox* m_compressor;
|
||||
QComboBox* m_mode;
|
||||
QSpinBox *m_levelOn, *m_levelOff;
|
||||
RadioLevelMeter* m_levelMeter;
|
||||
QLabel *m_levelText, *m_thresholdError;
|
||||
bool m_audioSettingsLoaded = false;
|
||||
RadioActivationSettings* m_activation;
|
||||
QLabel* m_portalStatus = nullptr;
|
||||
void startCapture(int index);
|
||||
void finishCapture(bool save);
|
||||
|
||||
@@ -34,6 +34,7 @@ extern "C" {
|
||||
#include <QTextStream>
|
||||
#include <QMessageBox>
|
||||
#include <QFileDialog>
|
||||
#include <QScrollArea>
|
||||
|
||||
SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNode* node,
|
||||
AudioRecorder* recorder, QWidget* parent)
|
||||
@@ -210,8 +211,12 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
|
||||
m_audioDevicePage->loadFromDb();
|
||||
m_pages->addWidget(m_audioDevicePage);
|
||||
|
||||
m_radioPage = new RadioSettingsPage(db, m_pages);
|
||||
m_pages->addWidget(m_radioPage);
|
||||
auto* radioScroll = new QScrollArea(m_pages);
|
||||
radioScroll->setWidgetResizable(true);
|
||||
radioScroll->setFrameShape(QFrame::NoFrame);
|
||||
m_radioPage = new RadioSettingsPage(db, radioScroll);
|
||||
radioScroll->setWidget(m_radioPage);
|
||||
m_pages->addWidget(radioScroll);
|
||||
rightLayout->addWidget(m_pages, 1);
|
||||
|
||||
// --- Bottom buttons ---
|
||||
|
||||
@@ -205,10 +205,15 @@ void VoiceAudioIo::captureLoop() {
|
||||
int16_t raw[1920]{}, cleaned[1920]{}, referencePcm[1920]{};
|
||||
uint8_t muteMask[960]{};
|
||||
unsigned filled = 0;
|
||||
int64_t frameTime = 0, lastReport = voice_time_us();
|
||||
int64_t frameTime = 0, lastReport = voice_time_us(), lastCapture = lastReport, maxQueueAge = 0;
|
||||
uint64_t expectedSequence = 0, missingReference = 0, reportedReferenceDrops = referenceDrops, captureRoute = 0;
|
||||
int64_t expectedCaptureTime = 0;
|
||||
bool referenceAvailable = false;
|
||||
const char* referenceReason = "empty_history";
|
||||
double referenceGapUs = 0;
|
||||
double rawEnergy = 0, cleanEnergy = 0, referenceEnergy = 0;
|
||||
float rawPeak = 0, cleanPeak = 0, referencePeak = 0;
|
||||
unsigned signalFrames = 0, aecFrames = 0;
|
||||
bool muted = false;
|
||||
auto applyMarker = [&](EventKind kind, bool value) {
|
||||
if (kind == EventKind::Mute) muted = value;
|
||||
@@ -238,6 +243,7 @@ void VoiceAudioIo::captureLoop() {
|
||||
}
|
||||
};
|
||||
auto alignReference = [&]() {
|
||||
referenceReason = "empty_history"; referenceGapUs = 0;
|
||||
if (!historyCount) return false;
|
||||
size_t index = 0;
|
||||
for (unsigned i = 0; i < 960; ++i) {
|
||||
@@ -251,7 +257,11 @@ void VoiceAudioIo::captureLoop() {
|
||||
if (measured > interval * 0.95 && measured < interval * 1.05) interval = measured;
|
||||
}
|
||||
double position = (time - block.time) / interval;
|
||||
if (position < 0 || position >= block.frames) return false;
|
||||
if (position < 0 || position >= block.frames) {
|
||||
referenceReason = position < 0 ? "history_too_new" : index + 1 == historyCount ? "render_not_ready" : "render_gap";
|
||||
referenceGapUs = position < 0 ? -position * interval : (position - block.frames) * interval;
|
||||
return false;
|
||||
}
|
||||
unsigned sample = (unsigned)position;
|
||||
double fraction = position - sample;
|
||||
for (unsigned channel = 0; channel < 2; ++channel) {
|
||||
@@ -260,6 +270,7 @@ void VoiceAudioIo::captureLoop() {
|
||||
referencePcm[2*i+channel] = (int16_t)(first + (second - first) * fraction);
|
||||
}
|
||||
}
|
||||
referenceReason = "aligned";
|
||||
return true;
|
||||
};
|
||||
auto deliver = [&](unsigned frames) {
|
||||
@@ -274,8 +285,42 @@ void VoiceAudioIo::captureLoop() {
|
||||
if (m_kind == Kind::Call && frames == 960) call_audio_feed_prepared_pcm(m_session, cleaned, muteMask);
|
||||
filled = 0; markerCount = 0;
|
||||
};
|
||||
// Отчёт работает и при отсутствии PCM, иначе длительный провал захвата невидим.
|
||||
auto report = [&]() {
|
||||
int64_t now = voice_time_us();
|
||||
if (now - lastReport >= 1000000) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: capture health session=%llu span=%.1fms pcm_age=%.1fms "
|
||||
"queue_age_max=%.1fms queue=%zu pending=%u sequence=%llu",
|
||||
(unsigned long long)m_session, (now - lastReport) / 1000.0, (now - lastCapture) / 1000.0,
|
||||
maxQueueAge / 1000.0, m_captureEvents.size(), filled, (unsigned long long)expectedSequence);
|
||||
if (now - lastCapture >= 1000000)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture stalled session=%llu no_pcm=%.1fms queue=%zu",
|
||||
(unsigned long long)m_session, (now - lastCapture) / 1000.0, m_captureEvents.size());
|
||||
maxQueueAge = 0;
|
||||
if (signalFrames) {
|
||||
auto db = [](double power) { return 10 * std::log10(std::max(power, 1e-12)); };
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: signal session=%llu frames=%u aec_frames=%u raw=%.1fdBFS clean=%.1fdBFS "
|
||||
"reference=%.1fdBFS peaks=%.4f/%.4f/%.4f reference_state=%s gap=%.1fus",
|
||||
(unsigned long long)m_session, signalFrames, aecFrames, db(rawEnergy / (signalFrames * 960 * m_channels)),
|
||||
db(cleanEnergy / (signalFrames * 960 * m_channels)), db(aecFrames ? referenceEnergy / (aecFrames * 1920) : 0),
|
||||
rawPeak, cleanPeak, referencePeak, referenceReason, referenceGapUs);
|
||||
signalFrames = aecFrames = 0; rawEnergy = cleanEnergy = referenceEnergy = 0;
|
||||
rawPeak = cleanPeak = referencePeak = 0;
|
||||
}
|
||||
uint64_t captureDrops = m_captureDropped.exchange(0), refDrops = m_referenceDropped.load();
|
||||
uint64_t underruns = m_playbackUnderruns.exchange(0);
|
||||
if (captureDrops || refDrops != reportedReferenceDrops || missingReference || underruns)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: session=%llu capture_drop=%llu reference_drop=%llu missing_ref=%llu output_missing=%llu",
|
||||
(unsigned long long)m_session, (unsigned long long)captureDrops, (unsigned long long)refDrops,
|
||||
(unsigned long long)missingReference, (unsigned long long)underruns);
|
||||
else DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: session=%llu reference history=%zu capture_queue=%zu",
|
||||
(unsigned long long)m_session, historyCount, m_captureEvents.size());
|
||||
missingReference = 0; reportedReferenceDrops = refDrops; lastReport = now;
|
||||
}
|
||||
};
|
||||
bool stopping = false;
|
||||
while (m_capturing && !stopping) {
|
||||
report();
|
||||
consumeReference();
|
||||
Event event{};
|
||||
if (!m_captureEvents.pop(event)) {
|
||||
@@ -289,9 +334,12 @@ void VoiceAudioIo::captureLoop() {
|
||||
else { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: too many controls within one capture frame"); stopping = true; }
|
||||
continue;
|
||||
}
|
||||
if (voice_time_us() - event.enqueued > 100000) {
|
||||
int64_t received = voice_time_us();
|
||||
maxQueueAge = std::max(maxQueueAge, received - event.enqueued);
|
||||
if (received - event.enqueued > 100000) {
|
||||
m_captureDropped.fetch_add(event.frames); continue;
|
||||
}
|
||||
lastCapture = received;
|
||||
bool clockJump = expectedCaptureTime && std::abs(event.time - expectedCaptureTime) > 20000;
|
||||
if (event.sequence != expectedSequence || (captureRoute && event.route != captureRoute) || clockJump) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture discontinuity expected=%llu actual=%llu pending=%u route=%016llx->%016llx",
|
||||
@@ -315,28 +363,34 @@ void VoiceAudioIo::captureLoop() {
|
||||
memcpy(cleaned, raw, 960 * m_channels * sizeof(int16_t));
|
||||
consumeReference();
|
||||
bool available = aec && alignReference();
|
||||
if (!aec) referenceReason = "disabled";
|
||||
if (available) {
|
||||
speex_aec_feed_playback(aec, referencePcm, 1920);
|
||||
speex_aec_process_capture(aec, raw, 960 * m_channels, cleaned);
|
||||
} else if (aec) {
|
||||
++missingReference;
|
||||
if (referenceAvailable) speex_aec_reset(aec);
|
||||
if (referenceAvailable) {
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: AEC bypass/reset session=%llu reason=%s gap=%.1fus history=%zu capture_time=%lld",
|
||||
(unsigned long long)m_session, referenceReason, referenceGapUs, historyCount, (long long)frameTime);
|
||||
speex_aec_reset(aec);
|
||||
}
|
||||
}
|
||||
if (debug_should_output(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_AEC)) {
|
||||
++signalFrames; if (available) ++aecFrames;
|
||||
for (int i = 0; i < 960 * m_channels; ++i) {
|
||||
float in = raw[i] / 32768.0f, out = cleaned[i] / 32768.0f;
|
||||
rawEnergy += in * in; cleanEnergy += out * out;
|
||||
rawPeak = std::max(rawPeak, std::abs(in)); cleanPeak = std::max(cleanPeak, std::abs(out));
|
||||
}
|
||||
if (available) for (int i = 0; i < 1920; ++i) {
|
||||
float ref = referencePcm[i] / 32768.0f;
|
||||
referenceEnergy += ref * ref; referencePeak = std::max(referencePeak, std::abs(ref));
|
||||
}
|
||||
}
|
||||
referenceAvailable = available;
|
||||
deliver(960);
|
||||
}
|
||||
int64_t now = voice_time_us();
|
||||
if (now - lastReport >= 1000000) {
|
||||
uint64_t captureDrops = m_captureDropped.exchange(0), refDrops = m_referenceDropped.load();
|
||||
uint64_t underruns = m_playbackUnderruns.exchange(0);
|
||||
if (captureDrops || refDrops != reportedReferenceDrops || missingReference || underruns)
|
||||
DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: session=%llu capture_drop=%llu reference_drop=%llu missing_ref=%llu output_missing=%llu",
|
||||
(unsigned long long)m_session, (unsigned long long)captureDrops, (unsigned long long)refDrops,
|
||||
(unsigned long long)missingReference, (unsigned long long)underruns);
|
||||
else DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: session=%llu aligned history=%zu capture_queue=%zu",
|
||||
(unsigned long long)m_session, historyCount, m_captureEvents.size());
|
||||
missingReference = 0; reportedReferenceDrops = refDrops; lastReport = now;
|
||||
}
|
||||
|
||||
}
|
||||
if (filled) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture tail=%u frames bypasses AEC; call tail discarded, radio tail preserved", filled);
|
||||
|
||||
Reference in new issue
Block a user