A figure folder that holds album folders instead of audio can play any of them, up to six, in folder order (depth-first, alphabetical). Each is its own album, titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays the album it played last, or the first, straight away; another figure placed in between forgets the position in that album, not the album itself. Meanwhile the ring offers all of them: one section per album from a fixed palette, edges shifting towards the neighbours' hue, the selected one bright and wobbling. The knob moves the selection one section per detent and wraps; pressing plays the selected album, and left alone for 8 s the picker fades into the figure's colours and the music carries on. While open, the device hands the knob to the picker (`capture_knob`): turns do not change the volume and the press is not published to Home Assistant. A tick per step and a confirm "pling" go with it. The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23), re-sent on every detent with the section to glide from. The look is tunable through a temporary `general.figure_selection` config section until the right values become constants. Every UI sound - the screen's blubb, the parents' timer, the picker - now follows the music volume with a floor, times a new parent-mode "Effekt-Lautstärke" (`general.effects_volume`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
7.0 KiB
C++
213 lines
7.0 KiB
C++
#pragma once
|
|
|
|
#include "effects/Common.h"
|
|
#include "helpers/ColorRGBW.h"
|
|
#include "helpers/ColorHSV.h"
|
|
#include "helpers/ColorConversions.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
|
|
static constexpr int SEGMENT_SELECT_MAX_SEGMENTS = 6;
|
|
|
|
// The ring as a picker: one section per choice, the selected one bright and wobbling.
|
|
//
|
|
// The host re-sends this on every rotary detent. Each message builds a fresh effect, so
|
|
// `previous` is what makes the highlight glide over from where it was instead of
|
|
// jumping; previous == selected means no glide.
|
|
#pragma pack(push, 1)
|
|
struct EffectSegmentSelectConfig
|
|
{
|
|
uint8_t count; // number of sections, 1..SEGMENT_SELECT_MAX_SEGMENTS
|
|
uint8_t selected; // index of the highlighted section
|
|
uint8_t previous; // index the highlight glides from
|
|
uint8_t gapLeds; // dark LEDs between neighbouring sections
|
|
ColorRGBW colors[SEGMENT_SELECT_MAX_SEGMENTS];
|
|
float offsetDegrees; // where section 0 starts
|
|
int8_t direction; // +1 / -1: which way round the sections go
|
|
float dimFactor; // brightness of the sections that are not selected, 0..1
|
|
float wobbleAmplitude; // in LEDs
|
|
float wobblePeriodMs;
|
|
float slideMs; // time for the highlight to glide from `previous` to `selected`
|
|
float edgeBlend; // 0..0.5: how far a section's edges shift towards its neighbours' hue
|
|
};
|
|
#pragma pack(pop)
|
|
|
|
template <typename TLedStrip>
|
|
class EffectSegmentSelect
|
|
{
|
|
public:
|
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
|
static constexpr int DELAY_MS = 10;
|
|
using ConfigType = EffectSegmentSelectConfig;
|
|
|
|
EffectSegmentSelect(const EffectSegmentSelectConfig &cfg, TLedStrip &ledStrip)
|
|
: config_(cfg),
|
|
ledStrip_(ledStrip)
|
|
{
|
|
if (config_.count > SEGMENT_SELECT_MAX_SEGMENTS)
|
|
config_.count = SEGMENT_SELECT_MAX_SEGMENTS;
|
|
if (config_.count > 0)
|
|
{
|
|
config_.selected %= config_.count;
|
|
config_.previous %= config_.count;
|
|
}
|
|
if (config_.direction == 0)
|
|
config_.direction = 1;
|
|
|
|
pitch_ = float(NUM_LEDS) / float(config_.count > 0 ? config_.count : 1);
|
|
halfWidth_ = std::max(0.5f, (pitch_ - float(config_.gapLeds)) / 2.0f);
|
|
offsetLeds_ = config_.offsetDegrees / 360.0f * float(NUM_LEDS);
|
|
|
|
// The glide takes the short way round, so going from the last section to the first
|
|
// moves one step forward rather than all the way back.
|
|
int delta = int(config_.selected) - int(config_.previous);
|
|
if (delta > config_.count / 2)
|
|
delta -= config_.count;
|
|
else if (delta < -(config_.count / 2))
|
|
delta += config_.count;
|
|
glideFrom_ = float(int(config_.selected) - delta);
|
|
|
|
// The dimmed background never changes, so it is computed once.
|
|
for (int led = 0; led < NUM_LEDS; ++led)
|
|
{
|
|
const float u = ringPosition(led);
|
|
const int section = std::min(int(u / pitch_), int(config_.count) - 1);
|
|
ColorRGBW color{0, 0, 0, 0};
|
|
if (config_.count > 0)
|
|
{
|
|
const float center = (float(section) + 0.5f) * pitch_;
|
|
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(u - center));
|
|
color = sectionColor(section, (u - center) / pitch_) * (coverage * config_.dimFactor);
|
|
}
|
|
background_[led] = color;
|
|
}
|
|
}
|
|
|
|
int operator()()
|
|
{
|
|
if (config_.count == 0)
|
|
{
|
|
clear(ledStrip_);
|
|
return 10000;
|
|
}
|
|
|
|
const float glide = config_.slideMs > 0 ? clamp01(elapsedMs_ / config_.slideMs) : 1.0f;
|
|
const float eased = glide * glide * (3.0f - 2.0f * glide); // smoothstep
|
|
const float index = glideFrom_ + (float(config_.selected) - glideFrom_) * eased;
|
|
|
|
float wobble = 0;
|
|
if (config_.wobblePeriodMs > 0)
|
|
wobble = config_.wobbleAmplitude * std::sin(2.0f * float(M_PI) * elapsedMs_ / config_.wobblePeriodMs);
|
|
const float center = (index + 0.5f) * pitch_ + wobble;
|
|
|
|
for (int led = 0; led < NUM_LEDS; ++led)
|
|
{
|
|
const float distance = wrappedDistance(ringPosition(led) - center);
|
|
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(distance));
|
|
if (coverage <= 0)
|
|
{
|
|
setLedRGBW(ledStrip_, led, background_[led]);
|
|
continue;
|
|
}
|
|
// The highlight carries the selected section's own gradient along with it, and
|
|
// changes colour from the previous section's as it glides.
|
|
const float local = distance / pitch_;
|
|
const ColorRGBW from = sectionColor(config_.previous, local);
|
|
const ColorRGBW to = sectionColor(config_.selected, local);
|
|
const ColorRGBW highlight = ColorRGBW::interpolate(from, to, eased);
|
|
setLedRGBW(ledStrip_, led, ColorRGBW::interpolate(background_[led], highlight, coverage));
|
|
}
|
|
|
|
elapsedMs_ += DELAY_MS;
|
|
return DELAY_MS;
|
|
}
|
|
|
|
private:
|
|
static float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
|
|
|
|
// An LED's position along the sections, 0..NUM_LEDS, section 0 starting at 0.
|
|
float ringPosition(int led) const
|
|
{
|
|
float u = float(config_.direction) * (float(led) + 0.5f - offsetLeds_);
|
|
u = std::fmod(u, float(NUM_LEDS));
|
|
return u < 0 ? u + float(NUM_LEDS) : u;
|
|
}
|
|
|
|
// Shortest signed distance on the ring.
|
|
static float wrappedDistance(float d)
|
|
{
|
|
const float n = float(NUM_LEDS);
|
|
d = std::fmod(d, n);
|
|
if (d > n / 2)
|
|
d -= n;
|
|
else if (d < -n / 2)
|
|
d += n;
|
|
return d;
|
|
}
|
|
|
|
// Colour of a section at `local`, -0.5 (its start) .. +0.5 (its end): its palette
|
|
// colour in the middle, shifting in hue towards each neighbour by up to edgeBlend.
|
|
ColorRGBW sectionColor(int section, float local) const
|
|
{
|
|
const int count = config_.count;
|
|
const int neighbour = local < 0 ? (section + count - 1) % count : (section + 1) % count;
|
|
const float amount = config_.edgeBlend * clamp01(std::fabs(local) * 2.0f);
|
|
return blendHue(config_.colors[section], config_.colors[neighbour], amount);
|
|
}
|
|
|
|
static ColorRGBW blendHue(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
|
{
|
|
if (f <= 0)
|
|
return c1;
|
|
ColorHSV h1 = rgb2hsv(c1);
|
|
ColorHSV h2 = rgb2hsv(c2);
|
|
// Greys have no hue of their own; borrow the other's so only saturation moves.
|
|
if (h1.s <= 0)
|
|
h1.h = h2.h;
|
|
if (h2.s <= 0)
|
|
h2.h = h1.h;
|
|
float dh = h2.h - h1.h;
|
|
if (dh > 180)
|
|
dh -= 360;
|
|
else if (dh < -180)
|
|
dh += 360;
|
|
float hue = h1.h + f * dh;
|
|
if (hue < 0)
|
|
hue += 360;
|
|
else if (hue >= 360)
|
|
hue -= 360;
|
|
ColorRGBW out = hsv2rgb(ColorHSV{hue, h1.s + f * (h2.s - h1.s), h1.v + f * (h2.v - h1.v)});
|
|
out.w = uint8_t(float(c1.w) + f * (float(c2.w) - float(c1.w)));
|
|
return out;
|
|
}
|
|
|
|
EffectSegmentSelectConfig config_;
|
|
TLedStrip &ledStrip_;
|
|
float pitch_ = 0;
|
|
float halfWidth_ = 0;
|
|
float offsetLeds_ = 0;
|
|
float glideFrom_ = 0;
|
|
float elapsedMs_ = 0;
|
|
ColorRGBW background_[NUM_LEDS];
|
|
};
|
|
|
|
// Traits
|
|
template <>
|
|
struct EffectIdToConfig<EffectId::SEGMENT_SELECT>
|
|
{
|
|
using type = EffectSegmentSelectConfig;
|
|
};
|
|
|
|
template <>
|
|
struct EffectConfigToId<EffectSegmentSelectConfig>
|
|
{
|
|
static constexpr auto id = EffectId::SEGMENT_SELECT;
|
|
};
|
|
|
|
template <typename TLedStrip>
|
|
struct EffectIdToClass<EffectId::SEGMENT_SELECT, TLedStrip>
|
|
{
|
|
using type = EffectSegmentSelect<TLedStrip>;
|
|
};
|