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 e52a92569..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; @@ -869,22 +883,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 +1195,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; 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]") {