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

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

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

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

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

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

View File

@@ -6,8 +6,9 @@ async handlers, so running it there would hold every other event up behind ``xse
Here each press gets its own task, and a hung or failing command is only logged.
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
ALSA device, at the player's volume. The command's last word of output says which way
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
ALSA device, as loud as UI sounds are (:func:`musicmouse.sound.ui_level`). The
command's last word of output says which way it went - ``on`` or ``off``, as
``kiosk-screen`` prints it; anything else gets no sound.
With an ``off_command`` it also switches the monitor off by itself once nothing has
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
@@ -68,7 +69,8 @@ class ScreenService:
self.config = config
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device
#: The player's volume as a fraction, read at the moment the sound plays.
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
#: read at the moment the sound plays.
self.volume = volume
self.is_playing = is_playing
self._clock = clock or RealClock()

View File

@@ -49,8 +49,6 @@ RING_SECONDS = 60.0
#: The chime's peak at full player volume, as a fraction of full scale - soft.
_CHIME_GAIN = 0.3
#: Never quieter than at this player volume: a muted player must not mute the timer.
_CHIME_MIN_VOLUME = 0.3
#: After a failed holiday fetch, try again this much later rather than at midnight.
_RETRY_SECONDS = 3600.0
@@ -94,7 +92,8 @@ class TimerService:
self.client = client
#: Where the chime plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device
#: The player's volume as a fraction, read when the chime plays.
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
#: read when the chime plays.
self.volume = volume
self._clock = clock or RealClock()
self._wall = wall
@@ -211,7 +210,7 @@ class TimerService:
async def _chime(self) -> None:
if self.alsa_device is None:
return
gain = _CHIME_GAIN * max(_CHIME_MIN_VOLUME, min(1.0, self.volume()))
gain = _CHIME_GAIN * max(0.0, min(1.0, self.volume()))
wav = await asyncio.to_thread(chime_wav, gain=gain)
await play_wav(self.alsa_device, wav)

View File

@@ -293,6 +293,7 @@ class SettingsOut(BaseModel):
initial_volume: int
volume_increment: int
button_leds_brightness: float
effects_volume: int
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
#: switch off (no ``screen`` section, or one without an ``off_command``).
screen_auto_off_minutes: int | None
@@ -304,6 +305,8 @@ class SettingsIn(BaseModel):
initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100)
button_leds_brightness: float = Field(ge=0, le=1)
#: UI sounds relative to the music, in percent.
effects_volume: int = Field(ge=0, le=100)
#: Ignored without a screen section. ``None`` leaves it as it is.
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)

View File

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