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)
This commit is contained in:
Jonathan Thomas
2026-05-02 23:20:08 -05:00
parent f872efc0fa
commit fc897ec522
8 changed files with 858 additions and 0 deletions
+1
View File
@@ -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
+4
View File
@@ -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());
+1
View File
@@ -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"
+288
View File
@@ -0,0 +1,288 @@
/**
* @file
* @brief Source file for BeatSync effect class
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @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 <algorithm>
#include <cmath>
#include <vector>
#include <QImage>
#include <AppConfig.h>
#include <juce_audio_basics/juce_audio_basics.h>
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 (2020000 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>& 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<const float*> 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<openshot::Frame> BeatSync::GetFrame(std::shared_ptr<openshot::Frame> frame, int64_t frame_number)
{
const std::shared_ptr<QImage> 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<Timeline*>(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<int>(response * 256.0f + 0.5f), 0, 256);
const int inv = 256 - blend;
auto out = std::make_shared<QImage>(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();
}
+61
View File
@@ -0,0 +1,61 @@
/**
* @file
* @brief Header file for BeatSync effect class
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @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 <memory>
#include <string>
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.010.0)
Keyframe threshold; // energy floor below which nothing happens (0.01.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<openshot::Frame> GetFrame(int64_t frame_number) override { return GetFrame(std::make_shared<openshot::Frame>(), frame_number); }
std::shared_ptr<openshot::Frame> GetFrame(std::shared_ptr<openshot::Frame> 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
+490
View File
@@ -0,0 +1,490 @@
/**
* @file
* @brief Unit tests for openshot::BeatSync effect
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2026 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include <cmath>
#include <memory>
#include <vector>
#include <QColor>
#include <QImage>
#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<Frame> 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>(frame_number, width, height, "#00000000", samples, 2);
auto img = std::make_shared<QImage>(width, height, QImage::Format_RGBA8888_Premultiplied);
img->fill(fill_color);
frame->AddImage(img);
frame->ResizeAudio(2, samples, sample_rate, LAYOUT_STEREO);
std::vector<float> 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<Frame> make_silent_frame(QColor fill_color = Qt::black) {
const int sample_rate = 48000;
const int samples = 1600;
auto frame = std::make_shared<Frame>(1, 320, 180, "#00000000", samples, 2);
auto img = std::make_shared<QImage>(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<float> 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>& 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>& 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<float>(total / count) : 0.0f;
}
}
// ── Registration ─────────────────────────────────────────────────────────────
TEST_CASE("BeatSync is registered in EffectInfo", "[effect][beat-sync]")
{
std::unique_ptr<EffectBase> 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<Frame>(1, 640, 360, "#00000000", samples, 2);
auto tiny = std::make_shared<QImage>(1, 1, QImage::Format_RGBA8888_Premultiplied);
tiny->fill(Qt::black);
frame->AddImage(tiny);
frame->ResizeAudio(2, samples, sample_rate, LAYOUT_STEREO);
std::vector<float> 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);
}
+12
View File
@@ -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<int> modes = {
AUDIO_VISUALIZATION_BARS,
+1
View File
@@ -53,6 +53,7 @@ set(OPENSHOT_TESTS
VideoCacheThread
# Effects
AudioVisualization
BeatSync
ColorGrade
ColorMap
ChromaKey