2026-10-04 22:23:51 -05:00
|
|
|
/**
|
|
|
|
|
* @file
|
|
|
|
|
* @brief Unit tests for audio seek and transport changes
|
|
|
|
|
* @author OpenShot Studios, LLC
|
|
|
|
|
*
|
|
|
|
|
* @ref License
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Copyright (c) 2026 OpenShot Studios, LLC
|
2026-10-04 20:07:34 -05:00
|
|
|
//
|
2026-10-04 17:16:15 -05:00
|
|
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
|
|
|
#include "openshot_catch.h"
|
|
|
|
|
#include "AudioReaderSource.h"
|
|
|
|
|
#include "DummyReader.h"
|
|
|
|
|
#include "Frame.h"
|
2026-10-04 17:41:30 -05:00
|
|
|
#include "Timeline.h"
|
|
|
|
|
#include "CacheBase.h"
|
2026-10-04 17:16:15 -05:00
|
|
|
#include <condition_variable>
|
|
|
|
|
#include <future>
|
|
|
|
|
#include <mutex>
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
class LevelReader : public openshot::DummyReader {
|
|
|
|
|
public:
|
|
|
|
|
std::mutex mutex;
|
|
|
|
|
std::condition_variable condition;
|
|
|
|
|
bool block = false, entered = false, released = false;
|
|
|
|
|
LevelReader() : DummyReader(openshot::Fraction(30, 1), 16, 16, 48000, 1, 1) {
|
|
|
|
|
info.has_audio = true;
|
|
|
|
|
}
|
2026-10-04 20:11:52 -05:00
|
|
|
void Release() {
|
|
|
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
|
|
|
released = true;
|
|
|
|
|
condition.notify_all();
|
|
|
|
|
}
|
2026-10-04 17:16:15 -05:00
|
|
|
std::shared_ptr<openshot::Frame> GetFrame(int64_t number) override {
|
|
|
|
|
if (block) {
|
|
|
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
|
|
|
entered = true;
|
|
|
|
|
condition.notify_all();
|
|
|
|
|
condition.wait(lock, [this] { return released; });
|
|
|
|
|
}
|
|
|
|
|
auto frame = std::make_shared<openshot::Frame>(number, 8, 1);
|
|
|
|
|
const float level = number == 1 ? 0.25f : -0.5f;
|
|
|
|
|
float samples[8];
|
|
|
|
|
std::fill(samples, samples + 8, level);
|
|
|
|
|
frame->AddAudio(true, 0, 0, samples, 8, 1.0f);
|
|
|
|
|
return frame;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("AudioReaderSource replaces only the requested buffer region", "[audio-source]") {
|
|
|
|
|
LevelReader reader;
|
|
|
|
|
openshot::AudioReaderSource source(&reader, 1);
|
|
|
|
|
juce::AudioBuffer<float> buffer(2, 12);
|
|
|
|
|
for (int channel = 0; channel < 2; ++channel)
|
|
|
|
|
for (int sample = 0; sample < 12; ++sample)
|
|
|
|
|
buffer.setSample(channel, sample, 0.75f);
|
|
|
|
|
source.getNextAudioBlock({&buffer, 2, 4});
|
|
|
|
|
CHECK(buffer.getSample(0, 2) == Approx(0.25f));
|
|
|
|
|
CHECK(buffer.getSample(1, 2) == Approx(0.0f));
|
|
|
|
|
CHECK(buffer.getSample(0, 0) == Approx(0.75f));
|
|
|
|
|
CHECK(buffer.getSample(1, 8) == Approx(0.75f));
|
|
|
|
|
source.setSpeed(0);
|
|
|
|
|
source.getNextAudioBlock({&buffer, 2, 4});
|
|
|
|
|
CHECK(buffer.getSample(0, 2) == Approx(0.0f));
|
|
|
|
|
CHECK(buffer.getSample(0, 0) == Approx(0.75f));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("AudioReaderSource discards audio decoded across a newer seek", "[audio-source][threading]") {
|
|
|
|
|
LevelReader reader;
|
|
|
|
|
reader.block = true;
|
|
|
|
|
openshot::AudioReaderSource source(&reader, 1);
|
|
|
|
|
juce::AudioBuffer<float> buffer(1, 4);
|
|
|
|
|
buffer.clear();
|
|
|
|
|
auto callback = std::async(std::launch::async, [&] { source.getNextAudioBlock({&buffer, 0, 4}); });
|
|
|
|
|
bool entered;
|
|
|
|
|
{
|
|
|
|
|
std::unique_lock<std::mutex> lock(reader.mutex);
|
|
|
|
|
entered = reader.condition.wait_for(lock, std::chrono::seconds(2), [&] { return reader.entered; });
|
|
|
|
|
}
|
|
|
|
|
if (!entered) {
|
2026-10-04 20:11:52 -05:00
|
|
|
reader.Release();
|
2026-10-04 17:16:15 -05:00
|
|
|
callback.get();
|
|
|
|
|
REQUIRE(entered);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
auto seek = std::async(std::launch::async, [&] { source.Seek(2); source.Seek(3); });
|
|
|
|
|
const bool seek_completed = seek.wait_for(std::chrono::milliseconds(100)) == std::future_status::ready;
|
2026-10-04 20:11:52 -05:00
|
|
|
reader.Release();
|
2026-10-04 17:16:15 -05:00
|
|
|
callback.get();
|
|
|
|
|
seek.get();
|
|
|
|
|
CHECK(seek_completed);
|
|
|
|
|
CHECK(buffer.getSample(0, 0) == Approx(0.0f));
|
|
|
|
|
source.getNextAudioBlock({&buffer, 0, 4});
|
|
|
|
|
CHECK(buffer.getSample(0, 0) == Approx(-0.5f));
|
|
|
|
|
REQUIRE(source.getFrame());
|
|
|
|
|
CHECK(source.getFrame()->number == 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("AudioReaderSource pause discards a whole multi-frame block without consuming it", "[audio-source][threading]") {
|
|
|
|
|
juce::AudioBuffer<float> buffer(1, 12);
|
|
|
|
|
// A decoder barrier on the second frame allows the first eight samples to
|
|
|
|
|
// be copied before pause invalidates the entire callback.
|
|
|
|
|
class SecondFrameReader : public LevelReader {
|
|
|
|
|
public:
|
|
|
|
|
std::shared_ptr<openshot::Frame> GetFrame(int64_t number) override {
|
|
|
|
|
block = number == 2;
|
|
|
|
|
return LevelReader::GetFrame(number);
|
|
|
|
|
}
|
|
|
|
|
} slow;
|
|
|
|
|
openshot::AudioReaderSource paused(&slow, 1);
|
|
|
|
|
auto callback = std::async(std::launch::async, [&] { paused.getNextAudioBlock({&buffer, 0, 12}); });
|
|
|
|
|
bool entered;
|
|
|
|
|
{
|
|
|
|
|
std::unique_lock<std::mutex> lock(slow.mutex);
|
|
|
|
|
entered = slow.condition.wait_for(lock, std::chrono::seconds(2), [&] { return slow.entered; });
|
|
|
|
|
}
|
|
|
|
|
paused.setSpeed(0);
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard<std::mutex> lock(slow.mutex);
|
|
|
|
|
slow.released = true;
|
|
|
|
|
slow.condition.notify_all();
|
|
|
|
|
}
|
|
|
|
|
callback.get();
|
|
|
|
|
REQUIRE(entered);
|
|
|
|
|
for (int sample = 0; sample < 12; ++sample)
|
|
|
|
|
CHECK(buffer.getSample(0, sample) == Approx(0.0f));
|
|
|
|
|
paused.setSpeed(1);
|
|
|
|
|
paused.getNextAudioBlock({&buffer, 0, 12});
|
|
|
|
|
for (int sample = 0; sample < 12; ++sample)
|
|
|
|
|
CHECK(buffer.getSample(0, sample) == Approx(sample < 8 ? 0.25f : -0.5f));
|
|
|
|
|
// Repeated speed assignment must not invalidate otherwise valid output.
|
|
|
|
|
paused.setSpeed(1);
|
|
|
|
|
paused.getNextAudioBlock({&buffer, 0, 4});
|
|
|
|
|
CHECK(buffer.getSample(0, 0) == Approx(-0.5f));
|
|
|
|
|
}
|
2026-10-04 17:41:30 -05:00
|
|
|
|
|
|
|
|
TEST_CASE("cached Timeline audio miss holds shared gate until player resync", "[audio-source][preroll]") {
|
|
|
|
|
class CacheGate : public openshot::VideoCacheThread {
|
|
|
|
|
public:
|
|
|
|
|
void readyAt(int64_t n) {
|
|
|
|
|
std::lock_guard<std::mutex> lock(seek_state_mutex);
|
|
|
|
|
cache_audio_only.store(true);
|
|
|
|
|
processed_generation.store(request_generation.load());
|
|
|
|
|
readiness_capacity = 20;
|
|
|
|
|
readiness_physical_capacity = 21;
|
|
|
|
|
readiness_minimum_step = 1;
|
|
|
|
|
min_frames_ahead.store(1);
|
|
|
|
|
requested_display_frame.store(n);
|
|
|
|
|
updateReadiness(reader->GetCache(), n, 1);
|
|
|
|
|
}
|
|
|
|
|
} gate;
|
|
|
|
|
class CachedTimeline : public openshot::Timeline {
|
|
|
|
|
public:
|
|
|
|
|
int calls = 0;
|
|
|
|
|
CachedTimeline() : Timeline(16,16,openshot::Fraction(30,1),48000,1,openshot::LAYOUT_MONO) {}
|
|
|
|
|
std::shared_ptr<openshot::Frame> GetFrame(int64_t) override { ++calls; return {}; }
|
|
|
|
|
} timeline;
|
|
|
|
|
gate.Reader(&timeline);
|
|
|
|
|
gate.setSpeed(1);
|
|
|
|
|
auto add = [&](int64_t n, float level) {
|
|
|
|
|
auto frame = std::make_shared<openshot::Frame>(n, 8, 1);
|
|
|
|
|
float samples[8];
|
|
|
|
|
std::fill(samples, samples + 8, level);
|
|
|
|
|
frame->AddAudio(true, 0, 0, samples, 8, 1.0f);
|
|
|
|
|
timeline.GetCache()->Add(frame);
|
|
|
|
|
};
|
|
|
|
|
add(1, 0.1f); add(2, 0.2f); // Startup has current + minimal next frame.
|
|
|
|
|
gate.readyAt(1);
|
|
|
|
|
openshot::AudioReaderSource source(&timeline, 1);
|
|
|
|
|
source.setVideoCache(&gate);
|
|
|
|
|
juce::AudioBuffer<float> buffer(1,24); // Spans a third frame not cached yet.
|
|
|
|
|
source.getNextAudioBlock({&buffer,0,24});
|
|
|
|
|
CHECK(buffer.getSample(0,0) == Approx(0.1f));
|
|
|
|
|
CHECK(buffer.getSample(0,8) == Approx(0.2f));
|
|
|
|
|
CHECK(buffer.getSample(0,16) == Approx(0.0f));
|
|
|
|
|
CHECK(timeline.calls == 0); // No synchronous Timeline decoding.
|
|
|
|
|
CHECK_FALSE(gate.isReady());
|
|
|
|
|
add(3, 0.3f); add(4, 0.4f);
|
|
|
|
|
gate.readyAt(3);
|
|
|
|
|
CHECK_FALSE(gate.isReady()); // Filling cache cannot bypass the handshake.
|
|
|
|
|
source.Seek(3); // Same operation PlayerPrivate performs during shared hold.
|
|
|
|
|
gate.AcknowledgeAudioCacheMiss();
|
|
|
|
|
CHECK(gate.isReady());
|
|
|
|
|
source.getNextAudioBlock({&buffer,0,8});
|
|
|
|
|
CHECK(buffer.getSample(0,0) == Approx(0.3f));
|
|
|
|
|
REQUIRE(source.getFrame());
|
|
|
|
|
CHECK(source.getFrame()->number == 3);
|
|
|
|
|
CHECK(timeline.calls == 0);
|
|
|
|
|
}
|