From 3c8c5bed8ce68f366eb975d77b36a580a1a2fab0 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 2 Oct 2026 01:11:28 +0200 Subject: [PATCH] Make Tile swap slow at a low Swap rate, on a curved slider A tile has to come back to the cut it started with for the clip to loop, so every tile swapped at least twice a clip, and below that rounding left every tile still: the bottom of the slider was either frozen or busy. Swap rate is now the average per tile: below two swaps a clip only a share of the tiles swap (twice each), fewer the lower it goes, and above it each tile rounds up or down by its own draw. ParamSpec::slider() takes an optional curve: the scale runs over the slider's travel and sliderValue/sliderPosition map it onto the number as position^curve. Swap rate uses a curve of 3, so the low end gets most of the slider; the command line and project file are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++++ CLAUDE.md | 4 +++- src/core/params.cpp | 18 ++++++++++++++- src/core/params.hpp | 15 +++++++++++-- src/effects/tileswap.cpp | 48 +++++++++++++++++++++++++--------------- src/gui/window.cpp | 26 +++++++++++++--------- tests/core_tests.cpp | 33 +++++++++++++++++++++++++++ tests/gui_tests.cpp | 11 ++++----- 8 files changed, 123 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b0776..5acdfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ commit that made it. The interface shows this file under Changelog in its menu. - The Swap rate of Tile swap is a slider marked with its range, and goes down in steps of 0.01 rather than 0.1, so a clip can swap its tiles much more slowly. Its top end is now 5 swaps a second (it was 30). +- At a low Swap rate, Tile swap now moves only some of its tiles, fewer the + lower the rate, instead of swapping every tile at least twice a clip (or, + below that, none at all). Each step down the bottom of the slider now + looks slower than the one above it. +- The Swap rate slider is curved: its low end takes up most of its length + (1 swap a second sits just past the middle), so slow rates are easy to set. ## [0.8.1] - 2026-09-26 diff --git a/CLAUDE.md b/CLAUDE.md index d7ca0c3..a828fee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,9 @@ travel as `ParamValues` (a An Int or Double param marked `.slider()` is shown in the interface as a slider with its minimum and maximum marked instead of a spin -button; nothing else changes. +button; nothing else changes. `.slider(curve)` bends it: the scale runs over the +slider's travel and `sliderValue`/`sliderPosition` map that onto the number as +`position^curve`, so a curve above 1 gives the low end more room. A param can also be marked `.styled()`, which says a *named style* carries it rather than the stage it sits on: the font and the colour of a text stage, but diff --git a/src/core/params.cpp b/src/core/params.cpp index 428a87f..8acb963 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -1,6 +1,7 @@ #include "core/params.hpp" #include +#include namespace effector { @@ -16,12 +17,27 @@ ParamSpec ParamSpec::styled() const { return copy; } -ParamSpec ParamSpec::slider() const { +ParamSpec ParamSpec::slider(double curve) const { ParamSpec copy = *this; copy.onSlider = true; + copy.curve = std::max(1e-3, curve); return copy; } +double ParamSpec::sliderValue(double position) const { + const double span = maximum - minimum; + const double raw = minimum + span * std::pow(std::clamp(position, 0.0, 1.0), curve); + const double unit = type == Type::Int ? std::max(1.0, std::round(step)) : step; + const double stepped = unit > 0.0 ? minimum + std::round((raw - minimum) / unit) * unit : raw; + return std::clamp(stepped, minimum, maximum); +} + +double ParamSpec::sliderPosition(double value) const { + const double span = maximum - minimum; + if (span <= 0.0) return 0.0; + return std::pow(std::clamp((value - minimum) / span, 0.0, 1.0), 1.0 / curve); +} + ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help, long step) { ParamSpec spec; diff --git a/src/core/params.hpp b/src/core/params.hpp index a76d7c0..648647e 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -30,6 +30,10 @@ struct ParamSpec { // Whether the interface shows this number as a slider marked with its // range rather than as a spin button: see slider(). bool onSlider = false; + // How a slider's travel is spread over its range: the value is + // minimum + (maximum - minimum) * position^curve, so 1 is even and a + // larger curve gives the low end more of the slider. See slider(). + double curve = 1.0; std::string flag() const; // "strip_height" -> "--strip-height" @@ -40,8 +44,15 @@ struct ParamSpec { // A copy of this Int or Double spec shown as a slider in the interface, for // a number whose place between its minimum and maximum says more than its - // digits do. The command line and the project file are unchanged. - ParamSpec slider() const; + // digits do. A `curve` above 1 spreads the low end of the range over more + // of the slider, for a number whose small values differ the most. The + // command line and the project file are unchanged. + ParamSpec slider(double curve = 1.0) const; + + // Where a slider at `position` (0 to 1) puts this number, rounded to its + // step and kept in range, and where on the slider `value` sits. + double sliderValue(double position) const; + double sliderPosition(double value) const; static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help = {}, long step = 1); diff --git a/src/effects/tileswap.cpp b/src/effects/tileswap.cpp index 10c320b..46efeda 100644 --- a/src/effects/tileswap.cpp +++ b/src/effects/tileswap.cpp @@ -26,10 +26,11 @@ const std::vector& TileSwapEffect::params() const { ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false, "Take tiles from the images in order instead of picking randomly."), ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0, - "Swaps per second of each tile, rounded so that the clip loops " - "without a jump; 0 for none.", + "Swaps per second of each tile, on average; 0 for none. Below two " + "swaps in the clip only some of the tiles swap, fewer the lower it " + "goes, so that the clip still loops without a jump.", 0.01, 2) - .slider(), + .slider(3.0), }; return specs; } @@ -57,8 +58,8 @@ struct Settings { double unit(Rng& rng) { return static_cast(rng.next() >> 11) * 0x1.0p-53; } -// Where a tile sits for the whole clip, when in its swaps it turns over, and -// the seed its cuts come from. +// Where a tile sits for the whole clip, when in its swaps it turns over, the +// seed its cuts come from, and the draw that settles how often it swaps. struct Cell { int x; int y; @@ -66,6 +67,7 @@ struct Cell { int h; double phase; std::uint64_t seed; + double share; }; } // namespace @@ -81,7 +83,7 @@ Image TileSwapEffect::compose(const std::vector& sources, int width, int Rng steady = at.steady(); std::vector cells; auto addCell = [&](int x, int y, int w, int h) { - cells.push_back(Cell{x, y, w, h, 0.0, 0}); + cells.push_back(Cell{x, y, w, h, 0.0, 0, 0.0}); }; if (scatter) { const Image& src = sources[steady.below(sources.size() - 1)]; @@ -120,27 +122,37 @@ Image TileSwapEffect::compose(const std::vector& sources, int width, int for (Cell& cell : cells) { cell.phase = unit(steady); cell.seed = steady.next(); + cell.share = unit(steady); } - // Where this frame is in time: each tile has swapped a whole number of - // times, counted round the clip so that the frame after the last shows - // what the first does. - long swaps = 0; - double perSecond = 0.0; - if (settings.rate > 0.0) { - perSecond = at.loopRate(settings.rate); - swaps = std::max(1L, std::lround(perSecond * at.length())); - } + // How often each tile swaps in one pass of the clip, `each` on average. A + // tile has to come back to the cut it started with, so one that swaps at + // all swaps at least twice; below that a share of the tiles swap twice and + // the rest hold still, so a low rate thins out the tiles that move instead + // of moving every one of them. Above it, each tile rounds `each` up or + // down by its own draw. + const double length = at.length(); + const double each = settings.rate * length; + auto swapsOf = [&](const Cell& cell) -> long { + if (each <= 0.0) return 0; + if (each < 2.0) return cell.share < each / 2.0 ? 2 : 0; + const double whole = std::floor(each); + return static_cast(whole) + (cell.share < each - whole ? 1 : 0); + }; - // What a tile shows depends only on the tile and its swap, so it holds - // still between swaps and any frame can be drawn on its own. + // What a tile shows depends only on the tile and how many times it has + // swapped, counted round the clip so that the frame after the last shows + // what the first does; so it holds still between swaps and any frame can + // be drawn on its own. std::vector plan; plan.reserve(cells.size()); for (std::size_t i = 0; i < cells.size(); ++i) { const Cell& cell = cells[i]; + const long swaps = swapsOf(cell); std::uint64_t turn = 0; if (swaps > 0) { - const auto passed = static_cast(std::floor(at.seconds() * perSecond + cell.phase)); + const auto passed = + static_cast(std::floor(at.seconds() * swaps / length + cell.phase)); turn = static_cast(passed % swaps); } Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL); diff --git a/src/gui/window.cpp b/src/gui/window.cpp index bd53925..d45e08f 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -957,22 +957,25 @@ void EffectorWindow::buildParamWidgets() { paramInt(values, spec.key, static_cast(spec.defaultValue))) : paramDouble(values, spec.key, spec.defaultValue); if (spec.onSlider) { - // The ends are marked with their values, so where the - // number sits in its range can be read at a glance. + // The scale runs over the slider's travel, 0 to 1, and + // the spec maps that onto the number, so a curved slider + // gives its low end more room. The ends are marked with + // their values, so where the number sits in its range can + // be read at a glance. const int digits = spec.type == ParamSpec::Type::Int ? 0 : spec.digits; auto scale = std::make_unique(Gtk::Orientation::HORIZONTAL); - scale->set_range(spec.minimum, spec.maximum); - scale->set_increments(spec.step, spec.step * 10); - scale->set_round_digits(digits); - scale->set_digits(digits); + scale->set_range(0.0, 1.0); + scale->set_increments(0.01, 0.1); scale->set_draw_value(true); scale->set_value_pos(Gtk::PositionType::RIGHT); const auto mark = [digits](double at) { return Glib::ustring::format(std::fixed, std::setprecision(digits), at); }; - scale->add_mark(spec.minimum, Gtk::PositionType::BOTTOM, mark(spec.minimum)); - scale->add_mark(spec.maximum, Gtk::PositionType::BOTTOM, mark(spec.maximum)); - scale->set_value(value); + scale->set_format_value_func( + [spec, mark](double position) { return mark(spec.sliderValue(position)); }); + scale->add_mark(0.0, Gtk::PositionType::BOTTOM, mark(spec.minimum)); + scale->add_mark(1.0, Gtk::PositionType::BOTTOM, mark(spec.maximum)); + scale->set_value(spec.sliderPosition(value)); scale->signal_value_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); scrollPastUnlessFocused(*scale); @@ -1093,8 +1096,9 @@ ParamValues EffectorWindow::paramValues() const { case ParamSpec::Type::Int: case ParamSpec::Type::Double: { const double value = - entry.spec.onSlider ? static_cast(entry.widget)->get_value() - : static_cast(entry.widget)->get_value(); + entry.spec.onSlider + ? entry.spec.sliderValue(static_cast(entry.widget)->get_value()) + : static_cast(entry.widget)->get_value(); if (entry.spec.type == ParamSpec::Type::Int) { values[entry.spec.key] = std::lround(value); } else { diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index ae09c4e..1d308be 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -4,8 +4,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -2079,6 +2081,37 @@ int main() { frozenSwap.values["tileswap_rate"] = 0.0; check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)), "at a rate of 0 it holds still"); + // How many pixels ever show something other than frame 0. + auto moving = [&](const ChainStage& at) { + const auto first = frameOf(at, 0); + std::vector moved(static_cast(80 * 48), false); + for (int index = 1; index < 8; ++index) { + const auto frame = frameOf(at, index); + for (int y = 0; y < 48; ++y) { + for (int x = 0; x < 80; ++x) { + if (std::memcmp(frame.row(y) + x * 3, first.row(y) + x * 3, 3) != 0) { + moved[static_cast(y * 80 + x)] = true; + } + } + } + } + return std::count(moved.begin(), moved.end(), true); + }; + ChainStage slowSwap = swapStage; + slowSwap.values["tileswap_rate"] = 0.3; + const auto slowMoving = moving(slowSwap); + check(slowMoving > 0 && slowMoving < moving(swapStage), + "a rate below two swaps a clip moves only some of the tiles"); + + const auto& rate = swap->params().back(); + check(rate.onSlider && rate.curve > 1.0, "its swap rate is a curved slider"); + check(rate.sliderValue(0.0) == 0.0 && rate.sliderValue(1.0) == 5.0 && + rate.sliderValue(0.5) < 2.5, + "which spans the whole range and gives the low end more travel"); + check(std::abs(rate.sliderValue(rate.sliderPosition(0.37)) - 0.37) < 1e-9 && + rate.sliderValue(0.123456) * 100.0 == + std::round(rate.sliderValue(0.123456) * 100.0), + "a value finds its place on the slider, and a place rounds to the step"); ChainStage scattered = swapStage; scattered.values["tileswap_layout"] = std::string("scatter"); ChainStage bricks = swapStage; diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index adff4fc..20ce103 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -658,17 +658,18 @@ void testSlider(Gtk::Window& window, const std::filesystem::path& dir) { check(scales.size() == 1, "a param marked as a slider is shown as one"); if (scales.size() != 1) return; auto* scale = scales.front(); - auto adjustment = scale->get_adjustment(); + const auto& spec = effector::effectByName("tileswap")->params().back(); // Written the way the locale writes numbers, as the interface does. const auto mark = [](double at) { return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at)); }; - check(adjustment->get_lower() == 0.0 && adjustment->get_upper() == 5.0 && - showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)), + check(showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)), "with its range marked at both ends"); - check(scale->get_value() == 0.5, "and shows the value saved"); + check(spec.sliderValue(scale->get_value()) == 0.5, "and shows the value saved"); + check(scale->get_value() > 0.5 / 5.0 + 0.3, + "a curved slider gives the low end of its range more of its travel"); - scale->set_value(0.05); + scale->set_value(spec.sliderPosition(0.05)); pump(100); window.activate_action("win.save-project"); pump(100);