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

A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:29:18 +02:00
parent a55cc4f67f
commit d465a74cde
37 changed files with 1570 additions and 68 deletions

View File

@@ -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:
```
<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>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
<root>/Kinderpodcasts/<Show>/ shows, newest episode first

View File

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

View File

@@ -0,0 +1,212 @@
#pragma once
#include "effects/Common.h"
#include "helpers/ColorRGBW.h"
#include "helpers/ColorHSV.h"
#include "helpers/ColorConversions.h"
#include <cmath>
#include <cstdint>
static constexpr int SEGMENT_SELECT_MAX_SEGMENTS = 6;
// The ring as a picker: one section per choice, the selected one bright and wobbling.
//
// The host re-sends this on every rotary detent. Each message builds a fresh effect, so
// `previous` is what makes the highlight glide over from where it was instead of
// jumping; previous == selected means no glide.
#pragma pack(push, 1)
struct EffectSegmentSelectConfig
{
uint8_t count; // number of sections, 1..SEGMENT_SELECT_MAX_SEGMENTS
uint8_t selected; // index of the highlighted section
uint8_t previous; // index the highlight glides from
uint8_t gapLeds; // dark LEDs between neighbouring sections
ColorRGBW colors[SEGMENT_SELECT_MAX_SEGMENTS];
float offsetDegrees; // where section 0 starts
int8_t direction; // +1 / -1: which way round the sections go
float dimFactor; // brightness of the sections that are not selected, 0..1
float wobbleAmplitude; // in LEDs
float wobblePeriodMs;
float slideMs; // time for the highlight to glide from `previous` to `selected`
float edgeBlend; // 0..0.5: how far a section's edges shift towards its neighbours' hue
};
#pragma pack(pop)
template <typename TLedStrip>
class EffectSegmentSelect
{
public:
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
static constexpr int DELAY_MS = 10;
using ConfigType = EffectSegmentSelectConfig;
EffectSegmentSelect(const EffectSegmentSelectConfig &cfg, TLedStrip &ledStrip)
: config_(cfg),
ledStrip_(ledStrip)
{
if (config_.count > SEGMENT_SELECT_MAX_SEGMENTS)
config_.count = SEGMENT_SELECT_MAX_SEGMENTS;
if (config_.count > 0)
{
config_.selected %= config_.count;
config_.previous %= config_.count;
}
if (config_.direction == 0)
config_.direction = 1;
pitch_ = float(NUM_LEDS) / float(config_.count > 0 ? config_.count : 1);
halfWidth_ = std::max(0.5f, (pitch_ - float(config_.gapLeds)) / 2.0f);
offsetLeds_ = config_.offsetDegrees / 360.0f * float(NUM_LEDS);
// The glide takes the short way round, so going from the last section to the first
// moves one step forward rather than all the way back.
int delta = int(config_.selected) - int(config_.previous);
if (delta > config_.count / 2)
delta -= config_.count;
else if (delta < -(config_.count / 2))
delta += config_.count;
glideFrom_ = float(int(config_.selected) - delta);
// The dimmed background never changes, so it is computed once.
for (int led = 0; led < NUM_LEDS; ++led)
{
const float u = ringPosition(led);
const int section = std::min(int(u / pitch_), int(config_.count) - 1);
ColorRGBW color{0, 0, 0, 0};
if (config_.count > 0)
{
const float center = (float(section) + 0.5f) * pitch_;
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(u - center));
color = sectionColor(section, (u - center) / pitch_) * (coverage * config_.dimFactor);
}
background_[led] = color;
}
}
int operator()()
{
if (config_.count == 0)
{
clear(ledStrip_);
return 10000;
}
const float glide = config_.slideMs > 0 ? clamp01(elapsedMs_ / config_.slideMs) : 1.0f;
const float eased = glide * glide * (3.0f - 2.0f * glide); // smoothstep
const float index = glideFrom_ + (float(config_.selected) - glideFrom_) * eased;
float wobble = 0;
if (config_.wobblePeriodMs > 0)
wobble = config_.wobbleAmplitude * std::sin(2.0f * float(M_PI) * elapsedMs_ / config_.wobblePeriodMs);
const float center = (index + 0.5f) * pitch_ + wobble;
for (int led = 0; led < NUM_LEDS; ++led)
{
const float distance = wrappedDistance(ringPosition(led) - center);
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(distance));
if (coverage <= 0)
{
setLedRGBW(ledStrip_, led, background_[led]);
continue;
}
// The highlight carries the selected section's own gradient along with it, and
// changes colour from the previous section's as it glides.
const float local = distance / pitch_;
const ColorRGBW from = sectionColor(config_.previous, local);
const ColorRGBW to = sectionColor(config_.selected, local);
const ColorRGBW highlight = ColorRGBW::interpolate(from, to, eased);
setLedRGBW(ledStrip_, led, ColorRGBW::interpolate(background_[led], highlight, coverage));
}
elapsedMs_ += DELAY_MS;
return DELAY_MS;
}
private:
static float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
// An LED's position along the sections, 0..NUM_LEDS, section 0 starting at 0.
float ringPosition(int led) const
{
float u = float(config_.direction) * (float(led) + 0.5f - offsetLeds_);
u = std::fmod(u, float(NUM_LEDS));
return u < 0 ? u + float(NUM_LEDS) : u;
}
// Shortest signed distance on the ring.
static float wrappedDistance(float d)
{
const float n = float(NUM_LEDS);
d = std::fmod(d, n);
if (d > n / 2)
d -= n;
else if (d < -n / 2)
d += n;
return d;
}
// Colour of a section at `local`, -0.5 (its start) .. +0.5 (its end): its palette
// colour in the middle, shifting in hue towards each neighbour by up to edgeBlend.
ColorRGBW sectionColor(int section, float local) const
{
const int count = config_.count;
const int neighbour = local < 0 ? (section + count - 1) % count : (section + 1) % count;
const float amount = config_.edgeBlend * clamp01(std::fabs(local) * 2.0f);
return blendHue(config_.colors[section], config_.colors[neighbour], amount);
}
static ColorRGBW blendHue(const ColorRGBW &c1, const ColorRGBW &c2, float f)
{
if (f <= 0)
return c1;
ColorHSV h1 = rgb2hsv(c1);
ColorHSV h2 = rgb2hsv(c2);
// Greys have no hue of their own; borrow the other's so only saturation moves.
if (h1.s <= 0)
h1.h = h2.h;
if (h2.s <= 0)
h2.h = h1.h;
float dh = h2.h - h1.h;
if (dh > 180)
dh -= 360;
else if (dh < -180)
dh += 360;
float hue = h1.h + f * dh;
if (hue < 0)
hue += 360;
else if (hue >= 360)
hue -= 360;
ColorRGBW out = hsv2rgb(ColorHSV{hue, h1.s + f * (h2.s - h1.s), h1.v + f * (h2.v - h1.v)});
out.w = uint8_t(float(c1.w) + f * (float(c2.w) - float(c1.w)));
return out;
}
EffectSegmentSelectConfig config_;
TLedStrip &ledStrip_;
float pitch_ = 0;
float halfWidth_ = 0;
float offsetLeds_ = 0;
float glideFrom_ = 0;
float elapsedMs_ = 0;
ColorRGBW background_[NUM_LEDS];
};
// Traits
template <>
struct EffectIdToConfig<EffectId::SEGMENT_SELECT>
{
using type = EffectSegmentSelectConfig;
};
template <>
struct EffectConfigToId<EffectSegmentSelectConfig>
{
static constexpr auto id = EffectId::SEGMENT_SELECT;
};
template <typename TLedStrip>
struct EffectIdToClass<EffectId::SEGMENT_SELECT, TLedStrip>
{
using type = EffectSegmentSelect<TLedStrip>;
};

View File

@@ -4,6 +4,7 @@
#include "effects/ReverseSwipe.h"
#include "effects/RandomTwoColorInterpolation.h"
#include "effects/SwipeAndChange.h"
#include "effects/SegmentSelect.h"
#include "Arduino.h"
#include <cstdint>
@@ -120,6 +121,8 @@ enum class MessageHostToFw : uint8_t
PREV_BUTTON_LED = 21,
NEXT_BUTTON_LED = 22,
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
};
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<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<EffectSegmentSelectConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SEGMENT_SELECT, msgType, msgBuffer)) {}
// Mouse LEDs
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}

View File

@@ -2,6 +2,7 @@
#include "effects/Circular.h"
#include "effects/Common.h"
#include "effects/SegmentSelect.h"
#include "drivers/Esp32DriverRGBW.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::SWIPE_AND_CHANGE >(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
timeoutMsForEffect = 0;

View File

@@ -3,6 +3,7 @@
#include "effects/AlexaSwipe.h"
#include "effects/ReverseSwipe.h"
#include "effects/SegmentSelect.h"
#include "helpers/ColorConversions.h"
#include <iostream>
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
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;

View File

@@ -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:
**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
`light.music_mouse_regal_licht`:
@@ -156,7 +158,7 @@ the scanner has to know about:
| 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 |
| `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 |

View File

@@ -10,7 +10,8 @@ general:
# 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:
#
# <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>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
# <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.
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,
# a half-finished .tmp - is ignored.
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
@@ -175,6 +197,12 @@ general:
progress_file: tippen-progress.json
# 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:
fuchs:
# 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
# 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
# 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
eule:

View File

@@ -42,6 +42,7 @@ from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.screen import ScreenService
from musicmouse.services.timers import TimerService
from musicmouse.services.web import WebService
from musicmouse.sound import ui_level
from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
@@ -381,7 +382,7 @@ def _build_services(
app.bus,
screen_config,
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,
clock=clock,
)
@@ -398,7 +399,7 @@ def _build_services(
cache_file=general.library.cache / "holidays.json",
client=httpx2.AsyncClient(timeout=30.0),
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,
)
)

View File

@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import logging
import math
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@@ -18,7 +20,7 @@ from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__)
__all__ = ["App", "AppState", "ParentTimer"]
__all__ = ["App", "AppState", "FigureSelector", "ParentTimer"]
@dataclass(frozen=True, slots=True)
@@ -32,6 +34,22 @@ class ParentTimer:
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
class AppState:
"""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.
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
#: readable off the transport; a broker's is not, so it is remembered here.
mqtt_connected: bool = False
@@ -63,7 +89,8 @@ class App:
mouse: MusicMouseDevice
player: Player
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)
state: AppState = field(default_factory=AppState)
#: `None` when `general.tippen` is absent - the typing game is off.
@@ -72,11 +99,21 @@ class App:
def colors(self, figure: str) -> FigureColors:
return self.config.figures[figure].colors
def playlist(self, figure: str) -> Playlist | None:
playlist = self.playlists.get(figure)
if playlist is None:
def choices(self, figure: str) -> tuple[Playlist, ...]:
"""The albums ``figure`` can play; empty, with a warning, if it has none."""
choices = self.playlists.get(figure, ())
if not choices:
_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:
"""The library album a playlist came from, if any."""

View File

@@ -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.
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
import functools
import io
import math
import wave
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
@@ -60,7 +64,58 @@ def chime_wav(*, gain: float) -> bytes:
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))
buffer = io.BytesIO()
with wave.open(buffer, "wb") as out:

View File

@@ -38,6 +38,7 @@ __all__ = [
"Digit",
"FigureColors",
"FigureConfig",
"FigureSelectionConfig",
"GeneralConfig",
"HaConfig",
"HaDeviceConfig",
@@ -301,6 +302,36 @@ class TippenConfig(_Strict):
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):
library: LibraryConfig
@@ -327,6 +358,11 @@ class GeneralConfig(_Strict):
initial_volume: int = Field(default=50, ge=0, le=200)
volume_increment: int = Field(default=5, ge=1, le=100)
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

View File

@@ -27,6 +27,7 @@ from musicmouse.devices.wire import (
from musicmouse.effects import OFF, LedEffect
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
ConnectionChanged,
DeviceConnected,
DeviceDisconnected,
@@ -34,8 +35,11 @@ from musicmouse.events import (
InputEvent,
LedEffectChanged,
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__)
@@ -62,6 +66,7 @@ class MusicMouseDevice:
self._active_figure: str | None = None
self._button_brightness: float = 0.0
self._effects: dict[LedZone, LedEffect] = {}
self._knob_captured = False
# -------------------------------------------------------------------- state
@@ -81,6 +86,10 @@ class MusicMouseDevice:
def effect(self, zone: LedZone) -> LedEffect | None:
return self._effects.get(zone)
@property
def knob_captured(self) -> bool:
return self._knob_captured
# ------------------------------------------------------------------ actions
def set_effect(
@@ -107,6 +116,17 @@ class MusicMouseDevice:
self._transport.write(encode_button_brightness(button, brightness))
_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:
for zone in LedZone:
self.set_effect(zone, OFF(), origin=origin)
@@ -153,6 +173,20 @@ class MusicMouseDevice:
def _publish(self, event: InputEvent) -> None:
if isinstance(event, RfidTokenRead):
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:
self._bus.emit(event)

View File

@@ -22,6 +22,7 @@ from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
@@ -114,9 +115,11 @@ class MessageHostToFw(IntEnum):
PREV_BUTTON_LED = 21
NEXT_BUTTON_LED = 22
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23
#: 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]] = {
LedZone.RING: {
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,
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
EffectSegmentSelect: MessageHostToFw.LED_WHEEL_EFFECT_SEGMENT_SELECT,
},
LedZone.MOUSE: {
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,

View File

@@ -22,10 +22,12 @@ from musicmouse.color import ColorHSV, ColorRGBW
__all__ = [
"OFF",
"SEGMENT_SELECT_MAX_SEGMENTS",
"EffectAlexaSwipeConfig",
"EffectCircularConfig",
"EffectRandomTwoColorInterpolationConfig",
"EffectReverseSwipe",
"EffectSegmentSelect",
"EffectStaticConfig",
"EffectStaticDetailedConfig",
"EffectSwipeAndChange",
@@ -41,6 +43,12 @@ _ALEXA_SWIPE = struct.Struct("<fffff?")
_TWO_COLOR = struct.Struct("<i?i??")
_CIRCULAR = struct.Struct("<ff")
_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):
@@ -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
"""A fresh "all LEDs off" effect."""
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))

View File

@@ -34,6 +34,8 @@ __all__ = [
"DeviceDisconnected",
"Event",
"EventSource",
"FigureSelectionChanged",
"FigureSelectorExpired",
"InputEvent",
"IntentEvent",
"LedEffectChanged",
@@ -50,6 +52,8 @@ __all__ = [
"PreviousTrackRequested",
"RfidTokenRead",
"RotaryTurned",
"SelectorPressed",
"SelectorTurned",
"SetTimerRequested",
"SetVolumeRequested",
"StateEvent",
@@ -141,6 +145,31 @@ class RotaryTurned(InputEvent):
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)
class PlaylistFinished(InputEvent):
"""The player reached the end of the playlist."""
@@ -300,6 +329,17 @@ class ActiveFigureChanged(StateEvent):
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)
class LedEffectChanged(StateEvent):
"""Emitted on *every* write to an LED zone, whatever caused it.

View File

@@ -36,6 +36,7 @@ _log = logging.getLogger(__name__)
__all__ = [
"ANALYZER_VERSION",
"MAX_FIGURE_ALBUMS",
"SECTIONS",
"Album",
"Analyzer",
@@ -50,6 +51,10 @@ __all__ = [
"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
#: 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.
@@ -120,17 +125,35 @@ class MusicLibrary:
entry = self._entries.get(identifier) if identifier else None
return entry[0] if entry else None
def figure_playlists(self) -> dict[str, Playlist]:
"""One playlist per figure folder, keyed by figure name.
def figure_playlists(self) -> dict[str, tuple[Playlist, ...]]:
"""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
same album: ``reactions.playback.play_figure`` resumes on an identity check.
"""
return {
album.figure: album.to_playlist()
for album, _ in self._entries.values()
if album.figure is not None
}
by_figure: dict[str, list[Album]] = {}
for album, _ in self._entries.values():
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:
"""The newest episode-unit album of a podcast show, by filename.

View File

@@ -54,9 +54,12 @@ __all__ = ["Fingerprint", "LibraryCache"]
# 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
# 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.
_INDEX_VERSION = 6
_INDEX_VERSION = 7
@dataclass(frozen=True, slots=True)

View File

@@ -197,7 +197,9 @@ def scan_album(
albums.append(tags.get("album", ""))
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
# figurine, so its media type has to be declared in the config.
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)
if section.title_from == "folder":
# 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 = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
title = folder.name
if figure is not None:
# 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:
title = _most_common(albums) or folder_title or folder.name

View File

@@ -14,6 +14,7 @@ service subscribes to those events directly.
from musicmouse.reactions import ( # noqa: F401 (import = register)
lighting,
playback,
selection,
status,
)
from musicmouse.reactions.registry import Reaction, on, register_all, registered

View File

@@ -54,7 +54,10 @@ def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
if event.figure is None:
off_animation(app)
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.config.general.button_leds_brightness, origin="device"
)
@@ -106,36 +109,42 @@ def touch_released(event: TouchButtonReleased, app: App) -> None:
)
if colors is None:
return
app.mouse.set_effect(
LedZone.MOUSE,
EffectRandomTwoColorInterpolationConfig(
color1=colors.primary, color2=colors.secondary, start_with_existing=True
),
origin="device",
)
fade_to_figure(app, LedZone.MOUSE, colors)
# ------------------------------------------------------------------- animations
def start_animation(app: App, colors: FigureColors) -> None:
ring = EffectSwipeAndChange()
ring.swipe.primary_color = colors.primary
ring.swipe.secondary_color = colors.secondary
ring.swipe.swipe_speed = SWIPE_SPEED
ring.change.color1 = colors.primary
ring.change.color2 = colors.secondary
def start_animation(app: App, colors: FigureColors, *, ring: bool = True) -> None:
swipe = EffectSwipeAndChange()
swipe.swipe.primary_color = colors.primary
swipe.swipe.secondary_color = colors.secondary
swipe.swipe.swipe_speed = SWIPE_SPEED
swipe.change.color1 = colors.primary
swipe.change.color2 = colors.secondary
app.mouse.set_effect(LedZone.RING, ring, origin="device")
app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device")
if ring:
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.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
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:
for zone in LedZone:
app.mouse.set_effect(

View File

@@ -29,6 +29,7 @@ from musicmouse.events import (
)
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
from musicmouse.library.models import Album
from musicmouse.media import Playlist
from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__)
@@ -63,24 +64,43 @@ def _figure_removed(previous: str | None, app: App) -> None:
@on(PlayFigureRequested)
def play_figure(event: PlayFigureRequested, app: App) -> None:
playlist = app.playlist(event.figure)
if playlist is None:
"""Play the figure's album - the one it played last, if it has several.
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
playlist = choices[app.default_choice(event.figure)]
remember_figure_album(app, event.figure, playlist)
if not event.restart and app.player.playlist is playlist:
_log.info("Resuming %r", event.figure)
app.player.play()
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.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:
# 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 = 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.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:
app.bus.emit(NextTrackRequested(source="device"))
# 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)

View 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))

View File

@@ -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.
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
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
ALSA device, as loud as UI sounds are (:func:`musicmouse.sound.ui_level`). The
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
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
@@ -68,7 +69,8 @@ class ScreenService:
self.config = config
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
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.is_playing = is_playing
self._clock = clock or RealClock()

View File

@@ -49,8 +49,6 @@ RING_SECONDS = 60.0
#: The chime's peak at full player volume, as a fraction of full scale - soft.
_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.
_RETRY_SECONDS = 3600.0
@@ -94,7 +92,8 @@ class TimerService:
self.client = client
#: Where the chime plays, or ``None`` for no sound (simulated audio).
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._clock = clock or RealClock()
self._wall = wall
@@ -211,7 +210,7 @@ class TimerService:
async def _chime(self) -> None:
if self.alsa_device is None:
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)
await play_wav(self.alsa_device, wav)

View File

@@ -293,6 +293,7 @@ class SettingsOut(BaseModel):
initial_volume: int
volume_increment: int
button_leds_brightness: float
effects_volume: int
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
#: switch off (no ``screen`` section, or one without an ``off_command``).
screen_auto_off_minutes: int | None
@@ -304,6 +305,8 @@ class SettingsIn(BaseModel):
initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100)
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.
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)

View File

@@ -55,6 +55,7 @@ def read_settings(general: GeneralConfig) -> SettingsOut:
initial_volume=general.initial_volume,
volume_increment=general.volume_increment,
button_leds_brightness=general.button_leds_brightness,
effects_volume=general.effects_volume,
screen_auto_off_minutes=(
screen.auto_off_minutes if screen is not None and screen.off_command else None
),

View File

@@ -2,6 +2,9 @@
``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.
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
@@ -11,7 +14,32 @@ import logging
_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:

View File

@@ -15,6 +15,7 @@ from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
@@ -135,7 +136,54 @@ def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
(EffectRandomTwoColorInterpolationConfig(), 35),
(EffectReverseSwipe(), 12),
(EffectSwipeAndChange(), 64),
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
],
)
def test_payload_sizes(effect: LedEffect, size: int) -> None:
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)

View File

@@ -15,7 +15,14 @@ from pathlib import Path
import pytest
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.cache import LibraryCache
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()
assert set(playlists) == {"fuchs", "eule"}
assert len(playlists["fuchs"]) == 3
assert [track.path.name for track in playlists["fuchs"].tracks] == [
(fuchs,) = playlists["fuchs"]
assert len(fuchs) == 3
assert [track.path.name for track in fuchs.tracks] == [
"00 - track.mp3",
"01 - track.mp3",
"02 - track.mp3",
]
# 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.
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:

View File

@@ -14,6 +14,7 @@ from musicmouse.effects import (
OFF,
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectSegmentSelect,
EffectStaticConfig,
)
from musicmouse.events import (
@@ -23,9 +24,12 @@ from musicmouse.events import (
Event,
LedEffectChanged,
RfidTokenRead,
RotaryTurned,
SelectorPressed,
SelectorTurned,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.simulator import FakeTransport
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(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> 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
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(
device: MusicMouseDevice, transport: FakeTransport
) -> None:

View 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()

View File

@@ -20,6 +20,7 @@ SETTINGS = {
"initial_volume": 40,
"volume_increment": 5,
"button_leds_brightness": 0.5,
"effects_volume": 100,
}
#: A config file with comments, to prove saving does not flatten them.

View 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)

View File

@@ -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 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:

View File

@@ -111,6 +111,8 @@ export interface Settings {
initial_volume: number;
volume_increment: 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.
* `null` when there is no screen to switch (no `off_command` configured). */
screen_auto_off_minutes: number | null;

View File

@@ -55,6 +55,7 @@ const FIELDS: Array<[AlwaysSetting, string, string, number, number, number]> = [
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
["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({