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:
@@ -98,7 +98,8 @@ production so one process serves both the UI and the API.
|
|||||||
names, not settings, because each has quirks the scanner knows about:
|
names, not settings, because each has quirks the scanner knows about:
|
||||||
|
|
||||||
```
|
```
|
||||||
<root>/Figuren/<figure name>/ one folder per figurine
|
<root>/Figuren/<figure name>/ one folder per figurine (or album subfolders
|
||||||
|
in it, up to six, picked on the LED ring)
|
||||||
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ enum class EffectId
|
|||||||
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
||||||
REVERSE_SWIPE,
|
REVERSE_SWIPE,
|
||||||
STATIC_DETAILED,
|
STATIC_DETAILED,
|
||||||
|
SEGMENT_SELECT,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <EffectId id>
|
template <EffectId id>
|
||||||
|
|||||||
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal 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>;
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "effects/ReverseSwipe.h"
|
#include "effects/ReverseSwipe.h"
|
||||||
#include "effects/RandomTwoColorInterpolation.h"
|
#include "effects/RandomTwoColorInterpolation.h"
|
||||||
#include "effects/SwipeAndChange.h"
|
#include "effects/SwipeAndChange.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
|
|
||||||
#include "Arduino.h"
|
#include "Arduino.h"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -120,6 +121,8 @@ enum class MessageHostToFw : uint8_t
|
|||||||
|
|
||||||
PREV_BUTTON_LED = 21,
|
PREV_BUTTON_LED = 21,
|
||||||
NEXT_BUTTON_LED = 22,
|
NEXT_BUTTON_LED = 22,
|
||||||
|
|
||||||
|
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <>
|
template <>
|
||||||
@@ -214,6 +217,7 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
|||||||
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
|
else if(handleLedEffect<EffectSegmentSelectConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SEGMENT_SELECT, msgType, msgBuffer)) {}
|
||||||
|
|
||||||
// Mouse LEDs
|
// Mouse LEDs
|
||||||
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "effects/Circular.h"
|
#include "effects/Circular.h"
|
||||||
#include "effects/Common.h"
|
#include "effects/Common.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
#include "drivers/Esp32DriverRGBW.h"
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -82,6 +83,7 @@ void _led_task_func(void *params)
|
|||||||
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
|
else if (dispatchEffectId<EffectId::SEGMENT_SELECT >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
// clang-format on
|
// clang-format on
|
||||||
|
|
||||||
timeoutMsForEffect = 0;
|
timeoutMsForEffect = 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "effects/AlexaSwipe.h"
|
#include "effects/AlexaSwipe.h"
|
||||||
#include "effects/ReverseSwipe.h"
|
#include "effects/ReverseSwipe.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
#include "helpers/ColorConversions.h"
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
|
|||||||
effectToFile("reverse_swipe.py", effect, strip, 200);
|
effectToFile("reverse_swipe.py", effect, strip, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
EffectSegmentSelectConfig cfg{};
|
||||||
|
cfg.count = 4;
|
||||||
|
cfg.selected = 0;
|
||||||
|
cfg.previous = 3;
|
||||||
|
cfg.gapLeds = 1;
|
||||||
|
cfg.colors[0] = ColorRGBW{255, 0, 0, 0};
|
||||||
|
cfg.colors[1] = ColorRGBW{255, 160, 0, 0};
|
||||||
|
cfg.colors[2] = ColorRGBW{0, 200, 0, 0};
|
||||||
|
cfg.colors[3] = ColorRGBW{0, 60, 255, 0};
|
||||||
|
cfg.offsetDegrees = 180;
|
||||||
|
cfg.direction = 1;
|
||||||
|
cfg.dimFactor = 0.15f;
|
||||||
|
cfg.wobbleAmplitude = 1.5f;
|
||||||
|
cfg.wobblePeriodMs = 1000;
|
||||||
|
cfg.slideMs = 200;
|
||||||
|
cfg.edgeBlend = 0.25f;
|
||||||
|
LedStripRGBW<51> strip;
|
||||||
|
|
||||||
|
EffectSegmentSelect<decltype(strip)> effect(cfg, strip);
|
||||||
|
effectToFile("segment_select.py", effect, strip, 200);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
effect.currentPosition_ = 150;
|
effect.currentPosition_ = 150;
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
|
|||||||
Two behaviours used to be hard-coded in `main.py` and now need automations:
|
Two behaviours used to be hard-coded in `main.py` and now need automations:
|
||||||
|
|
||||||
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
|
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
|
||||||
trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
|
trigger, action `light.toggle` on `light.kinderzimmer_fluter`. While the LED ring's album
|
||||||
|
picker is open (a figure with several albums was just put down) the knob is the picker's:
|
||||||
|
its turns do not change the volume and its press is not published.
|
||||||
|
|
||||||
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
||||||
`light.music_mouse_regal_licht`:
|
`light.music_mouse_regal_licht`:
|
||||||
@@ -156,7 +158,7 @@ the scanner has to know about:
|
|||||||
|
|
||||||
| Folder | Shown as | Why it is special |
|
| Folder | Shown as | Why it is special |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
|
| `Figuren/<figure>/` | an album per figurine - or, for one whose folder holds album folders instead of audio, up to six, picked on the LED ring | the folder name *is* the figure name from the config; a sub-album is titled `Fuchs - <folder>` |
|
||||||
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
||||||
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
||||||
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ general:
|
|||||||
# The music collection. One path; the shelves underneath it are fixed names, not
|
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||||
# settings, because each one has its own quirks the code already knows about:
|
# settings, because each one has its own quirks the code already knows about:
|
||||||
#
|
#
|
||||||
# <root>/Figuren/<figure name>/ one folder per figurine
|
# <root>/Figuren/<figure name>/ one folder per figurine - or, for a figure
|
||||||
|
# with several albums, one subfolder each
|
||||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
@@ -57,6 +58,27 @@ general:
|
|||||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||||
button_leds_brightness: 0.5
|
button_leds_brightness: 0.5
|
||||||
|
|
||||||
|
# Loudness of the UI sounds - the screen's on/off blubb, the parents' timer, the
|
||||||
|
# album picker's clicks - in percent of how loud they are made to sit under the
|
||||||
|
# music. They follow the music's volume, but never drop below what they are at 30 %
|
||||||
|
# of it. Editable in parent mode.
|
||||||
|
effects_volume: 100
|
||||||
|
|
||||||
|
# The LED ring's album picker, for a figure with several albums (see `figures:`).
|
||||||
|
# Temporary: for tuning on the device. Every value defaults to a constant in
|
||||||
|
# musicmouse/reactions/selection.py, which is where the right ones belong once found.
|
||||||
|
# figure_selection:
|
||||||
|
# timeout: 8.0 # seconds without a turn before the picker closes
|
||||||
|
# offset_degrees: 180 # where the first album's section starts on the ring
|
||||||
|
# ring_direction: 1 # 1 or -1: which way round the sections go
|
||||||
|
# knob_direction: 1 # 1 or -1: which way the knob turns to the next album
|
||||||
|
# gap_leds: 1 # dark LEDs between sections
|
||||||
|
# dim_factor: 0.15 # brightness of the sections not selected, 0..1
|
||||||
|
# wobble_amplitude: 1.5 # how far the selected section swings, in LEDs
|
||||||
|
# wobble_period_ms: 1000 # one swing back and forth
|
||||||
|
# slide_ms: 200 # glide from one section to the next
|
||||||
|
# edge_blend: 0.25 # 0..0.5: how far section edges shift to their neighbours' hue
|
||||||
|
|
||||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||||
# a half-finished .tmp - is ignored.
|
# a half-finished .tmp - is ignored.
|
||||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
@@ -175,6 +197,12 @@ general:
|
|||||||
progress_file: tippen-progress.json
|
progress_file: tippen-progress.json
|
||||||
|
|
||||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||||
|
#
|
||||||
|
# A figure folder that holds audio itself plays that. One that holds album folders
|
||||||
|
# instead can play any of them, up to six: putting the figure down plays the one it
|
||||||
|
# played last (or the first, alphabetically), and the LED ring shows one coloured
|
||||||
|
# section per album for a few seconds. Turn the knob to move between them and press it
|
||||||
|
# to play the selected one; leave it alone and the music just carries on.
|
||||||
figures:
|
figures:
|
||||||
fuchs:
|
fuchs:
|
||||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||||
@@ -185,7 +213,8 @@ figures:
|
|||||||
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
||||||
# its type is obvious; a figure folder is named after the figurine, so this is the
|
# its type is obvious; a figure folder is named after the figurine, so this is the
|
||||||
# one thing that has to be said out loud. The web UI draws albums square and
|
# one thing that has to be said out loud. The web UI draws albums square and
|
||||||
# audiobooks taller than wide, so getting it wrong is visible at a glance.
|
# audiobooks taller than wide, so getting it wrong is visible at a glance. It holds
|
||||||
|
# for every album of a figure that has several.
|
||||||
kind: music
|
kind: music
|
||||||
|
|
||||||
eule:
|
eule:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from musicmouse.services.podcasts import PodcastFeedService
|
|||||||
from musicmouse.services.screen import ScreenService
|
from musicmouse.services.screen import ScreenService
|
||||||
from musicmouse.services.timers import TimerService
|
from musicmouse.services.timers import TimerService
|
||||||
from musicmouse.services.web import WebService
|
from musicmouse.services.web import WebService
|
||||||
|
from musicmouse.sound import ui_level
|
||||||
from musicmouse.tippen.curriculum import CurriculumError
|
from musicmouse.tippen.curriculum import CurriculumError
|
||||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||||
|
|
||||||
@@ -381,7 +382,7 @@ def _build_services(
|
|||||||
app.bus,
|
app.bus,
|
||||||
screen_config,
|
screen_config,
|
||||||
alsa_device=None if general.audio_simulated else general.alsa_device,
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||||
volume=lambda: app.player.volume / 100,
|
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
|
||||||
is_playing=lambda: app.player.is_playing,
|
is_playing=lambda: app.player.is_playing,
|
||||||
clock=clock,
|
clock=clock,
|
||||||
)
|
)
|
||||||
@@ -398,7 +399,7 @@ def _build_services(
|
|||||||
cache_file=general.library.cache / "holidays.json",
|
cache_file=general.library.cache / "holidays.json",
|
||||||
client=httpx2.AsyncClient(timeout=30.0),
|
client=httpx2.AsyncClient(timeout=30.0),
|
||||||
alsa_device=None if general.audio_simulated else general.alsa_device,
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||||
volume=lambda: app.player.volume / 100,
|
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
|
||||||
clock=clock,
|
clock=clock,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ from musicmouse.tippen.runtime import TippenRuntime
|
|||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["App", "AppState", "ParentTimer"]
|
__all__ = ["App", "AppState", "FigureSelector", "ParentTimer"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -32,6 +34,22 @@ class ParentTimer:
|
|||||||
ringing: bool = False
|
ringing: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigureSelector:
|
||||||
|
"""The LED ring offering a figure's albums to pick from. Owned by
|
||||||
|
:mod:`musicmouse.reactions.selection`."""
|
||||||
|
|
||||||
|
figure: str
|
||||||
|
choices: tuple[Playlist, ...]
|
||||||
|
#: The highlighted choice - not necessarily the one playing until it is confirmed.
|
||||||
|
selected: int
|
||||||
|
#: Clock time at which the ring gives up waiting; pushed back by every turn.
|
||||||
|
deadline: float
|
||||||
|
#: Clock time of the last tick sound, so turning fast does not stack them up.
|
||||||
|
last_tick: float = -math.inf
|
||||||
|
expiry: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppState:
|
class AppState:
|
||||||
"""State that belongs to no single device but is shared between reactions."""
|
"""State that belongs to no single device but is shared between reactions."""
|
||||||
@@ -40,6 +58,14 @@ class AppState:
|
|||||||
#: instead of starting over. Cleared once its playlist runs out.
|
#: instead of starting over. Cleared once its playlist runs out.
|
||||||
last_partially_played_figure: str | None = None
|
last_partially_played_figure: str | None = None
|
||||||
|
|
||||||
|
#: Figure -> album id it last played, for figures with more than one album: putting
|
||||||
|
#: it back picks that one again. Kept when another figure is placed in between -
|
||||||
|
#: only the position within the album is forgotten then.
|
||||||
|
figure_albums: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
#: The ring's album picker, while it is open.
|
||||||
|
selector: FigureSelector | None = None
|
||||||
|
|
||||||
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
||||||
#: readable off the transport; a broker's is not, so it is remembered here.
|
#: readable off the transport; a broker's is not, so it is remembered here.
|
||||||
mqtt_connected: bool = False
|
mqtt_connected: bool = False
|
||||||
@@ -63,7 +89,8 @@ class App:
|
|||||||
mouse: MusicMouseDevice
|
mouse: MusicMouseDevice
|
||||||
player: Player
|
player: Player
|
||||||
library: MusicLibrary
|
library: MusicLibrary
|
||||||
playlists: dict[str, Playlist]
|
#: Figure -> the albums it can play, in the order the ring offers them.
|
||||||
|
playlists: dict[str, tuple[Playlist, ...]]
|
||||||
clock: Clock = field(default_factory=RealClock)
|
clock: Clock = field(default_factory=RealClock)
|
||||||
state: AppState = field(default_factory=AppState)
|
state: AppState = field(default_factory=AppState)
|
||||||
#: `None` when `general.tippen` is absent - the typing game is off.
|
#: `None` when `general.tippen` is absent - the typing game is off.
|
||||||
@@ -72,11 +99,21 @@ class App:
|
|||||||
def colors(self, figure: str) -> FigureColors:
|
def colors(self, figure: str) -> FigureColors:
|
||||||
return self.config.figures[figure].colors
|
return self.config.figures[figure].colors
|
||||||
|
|
||||||
def playlist(self, figure: str) -> Playlist | None:
|
def choices(self, figure: str) -> tuple[Playlist, ...]:
|
||||||
playlist = self.playlists.get(figure)
|
"""The albums ``figure`` can play; empty, with a warning, if it has none."""
|
||||||
if playlist is None:
|
choices = self.playlists.get(figure, ())
|
||||||
|
if not choices:
|
||||||
_log.warning("No playlist for figure %r", figure)
|
_log.warning("No playlist for figure %r", figure)
|
||||||
return playlist
|
return choices
|
||||||
|
|
||||||
|
def default_choice(self, figure: str) -> int:
|
||||||
|
"""Which of the figure's albums plays when it is put down: the one it played
|
||||||
|
last, or else the first."""
|
||||||
|
remembered = self.state.figure_albums.get(figure)
|
||||||
|
for index, playlist in enumerate(self.playlists.get(figure, ())):
|
||||||
|
if playlist.album_id == remembered:
|
||||||
|
return index
|
||||||
|
return 0
|
||||||
|
|
||||||
def album_for(self, playlist: Playlist | None) -> Album | None:
|
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||||
"""The library album a playlist came from, if any."""
|
"""The library album a playlist came from, if any."""
|
||||||
|
|||||||
@@ -9,17 +9,21 @@ it reaches the surface). Two of them make the "blubb"; switching on the second o
|
|||||||
higher, switching off it is lower, so the two directions are told apart by ear.
|
higher, switching off it is lower, so the two directions are told apart by ear.
|
||||||
|
|
||||||
The parents' timer has its own sound here too: a soft "ding-dong" bell
|
The parents' timer has its own sound here too: a soft "ding-dong" bell
|
||||||
(:func:`chime_wav`), made the same way.
|
(:func:`chime_wav`), made the same way. So do the LED ring's album picker: a short
|
||||||
|
tick for every step (:func:`tick_wav`) and a rising two-note "pling" when an album is
|
||||||
|
picked (:func:`confirm_wav`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import io
|
import io
|
||||||
import math
|
import math
|
||||||
import wave
|
import wave
|
||||||
from array import array
|
from array import array
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
__all__ = ["RATE", "blubb_wav", "chime_wav"]
|
__all__ = ["RATE", "blubb_wav", "chime_wav", "confirm_wav", "tick_wav"]
|
||||||
|
|
||||||
RATE = 44100
|
RATE = 44100
|
||||||
|
|
||||||
@@ -60,7 +64,58 @@ def chime_wav(*, gain: float) -> bytes:
|
|||||||
return _wav(samples, scale)
|
return _wav(samples, scale)
|
||||||
|
|
||||||
|
|
||||||
def _wav(samples: list[float], scale: float) -> bytes:
|
#: (Hz, start s) of the tick's partials: a high, woody click rather than a beep.
|
||||||
|
_TICK = ((2100.0, 0.0), (3350.0, 0.0))
|
||||||
|
_TICK_LENGTH = 0.03
|
||||||
|
_TICK_DECAY = 160.0
|
||||||
|
|
||||||
|
#: (Hz, start s) of the confirm sound: a rising fifth, quick, "pling-pling".
|
||||||
|
_CONFIRM = ((880.0, 0.0), (1318.5, 0.09))
|
||||||
|
_CONFIRM_LENGTH = 0.22
|
||||||
|
_CONFIRM_DECAY = 18.0
|
||||||
|
|
||||||
|
|
||||||
|
def tick_wav(*, gain: float) -> bytes:
|
||||||
|
"""A click of a few tens of milliseconds, for every step of the album picker. Same
|
||||||
|
format as :func:`blubb_wav`."""
|
||||||
|
return _scaled(_pings(_TICK, _TICK_LENGTH, _TICK_DECAY), gain)
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_wav(*, gain: float) -> bytes:
|
||||||
|
"""Two short rising notes for an album picked on the ring. Same format as
|
||||||
|
:func:`blubb_wav`."""
|
||||||
|
return _scaled(_pings(_CONFIRM, _CONFIRM_LENGTH, _CONFIRM_DECAY), gain)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _pings(
|
||||||
|
notes: tuple[tuple[float, float], ...], length: float, decay: float
|
||||||
|
) -> tuple[float, ...]:
|
||||||
|
total = max(start for _, start in notes) + length
|
||||||
|
samples = [0.0] * (int(total * RATE) + 1)
|
||||||
|
attack = int(0.002 * RATE)
|
||||||
|
count = int(length * RATE)
|
||||||
|
for frequency, start in notes:
|
||||||
|
offset = int(start * RATE)
|
||||||
|
for i in range(count):
|
||||||
|
t = i / RATE
|
||||||
|
index = offset + i
|
||||||
|
if index < len(samples):
|
||||||
|
samples[index] += (
|
||||||
|
math.sin(2 * math.pi * frequency * t)
|
||||||
|
* math.exp(-decay * t)
|
||||||
|
* min(1.0, i / attack)
|
||||||
|
)
|
||||||
|
# Cached: a tick plays on every step of the knob, from inside the event loop.
|
||||||
|
return tuple(samples)
|
||||||
|
|
||||||
|
|
||||||
|
def _scaled(samples: tuple[float, ...], gain: float) -> bytes:
|
||||||
|
peak = max(abs(s) for s in samples) or 1.0
|
||||||
|
return _wav(samples, 32767 * max(0.0, min(1.0, gain)) / peak)
|
||||||
|
|
||||||
|
|
||||||
|
def _wav(samples: Sequence[float], scale: float) -> bytes:
|
||||||
pcm = array("h", (round(s * scale) for s in samples))
|
pcm = array("h", (round(s * scale) for s in samples))
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
with wave.open(buffer, "wb") as out:
|
with wave.open(buffer, "wb") as out:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ __all__ = [
|
|||||||
"Digit",
|
"Digit",
|
||||||
"FigureColors",
|
"FigureColors",
|
||||||
"FigureConfig",
|
"FigureConfig",
|
||||||
|
"FigureSelectionConfig",
|
||||||
"GeneralConfig",
|
"GeneralConfig",
|
||||||
"HaConfig",
|
"HaConfig",
|
||||||
"HaDeviceConfig",
|
"HaDeviceConfig",
|
||||||
@@ -301,6 +302,36 @@ class TippenConfig(_Strict):
|
|||||||
return _resolve_folder(path, info, must_exist=False, kind="file")
|
return _resolve_folder(path, info, must_exist=False, kind="file")
|
||||||
|
|
||||||
|
|
||||||
|
class FigureSelectionConfig(_Strict):
|
||||||
|
"""Overrides for the LED ring's album picker, for tuning it on the real device.
|
||||||
|
|
||||||
|
Temporary by design: every value defaults to a constant in
|
||||||
|
:mod:`musicmouse.reactions.selection`, which is where the ones that look right
|
||||||
|
belong once they have been found. ``None`` means "use that constant".
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Seconds without a turn before the picker closes by itself.
|
||||||
|
timeout: float | None = Field(default=None, gt=0)
|
||||||
|
#: Where on the ring the first album's section starts, in degrees from LED 0.
|
||||||
|
offset_degrees: float | None = None
|
||||||
|
#: 1 or -1: which way round the ring the sections follow each other.
|
||||||
|
ring_direction: Literal[1, -1] | None = None
|
||||||
|
#: 1 or -1: which way the knob has to turn to move to the next album.
|
||||||
|
knob_direction: Literal[1, -1] | None = None
|
||||||
|
#: Dark LEDs between neighbouring sections.
|
||||||
|
gap_leds: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
#: Brightness of the sections that are not selected, 0..1.
|
||||||
|
dim_factor: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
#: How far the selected section swings back and forth, in LEDs.
|
||||||
|
wobble_amplitude: float | None = Field(default=None, ge=0)
|
||||||
|
#: How long one swing back and forth takes.
|
||||||
|
wobble_period_ms: float | None = Field(default=None, gt=0)
|
||||||
|
#: How long the highlight takes to glide to the next section.
|
||||||
|
slide_ms: float | None = Field(default=None, ge=0)
|
||||||
|
#: How far a section's edges shift towards its neighbours' colour, 0..0.5.
|
||||||
|
edge_blend: float | None = Field(default=None, ge=0, le=0.5)
|
||||||
|
|
||||||
|
|
||||||
class GeneralConfig(_Strict):
|
class GeneralConfig(_Strict):
|
||||||
library: LibraryConfig
|
library: LibraryConfig
|
||||||
|
|
||||||
@@ -327,6 +358,11 @@ class GeneralConfig(_Strict):
|
|||||||
initial_volume: int = Field(default=50, ge=0, le=200)
|
initial_volume: int = Field(default=50, ge=0, le=200)
|
||||||
volume_increment: int = Field(default=5, ge=1, le=100)
|
volume_increment: int = Field(default=5, ge=1, le=100)
|
||||||
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
||||||
|
#: Loudness of the UI sounds - the screen's blubb, the parents' timer, the album
|
||||||
|
#: picker's clicks - relative to the music, 0..100 %. Editable in parent mode.
|
||||||
|
effects_volume: int = Field(default=100, ge=0, le=100)
|
||||||
|
|
||||||
|
figure_selection: FigureSelectionConfig = Field(default_factory=FigureSelectionConfig)
|
||||||
|
|
||||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from musicmouse.devices.wire import (
|
|||||||
from musicmouse.effects import OFF, LedEffect
|
from musicmouse.effects import OFF, LedEffect
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
ActiveFigureChanged,
|
ActiveFigureChanged,
|
||||||
|
ButtonEvent,
|
||||||
ConnectionChanged,
|
ConnectionChanged,
|
||||||
DeviceConnected,
|
DeviceConnected,
|
||||||
DeviceDisconnected,
|
DeviceDisconnected,
|
||||||
@@ -34,8 +35,11 @@ from musicmouse.events import (
|
|||||||
InputEvent,
|
InputEvent,
|
||||||
LedEffectChanged,
|
LedEffectChanged,
|
||||||
RfidTokenRead,
|
RfidTokenRead,
|
||||||
|
RotaryTurned,
|
||||||
|
SelectorPressed,
|
||||||
|
SelectorTurned,
|
||||||
)
|
)
|
||||||
from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone
|
from musicmouse.hardware import NO_FIGURE_TAG, Button, ButtonAction, LedZone, RotaryDirection
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ class MusicMouseDevice:
|
|||||||
self._active_figure: str | None = None
|
self._active_figure: str | None = None
|
||||||
self._button_brightness: float = 0.0
|
self._button_brightness: float = 0.0
|
||||||
self._effects: dict[LedZone, LedEffect] = {}
|
self._effects: dict[LedZone, LedEffect] = {}
|
||||||
|
self._knob_captured = False
|
||||||
|
|
||||||
# -------------------------------------------------------------------- state
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
@@ -81,6 +86,10 @@ class MusicMouseDevice:
|
|||||||
def effect(self, zone: LedZone) -> LedEffect | None:
|
def effect(self, zone: LedZone) -> LedEffect | None:
|
||||||
return self._effects.get(zone)
|
return self._effects.get(zone)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def knob_captured(self) -> bool:
|
||||||
|
return self._knob_captured
|
||||||
|
|
||||||
# ------------------------------------------------------------------ actions
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
def set_effect(
|
def set_effect(
|
||||||
@@ -107,6 +116,17 @@ class MusicMouseDevice:
|
|||||||
self._transport.write(encode_button_brightness(button, brightness))
|
self._transport.write(encode_button_brightness(button, brightness))
|
||||||
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
|
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
|
||||||
|
|
||||||
|
def capture_knob(self, captured: bool) -> None:
|
||||||
|
"""Hand the rotary encoder to the ring's album picker, or give it back.
|
||||||
|
|
||||||
|
While captured, turns and presses come out as :class:`SelectorTurned` and
|
||||||
|
:class:`SelectorPressed` instead of :class:`RotaryTurned` and
|
||||||
|
:class:`ButtonEvent`, so the volume and Home Assistant never see them. Done here,
|
||||||
|
as the frames are decoded, rather than by each listener checking a flag: the
|
||||||
|
press that closes the picker would otherwise race whoever handles it after.
|
||||||
|
"""
|
||||||
|
self._knob_captured = captured
|
||||||
|
|
||||||
def all_leds_off(self, *, origin: EventSource = "system") -> None:
|
def all_leds_off(self, *, origin: EventSource = "system") -> None:
|
||||||
for zone in LedZone:
|
for zone in LedZone:
|
||||||
self.set_effect(zone, OFF(), origin=origin)
|
self.set_effect(zone, OFF(), origin=origin)
|
||||||
@@ -153,6 +173,20 @@ class MusicMouseDevice:
|
|||||||
def _publish(self, event: InputEvent) -> None:
|
def _publish(self, event: InputEvent) -> None:
|
||||||
if isinstance(event, RfidTokenRead):
|
if isinstance(event, RfidTokenRead):
|
||||||
self._publish_tag_read(event)
|
self._publish_tag_read(event)
|
||||||
|
elif self._knob_captured and isinstance(event, RotaryTurned):
|
||||||
|
if event.direction is not RotaryDirection.NONE:
|
||||||
|
# The first turn after boot reports no increment, but it was a detent.
|
||||||
|
steps = abs(event.increment) or 1
|
||||||
|
if event.direction is RotaryDirection.DOWN:
|
||||||
|
steps = -steps
|
||||||
|
self._bus.emit(SelectorTurned(steps=steps, source="device"))
|
||||||
|
elif (
|
||||||
|
self._knob_captured
|
||||||
|
and isinstance(event, ButtonEvent)
|
||||||
|
and event.button is Button.ROTARY
|
||||||
|
):
|
||||||
|
if event.action is ButtonAction.PRESSED:
|
||||||
|
self._bus.emit(SelectorPressed(source="device"))
|
||||||
else:
|
else:
|
||||||
self._bus.emit(event)
|
self._bus.emit(event)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from musicmouse.effects import (
|
|||||||
EffectCircularConfig,
|
EffectCircularConfig,
|
||||||
EffectRandomTwoColorInterpolationConfig,
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
EffectReverseSwipe,
|
EffectReverseSwipe,
|
||||||
|
EffectSegmentSelect,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
EffectStaticDetailedConfig,
|
EffectStaticDetailedConfig,
|
||||||
EffectSwipeAndChange,
|
EffectSwipeAndChange,
|
||||||
@@ -114,9 +115,11 @@ class MessageHostToFw(IntEnum):
|
|||||||
PREV_BUTTON_LED = 21
|
PREV_BUTTON_LED = 21
|
||||||
NEXT_BUTTON_LED = 22
|
NEXT_BUTTON_LED = 22
|
||||||
|
|
||||||
|
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23
|
||||||
|
|
||||||
|
|
||||||
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring
|
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring
|
||||||
#: accepts AlexaSwipe on its own, and only the shelf accepts StaticDetailed.
|
#: accepts AlexaSwipe and SegmentSelect, and only the shelf accepts StaticDetailed.
|
||||||
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
||||||
LedZone.RING: {
|
LedZone.RING: {
|
||||||
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
|
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
|
||||||
@@ -127,6 +130,7 @@ _EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
|||||||
),
|
),
|
||||||
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
|
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
|
||||||
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
|
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
|
||||||
|
EffectSegmentSelect: MessageHostToFw.LED_WHEEL_EFFECT_SEGMENT_SELECT,
|
||||||
},
|
},
|
||||||
LedZone.MOUSE: {
|
LedZone.MOUSE: {
|
||||||
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
|
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ from musicmouse.color import ColorHSV, ColorRGBW
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OFF",
|
"OFF",
|
||||||
|
"SEGMENT_SELECT_MAX_SEGMENTS",
|
||||||
"EffectAlexaSwipeConfig",
|
"EffectAlexaSwipeConfig",
|
||||||
"EffectCircularConfig",
|
"EffectCircularConfig",
|
||||||
"EffectRandomTwoColorInterpolationConfig",
|
"EffectRandomTwoColorInterpolationConfig",
|
||||||
"EffectReverseSwipe",
|
"EffectReverseSwipe",
|
||||||
|
"EffectSegmentSelect",
|
||||||
"EffectStaticConfig",
|
"EffectStaticConfig",
|
||||||
"EffectStaticDetailedConfig",
|
"EffectStaticDetailedConfig",
|
||||||
"EffectSwipeAndChange",
|
"EffectSwipeAndChange",
|
||||||
@@ -41,6 +43,12 @@ _ALEXA_SWIPE = struct.Struct("<fffff?")
|
|||||||
_TWO_COLOR = struct.Struct("<i?i??")
|
_TWO_COLOR = struct.Struct("<i?i??")
|
||||||
_CIRCULAR = struct.Struct("<ff")
|
_CIRCULAR = struct.Struct("<ff")
|
||||||
_REVERSE_SWIPE = struct.Struct("<fff")
|
_REVERSE_SWIPE = struct.Struct("<fff")
|
||||||
|
_SEGMENT_SELECT_HEAD = struct.Struct("<BBBB")
|
||||||
|
_SEGMENT_SELECT_TAIL = struct.Struct("<fbfffff")
|
||||||
|
|
||||||
|
#: ``SEGMENT_SELECT_MAX_SEGMENTS`` in ``SegmentSelect.h``: the colour array is this long
|
||||||
|
#: on the wire whatever ``count`` is.
|
||||||
|
SEGMENT_SELECT_MAX_SEGMENTS = 6
|
||||||
|
|
||||||
|
|
||||||
class LedEffect(Protocol):
|
class LedEffect(Protocol):
|
||||||
@@ -274,6 +282,86 @@ class EffectReverseSwipe:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectSegmentSelect:
|
||||||
|
"""The ring as a picker: one section per choice, the selected one bright.
|
||||||
|
|
||||||
|
Re-sent on every rotary detent. ``previous`` is where the highlight glides from, so
|
||||||
|
the fresh effect the firmware builds per message still moves smoothly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
colors: tuple[ColorRGBW, ...]
|
||||||
|
selected: int = 0
|
||||||
|
previous: int | None = None # None: same as selected, no glide
|
||||||
|
gap_leds: int = 1
|
||||||
|
offset_degrees: float = 180
|
||||||
|
direction: int = 1
|
||||||
|
dim_factor: float = 0.15
|
||||||
|
wobble_amplitude: float = 1.5 # LEDs
|
||||||
|
wobble_period_ms: float = 1000
|
||||||
|
slide_ms: float = 200
|
||||||
|
edge_blend: float = 0.25
|
||||||
|
|
||||||
|
SIZE: ClassVar[int] = (
|
||||||
|
_SEGMENT_SELECT_HEAD.size
|
||||||
|
+ SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
|
||||||
|
+ _SEGMENT_SELECT_TAIL.size
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= len(self.colors) <= SEGMENT_SELECT_MAX_SEGMENTS:
|
||||||
|
raise ValueError(
|
||||||
|
f"between 1 and {SEGMENT_SELECT_MAX_SEGMENTS} sections, got {len(self.colors)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_bytes(self) -> bytes:
|
||||||
|
count = len(self.colors)
|
||||||
|
previous = self.selected if self.previous is None else self.previous
|
||||||
|
padding = (ColorRGBW(0, 0, 0, 0),) * (SEGMENT_SELECT_MAX_SEGMENTS - count)
|
||||||
|
return (
|
||||||
|
_SEGMENT_SELECT_HEAD.pack(count, self.selected, previous, self.gap_leds)
|
||||||
|
+ b"".join(color.as_bytes() for color in (*self.colors, *padding))
|
||||||
|
+ _SEGMENT_SELECT_TAIL.pack(
|
||||||
|
self.offset_degrees,
|
||||||
|
self.direction,
|
||||||
|
self.dim_factor,
|
||||||
|
self.wobble_amplitude,
|
||||||
|
self.wobble_period_ms,
|
||||||
|
self.slide_ms,
|
||||||
|
self.edge_blend,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> EffectSegmentSelect:
|
||||||
|
_check_size(data, cls.SIZE, cls.__name__)
|
||||||
|
count, selected, previous, gap = _SEGMENT_SELECT_HEAD.unpack_from(data)
|
||||||
|
start = _SEGMENT_SELECT_HEAD.size
|
||||||
|
colors = tuple(
|
||||||
|
ColorRGBW.from_bytes(data[start + i * _RGBW_SIZE : start + (i + 1) * _RGBW_SIZE])
|
||||||
|
for i in range(count)
|
||||||
|
)
|
||||||
|
offset, direction, dim, amplitude, period, slide, blend = _SEGMENT_SELECT_TAIL.unpack_from(
|
||||||
|
data, start + SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
colors=colors,
|
||||||
|
selected=selected,
|
||||||
|
previous=None if previous == selected else previous,
|
||||||
|
gap_leds=gap,
|
||||||
|
offset_degrees=offset,
|
||||||
|
direction=direction,
|
||||||
|
dim_factor=dim,
|
||||||
|
wobble_amplitude=amplitude,
|
||||||
|
wobble_period_ms=period,
|
||||||
|
slide_ms=slide,
|
||||||
|
edge_blend=blend,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"SegmentSelect({self.previous} -> {self.selected} of {len(self.colors)})"
|
||||||
|
|
||||||
|
|
||||||
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
|
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
|
||||||
"""A fresh "all LEDs off" effect."""
|
"""A fresh "all LEDs off" effect."""
|
||||||
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ __all__ = [
|
|||||||
"DeviceDisconnected",
|
"DeviceDisconnected",
|
||||||
"Event",
|
"Event",
|
||||||
"EventSource",
|
"EventSource",
|
||||||
|
"FigureSelectionChanged",
|
||||||
|
"FigureSelectorExpired",
|
||||||
"InputEvent",
|
"InputEvent",
|
||||||
"IntentEvent",
|
"IntentEvent",
|
||||||
"LedEffectChanged",
|
"LedEffectChanged",
|
||||||
@@ -50,6 +52,8 @@ __all__ = [
|
|||||||
"PreviousTrackRequested",
|
"PreviousTrackRequested",
|
||||||
"RfidTokenRead",
|
"RfidTokenRead",
|
||||||
"RotaryTurned",
|
"RotaryTurned",
|
||||||
|
"SelectorPressed",
|
||||||
|
"SelectorTurned",
|
||||||
"SetTimerRequested",
|
"SetTimerRequested",
|
||||||
"SetVolumeRequested",
|
"SetVolumeRequested",
|
||||||
"StateEvent",
|
"StateEvent",
|
||||||
@@ -141,6 +145,31 @@ class RotaryTurned(InputEvent):
|
|||||||
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
|
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class SelectorTurned(InputEvent):
|
||||||
|
"""The rotary encoder turned while the ring's album picker had it.
|
||||||
|
|
||||||
|
Not a :class:`RotaryTurned`: while the picker is open the knob is not a volume
|
||||||
|
knob, and nothing that listens for one should hear it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Detents, positive for the direction that turns the volume up.
|
||||||
|
steps: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class SelectorPressed(InputEvent):
|
||||||
|
"""The rotary encoder was pressed while the ring's album picker had it. Instead of,
|
||||||
|
not as well as, a :class:`ButtonEvent` - so it is not published to Home Assistant."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class FigureSelectorExpired(InputEvent):
|
||||||
|
"""The ring's album picker went untouched for long enough to close by itself."""
|
||||||
|
|
||||||
|
figure: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class PlaylistFinished(InputEvent):
|
class PlaylistFinished(InputEvent):
|
||||||
"""The player reached the end of the playlist."""
|
"""The player reached the end of the playlist."""
|
||||||
@@ -300,6 +329,17 @@ class ActiveFigureChanged(StateEvent):
|
|||||||
previous: str | None = None
|
previous: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class FigureSelectionChanged(StateEvent):
|
||||||
|
"""The ring's album picker opened, moved or closed."""
|
||||||
|
|
||||||
|
figure: str
|
||||||
|
#: Index into the figure's albums of the highlighted one.
|
||||||
|
selected: int
|
||||||
|
count: int
|
||||||
|
open: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class LedEffectChanged(StateEvent):
|
class LedEffectChanged(StateEvent):
|
||||||
"""Emitted on *every* write to an LED zone, whatever caused it.
|
"""Emitted on *every* write to an LED zone, whatever caused it.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ANALYZER_VERSION",
|
"ANALYZER_VERSION",
|
||||||
|
"MAX_FIGURE_ALBUMS",
|
||||||
"SECTIONS",
|
"SECTIONS",
|
||||||
"Album",
|
"Album",
|
||||||
"Analyzer",
|
"Analyzer",
|
||||||
@@ -50,6 +51,10 @@ __all__ = [
|
|||||||
"track_key",
|
"track_key",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
#: How many albums one figure can offer. Each is a section of the LED ring, and past six
|
||||||
|
#: they get too narrow to tell apart; the firmware's colour array is no longer either.
|
||||||
|
MAX_FIGURE_ALBUMS: Final = 6
|
||||||
|
|
||||||
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
|
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
|
||||||
#: tens of minutes of narration with no musical mood to extract, and there are far more
|
#: tens of minutes of narration with no musical mood to extract, and there are far more
|
||||||
#: of them in a typical library than there are songs.
|
#: of them in a typical library than there are songs.
|
||||||
@@ -120,17 +125,35 @@ class MusicLibrary:
|
|||||||
entry = self._entries.get(identifier) if identifier else None
|
entry = self._entries.get(identifier) if identifier else None
|
||||||
return entry[0] if entry else None
|
return entry[0] if entry else None
|
||||||
|
|
||||||
def figure_playlists(self) -> dict[str, Playlist]:
|
def figure_playlists(self) -> dict[str, tuple[Playlist, ...]]:
|
||||||
"""One playlist per figure folder, keyed by figure name.
|
"""The albums a figure can play, keyed by figure name, in folder order.
|
||||||
|
|
||||||
|
Usually one. A figure whose folder holds album subfolders rather than audio has
|
||||||
|
one per subfolder, depth-first and alphabetical, which is the order the LED ring
|
||||||
|
offers them in - and the ring has room for no more than
|
||||||
|
:data:`MAX_FIGURE_ALBUMS`.
|
||||||
|
|
||||||
The figure path and the web path must hand the player the *same* object for the
|
The figure path and the web path must hand the player the *same* object for the
|
||||||
same album: ``reactions.playback.play_figure`` resumes on an identity check.
|
same album: ``reactions.playback.play_figure`` resumes on an identity check.
|
||||||
"""
|
"""
|
||||||
return {
|
by_figure: dict[str, list[Album]] = {}
|
||||||
album.figure: album.to_playlist()
|
for album, _ in self._entries.values():
|
||||||
for album, _ in self._entries.values()
|
if album.figure is not None:
|
||||||
if album.figure is not None
|
by_figure.setdefault(album.figure, []).append(album)
|
||||||
}
|
|
||||||
|
out: dict[str, tuple[Playlist, ...]] = {}
|
||||||
|
for figure, albums in by_figure.items():
|
||||||
|
albums.sort(key=lambda album: album.folder.parts)
|
||||||
|
if len(albums) > MAX_FIGURE_ALBUMS:
|
||||||
|
_log.warning(
|
||||||
|
"Figure %r has %d albums; only the first %d can be picked on the ring: %s",
|
||||||
|
figure,
|
||||||
|
len(albums),
|
||||||
|
MAX_FIGURE_ALBUMS,
|
||||||
|
", ".join(album.folder.name for album in albums[:MAX_FIGURE_ALBUMS]),
|
||||||
|
)
|
||||||
|
out[figure] = tuple(album.to_playlist() for album in albums[:MAX_FIGURE_ALBUMS])
|
||||||
|
return out
|
||||||
|
|
||||||
def latest_episode(self, series: str) -> Album | None:
|
def latest_episode(self, series: str) -> Album | None:
|
||||||
"""The newest episode-unit album of a podcast show, by filename.
|
"""The newest episode-unit album of a podcast show, by filename.
|
||||||
|
|||||||
@@ -54,9 +54,12 @@ __all__ = ["Fingerprint", "LibraryCache"]
|
|||||||
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
||||||
# re-reading them, only by going back to the original art. Discarding the index does
|
# re-reading them, only by going back to the original art. Discarding the index does
|
||||||
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
||||||
|
# 7: a figure's albums may sit in subfolders. `figure` is now the folder directly under
|
||||||
|
# Figuren rather than the album folder's own name, and such an album is titled
|
||||||
|
# "Fuchs - <folder>" - neither of which an entry cached before can know.
|
||||||
#
|
#
|
||||||
# Bumping this is cheap by design - see the module docstring.
|
# Bumping this is cheap by design - see the module docstring.
|
||||||
_INDEX_VERSION = 6
|
_INDEX_VERSION = 7
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -197,7 +197,9 @@ def scan_album(
|
|||||||
albums.append(tags.get("album", ""))
|
albums.append(tags.get("album", ""))
|
||||||
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
||||||
|
|
||||||
figure = folder.name if section.figures else None
|
# A figure with several albums keeps them in subfolders of its own folder, so the
|
||||||
|
# figure is the folder directly under the section, however deep the album sits.
|
||||||
|
figure = folder.relative_to(root / section_name).parts[0] if section.figures else None
|
||||||
# Every other shelf is named after what is on it. A figure folder is named after the
|
# Every other shelf is named after what is on it. A figure folder is named after the
|
||||||
# figurine, so its media type has to be declared in the config.
|
# figurine, so its media type has to be declared in the config.
|
||||||
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
||||||
@@ -205,9 +207,13 @@ def scan_album(
|
|||||||
folder_artist, folder_title = _split_folder_name(folder.name)
|
folder_artist, folder_title = _split_folder_name(folder.name)
|
||||||
|
|
||||||
if section.title_from == "folder":
|
if section.title_from == "folder":
|
||||||
# A figure folder is named in lowercase ("fuchs"); it sits next to real album
|
title = folder.name
|
||||||
# titles in the browse grid, so give it a capital.
|
if figure is not None:
|
||||||
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
|
# A figure folder is named in lowercase ("fuchs"); it sits next to real
|
||||||
|
# album titles in the browse grid, so give it a capital.
|
||||||
|
title = figure[:1].upper() + figure[1:]
|
||||||
|
if folder.name != figure:
|
||||||
|
title = f"{title} - {folder.name}"
|
||||||
else:
|
else:
|
||||||
title = _most_common(albums) or folder_title or folder.name
|
title = _most_common(albums) or folder_title or folder.name
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ service subscribes to those events directly.
|
|||||||
from musicmouse.reactions import ( # noqa: F401 (import = register)
|
from musicmouse.reactions import ( # noqa: F401 (import = register)
|
||||||
lighting,
|
lighting,
|
||||||
playback,
|
playback,
|
||||||
|
selection,
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
|||||||
if event.figure is None:
|
if event.figure is None:
|
||||||
off_animation(app)
|
off_animation(app)
|
||||||
else:
|
else:
|
||||||
start_animation(app, app.colors(event.figure))
|
# A figure with several albums gets the album picker on the ring instead
|
||||||
|
# (see ``reactions.selection``), which fades into this ring effect when done.
|
||||||
|
ring = len(app.playlists.get(event.figure, ())) < 2
|
||||||
|
start_animation(app, app.colors(event.figure), ring=ring)
|
||||||
app.mouse.set_button_brightness(
|
app.mouse.set_button_brightness(
|
||||||
app.config.general.button_leds_brightness, origin="device"
|
app.config.general.button_leds_brightness, origin="device"
|
||||||
)
|
)
|
||||||
@@ -106,36 +109,42 @@ def touch_released(event: TouchButtonReleased, app: App) -> None:
|
|||||||
)
|
)
|
||||||
if colors is None:
|
if colors is None:
|
||||||
return
|
return
|
||||||
|
fade_to_figure(app, LedZone.MOUSE, colors)
|
||||||
app.mouse.set_effect(
|
|
||||||
LedZone.MOUSE,
|
|
||||||
EffectRandomTwoColorInterpolationConfig(
|
|
||||||
color1=colors.primary, color2=colors.secondary, start_with_existing=True
|
|
||||||
),
|
|
||||||
origin="device",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- animations
|
# ------------------------------------------------------------------- animations
|
||||||
|
|
||||||
|
|
||||||
def start_animation(app: App, colors: FigureColors) -> None:
|
def start_animation(app: App, colors: FigureColors, *, ring: bool = True) -> None:
|
||||||
ring = EffectSwipeAndChange()
|
swipe = EffectSwipeAndChange()
|
||||||
ring.swipe.primary_color = colors.primary
|
swipe.swipe.primary_color = colors.primary
|
||||||
ring.swipe.secondary_color = colors.secondary
|
swipe.swipe.secondary_color = colors.secondary
|
||||||
ring.swipe.swipe_speed = SWIPE_SPEED
|
swipe.swipe.swipe_speed = SWIPE_SPEED
|
||||||
ring.change.color1 = colors.primary
|
swipe.change.color1 = colors.primary
|
||||||
ring.change.color2 = colors.secondary
|
swipe.change.color2 = colors.secondary
|
||||||
|
|
||||||
app.mouse.set_effect(LedZone.RING, ring, origin="device")
|
if ring:
|
||||||
app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device")
|
app.mouse.set_effect(LedZone.RING, swipe, origin="device")
|
||||||
|
app.mouse.set_effect(LedZone.SHELF, deepcopy(swipe), origin="device")
|
||||||
|
|
||||||
mouse = deepcopy(ring)
|
mouse = deepcopy(swipe)
|
||||||
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
|
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
|
||||||
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
|
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
|
||||||
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
||||||
|
|
||||||
|
|
||||||
|
def fade_to_figure(app: App, zone: LedZone, colors: FigureColors) -> None:
|
||||||
|
"""Fade ``zone`` from whatever it shows into the figure's resting colours - where
|
||||||
|
the start animation ends up after its swipe."""
|
||||||
|
app.mouse.set_effect(
|
||||||
|
zone,
|
||||||
|
EffectRandomTwoColorInterpolationConfig(
|
||||||
|
color1=colors.primary, color2=colors.secondary, start_with_existing=True
|
||||||
|
),
|
||||||
|
origin="device",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def web_animation(app: App, color: ColorRGBW) -> None:
|
def web_animation(app: App, color: ColorRGBW) -> None:
|
||||||
for zone in LedZone:
|
for zone in LedZone:
|
||||||
app.mouse.set_effect(
|
app.mouse.set_effect(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from musicmouse.events import (
|
|||||||
)
|
)
|
||||||
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
||||||
from musicmouse.library.models import Album
|
from musicmouse.library.models import Album
|
||||||
|
from musicmouse.media import Playlist
|
||||||
from musicmouse.reactions.registry import on
|
from musicmouse.reactions.registry import on
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -63,24 +64,43 @@ def _figure_removed(previous: str | None, app: App) -> None:
|
|||||||
|
|
||||||
@on(PlayFigureRequested)
|
@on(PlayFigureRequested)
|
||||||
def play_figure(event: PlayFigureRequested, app: App) -> None:
|
def play_figure(event: PlayFigureRequested, app: App) -> None:
|
||||||
playlist = app.playlist(event.figure)
|
"""Play the figure's album - the one it played last, if it has several.
|
||||||
if playlist is None:
|
|
||||||
|
Another figure placed in between forgets only *where* in that album it was
|
||||||
|
(``restart``), not which album it was.
|
||||||
|
"""
|
||||||
|
choices = app.choices(event.figure)
|
||||||
|
if not choices:
|
||||||
return
|
return
|
||||||
|
playlist = choices[app.default_choice(event.figure)]
|
||||||
|
remember_figure_album(app, event.figure, playlist)
|
||||||
|
|
||||||
if not event.restart and app.player.playlist is playlist:
|
if not event.restart and app.player.playlist is playlist:
|
||||||
_log.info("Resuming %r", event.figure)
|
_log.info("Resuming %r", event.figure)
|
||||||
app.player.play()
|
app.player.play()
|
||||||
return
|
return
|
||||||
|
|
||||||
_log.info("Starting %r from the beginning", event.figure)
|
_log.info("Starting %r (%s) from the beginning", event.figure, playlist.album_id)
|
||||||
app.player.set_playlist(playlist)
|
app.player.set_playlist(playlist)
|
||||||
app.player.play_from_start()
|
app.player.play_from_start()
|
||||||
|
|
||||||
|
|
||||||
|
def remember_figure_album(app: App, figure: str, playlist: Playlist) -> None:
|
||||||
|
"""Make ``playlist`` the album ``figure`` picks next time it is put down."""
|
||||||
|
if playlist.album_id is not None:
|
||||||
|
app.state.figure_albums[figure] = playlist.album_id
|
||||||
|
|
||||||
|
|
||||||
def _start_album(app: App, album: Album, track_index: int) -> None:
|
def _start_album(app: App, album: Album, track_index: int) -> None:
|
||||||
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
|
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
|
||||||
# Playlist object either way, because both come from the library index.
|
# Playlist object either way, because both come from the library index.
|
||||||
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
|
playlist = None
|
||||||
|
if album.figure is not None:
|
||||||
|
playlist = next(
|
||||||
|
(p for p in app.playlists.get(album.figure, ()) if p.album_id == album.id), None
|
||||||
|
)
|
||||||
|
if playlist is not None:
|
||||||
|
remember_figure_album(app, album.figure, playlist)
|
||||||
app.player.set_playlist(playlist or album.to_playlist())
|
app.player.set_playlist(playlist or album.to_playlist())
|
||||||
app.player.play_track(track_index)
|
app.player.play_track(track_index)
|
||||||
|
|
||||||
@@ -144,7 +164,9 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
|
|||||||
elif event.button is Button.RIGHT and app.player.is_playing:
|
elif event.button is Button.RIGHT and app.player.is_playing:
|
||||||
app.bus.emit(NextTrackRequested(source="device"))
|
app.bus.emit(NextTrackRequested(source="device"))
|
||||||
# The rotary press is published to Home Assistant by the MQTT service; what it
|
# The rotary press is published to Home Assistant by the MQTT service; what it
|
||||||
# controls is an automation over there, not something this backend decides.
|
# controls is an automation over there, not something this backend decides. While
|
||||||
|
# the ring's album picker is open the press is the picker's instead, and does not
|
||||||
|
# arrive here at all (see ``MusicMouseDevice.capture_knob``).
|
||||||
|
|
||||||
|
|
||||||
@on(RotaryTurned)
|
@on(RotaryTurned)
|
||||||
|
|||||||
234
python-backend/musicmouse/reactions/selection.py
Normal file
234
python-backend/musicmouse/reactions/selection.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""Picking one of a figure's albums on the LED ring.
|
||||||
|
|
||||||
|
A figure whose folder holds several albums starts playing its default one at once, as
|
||||||
|
any figure does, and meanwhile offers all of them on the ring: one coloured section
|
||||||
|
each, the selected one bright and wobbling. The knob moves the selection one section
|
||||||
|
per detent, and pressing it plays the selected album. Left alone, the picker closes by
|
||||||
|
itself and whatever is playing carries on.
|
||||||
|
|
||||||
|
While it is open the picker has the knob to itself (``MusicMouseDevice.capture_knob``):
|
||||||
|
turning does not change the volume and pressing is not sent to Home Assistant.
|
||||||
|
|
||||||
|
The firmware only draws the ring. Which section is selected, when to give up, and what
|
||||||
|
to play are decided here, and the ring is simply sent again on every detent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
from musicmouse.app import App, FigureSelector
|
||||||
|
from musicmouse.blubb import confirm_wav, tick_wav
|
||||||
|
from musicmouse.color import ColorRGBW
|
||||||
|
from musicmouse.effects import EffectSegmentSelect
|
||||||
|
from musicmouse.events import (
|
||||||
|
ActiveFigureChanged,
|
||||||
|
FigureSelectionChanged,
|
||||||
|
FigureSelectorExpired,
|
||||||
|
PlayAlbumRequested,
|
||||||
|
PlaylistFinished,
|
||||||
|
SelectorPressed,
|
||||||
|
SelectorTurned,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
|
from musicmouse.media import Playlist
|
||||||
|
from musicmouse.reactions.lighting import fade_to_figure
|
||||||
|
from musicmouse.reactions.registry import on
|
||||||
|
from musicmouse.sound import play_wav_soon, ui_level
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: One colour per album, by position. Neighbours are far apart in hue, so the sections
|
||||||
|
#: stay easy to tell apart even with the edge blend shifting them towards each other.
|
||||||
|
PALETTE: tuple[ColorRGBW, ...] = (
|
||||||
|
ColorRGBW(1.0, 0.0, 0.0, 0.0), # red
|
||||||
|
ColorRGBW(1.0, 0.35, 0.0, 0.0), # orange
|
||||||
|
ColorRGBW(1.0, 0.8, 0.0, 0.0), # yellow
|
||||||
|
ColorRGBW(0.0, 0.9, 0.1, 0.0), # green
|
||||||
|
ColorRGBW(0.0, 0.3, 1.0, 0.0), # blue
|
||||||
|
ColorRGBW(0.6, 0.0, 1.0, 0.0), # violet
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Turning faster than this plays one tick, not a pile of overlapping ones.
|
||||||
|
TICK_INTERVAL = 0.06
|
||||||
|
#: Peak of each sound at full UI loudness, as a fraction of full scale.
|
||||||
|
TICK_GAIN = 0.25
|
||||||
|
CONFIRM_GAIN = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Tuning:
|
||||||
|
"""How the picker looks and behaves. Field names match
|
||||||
|
:class:`~musicmouse.config.FigureSelectionConfig`, whose values override these."""
|
||||||
|
|
||||||
|
timeout: float = 8.0 # seconds without a turn before the picker closes
|
||||||
|
#: Where the first section starts. The figure's swipe starts at LED 0 and meets
|
||||||
|
#: itself opposite, which is the front of the ring.
|
||||||
|
offset_degrees: float = 180.0
|
||||||
|
ring_direction: int = 1
|
||||||
|
knob_direction: int = 1
|
||||||
|
gap_leds: int = 1
|
||||||
|
dim_factor: float = 0.15
|
||||||
|
wobble_amplitude: float = 1.5 # LEDs
|
||||||
|
wobble_period_ms: float = 1000.0
|
||||||
|
slide_ms: float = 200.0
|
||||||
|
edge_blend: float = 0.25
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULTS = Tuning()
|
||||||
|
|
||||||
|
|
||||||
|
def tuning(app: App) -> Tuning:
|
||||||
|
""":data:`DEFAULTS`, with whatever the config's ``figure_selection`` overrides."""
|
||||||
|
overrides = app.config.general.figure_selection.model_dump(exclude_none=True)
|
||||||
|
return replace(DEFAULTS, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- reactions
|
||||||
|
|
||||||
|
|
||||||
|
@on(ActiveFigureChanged)
|
||||||
|
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||||
|
# Whatever replaces the figure - nothing, or another one - sets the ring itself.
|
||||||
|
if app.state.selector is not None:
|
||||||
|
_close(app, fade=False)
|
||||||
|
if event.figure is None:
|
||||||
|
return
|
||||||
|
choices = app.playlists.get(event.figure, ())
|
||||||
|
if len(choices) > 1:
|
||||||
|
_open(app, event.figure, choices)
|
||||||
|
|
||||||
|
|
||||||
|
@on(SelectorTurned)
|
||||||
|
def selector_turned(event: SelectorTurned, app: App) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
if selector is None:
|
||||||
|
return # a turn still in the queue when the picker closed
|
||||||
|
settings = tuning(app)
|
||||||
|
now = app.clock.now()
|
||||||
|
selector.deadline = now + settings.timeout
|
||||||
|
|
||||||
|
previous = selector.selected
|
||||||
|
selector.selected = (previous + event.steps * settings.knob_direction) % len(selector.choices)
|
||||||
|
if selector.selected == previous:
|
||||||
|
return
|
||||||
|
app.mouse.set_effect(LedZone.RING, _ring(app, selector, previous), origin="device")
|
||||||
|
if now - selector.last_tick >= TICK_INTERVAL:
|
||||||
|
selector.last_tick = now
|
||||||
|
_play_sound(app, tick_wav, TICK_GAIN)
|
||||||
|
_announce(app, selector, open_=True)
|
||||||
|
|
||||||
|
|
||||||
|
@on(SelectorPressed)
|
||||||
|
def selector_pressed(_event: SelectorPressed, app: App) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
if selector is None:
|
||||||
|
return
|
||||||
|
playlist = selector.choices[selector.selected]
|
||||||
|
_close(app, fade=True)
|
||||||
|
_play_sound(app, confirm_wav, CONFIRM_GAIN)
|
||||||
|
if app.player.playlist is playlist:
|
||||||
|
_log.info("Album picker: keeping %s", playlist.album_id)
|
||||||
|
return
|
||||||
|
_log.info("Album picker: %r -> %s", selector.figure, playlist.album_id)
|
||||||
|
if playlist.album_id is not None:
|
||||||
|
# The same way in as the web front-end, which also remembers the album as the
|
||||||
|
# figure's for next time.
|
||||||
|
app.bus.emit(PlayAlbumRequested(album_id=playlist.album_id, source="device"))
|
||||||
|
|
||||||
|
|
||||||
|
@on(FigureSelectorExpired)
|
||||||
|
def selector_expired(event: FigureSelectorExpired, app: App) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
# Stale if the picker it was for has closed, or a newer one is still in time.
|
||||||
|
if selector is None or selector.figure != event.figure:
|
||||||
|
return
|
||||||
|
if app.clock.now() < selector.deadline:
|
||||||
|
return
|
||||||
|
_close(app, fade=True)
|
||||||
|
|
||||||
|
|
||||||
|
@on(PlaylistFinished)
|
||||||
|
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||||
|
# The off animation takes the ring; the knob goes back to being a volume knob.
|
||||||
|
if app.state.selector is not None:
|
||||||
|
_close(app, fade=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- internals
|
||||||
|
|
||||||
|
|
||||||
|
def _open(app: App, figure: str, choices: tuple[Playlist, ...]) -> None:
|
||||||
|
selector = FigureSelector(
|
||||||
|
figure=figure,
|
||||||
|
choices=choices,
|
||||||
|
selected=app.default_choice(figure),
|
||||||
|
deadline=app.clock.now() + tuning(app).timeout,
|
||||||
|
)
|
||||||
|
app.state.selector = selector
|
||||||
|
app.mouse.capture_knob(True)
|
||||||
|
app.mouse.set_effect(LedZone.RING, _ring(app, selector, None), origin="device")
|
||||||
|
selector.expiry = asyncio.create_task(_expire(app, selector), name="album-picker")
|
||||||
|
_log.info("Album picker for %r: %d albums", figure, len(choices))
|
||||||
|
_announce(app, selector, open_=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _close(app: App, *, fade: bool) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
if selector is None:
|
||||||
|
return
|
||||||
|
app.state.selector = None
|
||||||
|
app.mouse.capture_knob(False)
|
||||||
|
if selector.expiry is not None:
|
||||||
|
selector.expiry.cancel()
|
||||||
|
if fade:
|
||||||
|
fade_to_figure(app, LedZone.RING, app.colors(selector.figure))
|
||||||
|
_announce(app, selector, open_=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _expire(app: App, selector: FigureSelector) -> None:
|
||||||
|
# Every turn pushes the deadline back, so sleep until it stops moving.
|
||||||
|
while (remaining := selector.deadline - app.clock.now()) > 0:
|
||||||
|
await app.clock.sleep(remaining)
|
||||||
|
app.bus.emit(FigureSelectorExpired(figure=selector.figure))
|
||||||
|
|
||||||
|
|
||||||
|
def _ring(app: App, selector: FigureSelector, previous: int | None) -> EffectSegmentSelect:
|
||||||
|
settings = tuning(app)
|
||||||
|
return EffectSegmentSelect(
|
||||||
|
colors=PALETTE[: len(selector.choices)],
|
||||||
|
selected=selector.selected,
|
||||||
|
previous=previous,
|
||||||
|
gap_leds=settings.gap_leds,
|
||||||
|
offset_degrees=settings.offset_degrees,
|
||||||
|
direction=settings.ring_direction,
|
||||||
|
dim_factor=settings.dim_factor,
|
||||||
|
wobble_amplitude=settings.wobble_amplitude,
|
||||||
|
wobble_period_ms=settings.wobble_period_ms,
|
||||||
|
slide_ms=settings.slide_ms,
|
||||||
|
edge_blend=settings.edge_blend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _announce(app: App, selector: FigureSelector, *, open_: bool) -> None:
|
||||||
|
app.bus.emit(
|
||||||
|
FigureSelectionChanged(
|
||||||
|
figure=selector.figure,
|
||||||
|
selected=selector.selected,
|
||||||
|
count=len(selector.choices),
|
||||||
|
open=open_,
|
||||||
|
source="device",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _play_sound(app: App, make: Callable[..., bytes], base_gain: float) -> None:
|
||||||
|
general = app.config.general
|
||||||
|
if general.audio_simulated:
|
||||||
|
return
|
||||||
|
gain = base_gain * ui_level(app.player.volume / 100, general.effects_volume / 100)
|
||||||
|
if gain > 0:
|
||||||
|
play_wav_soon(general.alsa_device, make(gain=gain))
|
||||||
@@ -6,8 +6,9 @@ async handlers, so running it there would hold every other event up behind ``xse
|
|||||||
Here each press gets its own task, and a hung or failing command is only logged.
|
Here each press gets its own task, and a hung or failing command is only logged.
|
||||||
|
|
||||||
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
||||||
ALSA device, at the player's volume. The command's last word of output says which way
|
ALSA device, as loud as UI sounds are (:func:`musicmouse.sound.ui_level`). The
|
||||||
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
|
command's last word of output says which way it went - ``on`` or ``off``, as
|
||||||
|
``kiosk-screen`` prints it; anything else gets no sound.
|
||||||
|
|
||||||
With an ``off_command`` it also switches the monitor off by itself once nothing has
|
With an ``off_command`` it also switches the monitor off by itself once nothing has
|
||||||
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
|
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
|
||||||
@@ -68,7 +69,8 @@ class ScreenService:
|
|||||||
self.config = config
|
self.config = config
|
||||||
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||||
self.alsa_device = alsa_device
|
self.alsa_device = alsa_device
|
||||||
#: The player's volume as a fraction, read at the moment the sound plays.
|
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
|
||||||
|
#: read at the moment the sound plays.
|
||||||
self.volume = volume
|
self.volume = volume
|
||||||
self.is_playing = is_playing
|
self.is_playing = is_playing
|
||||||
self._clock = clock or RealClock()
|
self._clock = clock or RealClock()
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ RING_SECONDS = 60.0
|
|||||||
|
|
||||||
#: The chime's peak at full player volume, as a fraction of full scale - soft.
|
#: The chime's peak at full player volume, as a fraction of full scale - soft.
|
||||||
_CHIME_GAIN = 0.3
|
_CHIME_GAIN = 0.3
|
||||||
#: Never quieter than at this player volume: a muted player must not mute the timer.
|
|
||||||
_CHIME_MIN_VOLUME = 0.3
|
|
||||||
|
|
||||||
#: After a failed holiday fetch, try again this much later rather than at midnight.
|
#: After a failed holiday fetch, try again this much later rather than at midnight.
|
||||||
_RETRY_SECONDS = 3600.0
|
_RETRY_SECONDS = 3600.0
|
||||||
@@ -94,7 +92,8 @@ class TimerService:
|
|||||||
self.client = client
|
self.client = client
|
||||||
#: Where the chime plays, or ``None`` for no sound (simulated audio).
|
#: Where the chime plays, or ``None`` for no sound (simulated audio).
|
||||||
self.alsa_device = alsa_device
|
self.alsa_device = alsa_device
|
||||||
#: The player's volume as a fraction, read when the chime plays.
|
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
|
||||||
|
#: read when the chime plays.
|
||||||
self.volume = volume
|
self.volume = volume
|
||||||
self._clock = clock or RealClock()
|
self._clock = clock or RealClock()
|
||||||
self._wall = wall
|
self._wall = wall
|
||||||
@@ -211,7 +210,7 @@ class TimerService:
|
|||||||
async def _chime(self) -> None:
|
async def _chime(self) -> None:
|
||||||
if self.alsa_device is None:
|
if self.alsa_device is None:
|
||||||
return
|
return
|
||||||
gain = _CHIME_GAIN * max(_CHIME_MIN_VOLUME, min(1.0, self.volume()))
|
gain = _CHIME_GAIN * max(0.0, min(1.0, self.volume()))
|
||||||
wav = await asyncio.to_thread(chime_wav, gain=gain)
|
wav = await asyncio.to_thread(chime_wav, gain=gain)
|
||||||
await play_wav(self.alsa_device, wav)
|
await play_wav(self.alsa_device, wav)
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ class SettingsOut(BaseModel):
|
|||||||
initial_volume: int
|
initial_volume: int
|
||||||
volume_increment: int
|
volume_increment: int
|
||||||
button_leds_brightness: float
|
button_leds_brightness: float
|
||||||
|
effects_volume: int
|
||||||
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
|
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
|
||||||
#: switch off (no ``screen`` section, or one without an ``off_command``).
|
#: switch off (no ``screen`` section, or one without an ``off_command``).
|
||||||
screen_auto_off_minutes: int | None
|
screen_auto_off_minutes: int | None
|
||||||
@@ -304,6 +305,8 @@ class SettingsIn(BaseModel):
|
|||||||
initial_volume: int = Field(ge=0, le=200)
|
initial_volume: int = Field(ge=0, le=200)
|
||||||
volume_increment: int = Field(ge=1, le=100)
|
volume_increment: int = Field(ge=1, le=100)
|
||||||
button_leds_brightness: float = Field(ge=0, le=1)
|
button_leds_brightness: float = Field(ge=0, le=1)
|
||||||
|
#: UI sounds relative to the music, in percent.
|
||||||
|
effects_volume: int = Field(ge=0, le=100)
|
||||||
#: Ignored without a screen section. ``None`` leaves it as it is.
|
#: Ignored without a screen section. ``None`` leaves it as it is.
|
||||||
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
|
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def read_settings(general: GeneralConfig) -> SettingsOut:
|
|||||||
initial_volume=general.initial_volume,
|
initial_volume=general.initial_volume,
|
||||||
volume_increment=general.volume_increment,
|
volume_increment=general.volume_increment,
|
||||||
button_leds_brightness=general.button_leds_brightness,
|
button_leds_brightness=general.button_leds_brightness,
|
||||||
|
effects_volume=general.effects_volume,
|
||||||
screen_auto_off_minutes=(
|
screen_auto_off_minutes=(
|
||||||
screen.auto_off_minutes if screen is not None and screen.off_command else None
|
screen.auto_off_minutes if screen is not None and screen.off_command else None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
``aplay`` in a subprocess, fed the WAV on stdin: no audio library to load, and a hung
|
``aplay`` in a subprocess, fed the WAV on stdin: no audio library to load, and a hung
|
||||||
or missing ``aplay`` costs a log line, never the event loop.
|
or missing ``aplay`` costs a log line, never the event loop.
|
||||||
|
|
||||||
|
Every UI sound - the screen's blubb, the parents' timer, the album picker's clicks -
|
||||||
|
is as loud as :func:`ui_level` says, times its own base gain.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,7 +14,32 @@ import logging
|
|||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["play_wav"]
|
__all__ = ["MIN_PLAYER_VOLUME", "play_wav", "play_wav_soon", "ui_level"]
|
||||||
|
|
||||||
|
#: UI sounds are never quieter than at this player volume (0..1): a muted player must
|
||||||
|
#: not mute the timer, nor leave turning the album picker silent.
|
||||||
|
MIN_PLAYER_VOLUME = 0.3
|
||||||
|
|
||||||
|
#: Sounds started with :func:`play_wav_soon`, kept so they are not collected mid-play.
|
||||||
|
_playing: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def ui_level(player_volume: float, effects_volume: float) -> float:
|
||||||
|
"""How loud a UI sound plays, 0..1, before its own base gain.
|
||||||
|
|
||||||
|
``player_volume`` is the music's (0..1), floored at :data:`MIN_PLAYER_VOLUME`;
|
||||||
|
``effects_volume`` is the parent-mode setting (0..1) for sounds relative to music.
|
||||||
|
"""
|
||||||
|
player = max(MIN_PLAYER_VOLUME, min(1.0, player_volume))
|
||||||
|
return player * max(0.0, min(1.0, effects_volume))
|
||||||
|
|
||||||
|
|
||||||
|
def play_wav_soon(alsa_device: str, wav: bytes) -> None:
|
||||||
|
"""Start playing ``wav`` without waiting for it - for callers on the event bus,
|
||||||
|
which must not stall every other event for the length of a sound."""
|
||||||
|
task = asyncio.create_task(play_wav(alsa_device, wav), name="ui-sound")
|
||||||
|
_playing.add(task)
|
||||||
|
task.add_done_callback(_playing.discard)
|
||||||
|
|
||||||
|
|
||||||
async def play_wav(alsa_device: str, wav: bytes, *, timeout: float = 10.0) -> None:
|
async def play_wav(alsa_device: str, wav: bytes, *, timeout: float = 10.0) -> None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from musicmouse.effects import (
|
|||||||
EffectCircularConfig,
|
EffectCircularConfig,
|
||||||
EffectRandomTwoColorInterpolationConfig,
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
EffectReverseSwipe,
|
EffectReverseSwipe,
|
||||||
|
EffectSegmentSelect,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
EffectStaticDetailedConfig,
|
EffectStaticDetailedConfig,
|
||||||
EffectSwipeAndChange,
|
EffectSwipeAndChange,
|
||||||
@@ -135,7 +136,54 @@ def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
|
|||||||
(EffectRandomTwoColorInterpolationConfig(), 35),
|
(EffectRandomTwoColorInterpolationConfig(), 35),
|
||||||
(EffectReverseSwipe(), 12),
|
(EffectReverseSwipe(), 12),
|
||||||
(EffectSwipeAndChange(), 64),
|
(EffectSwipeAndChange(), 64),
|
||||||
|
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_payload_sizes(effect: LedEffect, size: int) -> None:
|
def test_payload_sizes(effect: LedEffect, size: int) -> None:
|
||||||
assert len(effect.as_bytes()) == size
|
assert len(effect.as_bytes()) == size
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_select() -> None:
|
||||||
|
effect = EffectSegmentSelect(
|
||||||
|
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 0, 1, 0)),
|
||||||
|
selected=1,
|
||||||
|
previous=0,
|
||||||
|
gap_leds=2,
|
||||||
|
offset_degrees=180.0,
|
||||||
|
direction=-1,
|
||||||
|
dim_factor=0.5,
|
||||||
|
wobble_amplitude=1.5,
|
||||||
|
wobble_period_ms=1000.0,
|
||||||
|
slide_ms=200.0,
|
||||||
|
edge_blend=0.25,
|
||||||
|
)
|
||||||
|
assert effect.as_bytes().hex() == (
|
||||||
|
"02" "01" "00" "02" # count, selected, previous, gap
|
||||||
|
"ff000000" "0000ff00" + "00000000" * 4 # always six colours on the wire
|
||||||
|
+ "00003443" # offset 180.0
|
||||||
|
"ff" # direction -1
|
||||||
|
"0000003f" # dim 0.5
|
||||||
|
"0000c03f" # wobble 1.5
|
||||||
|
"00007a44" # period 1000.0
|
||||||
|
"00004843" # slide 200.0
|
||||||
|
"0000803e" # edge blend 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_select_without_previous_does_not_glide() -> None:
|
||||||
|
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * 3, selected=2)
|
||||||
|
assert effect.as_bytes()[:3] == bytes([3, 2, 2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_select_round_trips() -> None:
|
||||||
|
# Every float exact in float32, as the firmware has them.
|
||||||
|
effect = EffectSegmentSelect(
|
||||||
|
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)), previous=1, dim_factor=0.25
|
||||||
|
)
|
||||||
|
assert EffectSegmentSelect.from_bytes(effect.as_bytes()) == effect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("count", [0, 7])
|
||||||
|
def test_segment_select_takes_one_to_six_sections(count: int) -> None:
|
||||||
|
with pytest.raises(ValueError, match="between 1 and 6"):
|
||||||
|
EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * count)
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||||
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
|
from musicmouse.effects import SEGMENT_SELECT_MAX_SEGMENTS
|
||||||
|
from musicmouse.library import (
|
||||||
|
MAX_FIGURE_ALBUMS,
|
||||||
|
Album,
|
||||||
|
Analyzer,
|
||||||
|
MusicLibrary,
|
||||||
|
default_worker_count,
|
||||||
|
)
|
||||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||||
from musicmouse.library.cache import LibraryCache
|
from musicmouse.library.cache import LibraryCache
|
||||||
from musicmouse.library.colors import colors_from_id
|
from musicmouse.library.colors import colors_from_id
|
||||||
@@ -92,15 +99,72 @@ async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> No
|
|||||||
playlists = library.figure_playlists()
|
playlists = library.figure_playlists()
|
||||||
|
|
||||||
assert set(playlists) == {"fuchs", "eule"}
|
assert set(playlists) == {"fuchs", "eule"}
|
||||||
assert len(playlists["fuchs"]) == 3
|
(fuchs,) = playlists["fuchs"]
|
||||||
assert [track.path.name for track in playlists["fuchs"].tracks] == [
|
assert len(fuchs) == 3
|
||||||
|
assert [track.path.name for track in fuchs.tracks] == [
|
||||||
"00 - track.mp3",
|
"00 - track.mp3",
|
||||||
"01 - track.mp3",
|
"01 - track.mp3",
|
||||||
"02 - track.mp3",
|
"02 - track.mp3",
|
||||||
]
|
]
|
||||||
# The playlist carries the album it came from, which is how a front-end answers
|
# The playlist carries the album it came from, which is how a front-end answers
|
||||||
# "what is playing?" without keeping its own copy of that state.
|
# "what is playing?" without keeping its own copy of that state.
|
||||||
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id
|
assert fuchs.album_id == album_named(library, "Fuchs").id
|
||||||
|
|
||||||
|
|
||||||
|
def _figure_with_albums(config_dir: Path, figure: str, *folders: str) -> None:
|
||||||
|
"""Replace a figure's audio with one album per folder (relative to the figure)."""
|
||||||
|
figure_folder = config_dir / "music" / "Figuren" / figure
|
||||||
|
for track in figure_folder.glob("*.mp3"):
|
||||||
|
track.unlink()
|
||||||
|
for folder in folders:
|
||||||
|
write_track(figure_folder / folder / "01 - track.mp3", title=f"In {folder}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_with_album_subfolders_offers_each_in_folder_order(
|
||||||
|
config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
# Depth-first and alphabetical, however deep an album sits.
|
||||||
|
_figure_with_albums(
|
||||||
|
config_dir, "fuchs", "B Lieder", "A Geschichten/Teil 2", "A Geschichten/Teil 1"
|
||||||
|
)
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
choices = library.figure_playlists()["fuchs"]
|
||||||
|
|
||||||
|
albums = [library.get(playlist.album_id) for playlist in choices]
|
||||||
|
assert [album.title if album else None for album in albums] == [
|
||||||
|
"Fuchs - Teil 1",
|
||||||
|
"Fuchs - Teil 2",
|
||||||
|
"Fuchs - B Lieder",
|
||||||
|
]
|
||||||
|
assert all(album is not None and album.figure == "fuchs" for album in albums)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_album_subfolder_takes_the_figures_kind(config_dir: Path) -> None:
|
||||||
|
_figure_with_albums(config_dir, "eule", "Folge 1", "Folge 2")
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
albums = [library.get(p.album_id) for p in library.figure_playlists()["eule"]]
|
||||||
|
assert len(albums) == 2
|
||||||
|
assert {album.kind if album else None for album in albums} == {"book"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_offers_no_more_albums_than_the_ring_has_room_for(
|
||||||
|
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
_figure_with_albums(config_dir, "fuchs", *(f"Album {n}" for n in range(MAX_FIGURE_ALBUMS + 2)))
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
choices = library.figure_playlists()["fuchs"]
|
||||||
|
|
||||||
|
assert len(choices) == MAX_FIGURE_ALBUMS
|
||||||
|
assert "only the first 6" in caplog.text
|
||||||
|
# Browsing still shows every one of them.
|
||||||
|
assert sum(album.figure == "fuchs" for album in library.albums) == MAX_FIGURE_ALBUMS + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_album_cap_is_the_rings_section_count() -> None:
|
||||||
|
assert MAX_FIGURE_ALBUMS == SEGMENT_SELECT_MAX_SEGMENTS
|
||||||
|
|
||||||
|
|
||||||
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
|
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from musicmouse.effects import (
|
|||||||
OFF,
|
OFF,
|
||||||
EffectAlexaSwipeConfig,
|
EffectAlexaSwipeConfig,
|
||||||
EffectCircularConfig,
|
EffectCircularConfig,
|
||||||
|
EffectSegmentSelect,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
)
|
)
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
@@ -23,9 +24,12 @@ from musicmouse.events import (
|
|||||||
Event,
|
Event,
|
||||||
LedEffectChanged,
|
LedEffectChanged,
|
||||||
RfidTokenRead,
|
RfidTokenRead,
|
||||||
|
RotaryTurned,
|
||||||
|
SelectorPressed,
|
||||||
|
SelectorTurned,
|
||||||
TouchButtonPressed,
|
TouchButtonPressed,
|
||||||
)
|
)
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
from musicmouse.simulator import FakeTransport
|
from musicmouse.simulator import FakeTransport
|
||||||
|
|
||||||
FUCHS = bytes.fromhex("04a1b2c3d4")
|
FUCHS = bytes.fromhex("04a1b2c3d4")
|
||||||
@@ -82,6 +86,56 @@ async def test_button_press_reaches_the_bus(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _turn(increment: int, direction: RotaryDirection) -> bytes:
|
||||||
|
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
|
||||||
|
|
||||||
|
|
||||||
|
def _knob(action: ButtonAction) -> bytes:
|
||||||
|
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_captured_knob_turns_the_selector_not_the_volume(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
device.capture_knob(True)
|
||||||
|
device.feed(_turn(2, RotaryDirection.UP))
|
||||||
|
device.feed(_turn(1, RotaryDirection.DOWN))
|
||||||
|
# The first turn after boot reports no increment, but it was still a detent.
|
||||||
|
device.feed(_turn(0, RotaryDirection.UP))
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
|
||||||
|
assert not only(seen, RotaryTurned)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_captured_knob_press_is_the_selectors_alone(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
device.capture_knob(True)
|
||||||
|
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
|
||||||
|
device.feed(_knob(action))
|
||||||
|
# The other buttons are not captured.
|
||||||
|
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert len(only(seen, SelectorPressed)) == 1
|
||||||
|
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_released_knob_is_a_volume_knob_again(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
device.capture_knob(True)
|
||||||
|
device.capture_knob(False)
|
||||||
|
device.feed(_turn(1, RotaryDirection.UP))
|
||||||
|
device.feed(_knob(ButtonAction.PRESSED))
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert len(only(seen, RotaryTurned)) == 1
|
||||||
|
assert len(only(seen, ButtonEvent)) == 1
|
||||||
|
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
|
||||||
|
|
||||||
|
|
||||||
async def test_touch_press_reaches_the_bus(
|
async def test_touch_press_reaches_the_bus(
|
||||||
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -255,6 +309,19 @@ async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
|
|||||||
assert "cannot be sent to the mouse LEDs" in caplog.text
|
assert "cannot be sent to the mouse LEDs" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_the_ring_can_be_a_selector(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
|
||||||
|
) -> None:
|
||||||
|
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
|
||||||
|
device.set_effect(LedZone.RING, effect)
|
||||||
|
device.set_effect(LedZone.SHELF, effect)
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
sent = transport.effect(LedZone.RING)
|
||||||
|
assert sent is not None and sent.as_bytes() == effect.as_bytes()
|
||||||
|
assert transport.effect(LedZone.SHELF) is None
|
||||||
|
|
||||||
|
|
||||||
async def test_button_brightness_sets_both_backlights(
|
async def test_button_brightness_sets_both_backlights(
|
||||||
device: MusicMouseDevice, transport: FakeTransport
|
device: MusicMouseDevice, transport: FakeTransport
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
371
python-backend/tests/test_selection.py
Normal file
371
python-backend/tests/test_selection.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.blubb import confirm_wav, tick_wav
|
||||||
|
from musicmouse.config import load_config
|
||||||
|
from musicmouse.effects import (
|
||||||
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
|
EffectReverseSwipe,
|
||||||
|
EffectSegmentSelect,
|
||||||
|
EffectSwipeAndChange,
|
||||||
|
)
|
||||||
|
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
|
from musicmouse.reactions import selection
|
||||||
|
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
|
||||||
|
from musicmouse.simulator.driver import SimulatorDriver
|
||||||
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
|
from musicmouse.sound import ui_level
|
||||||
|
from tests.conftest import VALID_CONFIG, write_config, write_track
|
||||||
|
|
||||||
|
TRACK_SECONDS = 10.0
|
||||||
|
TIMEOUT = DEFAULTS.timeout
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def _albums(config_dir: Path) -> None:
|
||||||
|
"""Give the fox three albums of two tracks each; the owl keeps its one."""
|
||||||
|
fuchs = config_dir / "music" / "Figuren" / "fuchs"
|
||||||
|
for track in fuchs.glob("*.mp3"):
|
||||||
|
track.unlink()
|
||||||
|
for folder in ("A", "B", "C"):
|
||||||
|
for index in range(2):
|
||||||
|
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
|
||||||
|
_albums(config_dir)
|
||||||
|
return await build_simulation(
|
||||||
|
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||||
|
simulation = await _simulation(config_dir, VALID_CONFIG)
|
||||||
|
try:
|
||||||
|
yield simulation
|
||||||
|
finally:
|
||||||
|
await simulation.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mouse(sim: Simulation) -> SimulatorDriver:
|
||||||
|
return sim.driver
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seen(sim: Simulation) -> list[Event]:
|
||||||
|
events: list[Event] = []
|
||||||
|
sim.bus.subscribe_all(events.append)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
def ring(sim: Simulation) -> object:
|
||||||
|
return sim.app.mouse.effect(LedZone.RING)
|
||||||
|
|
||||||
|
|
||||||
|
def picker(sim: Simulation) -> EffectSegmentSelect:
|
||||||
|
effect = ring(sim)
|
||||||
|
assert isinstance(effect, EffectSegmentSelect)
|
||||||
|
return effect
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- opening
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
effect = picker(sim)
|
||||||
|
assert effect.colors == PALETTE[:3]
|
||||||
|
assert effect.selected == 0
|
||||||
|
# Only the ring is the picker; the body and shelf run the usual start animation.
|
||||||
|
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
|
||||||
|
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_with_one_album_gets_no_picker(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("eule")
|
||||||
|
|
||||||
|
assert isinstance(ring(sim), EffectSwipeAndChange)
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
assert not sim.app.mouse.knob_captured
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
|
||||||
|
config = deepcopy(VALID_CONFIG)
|
||||||
|
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
|
||||||
|
sim = await _simulation(config_dir, config)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
effect = picker(sim)
|
||||||
|
assert effect.offset_degrees == 90
|
||||||
|
assert effect.wobble_amplitude == 3
|
||||||
|
assert effect.slide_ms == DEFAULTS.slide_ms
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- knob
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turning_moves_the_selection_instead_of_the_volume(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
volume = sim.player.volume
|
||||||
|
|
||||||
|
await mouse.turn(1)
|
||||||
|
|
||||||
|
effect = picker(sim)
|
||||||
|
assert (effect.previous, effect.selected) == (0, 1)
|
||||||
|
assert sim.player.volume == volume
|
||||||
|
assert not only(seen, RotaryTurned)
|
||||||
|
assert only(seen, FigureSelectionChanged)[-1].selected == 1
|
||||||
|
# Turning only moves the highlight; what plays changes on a press.
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
await mouse.turn(-1)
|
||||||
|
assert picker(sim).selected == 2
|
||||||
|
await mouse.turn(2)
|
||||||
|
assert picker(sim).selected == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(2)
|
||||||
|
|
||||||
|
await mouse.press("rotary")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - C")
|
||||||
|
mouse.check("track", "0")
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
effect = ring(sim)
|
||||||
|
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
|
||||||
|
assert effect.start_with_existing # a fade from the sections, not a jump
|
||||||
|
# The press was the picker's: Home Assistant never hears of it.
|
||||||
|
assert not only(seen, ButtonEvent)
|
||||||
|
assert only(seen, FigureSelectionChanged)[-1].open is False
|
||||||
|
|
||||||
|
volume = sim.player.volume
|
||||||
|
await mouse.turn(-1)
|
||||||
|
assert sim.player.volume < volume
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.wait(3)
|
||||||
|
|
||||||
|
await mouse.press("rotary")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
assert sim.player.position == pytest.approx(3.0)
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
|
||||||
|
mouse: SimulatorDriver, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.press("rotary")
|
||||||
|
|
||||||
|
assert len(only(seen, ButtonEvent)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ timeout
|
||||||
|
|
||||||
|
|
||||||
|
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
|
||||||
|
await mouse.wait(TIMEOUT + 0.1)
|
||||||
|
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
assert not sim.app.mouse.knob_captured
|
||||||
|
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.wait(TIMEOUT - 2)
|
||||||
|
await mouse.turn(1)
|
||||||
|
|
||||||
|
await mouse.wait(TIMEOUT - 1)
|
||||||
|
assert sim.app.state.selector is not None
|
||||||
|
|
||||||
|
await mouse.wait(1.1)
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifting_the_figure_closes_the_picker(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
assert not sim.app.mouse.knob_captured
|
||||||
|
assert isinstance(ring(sim), EffectReverseSwipe)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- memory
|
||||||
|
|
||||||
|
|
||||||
|
async def test_putting_the_figure_back_resumes_the_picked_album(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.wait(3)
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - B")
|
||||||
|
assert sim.player.position == pytest.approx(3.0)
|
||||||
|
assert picker(sim).selected == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.wait(TRACK_SECONDS + 3)
|
||||||
|
await mouse.remove()
|
||||||
|
await mouse.place("eule")
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - B")
|
||||||
|
mouse.check("track", "0")
|
||||||
|
assert sim.player.position == pytest.approx(0.0)
|
||||||
|
assert picker(sim).selected == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
|
||||||
|
mouse: SimulatorDriver,
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.wait(2 * TRACK_SECONDS + 1)
|
||||||
|
mouse.check("playing", "false")
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - B")
|
||||||
|
mouse.check("track", "0")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.play_album("Fuchs - C")
|
||||||
|
await mouse.wait(2)
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - C")
|
||||||
|
assert picker(sim).selected == 2
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------- sound
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
|
||||||
|
played: list[tuple[str, bytes]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
|
||||||
|
)
|
||||||
|
return played
|
||||||
|
|
||||||
|
|
||||||
|
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
|
||||||
|
config = deepcopy(VALID_CONFIG)
|
||||||
|
config["general"]["alsa_device"] = "softvol_effects"
|
||||||
|
config["general"]["effects_volume"] = effects_volume
|
||||||
|
return await _simulation(config_dir, config)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
|
||||||
|
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
sim = await _audible(config_dir)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.driver.turn(1)
|
||||||
|
await sim.driver.turn(1) # no time passed: one tick for both
|
||||||
|
await sim.driver.wait(0.1)
|
||||||
|
await sim.driver.turn(1)
|
||||||
|
|
||||||
|
level = ui_level(sim.player.volume / 100, 1.0)
|
||||||
|
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_picking_an_album_plays_the_confirm_sound(
|
||||||
|
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
sim = await _audible(config_dir, effects_volume=50)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.driver.press("rotary")
|
||||||
|
|
||||||
|
level = ui_level(sim.player.volume / 100, 0.5)
|
||||||
|
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_picker_sounds_with_effects_turned_off(
|
||||||
|
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
sim = await _audible(config_dir, effects_volume=0)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.driver.turn(1)
|
||||||
|
await sim.driver.press("rotary")
|
||||||
|
assert sounds == []
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
@@ -20,6 +20,7 @@ SETTINGS = {
|
|||||||
"initial_volume": 40,
|
"initial_volume": 40,
|
||||||
"volume_increment": 5,
|
"volume_increment": 5,
|
||||||
"button_leds_brightness": 0.5,
|
"button_leds_brightness": 0.5,
|
||||||
|
"effects_volume": 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
#: A config file with comments, to prove saving does not flatten them.
|
#: A config file with comments, to prove saving does not flatten them.
|
||||||
|
|||||||
47
python-backend/tests/test_sound.py
Normal file
47
python-backend/tests/test_sound.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""How loud UI sounds are, and the album picker's two sounds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.blubb import RATE, confirm_wav, tick_wav
|
||||||
|
from musicmouse.sound import MIN_PLAYER_VOLUME, ui_level
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_sounds_follow_the_music_volume() -> None:
|
||||||
|
assert ui_level(0.8, 1.0) == pytest.approx(0.8)
|
||||||
|
assert ui_level(1.0, 0.5) == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_sounds_never_drop_below_the_minimum_player_volume() -> None:
|
||||||
|
# A muted player must not mute the timer, nor make the album picker silent.
|
||||||
|
assert ui_level(0.0, 1.0) == pytest.approx(MIN_PLAYER_VOLUME)
|
||||||
|
assert ui_level(0.0, 0.5) == pytest.approx(MIN_PLAYER_VOLUME * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parents_can_still_turn_ui_sounds_off() -> None:
|
||||||
|
assert ui_level(0.8, 0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("make", "longest"), [(tick_wav, 0.05), (confirm_wav, 0.4)])
|
||||||
|
def test_the_picker_sounds_are_short_mono_wavs(make: Callable[..., bytes], longest: float) -> None:
|
||||||
|
with wave.open(io.BytesIO(make(gain=0.5))) as wav:
|
||||||
|
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
||||||
|
assert 0 < wav.getnframes() / RATE < longest
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gain_sets_the_peak() -> None:
|
||||||
|
def peak(data: bytes) -> int:
|
||||||
|
with wave.open(io.BytesIO(data)) as wav:
|
||||||
|
frames = wav.readframes(wav.getnframes())
|
||||||
|
return max(
|
||||||
|
abs(int.from_bytes(frames[i : i + 2], "little", signed=True))
|
||||||
|
for i in range(0, len(frames), 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert peak(tick_wav(gain=1.0)) == pytest.approx(32767, abs=1)
|
||||||
|
assert peak(tick_wav(gain=0.25)) == pytest.approx(32767 / 4, abs=1)
|
||||||
@@ -207,7 +207,7 @@ async def test_playing_a_figure_album_reuses_the_figure_playlist(
|
|||||||
await client.post("/api/play", json={"album_id": album["id"]})
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
|
|
||||||
assert sim.player.playlist is sim.app.playlists["fuchs"]
|
assert sim.player.playlist is sim.app.playlists["fuchs"][0]
|
||||||
|
|
||||||
|
|
||||||
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface Settings {
|
|||||||
initial_volume: number;
|
initial_volume: number;
|
||||||
volume_increment: number;
|
volume_increment: number;
|
||||||
button_leds_brightness: number;
|
button_leds_brightness: number;
|
||||||
|
/** UI sounds (screen, timer, album picker) relative to the music, in percent. */
|
||||||
|
effects_volume: number;
|
||||||
/** Minutes without music before the device's screen switches itself off, 0 = never.
|
/** Minutes without music before the device's screen switches itself off, 0 = never.
|
||||||
* `null` when there is no screen to switch (no `off_command` configured). */
|
* `null` when there is no screen to switch (no `off_command` configured). */
|
||||||
screen_auto_off_minutes: number | null;
|
screen_auto_off_minutes: number | null;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const FIELDS: Array<[AlwaysSetting, string, string, number, number, number]> = [
|
|||||||
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
||||||
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
|
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
|
||||||
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
||||||
|
["effects_volume", "Effekt-Lautstärke", "Klicks und Signaltöne, in % der Musik", 0, 100, 5],
|
||||||
];
|
];
|
||||||
|
|
||||||
function NumberField({
|
function NumberField({
|
||||||
|
|||||||
Reference in New Issue
Block a user