Figures: several albums per figure, picked on the LED ring

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>
This commit is contained in:
2026-10-04 00:29:18 +02:00
parent a55cc4f67f
commit d465a74cde
37 changed files with 1570 additions and 68 deletions

View File

@@ -9,6 +9,7 @@ enum class EffectId
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
REVERSE_SWIPE,
STATIC_DETAILED,
SEGMENT_SELECT,
};
template <EffectId id>

View File

@@ -0,0 +1,212 @@
#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>;
};