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:
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
234
python-backend/musicmouse/reactions/selection.py
Normal file
234
python-backend/musicmouse/reactions/selection.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Picking one of a figure's albums on the LED ring.
|
||||
|
||||
A figure whose folder holds several albums starts playing its default one at once, as
|
||||
any figure does, and meanwhile offers all of them on the ring: one coloured section
|
||||
each, the selected one bright and wobbling. The knob moves the selection one section
|
||||
per detent, and pressing it plays the selected album. Left alone, the picker closes by
|
||||
itself and whatever is playing carries on.
|
||||
|
||||
While it is open the picker has the knob to itself (``MusicMouseDevice.capture_knob``):
|
||||
turning does not change the volume and pressing is not sent to Home Assistant.
|
||||
|
||||
The firmware only draws the ring. Which section is selected, when to give up, and what
|
||||
to play are decided here, and the ring is simply sent again on every detent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from musicmouse.app import App, FigureSelector
|
||||
from musicmouse.blubb import confirm_wav, tick_wav
|
||||
from musicmouse.color import ColorRGBW
|
||||
from musicmouse.effects import EffectSegmentSelect
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
FigureSelectionChanged,
|
||||
FigureSelectorExpired,
|
||||
PlayAlbumRequested,
|
||||
PlaylistFinished,
|
||||
SelectorPressed,
|
||||
SelectorTurned,
|
||||
)
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.media import Playlist
|
||||
from musicmouse.reactions.lighting import fade_to_figure
|
||||
from musicmouse.reactions.registry import on
|
||||
from musicmouse.sound import play_wav_soon, ui_level
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
#: One colour per album, by position. Neighbours are far apart in hue, so the sections
|
||||
#: stay easy to tell apart even with the edge blend shifting them towards each other.
|
||||
PALETTE: tuple[ColorRGBW, ...] = (
|
||||
ColorRGBW(1.0, 0.0, 0.0, 0.0), # red
|
||||
ColorRGBW(1.0, 0.35, 0.0, 0.0), # orange
|
||||
ColorRGBW(1.0, 0.8, 0.0, 0.0), # yellow
|
||||
ColorRGBW(0.0, 0.9, 0.1, 0.0), # green
|
||||
ColorRGBW(0.0, 0.3, 1.0, 0.0), # blue
|
||||
ColorRGBW(0.6, 0.0, 1.0, 0.0), # violet
|
||||
)
|
||||
|
||||
#: Turning faster than this plays one tick, not a pile of overlapping ones.
|
||||
TICK_INTERVAL = 0.06
|
||||
#: Peak of each sound at full UI loudness, as a fraction of full scale.
|
||||
TICK_GAIN = 0.25
|
||||
CONFIRM_GAIN = 0.3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Tuning:
|
||||
"""How the picker looks and behaves. Field names match
|
||||
:class:`~musicmouse.config.FigureSelectionConfig`, whose values override these."""
|
||||
|
||||
timeout: float = 8.0 # seconds without a turn before the picker closes
|
||||
#: Where the first section starts. The figure's swipe starts at LED 0 and meets
|
||||
#: itself opposite, which is the front of the ring.
|
||||
offset_degrees: float = 180.0
|
||||
ring_direction: int = 1
|
||||
knob_direction: int = 1
|
||||
gap_leds: int = 1
|
||||
dim_factor: float = 0.15
|
||||
wobble_amplitude: float = 1.5 # LEDs
|
||||
wobble_period_ms: float = 1000.0
|
||||
slide_ms: float = 200.0
|
||||
edge_blend: float = 0.25
|
||||
|
||||
|
||||
DEFAULTS = Tuning()
|
||||
|
||||
|
||||
def tuning(app: App) -> Tuning:
|
||||
""":data:`DEFAULTS`, with whatever the config's ``figure_selection`` overrides."""
|
||||
overrides = app.config.general.figure_selection.model_dump(exclude_none=True)
|
||||
return replace(DEFAULTS, **overrides)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- reactions
|
||||
|
||||
|
||||
@on(ActiveFigureChanged)
|
||||
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||
# Whatever replaces the figure - nothing, or another one - sets the ring itself.
|
||||
if app.state.selector is not None:
|
||||
_close(app, fade=False)
|
||||
if event.figure is None:
|
||||
return
|
||||
choices = app.playlists.get(event.figure, ())
|
||||
if len(choices) > 1:
|
||||
_open(app, event.figure, choices)
|
||||
|
||||
|
||||
@on(SelectorTurned)
|
||||
def selector_turned(event: SelectorTurned, app: App) -> None:
|
||||
selector = app.state.selector
|
||||
if selector is None:
|
||||
return # a turn still in the queue when the picker closed
|
||||
settings = tuning(app)
|
||||
now = app.clock.now()
|
||||
selector.deadline = now + settings.timeout
|
||||
|
||||
previous = selector.selected
|
||||
selector.selected = (previous + event.steps * settings.knob_direction) % len(selector.choices)
|
||||
if selector.selected == previous:
|
||||
return
|
||||
app.mouse.set_effect(LedZone.RING, _ring(app, selector, previous), origin="device")
|
||||
if now - selector.last_tick >= TICK_INTERVAL:
|
||||
selector.last_tick = now
|
||||
_play_sound(app, tick_wav, TICK_GAIN)
|
||||
_announce(app, selector, open_=True)
|
||||
|
||||
|
||||
@on(SelectorPressed)
|
||||
def selector_pressed(_event: SelectorPressed, app: App) -> None:
|
||||
selector = app.state.selector
|
||||
if selector is None:
|
||||
return
|
||||
playlist = selector.choices[selector.selected]
|
||||
_close(app, fade=True)
|
||||
_play_sound(app, confirm_wav, CONFIRM_GAIN)
|
||||
if app.player.playlist is playlist:
|
||||
_log.info("Album picker: keeping %s", playlist.album_id)
|
||||
return
|
||||
_log.info("Album picker: %r -> %s", selector.figure, playlist.album_id)
|
||||
if playlist.album_id is not None:
|
||||
# The same way in as the web front-end, which also remembers the album as the
|
||||
# figure's for next time.
|
||||
app.bus.emit(PlayAlbumRequested(album_id=playlist.album_id, source="device"))
|
||||
|
||||
|
||||
@on(FigureSelectorExpired)
|
||||
def selector_expired(event: FigureSelectorExpired, app: App) -> None:
|
||||
selector = app.state.selector
|
||||
# Stale if the picker it was for has closed, or a newer one is still in time.
|
||||
if selector is None or selector.figure != event.figure:
|
||||
return
|
||||
if app.clock.now() < selector.deadline:
|
||||
return
|
||||
_close(app, fade=True)
|
||||
|
||||
|
||||
@on(PlaylistFinished)
|
||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||
# The off animation takes the ring; the knob goes back to being a volume knob.
|
||||
if app.state.selector is not None:
|
||||
_close(app, fade=False)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- internals
|
||||
|
||||
|
||||
def _open(app: App, figure: str, choices: tuple[Playlist, ...]) -> None:
|
||||
selector = FigureSelector(
|
||||
figure=figure,
|
||||
choices=choices,
|
||||
selected=app.default_choice(figure),
|
||||
deadline=app.clock.now() + tuning(app).timeout,
|
||||
)
|
||||
app.state.selector = selector
|
||||
app.mouse.capture_knob(True)
|
||||
app.mouse.set_effect(LedZone.RING, _ring(app, selector, None), origin="device")
|
||||
selector.expiry = asyncio.create_task(_expire(app, selector), name="album-picker")
|
||||
_log.info("Album picker for %r: %d albums", figure, len(choices))
|
||||
_announce(app, selector, open_=True)
|
||||
|
||||
|
||||
def _close(app: App, *, fade: bool) -> None:
|
||||
selector = app.state.selector
|
||||
if selector is None:
|
||||
return
|
||||
app.state.selector = None
|
||||
app.mouse.capture_knob(False)
|
||||
if selector.expiry is not None:
|
||||
selector.expiry.cancel()
|
||||
if fade:
|
||||
fade_to_figure(app, LedZone.RING, app.colors(selector.figure))
|
||||
_announce(app, selector, open_=False)
|
||||
|
||||
|
||||
async def _expire(app: App, selector: FigureSelector) -> None:
|
||||
# Every turn pushes the deadline back, so sleep until it stops moving.
|
||||
while (remaining := selector.deadline - app.clock.now()) > 0:
|
||||
await app.clock.sleep(remaining)
|
||||
app.bus.emit(FigureSelectorExpired(figure=selector.figure))
|
||||
|
||||
|
||||
def _ring(app: App, selector: FigureSelector, previous: int | None) -> EffectSegmentSelect:
|
||||
settings = tuning(app)
|
||||
return EffectSegmentSelect(
|
||||
colors=PALETTE[: len(selector.choices)],
|
||||
selected=selector.selected,
|
||||
previous=previous,
|
||||
gap_leds=settings.gap_leds,
|
||||
offset_degrees=settings.offset_degrees,
|
||||
direction=settings.ring_direction,
|
||||
dim_factor=settings.dim_factor,
|
||||
wobble_amplitude=settings.wobble_amplitude,
|
||||
wobble_period_ms=settings.wobble_period_ms,
|
||||
slide_ms=settings.slide_ms,
|
||||
edge_blend=settings.edge_blend,
|
||||
)
|
||||
|
||||
|
||||
def _announce(app: App, selector: FigureSelector, *, open_: bool) -> None:
|
||||
app.bus.emit(
|
||||
FigureSelectionChanged(
|
||||
figure=selector.figure,
|
||||
selected=selector.selected,
|
||||
count=len(selector.choices),
|
||||
open=open_,
|
||||
source="device",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _play_sound(app: App, make: Callable[..., bytes], base_gain: float) -> None:
|
||||
general = app.config.general
|
||||
if general.audio_simulated:
|
||||
return
|
||||
gain = base_gain * ui_level(app.player.volume / 100, general.effects_volume / 100)
|
||||
if gain > 0:
|
||||
play_wav_soon(general.alsa_device, make(gain=gain))
|
||||
@@ -6,8 +6,9 @@ async handlers, so running it there would hold every other event up behind ``xse
|
||||
Here each press gets its own task, and a hung or failing command is only logged.
|
||||
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
371
python-backend/tests/test_selection.py
Normal file
371
python-backend/tests/test_selection.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.blubb import confirm_wav, tick_wav
|
||||
from musicmouse.config import load_config
|
||||
from musicmouse.effects import (
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectSegmentSelect,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.reactions import selection
|
||||
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
|
||||
from musicmouse.simulator.driver import SimulatorDriver
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from musicmouse.sound import ui_level
|
||||
from tests.conftest import VALID_CONFIG, write_config, write_track
|
||||
|
||||
TRACK_SECONDS = 10.0
|
||||
TIMEOUT = DEFAULTS.timeout
|
||||
|
||||
T = TypeVar("T", bound=Event)
|
||||
|
||||
|
||||
def _albums(config_dir: Path) -> None:
|
||||
"""Give the fox three albums of two tracks each; the owl keeps its one."""
|
||||
fuchs = config_dir / "music" / "Figuren" / "fuchs"
|
||||
for track in fuchs.glob("*.mp3"):
|
||||
track.unlink()
|
||||
for folder in ("A", "B", "C"):
|
||||
for index in range(2):
|
||||
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
|
||||
|
||||
|
||||
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
|
||||
_albums(config_dir)
|
||||
return await build_simulation(
|
||||
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
simulation = await _simulation(config_dir, VALID_CONFIG)
|
||||
try:
|
||||
yield simulation
|
||||
finally:
|
||||
await simulation.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mouse(sim: Simulation) -> SimulatorDriver:
|
||||
return sim.driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seen(sim: Simulation) -> list[Event]:
|
||||
events: list[Event] = []
|
||||
sim.bus.subscribe_all(events.append)
|
||||
return events
|
||||
|
||||
|
||||
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||
return [e for e in events if isinstance(e, event_type)]
|
||||
|
||||
|
||||
def ring(sim: Simulation) -> object:
|
||||
return sim.app.mouse.effect(LedZone.RING)
|
||||
|
||||
|
||||
def picker(sim: Simulation) -> EffectSegmentSelect:
|
||||
effect = ring(sim)
|
||||
assert isinstance(effect, EffectSegmentSelect)
|
||||
return effect
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- opening
|
||||
|
||||
|
||||
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("playing", "true")
|
||||
mouse.check("album", "Fuchs - A")
|
||||
effect = picker(sim)
|
||||
assert effect.colors == PALETTE[:3]
|
||||
assert effect.selected == 0
|
||||
# Only the ring is the picker; the body and shelf run the usual start animation.
|
||||
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
|
||||
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
|
||||
|
||||
|
||||
async def test_a_figure_with_one_album_gets_no_picker(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("eule")
|
||||
|
||||
assert isinstance(ring(sim), EffectSwipeAndChange)
|
||||
assert sim.app.state.selector is None
|
||||
assert not sim.app.mouse.knob_captured
|
||||
|
||||
|
||||
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
|
||||
config = deepcopy(VALID_CONFIG)
|
||||
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
|
||||
sim = await _simulation(config_dir, config)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
effect = picker(sim)
|
||||
assert effect.offset_degrees == 90
|
||||
assert effect.wobble_amplitude == 3
|
||||
assert effect.slide_ms == DEFAULTS.slide_ms
|
||||
finally:
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- knob
|
||||
|
||||
|
||||
async def test_turning_moves_the_selection_instead_of_the_volume(
|
||||
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
volume = sim.player.volume
|
||||
|
||||
await mouse.turn(1)
|
||||
|
||||
effect = picker(sim)
|
||||
assert (effect.previous, effect.selected) == (0, 1)
|
||||
assert sim.player.volume == volume
|
||||
assert not only(seen, RotaryTurned)
|
||||
assert only(seen, FigureSelectionChanged)[-1].selected == 1
|
||||
# Turning only moves the highlight; what plays changes on a press.
|
||||
mouse.check("album", "Fuchs - A")
|
||||
|
||||
|
||||
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||
await mouse.place("fuchs")
|
||||
|
||||
await mouse.turn(-1)
|
||||
assert picker(sim).selected == 2
|
||||
await mouse.turn(2)
|
||||
assert picker(sim).selected == 1
|
||||
|
||||
|
||||
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
|
||||
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(2)
|
||||
|
||||
await mouse.press("rotary")
|
||||
|
||||
mouse.check("album", "Fuchs - C")
|
||||
mouse.check("track", "0")
|
||||
mouse.check("playing", "true")
|
||||
effect = ring(sim)
|
||||
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
|
||||
assert effect.start_with_existing # a fade from the sections, not a jump
|
||||
# The press was the picker's: Home Assistant never hears of it.
|
||||
assert not only(seen, ButtonEvent)
|
||||
assert only(seen, FigureSelectionChanged)[-1].open is False
|
||||
|
||||
volume = sim.player.volume
|
||||
await mouse.turn(-1)
|
||||
assert sim.player.volume < volume
|
||||
|
||||
|
||||
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.wait(3)
|
||||
|
||||
await mouse.press("rotary")
|
||||
|
||||
mouse.check("album", "Fuchs - A")
|
||||
assert sim.player.position == pytest.approx(3.0)
|
||||
assert sim.app.state.selector is None
|
||||
|
||||
|
||||
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
|
||||
mouse: SimulatorDriver, seen: list[Event]
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.press("rotary")
|
||||
await mouse.press("rotary")
|
||||
|
||||
assert len(only(seen, ButtonEvent)) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ timeout
|
||||
|
||||
|
||||
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
|
||||
await mouse.wait(TIMEOUT + 0.1)
|
||||
|
||||
assert sim.app.state.selector is None
|
||||
assert not sim.app.mouse.knob_captured
|
||||
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
|
||||
mouse.check("album", "Fuchs - A")
|
||||
mouse.check("playing", "true")
|
||||
|
||||
|
||||
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.wait(TIMEOUT - 2)
|
||||
await mouse.turn(1)
|
||||
|
||||
await mouse.wait(TIMEOUT - 1)
|
||||
assert sim.app.state.selector is not None
|
||||
|
||||
await mouse.wait(1.1)
|
||||
assert sim.app.state.selector is None
|
||||
|
||||
|
||||
async def test_lifting_the_figure_closes_the_picker(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.remove()
|
||||
|
||||
assert sim.app.state.selector is None
|
||||
assert not sim.app.mouse.knob_captured
|
||||
assert isinstance(ring(sim), EffectReverseSwipe)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- memory
|
||||
|
||||
|
||||
async def test_putting_the_figure_back_resumes_the_picked_album(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
await mouse.press("rotary")
|
||||
await mouse.wait(3)
|
||||
await mouse.remove()
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - B")
|
||||
assert sim.player.position == pytest.approx(3.0)
|
||||
assert picker(sim).selected == 1
|
||||
|
||||
|
||||
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
await mouse.press("rotary")
|
||||
await mouse.wait(TRACK_SECONDS + 3)
|
||||
await mouse.remove()
|
||||
await mouse.place("eule")
|
||||
await mouse.remove()
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - B")
|
||||
mouse.check("track", "0")
|
||||
assert sim.player.position == pytest.approx(0.0)
|
||||
assert picker(sim).selected == 1
|
||||
|
||||
|
||||
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
|
||||
mouse: SimulatorDriver,
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
await mouse.press("rotary")
|
||||
await mouse.wait(2 * TRACK_SECONDS + 1)
|
||||
mouse.check("playing", "false")
|
||||
await mouse.remove()
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - B")
|
||||
mouse.check("track", "0")
|
||||
|
||||
|
||||
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.play_album("Fuchs - C")
|
||||
await mouse.wait(2)
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - C")
|
||||
assert picker(sim).selected == 2
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- sound
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
|
||||
played: list[tuple[str, bytes]] = []
|
||||
monkeypatch.setattr(
|
||||
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
|
||||
)
|
||||
return played
|
||||
|
||||
|
||||
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
|
||||
config = deepcopy(VALID_CONFIG)
|
||||
config["general"]["alsa_device"] = "softvol_effects"
|
||||
config["general"]["effects_volume"] = effects_volume
|
||||
return await _simulation(config_dir, config)
|
||||
|
||||
|
||||
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
|
||||
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||
) -> None:
|
||||
sim = await _audible(config_dir)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
await sim.driver.turn(1)
|
||||
await sim.driver.turn(1) # no time passed: one tick for both
|
||||
await sim.driver.wait(0.1)
|
||||
await sim.driver.turn(1)
|
||||
|
||||
level = ui_level(sim.player.volume / 100, 1.0)
|
||||
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
|
||||
finally:
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
async def test_picking_an_album_plays_the_confirm_sound(
|
||||
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||
) -> None:
|
||||
sim = await _audible(config_dir, effects_volume=50)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
await sim.driver.press("rotary")
|
||||
|
||||
level = ui_level(sim.player.volume / 100, 0.5)
|
||||
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
|
||||
finally:
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
async def test_no_picker_sounds_with_effects_turned_off(
|
||||
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||
) -> None:
|
||||
sim = await _audible(config_dir, effects_volume=0)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
await sim.driver.turn(1)
|
||||
await sim.driver.press("rotary")
|
||||
assert sounds == []
|
||||
finally:
|
||||
await sim.aclose()
|
||||
@@ -20,6 +20,7 @@ SETTINGS = {
|
||||
"initial_volume": 40,
|
||||
"volume_increment": 5,
|
||||
"button_leds_brightness": 0.5,
|
||||
"effects_volume": 100,
|
||||
}
|
||||
|
||||
#: A config file with comments, to prove saving does not flatten them.
|
||||
|
||||
47
python-backend/tests/test_sound.py
Normal file
47
python-backend/tests/test_sound.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""How loud UI sounds are, and the album picker's two sounds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.blubb import RATE, confirm_wav, tick_wav
|
||||
from musicmouse.sound import MIN_PLAYER_VOLUME, ui_level
|
||||
|
||||
|
||||
def test_ui_sounds_follow_the_music_volume() -> None:
|
||||
assert ui_level(0.8, 1.0) == pytest.approx(0.8)
|
||||
assert ui_level(1.0, 0.5) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_ui_sounds_never_drop_below_the_minimum_player_volume() -> None:
|
||||
# A muted player must not mute the timer, nor make the album picker silent.
|
||||
assert ui_level(0.0, 1.0) == pytest.approx(MIN_PLAYER_VOLUME)
|
||||
assert ui_level(0.0, 0.5) == pytest.approx(MIN_PLAYER_VOLUME * 0.5)
|
||||
|
||||
|
||||
def test_parents_can_still_turn_ui_sounds_off() -> None:
|
||||
assert ui_level(0.8, 0.0) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("make", "longest"), [(tick_wav, 0.05), (confirm_wav, 0.4)])
|
||||
def test_the_picker_sounds_are_short_mono_wavs(make: Callable[..., bytes], longest: float) -> None:
|
||||
with wave.open(io.BytesIO(make(gain=0.5))) as wav:
|
||||
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
||||
assert 0 < wav.getnframes() / RATE < longest
|
||||
|
||||
|
||||
def test_the_gain_sets_the_peak() -> None:
|
||||
def peak(data: bytes) -> int:
|
||||
with wave.open(io.BytesIO(data)) as wav:
|
||||
frames = wav.readframes(wav.getnframes())
|
||||
return max(
|
||||
abs(int.from_bytes(frames[i : i + 2], "little", signed=True))
|
||||
for i in range(0, len(frames), 2)
|
||||
)
|
||||
|
||||
assert peak(tick_wav(gain=1.0)) == pytest.approx(32767, abs=1)
|
||||
assert peak(tick_wav(gain=0.25)) == pytest.approx(32767 / 4, abs=1)
|
||||
@@ -207,7 +207,7 @@ async def test_playing_a_figure_album_reuses_the_figure_playlist(
|
||||
await client.post("/api/play", json={"album_id": album["id"]})
|
||||
await 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:
|
||||
|
||||
Reference in New Issue
Block a user