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.
341 lines
16 KiB
341 lines
16 KiB
#include <QCoreApplication> |
|
#include "radio_audio_engine.h" |
|
#include "sound_manager.h" |
|
#include "../transport/audio_diagnostics.h" |
|
#include "../transport/gui_bridge.h" |
|
#include "../transport/config_updater.h" |
|
|
|
#include "miniaudio.h" |
|
|
|
extern "C" { |
|
#include "radio/radio_audio.h" |
|
#include "chat/chat_setting.h" |
|
#include "utun_instance.h" |
|
#include "../../lib/u_async.h" |
|
#include "../../lib/debug_config.h" |
|
#include "../../lib/mem.h" |
|
} |
|
|
|
#include <cstring> |
|
#include <algorithm> |
|
#include <QFile> |
|
#include <QSaveFile> |
|
#include <new> |
|
|
|
namespace { |
|
constexpr const char* settingKeys[] = {"radio_auto_enabled", "radio_vad_threshold", "radio_auto_hangover_ms", |
|
"radio_compressor_enabled", "radio_compressor_max_gain_db", "radio_vad_compressor_max_gain_db", |
|
"radio_auto_mode", "radio_level_on_dbfs", "radio_level_off_dbfs"}; |
|
constexpr int settingMin[] = {0, 10, 50, 0, 1, 1, 1, -90, -90}, settingMax[] = {1, 95, 2000, 1, 60, 60, 2, 0, 0}; |
|
struct SettingsRequest { |
|
posted_task task; // Первое поле: очередь освобождает весь запрос, включая отмену при shutdown. |
|
RadioAudioEngine* engine; |
|
UTUN_INSTANCE* inst; |
|
std::array<int, 9> values; |
|
bool apply; |
|
}; |
|
} |
|
|
|
RadioAudioEngine* RadioAudioEngine::instance() { |
|
static auto* engine = new RadioAudioEngine(); |
|
return engine; |
|
} |
|
|
|
RadioAudioEngine::RadioAudioEngine() { |
|
m_captureId = SoundManager::instance()->captureDevice(); |
|
m_playbackId = SoundManager::instance()->playbackDevice(); |
|
connect(SoundManager::instance(), &SoundManager::devicesChanged, this, [this]() { |
|
auto* manager = SoundManager::instance(); |
|
if (m_captureId != manager->captureDevice()) { |
|
m_captureId = manager->captureDevice(); |
|
m_capture.close(); m_voice.stopCapture(); if (m_active) radio_audio_capture_stop(); m_capture.retryNow(); |
|
} |
|
if (m_playbackId != manager->playbackDevice()) { |
|
m_playbackId = manager->playbackDevice(); |
|
manager->detachVoice(&m_voice); m_outputAttached = false; |
|
} |
|
recoverDevices(); |
|
}); |
|
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]() { |
|
m_capture.retryNow(); |
|
recoverDevices(); |
|
}); |
|
} |
|
|
|
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<QString, int>& values) { |
|
if (!m_settingsReady || m_settingsBusy || !gui_bridge_is_uasync_ready() || !gui_bridge_get_inst()) { |
|
m_settingsError = QCoreApplication::translate("ChatGui", "Radio settings are not ready yet"); |
|
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 = QCoreApplication::translate("ChatGui", "Invalid radio setting: %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[8] >= next[7]) { |
|
m_settingsError = QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold"); |
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: invalid threshold pair on=%d off=%d", next[7], next[8]); |
|
emit stateChanged(); |
|
return false; |
|
} |
|
if (next == m_settings && !m_active) return true; |
|
if (next != m_settings && !m_configPath.isEmpty()) { |
|
QFile input(m_configPath); |
|
if (!input.open(QIODevice::ReadOnly)) { |
|
m_settingsError = QCoreApplication::translate("ChatGui", "Failed to read configuration: %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 = QCoreApplication::translate("ChatGui", "Failed to save configuration: %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<int, 9>& 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 = QCoreApplication::translate("ChatGui", "Failed to send radio settings to the core"); |
|
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(); |
|
request->task.arg = request; |
|
request->task.callback = [](void* arg) { |
|
auto* request = static_cast<SettingsRequest*>(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; |
|
bool apply = request->apply; |
|
QMetaObject::invokeMethod(engine, [engine, snapshot, success, apply]() { |
|
bool changed = engine->m_settings != snapshot; |
|
engine->m_settings = snapshot; |
|
engine->m_settingsReady = true; |
|
engine->m_settingsBusy = false; |
|
if (snapshot[8] >= snapshot[7]) { |
|
engine->m_settingsError = QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold"); |
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: loaded invalid threshold pair on=%d off=%d", snapshot[7], snapshot[8]); |
|
} |
|
if (!success) engine->m_settingsError = QCoreApplication::translate("ChatGui", "The core rejected radio settings"); |
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: settings applied=%d auto=%d threshold=%d hangover=%dms TX_AGC=%d " |
|
"TX_gain=%ddB VAD_gain=%ddB mode=%d level_on=%d level_off=%d", success, snapshot[0], snapshot[1], snapshot[2], |
|
snapshot[3], snapshot[4], snapshot[5], snapshot[6], snapshot[7], snapshot[8]); |
|
if ((changed || apply) && engine->m_active) engine->reconfigureCapture(); |
|
emit engine->stateChanged(); |
|
}, Qt::QueuedConnection); |
|
request->~SettingsRequest(); |
|
}; |
|
uasync_post_reserved(inst->ua, &request->task); |
|
} |
|
|
|
void RadioAudioEngine::reconfigureCapture() { |
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: reconfigure capture group=%016llx manual=%d auto=%d", |
|
(unsigned long long)m_groupId.load(), m_talking.load(), autoEnabled()); |
|
m_capture.close(); |
|
m_voice.stopCapture(); |
|
radio_audio_capture_stop(); |
|
m_wantStereo = chat_setting_get_int(gui_bridge_get_inst(), "radio_capture_stereo", 0) != 0; |
|
updateState(); |
|
m_capture.retryNow(); |
|
recoverDevices(); |
|
} |
|
|
|
int RadioAudioEngine::start(quint64 groupId) { |
|
if (m_active && m_groupId == groupId) return 0; |
|
stop(); |
|
auto* inst = gui_bridge_get_inst(); |
|
if (!inst || radio_audio_start(groupId, 0) != 0) { |
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: cannot start group=%016llx", (unsigned long long)groupId); |
|
return -1; |
|
} |
|
m_groupId = groupId; |
|
if (!m_voice.start(VoiceAudioIo::Kind::Radio, groupId)) { radio_audio_stop(); m_groupId = 0; return -1; } |
|
m_wantStereo = chat_setting_get_int(inst, "radio_capture_stereo", 0) != 0; |
|
m_active = true; |
|
m_capture.retryNow(); |
|
recoverDevices(); |
|
m_recoveryTimer.start(); |
|
emit stateChanged(); |
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: listening group=%016llx", (unsigned long long)groupId); |
|
return 0; |
|
} |
|
|
|
void RadioAudioEngine::openCapture() { |
|
auto* context = SoundManager::instance()->context(); |
|
if (!context) return; |
|
for (int channels = m_wantStereo ? 2 : 1; channels >= 1; --channels) { |
|
ma_device_config config = ma_device_config_init(ma_device_type_capture); |
|
config.capture.format = ma_format_s16; |
|
config.capture.channels = channels; |
|
config.sampleRate = RADIO_AUDIO_SAMPLE_RATE; |
|
auto callback = [this](const void* in, void*, unsigned int frames) { captureCallback(in, frames); }; |
|
ma_device_id id; |
|
bool selected = SoundManager::deviceId(m_captureId, &id); |
|
for (int attempt = 0; attempt < (selected ? 2 : 1); ++attempt) { |
|
config.capture.pDeviceID = selected && attempt == 0 ? &id : nullptr; |
|
if (attempt) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: selected input unavailable, trying default ch=%d", channels); |
|
if (!m_capture.init(context, config, "radio-capture", m_groupId.load(), callback)) continue; |
|
m_captureChannels = channels; |
|
|
|
auto* inst = gui_bridge_get_inst(); |
|
if (radio_audio_capture_start(m_groupId.load(), channels, autoEnabled() ? autoMode() : RADIO_AUTO_OFF) != 0) { |
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: core capture start failed ch=%d mode=%d", channels, autoEnabled() ? autoMode() : RADIO_AUTO_OFF); |
|
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); |
|
if (!m_capture.start()) { m_voice.stopCapture(); radio_audio_capture_stop(); continue; } |
|
if (channels == 1 && m_wantStereo) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: stereo input unavailable, using mono"); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
void RadioAudioEngine::recoverDevices() { |
|
if (!m_active) return; |
|
auto* manager = SoundManager::instance(); |
|
if (!m_outputAttached && manager->context()) m_outputAttached = manager->attachVoice(&m_voice, m_playbackId); |
|
if ((m_capture.initialized() && !m_voice.captureRunning()) || m_capture.needsRecovery()) { |
|
m_capture.close(); m_voice.stopCapture(); radio_audio_capture_stop(); m_capture.retryLater(); |
|
} |
|
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()); |
|
auto input = radio_audio_get_input_status(); |
|
bool valid = capture && input.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; |
|
bool blocked = capture && input.blocked; |
|
bool transmitting = m_active && radio_audio_transmitting(); |
|
if (capture == m_captureAvailable && automatic == m_autoAvailable && transmitting == m_transmitting && blocked == m_autoBlocked) return; |
|
m_captureAvailable = capture; m_autoAvailable = automatic; m_transmitting = transmitting; m_autoBlocked = blocked; |
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: state group=%016llx capture=%d auto_requested=%d mode=%d ready=%d blocked=%d TX=%d manual=%d", |
|
(unsigned long long)m_groupId.load(), capture, autoEnabled(), autoMode(), automatic, blocked, transmitting, m_talking.load()); |
|
emit stateChanged(); |
|
} |
|
|
|
void RadioAudioEngine::pauseDevices() { |
|
m_capture.close(); m_voice.stopCapture(); |
|
SoundManager::instance()->detachVoice(&m_voice); m_outputAttached = false; |
|
if (m_active) radio_audio_capture_stop(); |
|
m_voice.resetPlayback(); |
|
updateState(); |
|
} |
|
|
|
void RadioAudioEngine::stop() { |
|
m_recoveryTimer.stop(); |
|
pauseDevices(); m_voice.stop(); |
|
uint64_t group = m_groupId.exchange(0); |
|
bool talking = m_talking.exchange(false); |
|
m_buttonTalking = m_hotkeyTalking = false; |
|
m_active = false; |
|
if (group) radio_audio_stop(); |
|
updateState(); |
|
if (talking) emit talkingChanged(false); |
|
if (group) emit stateChanged(); |
|
if (group) DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: stopped group=%016llx", (unsigned long long)group); |
|
} |
|
|
|
void RadioAudioEngine::talkBegin() { |
|
if (!m_active) return; |
|
m_buttonTalking = true; |
|
updateTalking(); |
|
} |
|
|
|
void RadioAudioEngine::talkEnd() { |
|
m_buttonTalking = false; |
|
updateTalking(); |
|
} |
|
|
|
void RadioAudioEngine::setHotkeyTalking(bool pressed) { |
|
m_hotkeyTalking = pressed && m_active; |
|
updateTalking(); |
|
} |
|
|
|
void RadioAudioEngine::updateTalking() { |
|
bool talking = m_active && (m_buttonTalking || m_hotkeyTalking); |
|
if (m_talking.exchange(talking) == talking || !m_active) return; |
|
m_voice.setPtt(talking); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioAudio: PTT %s group=%016llx button=%d hotkey=%d", |
|
talking ? "started" : "stopped", (unsigned long long)m_groupId.load(), m_buttonTalking, m_hotkeyTalking); |
|
emit talkingChanged(talking); |
|
} |
|
|
|
void RadioAudioEngine::captureCallback(const void* in, unsigned int frames) { |
|
if (in) m_voice.capture(static_cast<const int16_t*>(in), frames, m_capture.callbackTimeUs(), m_capture.route(), m_capture.captureMissing()); |
|
}
|
|
|