From 176460397732c1f14baf74822ea8bd60b3b7396f Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 21 Oct 2026 01:00:31 +0200 Subject: [PATCH] Isolate live video decoding from audio preparation in ChatGUI --- tools/chatgui/src/streamplayer_window.cpp | 600 +++++++++++----------- tools/chatgui/src/streamplayer_window.h | 5 +- 2 files changed, 314 insertions(+), 291 deletions(-) diff --git a/tools/chatgui/src/streamplayer_window.cpp b/tools/chatgui/src/streamplayer_window.cpp index e87cd799..4eb5906d 100644 --- a/tools/chatgui/src/streamplayer_window.cpp +++ b/tools/chatgui/src/streamplayer_window.cpp @@ -47,7 +47,6 @@ struct StreamPlayerWindow::Playback { unsigned requested = 0, received = 0; int64_t reserve = 0, feedAge = -1, workerAge = -1, audioPts = -1; size_t videoQueue = 0; - bool held = false; } audioGap; uint64_t id = ++nextId, group = 0, source = 0, epoch = 0; int64_t createdAt = nowUs(), firstInputAt = 0; @@ -56,7 +55,10 @@ struct StreamPlayerWindow::Playback { std::atomic volume{1.0f}; float outputVolume = 0.0f; uint64_t audioGeneration = 0; - std::thread worker; + std::thread worker, videoWorker; + std::deque videoInput; // Audio worker -> video worker, под lock; максимум два входа. + uint64_t videoGeneration = 1; + bool videoBusy = false, videoInputEnded = false; std::mutex lock; std::mutex wakeLock; std::condition_variable wake; @@ -71,14 +73,14 @@ struct StreamPlayerWindow::Playback { int64_t decoderLead = 0, framePeriod = 33333, lastInputPts = -1, lastDecodedPts = -1; uint64_t audioFrameStart = 0, shown = 0, underruns = 0, endFrames = UINT64_MAX; bool started = false, presenting = false, videoReady = false, ended = false, videoEnded = false; - bool activeAudio = false, audioEnded = false, holdAudio = false, resync = false; + bool activeAudio = false, audioEnded = false, resync = false; int64_t audioFedAt = 0, audioFedPts = -1, workerAt = 0, guiAt = 0, guiGapMax = 0; ~Playback() { stream_playback_audio_destroy(sound); } // Отдельный lock: уведомление из ядра не ждёт codec/PCM lock и не вызывает bridge. void notify(bool data = false) { { std::lock_guard guard(wakeLock); wakeVersion++; if (data) dataSignals++; } - wake.notify_one(); + wake.notify_all(); } uint64_t played(int64_t now) const { @@ -92,7 +94,6 @@ struct StreamPlayerWindow::Playback { if (!presenting) return position; if (activeAudio && sound && audioAvailable && !(audioEnded && endFrames != UINT64_MAX && played(now) >= endFrames)) return std::max(position, stream_playback_audio_position(sound, played(now))); - if (holdAudio) return position; int64_t proposed = position + (now - clockAt) * adaptation.tempo_milli / 1000; return std::max(position, std::min(ready, proposed)); } @@ -124,6 +125,7 @@ StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t connect(this, &StreamPlayerWindow::statusChanged, m_status, &QLabel::setText, Qt::QueuedConnection); attachAudio(); connect(&m_timer, &QTimer::timeout, this, &StreamPlayerWindow::tick); m_timer.start(15); + m_playback->videoWorker = std::thread(&StreamPlayerWindow::decodeVideo, this); m_playback->worker = std::thread(&StreamPlayerWindow::decode, this); } @@ -152,13 +154,13 @@ void StreamPlayerWindow::attachAudio() { p.deviceLead = std::max(0, firstTimeUs - now); if (!p.sound || !p.started || p.resync) return; p.audioFrameStart = stream_playback_audio_written(p.sound); p.audioDacAt = firstTimeUs; - int count = !p.presenting || p.holdAudio ? 0 : stream_playback_audio_pull(p.sound, pcm, frames, p.audioEnded ? INT64_MAX : p.ready); + int count = !p.presenting ? 0 : stream_playback_audio_pull(p.sound, pcm, frames, INT64_MAX); if (count < 0) { p.resync = true; count = 0; } if ((unsigned)count < frames) { stream_playback_audio_silence(p.sound, frames - (unsigned)count); if (p.presenting && !p.audioEnded) { p.underruns++; p.audioGap.count++; p.audioGap.silentFrames += frames - (unsigned)count; - p.audioGap.requested = frames; p.audioGap.received = (unsigned)count; p.audioGap.held = p.holdAudio; + p.audioGap.requested = frames; p.audioGap.received = (unsigned)count; p.audioGap.reserve = p.ready - p.position; p.audioGap.videoQueue = p.videos.size(); p.audioGap.feedAge = p.audioFedAt ? now - p.audioFedAt : -1; p.audioGap.audioPts = p.audioFedPts; p.audioGap.workerAge = p.workerAt ? now - p.workerAt : -1; @@ -228,6 +230,7 @@ void StreamPlayerWindow::stop() { m_timer.stop(); gui_stream_stop(m_playback->id); SoundManager::instance()->detachStream(m_playback->id); m_audioAttached = false; m_playback->audioAvailable = false; if (m_playback->worker.joinable()) m_playback->worker.join(); + if (m_playback->videoWorker.joinable()) m_playback->videoWorker.join(); DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop player stopped id=%llu shown=%llu underruns=%llu", (unsigned long long)m_playback->id, (unsigned long long)m_playback->shown, (unsigned long long)m_playback->underruns); } @@ -238,239 +241,122 @@ void StreamPlayerWindow::keyPressEvent(QKeyEvent* event) { else QWidget::keyPressEvent(event); } -/* Worker заполняет codec по готовности входа; GUI показывает только ближайшие декодированные кадры. */ +/* Один владелец reader и DSP. Видео никогда не блокирует получение/подготовку аудио. */ void StreamPlayerWindow::decode() { auto& p = *m_playback; #ifndef HAVE_FFMPEG DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop live decoder unavailable: FFmpeg disabled"); - p.failed = p.complete = true; - emit statusChanged(text("Live video playback requires FFmpeg")); + p.failed = p.complete = true; emit statusChanged(text("Live video playback requires FFmpeg")); #else - AVCodecContext* decoder = nullptr; AVFrame* frame = av_frame_alloc(); AVPacket* packet = av_packet_alloc(); - SwsContext* converter = nullptr; - bool waitKey = true, opened = false, finishing = false, eosSent = false; - stream_media_format videoFormat{}; - std::deque records; // Только worker; порядок внутри каждого трека, GAP/CLOSED отменяют старые данные. - uint32_t revision = 0; - uint64_t decoded = 0, discarded = 0, reportedShown = 0; - int64_t deadline = nowUs() + 10000000, report = nowUs(), decoderProgress = -1, lastInputAt = -1; - uint64_t reportedGaps = 0; - int64_t gapReportAt = 0; - struct { - uint64_t loops = 0, video = 0, audio = 0, empty = 0, batches = 0, batchMax = 0; - int64_t codec = 0, codecMax = 0, convert = 0, convertMax = 0; - int64_t audioFeed = 0, audioFeedMax = 0; - int64_t pull = 0, pullMax = 0, wait = 0, waitMax = 0, loopMax = 0; - } work; - int64_t lastLoopAt = report; + std::deque records; + bool opened = false; + uint64_t reportedGaps = 0, observedGaps = 0; + int64_t deadline = nowUs() + 10000000, reportAt = nowUs(), gapReportAt = 0; + uint64_t audioPackets = 0, videoPackets = 0, batches = 0, batchMax = 0; + int64_t feedMax = 0, pullMax = 0, workMax = 0; + auto fail = [&](const QString& reason, bool retry = true) { + p.failed = true; p.complete = true; + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop audio worker failed id=%llu retry=%d: %s", + (unsigned long long)p.id, retry, qPrintable(reason)); + emit statusChanged(reason); p.notify(); + }; auto reset = [&](bool request) { records.clear(); - avcodec_free_context(&decoder); av_packet_unref(packet); waitKey = true; finishing = eosSent = false; - decoderProgress = lastInputAt = -1; + stream_playback_audio* previous; { std::lock_guard guard(p.lock); - stream_playback_audio_destroy(p.sound); p.sound = nullptr; - p.videos.clear(); p.videoBytes = 0; p.started = p.presenting = p.videoReady = false; p.position = p.ready = -1; - p.decoderLead = 0; p.framePeriod = 33333; p.lastInputPts = p.lastDecodedPts = -1; - p.ended = p.videoEnded = p.audioEnded = false; - p.audioDacAt = 0; p.audioFrameStart = 0; p.holdAudio = p.resync = false; p.endFrames = UINT64_MAX; - p.audioFedAt = 0; p.audioFedPts = -1; + previous = p.sound; p.sound = nullptr; ++p.videoGeneration; + p.videoInput.clear(); p.videoInputEnded = p.videoBusy = false; + p.videos.clear(); p.videoBytes = 0; p.started = p.presenting = p.videoReady = false; + p.position = p.ready = -1; p.decoderLead = 0; p.framePeriod = 33333; + p.lastInputPts = p.lastDecodedPts = p.lastShownPts = -1; + p.ended = p.videoEnded = p.audioEnded = p.resync = false; + p.audioDacAt = 0; p.audioFrameStart = 0; p.endFrames = UINT64_MAX; + p.audioFedAt = 0; p.audioFedPts = -1; observedGaps = p.underruns; } - if (request && !gui_stream_resync(p.id)) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop playback resync refused"); + stream_playback_audio_destroy(previous); p.notify(); + if (request && !gui_stream_resync(p.id)) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop playback resync refused id=%llu", + (unsigned long long)p.id); deadline = nowUs() + 10000000; emit statusChanged(text("Waiting for a key frame…")); }; - auto fail = [&](const QString& reason, bool retry = true) { - p.failed = retry; p.complete = true; - DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop viewer failed id=%llu: %s", (unsigned long long)p.id, qPrintable(reason)); - emit statusChanged(reason); - }; auto observe = [&] { stream_media_status status{}; if (!gui_stream_status(p.id, status)) return; - std::lock_guard guard(p.lock); - p.media = status; - if (!status.initialized) return; - if (!p.started) { - p.activeAudio = status.audio_enabled && p.audioAvailable; - if (p.activeAudio) p.sound = stream_playback_audio_create(status.start_us, 40); - if (p.activeAudio && !p.sound) { p.running = false; return; } - p.position = status.start_us; p.clockAt = nowUs(); p.started = true; + if (status.initialized && !p.started) { + bool audio = status.audio_enabled && p.audioAvailable; + auto* sound = audio ? stream_playback_audio_create(status.start_us, 250) : nullptr; + if (audio && !sound) { fail(text("Stream audio decoder failed")); return; } + std::lock_guard guard(p.lock); + p.sound = sound; p.activeAudio = audio; p.position = status.start_us; + p.clockAt = nowUs(); p.started = true; stream_playback_clock_reset(&p.adaptation, p.id); DEBUG_INFO(DEBUG_CATEGORY_STREAM, - "desktop playback starts id=%llu pts=%lld fixed_prefill=0 elapsed_us=%lld " - "ready_us=%lld video_us=%lld audio_us=%lld initial_reserve_us=%lld audio=%d", - (unsigned long long)p.id, (long long)p.position, (long long)(nowUs() - p.createdAt), - (long long)status.ready_us, (long long)status.video_us, (long long)status.audio_us, - (long long)((p.activeAudio ? status.ready_us : status.video_us) - p.position), p.activeAudio); + "desktop playback starts id=%llu pts=%lld fixed_prefill=0 elapsed_us=%lld audio=%d PCM_target_ms=250 separate_video=1", + (unsigned long long)p.id, (long long)p.position, (long long)(nowUs() - p.createdAt), audio); } - p.ready = p.activeAudio ? status.ready_us : status.video_us; + std::lock_guard guard(p.lock); + p.media = status; + if (status.initialized) + p.ready = p.activeAudio && !status.audio_ended ? status.audio_us : std::max(status.audio_us, status.video_us); }; - /* Забираем выход прежде следующего входа. При FIN тот же проход дренирует codec до EOF. */ - auto drainVideo = [&] { - if (!decoder) return; - for (unsigned attempt = 0; attempt < 8 && p.running; attempt++) { - { std::lock_guard guard(p.lock); if (p.videoEnded || p.videos.size() >= 2) return; } - int64_t codecAt = nowUs(); - int result = avcodec_receive_frame(decoder, frame); - int64_t codecUs = nowUs() - codecAt; - work.codec += codecUs; work.codecMax = std::max(work.codecMax, codecUs); - if (result == AVERROR(EAGAIN)) return; - if (result == AVERROR_EOF) { - std::lock_guard guard(p.lock); p.videoEnded = true; p.holdAudio = false; - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop decoder drained id=%llu decoded=%llu", (unsigned long long)p.id, - (unsigned long long)decoded); return; - } - if (result < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop decoder output failed id=%llu codec=%u result=%d", - (unsigned long long)p.id, videoFormat.video_codec, result); - fail(text("Stream video decoder failed")); p.running = false; return; - } - decoded++; decoderProgress = nowUs(); - if (frame->width <= 0 || frame->width > 4096 || frame->height <= 0 || frame->height > 4096) { - DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop decoder invalid size=%dx%d", frame->width, frame->height); - fail(text("Invalid stream video dimensions")); p.running = false; return; - } - int64_t pts; - { std::lock_guard guard(p.lock); - pts = frame->best_effort_timestamp == AV_NOPTS_VALUE ? p.lastInputPts : frame->best_effort_timestamp; - p.decoderLead = std::max(p.decoderLead, p.lastInputPts - pts); p.lastDecodedPts = pts; p.videoReady = true; - if (!p.presenting) { p.presenting = true; p.clockAt = nowUs(); - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, - "desktop first decoded frame id=%llu pts=%lld decoder_lead=%lld elapsed_us=%lld decode_us=%lld ready_ahead_us=%lld", - (unsigned long long)p.id, (long long)pts, (long long)p.decoderLead, (long long)(nowUs() - p.createdAt), - (long long)(p.firstInputAt ? nowUs() - p.firstInputAt : -1), (long long)(p.ready - pts)); } - if (pts < p.clock(nowUs()) - 100000) { discarded++; continue; } } - int64_t convertAt = nowUs(); - converter = sws_getCachedContext(converter, frame->width, frame->height, (AVPixelFormat)frame->format, - frame->width, frame->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); - QImage image = video_frame_image(frame->width, frame->height); - if (!converter || image.isNull()) { fail(text("Stream video conversion failed")); p.running = false; return; } - uint8_t* destination[] = {image.bits(), nullptr, nullptr, nullptr}; - int strides[] = {(int)image.bytesPerLine(), 0, 0, 0}; - if (sws_scale(converter, frame->data, frame->linesize, 0, frame->height, destination, strides) != frame->height) { - fail(text("Stream video conversion failed")); p.running = false; return; - } - if (videoFormat.rotation) image = image.transformed(QTransform().rotate(videoFormat.rotation)); - int64_t convertUs = nowUs() - convertAt; - work.convert += convertUs; work.convertMax = std::max(work.convertMax, convertUs); - std::lock_guard guard(p.lock); - p.videoBytes += (size_t)image.sizeInBytes(); p.videos.push_back({std::move(image), pts}); - } - }; - if (!frame || !packet) fail(text("Stream decoder allocation failed")); - else if (!gui_stream_open(p.id, p.group, p.source, p.epoch, + if (!gui_stream_open(p.id, p.group, p.source, p.epoch, [](void* arg) { static_cast(arg)->notify(true); }, &p)) fail(text("Could not connect to the stream")); - else while (p.running) { - uint64_t wakeVersion; - { std::lock_guard guard(p.wakeLock); wakeVersion = p.wakeVersion; } - int64_t loopAt = nowUs(); - work.loops++; work.loopMax = std::max(work.loopMax, loopAt - lastLoopAt); lastLoopAt = loopAt; - if (loopAt - report >= 1000000) { - double seconds = (loopAt - report) / 1000000.0; - std::lock_guard guard(p.lock); - uint64_t dataSignals; - { std::lock_guard wakeGuard(p.wakeLock); dataSignals = p.dataSignals; p.dataSignals = 0; } - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, - "desktop viewer work id=%llu interval_us=%lld loops=%llu video=%llu audio=%llu empty=%llu " - "codec_us=%lld/%lld convert_us=%lld/%lld audio_feed_us=%lld/%lld pull_us=%lld/%lld wait_us=%lld/%lld " - "loop_max_us=%lld gui_gap_max_us=%lld gui_age_us=%lld decoded=%llu shown=%llu show_fps=%.1f " - "clock_us=%lld ready_us=%lld videoQ=%zu hold=%d underruns=%llu batches=%llu batch_max=%llu " - "pending_records=%zu data_signals=%llu presenting=%d audio_input=%d input_FIN=%d/%d/%d " - "drain=%d/%d packet_bytes=%d timing=total/max", - (unsigned long long)p.id, (long long)(loopAt - report), (unsigned long long)work.loops, - (unsigned long long)work.video, (unsigned long long)work.audio, (unsigned long long)work.empty, - (long long)work.codec, (long long)work.codecMax, (long long)work.convert, (long long)work.convertMax, - (long long)work.audioFeed, (long long)work.audioFeedMax, - (long long)work.pull, (long long)work.pullMax, (long long)work.wait, (long long)work.waitMax, - (long long)work.loopMax, (long long)p.guiGapMax, (long long)(p.guiAt ? nowUs() - p.guiAt : -1), - (unsigned long long)decoded, (unsigned long long)p.shown, (p.shown - reportedShown) / seconds, - (long long)p.position, (long long)p.ready, p.videos.size(), p.holdAudio, (unsigned long long)p.underruns, - (unsigned long long)work.batches, (unsigned long long)work.batchMax, records.size(), (unsigned long long)dataSignals, - p.presenting, p.sound ? stream_playback_audio_needs_input(p.sound) : -1, - p.media.finished, p.media.video_ended, p.media.audio_ended, finishing, eosSent, packet->size); - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, - "desktop viewer id=%llu decoded=%llu discarded=%llu shown=%llu showFps=%.1f videoQ=%zu " - "clock=%lld reserve_us=%lld minimum_us=%lld tempo=%d lead_us=%lld underruns=%llu " - "ready_us=%lld shown_pts=%lld display_lag_us=%lld decoder_lead_us=%lld hold=%d", - (unsigned long long)p.id, (unsigned long long)decoded, (unsigned long long)discarded, - (unsigned long long)p.shown, (p.shown - reportedShown) / seconds, p.videos.size(), (long long)p.position, - (long long)p.timing.reserve_us, (long long)p.timing.minimum_us, p.timing.tempo_milli, - (long long)p.deviceLead, (unsigned long long)p.underruns, (long long)p.ready, - (long long)p.lastShownPts, (long long)(p.lastShownPts >= 0 ? p.position - p.lastShownPts : -1), - (long long)p.decoderLead, p.holdAudio); - work = {}; report = loopAt; reportedShown = p.shown; p.guiGapMax = 0; - } + else while (p.running && !p.failed) { + uint64_t version; + { std::lock_guard guard(p.wakeLock); version = p.wakeVersion; } + int64_t began = nowUs(); bool resync; Playback::AudioGap gap; - { std::lock_guard guard(p.lock); resync = p.resync; p.workerAt = nowUs(); gap = p.audioGap; } - /* Callback сохраняет снимок; печать выполняется worker без блокировки аудиоустройства. */ - if (gap.count != reportedGaps && nowUs() - gapReportAt >= 1000000) { + { std::lock_guard guard(p.lock); resync = p.resync; p.workerAt = began; gap = p.audioGap; } + if (gap.count != reportedGaps && began - gapReportAt >= 1000000) { DEBUG_WARN(DEBUG_CATEGORY_STREAM, - "desktop audio gap id=%llu count=%llu delta=%llu silence_frames=%llu cause=%s got=%u/%u " - "reserve_us=%lld feed_age_us=%lld worker_age_us=%lld audio_pts=%lld videoQ=%zu", + "desktop audio gap id=%llu count=%llu delta=%llu silence_frames=%llu got=%u/%u reserve_us=%lld " + "feed_age_us=%lld worker_age_us=%lld audio_pts=%lld videoQ=%zu", (unsigned long long)p.id, (unsigned long long)gap.count, (unsigned long long)(gap.count - reportedGaps), - (unsigned long long)gap.silentFrames, gap.held ? "video_hold" : "pcm_short", gap.received, gap.requested, - (long long)gap.reserve, (long long)gap.feedAge, (long long)gap.workerAge, (long long)gap.audioPts, gap.videoQueue); - reportedGaps = gap.count; gapReportAt = nowUs(); + (unsigned long long)gap.silentFrames, gap.received, gap.requested, (long long)gap.reserve, + (long long)gap.feedAge, (long long)gap.workerAge, (long long)gap.audioPts, gap.videoQueue); + reportedGaps = gap.count; gapReportAt = began; } if (resync) reset(true); observe(); - drainVideo(); - if (!p.running) break; - bool space; - { std::lock_guard guard(p.lock); space = p.videos.size() < 2; } - if (space && decoder && (packet->size || (finishing && !eosSent))) { - int64_t codecAt = nowUs(); - int sent = avcodec_send_packet(decoder, packet->size ? packet : nullptr); - int64_t codecUs = nowUs() - codecAt; - work.codec += codecUs; work.codecMax = std::max(work.codecMax, codecUs); - if (sent == 0) { - if (packet->size) { - std::lock_guard guard(p.lock); - if (p.lastInputPts >= 0) p.framePeriod = std::clamp(packet->pts - p.lastInputPts, 1, 1000000); - p.lastInputPts = packet->pts; lastInputAt = nowUs(); - } else eosSent = true; - av_packet_unref(packet); drainVideo(); - } else if (sent != AVERROR(EAGAIN)) { - DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop decoder input failed id=%llu codec=%u bytes=%d result=%d", - (unsigned long long)p.id, videoFormat.video_codec, packet->size, sent); - fail(text("Stream video decoder failed")); break; - } - } - { std::lock_guard guard(p.lock); - if (p.ended && p.videoEnded) break; - if (p.videos.empty() && decoderProgress >= 0 && nowUs() - decoderProgress > 10000000 - && (packet->size || finishing || (lastInputAt >= 0 && nowUs() - lastInputAt < 1000000))) { - DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop decoder stalled id=%llu input_PTS=%lld output_PTS=%lld pending=%d FIN=%d", - (unsigned long long)p.id, (long long)p.lastInputPts, (long long)p.lastDecodedPts, packet->size, finishing); - fail(text("Stream video decoder failed")); break; - } } - int64_t position, audioAhead; bool wantVideo; unsigned videoBudget; + if (p.sound && stream_playback_audio_prepare(p.sound) < 0) { fail(text("Stream audio decoder failed")); break; } + int64_t position, audioAhead; + unsigned videoBudget; + int tempo = 1000; { std::lock_guard guard(p.lock); + if (p.ended && p.videoEnded && (!p.sound || stream_playback_audio_empty(p.sound))) break; int64_t now = nowUs(); position = p.started ? p.clock(now) : -1; - wantVideo = !finishing && !packet->size && p.videos.size() < 2; - videoBudget = finishing ? 0 : static_cast(std::max(0, 2 - (int)p.videos.size() - (packet->size ? 1 : 0))); - audioAhead = !p.audioEnded && (!p.activeAudio || !p.sound || stream_playback_audio_needs_input(p.sound)) - ? p.deviceLead + 80000 : -1; + videoBudget = p.videoInputEnded ? 0 : (unsigned)std::max(0, + 2 - (int)p.videos.size() - (int)p.videoInput.size() - (p.videoBusy ? 1 : 0)); + audioAhead = !p.audioEnded && (!p.sound || stream_playback_audio_needs_input(p.sound)) ? p.deviceLead + 500000 : -1; if (p.presenting && p.ready >= 0) { uint64_t played = p.played(now); - p.holdAudio = !p.videoEnded && (!p.videoReady || (p.videos.empty() && p.lastDecodedPts < position - p.framePeriod) - || (!p.videos.empty() && p.videos.front().pts < position - 40000)); - int virtualEmpty = !p.activeAudio && p.position + (now - p.clockAt) * p.adaptation.tempo_milli / 1000 > p.ready; - if (!p.activeAudio && p.holdAudio) position = p.position; - int starved = p.holdAudio || virtualEmpty || (p.sound && stream_playback_audio_starved(p.sound, played)); - int64_t guardUs = std::max(p.decoderLead + p.framePeriod, p.activeAudio ? p.deviceLead + 10000 : p.framePeriod); + bool starved = p.underruns != observedGaps; observedGaps = p.underruns; + if (!p.activeAudio && p.position + (now - p.clockAt) * p.adaptation.tempo_milli / 1000 > p.ready) starved = true; + int64_t guardUs = p.activeAudio ? std::max(40000, p.deviceLead + 20000) : p.framePeriod; int64_t pending = p.sound ? stream_playback_audio_pending_gain(p.sound, played) : 0; if (stream_playback_clock_update(&p.adaptation, now, std::max(p.position, position), p.ready, guardUs, pending, !p.ended && starved, &p.timing) < 0) p.resync = true; - else { - p.position = std::max(p.position, position); p.clockAt = now; - if (p.sound) stream_playback_audio_set_tempo(p.sound, p.timing.tempo_milli); - } + else { p.position = std::max(p.position, position); p.clockAt = now; tempo = p.timing.tempo_milli; } + } + if (began - reportAt >= 1000000) { + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, + "desktop viewer work id=%llu interval_us=%lld audio=%llu video=%llu batches=%llu batch_max=%llu " + "feed_max_us=%lld pull_max_us=%lld work_max_us=%lld PCM_frames=%u tempo=%d clock_us=%lld ready_us=%lld " + "reserve_us=%lld underruns=%llu silence_frames=%llu videoQ=%zu video_input=%zu video_busy=%d " + "gui_gap_max_us=%lld generation=%llu", + (unsigned long long)p.id, (long long)(began - reportAt), (unsigned long long)audioPackets, + (unsigned long long)videoPackets, (unsigned long long)batches, (unsigned long long)batchMax, + (long long)feedMax, (long long)pullMax, (long long)workMax, stream_playback_audio_buffered(p.sound), + tempo, (long long)p.position, (long long)p.ready, (long long)(p.ready - p.position), + (unsigned long long)p.underruns, (unsigned long long)p.audioGap.silentFrames, p.videos.size(), + p.videoInput.size(), p.videoBusy, (long long)p.guiGapMax, (unsigned long long)p.videoGeneration); + reportAt = began; audioPackets = videoPackets = batches = batchMax = 0; + feedMax = pullMax = workMax = 0; p.guiGapMax = 0; } } - // Конец трека обгоняет только чужой трек; аварийные события отменяют всю прежнюю очередь. + if (p.sound) stream_playback_audio_set_tempo(p.sound, tempo); auto urgent = std::find_if(records.begin(), records.end(), [](const StreamRecord& r) { return r.kind == StreamRecordKind::Gap || r.kind == StreamRecordKind::Closed; }); @@ -485,27 +371,19 @@ void StreamPlayerWindow::decode() { continue; } if (!video && !audio) { barrier = true; if (it == records.begin()) next = it; break; } - if (next == records.end() && ((video && wantVideo) || (audio && audioAhead >= 0))) next = it; + if (next == records.end() && ((video && videoBudget) || (audio && audioAhead >= 0))) next = it; } if (next == records.end()) { - size_t previous = records.size(); - int64_t pullAt = nowUs(); - // После обоих EOF нужен ещё общий END, даже когда больше media не требуется. + size_t previous = records.size(); int64_t at = nowUs(); bool received = !p.ended && !barrier && gui_stream_pull_batch(p.id, records, position, videoBudget > pendingVideo ? videoBudget - pendingVideo : 0, pendingAudio ? -1 : audioAhead); - int64_t pullUs = nowUs() - pullAt; - work.pull += pullUs; work.pullMax = std::max(work.pullMax, pullUs); - if (received) { - work.batches++; work.batchMax = std::max(work.batchMax, records.size() - previous); continue; - } - work.empty++; + pullMax = std::max(pullMax, nowUs() - at); workMax = std::max(workMax, nowUs() - began); + if (received) { ++batches; batchMax = std::max(batchMax, records.size() - previous); continue; } if (!gui_stream_ready()) { fail(text("Chat service is stopped")); break; } - if ((!opened || waitKey) && nowUs() > deadline) { fail(text("Stream connection timed out")); break; } - int64_t waitAt = nowUs(); - { std::unique_lock guard(p.wakeLock); - p.wake.wait_for(guard, std::chrono::milliseconds(50), [&] { return !p.running || p.wakeVersion != wakeVersion; }); } - int64_t waitUs = nowUs() - waitAt; - work.wait += waitUs; work.waitMax = std::max(work.waitMax, waitUs); continue; + if ((!opened || !p.started) && nowUs() > deadline) { fail(text("Stream connection timed out")); break; } + std::unique_lock guard(p.wakeLock); + p.wake.wait_for(guard, std::chrono::milliseconds(50), [&] { return !p.running || p.failed || p.wakeVersion != version; }); + continue; } StreamRecord record = std::move(*next); records.erase(next); if (record.kind == StreamRecordKind::Opened) { opened = true; continue; } @@ -518,93 +396,237 @@ void StreamPlayerWindow::decode() { fail(text(reason), record.reason == STREAM_CLOSE_FAILED); break; } if (record.kind == StreamRecordKind::VideoEnd || record.kind == StreamRecordKind::AudioEnd || record.kind == StreamRecordKind::End) { - std::lock_guard guard(p.lock); - if (record.kind != StreamRecordKind::AudioEnd && !finishing) { finishing = true; p.videoEnded = !decoder; } - if (record.kind != StreamRecordKind::VideoEnd && !p.audioEnded) { - p.audioEnded = true; - if (p.sound) stream_playback_audio_finish(p.sound); + if (record.kind != StreamRecordKind::VideoEnd && p.sound && !p.audioEnded) stream_playback_audio_finish(p.sound); + { + std::lock_guard guard(p.lock); + if (record.kind != StreamRecordKind::AudioEnd) p.videoInputEnded = true; + if (record.kind != StreamRecordKind::VideoEnd) p.audioEnded = true; + if (record.kind == StreamRecordKind::End) p.ended = true; + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop track end id=%llu track=%s video_input_end=%d audio_input_end=%d pending=%zu", + (unsigned long long)p.id, record.kind == StreamRecordKind::VideoEnd ? "video" + : record.kind == StreamRecordKind::AudioEnd ? "audio" : "all", p.videoInputEnded, p.audioEnded, records.size()); } - if (record.kind == StreamRecordKind::End) p.ended = true; - DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop track end id=%llu track=%s video_input_end=%d audio_input_end=%d pending=%zu", - (unsigned long long)p.id, record.kind == StreamRecordKind::VideoEnd ? "video" - : record.kind == StreamRecordKind::AudioEnd ? "audio" : "all", finishing, p.audioEnded, records.size()); - continue; + p.notify(); continue; } observe(); if (record.pts > INT64_MAX) { fail(text("Invalid stream timestamp")); break; } if (record.kind == StreamRecordKind::Audio) { - work.audio++; - int64_t audioAt = nowUs(); - int result = 0; - { std::lock_guard guard(p.lock); - if (p.sound) result = stream_playback_audio_feed(p.sound, (int64_t)record.pts, - reinterpret_cast(record.data.constData()), (size_t)record.data.size()); - if (p.sound && result == 0) { p.audioFedAt = nowUs(); p.audioFedPts = (int64_t)record.pts; } } - int64_t audioUs = nowUs() - audioAt; - work.audioFeed += audioUs; work.audioFeedMax = std::max(work.audioFeedMax, audioUs); + ++audioPackets; int64_t at = nowUs(); + int result = p.sound ? stream_playback_audio_feed(p.sound, (int64_t)record.pts, + reinterpret_cast(record.data.constData()), (size_t)record.data.size()) : 0; + feedMax = std::max(feedMax, nowUs() - at); if (result == -2) reset(true); else if (result < 0) { fail(text("Stream audio decoder failed")); break; } - continue; - } - work.video++; - if (revision != record.format.revision) { - avcodec_free_context(&decoder); waitKey = true; revision = record.format.revision; - decoderProgress = lastInputAt = -1; - { std::lock_guard guard(p.lock); p.videos.clear(); p.videoBytes = 0; p.videoReady = false; - p.decoderLead = 0; p.framePeriod = 33333; p.lastInputPts = p.lastDecodedPts = -1; } - deadline = nowUs() + 10000000; - } - videoFormat = record.format; - if (waitKey && !(record.flags & STREAM_MEDIA_KEY)) continue; - const uint8_t* data = reinterpret_cast(record.data.constData()); - const uint8_t* configuration = nullptr; size_t size = (size_t)record.data.size(), configurationSize = 0; - if (record.flags & STREAM_MEDIA_KEY) { - if (stream_media_key_parse(data, size, &configuration, &configurationSize, &data, &size) != STREAM_OK) { - fail(text("Stream video decoder failed")); break; + else if (p.sound && result == 0) { + std::lock_guard guard(p.lock); p.audioFedAt = nowUs(); p.audioFedPts = (int64_t)record.pts; } + } else { + ++videoPackets; + { std::lock_guard guard(p.lock); p.videoInput.push_back(std::move(record)); } + p.notify(); } - if (!decoder) { - int64_t openingAt = nowUs(); - AVCodecID codecId = record.format.video_codec == STREAM_MEDIA_H264 ? AV_CODEC_ID_H264 - : record.format.video_codec == STREAM_MEDIA_H265 ? AV_CODEC_ID_HEVC - : record.format.video_codec == STREAM_MEDIA_VP9 ? AV_CODEC_ID_VP9 - : record.format.video_codec == STREAM_MEDIA_AV1 ? AV_CODEC_ID_AV1 : AV_CODEC_ID_NONE; - if (record.format.video_codec == STREAM_MEDIA_H266) { - const AVCodecDescriptor* descriptor = avcodec_descriptor_get_by_name("vvc"); - if (descriptor) codecId = descriptor->id; + workMax = std::max(workMax, nowUs() - began); + } +#endif + gui_stream_stop(p.id); p.notify(); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop audio worker released id=%llu", (unsigned long long)p.id); +} + +/* Единственный владелец FFmpeg. Изображения/входы ограничены; generation отменяет результаты старого decode. */ +void StreamPlayerWindow::decodeVideo() { +#ifdef HAVE_FFMPEG + auto& p = *m_playback; + AVCodecContext* decoder = nullptr; AVFrame* frame = av_frame_alloc(); AVPacket* packet = av_packet_alloc(); + SwsContext* converter = nullptr; + stream_media_format format{}; + uint64_t generation = 0, decoded = 0, discarded = 0; + uint32_t revision = 0; + bool eosSent = false; + int64_t reportAt = nowUs(), codecTotal = 0, codecMax = 0, convertTotal = 0, convertMax = 0, progressAt = nowUs(); + auto fail = [&](const QString& reason) { + p.failed = p.complete = true; + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop video worker failed id=%llu: %s", (unsigned long long)p.id, qPrintable(reason)); + emit statusChanged(reason); p.notify(); + }; + auto codecTime = [&](int64_t at) { + int64_t elapsed = nowUs() - at; codecTotal += elapsed; codecMax = std::max(codecMax, elapsed); + }; + if (!frame || !packet) fail(text("Stream decoder allocation failed")); + while (p.running && !p.failed) { + uint64_t version; + { std::lock_guard guard(p.wakeLock); version = p.wakeVersion; } + uint64_t current; bool space; + { + std::lock_guard guard(p.lock); + current = p.videoGeneration; space = p.videos.size() < 2; + } + if (current != generation) { + avcodec_free_context(&decoder); av_packet_unref(packet); revision = 0; eosSent = false; + generation = current; progressAt = nowUs(); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "desktop video generation id=%llu generation=%llu", + (unsigned long long)p.id, (unsigned long long)generation); + } + bool worked = false; + if (decoder && space) { + int64_t at = nowUs(); int result = avcodec_receive_frame(decoder, frame); codecTime(at); + if (result == AVERROR_EOF) { + { std::lock_guard guard(p.lock); + if (generation != p.videoGeneration) continue; + p.videoEnded = true; p.videoBusy = false; } + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop decoder drained id=%llu decoded=%llu discarded=%llu", + (unsigned long long)p.id, (unsigned long long)decoded, (unsigned long long)discarded); + p.notify(); break; } - const AVCodec* codec = avcodec_find_decoder(codecId); - decoder = codec ? avcodec_alloc_context3(codec) : nullptr; - if (decoder) { - decoder->thread_count = 1; decoder->flags |= AV_CODEC_FLAG_LOW_DELAY; - if (configurationSize) { - decoder->extradata = static_cast(av_mallocz(configurationSize + AV_INPUT_BUFFER_PADDING_SIZE)); - if (!decoder->extradata) { fail(text("Stream decoder allocation failed")); break; } - std::memcpy(decoder->extradata, configuration, configurationSize); decoder->extradata_size = (int)configurationSize; + if (result < 0 && result != AVERROR(EAGAIN)) { fail(text("Stream video decoder failed")); break; } + if (result == 0) { + ++decoded; progressAt = nowUs(); worked = true; + if (frame->width <= 0 || frame->width > 4096 || frame->height <= 0 || frame->height > 4096) { + fail(text("Invalid stream video dimensions")); break; } + int64_t pts; + { + std::lock_guard guard(p.lock); + if (generation != p.videoGeneration) continue; + pts = frame->best_effort_timestamp == AV_NOPTS_VALUE ? p.lastInputPts : frame->best_effort_timestamp; + p.decoderLead = std::max(p.decoderLead, p.lastInputPts - pts); p.lastDecodedPts = pts; p.videoReady = true; + if (!p.presenting) { + p.presenting = true; p.clockAt = nowUs(); + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, + "desktop first decoded frame id=%llu pts=%lld elapsed_us=%lld decode_us=%lld", + (unsigned long long)p.id, (long long)pts, (long long)(nowUs() - p.createdAt), + (long long)(nowUs() - p.firstInputAt)); + } + if (pts < p.clock(nowUs()) - 100000) { ++discarded; continue; } + } + int64_t convertAt = nowUs(); + converter = sws_getCachedContext(converter, frame->width, frame->height, (AVPixelFormat)frame->format, + frame->width, frame->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); + QImage image = video_frame_image(frame->width, frame->height); + if (!converter || image.isNull()) { fail(text("Stream video conversion failed")); break; } + uint8_t* destination[] = {image.bits(), nullptr, nullptr, nullptr}; + int strides[] = {(int)image.bytesPerLine(), 0, 0, 0}; + if (sws_scale(converter, frame->data, frame->linesize, 0, frame->height, destination, strides) != frame->height) { + fail(text("Stream video conversion failed")); break; + } + if (format.rotation) image = image.transformed(QTransform().rotate(format.rotation)); + int64_t elapsed = nowUs() - convertAt; convertTotal += elapsed; convertMax = std::max(convertMax, elapsed); + { + std::lock_guard guard(p.lock); + if (generation != p.videoGeneration) continue; + p.videoBytes += (size_t)image.sizeInBytes(); p.videos.push_back({std::move(image), pts}); + } + p.notify(); continue; } - int openResult = !codec ? AVERROR_DECODER_NOT_FOUND : !decoder ? AVERROR(ENOMEM) : avcodec_open2(decoder, codec, nullptr); - if (openResult < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop decoder open failed codec=%u name=%s config=%zu result=%d", - record.format.video_codec, codec ? codec->name : "unavailable", configurationSize, openResult); + } + StreamRecord input; + bool haveInput = false, ending; + { + std::lock_guard guard(p.lock); + if (generation != p.videoGeneration) continue; + space = p.videos.size() < 2; ending = p.videoInputEnded && p.videoInput.empty(); + if (!packet->size && space && !p.videoInput.empty()) { + input = std::move(p.videoInput.front()); p.videoInput.pop_front(); p.videoBusy = true; haveInput = true; + } + } + if (haveInput) { + worked = true; + if (revision != input.format.revision) { + avcodec_free_context(&decoder); revision = input.format.revision; eosSent = false; progressAt = nowUs(); + std::lock_guard guard(p.lock); + if (generation != p.videoGeneration) continue; + p.videos.clear(); p.videoBytes = 0; p.videoReady = false; + p.decoderLead = 0; p.framePeriod = 33333; p.lastInputPts = p.lastDecodedPts = -1; + } + format = input.format; + const uint8_t* data = reinterpret_cast(input.data.constData()); + const uint8_t* configuration = nullptr; size_t size = (size_t)input.data.size(), configurationSize = 0; + if ((input.flags & STREAM_MEDIA_KEY) && + stream_media_key_parse(data, size, &configuration, &configurationSize, &data, &size) != STREAM_OK) { fail(text("Stream video decoder failed")); break; } - DEBUG_INFO(DEBUG_CATEGORY_STREAM, - "desktop decoder id=%llu codec=%u name=%s config=%zu elapsed_us=%lld open_us=%lld key_pts=%llu", - (unsigned long long)p.id, record.format.video_codec, codec->name, configurationSize, - (long long)(nowUs() - p.createdAt), (long long)(nowUs() - openingAt), (unsigned long long)record.pts); - decoder->pkt_timebase = AVRational{1, 1000000}; waitKey = false; - p.firstInputAt = nowUs(); + if (!decoder) { + if (!(input.flags & STREAM_MEDIA_KEY)) { + DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop video waiting KEY id=%llu revision=%u pts=%llu", + (unsigned long long)p.id, revision, (unsigned long long)input.pts); + { std::lock_guard guard(p.lock); p.videoBusy = false; } + p.notify(); continue; + } + int64_t openingAt = nowUs(); + AVCodecID codecId = format.video_codec == STREAM_MEDIA_H264 ? AV_CODEC_ID_H264 + : format.video_codec == STREAM_MEDIA_H265 ? AV_CODEC_ID_HEVC + : format.video_codec == STREAM_MEDIA_VP9 ? AV_CODEC_ID_VP9 + : format.video_codec == STREAM_MEDIA_AV1 ? AV_CODEC_ID_AV1 : AV_CODEC_ID_NONE; + if (format.video_codec == STREAM_MEDIA_H266) { + const AVCodecDescriptor* descriptor = avcodec_descriptor_get_by_name("vvc"); + if (descriptor) codecId = descriptor->id; + } + const AVCodec* codec = avcodec_find_decoder(codecId); + decoder = codec ? avcodec_alloc_context3(codec) : nullptr; + if (decoder) { + decoder->thread_count = 4; decoder->thread_type = FF_THREAD_SLICE; decoder->flags |= AV_CODEC_FLAG_LOW_DELAY; + decoder->pkt_timebase = AVRational{1, 1000000}; + if (configurationSize) { + decoder->extradata = static_cast(av_mallocz(configurationSize + AV_INPUT_BUFFER_PADDING_SIZE)); + if (!decoder->extradata) { fail(text("Stream decoder allocation failed")); break; } + std::memcpy(decoder->extradata, configuration, configurationSize); decoder->extradata_size = (int)configurationSize; + } + } + int result = !codec ? AVERROR_DECODER_NOT_FOUND : !decoder ? AVERROR(ENOMEM) : avcodec_open2(decoder, codec, nullptr); + if (result < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop decoder open id=%llu codec=%u result=%d", + (unsigned long long)p.id, format.video_codec, result); + fail(text("Stream video decoder failed")); break; + } + p.firstInputAt = nowUs(); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, + "desktop decoder id=%llu codec=%u name=%s threads=%d thread_type=%d config=%zu open_us=%lld key_pts=%llu", + (unsigned long long)p.id, format.video_codec, codec->name, decoder->thread_count, decoder->active_thread_type, + configurationSize, (long long)(nowUs() - openingAt), (unsigned long long)input.pts); + } + if (av_new_packet(packet, (int)size) < 0) { fail(text("Stream decoder allocation failed")); break; } + std::memcpy(packet->data, data, size); packet->pts = packet->dts = (int64_t)input.pts; } - av_packet_unref(packet); - if (av_new_packet(packet, (int)size) < 0) { fail(text("Stream decoder allocation failed")); break; } - std::memcpy(packet->data, data, size); - packet->pts = packet->dts = (int64_t)record.pts; - if (lastInputAt < 0 || nowUs() - lastInputAt > 1000000) decoderProgress = nowUs(); + if (space && decoder && (packet->size || (ending && !eosSent))) { + int64_t at = nowUs(); int result = avcodec_send_packet(decoder, packet->size ? packet : nullptr); codecTime(at); + if (result == 0) { + worked = true; + { + std::lock_guard guard(p.lock); + if (generation != p.videoGeneration) continue; + if (packet->size) { + if (p.lastInputPts >= 0) p.framePeriod = std::clamp(packet->pts - p.lastInputPts, 1, 1000000); + p.lastInputPts = packet->pts; + } else eosSent = true; + p.videoBusy = false; + } + av_packet_unref(packet); p.notify(); + } else if (result != AVERROR(EAGAIN)) { + DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop decoder input id=%llu result=%d bytes=%d", + (unsigned long long)p.id, result, packet->size); + fail(text("Stream video decoder failed")); break; + } + } + if (ending && !decoder && !packet->size) { + { std::lock_guard guard(p.lock); if (generation != p.videoGeneration) continue; p.videoEnded = true; } + p.notify(); break; + } + int64_t now = nowUs(); + if (now - reportAt >= 1000000) { + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, + "desktop video worker id=%llu interval_us=%lld decoded=%llu discarded=%llu codec_us=%lld/%lld convert_us=%lld/%lld", + (unsigned long long)p.id, (long long)(now - reportAt), (unsigned long long)decoded, + (unsigned long long)discarded, (long long)codecTotal, (long long)codecMax, (long long)convertTotal, (long long)convertMax); + reportAt = now; codecTotal = codecMax = convertTotal = convertMax = 0; + } + if (space && decoder && (packet->size || ending) && now - progressAt > 10000000) { + fail(text("Stream video decoder failed")); break; + } + if (worked) continue; + std::unique_lock guard(p.wakeLock); + p.wake.wait_for(guard, std::chrono::milliseconds(50), [&] { return !p.running || p.failed || p.wakeVersion != version; }); } avcodec_free_context(&decoder); av_frame_free(&frame); av_packet_free(&packet); if (converter) sws_freeContext(converter); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop video worker released id=%llu", (unsigned long long)p.id); #endif - gui_stream_stop(p.id); - DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop decoder released id=%llu", (unsigned long long)p.id); } diff --git a/tools/chatgui/src/streamplayer_window.h b/tools/chatgui/src/streamplayer_window.h index 0e0cd689..a894d04d 100644 --- a/tools/chatgui/src/streamplayer_window.h +++ b/tools/chatgui/src/streamplayer_window.h @@ -1,6 +1,6 @@ -/* Просмотр одной live-трансляции: запас остаётся в RS-кольце, worker готовит ближайшие FFmpeg/Opus кадры. +/* Просмотр live: audio worker получает батчи и готовит PCM, отдельный worker владеет FFmpeg. * GUI владеет окном; общий output смешивает PCM, callback сохраняет отдельные часы этой трансляции. - * Закрытие останавливает worker и снимает reader. Публикация и общий транспорт не закрываются. */ + * Закрытие отсоединяет output, останавливает оба worker и снимает reader. */ #pragma once #include @@ -39,4 +39,5 @@ private: void attachAudio(); void tick(); void decode(); + void decodeVideo(); };