From 7389caf4662cc90809b2b0eb054ae4dc2f5b7182 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 14 Sep 2026 15:36:46 -0500 Subject: [PATCH 1/2] Prevent camera cleanup from racing FFmpeg device initialization Serialize the FFmpeg capture Open path with GetFrame and Close using the existing recursive mutex. Close still signals cancellation before waiting, but cannot free a context while avformat_open_input or decoder setup uses it. Clean up partially initialized resources on failure or cancellation. Add a hardware-independent regression test that pauses a V4L2 open inside FFmpeg and requests Close from another thread. It reproduces SIGSEGV with the original implementation and passes with the fix, including 20 repeated runs. All 15 camera/screen capture test cases pass (78 assertions). Real-device AppImage validation remains necessary after rebuilding. --- src/ScreenCaptureReader.cpp | 27 +++++++---- tests/CameraCaptureReader.cpp | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/ScreenCaptureReader.cpp b/src/ScreenCaptureReader.cpp index e52a92569..e415c04ef 100644 --- a/src/ScreenCaptureReader.cpp +++ b/src/ScreenCaptureReader.cpp @@ -869,22 +869,31 @@ void ScreenCaptureReader::Open() } return; } + // Close() may run on the UI thread while a worker is opening the device. + // Serialize initialization with frame reads and cleanup to keep FFmpeg's + // context alive until avformat_open_input/OpenDecoder have finished. + const std::lock_guard lock(getFrameMutex); if (is_open) { return; } manual_system_audio = false; - if (system_audio) { - system_audio->Open(); - info.sample_rate = system_audio->SampleRate(); - info.channels = system_audio->Channels(); - info.channel_layout = info.channels == 1 ? LAYOUT_MONO : LAYOUT_STEREO; - } close_requested = false; try { + if (system_audio) { + system_audio->Open(); + info.sample_rate = system_audio->SampleRate(); + info.channels = system_audio->Channels(); + info.channel_layout = info.channels == 1 ? LAYOUT_MONO : LAYOUT_STEREO; + } OpenDevice(); OpenDecoder(); + if (close_requested) { + throw ReaderClosed("Capture initialization was cancelled."); + } } catch (...) { - if (system_audio) system_audio->Close(); + // Also release partially initialized device/decoder resources. The + // recursive lifecycle lock permits using the normal cleanup path here. + Close(); throw; } is_open = true; @@ -1172,8 +1181,8 @@ void ScreenCaptureReader::Close() backend_reader->Close(); } - // GetFrame() owns all decoder and system-audio use under this mutex. Do not - // release those resources until an interrupted read has completely exited. + // Open() and GetFrame() own FFmpeg and system-audio use under this mutex. + // Wait for initialization or an interrupted read before releasing resources. const std::lock_guard lock(getFrameMutex); if (system_audio) { system_audio->Close(); diff --git a/tests/CameraCaptureReader.cpp b/tests/CameraCaptureReader.cpp index 079ba1342..cc19ce099 100644 --- a/tests/CameraCaptureReader.cpp +++ b/tests/CameraCaptureReader.cpp @@ -15,8 +15,94 @@ #include "CameraCaptureReader.h" #include "Exceptions.h" +#include +#include +#include +#include +#include + +extern "C" { +#include +} + using namespace openshot; +#if defined(__linux__) +namespace { +// Pause a real V4L2 open at its error log, while FFmpeg still owns the input +// context. This requires no camera hardware and makes the cleanup race repeatable. +struct CameraOpenGate { + std::mutex mutex; + std::condition_variable changed; + bool entered = false; + bool release = false; + static thread_local CameraOpenGate* active; + + static void Log(void* context, int level, const char* format, va_list args) + { + if (!active) { + av_log_default_callback(context, level, format, args); + return; + } + std::unique_lock lock(active->mutex); + active->entered = true; + active->changed.notify_all(); + active->changed.wait(lock, [] { return active->release; }); + } +}; +thread_local CameraOpenGate* CameraOpenGate::active = nullptr; +} + +TEST_CASE("Camera close waits for in-flight FFmpeg initialization", + "[libopenshot][cameracapturereader][lifecycle]") +{ + CameraCaptureSettings settings; + settings.backend = CAMERA_CAPTURE_V4L2; + // /dev/null is a file, so this path cannot accidentally name a real camera. + settings.device = "/dev/null/openshot-test-camera"; + CameraCaptureReader reader(settings); + CameraOpenGate gate; + std::exception_ptr open_error; + av_log_set_callback(CameraOpenGate::Log); + std::thread opener([&] { + CameraOpenGate::active = &gate; + try { reader.Open(); } catch (...) { open_error = std::current_exception(); } + CameraOpenGate::active = nullptr; + }); + bool entered; + { + std::unique_lock lock(gate.mutex); + entered = gate.changed.wait_for(lock, std::chrono::seconds(5), [&] { return gate.entered; }); + } + std::promise close_started; + std::promise close_done; + auto done = close_done.get_future(); + std::thread closer([&] { + close_started.set_value(); + reader.Close(); + close_done.set_value(); + }); + close_started.get_future().wait(); + const bool cleanup_waited = done.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout; + { + std::lock_guard lock(gate.mutex); + gate.release = true; + } + gate.changed.notify_all(); + opener.join(); + closer.join(); + av_log_set_callback(av_log_default_callback); + REQUIRE(entered); + CHECK(cleanup_waited); + CHECK(open_error != nullptr); + CHECK_FALSE(reader.IsOpen()); + CHECK_NOTHROW(reader.Close()); + // A failed/cancelled open must leave the object safe for another attempt. + CHECK_THROWS_AS(reader.Open(), InvalidFile); + CHECK_FALSE(reader.IsOpen()); +} +#endif + TEST_CASE("Camera capture settings validation", "[libopenshot][cameracapturereader]") { CameraCaptureSettings settings; From dc43f4ddb5c501ad509dd29a949a98a466ce1864 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 14 Sep 2026 16:11:43 -0500 Subject: [PATCH 2/2] Keep screen recording audio aligned to capture timestamps Position PulseAudio and WASAPI loopback samples relative to recording start, dropping stale audio and preserving gaps without delaying subsequent sound. Request smaller PulseAudio fragments and tolerate bounded timestamp jitter to avoid crackling at packet boundaries. Add regression coverage for both capture clocks, pre-recording buffers, late packets, gaps, queue overflow, reset, and sample continuity. --- src/CaptureAudioBuffer.h | 121 ++++++++++++++++++++++++++++++++++ src/ScreenCaptureReader.cpp | 108 +++++++++++++++++------------- tests/ScreenCaptureReader.cpp | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 47 deletions(-) create mode 100644 src/CaptureAudioBuffer.h diff --git a/src/CaptureAudioBuffer.h b/src/CaptureAudioBuffer.h new file mode 100644 index 000000000..188acd01c --- /dev/null +++ b/src/CaptureAudioBuffer.h @@ -0,0 +1,121 @@ +/** @file @brief Timestamp-positioned audio for live capture. */ +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// SPDX-License-Identifier: LGPL-3.0-or-later + +#ifndef OPENSHOT_CAPTURE_AUDIO_BUFFER_H +#define OPENSHOT_CAPTURE_AUDIO_BUFFER_H + +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +namespace openshot { +// Positions are sample frames relative to recording start. Callers synchronize +// access. Delivery delays and dropped packets must never shift later samples. +class CaptureAudioBuffer { +public: + void Reset(int channels, int64_t capacity) + { + channel_count = channels; + max_samples = capacity; + position = 0; + queued_samples = 0; + timestamp_started = false; + next_timestamp_sample = 0; + blocks.clear(); + } + + void Push(int64_t start, std::vector> samples) + { + if (samples.size() != static_cast(channel_count) || samples.empty()) return; + const int64_t count = samples.front().size(); + for (const auto& channel : samples) { + if (channel.size() != static_cast(count)) return; + } + if (!count || start + count <= position) return; + // Trim samples from before recording start, already-written intervals, + // or a single packet larger than the bounded capture buffer. + const int64_t trim = std::max({0, position - start, count - max_samples}); + for (auto& channel : samples) channel.erase(channel.begin(), channel.begin() + trim); + start += trim; + queued_samples += count - trim; + const auto insertion = std::upper_bound(blocks.begin(), blocks.end(), start, + [](int64_t value, const Block& block) { return value < block.start; }); + blocks.insert(insertion, Block{start, std::move(samples)}); + while (queued_samples > max_samples && !blocks.empty()) { + queued_samples -= blocks.front().samples.front().size(); + blocks.pop_front(); + } + } + + void PushTimestamp(int64_t timestamp, int64_t epoch, AVRational time_base, + int sample_rate, std::vector> samples) + { + if (samples.empty() || samples.front().empty()) return; + int64_t start = av_rescale_q(timestamp - epoch, time_base, AVRational{1, sample_rate}); + // Capture timestamps include clock-estimation jitter. Placing every + // packet independently can insert silence or discard PCM at each seam. + // Keep adjacent packets sample-contiguous within one millisecond of + // their absolute capture position. Compare against the accumulated end, + // not the previous timestamp, so tolerance cannot accumulate into drift. + const int64_t tolerance = std::max(1, sample_rate / 1000); + if (timestamp_started && start >= next_timestamp_sample - tolerance + && start <= next_timestamp_sample + tolerance) { + start = next_timestamp_sample; + } + next_timestamp_sample = start + static_cast(samples.front().size()); + timestamp_started = true; + Push(start, std::move(samples)); + } + + bool Covers(int count) const + { + return std::any_of(blocks.begin(), blocks.end(), [&](const Block& block) { + return block.start + static_cast(block.samples.front().size()) >= position + count; + }); + } + + std::vector> Read(int count) + { + std::vector> result(channel_count, std::vector(count, 0.0f)); + const int64_t end = position + count; + for (const auto& block : blocks) { + const int64_t first = std::max(position, block.start); + const int64_t last = std::min(end, block.start + static_cast(block.samples.front().size())); + if (last <= first) continue; + for (int channel = 0; channel < channel_count; ++channel) { + std::copy_n(block.samples[channel].begin() + (first - block.start), last - first, + result[channel].begin() + (first - position)); + } + } + position = end; + for (auto it = blocks.begin(); it != blocks.end();) { + if (it->start + static_cast(it->samples.front().size()) <= position) { + queued_samples -= it->samples.front().size(); + it = blocks.erase(it); + } else ++it; + } + return result; + } + +private: + struct Block { + int64_t start; + std::vector> samples; + }; + std::deque blocks; + int channel_count = 0; + int64_t max_samples = 0; + int64_t position = 0; + int64_t queued_samples = 0; + bool timestamp_started = false; + int64_t next_timestamp_sample = 0; +}; +} +#endif diff --git a/src/ScreenCaptureReader.cpp b/src/ScreenCaptureReader.cpp index e415c04ef..2ef556b5d 100644 --- a/src/ScreenCaptureReader.cpp +++ b/src/ScreenCaptureReader.cpp @@ -11,6 +11,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later #include "ScreenCaptureReader.h" +#include "CaptureAudioBuffer.h" #include #include @@ -31,6 +32,7 @@ extern "C" { #include #include + #include } #if defined(_WIN32) @@ -107,6 +109,12 @@ class ScreenCaptureReader::SystemAudioCapture format_context->interrupt_callback.opaque = &close_requested; AVDictionary* options = nullptr; + av_dict_set(&options, "wallclock", "1", 0); + // Pulse defaults to server-selected buffering, which can deliver large + // batches too late for a live video frame. Request 20 ms of default + // 16-bit PCM; timestamps still determine placement, not this buffer size. + av_dict_set_int(&options, "fragment_size", + static_cast(settings.audio_sample_rate) * settings.audio_channels * 2 / 50, 0); av_dict_set(&options, "sample_rate", std::to_string(settings.audio_sample_rate).c_str(), 0); av_dict_set(&options, "channels", std::to_string(settings.audio_channels).c_str(), 0); const std::string device = settings.audio_device.empty() ? "@DEFAULT_MONITOR@" : settings.audio_device; @@ -130,6 +138,7 @@ class ScreenCaptureReader::SystemAudioCapture AVStream* stream = format_context->streams[audio_stream]; const AVCodec* codec = avcodec_find_decoder(stream->codecpar->codec_id); codec_context = codec ? ffmpeg_get_codec_context(stream, codec) : nullptr; + if (codec_context) codec_context->pkt_timebase = stream->time_base; if (!codec_context || avcodec_open2(codec_context, codec, nullptr) < 0) { throw InvalidCodec("Unable to open system audio capture decoder.", device); } @@ -140,7 +149,7 @@ class ScreenCaptureReader::SystemAudioCapture } settings.audio_sample_rate = codec_context->sample_rate > 0 ? codec_context->sample_rate : settings.audio_sample_rate; - channels.assign(settings.audio_channels, {}); + Reset(); close_requested = false; worker = std::thread(&SystemAudioCapture::CaptureLoop, this); } @@ -169,39 +178,30 @@ class ScreenCaptureReader::SystemAudioCapture if (!frame || sample_count <= 0) return; std::unique_lock lock(queue_mutex); - // The capture backend commonly delivers its first buffer later than the - // first video frame. Writing silence after the old 100 ms timeout moved - // every subsequently-delivered sample later on the recording timeline. - // Allow the first frame to establish the audio epoch before falling back - // to the short steady-state wait used for later frames. + // Wait for capture delivery, but keep its original sample positions. A + // timed-out interval becomes silence; late packets cannot move it forward. const auto wait_time = timeline_started ? std::chrono::milliseconds(100) : std::chrono::milliseconds(3000); ready.wait_for(lock, wait_time, [this, sample_count]() { - return close_requested || (!channels.empty() && static_cast(channels[0].size()) >= sample_count); + return close_requested || audio_buffer.Covers(sample_count); }); - if (!channels.empty() && static_cast(channels[0].size()) >= sample_count) { - timeline_started = true; - } + timeline_started = true; + auto samples = audio_buffer.Read(sample_count); frame->SampleRate(settings.audio_sample_rate); frame->ChannelsLayout(settings.audio_channels == 1 ? LAYOUT_MONO : LAYOUT_STEREO); for (int channel = 0; channel < settings.audio_channels; ++channel) { - std::vector samples(sample_count, 0.0f); - if (channel < static_cast(channels.size())) { - const int available = std::min(sample_count, static_cast(channels[channel].size())); - for (int index = 0; index < available; ++index) { - samples[index] = channels[channel].front(); - channels[channel].pop_front(); - } - } - frame->AddAudio(true, channel, 0, samples.data(), sample_count, 1.0f); + frame->AddAudio(true, channel, 0, samples[channel].data(), sample_count, 1.0f); } } void Reset() { std::lock_guard lock(queue_mutex); - for (auto& channel : channels) channel.clear(); + // PulseAudio wallclock PTS uses av_gettime(), corrected for input latency. + // Reset the epoch too, so packets buffered before recording are discarded. + epoch_us = av_gettime(); + audio_buffer.Reset(settings.audio_channels, static_cast(settings.audio_sample_rate) * 10); last_output_frame = 0; timeline_started = false; } @@ -260,14 +260,25 @@ class ScreenCaptureReader::SystemAudioCapture av_packet_unref(packet); if (send_result < 0) continue; while (avcodec_receive_frame(codec_context, decoded_frame) == 0) { - std::lock_guard lock(queue_mutex); + const int64_t timestamp = decoded_frame->best_effort_timestamp != AV_NOPTS_VALUE + ? decoded_frame->best_effort_timestamp : decoded_frame->pts; + if (timestamp == AV_NOPTS_VALUE) { + // A packet with unknown capture time cannot be aligned safely. + av_frame_unref(decoded_frame); + continue; + } + const int64_t timestamp_us = av_rescale_q(timestamp, + format_context->streams[audio_stream]->time_base, AVRational{1, AV_TIME_BASE}); + std::vector> samples(settings.audio_channels, + std::vector(decoded_frame->nb_samples)); for (int channel = 0; channel < settings.audio_channels; ++channel) { - const size_t max_samples = static_cast(settings.audio_sample_rate) * 10; for (int sample = 0; sample < decoded_frame->nb_samples; ++sample) { - if (channels[channel].size() >= max_samples) channels[channel].pop_front(); - channels[channel].push_back(SampleAt(decoded_frame, channel, sample)); + samples[channel][sample] = SampleAt(decoded_frame, channel, sample); } } + std::lock_guard lock(queue_mutex); + audio_buffer.PushTimestamp(timestamp_us, epoch_us, AVRational{1, AV_TIME_BASE}, + settings.audio_sample_rate, std::move(samples)); av_frame_unref(decoded_frame); ready.notify_all(); } @@ -284,7 +295,8 @@ class ScreenCaptureReader::SystemAudioCapture std::thread worker; std::mutex queue_mutex; std::condition_variable ready; - std::vector> channels; + CaptureAudioBuffer audio_buffer; + int64_t epoch_us = 0; int64_t last_output_frame = 0; bool timeline_started = false; }; @@ -329,35 +341,30 @@ class ScreenCaptureReader::SystemAudioCapture last_output_frame = std::max(last_output_frame, number); if (!frame || sample_count <= 0) return; std::unique_lock lock(queue_mutex); - // WASAPI can take longer than one video-frame interval to make its first - // loopback packet available. Keep that startup latency out of the encoded - // media timeline by waiting for the first complete frame of audio. const auto wait_time = timeline_started ? std::chrono::milliseconds(100) : std::chrono::milliseconds(3000); ready.wait_for(lock, wait_time, [this, sample_count]() { - return close_requested || (!channels.empty() && static_cast(channels[0].size()) >= sample_count); + return close_requested || audio_buffer.Covers(sample_count); }); - if (!channels.empty() && static_cast(channels[0].size()) >= sample_count) { - timeline_started = true; - } + timeline_started = true; + auto samples = audio_buffer.Read(sample_count); frame->SampleRate(sample_rate); frame->ChannelsLayout(channel_count == 1 ? LAYOUT_MONO : LAYOUT_STEREO); for (int channel = 0; channel < channel_count; ++channel) { - std::vector samples(sample_count, 0.0f); - const int available = std::min(sample_count, static_cast(channels[channel].size())); - for (int index = 0; index < available; ++index) { - samples[index] = channels[channel].front(); - channels[channel].pop_front(); - } - frame->AddAudio(true, channel, 0, samples.data(), sample_count, 1.0f); + frame->AddAudio(true, channel, 0, samples[channel].data(), sample_count, 1.0f); } } void Reset() { std::lock_guard lock(queue_mutex); - for (auto& channel : channels) channel.clear(); + // WASAPI returns QPC timestamps already converted to 100 ns units. + LARGE_INTEGER counter, frequency; + QueryPerformanceCounter(&counter); + QueryPerformanceFrequency(&frequency); + epoch_100ns = av_rescale(counter.QuadPart, 10000000, frequency.QuadPart); + audio_buffer.Reset(channel_count, static_cast(sample_rate) * 10); last_output_frame = 0; timeline_started = false; } @@ -415,7 +422,7 @@ class ScreenCaptureReader::SystemAudioCapture sample_rate = static_cast(format->nSamplesPerSec); channel_count = std::max(1, std::min(2, static_cast(format->nChannels))); - channels.assign(channel_count, {}); + Reset(); bool floating_point = format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT; if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE && format->cbSize >= 22) { const auto* extensible = reinterpret_cast(format); @@ -437,9 +444,10 @@ class ScreenCaptureReader::SystemAudioCapture BYTE* data = nullptr; UINT32 frames = 0; DWORD flags = 0; - if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr))) break; + UINT64 timestamp_100ns = 0; + if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, ×tamp_100ns))) break; { - std::lock_guard lock(queue_mutex); + std::vector> samples(channel_count, std::vector(frames)); for (UINT32 frame_index = 0; frame_index < frames; ++frame_index) { for (int channel = 0; channel < channel_count; ++channel) { float sample = 0.0f; @@ -453,11 +461,16 @@ class ScreenCaptureReader::SystemAudioCapture sample = static_cast(reinterpret_cast(data)[offset] / 2147483648.0); } } - const size_t max_samples = static_cast(sample_rate) * 10; - if (channels[channel].size() >= max_samples) channels[channel].pop_front(); - channels[channel].push_back(sample); + samples[channel][frame_index] = sample; } } + // Preserve timestamp gaps (including silence/discontinuities). Never + // invent a new epoch from packet arrival time after a delayed read. + if (!(flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR)) { + std::lock_guard lock(queue_mutex); + audio_buffer.PushTimestamp(static_cast(timestamp_100ns), epoch_100ns, + AVRational{1, 10000000}, sample_rate, std::move(samples)); + } } capture->ReleaseBuffer(frames); ready.notify_all(); @@ -474,7 +487,8 @@ class ScreenCaptureReader::SystemAudioCapture std::thread worker; std::mutex queue_mutex; std::condition_variable ready; - std::vector> channels; + CaptureAudioBuffer audio_buffer; + int64_t epoch_100ns = 0; int64_t last_output_frame = 0; bool timeline_started = false; int sample_rate = 48000; diff --git a/tests/ScreenCaptureReader.cpp b/tests/ScreenCaptureReader.cpp index b0a50316e..13455e14e 100644 --- a/tests/ScreenCaptureReader.cpp +++ b/tests/ScreenCaptureReader.cpp @@ -13,6 +13,7 @@ #include "openshot_catch.h" #include "Exceptions.h" +#include "CaptureAudioBuffer.h" #include "ScreenCaptureReader.h" #include "WaylandBufferUtilities.h" @@ -23,6 +24,121 @@ using namespace openshot; +TEST_CASE("PulseAudio and WASAPI clocks align to the same recording samples", + "[libopenshot][screencapturereader][audio][sync]") +{ + for (int ticks_per_second : {1000000, 10000000}) { + for (int rate : {44100, 48000}) { + CaptureAudioBuffer buffer; + buffer.Reset(2, rate * 10); + const int64_t epoch = int64_t{123456789} * ticks_per_second; + // Device delivers a packet spanning recording start, followed by a + // gap and a new sound. Arrival time must not define either position. + buffer.PushTimestamp(epoch - ticks_per_second / 10, epoch, {1, ticks_per_second}, + rate, {std::vector(rate / 5, 1), std::vector(rate / 5, 1)}); + buffer.PushTimestamp(epoch + ticks_per_second / 5, epoch, {1, ticks_per_second}, + rate, {std::vector(rate / 10, 2), std::vector(rate / 10, 2)}); + CHECK(buffer.Read(rate / 10)[0] == std::vector(rate / 10, 1)); + CHECK(buffer.Read(rate / 10)[0] == std::vector(rate / 10, 0)); + CHECK(buffer.Read(rate / 10)[0] == std::vector(rate / 10, 2)); + CHECK(buffer.Read(rate / 10)[0] == std::vector(rate / 10, 0)); + } + } +} + +TEST_CASE("Capture timestamp jitter does not cut or pad adjacent PCM packets", + "[libopenshot][screencapturereader][audio][sync]") +{ + for (int ticks_per_second : {1000000, 10000000}) { + CaptureAudioBuffer buffer; + buffer.Reset(1, 480000); + const int64_t epoch = int64_t{123456789} * ticks_per_second; + for (int packet = 0; packet < 200; ++packet) { + std::vector samples(960); + for (int i = 0; i < 960; ++i) samples[i] = float(packet * 960 + i + 1); + const int jitter = packet == 0 ? 0 : (packet % 13) - 6; + const int64_t timestamp = epoch + av_rescale(packet * 960 + jitter, ticks_per_second, 48000); + buffer.PushTimestamp(timestamp, epoch, {1, ticks_per_second}, 48000, {samples}); + // Consume every packet: continuity must survive an empty queue too. + CHECK(buffer.Read(960)[0] == samples); + } + // A genuine 20 ms gap is retained, rather than appended to prior audio. + buffer.PushTimestamp(epoch + int64_t{ticks_per_second} * 402 / 100, epoch, + {1, ticks_per_second}, 48000, {{7.0f}}); + CHECK(buffer.Read(960)[0] == std::vector(960, 0.0f)); + CHECK(buffer.Read(1)[0] == std::vector{7.0f}); + buffer.Reset(1, 480000); + buffer.PushTimestamp(epoch, epoch, {1, ticks_per_second}, 48000, {{9.0f}}); + CHECK(buffer.Read(1)[0] == std::vector{9.0f}); + } +} + +TEST_CASE("System audio buffered before recording does not delay the recorded stop", + "[libopenshot][screencapturereader][audio][sync]") +{ + // 1 kHz makes sample indices milliseconds. The source delivers two seconds + // of pre-recording audio followed by a tone at 200-299 ms of the recording. + CaptureAudioBuffer buffer; + buffer.Reset(2, 10000); + std::vector captured(2500, 0.0f); + std::fill(captured.begin() + 2200, captured.begin() + 2300, 1.0f); + buffer.Push(-2000, {captured, captured}); + const auto audio = buffer.Read(500); + REQUIRE(audio.size() == 2); + for (const auto& channel : audio) { + CHECK(std::all_of(channel.begin(), channel.begin() + 200, [](float x) { return x == 0.0f; })); + CHECK(std::all_of(channel.begin() + 200, channel.begin() + 300, [](float x) { return x == 1.0f; })); + CHECK(std::all_of(channel.begin() + 300, channel.end(), [](float x) { return x == 0.0f; })); + } +} + +TEST_CASE("Late system audio never shifts samples beyond an already written gap", + "[libopenshot][screencapturereader][audio][sync]") +{ + CaptureAudioBuffer buffer; + buffer.Reset(1, 1000); + CHECK_FALSE(buffer.Covers(100)); + CHECK(buffer.Read(100)[0] == std::vector(100, 0.0f)); + // First packet arrives after a timeout. Only its unwritten half is usable. + std::vector packet(200, 0.0f); + packet[150] = 1.0f; + buffer.Push(0, {packet}); + CHECK(buffer.Covers(100)); + const auto audio = buffer.Read(100); + CHECK(audio[0][50] == 1.0f); + buffer.Push(0, {std::vector(100, 1.0f)}); + CHECK(buffer.Read(100)[0] == std::vector(100, 0.0f)); +} + +TEST_CASE("System audio gaps and queue overflow preserve sample positions", + "[libopenshot][screencapturereader][audio][sync]") +{ + CaptureAudioBuffer buffer; + buffer.Reset(1, 100); + buffer.Push(0, {std::vector(100, 1.0f)}); + buffer.Push(200, {std::vector(100, 2.0f)}); + const auto audio = buffer.Read(300); + CHECK(std::all_of(audio[0].begin(), audio[0].begin() + 200, [](float x) { return x == 0.0f; })); + CHECK(std::all_of(audio[0].begin() + 200, audio[0].end(), [](float x) { return x == 2.0f; })); + buffer.Reset(1, 100); + CHECK_FALSE(buffer.Covers(1)); + buffer.Push(-20, {std::vector(10, 1.0f)}); + buffer.Push(50, {{3.0f}}); + CHECK(buffer.Read(51)[0][50] == 3.0f); +} + +TEST_CASE("System audio packet delivery boundaries do not alter the timeline", + "[libopenshot][screencapturereader][audio][sync]") +{ + CaptureAudioBuffer buffer; + buffer.Reset(1, 100); + buffer.Push(4, {{5, 6, 7, 8}}); + buffer.Push(0, {{1, 2, 3, 4}}); + CHECK(buffer.Read(3)[0] == std::vector{1, 2, 3}); + CHECK(buffer.Read(3)[0] == std::vector{4, 5, 6}); + CHECK(buffer.Read(3)[0] == std::vector{7, 8, 0}); +} + TEST_CASE("Wayland packed video layout clamps unsafe PipeWire metadata", "[libopenshot][screencapturereader][wayland]") {