diff --git a/tools/chatgui/src/stream_publish_engine.cpp b/tools/chatgui/src/stream_publish_engine.cpp new file mode 100644 index 00000000..405a4fbd --- /dev/null +++ b/tools/chatgui/src/stream_publish_engine.cpp @@ -0,0 +1,433 @@ +#include "stream_publish_engine.h" +#include "sound_manager.h" +#include "audio_event_queue.h" +#include "../transport/stream_bridge.h" +#include "debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "opus_codec.h" +#include "audio_compressor.h" +} +#ifdef HAVE_STREAM_CAPTURE +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace { +int64_t monotonicUs() { return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); } +QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } +} +struct StreamPublishEngine::Impl { + struct Audio { int64_t pts = 0, created = 0; uint64_t sequence = 0; unsigned frames = 0; int16_t pcm[1920]{}; }; + std::mutex lock; + std::condition_variable wake; + std::thread videoThread, audioThread, probeThread; + std::atomic running{false}, muted{false}; + std::atomic audioDropped{0}; + AudioEventQueue audio; + uint64_t id = 0, group = 0, generation = 0, frameGeneration = 0, audioSequence = 0; + int64_t origin = 0, started = 0, framePts = -1, backendOrigin = -1, backendAnchor = -1; + int channels = 2; + bool probeReady = false, probeDelivered = false, hasGood = false, rollingBack = false; + StreamPublishConfig wanted, good; + QList codecs; + QImage preview; + stream_publisher_status stats{}; +#ifdef HAVE_STREAM_CAPTURE + std::unique_ptr session; + std::unique_ptr sink; + std::unique_ptr camera; + std::unique_ptr screen; + std::unique_ptr window; + QVideoFrame frame; +#endif +}; + +StreamPublishEngine::StreamPublishEngine(QObject* parent) : QObject(parent), m(std::make_unique()) { + m_timer.setInterval(100); connect(&m_timer, &QTimer::timeout, this, &StreamPublishEngine::tick); m_timer.start(); + m->probeThread = std::thread([this] { + auto codecs = supported() ? StreamVideoEncoder::available() : QList(); + std::lock_guard guard(m->lock); m->codecs = std::move(codecs); m->probeReady = true; + }); + auto* sound = SoundManager::instance(); + connect(sound, &SoundManager::contextAboutToReset, this, [this] { m_capture.close(); }); + connect(sound, &SoundManager::contextRestored, this, [this] { if (m->running && m->wanted.audio) openMicrophone(); }); +} +StreamPublishEngine::~StreamPublishEngine() { + stop(); m_timer.stop(); + if (m->probeThread.joinable()) m->probeThread.join(); +} +bool StreamPublishEngine::supported() { +#ifdef HAVE_STREAM_CAPTURE + return true; +#else + return false; +#endif +} +bool StreamPublishEngine::active() const { return m->id != 0; } +uint64_t StreamPublishEngine::group() const { return m->group; } +StreamPublishConfig StreamPublishEngine::config() const { std::lock_guard guard(m->lock); return m->wanted; } +QList StreamPublishEngine::codecs() const { std::lock_guard guard(m->lock); return m->codecs; } +void StreamPublishEngine::setMuted(bool muted) { m->muted = muted; } + +QList StreamPublishEngine::sources() { + QList sources; +#ifdef HAVE_STREAM_CAPTURE + for (const auto& camera : QMediaDevices::videoInputs()) sources.push_back({StreamVideoSource::Camera, camera.description(), camera.id(), {}}); + if (QGuiApplication::platformName().contains("wayland")) { + sources.push_back({StreamVideoSource::Screen, text("Screen or window (system selection)"), "portal", {}}); + } else { + for (auto* screen : QGuiApplication::screens()) sources.push_back({StreamVideoSource::Screen, screen->name(), screen->name().toUtf8(), {}}); + for (const auto& window : QWindowCapture::capturableWindows()) + sources.push_back({StreamVideoSource::Window, window.description(), {}, QVariant::fromValue(window)}); + } +#endif + return sources; +} + +/* Source создаётся в GUI; callbacks старого поколения не меняют новый capture. */ +bool StreamPublishEngine::openSource(const StreamPublishConfig& config) { + closeSource(); +#ifdef HAVE_STREAM_CAPTURE + uint64_t generation; + { std::lock_guard guard(m->lock); generation = m->generation; m->backendOrigin = m->backendAnchor = -1; } + m->session = std::make_unique(); m->sink = std::make_unique(); + m->session->setVideoSink(m->sink.get()); + connect(m->sink.get(), &QVideoSink::videoFrameChanged, this, [this, generation](const QVideoFrame& frame) { + if (!m->running || !frame.isValid()) return; + std::lock_guard guard(m->lock); + if (generation != m->generation) return; + int64_t now = monotonicUs(), capture = frame.startTime(); + if (capture >= 0) { + if (m->backendOrigin < 0) { + m->backendOrigin = capture; m->backendAnchor = now; + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop capture timing generation=%llu source_PTS=%lld anchor=%lld calibration=arrival-estimate", + (unsigned long long)generation, (long long)capture, (long long)now); + } + now = m->backendAnchor + capture - m->backendOrigin; + } else if (m->backendAnchor < 0) { + m->backendAnchor = now; + DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop capture has no source PTS; using monotonic callback estimate"); + } + m->frame = frame; m->framePts = std::max(1, now - m->origin); m->frameGeneration = generation; + m->wake.notify_all(); + }); + if (config.source.kind == StreamVideoSource::Camera) { + QCameraDevice selected; + for (const auto& camera : QMediaDevices::videoInputs()) if (camera.id() == config.source.id) selected = camera; + if (selected.isNull()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop camera disappeared"); return false; } + m->camera = std::make_unique(selected); + QCameraFormat best; int score = INT_MAX; + for (const auto& format : selected.videoFormats()) { + if (format.maxFrameRate() + 0.1 < config.video.fps) continue; + int candidate = std::abs(format.resolution().width() - config.video.width) + + std::abs(format.resolution().height() - config.video.height); + if (candidate < score) { score = candidate; best = format; } + } + if (best.isNull()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop camera lacks requested fps=%d", config.video.fps); return false; } + m->camera->setCameraFormat(best); m->session->setCamera(m->camera.get()); + connect(m->camera.get(), &QCamera::errorOccurred, this, [this, generation](QCamera::Error, const QString& error) { sourceFailed(generation, error); }); + m->camera->start(); + } else if (config.source.kind == StreamVideoSource::Screen) { + m->screen = std::make_unique(); + if (config.source.id != "portal") { + QScreen* selected = nullptr; + for (auto* screen : QGuiApplication::screens()) if (screen->name().toUtf8() == config.source.id) selected = screen; + if (!selected) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop screen disappeared"); return false; } + m->screen->setScreen(selected); + } + m->session->setScreenCapture(m->screen.get()); + connect(m->screen.get(), &QScreenCapture::errorOccurred, this, [this, generation](QScreenCapture::Error, const QString& error) { sourceFailed(generation, error); }); + m->screen->start(); + } else { + auto window = config.source.window.value(); + if (!window.isValid()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop window is unavailable"); return false; } + m->window = std::make_unique(); m->window->setWindow(window); m->session->setWindowCapture(m->window.get()); + connect(m->window.get(), &QWindowCapture::errorOccurred, this, [this, generation](QWindowCapture::Error, const QString& error) { sourceFailed(generation, error); }); + m->window->start(); + } + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop capture started generation=%llu kind=%d source=%s target=%dx%d/%dfps", + (unsigned long long)generation, config.source.kind, qPrintable(config.source.name), config.video.width, config.video.height, config.video.fps); + return true; +#else + Q_UNUSED(config); return false; +#endif +} +void StreamPublishEngine::closeSource() { +#ifdef HAVE_STREAM_CAPTURE + if (m->sink) disconnect(m->sink.get(), nullptr, this, nullptr); + if (m->camera) { disconnect(m->camera.get(), nullptr, this, nullptr); m->camera->stop(); } + if (m->screen) { disconnect(m->screen.get(), nullptr, this, nullptr); m->screen->stop(); } + if (m->window) { disconnect(m->window.get(), nullptr, this, nullptr); m->window->stop(); } + m->session.reset(); m->camera.reset(); m->screen.reset(); m->window.reset(); m->sink.reset(); + std::lock_guard guard(m->lock); m->frame = {}; m->framePts = -1; +#endif +} + +bool StreamPublishEngine::start(uint64_t group, const StreamPublishConfig& config) { + if (active()) { emit errorOccurred(text("A stream is already running")); return false; } + if (!supported() || !codecs().contains(config.video.codec)) { emit errorOccurred(text("Video capture or encoder is unavailable")); return false; } + if (config.audio && !m_microphone.acquire()) { + DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop publication blocked: microphone owned by another operation"); + emit errorOccurred(text("The microphone is used by a call, radio or recording")); return false; + } + { std::lock_guard guard(m->lock); + m->wanted = config; m->good = {}; m->hasGood = m->rollingBack = false; m->generation++; + m->origin = m->started = monotonicUs(); m->preview = {}; m->stats = {}; } + m->id = QRandomGenerator::global()->generate64(); if (!m->id) m->id = 1; + m->group = group; m->audio.reset(); m->audioSequence = 0; m->muted = false; + stream_media_format format{(uint16_t)config.video.width, (uint16_t)config.video.height, (uint16_t)config.video.rotation, + (uint8_t)config.video.codec, (uint8_t)(config.audio ? STREAM_MEDIA_OPUS_STEREO : 0), 1}; + if (!gui_stream_publish_open(m->id, group, format)) { + m->id = m->group = 0; m_microphone.release(); emit errorOccurred(text("Could not start the stream")); return false; + } + m->running = true; + try { + m->videoThread = std::thread(&StreamPublishEngine::videoLoop, this); + if (config.audio) m->audioThread = std::thread(&StreamPublishEngine::audioLoop, this); + } catch (const std::system_error& error) { sourceFailed(m->generation, QString::fromUtf8(error.what())); return false; } + if (!openSource(config) || (config.audio && !openMicrophone())) { + sourceFailed(m->generation, text("Could not open the selected capture device")); return false; + } + emit stateChanged(text("Starting stream…")); emit configurationChanged(); return true; +} + +bool StreamPublishEngine::reconfigure(const StreamPublishConfig& config) { + if (!m->running) return false; + StreamPublishConfig previous = this->config(); + if (config.audio != previous.audio || config.microphone != previous.microphone) { + emit errorOccurred(text("Change the microphone before starting the stream")); return false; + } + { std::lock_guard guard(m->lock); m->wanted = config; m->generation++; m->started = monotonicUs(); } + if (!openSource(config)) { sourceFailed(m->generation, text("Could not open the selected video source")); return false; } + m->wake.notify_all(); emit stateChanged(text("Applying video settings…")); return true; +} + +void StreamPublishEngine::sourceFailed(uint64_t generation, const QString& error) { + StreamPublishConfig restore; bool rollback; + { std::lock_guard guard(m->lock); + if (generation != m->generation || !m->running) return; + rollback = m->hasGood && !m->rollingBack; restore = m->good; m->rollingBack = true; } + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop capture failed generation=%llu rollback=%d reason=%s", + (unsigned long long)generation, rollback, qPrintable(error)); + emit errorOccurred(rollback ? text("Video settings were restored: %1").arg(error) : error); + if (rollback) reconfigure(restore); else stop(); +} + +/* Сначала callbacks, затем workers; FIN не обгоняет уже принятые access units. */ +void StreamPublishEngine::stop() { + if (!m->running.exchange(false)) return; + closeSource(); m_capture.close(); m->wake.notify_all(); + if (m->videoThread.joinable()) m->videoThread.join(); + if (m->audioThread.joinable()) m->audioThread.join(); + m_microphone.release(); gui_stream_publish_stop(m->id); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop capture stopped id=%llu; draining publisher", (unsigned long long)m->id); + emit stateChanged(text("Finishing stream…")); +} + +bool StreamPublishEngine::openMicrophone() { + m_capture.close(); + auto config = this->config(); auto* context = SoundManager::instance()->context(); + if (!context) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream audio context unavailable"); return false; } + ma_device_id device; bool selected = SoundManager::deviceId(config.microphone, &device); + for (int channels : {2, 1}) { + ma_device_config capture = ma_device_config_init(ma_device_type_capture); + capture.capture.format = ma_format_s16; capture.capture.channels = channels; capture.sampleRate = 48000; + capture.capture.pDeviceID = selected ? &device : nullptr; + auto callback = [this](const void* in, void*, unsigned frames) { captureAudio(in, frames); }; + if (!m_capture.init(context, capture, "stream-capture", m->id, callback)) continue; + m->channels = channels; + if (!m_capture.start()) continue; + if (channels == 1) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop stream input is mono; stereo transport duplicates the input"); + emit audioChannelsChanged(channels); return true; + } + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream cannot open microphone: %s", qPrintable(config.microphone)); return false; +} + +/* Hardware callback копирует максимум 20ms за элемент, без codec/DSP/allocations. */ +void StreamPublishEngine::captureAudio(const void* input, unsigned frames) { + const auto* pcm = static_cast(input); + if (!m->running || !pcm) return; + int64_t first = m_capture.callbackTimeUs() - m->origin; + for (unsigned offset = 0; offset < frames;) { + Impl::Audio record; record.frames = std::min(960u, frames - offset); + record.pts = std::max(0, first + (int64_t)offset * 1000000 / 48000); record.created = monotonicUs(); + record.sequence = ++m->audioSequence; + for (unsigned i = 0; i < record.frames; i++) { + record.pcm[2 * i] = pcm[(offset + i) * m->channels]; + record.pcm[2 * i + 1] = pcm[(offset + i) * m->channels + (m->channels == 2 ? 1 : 0)]; + } + if (!m->audio.push(record)) m->audioDropped++; + offset += record.frames; + } + m->wake.notify_all(); +} + +void StreamPublishEngine::audioLoop() { + auto* encoder = opus_codec_encoder_create(48000, 2); auto* compressor = audio_compressor_create(); + int result = encoder && compressor ? opus_codec_encoder_application_set(encoder, OPUS_CODEC_APP_AUDIO) : -1; + if (result >= 0) result = opus_codec_encoder_bitrate_set(encoder, 128000); + if (result < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream audio encoder initialization failed"); + QMetaObject::invokeMethod(this, [this] { sourceFailed(m->generation, text("Audio encoder initialization failed")); }); + } else { + int16_t pcm[1920]{}; unsigned used = 0; uint64_t sequence = 0; + int64_t pts = 0, expected = -1, lastSent = -1; int settings[3] = {-1, -1, -1}; + while (m->running) { + Impl::Audio record; + if (!m->audio.pop(record)) { std::unique_lock lock(m->lock); m->wake.wait_for(lock, std::chrono::milliseconds(5)); continue; } + if (monotonicUs() - record.created > 250000) { m->audioDropped++; used = 0; continue; } + if ((sequence && record.sequence != sequence + 1) || (expected >= 0 && std::abs(record.pts - expected) > 5000)) { + DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop stream PCM discontinuity sequence=%llu->%llu PTS=%lld expected=%lld", + (unsigned long long)sequence, (unsigned long long)record.sequence, (long long)record.pts, (long long)expected); + used = 0; audio_compressor_reset(compressor); + } + sequence = record.sequence; expected = record.pts + (int64_t)record.frames * 1000000 / 48000; + unsigned offset = 0; + while (offset < record.frames) { + if (!used) pts = record.pts + (int64_t)offset * 1000000 / 48000; + unsigned take = std::min(960 - used, record.frames - offset); + memcpy(pcm + 2 * used, record.pcm + 2 * offset, take * 2 * sizeof(int16_t)); used += take; offset += take; + if (used < 960) continue; + stream_publisher_status stats; + { std::lock_guard guard(m->lock); stats = m->stats; } + int wanted[] = {stats.compressor_enabled, stats.compressor_max_gain_db ? stats.compressor_max_gain_db : 15, + stats.compressor_rise_rate ? stats.compressor_rise_rate : 2}; + if (!std::equal(wanted, wanted + 3, settings)) { + audio_compressor_config_t config{48000, 2, 20, 200, 0, (float)wanted[1], (float)wanted[2], wanted[2] * 5.0f, 0.25f}; + if (audio_compressor_configure(compressor, &config) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream compressor configuration failed"); break; + } + audio_compressor_set_enabled(compressor, wanted[0]); std::copy(wanted, wanted + 3, settings); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream compressor enabled=%d max_gain=%d rise=%d stereo=1 delay=0", + wanted[0], wanted[1], wanted[2]); + } + if (m->muted) memset(pcm, 0, sizeof(pcm)); + uint8_t packet[STREAM_MEDIA_MAX_OPUS]; + result = audio_compressor_process_frame(compressor, pcm, pcm, 1920); + if (!result) result = opus_codec_encode(encoder, pcm, 960, packet, sizeof(packet)); + if (result <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream audio encoding failed result=%d", result); break; } + if (pts > lastSent) { + gui_stream_publish_feed(m->id, false, pts, false, QByteArray(reinterpret_cast(packet), result)); lastSent = pts; + } else DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop stream audio stale PTS=%lld last=%lld", (long long)pts, (long long)lastSent); + used = 0; + } + if (result < 0) { + QMetaObject::invokeMethod(this, [this] { sourceFailed(m->generation, text("Audio encoding failed")); }); break; + } + } + } + opus_codec_encoder_destroy(encoder); audio_compressor_destroy(compressor); +} + +void StreamPublishEngine::videoLoop() { +#ifdef HAVE_STREAM_CAPTURE + std::unique_ptr encoder; + QImage lastImage; uint64_t current = 0; uint32_t revision = 0; + int64_t lastPts = -1, lastKey = -1000000, next = 0, report = 0; unsigned count = 0, skipped = 0; + while (m->running) { + StreamPublishConfig config; uint64_t generation; QVideoFrame frame; int64_t capture; + { std::unique_lock lock(m->lock); config = m->wanted; generation = m->generation; + int64_t now = monotonicUs(); + if (next > now) { m->wake.wait_for(lock, std::chrono::microseconds(next - now)); continue; } + if (m->frameGeneration == generation) { frame = m->frame; capture = m->framePts; m->frame = {}; } + else capture = -1; } + if (generation != current) { + auto candidate = std::make_unique(); + if (!candidate->open(config.video)) { + QString error = candidate->error(); + QMetaObject::invokeMethod(this, [this, generation, error] { sourceFailed(generation, error); }); + std::unique_lock lock(m->lock); + m->wake.wait_for(lock, std::chrono::milliseconds(100), [this, generation] { return !m->running || m->generation != generation; }); + continue; + } + encoder = std::move(candidate); current = generation; revision++; lastImage = {}; lastKey = -1000000; + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop video prepared generation=%llu revision=%u encoder=%s", + (unsigned long long)generation, revision, qPrintable(encoder->name())); + } + bool screen = config.source.kind != StreamVideoSource::Camera; + if (frame.isValid()) { + if (capture <= lastPts || monotonicUs() - m->origin - capture > 250000) { skipped++; continue; } + lastImage = frame.toImage(); + if (lastImage.isNull()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop captured frame conversion failed"); continue; } + } else if (!screen || lastImage.isNull()) { + std::unique_lock lock(m->lock); m->wake.wait_for(lock, std::chrono::milliseconds(5)); continue; + } + int64_t now = monotonicUs(), pts = screen ? now - m->origin : capture; + next = now + 1000000 / config.video.fps; + stream_publisher_status status; bool needsKey = gui_stream_publish_status(m->id, status) && status.need_key; + bool key = pts - lastKey >= 1000000 || needsKey; + QList output; + if (!encoder->encode(lastImage, std::max(lastPts + 1, pts), key, output)) { + QString error = encoder->error(); QMetaObject::invokeMethod(this, [this, generation, error] { sourceFailed(generation, error); }); continue; + } + stream_media_format format{(uint16_t)config.video.width, (uint16_t)config.video.height, (uint16_t)config.video.rotation, + (uint8_t)config.video.codec, (uint8_t)(config.audio ? STREAM_MEDIA_OPUS_STEREO : 0), revision}; + for (const auto& encoded : output) { + if (gui_stream_publish_feed(m->id, true, encoded.pts, encoded.key, encoded.data, &format) == 1) { + lastPts = encoded.pts; if (encoded.key) lastKey = encoded.pts; count++; + if (encoded.key) { + bool first; + { std::lock_guard guard(m->lock); first = !m->hasGood || m->good.video != config.video || m->rollingBack; + if (m->generation == generation) { m->good = config; m->hasGood = true; m->rollingBack = false; } } + if (first) QMetaObject::invokeMethod(this, [this, generation] { + if (generation != m->generation) return; + emit configurationChanged(); emit stateChanged(text("Live")); + }); + } + } else skipped++; + } + { std::lock_guard guard(m->lock); if (m->generation == generation) m->preview = lastImage; } + if (now - report >= 1000000) { + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "desktop capture generation=%llu encoded=%u skipped=%u frame_age=%lldus encoder=%s", + (unsigned long long)generation, count, skipped, (long long)(screen ? 0 : now - m->origin - capture), qPrintable(encoder->name())); + report = now; count = skipped = 0; + } + } +#endif +} + +void StreamPublishEngine::tick() { + bool ready; + { std::lock_guard guard(m->lock); ready = m->probeReady && !m->probeDelivered; m->probeDelivered |= ready; } + if (ready) { if (m->probeThread.joinable()) m->probeThread.join(); emit codecsChanged(); } + if (!active()) return; + stream_publisher_status status; + if (!gui_stream_publish_status(m->id, status)) { + stop(); m->id = m->group = 0; emit stateChanged(text("Chat service is stopped")); return; + } + { std::lock_guard guard(m->lock); m->stats = status; } + emit telemetryChanged(status); + QImage preview; + { std::lock_guard guard(m->lock); preview = m->preview; } + if (!preview.isNull()) emit previewChanged(preview); + if (status.state == STREAM_PUBLISHER_ENDED || status.state == STREAM_PUBLISHER_FAILED) { + stop(); m->id = m->group = 0; + if (status.state == STREAM_PUBLISHER_FAILED) emit errorOccurred(text("Stream publication failed")); + emit stateChanged(text("Stopped")); emit configurationChanged(); return; + } + if (m->running) { + bool hasGood; int64_t started; + { std::lock_guard guard(m->lock); hasGood = m->hasGood; started = m->started; } + if (!hasGood && monotonicUs() - started > 8000000) sourceFailed(m->generation, text("Timed out waiting for the first video frame")); + if (m->wanted.audio && m_capture.needsRecovery()) { m_capture.close(); m_capture.retryLater(); } + if (m->wanted.audio && m_capture.retryReady()) openMicrophone(); + } + unsigned dropped = m->audioDropped.exchange(0); + if (dropped) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop audio capture discarded blocks=%u", dropped); +} diff --git a/tools/chatgui/src/stream_publish_engine.h b/tools/chatgui/src/stream_publish_engine.h new file mode 100644 index 00000000..7cd375e4 --- /dev/null +++ b/tools/chatgui/src/stream_publish_engine.h @@ -0,0 +1,66 @@ +/* Desktop publisher: GUI управляет источниками и AudioDevice, workers кодируют видео/Opus. + * Окно не владеет трансляцией. Stop закрывает callbacks, joins workers и отправляет FIN. + * Captured video/preview хранят только последний кадр; публикация использует общий C actor. */ +#pragma once +#include +#include +#include +#include +#include +#include +#include "stream_video_encoder.h" +#include "audio_device.h" +#include "audio_capture_lease.h" +#include "../../../src/stream/stream_publisher.h" + +struct StreamVideoSource { + enum Kind { Camera, Screen, Window }; + Kind kind = Camera; + QString name; + QByteArray id; + QVariant window; +}; +struct StreamPublishConfig { + StreamVideoSource source; + StreamVideoConfig video; + QString microphone; + bool audio = true; +}; +class StreamPublishEngine : public QObject { + Q_OBJECT +public: + explicit StreamPublishEngine(QObject* parent = nullptr); + ~StreamPublishEngine() override; + bool start(uint64_t group, const StreamPublishConfig& config); + bool reconfigure(const StreamPublishConfig& config); + void stop(); + bool active() const; + uint64_t group() const; + StreamPublishConfig config() const; + QList codecs() const; + static QList sources(); + static bool supported(); + void setMuted(bool muted); +signals: + void codecsChanged(); + void stateChanged(const QString& state); + void configurationChanged(); + void previewChanged(const QImage& frame); + void telemetryChanged(const stream_publisher_status& status); + void errorOccurred(const QString& error); + void audioChannelsChanged(int channels); +private: + struct Impl; + std::unique_ptr m; + AudioDevice m_capture; + AudioCaptureLease m_microphone; + QTimer m_timer; + bool openSource(const StreamPublishConfig& config); + void closeSource(); + bool openMicrophone(); + void sourceFailed(uint64_t generation, const QString& error); + void tick(); + void videoLoop(); + void audioLoop(); + void captureAudio(const void* pcm, unsigned frames); +};