From fc897ec52294eaa2db326df3db3b8846b33cd880 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 2 May 2026 23:20:08 -0500 Subject: [PATCH] Add Beat Sync audio-reactive flash effect -Generates a full-frame color layer from low/high colors, shaped by audio energy and a response curve, for beat-synced compositing. - Really useful effect for generating, shaping, and outputting a flashing white or black image (to be composited into another clip) --- src/CMakeLists.txt | 1 + src/EffectInfo.cpp | 4 + src/Effects.h | 1 + src/effects/BeatSync.cpp | 288 +++++++++++++++++++++++ src/effects/BeatSync.h | 61 +++++ tests/BeatSync.cpp | 490 +++++++++++++++++++++++++++++++++++++++ tests/Benchmark.cpp | 12 + tests/CMakeLists.txt | 1 + 8 files changed, 858 insertions(+) create mode 100644 src/effects/BeatSync.cpp create mode 100644 src/effects/BeatSync.h create mode 100644 tests/BeatSync.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a8f1fa0f..b7958e7b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -111,6 +111,7 @@ set(OPENSHOT_CV_SOURCES set(EFFECTS_SOURCES effects/AudioVisualization.cpp effects/Bars.cpp + effects/BeatSync.cpp effects/Blur.cpp effects/Brightness.cpp effects/Caption.cpp diff --git a/src/EffectInfo.cpp b/src/EffectInfo.cpp index f0111939..c41261ed 100644 --- a/src/EffectInfo.cpp +++ b/src/EffectInfo.cpp @@ -35,6 +35,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) { if (effect_type == "Bars") return new Bars(); + if (effect_type == "BeatSync") + return new BeatSync(); + if (effect_type == "Blur") return new Blur(); @@ -158,6 +161,7 @@ Json::Value EffectInfo::JsonValue() { root.append(AnalogTape().JsonInfo()); root.append(AudioVisualization().JsonInfo()); root.append(Bars().JsonInfo()); + root.append(BeatSync().JsonInfo()); root.append(Blur().JsonInfo()); root.append(Brightness().JsonInfo()); root.append(Caption().JsonInfo()); diff --git a/src/Effects.h b/src/Effects.h index 02d8cda5..3b5bc729 100644 --- a/src/Effects.h +++ b/src/Effects.h @@ -17,6 +17,7 @@ #include "effects/AnalogTape.h" #include "effects/AudioVisualization.h" #include "effects/Bars.h" +#include "effects/BeatSync.h" #include "effects/Blur.h" #include "effects/Brightness.h" #include "effects/Caption.h" diff --git a/src/effects/BeatSync.cpp b/src/effects/BeatSync.cpp new file mode 100644 index 00000000..4df5afd5 --- /dev/null +++ b/src/effects/BeatSync.cpp @@ -0,0 +1,288 @@ +/** + * @file + * @brief Source file for BeatSync effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "BeatSync.h" +#include "Exceptions.h" +#include "Timeline.h" + +#include +#include +#include + +#include + +#include +#include + +using namespace openshot; + +namespace { + constexpr double PI = 3.14159265358979323846; + + inline float clampf(float v, float lo, float hi) { + return v < lo ? lo : (v > hi ? hi : v); + } + + inline int clampi(int v, int lo, int hi) { + return v < lo ? lo : (v > hi ? hi : v); + } + + inline int blend_channel(int low, int high, int inv, int blend) { + return (clampi(low, 0, 255) * inv + clampi(high, 0, 255) * blend) >> 8; + } + + // Logarithmic normalized [0,1] → Hz mapping (20–20000 Hz), matching AudioVisualization + float normalized_frequency_to_hz(float value) { + const float min_hz = 20.0f; + const float max_hz = 20000.0f; + const float n = clampf(value, 0.0f, 1.0f); + return min_hz * std::pow(max_hz / min_hz, n); + } + + float hz_to_normalized_frequency(float hz) { + if (hz >= 0.0f && hz <= 1.0f) + return hz; + const float min_hz = 20.0f; + const float max_hz = 20000.0f; + hz = clampf(hz, min_hz, max_hz); + return clampf(std::log(hz / min_hz) / std::log(max_hz / min_hz), 0.0f, 1.0f); + } + + // Time-domain bandpass energy: difference of two first-order IIR low-pass filters. + // Returns linear 0-1 based on RMS + peak of the filtered signal, scaled by gain. + // This mirrors the reactive_level() approach in AudioVisualization but adds band filtering. + float band_energy(const std::shared_ptr& frame, float low_hz, float high_hz, float gain) { + const int samples = frame->GetAudioSamplesCount(); + const int channels = frame->GetAudioChannelsCount(); + const int sample_rate = std::max(1, frame->SampleRate()); + if (samples <= 0 || channels <= 0) + return 0.0f; + + const float nyquist = sample_rate * 0.5f; + low_hz = clampf(low_hz, 0.0f, nyquist - 1.0f); + high_hz = clampf(high_hz, low_hz + 1.0f, nyquist); + + // First-order IIR coefficients: alpha = dt / (tau + dt), tau = 1/(2*pi*fc) + const float dt = 1.0f / sample_rate; + const float alpha_hi = dt / (dt + 1.0f / (2.0f * (float)PI * high_hz)); + const float alpha_lo = low_hz > 1.0f + ? dt / (dt + 1.0f / (2.0f * (float)PI * low_hz)) + : 0.0f; // no high-pass when low_hz is at/near DC + + auto* buffer = frame->GetAudioSampleBuffer(); + std::vector ch(channels); + for (int c = 0; c < channels; ++c) + ch[c] = buffer->getReadPointer(c); + + float lp_hi = 0.0f, lp_lo = 0.0f; + double sum_sq = 0.0; + float peak = 0.0f; + for (int s = 0; s < samples; ++s) { + float x = 0.0f; + for (int c = 0; c < channels; ++c) + x += ch[c][s]; + x /= channels; + + lp_hi += alpha_hi * (x - lp_hi); // low-pass at high_hz + lp_lo += alpha_lo * (x - lp_lo); // low-pass at low_hz + const float filtered = lp_hi - lp_lo; // bandpass = difference + + const float abs_v = std::fabs(filtered); + sum_sq += abs_v * abs_v; + peak = std::max(peak, abs_v); + } + + const float rms = std::sqrt((float)(sum_sq / samples)); + // Combine RMS and peak with the same weighting reactive_level() uses + const float combined = std::max(rms * 3.6f, peak * 1.15f); + return clampf(combined * gain, 0.0f, 1.0f); + } +} + +BeatSync::BeatSync() : + low_color((unsigned char)0, (unsigned char)0, (unsigned char)0, (unsigned char)255), + high_color((unsigned char)255, (unsigned char)255, (unsigned char)255, (unsigned char)255), + intensity(2.0), + threshold(0.1), + attack_ms(10.0), + decay_ms(200.0), + frequency_low(0.0), + frequency_high(1.0), + invert(false), + envelope_(0.0f), + last_frame_(-1) +{ + init_effect_details(); +} + +BeatSync::BeatSync(Color color, Keyframe intensity) : + BeatSync() +{ + this->high_color = color; + this->intensity = intensity; +} + +void BeatSync::init_effect_details() +{ + InitEffectInfo(); + info.class_name = "BeatSync"; + info.name = "Beat Sync"; + info.description = "Generates an audio-reactive color flash layer, synchronized to the beat."; + info.has_audio = false; + info.has_video = true; +} + +std::shared_ptr BeatSync::GetFrame(std::shared_ptr frame, int64_t frame_number) +{ + const std::shared_ptr frame_image = frame->GetImage(); + int width = frame_image ? std::max(1, frame_image->width()) : 1; + int height = frame_image ? std::max(1, frame_image->height()) : 1; + if ((width <= 1 || height <= 1) && ParentTimeline()) { + if (Timeline* timeline = dynamic_cast(ParentTimeline())) { + if (timeline->info.width > 1 && timeline->info.height > 1) { + width = timeline->info.width; + height = timeline->info.height; + } + } + } + + // Reset envelope on seek (discontinuous frame access) + if (last_frame_ >= 0 && std::abs(frame_number - last_frame_) >= 2) + envelope_ = 0.0f; + last_frame_ = frame_number; + + // --- Audio energy for the configured frequency band --- + const float low_hz = normalized_frequency_to_hz(clampf(frequency_low.GetValue(frame_number), 0.0f, 1.0f)); + const float high_hz = std::max(low_hz + 1.0f, + normalized_frequency_to_hz(clampf(frequency_high.GetValue(frame_number), 0.0f, 1.0f))); + const float gain_v = std::max(0.01f, (float)intensity.GetValue(frame_number)); + const float raw_energy = band_energy(frame, low_hz, high_hz, gain_v); + + // --- IIR envelope follower --- + // Derive per-frame time constant from audio context + const int samples = frame->GetAudioSamplesCount(); + const int sample_rate = std::max(1, frame->SampleRate()); + const float frame_sec = (samples > 0) ? (float)samples / sample_rate : 1.0f / 24.0f; + + const float atk = clampf((float)attack_ms.GetValue(frame_number), 1.0f, 5000.0f); + const float dec = clampf((float)decay_ms.GetValue(frame_number), 1.0f, 5000.0f); + const float atk_coef = std::exp(-frame_sec / (atk * 0.001f)); + const float dec_coef = std::exp(-frame_sec / (dec * 0.001f)); + + if (raw_energy > envelope_) + envelope_ = atk_coef * envelope_ + (1.0f - atk_coef) * raw_energy; + else + envelope_ = dec_coef * envelope_ + (1.0f - dec_coef) * raw_energy; + + // --- Apply threshold and response curve --- + const float thr = clampf((float)threshold.GetValue(frame_number), 0.0f, 1.0f); + float energy = envelope_; + if (energy <= thr) { + energy = 0.0f; + } else { + energy = clampf((energy - thr) / std::max(0.001f, 1.0f - thr), 0.0f, 1.0f); + } + if (invert) + energy = 1.0f - energy; + + const float response = clampf(response_curve.Sample(energy, frame_number), 0.0f, 1.0f); + const int blend = clampi(static_cast(response * 256.0f + 0.5f), 0, 256); + const int inv = 256 - blend; + auto out = std::make_shared(width, height, QImage::Format_RGBA8888_Premultiplied); + out->fill(QColor( + blend_channel(low_color.red.GetInt(frame_number), high_color.red.GetInt(frame_number), inv, blend), + blend_channel(low_color.green.GetInt(frame_number), high_color.green.GetInt(frame_number), inv, blend), + blend_channel(low_color.blue.GetInt(frame_number), high_color.blue.GetInt(frame_number), inv, blend), + blend_channel(low_color.alpha.GetInt(frame_number), high_color.alpha.GetInt(frame_number), inv, blend))); + + frame->AddImage(out); + return frame; +} + +std::string BeatSync::Json() const { + return JsonValue().toStyledString(); +} + +Json::Value BeatSync::JsonValue() const { + Json::Value root = EffectBase::JsonValue(); + root["type"] = info.class_name; + root["low_color"] = low_color.JsonValue(); + root["high_color"] = high_color.JsonValue(); + root["intensity"] = intensity.JsonValue(); + root["threshold"] = threshold.JsonValue(); + root["attack_ms"] = attack_ms.JsonValue(); + root["decay_ms"] = decay_ms.JsonValue(); + root["frequency_low"] = frequency_low.JsonValue(); + root["frequency_high"] = frequency_high.JsonValue(); + root["invert"] = invert; + root["response_curve"] = response_curve.JsonValue(); + return root; +} + +void BeatSync::SetJson(const std::string value) { + try { + const Json::Value root = openshot::stringToJson(value); + SetJsonValue(root); + } catch (const std::exception& e) { + throw InvalidJSON("JSON is invalid (missing keys or invalid data types)"); + } +} + +void BeatSync::SetJsonValue(const Json::Value root) { + EffectBase::SetJsonValue(root); + if (!root["low_color"].isNull()) low_color.SetJsonValue(root["low_color"]); + if (!root["high_color"].isNull()) high_color.SetJsonValue(root["high_color"]); + if (!root["color"].isNull()) high_color.SetJsonValue(root["color"]); + if (!root["intensity"].isNull()) intensity.SetJsonValue(root["intensity"]); + if (!root["threshold"].isNull()) threshold.SetJsonValue(root["threshold"]); + if (!root["attack_ms"].isNull()) attack_ms.SetJsonValue(root["attack_ms"]); + if (!root["decay_ms"].isNull()) decay_ms.SetJsonValue(root["decay_ms"]); + if (!root["frequency_low"].isNull()) frequency_low.SetJsonValue(root["frequency_low"]); + if (!root["frequency_high"].isNull()) frequency_high.SetJsonValue(root["frequency_high"]); + if (!root["invert"].isNull()) invert = root["invert"].asBool(); + if (!root["response_curve"].isNull()) response_curve.SetJsonValue(root["response_curve"]); +} + +std::string BeatSync::PropertiesJSON(int64_t requested_frame) const { + Json::Value root = BasePropertiesJSON(requested_frame); + + root["low_color"] = add_property_json("Low Color", 0.0, "color", "", &low_color.red, 0, 255, false, requested_frame); + root["low_color"]["red"] = add_property_json("Red", low_color.red.GetValue(requested_frame), "float", "", &low_color.red, 0, 255, false, requested_frame); + root["low_color"]["green"] = add_property_json("Green", low_color.green.GetValue(requested_frame), "float", "", &low_color.green, 0, 255, false, requested_frame); + root["low_color"]["blue"] = add_property_json("Blue", low_color.blue.GetValue(requested_frame), "float", "", &low_color.blue, 0, 255, false, requested_frame); + root["low_color"]["alpha"] = add_property_json("Alpha", low_color.alpha.GetValue(requested_frame), "float", "", &low_color.alpha, 0, 255, false, requested_frame); + + root["high_color"] = add_property_json("High Color", 0.0, "color", "", &high_color.red, 0, 255, false, requested_frame); + root["high_color"]["red"] = add_property_json("Red", high_color.red.GetValue(requested_frame), "float", "", &high_color.red, 0, 255, false, requested_frame); + root["high_color"]["green"] = add_property_json("Green", high_color.green.GetValue(requested_frame), "float", "", &high_color.green, 0, 255, false, requested_frame); + root["high_color"]["blue"] = add_property_json("Blue", high_color.blue.GetValue(requested_frame), "float", "", &high_color.blue, 0, 255, false, requested_frame); + root["high_color"]["alpha"] = add_property_json("Alpha", high_color.alpha.GetValue(requested_frame), "float", "", &high_color.alpha, 0, 255, false, requested_frame); + + root["intensity"] = add_property_json("Intensity", intensity.GetValue(requested_frame), "float", "", &intensity, 0.0, 10.0, false, requested_frame); + root["threshold"] = add_property_json("Threshold", threshold.GetValue(requested_frame), "float", "", &threshold, 0.0, 1.0, false, requested_frame); + root["attack_ms"] = add_property_json("Attack (ms)", attack_ms.GetValue(requested_frame), "float", "", &attack_ms, 1.0, 500.0, false, requested_frame); + root["decay_ms"] = add_property_json("Decay (ms)", decay_ms.GetValue(requested_frame), "float", "", &decay_ms, 1.0, 2000.0, false, requested_frame); + + root["frequency_low"] = add_property_json("Low Frequency", hz_to_normalized_frequency(frequency_low.GetValue(requested_frame)), "float", "Normalized frequency floor: 0 = 20 Hz, 1 = 20 kHz", &frequency_low, 0.0, 1.0, false, requested_frame); + root["frequency_high"] = add_property_json("High Frequency", hz_to_normalized_frequency(frequency_high.GetValue(requested_frame)), "float", "Normalized frequency ceiling: 0 = 20 Hz, 1 = 20 kHz", &frequency_high, 0.0, 1.0, false, requested_frame); + + root["invert"] = add_property_json("Invert", invert ? 1.0 : 0.0, "int", "", NULL, 0, 1, false, requested_frame); + root["invert"]["choices"].append(add_property_choice_json("No", 0, invert ? 1 : 0)); + root["invert"]["choices"].append(add_property_choice_json("Yes", 1, invert ? 1 : 0)); + + root["response_curve"] = add_property_json("Response Curve", 0.0, "colorgrade_curve", response_curve.Summary(requested_frame), NULL, 0.0, 1.0, false, requested_frame); + root["response_curve"]["curve"] = response_curve.JsonValue(); + root["response_curve"]["channel"] = "all"; + root["response_curve"]["summary"] = response_curve.Summary(requested_frame); + + return root.toStyledString(); +} diff --git a/src/effects/BeatSync.h b/src/effects/BeatSync.h new file mode 100644 index 00000000..cff8569a --- /dev/null +++ b/src/effects/BeatSync.h @@ -0,0 +1,61 @@ +/** + * @file + * @brief Header file for BeatSync effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#ifndef OPENSHOT_BEAT_SYNC_EFFECT_H +#define OPENSHOT_BEAT_SYNC_EFFECT_H + +#include "../AnimatedCurve.h" +#include "../Color.h" +#include "../EffectBase.h" +#include "../Frame.h" +#include "../Json.h" +#include "../KeyFrame.h" + +#include +#include + +namespace openshot +{ + class BeatSync : public EffectBase + { + private: + void init_effect_details(); + float envelope_; // persistent IIR envelope state + int64_t last_frame_; // detects seeks so we can reset envelope + + public: + Color low_color; // generated color at minimum response + Color high_color; // generated color at maximum response + Keyframe intensity; // audio gain multiplier (0.0–10.0) + Keyframe threshold; // energy floor below which nothing happens (0.0–1.0) + Keyframe attack_ms; // envelope rise time in milliseconds + Keyframe decay_ms; // envelope fall time in milliseconds + Keyframe frequency_low; // normalized frequency floor (0=20Hz, 1=20kHz) + Keyframe frequency_high; // normalized frequency ceiling (0=20Hz, 1=20kHz) + bool invert; // flip: silence = max effect, loud = no effect + AnimatedCurve response_curve; // maps normalized audio response to color blend + + BeatSync(); + BeatSync(Color color, Keyframe intensity); + + std::shared_ptr GetFrame(int64_t frame_number) override { return GetFrame(std::make_shared(), frame_number); } + std::shared_ptr GetFrame(std::shared_ptr frame, int64_t frame_number) override; + + std::string Json() const override; + void SetJson(const std::string value) override; + Json::Value JsonValue() const override; + void SetJsonValue(const Json::Value root) override; + std::string PropertiesJSON(int64_t requested_frame) const override; + }; +} + +#endif diff --git a/tests/BeatSync.cpp b/tests/BeatSync.cpp new file mode 100644 index 00000000..a17f717f --- /dev/null +++ b/tests/BeatSync.cpp @@ -0,0 +1,490 @@ +/** + * @file + * @brief Unit tests for openshot::BeatSync effect + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include +#include +#include + +#include +#include + +#include "EffectInfo.h" +#include "Frame.h" +#include "Timeline.h" +#include "effects/BeatSync.h" +#include "openshot_catch.h" + +using namespace openshot; + +namespace { + constexpr double PI = 3.14159265358979323846; + + // Frame with a solid-color image and a sine-wave audio signal at the given amplitude + std::shared_ptr make_frame(int64_t frame_number, QColor fill_color, float audio_amplitude, + int width = 320, int height = 180, float freq_hz = 440.0f) { + const int sample_rate = 48000; + const int samples = 1600; + auto frame = std::make_shared(frame_number, width, height, "#00000000", samples, 2); + auto img = std::make_shared(width, height, QImage::Format_RGBA8888_Premultiplied); + img->fill(fill_color); + frame->AddImage(img); + frame->ResizeAudio(2, samples, sample_rate, LAYOUT_STEREO); + std::vector audio(samples); + for (int i = 0; i < samples; ++i) + audio[i] = audio_amplitude * std::sin(2.0 * PI * freq_hz * i / sample_rate); + frame->AddAudio(true, 0, 0, audio.data(), samples, 1.0f); + frame->AddAudio(true, 1, 0, audio.data(), samples, 1.0f); + return frame; + } + + // Frame with no audio samples at all + std::shared_ptr make_silent_frame(QColor fill_color = Qt::black) { + const int sample_rate = 48000; + const int samples = 1600; + auto frame = std::make_shared(1, 320, 180, "#00000000", samples, 2); + auto img = std::make_shared(320, 180, QImage::Format_RGBA8888_Premultiplied); + img->fill(fill_color); + frame->AddImage(img); + frame->ResizeAudio(2, samples, sample_rate, LAYOUT_STEREO); + // Leave audio zeroed out — silence + std::vector zeros(samples, 0.0f); + frame->AddAudio(true, 0, 0, zeros.data(), samples, 1.0f); + frame->AddAudio(true, 1, 0, zeros.data(), samples, 1.0f); + return frame; + } + + QColor sample_pixel(const std::shared_ptr& frame, int x, int y) { + return frame->GetImage()->pixelColor(x, y); + } + + // Average luminance across the center of the image + float average_luminance(const std::shared_ptr& frame) { + const QImage& img = *frame->GetImage(); + double total = 0.0; + const int cx = img.width() / 2, cy = img.height() / 2; + const int size = 20; + int count = 0; + for (int y = cy - size; y < cy + size; ++y) { + for (int x = cx - size; x < cx + size; ++x) { + if (x < 0 || y < 0 || x >= img.width() || y >= img.height()) continue; + QColor c = img.pixelColor(x, y); + total += (c.red() + c.green() + c.blue()) / 3.0; + ++count; + } + } + return count > 0 ? static_cast(total / count) : 0.0f; + } +} + +// ── Registration ───────────────────────────────────────────────────────────── + +TEST_CASE("BeatSync is registered in EffectInfo", "[effect][beat-sync]") +{ + std::unique_ptr effect(EffectInfo().CreateEffect("BeatSync")); + REQUIRE(effect != nullptr); + const Json::Value props = effect->JsonValue(); + CHECK(props["type"].asString() == "BeatSync"); +} + +// ── Defaults ───────────────────────────────────────────────────────────────── + +TEST_CASE("BeatSync default parameters are sane", "[effect][beat-sync]") +{ + BeatSync effect; + CHECK(effect.low_color.red.GetValue(1) == Approx(0.0).margin(0.01)); + CHECK(effect.low_color.green.GetValue(1) == Approx(0.0).margin(0.01)); + CHECK(effect.low_color.blue.GetValue(1) == Approx(0.0).margin(0.01)); + CHECK(effect.low_color.alpha.GetValue(1) == Approx(255.0).margin(0.01)); + CHECK(effect.high_color.red.GetValue(1) == Approx(255.0).margin(0.01)); + CHECK(effect.high_color.green.GetValue(1) == Approx(255.0).margin(0.01)); + CHECK(effect.high_color.blue.GetValue(1) == Approx(255.0).margin(0.01)); + CHECK(effect.high_color.alpha.GetValue(1) == Approx(255.0).margin(0.01)); + CHECK(effect.intensity.GetValue(1) == Approx(2.0).margin(0.01)); + CHECK(effect.threshold.GetValue(1) == Approx(0.1).margin(0.01)); + CHECK(effect.attack_ms.GetValue(1) == Approx(10.0).margin(0.01)); + CHECK(effect.decay_ms.GetValue(1) == Approx(200.0).margin(0.01)); + CHECK(effect.frequency_low.GetValue(1) == Approx(0.0).margin(0.01)); + CHECK(effect.frequency_high.GetValue(1) == Approx(1.0).margin(0.01)); + CHECK(effect.response_curve.Sample(0.5f, 1) == Approx(0.5f).margin(0.01)); + CHECK(effect.invert == false); +} + +// ── Output size ────────────────────────────────────────────────────────────── + +TEST_CASE("BeatSync output matches input frame size", "[effect][beat-sync]") +{ + BeatSync effect; + effect.threshold = Keyframe(0.0); + auto out = effect.GetFrame(make_frame(1, Qt::black, 0.8f), 1); + REQUIRE(out->GetImage() != nullptr); + CHECK(out->GetImage()->width() == 320); + CHECK(out->GetImage()->height() == 180); +} + +TEST_CASE("BeatSync uses timeline canvas when frame image is 1x1", "[effect][beat-sync]") +{ + Timeline timeline(640, 360, Fraction(30, 1), 48000, 2, LAYOUT_STEREO); + + const int sample_rate = 48000; + const int samples = 1600; + auto frame = std::make_shared(1, 640, 360, "#00000000", samples, 2); + auto tiny = std::make_shared(1, 1, QImage::Format_RGBA8888_Premultiplied); + tiny->fill(Qt::black); + frame->AddImage(tiny); + frame->ResizeAudio(2, samples, sample_rate, LAYOUT_STEREO); + std::vector audio(samples, 0.8f); + frame->AddAudio(true, 0, 0, audio.data(), samples, 1.0f); + frame->AddAudio(true, 1, 0, audio.data(), samples, 1.0f); + + BeatSync effect; + effect.threshold = Keyframe(0.0); + effect.ParentTimeline(&timeline); + + auto out = effect.GetFrame(frame, 1)->GetImage(); + REQUIRE(out != nullptr); + CHECK(out->width() == 640); + CHECK(out->height() == 360); +} + +// ── Core pixel behavior ─────────────────────────────────────────────────────── + +TEST_CASE("BeatSync generates high color on loud audio", "[effect][beat-sync]") +{ + BeatSync effect; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(10.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + + auto out = effect.GetFrame(make_frame(1, Qt::red, 0.9f), 1); + const float luma = average_luminance(out); + CHECK(luma > 200.0f); +} + +TEST_CASE("BeatSync generates low color on silence", "[effect][beat-sync]") +{ + BeatSync effect; + effect.threshold = Keyframe(0.05); + + auto out = effect.GetFrame(make_silent_frame(Qt::red), 1); + const float luma = average_luminance(out); + CHECK(luma < 2.0f); +} + +TEST_CASE("BeatSync can generate white to black flashes", "[effect][beat-sync]") +{ + BeatSync effect; + effect.low_color = Color((unsigned char)255, (unsigned char)255, (unsigned char)255, (unsigned char)255); + effect.high_color = Color((unsigned char)0, (unsigned char)0, (unsigned char)0, (unsigned char)255); + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(10.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + + auto out = effect.GetFrame(make_frame(1, Qt::red, 0.9f), 1); + const float luma = average_luminance(out); + CHECK(luma < 55.0f); +} + +TEST_CASE("BeatSync louder audio produces more effect than quieter audio", "[effect][beat-sync]") +{ + // intensity=0.2 keeps even loud tones below full saturation so levels are distinguishable + auto run = [](float amplitude) { + BeatSync effect; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(0.2); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + return average_luminance(effect.GetFrame(make_frame(1, Qt::red, amplitude), 1)); + }; + + CHECK(run(0.8f) > run(0.2f)); + CHECK(run(0.2f) > run(0.05f)); +} + +TEST_CASE("BeatSync ignores source footage color", "[effect][beat-sync]") +{ + BeatSync effect_a, effect_b; + effect_a.threshold = effect_b.threshold = Keyframe(0.0); + effect_a.intensity = effect_b.intensity = Keyframe(0.5); + effect_a.attack_ms = effect_b.attack_ms = Keyframe(1.0); + effect_a.decay_ms = effect_b.decay_ms = Keyframe(1.0); + + const QColor out_a = effect_a.GetFrame(make_frame(1, Qt::red, 0.5f), 1)->GetImage()->pixelColor(160, 90); + const QColor out_b = effect_b.GetFrame(make_frame(1, Qt::blue, 0.5f), 1)->GetImage()->pixelColor(160, 90); + CHECK(out_a == out_b); +} + +// ── Alpha generation ───────────────────────────────────────────────────────── + +TEST_CASE("BeatSync interpolates configured alpha", "[effect][beat-sync]") +{ + BeatSync effect; + effect.low_color = Color((unsigned char)0, (unsigned char)0, (unsigned char)0, (unsigned char)0); + effect.high_color = Color((unsigned char)255, (unsigned char)255, (unsigned char)255, (unsigned char)255); + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(0.2); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + + const int quiet_alpha = sample_pixel(effect.GetFrame(make_frame(1, Qt::red, 0.2f), 1), 160, 90).alpha(); + BeatSync loud_effect; + loud_effect.low_color = Color((unsigned char)0, (unsigned char)0, (unsigned char)0, (unsigned char)0); + loud_effect.high_color = Color((unsigned char)255, (unsigned char)255, (unsigned char)255, (unsigned char)255); + loud_effect.threshold = Keyframe(0.0); + loud_effect.intensity = Keyframe(0.2); + loud_effect.attack_ms = Keyframe(1.0); + loud_effect.decay_ms = Keyframe(1.0); + const int loud_alpha = sample_pixel(loud_effect.GetFrame(make_frame(1, Qt::red, 0.8f), 1), 160, 90).alpha(); + CHECK(loud_alpha > quiet_alpha); +} + +TEST_CASE("BeatSync response curve shapes generated color blend", "[effect][beat-sync][curve]") +{ + auto run = [](bool boost_mid) { + BeatSync effect; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(0.2); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + if (boost_mid) { + effect.response_curve.Nodes().clear(); + effect.response_curve.Nodes().push_back(AnimatedCurveNode(10, 0.0, 0.0, LINEAR)); + effect.response_curve.Nodes().push_back(AnimatedCurveNode(20, 0.5, 0.9, LINEAR)); + effect.response_curve.Nodes().push_back(AnimatedCurveNode(30, 1.0, 1.0, LINEAR)); + } + return average_luminance(effect.GetFrame(make_frame(1, Qt::red, 0.4f), 1)); + }; + + CHECK(run(true) > run(false)); +} + +// ── Invert mode ─────────────────────────────────────────────────────────────── + +TEST_CASE("BeatSync invert=true applies maximum effect on silence", "[effect][beat-sync]") +{ + BeatSync effect; + effect.invert = true; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(2.0); + + // Silence -> energy=0 -> inverted to 1.0 -> full high color + auto out = effect.GetFrame(make_silent_frame(Qt::black), 1); + const float luma = average_luminance(out); + CHECK(luma > 200.0f); +} + +TEST_CASE("BeatSync invert=true reduces effect on loud audio", "[effect][beat-sync]") +{ + auto run_inverted = [](float amplitude) { + BeatSync effect; + effect.invert = true; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(5.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + return average_luminance(effect.GetFrame(make_frame(1, Qt::black, amplitude), 1)); + }; + + // Louder audio → energy closer to 1 → inverted closer to 0 → less effect + CHECK(run_inverted(0.9f) < run_inverted(0.05f)); +} + +// ── Frequency filtering ─────────────────────────────────────────────────────── + +TEST_CASE("BeatSync bass band detects low-frequency tone", "[effect][beat-sync]") +{ + // 80 Hz tone — should be captured by bass band, attenuated in highs band + const float bass_low = 0.03f; // ~20 Hz + const float bass_high = 0.25f; // ~250 Hz + const float hi_low = 0.72f; // ~6 kHz + const float hi_high = 1.0f; // 20 kHz + + auto run_band = [](float freq_hz, float band_lo, float band_hi) { + BeatSync effect; + effect.frequency_low = Keyframe(band_lo); + effect.frequency_high = Keyframe(band_hi); + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(5.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + return average_luminance(effect.GetFrame(make_frame(1, Qt::black, 0.9f, 320, 180, freq_hz), 1)); + }; + + // An 80 Hz tone should trigger more effect in the bass band than the highs band + CHECK(run_band(80.0f, bass_low, bass_high) > run_band(80.0f, hi_low, hi_high)); +} + +TEST_CASE("BeatSync high band detects high-frequency tone", "[effect][beat-sync]") +{ + const float bass_low = 0.03f; + const float bass_high = 0.25f; + const float hi_low = 0.72f; + const float hi_high = 1.0f; + + auto run_band = [](float freq_hz, float band_lo, float band_hi) { + BeatSync effect; + effect.frequency_low = Keyframe(band_lo); + effect.frequency_high = Keyframe(band_hi); + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(5.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(1.0); + return average_luminance(effect.GetFrame(make_frame(1, Qt::black, 0.9f, 320, 180, freq_hz), 1)); + }; + + // An 8 kHz tone should trigger more effect in the highs band than the bass band + CHECK(run_band(8000.0f, hi_low, hi_high) > run_band(8000.0f, bass_low, bass_high)); +} + +// ── Envelope behavior ───────────────────────────────────────────────────────── + +TEST_CASE("BeatSync envelope decays over silent frames", "[effect][beat-sync]") +{ + BeatSync effect; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(10.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(50.0); + + // Prime the envelope with a loud frame, then let it decay through silence + effect.GetFrame(make_frame(1, Qt::black, 0.9f), 1); + const float luma_after_loud = average_luminance(effect.GetFrame(make_silent_frame(), 2)); + + for (int i = 3; i <= 20; ++i) + effect.GetFrame(make_silent_frame(), i); + const float luma_after_decay = average_luminance(effect.GetFrame(make_silent_frame(), 21)); + + CHECK(luma_after_decay < luma_after_loud); +} + +TEST_CASE("BeatSync envelope resets on seek", "[effect][beat-sync]") +{ + BeatSync effect; + effect.threshold = Keyframe(0.0); + effect.intensity = Keyframe(10.0); + effect.attack_ms = Keyframe(1.0); + effect.decay_ms = Keyframe(5000.0); // very slow decay + + // Build up envelope + effect.GetFrame(make_frame(1, Qt::black, 0.9f), 1); + + // Seek far forward (discontinuous), envelope should reset + const float luma_post_seek = average_luminance(effect.GetFrame(make_silent_frame(), 100)); + CHECK(luma_post_seek < 5.0f); +} + +// ── JSON round-trip ─────────────────────────────────────────────────────────── + +TEST_CASE("BeatSync JSON round-trip preserves all properties", "[effect][beat-sync][json]") +{ + BeatSync effect; + effect.low_color = Color((unsigned char)10, (unsigned char)20, (unsigned char)30, (unsigned char)40); + effect.high_color = Color((unsigned char)200, (unsigned char)50, (unsigned char)100, (unsigned char)180); + effect.intensity = Keyframe(3.5); + effect.threshold = Keyframe(0.15); + effect.attack_ms = Keyframe(25.0); + effect.decay_ms = Keyframe(300.0); + effect.frequency_low = Keyframe(0.12); + effect.frequency_high = Keyframe(0.78); + effect.invert = true; + effect.response_curve.Nodes().clear(); + effect.response_curve.Nodes().push_back(AnimatedCurveNode(10, 0.0, 0.0, LINEAR)); + effect.response_curve.Nodes().push_back(AnimatedCurveNode(20, 0.5, 0.9, LINEAR)); + effect.response_curve.Nodes().push_back(AnimatedCurveNode(30, 1.0, 1.0, LINEAR)); + + BeatSync copy; + copy.SetJsonValue(effect.JsonValue()); + + CHECK(copy.low_color.red.GetValue(1) == Approx(10.0).margin(0.01)); + CHECK(copy.low_color.green.GetValue(1) == Approx(20.0).margin(0.01)); + CHECK(copy.low_color.blue.GetValue(1) == Approx(30.0).margin(0.01)); + CHECK(copy.low_color.alpha.GetValue(1) == Approx(40.0).margin(0.01)); + CHECK(copy.high_color.red.GetValue(1) == Approx(200.0).margin(0.01)); + CHECK(copy.high_color.green.GetValue(1) == Approx(50.0).margin(0.01)); + CHECK(copy.high_color.blue.GetValue(1) == Approx(100.0).margin(0.01)); + CHECK(copy.high_color.alpha.GetValue(1) == Approx(180.0).margin(0.01)); + CHECK(copy.intensity.GetValue(1) == Approx(3.5).margin(0.001)); + CHECK(copy.threshold.GetValue(1) == Approx(0.15).margin(0.001)); + CHECK(copy.attack_ms.GetValue(1) == Approx(25.0).margin(0.001)); + CHECK(copy.decay_ms.GetValue(1) == Approx(300.0).margin(0.001)); + CHECK(copy.frequency_low.GetValue(1) == Approx(0.12).margin(0.001)); + CHECK(copy.frequency_high.GetValue(1) == Approx(0.78).margin(0.001)); + CHECK(copy.invert == true); + CHECK(copy.response_curve.Nodes().size() == 3); + CHECK(copy.response_curve.Sample(0.5f, 1) == Approx(0.9f).margin(0.02f)); +} + +TEST_CASE("BeatSync SetJson string round-trip works", "[effect][beat-sync][json]") +{ + BeatSync effect; + effect.intensity = Keyframe(4.0); + effect.threshold = Keyframe(0.2); + effect.invert = true; + + BeatSync copy; + copy.SetJson(effect.Json()); + + CHECK(copy.intensity.GetValue(1) == Approx(4.0).margin(0.001)); + CHECK(copy.threshold.GetValue(1) == Approx(0.2).margin(0.001)); + CHECK(copy.invert == true); +} + +TEST_CASE("BeatSync SetJson maps legacy color to high color", "[effect][beat-sync][json]") +{ + BeatSync legacy; + legacy.high_color = Color((unsigned char)20, (unsigned char)30, (unsigned char)40, (unsigned char)255); + Json::Value root = legacy.JsonValue(); + root["color"] = root["high_color"]; + root.removeMember("high_color"); + + BeatSync copy; + copy.SetJsonValue(root); + CHECK(copy.high_color.red.GetValue(1) == Approx(20.0).margin(0.01)); + CHECK(copy.high_color.green.GetValue(1) == Approx(30.0).margin(0.01)); + CHECK(copy.high_color.blue.GetValue(1) == Approx(40.0).margin(0.01)); +} + +TEST_CASE("BeatSync SetJson throws on invalid JSON", "[effect][beat-sync][json]") +{ + BeatSync effect; + CHECK_THROWS_AS(effect.SetJson("{not valid json}"), openshot::InvalidJSON); +} + +// ── PropertiesJSON ──────────────────────────────────────────────────────────── + +TEST_CASE("BeatSync PropertiesJSON exposes all expected properties", "[effect][beat-sync]") +{ + BeatSync effect; + const Json::Value props = openshot::stringToJson(effect.PropertiesJSON(1)); + + CHECK(props["intensity"]["value"].asFloat() == Approx(2.0f).margin(0.01f)); + CHECK(props["intensity"]["min"].asFloat() == Approx(0.0f).margin(0.01f)); + CHECK(props["intensity"]["max"].asFloat() == Approx(10.0f).margin(0.01f)); + + CHECK(props["threshold"]["value"].asFloat() == Approx(0.1f).margin(0.01f)); + CHECK(props["threshold"]["min"].asFloat() == Approx(0.0f).margin(0.01f)); + CHECK(props["threshold"]["max"].asFloat() == Approx(1.0f).margin(0.01f)); + + CHECK(props["frequency_low"]["value"].asFloat() == Approx(0.0f).margin(0.01f)); + CHECK(props["frequency_high"]["value"].asFloat() == Approx(1.0f).margin(0.01f)); + CHECK(props["frequency_low"]["min"].asFloat() == Approx(0.0f).margin(0.01f)); + CHECK(props["frequency_high"]["max"].asFloat() == Approx(1.0f).margin(0.01f)); + + // Invert should expose both choices + CHECK(props["invert"]["choices"].size() == 2); + + CHECK(props["low_color"]["type"].asString() == "color"); + CHECK(props["high_color"]["type"].asString() == "color"); + CHECK(props["response_curve"]["type"].asString() == "colorgrade_curve"); + CHECK(props["response_curve"]["curve"]["nodes"].size() == 2); +} diff --git a/tests/Benchmark.cpp b/tests/Benchmark.cpp index 04b34f8e..df44ffe5 100644 --- a/tests/Benchmark.cpp +++ b/tests/Benchmark.cpp @@ -36,6 +36,7 @@ #include "effects/ChromaKey.h" #include "effects/Crop.h" #include "effects/AudioVisualization.h" +#include "effects/BeatSync.h" #include "effects/Mask.h" #include "effects/Saturation.h" @@ -362,6 +363,17 @@ int main(int argc, char* argv[]) { run_audio_visualization_mode(mode.second, 120); }); + trials.emplace_back("Effect_BeatSync", [&]() { + BeatSync effect; + effect.frequency_low = Keyframe(0.0); // full band + effect.frequency_high = Keyframe(1.0); + effect.threshold = Keyframe(0.05); + effect.attack_ms = Keyframe(10.0); + effect.decay_ms = Keyframe(200.0); + for (int64_t i = 1; i <= 120; ++i) + effect.GetFrame(make_audio_visualization_frame(i), i); + }); + trials.emplace_back("Effect_AudioVisualization_SpectrumModes", [&]() { const std::vector modes = { AUDIO_VISUALIZATION_BARS, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6a23f72f..5057e7ce 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -53,6 +53,7 @@ set(OPENSHOT_TESTS VideoCacheThread # Effects AudioVisualization + BeatSync ColorGrade ColorMap ChromaKey