From cb49b9d4a484ba99d665d66b9155256536a27bf3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:24:46 +0100 Subject: [PATCH] Complete desktop radio UI and audio engine contracts --- tools/chatgui/src/radio_audio_engine.h | 112 +++++++++++++------------ tools/chatgui/src/radiopanel.h | 36 ++++---- tools/chatgui/src/radiosettingspage.h | 61 +++++++++----- 3 files changed, 118 insertions(+), 91 deletions(-) diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h index b5e1b705..0e5e6382 100644 --- a/tools/chatgui/src/radio_audio_engine.h +++ b/tools/chatgui/src/radio_audio_engine.h @@ -1,3 +1,9 @@ +/* Desktop-аудио одной активной рации: AudioCapture поставляет PCM, VoiceAudioIo выполняет AEC/кодирование и RX, + * SoundManager смешивает голос с остальными звуками приложения и возвращает итоговый stereo reference. + * Управление/обычные свойства/сигналы — GUI-поток; captureCallback — аппаратный поток. isTalking/isActive/groupId атомарны. + * После DB_READY загрузить настройки; start вызывается после включения сетевой подписки рации отдельной командой ядру. + * До уничтожения ядра вызвать stop: снять capture, join capture worker, отсоединить output, join RX, освободить core DSP. + * Уже запущенное закрытие backend может завершиться позже; singleton живёт до конца процесса. */ #pragma once #include @@ -13,24 +19,20 @@ struct ma_device; -/* Desktop-контур рации: GUI владеет подпиской AudioCapture и жизненным циклом. - * Hardware callback копирует PCM в ограниченную очередь VoiceAudioIo. - * Capture worker выполняет AEC и упорядочивает PCM/PTT/mute; независимый RX worker - * получает готовый голос из сервисного слоя. SoundManager смешивает его со всеми - * звуками приложения на том же выходе и возвращает итоговый stereo reference. - * Stop: close capture → join capture worker → detach output → join RX → release core. */ class RadioAudioEngine : public QObject { Q_OBJECT public: - static RadioAudioEngine* instance(); + static RadioAudioEngine* instance(); // Заимствованный singleton; первый вызов — в GUI при живом SoundManager. - /* Включить прослушивание канала (после toggle рации). 0 = успех. */ + /* Запускает DSP/RX и recovery для groupId: 0 — сессия запущена, -1 — core/worker/закрывающийся capture мешают запуску. + * Аппаратная готовность не гарантируется. Тот же активный ID — без изменений; другой сначала останавливает прежний. */ int start(quint64 groupId); - /* Выключить прослушивание канала (toggle рации выключен). */ + /* Идемпотентно останавливает аудиосессию и снимает обоих ручных владельцев PTT; настройки сохраняются. */ void stop(); - /* PTT: зажата / отпущена (только когда радио канала включено). */ + /* Кнопочный владелец PTT, без счётчика вложенности: повтор безопасен. Begin требует активную рацию без captureError. + * End снимает только кнопку; хоткей и автоматическая передача независимы. */ void talkBegin(); void talkEnd(); void setHotkeyTalking(bool pressed); // независимый владелец PTT — глобальная клавиатура @@ -38,75 +40,79 @@ public: /* GUI-поток: начать новое чтение настроек из ядра, в том числе после DB_READY. * configPath — INI для последующего applySettings; loadSettings сам файл не читает. * Снимает прежнее ожидание, даже если ядро удалило задачу без callback; поздний результат прежнего поколения игнорируется. - * Физически уже поставленные задачи остаются в очереди ядра; новый снимок читается после них. */ + * Уже стоящие задачи живого ядра не отменяются; новое чтение следует за ними. */ void loadSettings(const QString& configPath); - /* Применение в uasync; true — принято в очередь либо значения уже совпадают при выключенной рации. + /* values — подмножество девяти поддержанных radio_* настроек; проверяет имена/диапазоны и level_off < level_on. + * Изменения сначала атомарно сохраняются в INI (если configPath непустой), затем применяются по одному в uasync; + * общей транзакции INI/core нет. true — принято в очередь либо значения совпадают при выключенной рации. * После подтверждения GUI перезапускает только capture; готовность/ошибка приходят через stateChanged. */ bool applySettings(const QMap& values); - int setting(const QString& name) const; - bool settingsReady() const { return m_settingsReady; } - bool settingsBusy() const { return m_settingsBusy; } - bool autoEnabled() const { return m_settings[0] != 0; } - int autoMode() const { return m_settings[6]; } + int setting(const QString& name) const; // Последний снимок; неизвестное имя логируется и возвращает 0. + bool settingsReady() const { return m_settingsReady; } // Получен снимок текущего поколения, не гарантия отсутствия settingsError. + bool settingsBusy() const { return m_settingsBusy; } // Ожидание GUI completion текущего поколения. + bool autoEnabled() const { return m_settings[0] != 0; } // Запрошенное включение автоматики, независимо от готовности capture. + int autoMode() const { return m_settings[6]; } // Запрошенный режим: 1=VAD, 2=уровень сигнала; выключение — через autoEnabled. + /* Последние измерения: уровень dBFS, вероятность речи 0..1 и усиление VAD в dB; проверять соответствующий Valid. */ 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; } - bool isTransmitting() const { return m_transmitting; } + bool autoBlocked() const { return m_autoBlocked; } // Детектор временно заблокирован занятым каналом при готовом capture. + bool autoAvailable() const { return m_autoAvailable; } // Core auto mode активен при готовом capture, включая режим уровня. + bool captureAvailable() const { return m_captureAvailable; } // Работают и AudioCapture, и capture worker. + bool isTransmitting() const { return m_transmitting; } // Последний фактический TX ядра, включая автоматику. QString settingsError() const { return m_settingsError; } QString captureError() const { return m_captureError; } - bool isTalking() const { return m_talking.load(); } - bool isActive() const { return m_active.load(); } - quint64 groupId() const { return m_groupId.load(); } + bool isTalking() const { return m_talking.load(); } // Суммарный ручной запрос кнопки/хоткея, не фактический TX. + bool isActive() const { return m_active.load(); } // Аудиосессия, независимо от наличия устройств. + quint64 groupId() const { return m_groupId.load(); } // ID активной группы, 0 после stop. - /* Hardware capture callback: без DSP и растущих буферов. */ + /* Заимствованный Frame аппаратного callback: синхронно копирует PCM/время/route/missing в ограниченную очередь, без DSP. */ void captureCallback(const AudioCapture::Frame& frame); signals: void talkingChanged(bool talking); // GUI-поток, суммарное состояние кнопки и хоткея - void inputLevelChanged(); // снимок измерителя, не чаще 10Гц + void inputLevelChanged(); // Изменился снимок измерителя: обычно раз в 100мс, также при переходах состояния. void stateChanged(); // фактический TX, доступность capture/VAD и настройки; GUI-поток private: RadioAudioEngine(); - void updateTalking(); + void updateTalking(); // Объединяет два ручных флага, посылает упорядоченную PTT-команду и talkingChanged при изменении. bool m_buttonTalking = false, m_hotkeyTalking = false; // GUI-поток - void pauseDevices(); - void recoverDevices(); - void openCapture(); - void updateState(); - void reconfigureCapture(); - void postSettings(const std::array& values, bool apply); + void pauseDevices(); // Перед reset контекста SoundManager закрывает capture, отсоединяет output, сбрасывает playback. + void recoverDevices(); // При активной сессии восстанавливает устройства/AEC; отсутствие PCM 1с блокирует capture до смены входа/start. + void openCapture(); // Подписка Role::Radio; ready/stopped запускают/останавливают core capture и его worker. + void updateState(); // Снимает статус ядра и готовность устройств, уведомляет GUI при изменениях. + void reconfigureCapture(); // Переоткрывает только capture, сохраняя RX/output и ручной запрос PTT. + void postSettings(const std::array& values, bool apply); // Публикует read/apply в uasync с возвратом снимка в GUI. + /* auto_enabled, vad_threshold (%), hangover (мс), compressor_enabled, TX/VAD gain (dB), auto_mode, on/off (dBFS). */ std::array m_settings{0, 50, 200, 1, 15, 15, 1, -35, -45}; - QString m_configPath; - QString m_settingsError; + QString m_configPath; // INI для последующего сохранения изменённых полей в [chatserver]. + QString m_settingsError; // Ошибка загрузки/валидации/сохранения/применения настроек. uint64_t m_settingsGeneration = 0; // Только GUI; каждый loadSettings инвалидирует старые queued completions. - bool m_settingsReady = false, m_settingsBusy = false; - bool m_transmitting = false, m_autoAvailable = false, m_captureAvailable = false; + bool m_settingsReady = false, m_settingsBusy = false; // Готовность снимка и ожидание операции текущего поколения. + bool m_transmitting = false, m_autoAvailable = false, m_captureAvailable = false; // Последний GUI-снимок готовности/TX. - 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; + float m_inputLevel = -120; // RMS детектора в dBFS; значение использовать только при inputLevelValid. + bool m_inputLevelValid = false, m_autoBlocked = false; // Валидность уровня и занятость канала для автоматики. + float m_vadProbability = 0, m_vadGain = 0; // Вероятность речи 0..1 и усиление VAD-компрессора в dB. + bool m_vadValid = false, m_vadCompressorEnabled = false; // Валидность VAD и фактическое включение его компрессора. - std::atomic m_active{false}; - std::atomic m_talking{false}; - std::atomic m_groupId{0}; + std::atomic m_active{false}; // Аудиосессия запущена. + std::atomic m_talking{false}; // Ручной PTT: кнопка либо хоткей. + std::atomic m_groupId{0}; // ID сессии, не признак готовности сети/аппаратуры. - AudioCapture m_capture; - AudioCaptureLease m_microphone{AudioCaptureLease::Mode::Radio}; - VoiceAudioIo m_voice; - bool m_outputAttached = false; - QTimer m_recoveryTimer; - bool m_wantStereo = false; - QString m_captureId, m_playbackId; - QString m_captureError; + AudioCapture m_capture; // GUI-подписка на общую с трансляцией capture-сессию. + AudioCaptureLease m_microphone{AudioCaptureLease::Mode::Radio}; // Radio не участвует в блокировке publisher/call/recorder. + VoiceAudioIo m_voice; // Capture/RX workers и ограниченные очереди. + bool m_outputAttached = false; // Источник зарегистрирован в SoundManager, не гарантия готовности выхода. + QTimer m_recoveryTimer; // Опрос/восстановление и обновление снимков каждые 100мс при активной сессии. + bool m_wantStereo = false; // radio_capture_stereo из ядра при start/переконфигурации. + QString m_captureId, m_playbackId; // Запрошенные общие device ID из SoundManager. + QString m_captureError; // Fatal отсутствие PCM; смена входа либо новая сессия снимают блокировку. - int m_captureChannels = 1; + int m_captureChannels = 1; // Число каналов, подтверждённое ready текущей подписки. }; diff --git a/tools/chatgui/src/radiopanel.h b/tools/chatgui/src/radiopanel.h index d310fb07..d5bf032d 100644 --- a/tools/chatgui/src/radiopanel.h +++ b/tools/chatgui/src/radiopanel.h @@ -1,3 +1,7 @@ +/* GUI-панель активной рации, сама аудиосессию не запускает: отображает singleton RadioAudioEngine. + * PTT удерживается кнопкой либо фиксируется двойным левым кликом; следующая кнопка/остановка рации/потеря capture снимают фиксацию. + * Короткий клик автоматики включает/выключает выбранный VAD/уровень; удержание 500мс либо правый клик открывают редактор. + * Устройства общие через SoundManager, их ID сохраняются через core ui_state; режим/пороги — через настройки движка. */ #pragma once #include @@ -9,27 +13,29 @@ class QLabel; class QMenu; class RadioActivationSettings; -/* Верхняя панель активной рации: общие устройства SoundManager, VAD и удерживаемая PTT. - * Состояние передачи берётся из ядра; включение VAD хранится в конфигурации приложения. */ class RadioPanel : public QWidget { Q_OBJECT public: + /* Создаёт дочерние контролы и подписки на stateChanged/devicesChanged, сразу обновляет снимок UI. */ explicit RadioPanel(QWidget* parent = nullptr); + /* Снимает принадлежащую панели фиксацию кнопочного PTT; аудиодвижок не останавливает. */ ~RadioPanel() override; private: + /* Различает удержание автоматики и фиксацию PTT; скрытие/деактивация кнопки автоматики отменяют только её таймер. */ bool eventFilter(QObject* watched, QEvent* event) override; + /* Меняет только кнопочного владельца движка; reason заимствуется на вызов для лога. */ 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); - QToolButton *m_input, *m_output; - 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; + void updateState(); // Готовность контролов, ошибки, режим и фактический TX; VAD-кнопка рисует текущую вероятность речи. + void showActivationSettings(); // Лениво создаёт popup, каждый раз загружает настройки; закрытый черновик не сохраняется. + void updateDevices(); // Обновляет подписи/tooltip из выбранных ID SoundManager. + void populateDevices(QToolButton* button, bool capture); // Перечитывает список при открытии меню; пустой ID — default. + void selectDevice(const QString& id, bool capture); // Планирует сохранение ui_state и сразу меняет общие устройства. + QToolButton *m_input, *m_output; // Дочерние кнопки с меню устройств ввода/вывода. + QPushButton *m_auto, *m_ptt; // Автоматика с измерителем VAD и кнопочный PTT. + QLabel* m_status; // Ошибка/состояние рации с tooltip подробностей настроек. + bool m_pttLatched = false; // Фиксация кнопочного владельца до снятия/уничтожения панели. + QTimer m_autoHold; // Однократный порог долгого нажатия автоматики, 500мс. + bool m_autoLongPressed = false; // Подавляет обычное переключение при отпускании после долгого нажатия. + QMenu* m_activationMenu = nullptr; // Дочерний popup, создаётся при первом открытии. + RadioActivationSettings* m_activationSettings = nullptr; // Компактный редактор, принадлежит содержимому popup. }; diff --git a/tools/chatgui/src/radiosettingspage.h b/tools/chatgui/src/radiosettingspage.h index 654f9988..f6e6e2a8 100644 --- a/tools/chatgui/src/radiosettingspage.h +++ b/tools/chatgui/src/radiosettingspage.h @@ -1,3 +1,7 @@ +/* GUI-настройки рации: общий редактор автоматики и вкладка глобальных PTT-сочетаний. + * Черновик режима/порогов хранится в контролах; измерения/ошибки обновляются отдельно и не заменяют его. + * Физические hotkeys применяются в памяти, вызывающий сохраняет hotkeys().join('|') в ui_state radio_ptt_hotkeys. + * Wayland-портал сохраняет назначения сам; видимая вкладка временно запрещает portal-PTT. */ #pragma once #include "ptt_key_chord.h" @@ -15,36 +19,47 @@ class QCheckBox; class QButtonGroup; class RadioLevelMeter; -/* Общий редактор вкладки и мини-блока; метрики не перезаписывают редактируемые настройки. */ +/* Один редактор для полной вкладки и компактного popup RadioPanel. */ class RadioActivationSettings : public QWidget { Q_OBJECT public: + /* compact скрывает TX-компрессор/его усиление, остальные поля общие; дочерние контролы принадлежат виджету. */ explicit RadioActivationSettings(bool compact, QWidget* parent = nullptr); + /* Заменяет черновик снимком движка, когда settingsReady; иначе оставляет поля до первого готового снимка. */ void loadSettings(); + /* Передаёт карту полей applySettings движка; true — принятие/совпадение, завершение операции асинхронно. */ bool apply(); + /* Есть изменения, снимок готов, нет pending и stop threshold ниже start; не проверяет доступ к INI/ядру. */ bool canApply() const; signals: + /* Черновик/ошибка/доступность изменились; получатель заново вычисляет canApply. */ void changed(); private: - QMap 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; + QMap values() const; // Черновик radio_* полей; compact не включает TX-компрессор и его усиление. + void updateControls(); // Проверяет пороги/ошибку и обновляет отметки шкал, не загружает заново настройки. + void updateMeters(); // Только текущие измерения/подписи из движка, без изменения черновика. + bool m_compact, m_loaded = false; // Режим отображения и первичная загрузка снимка. + QButtonGroup* m_mode; // Дочерняя группа: 1=VAD, 2=уровень сигнала. + QSpinBox *m_threshold, *m_hangover, *m_txGain = nullptr, *m_vadGain, *m_levelOn, *m_levelOff; // %, мс, TX/VAD dB, on/off dBFS. + QCheckBox* m_compressor = nullptr; // Общий флаг компрессора, отсутствует в compact. + RadioLevelMeter *m_levelMeter, *m_vadMeter; // Дочерние шкалы уровня/вероятности с отметками черновых порогов. + QLabel *m_levelText, *m_vadText, *m_gainText, *m_error; // Дочерние подписи измерений и ошибки. }; -// Запись глобальных сочетаний PTT, включая отдельные левые/правые модификаторы. +/* Вкладка редактирует три физических сочетания Windows/X11 либо открывает системное назначение Wayland. */ class RadioSettingsPage : public QWidget { Q_OBJECT public: + /* db заимствуется, может быть nullptr; нужна только при loadFromDb. Менеджер hotkeys должен быть инициализирован отдельно. */ explicit RadioSettingsPage(DbManager* db, QWidget* parent = nullptr); + /* Отменяет запись клавиш; видимая portal-вкладка снимает свой запрет portal-PTT. */ ~RadioSettingsPage() override; + /* Перезагружает редактор и три позиции из БД, отменяя физическую запись; portal только перечитывает подписи. */ void loadFromDb(); + /* Три кодированных физических позиции, включая пустые; в режиме portal — пустой список. */ QStringList hotkeys() const; + /* Сначала apply редактора; при успехе вне portal отменяет запись и применяет hotkeys в памяти менеджера. + * БД hotkeys не сохраняет, завершения uasync-настроек не ждёт; false оставляет сочетания неприменёнными. */ bool apply(); protected: @@ -53,16 +68,16 @@ protected: void hideEvent(QHideEvent* event) override; private: - void updatePortalLabels(); - RadioActivationSettings* m_activation; - QLabel* m_portalStatus = nullptr; - void startCapture(int index); - void finishCapture(bool save); - void updateLabel(int index); - DbManager* m_db; - QVector m_chords; - QVector m_buttons; - QVector m_labels; - PttChordCapture m_capture; - int m_captureIndex = -1; + void updatePortalLabels(); // Три описания/статус и доступность системной кнопки, только portal-ветка. + RadioActivationSettings* m_activation; // Дочерний полный редактор автоматики. + QLabel* m_portalStatus = nullptr; // Дочерняя подпись; ненулевая только в portal-ветке. + void startCapture(int index); // Отменяет прежнюю запись и начинает физическую запись позиции 0..2. + void finishCapture(bool save); // Сохраняет candidate только в черновик либо отменяет; снимает eventFilter/режим записи. + void updateLabel(int index); // Показывает физическое сочетание позиции 0..2 через менеджер. + DbManager* m_db; // Заимствованная БД, записью hotkeys владеет SettingsDialog. + QVector m_chords; // Три физических черновика либо пусто для portal. + QVector m_buttons; // Три кнопки записи либо одна системная кнопка, дочерние. + QVector m_labels; // Три дочерние подписи сочетаний. + PttChordCapture m_capture; // Самый большой одновременный снимок текущей физической записи. + int m_captureIndex = -1; // 0..2 во время записи, -1 — запись не активна. };