From d465a74cde83576f9452a726b84f51907ac2dc4c Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sun, 4 Oct 2026 00:29:18 +0200 Subject: [PATCH] Figures: several albums per figure, picked on the LED ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - " 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 --- docs/REPO_OVERVIEW.md | 3 +- esp-firmware/lib/ledtl/effects/Common.h | 1 + .../lib/ledtl/effects/SegmentSelect.h | 212 ++++++++++ esp-firmware/src/Messages.h | 4 + esp-firmware/src/TaskLed.h | 2 + esp-firmware/src/host_test.cpp | 24 ++ python-backend/README.md | 6 +- python-backend/config.yml.example | 33 +- python-backend/musicmouse/__main__.py | 5 +- python-backend/musicmouse/app.py | 49 ++- python-backend/musicmouse/blubb.py | 61 ++- python-backend/musicmouse/config.py | 36 ++ python-backend/musicmouse/devices/mouse.py | 36 +- python-backend/musicmouse/devices/wire.py | 6 +- python-backend/musicmouse/effects.py | 88 +++++ python-backend/musicmouse/events.py | 40 ++ python-backend/musicmouse/library/__init__.py | 37 +- python-backend/musicmouse/library/cache.py | 5 +- python-backend/musicmouse/library/scanner.py | 14 +- .../musicmouse/reactions/__init__.py | 1 + .../musicmouse/reactions/lighting.py | 47 ++- .../musicmouse/reactions/playback.py | 32 +- .../musicmouse/reactions/selection.py | 234 +++++++++++ python-backend/musicmouse/services/screen.py | 8 +- python-backend/musicmouse/services/timers.py | 7 +- .../musicmouse/services/web/schemas.py | 3 + .../musicmouse/services/web/settings.py | 1 + python-backend/musicmouse/sound.py | 30 +- python-backend/tests/test_effects.py | 48 +++ python-backend/tests/test_library.py | 72 +++- python-backend/tests/test_mouse.py | 69 +++- python-backend/tests/test_selection.py | 371 ++++++++++++++++++ python-backend/tests/test_settings.py | 1 + python-backend/tests/test_sound.py | 47 +++ python-backend/tests/test_web.py | 2 +- web/src/api/types.ts | 2 + web/src/components/ParentPanel.tsx | 1 + 37 files changed, 1570 insertions(+), 68 deletions(-) create mode 100644 esp-firmware/lib/ledtl/effects/SegmentSelect.h create mode 100644 python-backend/musicmouse/reactions/selection.py create mode 100644 python-backend/tests/test_selection.py create mode 100644 python-backend/tests/test_sound.py diff --git a/docs/REPO_OVERVIEW.md b/docs/REPO_OVERVIEW.md index c4c0591..517ac46 100644 --- a/docs/REPO_OVERVIEW.md +++ b/docs/REPO_OVERVIEW.md @@ -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: ``` -/Figuren/
/ one folder per figurine +/Figuren/
/ one folder per figurine (or album subfolders + in it, up to six, picked on the LED ring) /Musik/ - / albums, grouped by artist /Hörbücher/ - / audiobooks, grouped by character /Kinderpodcasts// shows, newest episode first diff --git a/esp-firmware/lib/ledtl/effects/Common.h b/esp-firmware/lib/ledtl/effects/Common.h index 34050de..d780684 100644 --- a/esp-firmware/lib/ledtl/effects/Common.h +++ b/esp-firmware/lib/ledtl/effects/Common.h @@ -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 diff --git a/esp-firmware/lib/ledtl/effects/SegmentSelect.h b/esp-firmware/lib/ledtl/effects/SegmentSelect.h new file mode 100644 index 0000000..05e2553 --- /dev/null +++ b/esp-firmware/lib/ledtl/effects/SegmentSelect.h @@ -0,0 +1,212 @@ +#pragma once + +#include "effects/Common.h" +#include "helpers/ColorRGBW.h" +#include "helpers/ColorHSV.h" +#include "helpers/ColorConversions.h" + +#include +#include + +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 +class EffectSegmentSelect +{ +public: + static constexpr auto NUM_LEDS = numLeds(); + 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 +{ + using type = EffectSegmentSelectConfig; +}; + +template <> +struct EffectConfigToId +{ + static constexpr auto id = EffectId::SEGMENT_SELECT; +}; + +template +struct EffectIdToClass +{ + using type = EffectSegmentSelect; +}; diff --git a/esp-firmware/src/Messages.h b/esp-firmware/src/Messages.h index 169a07c..d7b4f27 100644 --- a/esp-firmware/src/Messages.h +++ b/esp-firmware/src/Messages.h @@ -4,6 +4,7 @@ #include "effects/ReverseSwipe.h" #include "effects/RandomTwoColorInterpolation.h" #include "effects/SwipeAndChange.h" +#include "effects/SegmentSelect.h" #include "Arduino.h" #include @@ -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(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {} else if(handleLedEffect(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {} else if(handleLedEffect(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {} + else if(handleLedEffect(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SEGMENT_SELECT, msgType, msgBuffer)) {} // Mouse LEDs else if(handleLedEffect(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {} diff --git a/esp-firmware/src/TaskLed.h b/esp-firmware/src/TaskLed.h index 2511c73..500b95f 100644 --- a/esp-firmware/src/TaskLed.h +++ b/esp-firmware/src/TaskLed.h @@ -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(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} else if (dispatchEffectId(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} else if (dispatchEffectId(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} + else if (dispatchEffectId(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} // clang-format on timeoutMsForEffect = 0; diff --git a/esp-firmware/src/host_test.cpp b/esp-firmware/src/host_test.cpp index 6c45286..a3b5c68 100644 --- a/esp-firmware/src/host_test.cpp +++ b/esp-firmware/src/host_test.cpp @@ -3,6 +3,7 @@ #include "effects/AlexaSwipe.h" #include "effects/ReverseSwipe.h" +#include "effects/SegmentSelect.h" #include "helpers/ColorConversions.h" #include @@ -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 effect(cfg, strip); + effectToFile("segment_select.py", effect, strip, 200); + } + /* effect.currentPosition_ = 150; diff --git a/python-backend/README.md b/python-backend/README.md index fd89433..3642602 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -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/
/` | an album per figurine | the folder name *is* the figure name from the config | +| `Figuren/
/` | 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 - ` | | `Musik/ - /` | music, grouped by artist | plain ID3 | | `Hörbücher/ - /` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully | | `Kinderpodcasts//` | 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 | diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index f8d46cd..6c56b7f 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -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: # - # /Figuren/
/ one folder per figurine + # /Figuren/
/ one folder per figurine - or, for a figure + # with several albums, one subfolder each # /Musik/ - / albums, grouped by artist # /Hörbücher/ - / audiobooks, grouped by character # /Kinderpodcasts// 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: diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index b462cd7..b2b4159 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -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, ) ) diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index f771d50..2ab9516 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -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.""" diff --git a/python-backend/musicmouse/blubb.py b/python-backend/musicmouse/blubb.py index 6dac0af..7e81341 100644 --- a/python-backend/musicmouse/blubb.py +++ b/python-backend/musicmouse/blubb.py @@ -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: diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index d04c2a3..4062ab8 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -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 diff --git a/python-backend/musicmouse/devices/mouse.py b/python-backend/musicmouse/devices/mouse.py index 7e6d2f4..746d928 100644 --- a/python-backend/musicmouse/devices/mouse.py +++ b/python-backend/musicmouse/devices/mouse.py @@ -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) diff --git a/python-backend/musicmouse/devices/wire.py b/python-backend/musicmouse/devices/wire.py index d0ebe30..9abbce2 100644 --- a/python-backend/musicmouse/devices/wire.py +++ b/python-backend/musicmouse/devices/wire.py @@ -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, diff --git a/python-backend/musicmouse/effects.py b/python-backend/musicmouse/effects.py index a7a35e7..3f40efa 100644 --- a/python-backend/musicmouse/effects.py +++ b/python-backend/musicmouse/effects.py @@ -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(" 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)) diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index c1d2d32..0f90732 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -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. diff --git a/python-backend/musicmouse/library/__init__.py b/python-backend/musicmouse/library/__init__.py index 80b38cd..b924797 100644 --- a/python-backend/musicmouse/library/__init__.py +++ b/python-backend/musicmouse/library/__init__.py @@ -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. diff --git a/python-backend/musicmouse/library/cache.py b/python-backend/musicmouse/library/cache.py index 4eb3fd5..cfef2d0 100644 --- a/python-backend/musicmouse/library/cache.py +++ b/python-backend/musicmouse/library/cache.py @@ -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 - " - 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) diff --git a/python-backend/musicmouse/library/scanner.py b/python-backend/musicmouse/library/scanner.py index 992f3b3..9e4a885 100644 --- a/python-backend/musicmouse/library/scanner.py +++ b/python-backend/musicmouse/library/scanner.py @@ -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 diff --git a/python-backend/musicmouse/reactions/__init__.py b/python-backend/musicmouse/reactions/__init__.py index 39afabb..5f89073 100644 --- a/python-backend/musicmouse/reactions/__init__.py +++ b/python-backend/musicmouse/reactions/__init__.py @@ -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 diff --git a/python-backend/musicmouse/reactions/lighting.py b/python-backend/musicmouse/reactions/lighting.py index 1fd0881..0a1fd66 100644 --- a/python-backend/musicmouse/reactions/lighting.py +++ b/python-backend/musicmouse/reactions/lighting.py @@ -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( diff --git a/python-backend/musicmouse/reactions/playback.py b/python-backend/musicmouse/reactions/playback.py index a2241e3..17a34ab 100644 --- a/python-backend/musicmouse/reactions/playback.py +++ b/python-backend/musicmouse/reactions/playback.py @@ -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) diff --git a/python-backend/musicmouse/reactions/selection.py b/python-backend/musicmouse/reactions/selection.py new file mode 100644 index 0000000..abf67b1 --- /dev/null +++ b/python-backend/musicmouse/reactions/selection.py @@ -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)) diff --git a/python-backend/musicmouse/services/screen.py b/python-backend/musicmouse/services/screen.py index 08f9dd0..6b8f1b5 100644 --- a/python-backend/musicmouse/services/screen.py +++ b/python-backend/musicmouse/services/screen.py @@ -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() diff --git a/python-backend/musicmouse/services/timers.py b/python-backend/musicmouse/services/timers.py index 572d1b8..892e8d3 100644 --- a/python-backend/musicmouse/services/timers.py +++ b/python-backend/musicmouse/services/timers.py @@ -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) diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index aa45520..fb5725a 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -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) diff --git a/python-backend/musicmouse/services/web/settings.py b/python-backend/musicmouse/services/web/settings.py index 42ce874..1bd94e3 100644 --- a/python-backend/musicmouse/services/web/settings.py +++ b/python-backend/musicmouse/services/web/settings.py @@ -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 ), diff --git a/python-backend/musicmouse/sound.py b/python-backend/musicmouse/sound.py index ea6d50c..6dd3a10 100644 --- a/python-backend/musicmouse/sound.py +++ b/python-backend/musicmouse/sound.py @@ -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: diff --git a/python-backend/tests/test_effects.py b/python-backend/tests/test_effects.py index 163aa61..a124692 100644 --- a/python-backend/tests/test_effects.py +++ b/python-backend/tests/test_effects.py @@ -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) diff --git a/python-backend/tests/test_library.py b/python-backend/tests/test_library.py index da264bb..f3c793b 100644 --- a/python-backend/tests/test_library.py +++ b/python-backend/tests/test_library.py @@ -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: diff --git a/python-backend/tests/test_mouse.py b/python-backend/tests/test_mouse.py index 37dbad7..6658cbc 100644 --- a/python-backend/tests/test_mouse.py +++ b/python-backend/tests/test_mouse.py @@ -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: diff --git a/python-backend/tests/test_selection.py b/python-backend/tests/test_selection.py new file mode 100644 index 0000000..c4c0ad8 --- /dev/null +++ b/python-backend/tests/test_selection.py @@ -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() diff --git a/python-backend/tests/test_settings.py b/python-backend/tests/test_settings.py index 4eebf53..d1b43c5 100644 --- a/python-backend/tests/test_settings.py +++ b/python-backend/tests/test_settings.py @@ -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. diff --git a/python-backend/tests/test_sound.py b/python-backend/tests/test_sound.py new file mode 100644 index 0000000..e864d08 --- /dev/null +++ b/python-backend/tests/test_sound.py @@ -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) diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index 1cbe78f..8844731 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -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: diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 893d102..b3f71d5 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/components/ParentPanel.tsx b/web/src/components/ParentPanel.tsx index ae3509e..7b8300d 100644 --- a/web/src/components/ParentPanel.tsx +++ b/web/src/components/ParentPanel.tsx @@ -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({