diff --git a/tools/chatgui/src/audio_capture.cpp b/tools/chatgui/src/audio_capture.cpp new file mode 100644 index 00000000..4a3e29fa --- /dev/null +++ b/tools/chatgui/src/audio_capture.cpp @@ -0,0 +1,144 @@ +#include "audio_capture.h" +#include "audio_device.h" +#include "sound_manager.h" +#include "debug_config.h" +#include +#include +#include +#include + +struct AudioCapture::Session { + struct Slot { + AudioCapture* client = nullptr; // Только GUI. + std::atomic active{nullptr}; + std::atomic readers{0}; + }; + AudioDevice device; + QString key; + std::array slots; + bool streamProfile = false; + int requestedChannels = 1, channels = 1; + uint64_t tag = 0; + QElapsedTimer clock; + qint64 prepareAt = 0; + + explicit Session(QString input) : key(std::move(input)) { clock.start(); } + ~Session() { stop(); } + + void detach(Slot& slot) { + slot.active.store(nullptr); + // seq_cst: callback либо увидит nullptr, либо завершится до освобождения клиента. + while (slot.readers.load()) std::this_thread::yield(); + if (slot.client->m_prepared) { + slot.client->m_prepared = false; + slot.client->m_stopped(); + } + } + void stop() { + device.close(); // join до stopped: workers больше не получат PCM старого формата. + for (auto& slot : slots) if (slot.client) detach(slot); + } + void prepare() { + if (clock.elapsed() < prepareAt) return; + prepareAt = clock.elapsed() + 250; + for (auto& slot : slots) { + auto* client = slot.client; + if (!client || client->m_prepared) continue; + if (!client->m_ready(channels, device.device()->capture.internalChannels)) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "capture: consumer start failed role=%s channels=%d", + client->m_role == Role::Stream ? "stream" : "radio", channels); + continue; + } + client->m_prepared = true; + slot.active.store(client); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "capture: attached role=%s profile=%s channels=%d rate=48000", + client->m_role == Role::Stream ? "stream" : "radio", streamProfile ? "stream" : "radio", channels); + } + } + void recover() { + if (device.needsRecovery()) { stop(); device.retryLater(); } + if (device.initialized()) { prepare(); return; } + auto* context = SoundManager::instance()->context(); + if (!context || !device.retryReady()) return; + ma_device_id id; + bool selected = SoundManager::deviceId(key, &id); + if (!key.isEmpty() && !selected) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "capture: selected input unavailable; retaining selection"); + device.retryLater(); return; + } + for (int count = requestedChannels; count >= 1; --count) { + auto config = ma_device_config_init(ma_device_type_capture); + config.capture.format = ma_format_s16; config.capture.channels = count; config.sampleRate = 48000; + config.capture.pDeviceID = selected ? &id : nullptr; + if (!streamProfile) AudioDevice::requestLowLatency(context, config); + auto callback = [this](const void* in, void*, unsigned frames) { + Frame frame{static_cast(in), frames, channels, device.callbackTimeUs(), + device.route(), device.timingMeasured(), device.captureMissing()}; + // Трансляция получает исходный PCM первой; оба callback только копируют данные в свои очереди. + for (auto& slot : slots) { + slot.readers.fetch_add(1); + if (auto* client = slot.active.load()) client->m_callback(frame); + slot.readers.fetch_sub(1); + } + }; + if (!device.init(context, config, streamProfile ? "stream-capture" : "radio-capture", tag, callback)) continue; + channels = count; prepareAt = 0; prepare(); + if (device.start()) { + DEBUG_INFO(DEBUG_CATEGORY_CALL, "capture: opened profile=%s channels=%d requested=%d input=%s", + streamProfile ? "stream" : "radio", channels, requestedChannels, qPrintable(key)); + return; + } + stop(); + } + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "capture: cannot open selected input profile=%s", streamProfile ? "stream" : "radio"); + } +}; + +AudioCapture::~AudioCapture() { close(); } + +bool AudioCapture::open(const QString& input, Role role, int channels, uint64_t tag, Ready ready, + std::function stopped, Callback callback) { + close(); + static std::vector> sessions; // Только GUI; владение у подписчиков. + QString key = input; + if (key.isEmpty()) for (const auto& item : SoundManager::enumerateCaptureDevices()) { + if (item.isDefault) { key = item.id; break; } + } + for (auto it = sessions.begin(); it != sessions.end();) { + auto session = it->lock(); + if (!session) { it = sessions.erase(it); continue; } + if (session->key == key) m_session = session; + ++it; + } + if (!m_session) { m_session = std::make_shared(key); sessions.push_back(m_session); } + auto& session = *m_session; + auto& slot = session.slots[role == Role::Stream ? 0 : 1]; + if (slot.client) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "capture: duplicate consumer role=%s", role == Role::Stream ? "stream" : "radio"); + m_session.reset(); return false; + } + bool takeover = role == Role::Stream && !session.streamProfile; + if (takeover) { + DEBUG_INFO(DEBUG_CATEGORY_CALL, "capture: stream takes ownership of capture profile"); + session.stop(); session.streamProfile = true; + } + m_role = role; m_ready = std::move(ready); m_stopped = std::move(stopped); m_callback = std::move(callback); + slot.client = this; + if (role == Role::Stream || !session.streamProfile) { session.requestedChannels = channels; session.tag = tag; } + session.prepareAt = 0; session.device.retryNow(); session.recover(); + return running(); +} + +void AudioCapture::close() { + if (!m_session) return; + auto& slot = m_session->slots[m_role == Role::Stream ? 0 : 1]; + m_session->detach(slot); slot.client = nullptr; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "capture: detached role=%s remaining=%d", m_role == Role::Stream ? "stream" : "radio", + int(m_session->slots[0].client != nullptr) + int(m_session->slots[1].client != nullptr)); + m_session.reset(); m_callback = {}; m_ready = {}; m_stopped = {}; +} + +void AudioCapture::recover() { if (m_session) m_session->recover(); } +bool AudioCapture::running() const { + return m_session && m_prepared && m_session->device.initialized() && ma_device_is_started(m_session->device.device()); +} diff --git a/tools/chatgui/src/audio_capture.h b/tools/chatgui/src/audio_capture.h new file mode 100644 index 00000000..6f04e576 --- /dev/null +++ b/tools/chatgui/src/audio_capture.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include + +/* GUI владеет подпиской, сессия — устройством. Одинаковый выбранный вход открывается один раз. + * Профиль трансляции имеет приоритет и сохраняется для рации после её остановки. + * Callback только копирует PCM; ready/stopped вызываются в GUI вне аппаратного callback. + * close() ждёт завершения своего callback, не останавливая устройство другого потребителя. */ +class AudioCapture { +public: + enum class Role { Stream, Radio }; + struct Frame { + const int16_t* pcm; + unsigned frames; + int channels; + int64_t timeUs; + uint64_t route; + bool measured, missing; + }; + using Ready = std::function; + using Callback = std::function; + AudioCapture() = default; + ~AudioCapture(); + AudioCapture(const AudioCapture&) = delete; + AudioCapture& operator=(const AudioCapture&) = delete; + bool open(const QString& input, Role role, int channels, uint64_t tag, Ready ready, + std::function stopped, Callback callback); + void close(); + void recover(); + bool subscribed() const { return bool(m_session); } + bool running() const; +private: + struct Session; + std::shared_ptr m_session; + Ready m_ready; + std::function m_stopped; + Callback m_callback; + Role m_role = Role::Radio; + bool m_prepared = false; +};