From dcb07c9124391373bbfd8aa9357253fd4e0fcbb0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 3 Oct 2026 13:08:04 +0300 Subject: [PATCH] Add live desktop radio VAD settings and transmission state --- tools/chatgui/src/radio_audio_engine.cpp | 160 ++++++++++++++++++++++- tools/chatgui/src/radio_audio_engine.h | 24 +++- 2 files changed, 180 insertions(+), 4 deletions(-) diff --git a/tools/chatgui/src/radio_audio_engine.cpp b/tools/chatgui/src/radio_audio_engine.cpp index 16841a36..125e5098 100644 --- a/tools/chatgui/src/radio_audio_engine.cpp +++ b/tools/chatgui/src/radio_audio_engine.cpp @@ -2,6 +2,7 @@ #include "sound_manager.h" #include "../transport/audio_diagnostics.h" #include "../transport/gui_bridge.h" +#include "../transport/config_updater.h" #include "miniaudio.h" @@ -9,10 +10,26 @@ extern "C" { #include "radio/radio_audio.h" #include "chat/chat_setting.h" #include "../../lib/debug_config.h" +#include "../../lib/mem.h" } #include #include +#include +#include +#include + +namespace { +constexpr const char* settingKeys[] = {"radio_vad_enabled", "radio_vad_threshold", "radio_vad_hangover_ms", + "radio_compressor_enabled", "radio_compressor_max_gain_db", "radio_vad_compressor_max_gain_db"}; +constexpr int settingMin[] = {0, 10, 50, 0, 1, 1}, settingMax[] = {1, 95, 2000, 1, 60, 60}; +struct SettingsRequest { + RadioAudioEngine* engine; + UTUN_INSTANCE* inst; + std::array values; + bool apply; +}; +} RadioAudioEngine* RadioAudioEngine::instance() { static auto* engine = new RadioAudioEngine(); @@ -34,7 +51,7 @@ RadioAudioEngine::RadioAudioEngine() { } recoverDevices(); }); - m_recoveryTimer.setInterval(200); + m_recoveryTimer.setInterval(100); connect(&m_recoveryTimer, &QTimer::timeout, this, &RadioAudioEngine::recoverDevices); connect(SoundManager::instance(), &SoundManager::contextAboutToReset, this, &RadioAudioEngine::pauseDevices); connect(SoundManager::instance(), &SoundManager::contextRestored, this, [this]() { @@ -43,6 +60,127 @@ RadioAudioEngine::RadioAudioEngine() { }); } +int RadioAudioEngine::setting(const QString& name) const { + for (size_t i = 0; i < m_settings.size(); ++i) if (name == QLatin1String(settingKeys[i])) return m_settings[i]; + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: unknown setting %s", qPrintable(name)); + return 0; +} + +void RadioAudioEngine::loadSettings(const QString& configPath) { + if (m_settingsBusy) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: settings load deferred: update pending"); + return; + } + m_configPath = configPath; + m_settingsReady = false; + postSettings(m_settings, false); +} + +/* Проверяем значения и атомарно сохраняем INI до отправки команды в ядро. */ +bool RadioAudioEngine::applySettings(const QMap& values) { + if (!m_settingsReady || m_settingsBusy || !gui_bridge_is_uasync_ready() || !gui_bridge_get_inst()) { + m_settingsError = QStringLiteral("Настройки рации ещё не готовы"); + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: cannot apply settings ready=%d busy=%d", m_settingsReady, m_settingsBusy); + emit stateChanged(); + return false; + } + auto next = m_settings; + for (auto it = values.cbegin(); it != values.cend(); ++it) { + size_t index = 0; + while (index < next.size() && it.key() != QLatin1String(settingKeys[index])) ++index; + if (index == next.size() || it.value() < settingMin[index] || it.value() > settingMax[index]) { + m_settingsError = QStringLiteral("Некорректная настройка рации: %1").arg(it.key()); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: invalid setting %s=%d", qPrintable(it.key()), it.value()); + emit stateChanged(); + return false; + } + next[index] = it.value(); + } + if (next == m_settings) return true; + if (!m_configPath.isEmpty()) { + QFile input(m_configPath); + if (!input.open(QIODevice::ReadOnly)) { + m_settingsError = QStringLiteral("Не удалось прочитать конфигурацию: %1").arg(input.errorString()); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: read config failed: %s", qPrintable(input.errorString())); + emit stateChanged(); + return false; + } + QStringList lines = QString::fromUtf8(input.readAll()).split('\n'); + input.close(); + for (size_t i = 0; i < next.size(); ++i) + if (next[i] != m_settings[i]) config_update_in_lines(lines, "chatserver", settingKeys[i], QString::number(next[i]), true); + QByteArray data = lines.join('\n').toUtf8(); + if (!data.endsWith('\n')) data.append('\n'); + QSaveFile output(m_configPath); + if (!output.open(QIODevice::WriteOnly) || output.write(data) != data.size() || !output.commit()) { + m_settingsError = QStringLiteral("Не удалось сохранить конфигурацию: %1").arg(output.errorString()); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: save config failed: %s", qPrintable(output.errorString())); + emit stateChanged(); + return false; + } + } + postSettings(next, true); + return m_settingsBusy; +} + +/* Возврат в GUI происходит после применения всей группы настроек в uasync. */ +void RadioAudioEngine::postSettings(const std::array& values, bool apply) { + auto* inst = gui_bridge_get_inst(); + void* memory = inst && gui_bridge_is_uasync_ready() ? u_malloc(sizeof(SettingsRequest)) : nullptr; + if (!memory) { + m_settingsError = QStringLiteral("Не удалось отправить настройки рации в ядро"); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: settings request unavailable inst=%d uasync=%d", + inst != nullptr, gui_bridge_is_uasync_ready()); + emit stateChanged(); + return; + } + auto* request = new (memory) SettingsRequest{this, inst, values, apply}; + m_settingsBusy = true; + m_settingsError.clear(); + emit stateChanged(); + gui_bridge_post_uasync_fn([](void* arg) { + auto* request = static_cast(arg); + bool success = true; + for (size_t i = 0; i < request->values.size(); ++i) { + if (request->apply) { + char value[16]; + snprintf(value, sizeof(value), "%d", request->values[i]); + if (chat_setting_set(request->inst, settingKeys[i], value) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: core rejected %s=%s", settingKeys[i], value); + success = false; + } + } + request->values[i] = chat_setting_get_int(request->inst, settingKeys[i], request->values[i]); + } + auto* engine = request->engine; + auto snapshot = request->values; + QMetaObject::invokeMethod(engine, [engine, snapshot, success]() { + bool changed = engine->m_settings != snapshot; + engine->m_settings = snapshot; + engine->m_settingsReady = true; + engine->m_settingsBusy = false; + if (!success) engine->m_settingsError = QStringLiteral("Ядро отклонило настройки рации"); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: settings applied=%d vad=%d threshold=%d hangover=%dms TX_AGC=%d " + "TX_gain=%ddB VAD_gain=%ddB", success, snapshot[0], snapshot[1], snapshot[2], snapshot[3], snapshot[4], snapshot[5]); + if (changed && engine->m_active) engine->reconfigureCapture(); + emit engine->stateChanged(); + }, Qt::QueuedConnection); + request->~SettingsRequest(); + u_free(request); + }, request); +} + +void RadioAudioEngine::reconfigureCapture() { + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: reconfigure capture group=%016llx manual=%d vad=%d", + (unsigned long long)m_groupId.load(), m_talking.load(), vadEnabled()); + m_capture.close(); + m_voice.stopCapture(); + radio_audio_capture_stop(); + updateState(); + m_capture.retryNow(); + recoverDevices(); +} + int RadioAudioEngine::start(quint64 groupId) { if (m_active && m_groupId == groupId) return 0; stop(); @@ -80,11 +218,12 @@ void RadioAudioEngine::openCapture() { m_captureChannels = channels; auto* inst = gui_bridge_get_inst(); - int vad = chat_setting_get_int(inst, "radio_vad_enabled", 0); - if (radio_audio_capture_start(m_groupId.load(), channels, vad) != 0) { + if (radio_audio_capture_start(m_groupId.load(), channels, vadEnabled()) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: core capture start failed ch=%d vad=%d", channels, vadEnabled()); m_capture.close(); m_capture.retryLater(); return; } if (!m_voice.startCapture(channels, chat_setting_get_int(inst, "aec_enabled", 1) != 0)) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: capture worker start failed ch=%d", channels); m_capture.close(); radio_audio_capture_stop(); return; } if (m_talking) m_voice.setPtt(true); @@ -104,6 +243,19 @@ void RadioAudioEngine::recoverDevices() { } if (m_capture.retryReady()) openCapture(); m_voice.setAecEnabled(chat_setting_get_int(gui_bridge_get_inst(), "aec_enabled", 1) != 0); + updateState(); +} + +/* Снимок фактического TX включает ручной PTT и VAD; уведомляем GUI только при изменении. */ +void RadioAudioEngine::updateState() { + bool capture = m_active && m_capture.initialized() && m_voice.captureRunning() && ma_device_is_started(m_capture.device()); + bool vad = capture && radio_audio_vad_mode(); + bool transmitting = m_active && radio_audio_transmitting(); + if (capture == m_captureAvailable && vad == m_vadAvailable && transmitting == m_transmitting) return; + m_captureAvailable = capture; m_vadAvailable = vad; m_transmitting = transmitting; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: state group=%016llx capture=%d vad_requested=%d vad_ready=%d TX=%d manual=%d", + (unsigned long long)m_groupId.load(), capture, vadEnabled(), vad, transmitting, m_talking.load()); + emit stateChanged(); } void RadioAudioEngine::pauseDevices() { @@ -111,6 +263,7 @@ void RadioAudioEngine::pauseDevices() { SoundManager::instance()->detachVoice(&m_voice); m_outputAttached = false; if (m_active) radio_audio_capture_stop(); m_voice.resetPlayback(); + updateState(); } void RadioAudioEngine::stop() { @@ -121,6 +274,7 @@ void RadioAudioEngine::stop() { m_buttonTalking = m_hotkeyTalking = false; m_active = false; if (group) radio_audio_stop(); + updateState(); if (talking) emit talkingChanged(false); if (group) DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: stopped group=%016llx", (unsigned long long)group); } diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h index b879b19d..73b25566 100644 --- a/tools/chatgui/src/radio_audio_engine.h +++ b/tools/chatgui/src/radio_audio_engine.h @@ -7,6 +7,8 @@ #include "audio_device.h" #include "voice_audio_io.h" #include +#include +#include struct ma_device; @@ -32,6 +34,18 @@ public: void talkEnd(); void setHotkeyTalking(bool pressed); // независимый владелец PTT — глобальная клавиатура + /* Настройки читаются/применяются в uasync; после подтверждения GUI перезапускает только capture. */ + void loadSettings(const QString& configPath); + bool applySettings(const QMap& values); + int setting(const QString& name) const; + bool settingsReady() const { return m_settingsReady; } + bool settingsBusy() const { return m_settingsBusy; } + bool vadEnabled() const { return m_settings[0] != 0; } + bool vadAvailable() const { return m_vadAvailable; } + bool captureAvailable() const { return m_captureAvailable; } + bool isTransmitting() const { return m_transmitting; } + QString settingsError() const { return m_settingsError; } + bool isTalking() const { return m_talking.load(); } bool isActive() const { return m_active.load(); } quint64 groupId() const { return m_groupId.load(); } @@ -41,6 +55,7 @@ public: signals: void talkingChanged(bool talking); // GUI-поток, суммарное состояние кнопки и хоткея + void stateChanged(); // фактический TX, доступность capture/VAD и настройки; GUI-поток private: RadioAudioEngine(); @@ -49,7 +64,14 @@ private: void pauseDevices(); void recoverDevices(); void openCapture(); - + void updateState(); + void reconfigureCapture(); + void postSettings(const std::array& values, bool apply); + std::array m_settings{0, 50, 200, 1, 15, 15}; + QString m_configPath; + QString m_settingsError; + bool m_settingsReady = false, m_settingsBusy = false; + bool m_transmitting = false, m_vadAvailable = false, m_captureAvailable = false; std::atomic m_active{false}; std::atomic m_talking{false};