64 Commits

Author SHA1 Message Date
cf38ae6fe2 Room page: colour swatches send their colour, not white
oklchToRgb read the colour back from the browser's CSS engine and expected
rgb(...). Current Chromium (the kiosk's 153) resolves an oklch colour to
oklch(...), so the match failed and every swatch fell back to [255, 255, 255]:
tapping a colour set the light to white. It now does the OKLab maths itself.

Also log a Home Assistant call that fails in the proxy: httpx only logs a
request once it has a response, so a call that timed out left no trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:24:02 +02:00
de94bd4118 Room page: taps stay visible, swatches match, an unreachable blind says so
- A tapped light or blind state now holds for 4s against polled state: a poll
  that starts just after the tap can still return the device's old state
  (a Hue light reports about a second after HA answers), which flipped the
  card back and looked like the tap had failed.
- Swatches match what a light reports, not only what it was sent: a Hue
  light hands rgb_color back at full intensity, so the exact comparison never
  showed a swatch as selected.
- The Rollo card says "Nicht erreichbar" and disables its buttons while HA has
  the cover as unavailable, instead of showing "Offen"; up/down show the
  blind as moving at once, so they are not pressed again and again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:23:55 +02:00
13d14c00aa Clock: bubbles on the cards' side while a countdown is showing
With countdown cards the watch moves left and covers roughly 6-49% of the
width, where the plain field's bubbles would rise behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:48:02 +02:00
701e90153c Clock: hand outlines drawn once, bubbles beside the watch
The hands' drop-shadow outline sat on their own compositor layers, so the GPU
recomputed it every frame a bubble moved. It is now on an inner box, painted
once into the hand's layer. The bubbles rise beside the watch, never behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:45:31 +02:00
e94e39addc web README: installing the parents' app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:19:54 +02:00
8ddfafebcb PWA: a home-screen install from ?parentMode=1 keeps parent mode
iOS starts an installed web app at the manifest's start_url ("/"), so the
query parameter was dropped. Opened with ?parentMode=1, the page now points at
a second manifest whose start_url keeps it, and names the app "Musik Eltern".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:19:42 +02:00
17db811bba Clock: show it at once and render it cheaply; Guide is KEY_GUIDE
The remote's hauppauge.rem names the Guide button KEY_GUIDE, not lircd's
KEY_EPG, so the remote never toggled the clock.

The overlay is now hidden by opacity rather than display: none, so it is
already laid out and painted when Pos1 or Guide shows it. The drop-shadow
filter on the whole watch is gone (it re-rendered and re-blurred the dial on
every second-hand tick), the hands sit on their own layers, and the bubbles
and countdown cards only exist while the clock is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:59:06 +02:00
b56b41996b Clock: toggle with Pos1 instead of Einfg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:36:33 +02:00
7b9e072713 Browse: scroll the root shelves again, just without a scrollbar
Hiding the vertical scrollbar with overflow: hidden also stopped the wheel from
scrolling. Keep overflow: auto and hide only the bar, as the shelf rows already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:16:29 +02:00
4cb2d085ca Add agent task files for the review's remaining fixes
Authentication (01), one input policy for skip/mute/volume steps (04), the
firmware's interleaved serial frames (05), podcast as an album kind (06), and eight
smaller cleanups (07). Each says why, what was decided, the design, the tests and
when it is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00
a737ccf108 Bring the docs in line with the code
- python-backend/README: track analysis is implemented (2e0e6ad); the cache layout,
  the module table (lirc, screen, timers, podcasts, tippen) and the route table
  (track analysis, HA proxy, typing game) were missing pieces.
- docs/REPO_OVERVIEW: Mein Zimmer and the clock are implemented; Home Assistant is
  also reached over HTTP through the room page's proxy, not MQTT only; the IR remote,
  scripts/, the optional config sections and figure kind were not mentioned.
- web/README: the key table was the mockup's (Ctrl transport keys, TAB over 'all');
  ?pi=1 is now a preset with per-browser overrides, not a read-once flag; the room
  page and the typing game exist; eslint is part of the checks.
- Docstrings that described earlier designs: HaConfig (written as browser-direct in
  the same commit that built the proxy, a8ed350), services/__init__, events,
  playback, media, colors, tippen progress and AppHeader, lowPower.ts, and the
  'Hoerbuecher' spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00
a0ac48ccbf Remove bloat: the design bundle zip, duplicate images, course notebooks
- claude-design/Dolphin Beats Music Player.zip (32 MB): the design tool's export of
  the mockups already unpacked next to it. Still in history (30065bb) if needed.
- claude-design/pasted-*.png: byte-identical copies of dolphin-mascot.png and
  dolphin-remote.png that nothing references.
- python-backend/notebooks: the chord-recognition course material with stale paths.
  effect_debug.ipynb stays.

The hardware models are untouched, and the history keeps its size: removing files
from the tree does not shrink the pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00
751637d2f1 Remove dead code
- wire.with_figure: the device uses dataclasses.replace directly.
- DeviceConnected/DeviceDisconnected: emitted, never subscribed to, and a duplicate
  of ConnectionChanged(target="firmware").
- ScreenService.toggle(): never called.
- GET /api/lirc (LircConfigOut, api.lircConfig): no front-end reads it.
- POST /api/volume's delta_percent (api.nudgeVolume): no front-end sends it.
- LibraryCache.shrink_stored_covers: a one-off migration that re-read every cover
  header on every scan. _INDEX_VERSION 7 rebuilds any cache that could still need it.
- Firmware: the MFRC522 library and its unused globals (lib/nfc/rc522.c is the
  driver in use), and mfrc_version.h, a reader test sketch nothing included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00
7a7ad9f0a7 Push a state snapshot only for events that change it
The hub sent every open tab a full snapshot on every StateEvent, LED writes
included - placing and removing a figure was 13 snapshots, 9 of them for LED
changes the snapshot does not even contain. List the events that matter, and make
a test fail when a new StateEvent is sorted into neither list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00
b791beed9c Make mypy run again, fix what it found, and add one check script
a7fb56c pinned mypy to Python 3.11 for the Pi. In a dev venv that also has the
analysis extra, numpy's stubs use 3.12 syntax, so mypy aborted on them before
checking a single file - and from the next day on four real errors went in
unnoticed (ebab03b, 0ef5a04, 9fd106b). Skip following numpy/librosa instead; only
librosa_analyzer.py touches them. Fix the four errors (variables reused with a
narrower type).

There is no CI, so scripts/check.sh is the gate: pytest, ruff, mypy, vitest, tsc
and eslint in one command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:49:23 +02:00
14b5133543 Resume a figure after a library rescan
play_figure resumed only when the player's playlist *was* the figure's playlist
object. rescan_library rebuilds every Playlist, so after the refresh button - or
the podcast poller landing a new episode, every few hours on its own - putting a
figure back started it over. Playlist is a frozen dataclass: compare by value.

The album picker's "keep what is already playing" check had the same identity
comparison and restarted the album after a rescan; it compares by value too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:49:23 +02:00
ecb35dd5b5 Remove the Slint front-end
It has been untouched since 2026-09-21 while the web front-end got every feature and
the Pi performance work since (thumbnails, ?pi=1, number keys, clock, volume steps).
Keeping it meant a second, already stale copy of the search, keymap, Home Assistant
client and API types. Nothing outside slint-frontend/ referenced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:48:17 +02:00
4428357e13 Phone layout: opaque status bar and one safe-area rule for every edge
Each fix to the home-screen app's layout on iOS broke something else, and they
all traced back to black-translucent: it runs the page under the clock, so any
top-anchored control without its own inset sat behind it (the play view's back
button), and since iOS 26 it sizes the app one status bar short with a strip at
the bottom that CSS cannot paint (WebKit 301108). The status bar is opaque now
("black"), the page starts below it, and lib/fullHeight.ts - the JS workaround
for the short viewport - is gone with the CSS that depended on it.

viewport-fit=cover stays, so everything against a screen edge now adds that
edge's inset from shared --safe-* variables: the corner back button, the
remote-assign button, the assign toast, the tool buttons and the tab rail did
not before. The rules are written down under "Layout on phones" in the README.

On a phone held sideways the mini player and the tabs share one row instead of
taking half the screen.

Home-screen installs read the status-bar tag once: remove and re-add the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:46:06 +02:00
34f2f016f9 Firmware: port to Arduino core 3 / ESP-IDF 5.5 and pin every dependency
The unpinned platform had moved to Arduino core 3, which the firmware no longer
built against. Pinned now: pioarduino's platform 55.03.312-1 (Arduino core 3.3.12,
ESP-IDF 5.5.5) - PlatformIO's own espressif32 platform still ships core 2 - plus
MFRC522 1.4.12, AceButton 1.10.1 and native 1.2.1.

- LED strips: the legacy RMT driver is replaced by the new rmt_tx API with a bytes
  encoder, at the same 50 ns tick, bit timings and byte order. ESP-IDF 5 aborts at
  boot when both drivers are linked, and the driver now picks the channel itself,
  so Esp32DriverRGBW::begin() takes only the pin.
- Button backlights: ledcAttach instead of ledcSetup/ledcAttachPin, and ledcWrite
  is addressed by pin rather than channel.
- Rotary encoder: gpio_reset_pin instead of the removed gpio_pad_select_gpio.

The touch pads stay on the legacy touch driver: on the plain ESP32 it only warns,
its conflict check passes as long as the new driver is not linked (it is not), and
moving would change the raw counts the thresholds were tuned against.

src_filter is build_src_filter now. Not yet tried on the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:26 +02:00
d465a74cde 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>
2026-10-04 00:29:18 +02:00
a55cc4f67f Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big
analog watch over whatever page is open, built from the Delphin Uhr mockup
without its swimming dolphin. The overlay stays mounted and is only hidden, so it
appears at once. Beside the watch, only while they run:

- The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25)
  that starts `bus_lead_minutes` (60) before. School days skip weekends and the
  school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily
  from openholidaysapi.org and cached under the library cache. A badge on the
  clock says whether there is school today, and why not.
- The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time).
  It lives in the backend, so a parent's phone can start it for the kiosk. When it
  runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the
  device; it clears after a minute, or at the next key, tap or remote press.

Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button
next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay
bookmarked. The timer comes first there, the device settings below it.

Unmapped IR buttons are logged at debug level, to find out what irserver calls a
new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:45:45 +02:00
30065bb9e2 Design: the "Delphin Uhr" clock mockup, and the updated design bundle
Delphin Uhr.dc.html is the reference for the kiosk's clock overlay. The bundle
now also carries it, its bus and dolphin-with-watch images, and the dinosaur
skin's artwork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:44:32 +02:00
1254790374 Volume: quieter/louder buttons that take the remote's own step
Next to the five volume bars, a − and a + button move the volume by
`volume_increment` device units, the same step as the IR remote's volume keys and
the rotary encoder, via a new `{steps}` form of POST /api/volume. The bars still
jump to a percentage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:44:07 +02:00
c74cb09324 Browse: no vertical scrollbar on the root shelves
The three shelf rows fit the screen, and each scrolls sideways on its own, so the
page around them no longer scrolls vertically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:43:06 +02:00
077c4e819f Screen: switch off by itself when nothing is going on
With `general.screen.off_command` set, the monitor switches off once nothing has
played and nobody has done anything for `auto_off_minutes` (15 by default, 0 for
never, editable in parent mode). "Doing anything" is any intent a person could
have caused: a remote key, a web command, or a key press or tap in the kiosk,
which the page reports at most every 30s via POST /api/screen/activity. The first
such intent after an auto-off runs `on_command`, so the remote wakes the screen
too. The auto-off itself is silent; switches a person asks for still blubb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:42:59 +02:00
459599d2ba Number keys: the keyboard's digits work like the remote's; a key can hold one song
The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:41:34 +02:00
92305bece0 Screen switch: a blubb on every switch, and Del on the kiosk keyboard
ScreenService plays a synthesised two-bubble "blubb" (musicmouse.blubb) after each
switch, through aplay on the music's ALSA device at the player's volume: rising for
on, falling for off, going by the last word the toggle command prints. Nothing plays
with simulated audio or at volume 0. Built from samples rather than shipped as a
file, like the front-end's WebAudio blips, so its loudness follows the volume.

Del maps to a new "screen" action on every page, which App.tsx sends to the new
POST /api/screen/toggle only when the page is served from localhost - the kiosk on
the device - so Del on a tablet does not switch off a screen in another room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:00:37 +02:00
2d0caeb732 IR remote: power key switches the screen; count irserver's repeats
KEY_POWER emits ToggleScreenRequested, which a new ScreenService turns into the
optional general.screen.toggle_command (musicdolphin: ansible's kiosk-screen). It is
a service, not a reaction, so the subprocess runs in its own task instead of holding
the bus; a press while one is still running is dropped, and failures, hangs and a
missing command are only logged.

irserver reports every line of a held button as repeat 00, so a slightly long press
on Next skipped several tracks and Power would switch the screen off and on again.
A line of the same button within 0.25s of the previous one now counts as a repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:13:22 +02:00
92644e9519 IR remote: accept irserver's lowercase button and remote names
irserver (musicdolphin's IRTrans daemon) serves lircd's network protocol on port 8765,
but sends "key_play hauppauge" where lircd sends "KEY_PLAY Hauppauge", so every press
was dropped. Uppercase button names when parsing and compare the remote name
case-insensitively.

irserver also always reports repeat 00, so a held transport button is not debounced
there; volume ramps as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:57:50 +02:00
f67f84ded3 Phone layout: make the whole document reach the bottom in the home-screen app
Sizing the stage from the screen height was not enough on iOS 26: with the layout
viewport a status bar short, WebKit clips at the short viewport and draws nothing
below the document, so the dock still floated over a dark strip. In standalone mode
`<html>` now takes the larger of `100vh` and the screen height too, and the stage
follows it.

Coming back from the background, or a relaunch restoring the page, is when iOS is most
likely to hand back a short viewport and it does not always fire `resize`, so the
height is also re-applied on `pageshow` and `visibilitychange`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:29:50 +02:00
34656a8517 Phone layout: fill the screen in the home-screen app, drop the title row
In the iOS home-screen app the layout viewport - `100dvh`, `innerHeight`, even a
`position: fixed; inset: 0` box - can come back one status bar short, which left the
dock floating that far above the bottom of the screen with an empty strip below it.
In standalone mode the stage now takes its height from `screen` instead (swapped for
landscape, since iOS reports it in portrait), via a `--full-height` variable that
lib/fullHeight.ts sets only there; the keyboard inset is measured against the same
height. A Safari tab, Android and the kiosk are unchanged.

The mascot and app title above the group switch only cost a row on a phone, so they
are gone; the connection/figure pill moves above the group switch, centred, and only
shows while it has something to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:40:21 +02:00
1da2274076 Pi profile: no glass blur, keep the drift; consolidate the perf knobs
With the ambient canvas off under ?pi=1, the backdrop blur no longer buys anything:
it paid for itself by giving each panel a compositing layer above the animating
canvas, and with nothing animating underneath it was just cost - enough to make the
browse rows visibly sluggish on the device. So the Pi profile now drops every backdrop
blur (`glassBlur`, which the music app and the typing game share), and turns the
drift and the moving mascot back on: plain CSS transform loops the compositor runs by
itself, which measured as noise.

The knob set is consolidated to match: one glass toggle instead of panel and card
blur, one shadows toggle for cards and tiles, the typing game's drift and view
transitions folded into the shared ones, and the backing-store cap fixed by profile
rather than a setting. The parent dialog groups them into Darstellung / Abspielen /
System and hides the canvas tuning while the canvas is off. Stored settings move to
musicmouse.perf.v2, since v1 drafts held the old preset in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:19:06 +02:00
2a629faf50 Web frontend: switchable dolphin/dinosaur skins
Two kids, two looks: ?theme=dolphin or ?theme=dinosaur picks one, and the
choice is remembered in the browser so the installed home-screen app keeps it.
Everything that differs per child lives in one Skin object per file under
web/src/lib/skins/; layout and feature toggles in lib/theme.ts stay shared.
The dolphin renders pixel-identical to before.

The hardcoded sea hues (210 music, 175 typing game, 340 accent) are now CSS
variables written once at startup, and the Gecko-friendly sRGB gradient stops
come from the skin. The dinosaur is jungle green with a lava-orange accent,
its own mascot, icons, manifest and browser colour.

Nothing underwater is left in the dinosaur skin: leaves instead of bubbles
(play-view canvas, room page, typing game), a walking mascot, lava blobs in
the bubble game, a hatching egg instead of the treasure chest, dino pets that
walk or fly, a prehistoric speed ladder with the T-Rex as the hero rung, and
renamed games and light colours. Pets and ladder rungs are keyed by the
backend's existing ids, used as slots, so the backend and save format are
unchanged. Pets show as emoji until drawings exist.

python-backend/.gitignore: ignore tippen-curriculum-dino.yml, the dino
device's personal curriculum, like the original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:41 +02:00
21d65cad9e Web frontend: phone header glass and full-height stage
On an iPhone the sticky group switch sat on a fixed teal fade, which was the
wrong colour as soon as the ambience took on another album's tint (brown for
Conni). It is now a neutral frosted blur that fades out, so it suits whatever
is playing, and it reaches up under the status bar so scrolled content never
shows behind the clock.

After the search keyboard had been up, 100dvh in the home-screen app came back
a status bar short and the white page showed as a strip along the bottom. On
phones the stage is pinned to the viewport edges instead, with the sea colour
behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:45:52 +02:00
6981890be0 Web frontend: hide app title and row titles, regroup help sheet, drop Ctrl+d/u
- New SHOW_APP_TITLE switch (off): the mascot + title header goes, the status pill
  stays on a slim row of its own.
- SHOW_ROW_TITLES off; with only icons, the shelf icon moves into a column left of
  the tiles so an icon-only shelf is no taller than one without.
- Help sheet regrouped into sections in a compact two-column grid, and reachable
  from a small keyboard button (not on phones or the typing page).
- Ctrl+d/Ctrl+u half-page jumps removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:06 +02:00
5af5c54c62 Backfill podcast covers for episodes already on disk
A podcast's episode cover was only fetched right after the feed sync downloaded that
episode, so anything placed by hand (or downloaded before the og:image fallback
existed) never got one - in Wissen macht Ah only the three newest episodes had their
own art. The sync now fetches the cover of every on-disk episode in the feed window
that lacks a sidecar image. One that can't be found or fetched is recorded in
.missing-covers.json and left alone for the same 7-day backoff as failed downloads,
so a feed without per-episode art doesn't cost a page fetch per episode every poll.

The scanner's per-episode fingerprint now includes the sidecar cover, so one added
after the episode was first scanned invalidates its cached entry instead of being
ignored until the index is rebuilt. Episodes without a sidecar fingerprint exactly
as before, so existing caches stay valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:06 +02:00
0f08e1d16c Web frontend: touch layout for phones
Phones got the kiosk's keyboard-first layout squeezed down: shelves cut off
by the tab rail and a header taking a fifth of the screen. They now get
their own layout, chosen by a media query (narrow, or short landscape on a
touch screen); tablets and desktop are unchanged.

- Pick a group (Musik/Hörbücher/Podcasts), then a category, then scroll
  through a grid of smaller albums.
- A floating glass dock: mini player, Musik/Zimmer tabs (no typing tab - a
  phone has no keyboard for it) and a search button that turns into a real
  search field, raised above the on-screen keyboard.
- Album sheet and room page get compact phone variants.

Browse state stays in the shared UiState, so resizing or rotating keeps
your place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:33:26 +02:00
61104d1e25 sync-library.sh: run the closing report under sh, not the remote login shell
The report was an ssh command string, which the remote user's login shell runs - and
on the devices root's is fish, which rejects `if ...; then`. The copy itself finished,
but the script then exited 127 at the very end, so a successful sync looked failed.
Feed the report to `sh -s` on stdin instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:22:21 +02:00
683cda35cb Add design references, editor settings and todo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
2db4368fc9 Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
9fd106b757 Backend: cover cache and web API changes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
794126bfdb Port "Mein Zimmer" - the Home Assistant room panel
The second of the web front-end's three pages. Lights get a toggle, five brightness
steps and six colours; shutters get four presets, up/stop/down and a window that
reflects the position Home Assistant reports. Its own violet hue rather than the
player's teal, so the two pages read as siblings rather than one bleeding into the
other - the same reason room.css has its own tokens.

The backend keeps Home Assistant's URL and token and relays the calls, so this client
learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha
means Home Assistant is not configured, which is a normal answer: the tab simply does
not appear.

Polled, not subscribed, and only while the page is on screen. HA's own auth-and-
subscribe websocket is more machinery than a room panel needs, and a lamp's state is
not worth a request every 2.5 seconds when nobody is looking at it - this device has a
music player to stay out of the way of. A tap patches the entity locally and records
when; a poll that was already in flight when the patch landed is dropped for that
entity rather than putting the lamp back to "off" until the next one catches up. That
reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried
over for the same reason.

Nothing on a shutter card animates locally. A real cover reports its own movement and
position, and a client-side guess at where it will end up is precisely what would
fight with that - the design mockup animated it because it had no backend to ask.

Two additions beyond a straight port, both because a kiosk has no pointer:

CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a
front-end whose README claims every screen is reachable from the keyboard. Plain TAB
was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere
else.

--page opens directly on a page, which a kiosk may well want and which is the only way
in with neither keyboard nor pointer.

Verified against the real Home Assistant: the shutter reported open and the lamp off,
and both drew that way. Nothing was switched - those entities are hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 01:02:18 +02:00
ec9d617ad8 Vendor Nunito as a TTF so the device renders in the right face
Slint cannot read woff2, which is the only form web/public/fonts/ carries, and Raspbian
has no fonts-nunito to install instead - so the device was going to fall back to
DejaVu Sans, which is not what any of this was drawn in. The upstream variable TTF
(SIL OFL, weights 200-1000) is small enough to vendor and covers every weight the
design uses, including the 800/900 the headings lean on.

SLINT_DEFAULT_FONT takes one file and makes it the primary font. shell.nix now points
at this copy rather than at nixpkgs' own, so development and the device render from the
identical file, and the binary falls back to finding it beside the executable when
neither the dev shell nor the device's launcher has set the variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:45:37 +02:00
6ccb3e458f Add a native Slint front-end for the music player
The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor
deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport,
and a JPEG decode per album card per cold start. The last three commits chased that
through the service worker and the cover pipeline. This is the other direction - the
same backend, the same layout, the same German copy, without a browser.

Browse and play only. The typing game, the room-lights page, parent mode and the
IR-remote assignment stay web-only, and nothing here touches python-backend.

Four things carry the performance claim, in rough order of how much they should matter:

Covers are downscaled once, ever. The backend serves one size - 640px on the long edge,
no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker
thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/.
Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel
size would give each album a dozen near-identical files and a fresh decode for each. The
cache is dropped when the websocket announces a rescan, because that is the one moment
the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn
about the hard way.

The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the
view puts those in a ListView, which instantiates only what is on screen. A flat list of
660 cards gives it no rows to skip, hence the chunking. Same purpose as
content-visibility: auto on .grid > .card.

The progress bar animates between the 2 Hz pushes rather than running a clock, so
interpolation costs a property evaluation per frame on the render side and there is no
equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a
new track jumps instead of sliding across two unrelated positions.

Nothing on screen animates by itself. The ambient canvas is not ported, for the reason
lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get
under while it runs at all.

The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as
solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and
the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays
the default only for desktop development. Both profiles are verified to build; the kiosk
binary links libinput/libgbm/libdrm and no X11 or Wayland at all.

lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their
own tests, so they port across as pure Rust with theirs: 43 tests, no window required.
The .slint files are layout only - nothing in them formats a number or picks a word.

Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh
renders the UI inside a nested headless compositor and grabs a frame per screen, which
is how that was checked on a machine whose session was locked; a Slint window needs a
real compositor, and under bare Xvfb nothing maps at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:26:09 +02:00
83d16e0058 Take the service worker off the cover path, and cut cover art to 384px
Search was still stalling for seconds a keystroke. A trace across five keystrokes said
the renderer main thread was blocked for 9.4 s in a single task with *zero* V8 samples
inside it - no JavaScript ran at all. What ran instead, on the worker pool during those
same 9.4 s: ImageDecodeTask 7.6 s, RasterTask 1.7 s, and only 229 ms of actual "Decode
Image". The main thread was waiting on cover bytes, not computing anything.

Two causes, and the first one was mine. The stale-while-revalidate handler added earlier
today made every cover do a Cache Storage read *plus* a network fetch *plus* a cache
write, all serialised through one worker thread. Measured with
Network.setBypassServiceWorker: worst keystroke 5580 ms through the worker against
461 ms without it. So covers no longer go through the worker at all. That costs nothing
here: this worker only registers on localhost or over HTTPS, which on this setup is the
kiosk on the device itself, where the backend is the same machine and a cache lookup is
strictly more work than asking for the file. The shell caching, which is what makes it
installable, stays.

The second is that decode cost goes with pixel count, and 640 px was headroom for a
tablet at devicePixelRatio 2 that nobody had asked for. A browse grid paints a card
132 px wide; the largest any screen asks for is 340. At 384 px, typing "conni" over 343
albums, keydown to painted, three runs:

  before   3735,  270, 5580,  88,  57 ms
  after     873,   75,   17,  24,  26 ms

`shrink_cover` never scales art up, so dropping the limit cannot be applied by
re-reading the cache - only by going back to the original art. _INDEX_VERSION 6 does
that: the index is discarded and every album is scanned again through store_cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:31:57 +02:00
ebab03b700 Cache every cover, not only the ones pulled out of tags
The downscaling landed and the device kept serving 1920px art, because _cover_for has
two branches and only one of them went through the cache. An album with a cover.jpg
already sitting beside its audio - which most of this library has - had its `cover`
point straight at that file, so FileResponse served whatever the internet had given it.
The cache directory was full of tidy 640px files that half the albums never used.

Both branches now store through the cache, in _cover_for and in _cover_for_episode
(sidecar and shared-folder art alike). The undownscaled bytes still come back alongside
the path, because colour extraction wants the real thing.

That re-points `cover` for every album, but only for albums that are actually rescanned,
and the scanner reuses anything whose fingerprint is unchanged - so _INDEX_VERSION goes
to 5. An index from 4 is discarded and rebuilt, which is what that mechanism is for and
is cheap by design.

Two tests asserted `cover == <the library file>`, which is precisely the behaviour being
changed. The episode one was also checking something real - that an episode's own
sidecar art beats the show's shared cover - and both covers now live under their own
album id, so it reads the colour back out of the stored file instead of comparing paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:57:00 +02:00
3fea56d4e1 Stop cover art going stale in two caches at once
Shrinking the covers changed nothing on the device, twice over, because two layers were
independently serving the old bytes and each had the same wrong premise written into its
comment: that a cover is "content-addressed by album id". The id addresses which album
the art belongs to. The bytes behind the URL change whenever the art is reprocessed.

The backend sent `public, max-age=604800`, so every browser that had loaded the page in
the previous week kept decoding the 3000px original out of its own disk cache without
asking. It now sends `no-cache`, which does not mean "do not store" but "revalidate
before reusing" - the file stays cached and the usual answer is a 304.

The service worker cached covers cache-first on a URL that never changes, which means a
client could serve a stale cover for ever; bumping the cache name fixed today's covers
and would have had to be done again for the next batch. It now does
stale-while-revalidate: the cached copy is returned immediately, so a grid of album art
still never waits on the network, and a fresh copy is fetched behind the page for next
time. Neither layer needs a version bump when art is reprocessed again.

The revalidation is off the paint path entirely, which is what makes `no-cache`
affordable here: the service worker answers first, the network call happens behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:43:56 +02:00
e1c10f5408 Retire the service worker's cache of the old, huge cover art
A DevTools trace recorded on musicdolphin put the whole question beyond argument:

  ImageDecodeTask      29,939 ms  x20   (~1.5 s each)
  Decode LazyPixelRef   3,924 ms  x20
  Decode Image          3,530 ms  x22
  Paint                    99.7 ms
  Layout                   77.3 ms
  all app JavaScript      <400 ms  across 22 seconds

Image decoding is not the largest cost on that page, it is very nearly the only one.

The covers should already have been small - the backend downscales to 640 px now - but
the page was still decoding them at 1920, 1600, 1400 px, with deliveryType
"cache-storage" on every one. The service worker caches cover art cache-first, keyed on
URL, on the premise that it is "immutable per album id". The id did not change; the
bytes did. So every client that had ever loaded a cover kept serving the 3000 px
original from its own disk and never asked the backend for the new one.

Renaming the cache is the retirement mechanism the worker already has - `activate`
deletes every cache that is not one of the two current names - so COVERS becomes v2,
with a note saying that changing how covers are produced means bumping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:29:37 +02:00
0ef5a04cb9 Downscale cached cover art, and stop every animation loop under ?pi=1
Covers. Art out of an ID3 APIC frame is sized for a record sleeve: this library
averaged 3000x3000 and 580 kB per cover, 140 MB across 284 albums. The browser was
decoding nine megapixels - around 36 MB of bitmap - for every cover it painted, to show
it in a 185 px card, on a Pi with 2 GB of RAM. The largest any screen in this app asks
for is 340 px (the play view), so cache.store_cover now downscales to a 640 px long
edge, which leaves room for a tablet at devicePixelRatio 2 and cuts the decode about
twentyfold. Pillow was already a hard dependency, for colour extraction.

scan_library reuses an album whose fingerprint is unchanged without re-reading its
tags, so covers already on disk would never be rewritten - hence shrink_stored_covers(),
a pass at the top of a scan. Reading a JPEG's dimensions only parses its header, so
after the first run it costs one small read per album. Art already small enough is
returned byte-identical rather than re-encoded, so repeated scans cannot slowly grind
it down, and anything Pillow cannot read is passed through untouched: a cover that is
too big is a performance problem, a cover that is missing is a visible one.

Animation. Halving the ambient canvas to a quarter of the pixels at 30fps took it from
53.5% of a core to 25%, and 25% was still not good enough to use. A requestAnimationFrame
loop repainting the viewport is a floor you cannot get under while it runs at all, so
?pi=1 now switches it off outright rather than thinning it, along with the decorative
CSS loops, the view transitions, the typing game's bubbles and its next-key pulse. The
pets stay on screen but hold still, through the same path prefers-reduced-motion already
took - taking the animation away is the point, taking away what she earned is not.
.stage keeps its own static gradient, so there is still a sea behind everything.

Both blurs on the panels stay on. Dropping those measured five times worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:44:02 +02:00
3cdad1e714 Stop rendering album cards that are off screen, and split card blur from panel blur
Searching a single letter over a real library puts 340 cards in one grid, each with a
cover image, a shadow and a backdrop blur, all of them live whether or not they are on
screen. On musicdolphin that saturated the renderer badly enough that a DevTools
Runtime.evaluate could not be scheduled on the main thread inside 30 seconds, which is
a fair description of what "sluggish" felt like.

content-visibility: auto on the grid's cards is the browser's own answer: off-screen
cards skip layout, paint and compositing and come back as they scroll near the
viewport. The grid's tracks are sized by minmax(180px, 1fr) rather than by card
content, so skipping that content cannot move the columns; contain-intrinsic-size
supplies the block-axis guess and `auto` remembers each card's real size after its
first render, so scroll height and offsetTop - which BrowseView's keep-the-selection-
on-screen effect reads - stay honest. Not behind ?pi=1: there is no visual difference
to trade away.

SHOW_CARD_BLUR separates the repeated frosted surfaces (every card, every list row)
from the handful of panels wrapped around them, because the two behave nothing alike
and lumping them together is what made the first Pi profile five times slower. A panel
is one live backdrop copy that buys its whole subtree a compositing layer; a card is
one of three hundred sitting directly over the animated canvas. ?pi=1 now drops the
card blur and keeps the panel blur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:12:26 +02:00
2514ecbc35 Measure the Pi profile on the Pi, and keep only what helped
The first cut of ?pi=1 switched off the two things that look most expensive - the
backdrop-filter glass blur and the decorative CSS animation loops - and made the device
slower, not faster: 53.5% of a core idle became 118%. Measured on musicdolphin, sum of
the Firefox process tree, idle on the browse screen, 15s average:

  full app                                       53.5%
  + ambient canvas at half resolution            25.0%   <- the whole win
  + 30fps cap on top of that                     25.0%   (no idle change)
  + decorative CSS animation loops off           24.6%   (noise; left on)
  + backdrop-filter glass blur off              118.1%   <- 5x worse

The blur is what promotes each glass panel to its own compositing layer. Without it the
animated canvas and the whole album grid above it collapse into one layer and every
canvas frame repaints all of it. The most expensive-looking CSS in the app is what was
keeping the rest of it cheap. SHOW_GLASS_BLUR and SHOW_DECORATIVE_ANIMATIONS therefore
go back to unconditionally on, in both the music app and the typing game, with the
numbers written down next to them so the next person does not repeat this.

What is left is one real change - paint a quarter of the pixels - plus three caps that
only bite while something is playing and so are not in the table above: 30fps on the
canvas, 60 bubbles alive at once, and ten progress-bar re-renders a second. Those three
are unmeasured; measuring them means playing audio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:44:05 +02:00
cc3db44c4e Add a ?pi=1 profile, and stop re-deriving search keys per keystroke
Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the
app was burning ~70% of a core with nothing happening on screen.

Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle
field every frame, and `usePlaybackClock` pushes a React setState per animation frame
into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/
lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a
quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a
second - a progress bar advances one pixel every few hundred ms and its label has
one-second resolution, so nothing on screen can tell. Only the effects with no cheap
version actually go: the backdrop-filter glass blur and the decorative CSS loops.
Also drops a redundant full-canvas clearRect that the opaque gradient always covered.

Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches
called it on every album title and every track title on every keystroke - 4969 of them
for a track search, whose answer cannot change until the library does. buildSearchIndex
does it once per library payload; a keystroke is now String.includes over strings that
already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track
query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and
pre-sorts each shelf's categories, which App and BrowseView were deriving separately
from the same data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:15:23 +02:00
a7fb56c9fe Target the Python that Raspberry Pi OS ships
Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.

The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:

  type X = Y               ->  X: TypeAlias = Y
  type Handler[E: Event]   ->  E = TypeVar("E", bound=Event) plus a plain alias,
                               which is generic anyway because it carries a TypeVar
  def f[T: Bound](...)     ->  a module-level TypeVar

Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.

Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:20:59 +02:00
fa3d92189c Keep only the newest 50 episodes of each podcast
A show that has published for years is unbounded: GEOlino Spezial alone is 358
episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also
holds the rest of the library. Nothing stopped the 6-hourly poll from eventually
filling it.

Cap each show's folder at general.podcast_episode_limit (default 50, null to
keep everything), pruning the oldest past that after each sync pass.

The same limit caps what is downloaded, and it has to be one number for both.
Prune to the newest N but keep fetching everything the feed offers, and every
poll would re-download exactly the episodes the previous one deleted - forever,
at full size, since missing_episodes() decides purely from what is on disk.
There is a test for that specific loop.

Pruning only touches files named the way this module names them
(YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and
anything placed by hand are all left alone; a parse that fails means "not ours",
not "delete it". An episode's sidecar cover goes with it. A pass that only
deleted still reports a change, because the library needs the rescan just as
much as it does after a download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:40:53 +02:00
dd14f5d901 Add a script to seed a device's library and analysis cache
Copying the library to a Pi is one rsync; copying the cache with it is not,
because its three parts travel differently. covers/ is keyed by the album
folder's path relative to the library root, so it survives the move. analysis/
is keyed by name:size:mtime, so it survives only if mtimes do - hence -a
everywhere, and a comment saying why. index.json holds absolute source paths and
must not be copied at all: scan_library() reuses a cached album on a matching
(name, size, mtime) fingerprint without re-checking the path, so copying it
leaves every album pointing at the dev machine and playback fails on files that
are present. It is the cheap part, and the device rebuilds it on first scan
while reusing everything expensive.

Syncs shelf by shelf rather than the whole directory, which is what makes
--delete safe: config.yml, tippen-curriculum.yml, tippen-progress.json and the
cache all live in the same directory on the device and have no counterpart here.
Preflight checks ssh, sizes the transfer and refuses to fill the SD card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:58:50 +02:00
274085b92e Stop tracking the library cache
python-backend/.gitignore lists /.musicmouse-cache, but index.json and seven
cover JPGs were committed before that ignore existed, so the ignore never
applied to them. index.json is a 2 MB machine-specific blob holding 6606
absolute /home/martin/... paths, rewritten on every app start - it showed up as
modified in git status permanently, and a deploy checkout would have shipped a
dev machine's index to the device.

The files stay on disk; only the tracking goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:26:45 +02:00
f3337975c2 Celebrate a media unlock with a treasure chest that opens
The reward pipeline worked end to end already, but it had nothing to show for
itself: the unlock was one more line at the bottom of the result sheet, a 52px
thumbnail with the generic 520ms pop, below the stars, the stats, the progress
bar, the animal ladder and three other badges. The sound was `playFanfare`, the
same chirp used for a new lesson and a new aquarium pet. It fired correctly and
was impossible to notice.

This is the only reward that reaches outside the game, so it now gets the whole
screen. A chest drops in shut and rattles, the lid swings open on a burst of
light and confetti, the cover art rises out of it, and the tune is a real melody
- two seconds landing on a held major chord - rather than another blip. It is
dismissed by hand, so she can look at what she won for as long as she likes.

On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon
and open with the cover inside once it has been. It is rendered as a sibling of
the lesson node rather than a child, because a locked node is dimmed to 45% and
the chest that most needs to be bright is the one three worlds away.

One real bug behind the missing badge state: `progressFromApi` dropped the
`earned` flag the backend already sends, so the map could not tell a claimed
reward from an unclaimed one. Added, with a test that names it - a hand-written
field mapping loses fields without failing a type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:10:11 +02:00
e6f6b15cc7 Make the lesson map the floor screen of the typing tab
The aquarium was a separate landing screen in front of the map, showing the pets
collected so far and a "Weiter üben" button. But the pets already swim behind
every screen in the tab, so the screen mostly restated what was visible anyway,
at the cost of one extra step between opening the tab and typing.

The map absorbs what was worth keeping: the "Weiter üben" button now floats over
it, and the pets, streak and best animal move into the header. One Escape from
the map leaves the tab entirely, where it used to go back a screen first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:08:11 +02:00
f3f7082fb8 Draw locked covers as artwork instead of a question mark
A reward-gated album showed a bare ❓ over the generated gradient, which reads as
"something is broken" rather than "something is waiting". It is now a proper
placeholder cover, with a separate one for audiobooks and podcasts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
36e93be79b Let a lesson list review keys beside the two it introduces
A lesson's `keys` was capped at two outright, which made it impossible to write
"K, with the four keys before it still in play". The cap now counts only what is
*new*; keys already taught can be listed freely, and the spotlight falls on the
new ones alone, so a lesson titled "K" drills K instead of spreading itself
evenly over all five keys it names. A round that introduces nothing new still
spotlights its whole list - that is the "mixed" replay, unchanged.

Also documents `unlocks:` paths as relative to the library root. Absolute and
"~" paths still resolve, so an existing file keeps working, but a relative path
survives moving the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
b6342cc117 Drop the pearls currency
Pearls were earned every run and spent on nothing: the aquarium fills up by
finishing worlds, not by paying for it. A counter that only ever goes up is one
more stat competing for attention on the result sheet and the home screen, and
one more field to carry through the run payload, the progress file and both test
suites.

Stars and the animal ladder already say how a run went, so nothing is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:08 +02:00
fd6283718c Analyse tracks in a pool of worker processes
A first pass over an unanalysed library is hours of librosa, and there was no
reason for a desktop to spend them one core at a time. Analysis now runs in a
process pool sized by `general.library.analysis_workers`, defaulting to one per
core bar one (capped at 8) when the key is absent.

Two consequences worth knowing before touching this: whatever an `Analyzer`
returns has to be picklable, and an analyzer that keeps state on its own
instance - a test double counting calls - only behaves as written with
`analysis_workers=1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:04:01 +02:00
75b0eed080 Remove the Quallenalarm (jellyfish) typing minigame
Letters-round lessons only had bubbles and jellyfish as eligible arcade
modes; with jellyfish gone, bubbles is now always used instead of the
two alternating - no lessons need to be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:38:19 +02:00
a69db98241 Use a plain text note glyph for the music tab, not the emoji
Setting color explicitly wasn't enough: the 🎵 emoji has its own
baked-in colour (a muted grey-blue on at least one real platform) and
simply ignores the CSS color property, unlike a plain Unicode symbol.
Swapped to the bare eighth-note character (♪), which renders as text
and finally responds to the color already set on the button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:29:18 +02:00
219 changed files with 18006 additions and 4013 deletions

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"files.associations": {
"*.tcc": "cpp",
"deque": "cpp",
"string": "cpp",
"unordered_map": "cpp",
"vector": "cpp",
"system_error": "cpp"
}
}

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;padding:14px 78px 8px 32px;">
<a href="{{ linkHref }}" style="justify-self:start;text-decoration:none;background:oklch(97% 0.01 230 / .95);border-radius:999px;padding:9px 16px;font-size:14px;font-weight:800;color:oklch(30% 0.03 230);display:inline-flex;align-items:center;gap:8px;box-shadow:0 6px 18px oklch(15% 0.04 230 / .4);white-space:nowrap;">{{ linkLabel }}</a>
<div style="display:flex;align-items:center;gap:12px;">
<img src="{{ mascot }}" alt="Delfin" style="width:82px;height:82px;object-fit:contain;flex:none;filter:drop-shadow(0 4px 10px oklch(15% 0.04 230 / .45));">
<div style="font-size:44px;font-weight:900;color:oklch(97% 0.01 230);letter-spacing:.5px;white-space:nowrap;">{{ title }}</div>
</div>
<div style="justify-self:end;display:flex;align-items:center;gap:8px;">
<sc-for list="{{ actions }}" as="action" hint-placeholder-count="0">
<button onClick="{{ action.onClick }}" style="{{ action.style }}">{{ action.label }}</button>
</sc-for>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1180, &quot;height&quot;: 100}, &quot;title&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;Musik Delphin&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;mascot&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;dolphin-mascot.png&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;linkHref&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;./Mein%20Zimmer.dc.html&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;linkLabel&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;💡 Mein Zimmer&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;actions&quot;: {&quot;editor&quot;: null, &quot;tsType&quot;: &quot;Array&lt;{label: string, style: string, onClick: () =&gt; void}&gt;&quot;}}"></script>
</body>
</html>

View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap" rel="stylesheet">
<style>
@keyframes bubbleUp { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-115vh) scale(1.3); opacity: 0; } }
@keyframes dolphinSwim {
0% { transform: translate(-35vmin, 0) scaleX(1); }
46% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(1); }
50% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(-1); }
96% { transform: translate(-35vmin, 22vh) scaleX(-1); }
100% { transform: translate(-35vmin, 0) scaleX(1); }
}
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-3vmin) rotate(5deg); } }
html, body { margin: 0; height: 100%; overflow: hidden; font-family: 'Nunito', system-ui, sans-serif; }
a { color: oklch(70% 0.16 340); }
a:hover { color: oklch(55% 0.16 340); }
</style>
</helmet>
<div style="position:relative;width:100vw;height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:6vmin;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:0s;"></div>
<div style="position:absolute;left:12%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 12s linear infinite;animation-delay:-3s;"></div>
<div style="position:absolute;left:22%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 8s linear infinite;animation-delay:-6s;"></div>
<div style="position:absolute;left:48%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 11s linear infinite;animation-delay:-2s;"></div>
<div style="position:absolute;left:63%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 10s linear infinite;animation-delay:-7s;"></div>
<div style="position:absolute;left:78%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 14s linear infinite;animation-delay:-5s;"></div>
<div style="position:absolute;left:88%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:-1s;"></div>
<div style="position:absolute;left:95%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 13s linear infinite;animation-delay:-9s;"></div>
<div style="position:absolute;left:0;top:30%;width:min(30vmin, 260px);pointer-events:none;animation:dolphinSwim 46s ease-in-out infinite;">
<img src="dolphin-watch.png" alt="Delphin" style="width:100%;height:auto;display:block;filter:drop-shadow(0 2vmin 3vmin oklch(15% 0.05 210 / .45));animation:dolphinBob 3.2s ease-in-out infinite;">
</div>
<!-- watch -->
<div style="position:relative;width:78vmin;height:96vmin;flex:none;filter:drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / .5));">
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(70% 0.16 340) 0%, oklch(70% 0.16 340) 70%, oklch(from oklch(70% 0.16 340) calc(l - .08) c h) 100%);left:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(-8deg);transform-origin:50% 100%;"></div>
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(66% 0.12 205) 0%, oklch(66% 0.12 205) 70%, oklch(from oklch(66% 0.12 205) calc(l - .08) c h) 100%);right:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(8deg);transform-origin:50% 100%;"></div>
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(64% 0.11 175) 0%, oklch(64% 0.11 175) 70%, oklch(from oklch(64% 0.11 175) calc(l - .08) c h) 100%);left:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(8deg);transform-origin:50% 0%;"></div>
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(58% 0.12 245) 0%, oklch(58% 0.12 245) 70%, oklch(from oklch(58% 0.12 245) calc(l - .08) c h) 100%);right:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(-8deg);transform-origin:50% 0%;"></div>
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(70% 0.16 340);transform:rotate(60deg) translateY(-38vmin);"></div>
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(66% 0.12 205);transform:rotate(118deg) translateY(-38vmin);"></div>
<div style="position:absolute;left:0;top:9vmin;width:78vmin;height:78vmin;border-radius:50%;box-sizing:border-box;border:.6vmin solid oklch(28% 0.04 220);background:radial-gradient(circle closest-side, transparent 88.6%, oklch(28% 0.04 220) 88.6%, oklch(28% 0.04 220) 89.6%, transparent 89.6%), conic-gradient(from 210deg, oklch(94% 0.008 210), oklch(81% 0.015 210), oklch(96% 0.006 210), oklch(79% 0.015 210), oklch(94% 0.008 210));">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-36.8vmin) rotate(0deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(7.5deg) translateY(-36.8vmin) rotate(-7.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(15deg) translateY(-36.8vmin) rotate(-15deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(22.5deg) translateY(-36.8vmin) rotate(-22.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-36.8vmin) rotate(-30deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(37.5deg) translateY(-36.8vmin) rotate(-37.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(45deg) translateY(-36.8vmin) rotate(-45deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(52.5deg) translateY(-36.8vmin) rotate(-52.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-36.8vmin) rotate(-60deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(67.5deg) translateY(-36.8vmin) rotate(-67.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(75deg) translateY(-36.8vmin) rotate(-75deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(82.5deg) translateY(-36.8vmin) rotate(-82.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-36.8vmin) rotate(-90deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(97.5deg) translateY(-36.8vmin) rotate(-97.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(105deg) translateY(-36.8vmin) rotate(-105deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(112.5deg) translateY(-36.8vmin) rotate(-112.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-36.8vmin) rotate(-120deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(127.5deg) translateY(-36.8vmin) rotate(-127.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(135deg) translateY(-36.8vmin) rotate(-135deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(142.5deg) translateY(-36.8vmin) rotate(-142.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-36.8vmin) rotate(-150deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(157.5deg) translateY(-36.8vmin) rotate(-157.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(165deg) translateY(-36.8vmin) rotate(-165deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(172.5deg) translateY(-36.8vmin) rotate(-172.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-36.8vmin) rotate(-180deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(187.5deg) translateY(-36.8vmin) rotate(-187.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(195deg) translateY(-36.8vmin) rotate(-195deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(202.5deg) translateY(-36.8vmin) rotate(-202.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-36.8vmin) rotate(-210deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(217.5deg) translateY(-36.8vmin) rotate(-217.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(225deg) translateY(-36.8vmin) rotate(-225deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(232.5deg) translateY(-36.8vmin) rotate(-232.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-36.8vmin) rotate(-240deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(247.5deg) translateY(-36.8vmin) rotate(-247.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(255deg) translateY(-36.8vmin) rotate(-255deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(262.5deg) translateY(-36.8vmin) rotate(-262.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-36.8vmin) rotate(-270deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(277.5deg) translateY(-36.8vmin) rotate(-277.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(285deg) translateY(-36.8vmin) rotate(-285deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(292.5deg) translateY(-36.8vmin) rotate(-292.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-36.8vmin) rotate(-300deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(307.5deg) translateY(-36.8vmin) rotate(-307.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(315deg) translateY(-36.8vmin) rotate(-315deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(322.5deg) translateY(-36.8vmin) rotate(-322.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-36.8vmin) rotate(-330deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(337.5deg) translateY(-36.8vmin) rotate(-337.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(345deg) translateY(-36.8vmin) rotate(-345deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(352.5deg) translateY(-36.8vmin) rotate(-352.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;width:68.4vmin;height:68.4vmin;margin:-34.2vmin 0 0 -34.2vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:inset 0 .6vmin 1.4vmin oklch(30% 0.03 220 / .18);"></div>
<!-- hour sections -->
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(91% 0.06 205) 0deg 30deg, oklch(91% 0.06 175) 30deg 60deg, oklch(91% 0.06 245) 60deg 90deg, oklch(91% 0.07 345) 90deg 120deg, oklch(91% 0.06 205) 120deg 150deg, oklch(91% 0.06 175) 150deg 180deg, oklch(91% 0.06 245) 180deg 210deg, oklch(91% 0.07 345) 210deg 240deg, oklch(91% 0.06 205) 240deg 270deg, oklch(91% 0.06 175) 270deg 300deg, oklch(91% 0.06 245) 300deg 330deg, oklch(91% 0.07 345) 330deg 360deg);"></div>
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:repeating-conic-gradient(from 14.6deg, oklch(99% 0.005 210) 0deg .8deg, transparent .8deg 30deg);"></div>
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;box-sizing:border-box;border:.4vmin solid oklch(28% 0.04 220);"></div>
<div style="position:absolute;left:50%;top:50%;width:20vmin;height:20vmin;margin:-10vmin 0 0 -10vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:0 0 0 .4vmin oklch(28% 0.04 220 / .25);"></div>
<!-- minute track -->
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(80% 0.10 205) 0deg 30deg, oklch(80% 0.10 175) 30deg 60deg, oklch(80% 0.10 245) 60deg 90deg, oklch(80% 0.13 345) 90deg 120deg, oklch(80% 0.10 205) 120deg 150deg, oklch(80% 0.10 175) 150deg 180deg, oklch(80% 0.10 245) 180deg 210deg, oklch(80% 0.13 345) 210deg 240deg, oklch(80% 0.10 205) 240deg 270deg, oklch(80% 0.10 175) 270deg 300deg, oklch(80% 0.10 245) 300deg 330deg, oklch(80% 0.13 345) 330deg 360deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);"></div>
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -.5deg, oklch(28% 0.04 220) 0deg 1deg, transparent 1deg 6deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);opacity:.7;"></div>
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -1.3deg, oklch(28% 0.04 220) 0deg 2.6deg, transparent 2.6deg 30deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);"></div>
<sc-if value="{{ showMinutes }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:2.3vmin;line-height:1;color:oklch(28% 0.04 220);">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-29.5vmin) rotate(0deg);">60</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-29.5vmin) rotate(-30deg);">5</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-29.5vmin) rotate(-60deg);">10</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-29.5vmin) rotate(-90deg);">15</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-29.5vmin) rotate(-120deg);">20</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-29.5vmin) rotate(-150deg);">25</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-29.5vmin) rotate(-180deg);">30</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-29.5vmin) rotate(-210deg);">35</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-29.5vmin) rotate(-240deg);">40</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-29.5vmin) rotate(-270deg);">45</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-29.5vmin) rotate(-300deg);">50</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-29.5vmin) rotate(-330deg);">55</div>
</div>
</sc-if>
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:8.2vmin;line-height:1;letter-spacing:-.3vmin;-webkit-text-stroke:.45vmin oklch(28% 0.04 220);paint-order:stroke fill;">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-22.4vmin) rotate(-30deg);color:oklch(56% 0.13 175);">1</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-22.4vmin) rotate(-60deg);color:oklch(56% 0.13 245);">2</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-22.4vmin) rotate(-90deg);color:oklch(60% 0.19 345);">3</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-22.4vmin) rotate(-120deg);color:oklch(56% 0.13 205);">4</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-22.4vmin) rotate(-150deg);color:oklch(56% 0.13 175);">5</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-22.4vmin) rotate(-180deg);color:oklch(56% 0.13 245);">6</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-22.4vmin) rotate(-210deg);color:oklch(60% 0.19 345);">7</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-22.4vmin) rotate(-240deg);color:oklch(56% 0.13 205);">8</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-22.4vmin) rotate(-270deg);color:oklch(56% 0.13 175);">9</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-22.4vmin) rotate(-300deg);color:oklch(56% 0.13 245);">10</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-22.4vmin) rotate(-330deg);color:oklch(60% 0.19 345);">11</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(360deg) translateY(-22.4vmin) rotate(-360deg);color:oklch(56% 0.13 205);">12</div>
</div>
<div style="position:absolute;left:50%;bottom:50%;width:5.4vmin;height:15vmin;margin-left:-2.7vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ hourStyle }}">
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.06 345);clip-path:polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);"></div>
</div>
<div style="position:absolute;left:50%;bottom:50%;width:5vmin;height:24vmin;margin-left:-2.5vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ minuteStyle }}">
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.05 200);clip-path:polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);"></div>
</div>
<sc-if value="{{ showSeconds }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;left:50%;bottom:50%;width:.7vmin;height:27vmin;margin-left:-.35vmin;transform-origin:50% 100%;{{ secondStyle }}">
<div style="position:absolute;inset:0 0 -5vmin 0;border-radius:1vmin;background:oklch(64% 0.19 345);"></div>
</div>
</sc-if>
<div style="position:absolute;left:50%;top:50%;width:4.2vmin;height:4.2vmin;margin:-2.1vmin 0 0 -2.1vmin;border-radius:50%;background:oklch(80% 0.10 205);box-shadow:0 0 0 .5vmin oklch(28% 0.04 220);"></div>
<div style="position:absolute;left:50%;top:50%;width:1.4vmin;height:1.4vmin;margin:-.7vmin 0 0 -.7vmin;border-radius:50%;background:oklch(28% 0.04 220);"></div>
</div>
</div>
<!-- countdowns -->
<sc-if value="{{ hasCountdowns }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;flex:0 1 74vmin;min-width:0;display:flex;flex-direction:column;gap:3vmin;">
<sc-for list="{{ countdowns }}" as="cd" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:3.5vmin;padding:3vmin 4vmin 3vmin 3vmin;border-radius:4.5vmin;background:oklch(97% 0.01 210 / .68);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 2vmin 5vmin oklch(15% 0.05 210 / .35);">
<div style="width:24vmin;height:18vmin;flex:none;display:flex;align-items:center;justify-content:center;">
<img src="{{ cd.image }}" alt="{{ cd.label }}" style="max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / .3));">
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:.6vmin;">
<div style="font-size:15vmin;font-weight:900;line-height:.95;font-variant-numeric:tabular-nums;letter-spacing:-.3vmin;{{ cd.timeStyle }}">{{ cd.remaining }}</div>
<div style="height:2vmin;border-radius:999px;background:oklch(30% 0.04 210 / .15);overflow:hidden;margin-top:.8vmin;">
<div style="height:100%;border-radius:999px;{{ cd.barStyle }}"></div>
</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;showSeconds&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;showMinuteNumbers&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;demoCountdowns&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;}}">
// Countdown rules. Each shows from (target - lead) until target on the given weekdays (0 = So … 6 = Sa).
const COUNTDOWNS = [
{ id: "bus", label: "Bus", image: "bus.png", target: "07:25", lead: 45, days: [1, 2, 3, 4, 5] },
{ id: "bett", label: "Schlafenszeit", image: "dolphin-watch.png", target: "19:30", lead: 30, days: [0, 1, 2, 3, 4, 5, 6] },
];
class Component extends DCLogic {
state = { now: new Date() };
componentDidMount() { this._t = setInterval(() => this.setState({ now: new Date() }), 250); }
componentWillUnmount() { clearInterval(this._t); }
activeCountdowns(d, demo) {
const daySec = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
const out = [];
COUNTDOWNS.forEach((r, i) => {
const [th, tm] = r.target.split(":").map(Number);
const targetSec = th * 3600 + tm * 60;
const leadSec = r.lead * 60;
let rem = targetSec - daySec;
const active = r.days.includes(d.getDay()) && rem > 0 && rem <= leadSec;
if (!active) {
if (!demo) return;
rem = leadSec - ((daySec + i * 1100) % leadSec);
}
out.push({ r, rem, frac: rem / leadSec });
});
return out.sort((a, b) => a.rem - b.rem);
}
renderVals() {
const d = this.state.now;
const day = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
const rot = (deg) => `transform:rotate(${deg}deg);`;
const pad = (n) => String(n).padStart(2, "0");
const list = this.activeCountdowns(d, this.props.demoCountdowns ?? true).map(({ r, rem, frac }) => {
const mins = Math.ceil(rem / 60);
const soon = mins <= 10;
return {
id: r.id,
label: r.label,
image: r.image,
target: r.target.replace(/^0/, ""),
remaining: `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`,
timeStyle: `color:${soon ? "oklch(58% 0.19 345)" : "oklch(30% 0.05 210)"};`,
barStyle: `width:${(frac * 100).toFixed(2)}%;background:${soon ? "oklch(70% 0.16 340)" : "oklch(66% 0.12 205)"};transition:width .25s linear;`,
};
});
return {
hourStyle: rot(day / 120),
minuteStyle: rot(day / 10),
secondStyle: rot(day * 6),
showSeconds: this.props.showSeconds ?? true,
showMinutes: this.props.showMinuteNumbers ?? true,
countdowns: list,
hasCountdowns: list.length > 0,
};
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,920 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
<style>
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-10px) rotate(2deg); } }
@keyframes dolphinSwim {
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
}
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
a { color: oklch(70% 0.16 340); }
a:hover { color: oklch(55% 0.16 340); }
</style>
</helmet>
<div style="position:relative;width:100%;height:100vh;overflow:hidden;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9s linear infinite;animation-delay:0s;"></div>
<div style="position:absolute;left:15%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .4);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
<div style="position:absolute;left:28%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
<div style="position:absolute;left:42%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 10s linear infinite;animation-delay:4s;"></div>
<div style="position:absolute;left:58%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8s linear infinite;animation-delay:3s;"></div>
<div style="position:absolute;left:70%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(90% 0.02 210 / .35);animation:bubbleRise 13s linear infinite;animation-delay:5s;"></div>
<div style="position:absolute;left:82%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9.5s linear infinite;animation-delay:1.5s;"></div>
<div style="position:absolute;left:92%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.5s linear infinite;animation-delay:2.5s;"></div>
<div style="position:absolute;left:2%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 7.5s linear infinite;animation-delay:3.5s;"></div>
<div style="position:absolute;left:9%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 14s linear infinite;animation-delay:6s;"></div>
<div style="position:absolute;left:21%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8.5s linear infinite;animation-delay:5.5s;"></div>
<div style="position:absolute;left:34%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .32);animation:bubbleRise 12.5s linear infinite;animation-delay:1.2s;"></div>
<div style="position:absolute;left:38%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:4.6s;"></div>
<div style="position:absolute;left:48%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:0.6s;"></div>
<div style="position:absolute;left:53%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:7s;"></div>
<div style="position:absolute;left:63%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7.2s linear infinite;animation-delay:2.2s;"></div>
<div style="position:absolute;left:66%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .38);animation:bubbleRise 11.5s linear infinite;animation-delay:5.2s;"></div>
<div style="position:absolute;left:76%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(90% 0.02 210 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:6.4s;"></div>
<div style="position:absolute;left:87%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .28);animation:bubbleRise 15s linear infinite;animation-delay:3.2s;"></div>
<div style="position:absolute;left:96%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.8s linear infinite;animation-delay:8s;"></div>
<div style="position:absolute;left:45%;bottom:-40px;width:7px;height:7px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.2s linear infinite;animation-delay:7.6s;"></div>
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;z-index:1;height:100%;display:flex;flex-direction:column;">
<!-- header -->
<dc-import name="AppHeader" title="Musik Delphin" mascot="dolphin-mascot.png" link-href="./Mein%20Zimmer.dc.html" link-label="💡 Mein Zimmer" hint-size="100%,116px"></dc-import>
<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:4px 32px 6px;">
<button onClick="{{ setFilterAll }}" style="{{ filterAllStyle }}">Alles</button>
<button onClick="{{ setFilterMusic }}" style="{{ filterMusicStyle }}">🎵 Musik</button>
<button onClick="{{ setFilterBook }}" style="{{ filterBookStyle }}">📖 Hörbücher</button>
</div>
<!-- search pill -->
<sc-if value="{{ showSearchBar }}" hint-placeholder-val="{{ false }}">
<div style="margin:2px 32px 6px;display:flex;align-items:center;justify-content:center;gap:12px;">
<div style="background:oklch(97% 0.01 210);color:oklch(30% 0.04 210);font-weight:800;font-size:20px;padding:10px 20px;border-radius:999px;box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);display:flex;align-items:center;gap:10px;">
<span style="font-size:14px;font-weight:800;background:oklch(30% 0.04 210);color:#fff;padding:4px 10px;border-radius:999px;">{{ modeLabel }}</span>
<span>{{ searchDisplay }}</span>
</div>
<div style="color:oklch(90% 0.02 210 / .85);font-weight:700;font-size:14px;">{{ resultCountLabel }}</div>
</div>
</sc-if>
<!-- results -->
<div ref="{{ scrollerRef }}" style="flex:1;overflow:auto;min-height:0;padding:14px 32px 250px;">
<!-- song hits -->
<sc-if value="{{ hasSongHits }}" hint-placeholder-val="{{ false }}">
<div style="margin-bottom:30px;">
<div style="display:flex;align-items:baseline;justify-content:center;gap:10px;margin-bottom:12px;">
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);">Titel</div>
<div style="font-size:13px;font-weight:700;color:oklch(88% 0.02 210 / .7);">{{ songHitLabel }}</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px;max-width:760px;margin:0 auto;">
<sc-for list="{{ songHits }}" as="song" hint-placeholder-count="3">
<div onClick="{{ playSongHit }}" data-nav-index="{{ song.navIndex }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
<div style="{{ song.chipStyle }}"></div>
<div style="flex:1;min-width:0;">
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ song.title }}</div>
<div style="font-size:12px;font-weight:700;color:oklch(88% 0.02 210 / .65);">{{ song.albumLine }}</div>
</div>
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(88% 0.02 210 / .6);">{{ song.duration }}</div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<!-- category grid -->
<sc-if value="{{ hasCategories }}" hint-placeholder-val="{{ true }}">
<div>
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ categorySectionLabel }}</div>
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;">
<sc-for list="{{ categories }}" as="cat" hint-placeholder-count="8">
<div onClick="{{ openCategory }}" data-nav-index="{{ cat.navIndex }}" data-key="{{ cat.key }}" style="{{ cat.cardStyle }}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;aspect-ratio:1;">
<sc-for list="{{ cat.tiles }}" as="tile" hint-placeholder-count="4">
<div style="{{ tile.style }}"></div>
</sc-for>
</div>
<div style="padding:4px 12px 14px;">
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ cat.name }}</div>
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:5px;">{{ cat.countLabel }}</div>
</div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<!-- album grid -->
<sc-if value="{{ hasAlbumHits }}" hint-placeholder-val="{{ false }}">
<div>
<sc-if value="{{ hasCategoryCrumb }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:14px;">
<button onClick="{{ clearCategory }}" style="border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 16px;border-radius:999px;background:oklch(97% 0.01 210 / .95);color:oklch(30% 0.04 210);">← Alle</button>
<div style="font-size:22px;font-weight:900;color:oklch(97% 0.01 210);">{{ categoryCrumb }}</div>
</div>
</sc-if>
<sc-if value="{{ hasSearch }}" hint-placeholder-val="{{ false }}">
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ albumSectionLabel }}</div>
</sc-if>
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;justify-content:center;">
<sc-for list="{{ filteredAlbums }}" as="album" hint-placeholder-count="8">
<div onClick="{{ openAlbumCard }}" data-nav-index="{{ album.navIndex }}" data-id="{{ album.id }}" style="{{ album.cardStyle }}">
<div style="{{ album.coverStyle }}">
<span style="{{ album.coverLabelStyle }}">{{ album.coverLabel }}</span>
</div>
<div style="{{ album.bodyStyle }}">
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ album.title }}</div>
<div style="font-size:13px;font-weight:700;color:oklch(30% 0.03 210);margin-top:2px;">{{ album.artist }}</div>
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:6px;">{{ album.songCountLabel }}</div>
</div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{ hasNoResults }}" hint-placeholder-val="{{ false }}">
<div style="text-align:center;margin-top:60px;color:oklch(92% 0.02 210 / .8);">
<img src="dolphin-mascot.png" alt="" style="width:120px;height:120px;object-fit:contain;opacity:.9;">
<div style="font-size:22px;font-weight:800;margin-top:10px;">Nichts gefunden — probier andere Buchstaben!</div>
</div>
</sc-if>
</div>
</div>
</sc-if>
<!-- play view -->
<sc-if value="{{ isPlay }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;z-index:1;height:100%;min-height:0;overflow:auto;">
<div style="min-height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px, 1.8vh, 26px);padding:clamp(14px, 3.5vh, 40px) 40px;">
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="position:absolute;top:14%;left:0;width:140px;height:140px;object-fit:contain;z-index:0;pointer-events:none;opacity:.92;animation:dolphinSwim 62s linear infinite;filter:drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45));">
<div style="{{ bigCoverStyle }}">
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;color:oklch(98% 0 0 / .85);letter-spacing:1px;">ALBUM COVER</span>
</div>
<div style="text-align:center;max-width:760px;position:relative;z-index:1;flex:none;">
<div style="font-size:clamp(28px, 4.6vh, 46px);font-weight:900;color:oklch(97% 0.01 210);line-height:1.1;text-wrap:pretty;">{{ currentSongTitle }}</div>
<div style="font-size:20px;font-weight:700;color:oklch(88% 0.02 210 / .8);margin-top:8px;">{{ currentAlbumLine }}</div>
<div style="font-size:15px;font-weight:800;color:oklch(78% 0.14 340);margin-top:6px;">{{ trackCounter }}</div>
</div>
<div style="width:min(680px, 90%);position:relative;z-index:1;">
<div style="height:14px;border-radius:999px;background:oklch(30% 0.03 210 / .55);overflow:hidden;">
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px;font-size:14px;font-weight:800;color:oklch(88% 0.02 210 / .8);">
<span style="font-family:ui-monospace,Menlo,monospace;">{{ trackRemainingLabel }}</span>
<span>{{ albumRemainingLabel }}</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:22px;position:relative;z-index:1;flex:none;">
<button onClick="{{ prevTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span><span style="width:22px;height:26px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
<button onClick="{{ togglePlay }}" style="width:clamp(68px, 12vh, 110px);height:clamp(68px, 12vh, 110px);border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 30px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:9px;"><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:clamp(24px, 4.2vh, 38px);height:clamp(26px, 4.6vh, 42px);background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
<button onClick="{{ nextTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:22px;height:26px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span></span></button>
</div>
<div style="display:flex;align-items:center;gap:12px;position:relative;z-index:1;">
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:24px;line-height:1;">🔊</button>
<div style="display:flex;gap:5px;">
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:18px;height:clamp(26px, 4.6vh, 44px);border-radius:5px;{{ bar.style }}"></button>
</sc-for>
</div>
</div>
<button onClick="{{ goBrowse }}" style="position:absolute;top:28px;left:32px;border:none;cursor:pointer;background:oklch(97% 0.01 210 / .95);border-radius:999px;padding:11px 20px;font-family:inherit;font-size:14px;font-weight:800;color:oklch(30% 0.04 210);display:flex;align-items:center;gap:8px;box-shadow:0 8px 24px oklch(15% 0.05 210 / .4);">
<span style="font:800 13px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:2px 8px;border-radius:6px;">/</span>
Zurück zur Suche
</button>
</div>
</div>
</sc-if>
<!-- album detail panel -->
<sc-if value="{{ hasOpenAlbum }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeAlbum }}" style="position:absolute;inset:0;z-index:4;background:oklch(15% 0.03 210 / .6);display:flex;align-items:center;justify-content:center;padding:40px;">
<div onClick="{{ stopClick }}" style="background:oklch(96% 0.012 210);border-radius:26px;padding:26px;width:640px;max-width:100%;max-height:100%;overflow:auto;box-shadow:0 24px 60px oklch(10% 0.04 210 / .55);">
<div style="display:flex;gap:20px;align-items:flex-start;">
<div style="{{ openAlbumCoverStyle }}">
<span style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);">ALBUM COVER</span>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:26px;font-weight:900;color:oklch(22% 0.03 210);line-height:1.15;">{{ openAlbumTitle }}</div>
<div style="font-size:15px;font-weight:700;color:oklch(45% 0.03 210 / .85);margin-top:2px;">{{ openAlbumArtist }}</div>
<div style="font-size:13px;font-weight:700;color:oklch(50% 0.03 210 / .7);margin-top:2px;">{{ openAlbumMeta }}</div>
<button onClick="{{ playOpenAlbum }}" style="margin-top:14px;border:none;cursor:pointer;background:oklch(70% 0.16 340);color:#fff;font-family:inherit;font-size:16px;font-weight:800;padding:12px 22px;border-radius:999px;display:flex;align-items:center;gap:10px;box-shadow:0 4px 14px oklch(70% 0.16 340 / .45);">
<span style="width:14px;height:16px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span>
Alle Songs abspielen
</button>
</div>
<button onClick="{{ closeAlbum }}" style="border:none;cursor:pointer;background:oklch(88% 0.02 210);color:oklch(30% 0.04 210);font-family:inherit;font-size:18px;font-weight:900;width:40px;height:40px;border-radius:999px;flex:none;">×</button>
</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:22px;">
<sc-for list="{{ openAlbumSongs }}" as="song" hint-placeholder-count="5">
<div onClick="{{ playSongHit }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
<div style="{{ song.numStyle }}">{{ song.number }}</div>
<div style="flex:1;min-width:0;font-size:16px;font-weight:800;{{ song.titleStyle }}">{{ song.title }}</div>
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(45% 0.03 210 / .7);">{{ song.duration }}</div>
</div>
</sc-for>
</div>
</div>
</div>
</sc-if>
<!-- shortcut help toggle + overlay -->
<button onClick="{{ toggleHelp }}" title="Zaubertasten (F1)" style="position:absolute;top:18px;right:24px;z-index:3;width:44px;height:44px;background:oklch(97% 0.01 210 / .95);border:none;border-radius:999px;box-shadow:0 6px 18px oklch(15% 0.05 210 / .4);font-size:20px;font-weight:900;color:oklch(30% 0.04 210);cursor:pointer;display:flex;align-items:center;justify-content:center;">?</button>
<sc-if value="{{ showHelp }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ toggleHelp }}" style="position:absolute;inset:0;background:oklch(15% 0.03 210 / .55);z-index:5;display:flex;align-items:center;justify-content:center;">
<div style="background:oklch(97% 0.01 210);border-radius:24px;padding:26px 30px;width:300px;box-shadow:0 20px 50px oklch(10% 0.04 210 / .5);">
<div style="font-size:17px;font-weight:800;color:oklch(30% 0.04 210);margin-bottom:14px;letter-spacing:.3px;">⌨️ Zaubertasten</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">LEER</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Play / Pause</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="display:flex;gap:4px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+H</div>
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+L</div>
</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Song zurück / vor</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="display:flex;gap:4px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+J</div>
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+K</div>
</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Leiser / lauter</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">A-Z</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Album oder Hörbuch suchen</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">?</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Einzelne Titel suchen</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">F1</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Diese Hilfe</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">TAB</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Musik / Hörbücher / alles</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">← ↑ ↓ →</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl bewegen</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ENTER</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl abspielen</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ESC</div>
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Schließen / Suche löschen</div>
</div>
</div>
<div style="margin-top:16px;font-size:12px;font-weight:600;color:oklch(50% 0.03 210 / .8);text-align:center;">Tippe irgendwohin, um zu schließen</div>
</div>
</div>
</sc-if>
<!-- player bar -->
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;left:0;right:0;bottom:0;z-index:2;background:oklch(18% 0.05 210 / .96);backdrop-filter:blur(6px);padding:16px clamp(12px, 3vw, 32px);box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:clamp(10px, 1.6vw, 22px);border-top:3px solid oklch(70% 0.16 340 / .5);">
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="width:clamp(56px, 7vw, 84px);height:clamp(56px, 7vw, 84px);object-fit:contain;flex:0 1 auto;min-width:0;animation:{{ dolphinBobAnim }};filter:drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5));">
<div style="width:52px;height:52px;border-radius:12px;flex:none;{{ playerCoverStyle }}"></div>
<div style="flex:0 1 220px;min-width:0;overflow:hidden;">
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ currentSongTitle }}</div>
<div style="font-size:13px;font-weight:600;color:oklch(85% 0.02 210 / .75);">{{ currentAlbumLine }}</div>
<div style="font-size:12px;font-weight:800;color:oklch(75% 0.14 340);margin-top:1px;">{{ trackCounter }}</div>
</div>
<div style="flex:1 1 auto;max-width:360px;min-width:90px;">
<div style="height:10px;border-radius:999px;background:oklch(30% 0.03 210 / .6);overflow:hidden;">
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<button onClick="{{ prevTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span><span style="width:14px;height:16px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
<button onClick="{{ togglePlay }}" style="width:60px;height:60px;border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:5px;"><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:20px;height:22px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
<button onClick="{{ nextTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:14px;height:16px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span></span></button>
</div>
<div style="display:flex;align-items:center;gap:8px;flex:0 0 auto;">
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:18px;line-height:1;">🔊</button>
<div style="display:flex;gap:3px;">
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:11px;height:30px;border-radius:4px;{{ bar.style }}"></button>
</sc-for>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1280, &quot;height&quot;: 800}}">
const ALBUMS = [
{ id: 1, title: "Ocean Adventure", artist: "The Coral Crew", hue: 180, songs: [["Set Sail", "2:41"], ["Wave Rider", "3:05"], ["Lighthouse", "2:28"], ["Salty Wind", "3:22"], ["Safe Harbour", "2:55"]] },
{ id: 2, title: "Splash Party", artist: "Wavy Tunes", hue: 200, songs: [["Cannonball", "2:12"], ["Pool Noodle Boogie", "2:47"], ["Big Splash", "3:01"], ["Towel Dance", "2:33"]] },
{ id: 3, title: "Deep Blue Dreams", artist: "Finn & Friends", hue: 220, songs: [["Sinking Slow", "3:48"], ["Blue Lullaby", "4:12"], ["Silent Depths", "3:30"], ["Bioluminescence", "4:02"], ["Morning Surface", "3:14"]] },
{ id: 4, title: "Bubble Trouble", artist: "The Coral Crew", hue: 250, songs: [["Bubble Machine", "2:20"], ["Pop Pop Pop", "1:58"], ["Fizzy Water", "2:44"], ["Upside Down", "3:06"]] },
{ id: 5, title: "Starfish Disco", artist: "Reef Riders", hue: 20, songs: [["Five Arms Up", "3:18"], ["Glitter Sand", "2:52"], ["Disco Tide", "3:40"], ["Neon Anemone", "3:02"], ["Last Dance", "4:05"]] },
{ id: 6, title: "Whale Song", artist: "Finn & Friends", hue: 240, songs: [["Long Call", "5:12"], ["Migration", "4:36"], ["Under Ice", "3:58"], ["Deep Echo", "4:20"]] },
{ id: 7, title: "Tidal Groove", artist: "Wavy Tunes", hue: 195, songs: [["High Tide", "3:11"], ["Low Tide", "2:49"], ["Moon Pull", "3:35"], ["Rock Pool", "2:58"], ["Undertow", "3:24"]] },
{ id: 8, title: "Coral Reef Jam", artist: "Reef Riders", hue: 15, songs: [["Clownfish Shuffle", "2:36"], ["Parrotfish Parade", "3:08"], ["Coral Bloom", "3:44"], ["Reef Party", "2:50"]] },
{ id: 9, title: "Sandy Shores", artist: "Beach Buddies", hue: 40, songs: [["Barefoot", "2:26"], ["Sandcastle", "3:02"], ["Seagull Song", "2:14"], ["Sunset Picnic", "3:30"], ["Beach Fire", "3:52"]] },
{ id: 10, title: "Moonlit Lagoon", artist: "Finn & Friends", hue: 260, songs: [["Silver Water", "4:08"], ["Night Swim", "3:36"], ["Sleepy Palms", "3:12"], ["Star Reflection", "4:24"]] },
{ id: 11, title: "Jellyfish Jive", artist: "The Coral Crew", hue: 300, songs: [["Wobble Wobble", "2:18"], ["Glow Parade", "2:56"], ["Tentacle Twist", "3:14"], ["Drifting", "3:40"], ["Sting Free", "2:44"]] },
{ id: 12, title: "Seahorse Shuffle", artist: "Beach Buddies", hue: 30, songs: [["Tiny Trot", "2:22"], ["Seagrass Swing", "2:58"], ["Curly Tail", "3:16"], ["Slow Parade", "3:04"]] },
{ id: 13, title: "Current Rhythm", artist: "Wavy Tunes", hue: 205, songs: [["Fast Current", "3:26"], ["Drift Beat", "3:02"], ["Whirlpool", "3:48"], ["Cross Stream", "2:52"], ["Open Water", "4:10"]] },
{ id: 14, title: "Anchor Down", artist: "Reef Riders", hue: 10, songs: [["Chain Rattle", "2:40"], ["Harbour Lights", "3:20"], ["Old Rope", "3:04"], ["Anchor Down", "3:52"]] },
{ id: 15, type: "book", series: "Wal Wilma", title: "Der kleine Wal Wilma", artist: "Erzählt von Lena Fisch", hue: 230, songs: [["Kapitel 1 — Wilma wacht auf", "8:12"], ["Kapitel 2 — Die große Welle", "9:04"], ["Kapitel 3 — Ein Freund im Dunkeln", "7:48"], ["Kapitel 4 — Heimweg", "8:36"]] },
{ id: 16, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe und der Schatz", artist: "Erzählt von Jonas Meer", hue: 25, songs: [["Kapitel 1 — Die alte Karte", "10:20"], ["Kapitel 2 — Sturm vor Kap Kork", "11:02"], ["Kapitel 3 — Die Höhle", "9:44"], ["Kapitel 4 — Gold und Muscheln", "10:12"], ["Kapitel 5 — Zurück im Hafen", "8:58"]] },
{ id: 17, type: "book", series: "Nele", title: "Nele taucht ab", artist: "Erzählt von Mira Sand", hue: 190, songs: [["Kapitel 1 — Die neue Taucherbrille", "7:30"], ["Kapitel 2 — Unten am Riff", "8:14"], ["Kapitel 3 — Das leise Lied", "9:06"], ["Kapitel 4 — Wieder oben", "6:52"]] },
{ id: 18, type: "book", series: "Muschelbande", title: "Die Muschelbande", artist: "Erzählt von Tom Anker", hue: 300, songs: [["Kapitel 1 — Drei Freunde", "8:48"], ["Kapitel 2 — Der Plan", "9:22"], ["Kapitel 3 — Verfolgt", "10:04"], ["Kapitel 4 — Das Versteck", "8:16"], ["Kapitel 5 — Alles gut", "7:40"]] },
{ id: 19, type: "book", series: "Riff-Geschichten", title: "Gute-Nacht-Geschichten vom Riff", artist: "Erzählt von Lena Fisch", hue: 265, songs: [["Der schläfrige Seestern", "6:40"], ["Wo die Quallen leuchten", "7:12"], ["Das müde Seepferdchen", "6:28"], ["Lied der Strömung", "8:02"]] },
{ id: 20, type: "book", series: "Wal Wilma", title: "Wal Wilma im Eismeer", artist: "Erzählt von Lena Fisch", hue: 245, songs: [["Kapitel 1 — Kaltes Wasser", "9:10"], ["Kapitel 2 — Das Eisloch", "8:26"], ["Kapitel 3 — Der Pinguin", "9:44"], ["Kapitel 4 — Nach Hause", "8:02"]] },
{ id: 21, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe segelt los", artist: "Erzählt von Jonas Meer", hue: 35, songs: [["Kapitel 1 — Neue Segel", "9:52"], ["Kapitel 2 — Der blinde Passagier", "10:36"], ["Kapitel 3 — Nebelbank", "9:18"], ["Kapitel 4 — Land in Sicht", "8:44"]] },
{ id: 22, type: "book", series: "Nele", title: "Nele und der Seestern", artist: "Erzählt von Mira Sand", hue: 200, songs: [["Kapitel 1 — Fünf Arme", "7:22"], ["Kapitel 2 — Der Gezeitenteich", "8:06"], ["Kapitel 3 — Zurück ins Meer", "7:54"]] },
];
const TRACK_DURATION_MS = 24000;
class Component extends DCLogic {
state = {
search: "",
mode: "albums",
category: null,
filter: "all",
albumId: null,
trackIndex: 0,
openAlbumId: null,
view: "browse",
selIndex: 0,
cols: 4,
isPlaying: false,
volume: 70,
progress: 0,
showHelp: false,
};
playPop(freq) {
try {
if (!this._ac) this._ac = new (window.AudioContext || window.webkitAudioContext)();
const ac = this._ac;
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, ac.currentTime);
osc.frequency.exponentialRampToValueAtTime(freq * 1.8, ac.currentTime + 0.08);
gain.gain.setValueAtTime(0.15, ac.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.15);
osc.connect(gain).connect(ac.destination);
osc.start();
osc.stop(ac.currentTime + 0.16);
} catch (e) {}
}
componentDidMount() {
this._onKey = (e) => this.handleKey(e);
window.addEventListener("keydown", this._onKey);
this._onResize = () => this.measureCols();
window.addEventListener("resize", this._onResize);
this.measureCols();
}
componentWillUnmount() {
window.removeEventListener("keydown", this._onKey);
window.removeEventListener("resize", this._onResize);
if (this._timer) clearInterval(this._timer);
}
navCounts() {
return { songs: this.songMatches().length, albums: this.albumMatches().length + this.categoryMatches().length };
}
moveSel(dx, dy) {
const { songs, albums } = this.navCounts();
const total = songs + albums;
if (!total) return;
const cols = Math.max(1, this.state.cols);
this.setState((s) => {
let i = Math.min(s.selIndex, total - 1);
if (dx) i += dx;
if (dy) {
if (i < songs) i += dy;
else i += dy * cols;
}
if (i < 0) i = 0;
if (i > total - 1) i = total - 1;
return { selIndex: i };
});
}
playSelection() {
const songs = this.songMatches();
const albums = this.albumMatches();
const cats = this.categoryMatches();
const i = Math.min(this.state.selIndex, songs.length + albums.length + cats.length - 1);
if (i < 0) return;
if (i < songs.length) return this.playSong(songs[i].album.id, songs[i].index);
if (cats.length) {
const c = cats[i - songs.length];
if (c) this.setState({ category: c.key, selIndex: 0 });
return;
}
const a = albums[i - songs.length];
if (a) this.playSong(a.id, 0);
}
componentDidUpdate() {
const sc = this._scroller;
if (!sc) return;
if (this._lastSel === this.state.selIndex) return;
this._lastSel = this.state.selIndex;
const el = sc.querySelector('[data-nav-index="' + this.state.selIndex + '"]');
if (!el) return;
const top = el.offsetTop - sc.offsetTop;
const bottom = top + el.offsetHeight;
const pad = 24;
if (top - pad < sc.scrollTop) sc.scrollTop = Math.max(0, top - pad);
else if (bottom + pad > sc.scrollTop + sc.clientHeight) sc.scrollTop = bottom + pad - sc.clientHeight;
}
measureCols() {
const g = this._grid;
if (!g) return;
const n = getComputedStyle(g).gridTemplateColumns.split(" ").filter(Boolean).length;
if (n && n !== this.state.cols) this.setState({ cols: n });
}
normalize(s) {
return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
setFilter(f) {
this.playPop(380);
this.setState({ filter: f, selIndex: 0, view: "browse", category: null });
}
cycleFilter() {
const order = ["all", "music", "book"];
this.setFilter(order[(order.indexOf(this.state.filter) + 1) % order.length]);
}
secs(d) {
const p = (d || "0:00").split(":");
return Number(p[0]) * 60 + Number(p[1]);
}
clock(total) {
const t = Math.max(0, Math.round(total));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
const pad = (n) => String(n).padStart(2, "0");
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
remainingSecs(album) {
if (!album) return 0;
const i = this.state.trackIndex;
const cur = this.secs(album.songs[i] ? album.songs[i][1] : "0:00");
let rest = cur * (1 - this.state.progress / 100);
for (let k = i + 1; k < album.songs.length; k++) rest += this.secs(album.songs[k][1]);
return rest;
}
isBook(a) {
return a.type === "book";
}
categoryOf(a) {
return this.isBook(a) ? a.series || a.artist : a.artist;
}
listMode() {
if (this.state.mode === "tracks") return "tracks";
if (this.state.search || this.state.category) return "albums";
return "categories";
}
categoryMatches() {
if (this.listMode() !== "categories") return [];
const map = new Map();
this.pool().forEach((a) => {
const key = this.categoryOf(a);
if (!map.has(key)) map.set(key, { key, name: key, albums: [] });
map.get(key).albums.push(a);
});
return Array.from(map.values());
}
navTotal() {
return this.songMatches().length + this.albumMatches().length + this.categoryMatches().length;
}
inFilter(a) {
const f = this.state.filter;
if (f === "book") return this.isBook(a);
if (f === "music") return !this.isBook(a);
return true;
}
pool() {
return ALBUMS.filter((a) => this.inFilter(a));
}
albumMatches() {
if (this.state.mode === "tracks") return [];
const q = this.normalize(this.state.search);
let pool = this.pool();
if (!q && !this.state.category) return [];
if (this.state.category) pool = pool.filter((a) => this.categoryOf(a) === this.state.category);
if (!q) return pool;
return pool.filter((a) => this.normalize(a.title + a.artist).includes(q));
}
songMatches() {
if (this.state.mode !== "tracks") return [];
const q = this.normalize(this.state.search);
const hits = [];
this.pool().forEach((a) => {
a.songs.forEach((s, i) => {
if (!q || this.normalize(s[0]).includes(q)) hits.push({ album: a, index: i, title: s[0], duration: s[1] });
});
});
return hits.slice(0, 40);
}
playSong(albumId, index) {
this.playPop(300);
this.setState({ albumId, trackIndex: index, isPlaying: true, progress: 0, view: "play", openAlbumId: null });
this.startTimer();
}
startTimer() {
if (this._timer) clearInterval(this._timer);
this._timer = setInterval(() => {
this.setState((s) => {
if (!s.isPlaying) return {};
const next = s.progress + 100 / (TRACK_DURATION_MS / 200);
if (next >= 100) {
this.advance(1, true);
return {};
}
return { progress: next };
});
}, 200);
}
advance(dir, silent) {
if (!silent) this.playPop(260);
this.setState((s) => {
const album = ALBUMS.find((a) => a.id === s.albumId);
if (!album) {
const first = this.albumMatches()[0] || ALBUMS[0];
return { albumId: first.id, trackIndex: 0, progress: 0, isPlaying: true };
}
const next = s.trackIndex + dir;
if (next >= 0 && next < album.songs.length) {
return { trackIndex: next, progress: 0, isPlaying: true };
}
// step to the neighbouring album and continue there
const order = ALBUMS;
const ai = order.findIndex((a) => a.id === album.id);
const nextAlbum = order[(ai + (dir > 0 ? 1 : -1) + order.length) % order.length];
return {
albumId: nextAlbum.id,
trackIndex: dir > 0 ? 0 : nextAlbum.songs.length - 1,
progress: 0,
isPlaying: true,
};
});
}
handleKey(e) {
const key = e.key;
if (e.ctrlKey || e.metaKey) {
const k = key.toLowerCase();
if (k === "l") { e.preventDefault(); this.advance(1); return; }
if (k === "h") { e.preventDefault(); this.advance(-1); return; }
if (k === "k") { e.preventDefault(); this.setState((s) => ({ volume: Math.min(100, s.volume + 10) })); return; }
if (k === "j") { e.preventDefault(); this.setState((s) => ({ volume: Math.max(0, s.volume - 10) })); return; }
return;
}
if (key === "Tab") {
e.preventDefault();
this.cycleFilter();
} else if (key === "/") {
e.preventDefault();
this.setState({ view: "browse" });
} else if (key === " ") {
e.preventDefault();
this.togglePlayInternal();
} else if (key === "ArrowRight" || key === "ArrowLeft" || key === "ArrowUp" || key === "ArrowDown") {
e.preventDefault();
const browsing = this.state.view === "browse" && this.state.openAlbumId === null && !this.state.showHelp;
if (browsing) {
if (key === "ArrowRight") this.moveSel(1, 0);
else if (key === "ArrowLeft") this.moveSel(-1, 0);
else if (key === "ArrowDown") this.moveSel(0, 1);
else this.moveSel(0, -1);
} else if (key === "ArrowRight") this.advance(1);
else if (key === "ArrowLeft") this.advance(-1);
else if (key === "ArrowUp") this.setState((s) => ({ volume: Math.min(100, s.volume + 10) }));
else this.setState((s) => ({ volume: Math.max(0, s.volume - 10) }));
} else if (key === "?") {
e.preventDefault();
this.playPop(460);
this.setState({ mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false });
} else if (key === "F1") {
e.preventDefault();
this.setState((s) => ({ showHelp: !s.showHelp }));
} else if (key === "Escape") {
this.setState((s) => (s.showHelp || s.openAlbumId !== null
? { showHelp: false, openAlbumId: null }
: s.search
? { search: "", selIndex: 0 }
: s.mode === "tracks"
? { mode: "albums", selIndex: 0 }
: { category: null, selIndex: 0 }));
} else if (key === "Backspace") {
this.setState((s) => ({ search: s.search.slice(0, -1), selIndex: 0 }));
} else if (key === "Enter") {
if (this.state.openAlbumId !== null) {
this.playSong(this.state.openAlbumId, 0);
} else {
this.playSelection();
}
} else if (/^[a-zA-Z0-9]$/.test(key)) {
this.setState((s) => ({ search: s.search + key, view: "browse", selIndex: 0 }));
}
}
togglePlayInternal() {
this.playPop(340);
this.setState((s) => {
if (s.albumId === null) {
const first = this.albumMatches()[0];
if (!first) return {};
this.startTimer();
return { albumId: first.id, trackIndex: 0, isPlaying: true, progress: 0 };
}
return { isPlaying: !s.isPlaying };
});
}
coverBg(a, w) {
if (this.isBook(a)) {
const h = a.hue;
return `background:
linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%, oklch(90% 0.03 88) 97.5% 100%),
linear-gradient(90deg, transparent 0 3.5%, oklch(92% 0.04 ${h} / .45) 3.5% 4.3%, transparent 4.3% 7%, oklch(92% 0.04 ${h} / .45) 7% 7.8%, transparent 7.8%),
linear-gradient(90deg, oklch(30% 0.06 ${h}) 0 11%, oklch(24% 0.05 ${h}) 11% 13%, transparent 13%),
linear-gradient(155deg, oklch(64% 0.10 ${h}) 0%, oklch(48% 0.09 ${h}) 100%);`;
}
return this.stripes(a.hue, w);
}
filterPill(value, active) {
return `border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 15px;border-radius:999px;white-space:nowrap;${
active
? "background:oklch(97% 0.01 210);color:oklch(28% 0.04 210);box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);"
: "background:oklch(97% 0.01 210 / .16);color:oklch(97% 0.01 210);"
}`;
}
stripes(hue, w) {
return `background:repeating-linear-gradient(135deg, oklch(62% 0.12 ${hue}) 0px, oklch(62% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w * 2}px);`;
}
renderVals() {
const albums = this.albumMatches();
const songHits = this.songMatches();
const album = ALBUMS.find((a) => a.id === this.state.albumId) || null;
const song = album ? album.songs[this.state.trackIndex] : null;
const openAlbum = ALBUMS.find((a) => a.id === this.state.openAlbumId) || null;
const cats = this.categoryMatches();
const total = songHits.length + albums.length + cats.length;
const sel = total ? Math.min(this.state.selIndex, total - 1) : -1;
const categoryCards = cats.map((c, ci) => {
const isSel = sel === songHits.length + ci;
const books = c.albums.filter((a) => this.isBook(a)).length;
const allBooks = books === c.albums.length;
const tiles = c.albums.slice(0, 4).map((a) => ({
style: `border-radius:6px;${this.coverBg(a, 7)}`,
}));
return {
key: c.key,
navIndex: songHits.length + ci,
name: c.name,
typeLabel: allBooks ? "📖 Hörbücher" : books ? "🎵📖 Gemischt" : "🎵 Musik",
countLabel: `${c.albums.length} ${allBooks ? (c.albums.length === 1 ? "Hörbuch" : "Hörbücher") : c.albums.length === 1 ? "Album" : "Alben"}`,
tiles,
cardStyle: `background:${allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${allBooks ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:0 6px 18px oklch(15% 0.05 210 / .35);${
isSel ? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);" : ""
}`,
};
});
const albumCards = albums.map((a, ai) => {
const isSel = sel === songHits.length + cats.length + ai;
const ring = isSel
? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);"
: a.id === this.state.albumId
? "outline:4px solid oklch(70% 0.16 340);"
: "";
const book = this.isBook(a);
return {
id: a.id,
navIndex: songHits.length + cats.length + ai,
title: a.title,
artist: a.artist,
typeLabel: book ? "📖 Hörbuch" : "🎵 Musik",
coverLabel: book ? "HÖRBUCH COVER" : "ALBUM COVER",
coverLabelStyle: book
? "font-family:ui-monospace,Menlo,monospace;font-size:10px;color:oklch(98% 0 0 / .9);letter-spacing:.5px;text-align:center;margin-left:11%;padding:14px 10px;border:2px solid oklch(98% 0 0 / .55);border-radius:4px;"
: "font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);letter-spacing:.5px;",
songCountLabel: book ? `${a.songs.length} Kapitel` : `${a.songs.length} Songs`,
bodyStyle: `padding:10px 12px 14px;${book ? "padding-right:22px;" : ""}`,
badgeStyle: `position:absolute;top:8px;left:8px;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:${book ? "oklch(94% 0.09 88)" : "oklch(97% 0.01 210)"};color:oklch(26% 0.04 210);box-shadow:0 2px 6px oklch(15% 0.05 210 / .3);`,
cardStyle: `background:${book ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${book ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:${
book
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7), 0 6px 18px oklch(15% 0.05 210 / .35)"
: "0 6px 18px oklch(15% 0.05 210 / .35)"
};${ring}`,
coverStyle: `width:100%;aspect-ratio:${book ? "0.82" : "1"};position:relative;display:flex;align-items:center;justify-content:center;${this.coverBg(a, 14)}`,
};
});
const songRows = songHits.map((h, hi) => ({
albumId: h.album.id,
navIndex: hi,
index: h.index,
title: h.title,
duration: h.duration,
albumLine: `${this.isBook(h.album) ? "📖 " : ""}${h.album.title} · ${h.album.artist}`,
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 16px;border-radius:14px;cursor:pointer;background:oklch(97% 0.01 210 / ${album && album.id === h.album.id && this.state.trackIndex === h.index ? ".22" : ".10"});${sel === hi ? "outline:4px solid oklch(97% 0.01 210);outline-offset:2px;" : ""}`,
chipStyle: `width:38px;height:38px;flex:none;border-radius:${this.isBook(h.album) ? "6px 12px 12px 6px" : "10px"};${this.coverBg(h.album, 7)}`,
}));
const openSongs = openAlbum
? openAlbum.songs.map((s, i) => {
const isCurrent = album && album.id === openAlbum.id && this.state.trackIndex === i;
return {
albumId: openAlbum.id,
index: i,
number: String(i + 1),
title: s[0],
duration: s[1],
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 14px;border-radius:14px;cursor:pointer;background:${isCurrent ? "oklch(70% 0.16 340 / .16)" : "oklch(30% 0.03 210 / .05)"};`,
numStyle: `width:30px;height:30px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;font:800 13px ui-monospace,Menlo,monospace;background:${isCurrent ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210)"};color:${isCurrent ? "#fff" : "oklch(35% 0.03 210)"};`,
titleStyle: `color:${isCurrent ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)"};`,
};
})
: [];
const volumeBars = [0, 1, 2, 3, 4].map((i) => ({
level: (i + 1) * 20,
style: `border:none;padding:0;cursor:pointer;background:${this.state.volume >= (i + 1) * 20 ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210 / .4)"};`,
}));
const parts = [];
if (this.state.mode === "tracks") parts.push(`${songHits.length} Titel`);
else if (albums.length) parts.push(`${albums.length} Album${albums.length === 1 ? "" : "en"}`);
return {
filterAllStyle: this.filterPill("all", this.state.filter === "all"),
filterMusicStyle: this.filterPill("music", this.state.filter === "music"),
filterBookStyle: this.filterPill("book", this.state.filter === "book"),
setFilterAll: () => this.setFilter("all"),
setFilterMusic: () => this.setFilter("music"),
setFilterBook: () => this.setFilter("book"),
scrollerRef: (el) => { this._scroller = el; },
gridRef: (el) => { this._grid = el; if (el) requestAnimationFrame(() => this.measureCols()); },
showHelp: this.state.showHelp,
toggleHelp: () => this.setState((s) => ({ showHelp: !s.showHelp })),
hasSearch: this.state.search.length > 0,
showSearchBar: this.state.search.length > 0 || this.state.mode === "tracks",
modeLabel: this.state.mode === "tracks" ? "♪ Titel" : "🔎 Alben",
searchDisplay: this.state.search ? `"${this.state.search}"` : "tippe …",
resultCountLabel: parts.length ? parts.join(" · ") + " gefunden" : "nichts gefunden",
albumSectionLabel: this.state.filter === "book" ? "Hörbücher" : this.state.filter === "music" ? "Alben" : "Alben & Hörbücher",
categories: categoryCards,
hasCategories: categoryCards.length > 0,
categorySectionLabel: this.state.filter === "book" ? "Figuren" : this.state.filter === "music" ? "Künstler" : "Figuren & Künstler",
openCategory: (e) => {
this.playPop(440);
this.setState({ category: e.currentTarget.dataset.key, selIndex: 0 });
},
hasCategoryCrumb: this.state.category !== null && !this.state.search,
categoryCrumb: this.state.category || "",
clearCategory: () => this.setState({ category: null, selIndex: 0 }),
hasAlbumHits: albums.length > 0,
hasSongHits: songHits.length > 0,
hasNoResults: albums.length === 0 && songHits.length === 0 && cats.length === 0,
songHitLabel: `${songHits.length} Treffer`,
filteredAlbums: albumCards,
songHits: songRows,
openAlbumCard: (e) => {
this.playPop(420);
const nav = Number(e.currentTarget.dataset.navIndex);
this.setState({ openAlbumId: Number(e.currentTarget.dataset.id), selIndex: isNaN(nav) ? this.state.selIndex : nav });
},
closeAlbum: () => this.setState({ openAlbumId: null }),
stopClick: (e) => e.stopPropagation(),
playOpenAlbum: () => {
if (this.state.openAlbumId !== null) {
this.playSong(this.state.openAlbumId, 0);
this.setState({ openAlbumId: null });
}
},
playSongHit: (e) => {
const d = e.currentTarget.dataset;
this.playSong(Number(d.album), Number(d.index));
},
hasOpenAlbum: openAlbum !== null,
openAlbumTitle: openAlbum ? openAlbum.title : "",
openAlbumArtist: openAlbum ? openAlbum.artist : "",
openAlbumMeta: openAlbum
? this.isBook(openAlbum)
? `📖 Hörbuch · ${openAlbum.songs.length} Kapitel`
: `🎵 Musik · ${openAlbum.songs.length} Songs`
: "",
openAlbumCoverStyle: openAlbum
? `width:150px;height:150px;flex:none;border-radius:${this.isBook(openAlbum) ? "10px 18px 18px 10px" : "16px"};display:flex;align-items:center;justify-content:center;${this.coverBg(openAlbum, 12)}`
: "display:none;",
openAlbumSongs: openSongs,
togglePlay: () => this.togglePlayInternal(),
prevTrack: () => this.advance(-1),
nextTrack: () => this.advance(1),
isBrowse: this.state.view === "browse",
isPlay: this.state.view === "play",
goBrowse: () => this.setState({ view: "browse" }),
bigCoverStyle: album
? `width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:${this.isBook(album) ? "18px 34px 34px 18px" : "28px"};display:flex;align-items:center;justify-content:center;box-shadow:0 20px 50px oklch(10% 0.05 210 / .55);${this.coverBg(album, 22)}`
: "width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:28px;background:oklch(35% 0.03 210);",
isPlaying: this.state.isPlaying,
isPaused: !this.state.isPlaying,
currentSongTitle: song ? song[0] : "Wähl ein Album!",
currentAlbumLine: album ? `${this.isBook(album) ? "📖 " : ""}${album.title} · ${album.artist}` : "Tippen oder klicken",
trackCounter: album ? `${this.isBook(album) ? "Kapitel" : "Song"} ${this.state.trackIndex + 1} von ${album.songs.length}` : "",
playerCoverStyle: album ? this.coverBg(album, 8) : "background:oklch(35% 0.03 210);",
albumRemainingLabel: album
? `Noch ${this.clock(this.remainingSecs(album))} ${this.isBook(album) ? "im Hörbuch" : "im Album"}`
: "",
trackRemainingLabel: album && album.songs[this.state.trackIndex]
? `−${this.clock(this.secs(album.songs[this.state.trackIndex][1]) * (1 - this.state.progress / 100))}`
: "",
progressStyle: `width:${this.state.progress}%;transition:width .2s linear;`,
dolphinBobAnim: this.state.isPlaying ? "dolphinBob 1.6s ease-in-out infinite" : "none",
volumeBars,
setVolume: (e) => {
this.playPop(500);
this.setState({ volume: Number(e.currentTarget.dataset.level) });
},
muteToggle: () => this.setState((s) => ({ volume: s.volume > 0 ? 0 : 60 })),
};
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,317 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
<style>
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
@keyframes dolphinFloat { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-12px) rotate(2deg); } }
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
a { color: oklch(80% 0.14 302); }
a:hover { color: oklch(92% 0.10 302); }
</style>
</helmet>
<div style="position:relative;width:100%;min-height:100vh;box-sizing:border-box;overflow:hidden;background:linear-gradient(180deg, oklch(56% 0.14 305) 0%, oklch(38% 0.12 300) 45%, oklch(21% 0.08 298) 100%);">
<div style="position:absolute;left:4%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 9s linear infinite;"></div>
<div style="position:absolute;left:11%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(94% 0.03 302 / .35);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
<div style="position:absolute;left:19%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
<div style="position:absolute;left:27%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(94% 0.03 302 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:4s;"></div>
<div style="position:absolute;left:34%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:3s;"></div>
<div style="position:absolute;left:42%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(94% 0.03 302 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:5.5s;"></div>
<div style="position:absolute;left:49%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 8.4s linear infinite;animation-delay:1.6s;"></div>
<div style="position:absolute;left:57%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(94% 0.03 302 / .4);animation:bubbleRise 11.2s linear infinite;animation-delay:6.2s;"></div>
<div style="position:absolute;left:64%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7.4s linear infinite;animation-delay:2.4s;"></div>
<div style="position:absolute;left:72%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(94% 0.03 302 / .32);animation:bubbleRise 14s linear infinite;animation-delay:3.8s;"></div>
<div style="position:absolute;left:80%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:5s;"></div>
<div style="position:absolute;left:88%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(94% 0.03 302 / .28);animation:bubbleRise 15s linear infinite;animation-delay:1.2s;"></div>
<div style="position:absolute;left:95%;bottom:-40px;width:13px;height:13px;border-radius:50%;background:oklch(94% 0.03 302 / .45);animation:bubbleRise 10.2s linear infinite;animation-delay:7s;"></div>
<div style="position:relative;z-index:1;padding-top:10px;">
<dc-import name="AppHeader" title="Mein Zimmer" mascot="dolphin-remote.png" link-href="./Dolphin%20Beats.dc.html" link-label="♪ Musik" hint-size="100%,116px"></dc-import>
</div>
<div style="position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:0 32px 64px;box-sizing:border-box;">
<div style="margin-top:18px;">
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 302);text-align:center;margin-bottom:14px;">Szenen</div>
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px;">
<sc-for list="{{ scenes }}" as="scene" hint-placeholder-count="5">
<button onClick="{{ activateScene }}" data-id="{{ scene.id }}" style="{{ scene.style }}">
<span style="font-size:22px;">{{ scene.emoji }}</span>
<span>{{ scene.name }}</span>
</button>
</sc-for>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(300px, 1fr));gap:20px;margin-top:30px;">
<sc-for list="{{ lights }}" as="light" hint-placeholder-count="4">
<div style="{{ light.cardStyle }}">
<div style="display:flex;align-items:center;gap:14px;">
<div style="{{ light.iconWrapStyle }}">
<sc-if value="{{ light.isBeyond }}" hint-placeholder-val="{{ true }}">
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" stroke-width="3"></circle>
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85"></circle>
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"></circle>
</svg>
</sc-if>
<sc-if value="{{ light.isStrip }}" hint-placeholder-val="{{ false }}">
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<rect x="4" y="19" width="40" height="10" rx="5" fill="none" stroke="currentColor" stroke-width="3"></rect>
<circle cx="13" cy="24" r="2.4" fill="currentColor"></circle>
<circle cx="24" cy="24" r="2.4" fill="currentColor"></circle>
<circle cx="35" cy="24" r="2.4" fill="currentColor"></circle>
</svg>
</sc-if>
<sc-if value="{{ light.isCeiling }}" hint-placeholder-val="{{ false }}">
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<path d="M24 4v7" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
<path d="M16 37h16" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"></path>
<path d="M20 43h8" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".3"></path>
</svg>
</sc-if>
</div>
<div style="flex:1;min-width:0;font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">{{ light.name }}</div>
<button onClick="{{ toggleLight }}" data-id="{{ light.id }}" style="{{ light.switchStyle }}">
<span style="{{ light.knobStyle }}"></span>
</button>
</div>
<div style="margin-top:16px;">
<div style="display:flex;gap:6px;">
<sc-for list="{{ light.levels }}" as="lvl" hint-placeholder-count="5">
<button onClick="{{ setLevel }}" data-id="{{ lvl.lightId }}" data-level="{{ lvl.value }}" style="{{ lvl.style }}"></button>
</sc-for>
</div>
</div>
<sc-if value="{{ light.hasColors }}" hint-placeholder-val="{{ true }}">
<div style="margin-top:14px;">
<div style="display:flex;flex-wrap:wrap;gap:10px;">
<sc-for list="{{ light.swatches }}" as="sw" hint-placeholder-count="5">
<button onClick="{{ setColor }}" data-id="{{ sw.lightId }}" data-color="{{ sw.value }}" title="{{ sw.name }}" style="{{ sw.style }}"></button>
</sc-for>
</div>
</div>
</sc-if>
</div>
</sc-for>
<div style="background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35);">
<div style="display:flex;align-items:center;gap:14px;">
<div style="width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;background:oklch(88% 0.03 300);color:oklch(35% 0.05 300);">
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="3"></rect>
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" stroke-width="2.5"></path>
</svg>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">Rollo</div>
<div style="font-size:13px;font-weight:700;color:oklch(45% 0.03 300 / .8);">{{ shutterLabel }}</div>
</div>
</div>
<div style="margin-top:16px;display:flex;gap:18px;align-items:stretch;">
<div style="width:104px;flex:none;border-radius:12px;overflow:hidden;position:relative;background:linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200));border:3px solid oklch(35% 0.04 300);">
<div style="position:absolute;left:0;right:0;top:0;{{ shutterFillStyle }}"></div>
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<sc-for list="{{ shutterPresets }}" as="p" hint-placeholder-count="4">
<button onClick="{{ setShutter }}" data-value="{{ p.value }}" style="{{ p.style }}">{{ p.name }}</button>
</sc-for>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
<button onClick="{{ shutterUp }}" style="{{ upStyle }}">▲</button>
<button onClick="{{ shutterStop }}" style="{{ stopStyle }}">■</button>
<button onClick="{{ shutterDown }}" style="{{ downStyle }}">▼</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1280, &quot;height&quot;: 900}}">
const SWATCHES = [
{ name: "Warmweiß", value: "oklch(90% 0.06 85)" },
{ name: "Sonnengelb", value: "oklch(85% 0.16 95)" },
{ name: "Korallenrot", value: "oklch(65% 0.20 25)" },
{ name: "Delfinblau", value: "oklch(70% 0.15 235)" },
{ name: "Riffgrün", value: "oklch(75% 0.16 155)" },
{ name: "Quallenlila", value: "oklch(65% 0.18 310)" },
];
const SCENES = [
{ id: "lesen", name: "Lesen", emoji: "📖", lights: [{ on: true, level: 4, color: "oklch(90% 0.06 85)" }, { on: true, level: 2, color: "oklch(90% 0.06 85)" }, { on: false, level: 2, color: "oklch(70% 0.15 235)" }], ceiling: { on: true, level: 3 }, shutter: 0 },
{ id: "spielen", name: "Spielen", emoji: "🐬", lights: [{ on: true, level: 5, color: "oklch(75% 0.16 155)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }, { on: true, level: 4, color: "oklch(65% 0.18 310)" }], ceiling: { on: true, level: 5 }, shutter: 0 },
{ id: "party", name: "Disco", emoji: "🪩", lights: [{ on: true, level: 5, color: "oklch(65% 0.18 310)" }, { on: true, level: 5, color: "oklch(65% 0.20 25)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }], ceiling: { on: false, level: 2 }, shutter: 85 },
{ id: "nacht", name: "Gute Nacht", emoji: "🌙", lights: [{ on: true, level: 1, color: "oklch(70% 0.15 235)" }, { on: false, level: 1, color: "oklch(70% 0.15 235)" }, { on: true, level: 1, color: "oklch(65% 0.18 310)" }], ceiling: { on: false, level: 1 }, shutter: 100 },
{ id: "aufwachen", name: "Aufwachen", emoji: "🌞", lights: [{ on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: false, level: 2, color: "oklch(85% 0.16 95)" }], ceiling: { on: true, level: 2 }, shutter: 0 },
];
class Component extends DCLogic {
state = {
lights: [
{ id: "beyond1", kind: "beyond", name: "Hue Beyond links", on: true, level: 4, color: "oklch(70% 0.15 235)" },
{ id: "beyond2", kind: "beyond", name: "Hue Beyond rechts", on: false, level: 3, color: "oklch(75% 0.16 155)" },
{ id: "strip", kind: "strip", name: "Lichtband Bett", on: true, level: 3, color: "oklch(65% 0.18 310)" },
{ id: "ceiling", kind: "ceiling", name: "Deckenlampe", on: true, level: 5, color: null },
],
shutter: 0,
moving: null,
scene: null,
};
componentWillUnmount() {
if (this._mover) clearInterval(this._mover);
}
patch(id, changes) {
this.setState((s) => ({
scene: null,
lights: s.lights.map((l) => (l.id === id ? Object.assign({}, l, changes) : l)),
}));
}
move(dir) {
if (this._mover) clearInterval(this._mover);
this.setState({ moving: dir, scene: null });
this._mover = setInterval(() => {
this.setState((s) => {
const next = s.shutter + dir * 4;
if (next <= 0) { clearInterval(this._mover); this._mover = null; return { shutter: 0, moving: null }; }
if (next >= 100) { clearInterval(this._mover); this._mover = null; return { shutter: 100, moving: null }; }
return { shutter: next };
});
}, 90);
}
stop() {
if (this._mover) clearInterval(this._mover);
this._mover = null;
this.setState({ moving: null });
}
glow(color, level) {
return `0 0 0 4px ${color.replace(")", " / .28)")}, 0 10px 26px ${color.replace(")", ` / ${0.18 + level * 0.08})`)}`;
}
renderVals() {
const shutter = Math.round(this.state.shutter);
const lights = this.state.lights.map((l) => {
const tint = l.color || "oklch(92% 0.05 90)";
const active = l.on;
return {
id: l.id,
name: l.name,
isBeyond: l.kind === "beyond",
isStrip: l.kind === "strip",
isCeiling: l.kind === "ceiling",
hasColors: l.kind !== "ceiling",
cardStyle: `background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35)${active ? `, 0 0 0 3px ${tint.replace(")", " / .55)")}` : ""};`,
iconWrapStyle: `width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;transition:background .15s ease;background:${active ? tint : "oklch(88% 0.02 300)"};color:${active ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)"};${active ? `box-shadow:0 0 22px ${tint.replace(")", " / .7)")};` : ""}`,
switchStyle: `width:74px;height:42px;flex:none;border:none;cursor:pointer;border-radius:999px;padding:4px;display:flex;justify-content:${active ? "flex-end" : "flex-start"};background:${active ? "oklch(62% 0.15 302)" : "oklch(84% 0.01 300)"};transition:background .15s ease;`,
knobStyle: "width:34px;height:34px;border-radius:999px;background:#fff;box-shadow:0 2px 6px oklch(20% 0.03 300 / .4);display:block;",
levels: [1, 2, 3, 4, 5].map((v) => ({
lightId: l.id,
value: v,
style: `flex:1;height:46px;min-width:0;border:none;cursor:pointer;border-radius:12px;background:${active && l.level >= v ? tint : "oklch(88% 0.02 300)"};opacity:${active ? 1 : 0.55};`,
})),
swatches: SWATCHES.map((sw) => ({
lightId: l.id,
value: sw.value,
name: sw.name,
style: `width:46px;height:46px;border-radius:999px;cursor:pointer;background:${sw.value};border:${l.color === sw.value ? "4px solid oklch(28% 0.05 300)" : "4px solid oklch(96% 0.012 300 / .8)"};box-shadow:0 3px 8px oklch(20% 0.03 300 / .35);`,
})),
};
});
const presetBtn = (active) =>
`border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:0 10px;height:48px;border-radius:14px;background:${active ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${active ? "#fff" : "oklch(28% 0.04 300)"};`;
const label =
shutter >= 99 ? "Ganz zu" :
shutter >= 80 ? "Fast zu" :
shutter >= 40 ? "Halb zu" :
shutter <= 1 ? "Offen" : `${shutter}% zu`;
return {
lights,
toggleLight: (e) => {
const id = e.currentTarget.dataset.id;
const l = this.state.lights.find((x) => x.id === id);
this.patch(id, { on: !l.on });
},
setLevel: (e) => {
const d = e.currentTarget.dataset;
this.patch(d.id, { level: Number(d.level), on: true });
},
setColor: (e) => {
const d = e.currentTarget.dataset;
this.patch(d.id, { color: d.color, on: true });
},
shutterLabel: this.state.moving ? (this.state.moving > 0 ? "Fährt runter …" : "Fährt hoch …") : label,
shutterFillStyle: `height:${shutter}%;transition:height .2s linear;background:repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px);box-shadow:0 4px 10px oklch(20% 0.03 300 / .4);`,
shutterPresets: [
{ name: "Offen", value: 0, style: presetBtn(shutter <= 1) },
{ name: "Halb zu", value: 50, style: presetBtn(shutter > 1 && shutter < 80) },
{ name: "Fast zu", value: 85, style: presetBtn(shutter >= 80 && shutter < 99) },
{ name: "Ganz zu", value: 100, style: presetBtn(shutter >= 99) },
],
setShutter: (e) => {
this.stop();
this.setState({ shutter: Number(e.currentTarget.dataset.value), scene: null });
},
shutterUp: () => this.move(-1),
shutterDown: () => this.move(1),
shutterStop: () => this.stop(),
upStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === -1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === -1 ? "#fff" : "oklch(28% 0.04 300)"};`,
downStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === 1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === 1 ? "#fff" : "oklch(28% 0.04 300)"};`,
stopStyle: "border:none;cursor:pointer;font-family:inherit;font-size:18px;font-weight:900;height:52px;border-radius:14px;background:oklch(65% 0.18 25);color:#fff;",
scenes: SCENES.map((sc) => ({
id: sc.id,
name: sc.name,
emoji: sc.emoji,
style: `border:none;cursor:pointer;font-family:inherit;font-size:15px;font-weight:800;padding:12px 20px;border-radius:999px;display:flex;align-items:center;gap:10px;min-height:52px;background:${
this.state.scene === sc.id ? "oklch(97% 0.01 302)" : "oklch(97% 0.01 302 / .18)"
};color:${this.state.scene === sc.id ? "oklch(26% 0.05 300)" : "oklch(97% 0.01 302)"};box-shadow:${
this.state.scene === sc.id ? "0 6px 18px oklch(15% 0.05 300 / .4)" : "none"
};`,
})),
activateScene: (e) => {
const sc = SCENES.find((x) => x.id === e.currentTarget.dataset.id);
if (!sc) return;
this.stop();
this.setState((s) => ({
scene: sc.id,
shutter: sc.shutter,
lights: s.lights.map((l, i) =>
l.kind === "ceiling"
? Object.assign({}, l, sc.ceiling)
: Object.assign({}, l, sc.lights[i] || {})
),
}));
},
};
}
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

1911
claude-design/support.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,435 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
/**
* <three-d-stage> — 3D object viewer + exporter shell (three.js).
*
* The stage owns the whole scene: WebGL renderer, neutral studio lighting
* with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom,
* right-drag to pan), a camera auto-framed to the object's bounds, resize
* handling, and a download toolbar that exports the current object as
* OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser;
* GLB is the interchange format every modern 3D tool imports.
*
* three.js loads through the page's import map. Include this EXACT pinned
* map in <head>, before any module runs — versions and integrity hashes
* stay together (same map the "3D object" skill mandates):
*
* <script type="importmap">
* {
* "imports": {
* "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
* "three/addons/controls/OrbitControls.js": "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js",
* "three/addons/exporters/OBJExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js",
* "three/addons/exporters/GLTFExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js"
* },
* "integrity": {
* "https://unpkg.com/three@0.184.0/build/three.module.js": "sha384-8FCZ1eVO6it4+pbec2aDtnTrwjWXZLJRC+MAGCIPDgsYnUrl/E0A2YlF8ioMKI/J",
* "https://unpkg.com/three@0.184.0/build/three.core.js": "sha384-dw2ooPewaEIrAgl6oFDBmmBWCE9oW9LxRGcfwZ0hLvEprzo202wXl7vCYHRlSnOT",
* "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js": "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js": "sha384-nbwtoZENJD3Vq+ACK0CuGQdPMuDWHkamC2KJD70EV5nfg6jQjfppKOea07YJN+N3",
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js": "sha384-VofkvpG6HERhFCYbsUOHeNXBCqID2nfqkQqnVzE1jc/oPcz+qJ13ADdXH08hE+cQ"
* }
* }
* </script>
*
* Usage:
* <style>three-d-stage:not(:defined){visibility:hidden}</style>
* <three-d-stage name="rocket"></three-d-stage>
* <script src="three-d-stage.js"></script>
* <script type="module">
* const stage = document.querySelector('three-d-stage');
* const { THREE } = await stage.ready;
* const model = new THREE.Group();
* // …build the model out of named meshes with named materials —
* // the names become the o / usemtl entries in the exported OBJ…
* stage.setObject(model);
* </script>
*
* Attributes:
* name — export file basename (default "model")
* background — CSS color behind the scene (default a warm paper tone)
* autorotate — when present, a slow turntable until the user interacts
*
* Model in real-world meters, centered on the origin, y-up — exports
* inherit the scene's units and orientation. The stage fills its own box;
* size it with ordinary CSS (default 100vw/100vh page hero).
*
* Default setup: neutral studio lighting (hemisphere + key + fill), a
* soft ground shadow, and NO environment map — so high metalness has
* nothing to reflect and renders near-black. Cap metalness around
* 0.3–0.4 and carry a metal look with a brighter base color. The copied
* file is yours: adjust the lights, shadow, or background in _boot()
* when the object needs a different look.
*/
/* END USAGE */
(() => {
const stylesheet = `
:host {
position: relative;
display: block;
width: 100%;
height: 100vh;
background: var(--stage-bg, #f0eee6);
overflow: hidden;
}
canvas { display: block; outline: none; }
.toolbar {
position: absolute;
right: 16px;
bottom: 16px;
display: flex;
gap: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.toolbar button {
appearance: none;
border: 1px solid rgba(20, 20, 19, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #1a1915;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
padding: 9px 12px;
cursor: default;
}
.toolbar button:hover { background: #fff; }
.toolbar button:active { transform: translateY(1px); }
.toolbar button[disabled] { opacity: 0.5; pointer-events: none; }
.note {
position: absolute;
left: 16px;
bottom: 16px;
max-width: 60%;
font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: rgba(26, 25, 21, 0.55);
user-select: none;
}
.err {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #8a2f20;
text-align: center;
white-space: pre-line;
}
`;
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
/** Tell the host an export attempt settled — telemetry only. The host
* (HTMLViewer) verifies the source and re-reads these fields defensively
* before counting; nothing else crosses the frame boundary. Guarded so
* telemetry can never break the download path. */
function notifyExport(format, ok) {
try {
window.parent.postMessage(
{ type: 'omelette:notify-3d-export', format: format, ok: ok === true },
'*'
);
} catch (e) {}
}
class ThreeDStage extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = stylesheet;
root.appendChild(style);
this._err = document.createElement('div');
this._err.className = 'err';
root.appendChild(this._err);
const note = document.createElement('div');
note.className = 'note';
note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan';
root.appendChild(note);
this._toolbar = document.createElement('div');
this._toolbar.className = 'toolbar';
this._objBtn = document.createElement('button');
this._objBtn.type = 'button';
this._objBtn.textContent = 'Download OBJ + MTL';
this._objBtn.addEventListener('click', () => this._runExport('obj'));
this._glbBtn = document.createElement('button');
this._glbBtn.type = 'button';
this._glbBtn.textContent = 'Download GLB';
this._glbBtn.addEventListener('click', () => this._runExport('glb'));
this._toolbar.appendChild(this._objBtn);
this._toolbar.appendChild(this._glbBtn);
root.appendChild(this._toolbar);
this._setButtonsEnabled(false);
/** Resolves with { THREE } once the scene is live — build the model
* in `await stage.ready` so nothing races the library load. */
this.ready = new Promise((resolve, reject) => {
this._readyResolve = resolve;
this._readyReject = reject;
});
}
connectedCallback() {
if (this._booted) {
// Re-attached after a removal — resume what disconnected stopped.
if (this._renderer) {
this._renderer.setAnimationLoop(this._loop);
this._ro && this._ro.observe(this);
}
return;
}
this._booted = true;
this._boot().catch((err) => {
this._err.style.display = 'flex';
this._err.textContent =
'three.js failed to load.\n' +
'Check that the pinned <script type="importmap"> from the usage ' +
'notes is in <head> before any module script.\n\n' +
String(err && err.message ? err.message : err);
this._readyReject(err);
});
}
async _boot() {
const bg = this.getAttribute('background');
if (bg) this.style.setProperty('--stage-bg', bg);
const [THREE, controlsMod] = await Promise.all([
import('three'),
import('three/addons/controls/OrbitControls.js'),
]);
this._THREE = THREE;
// preserveDrawingBuffer keeps the last frame readable after
// compositing (toDataURL / drawImage) — it's what lets the
// screenshot tools capture the scene instead of a blank canvas.
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this._renderer = renderer;
this.shadowRoot.insertBefore(renderer.domElement, this._err);
const scene = new THREE.Scene();
this._scene = scene;
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 500);
camera.position.set(3, 2.2, 4);
this._camera = camera;
const controls = new controlsMod.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
this._controls = controls;
// Neutral studio: soft sky/ground wash, a shadow-casting key light,
// and a dim fill from behind so silhouettes never go black.
scene.add(new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0));
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(4, 7, 5);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.bias = -0.0002;
this._key = key;
scene.add(key);
const fill = new THREE.DirectionalLight(0xfff4e6, 0.5);
fill.position.set(-5, 3, -4);
scene.add(fill);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.ShadowMaterial({ opacity: 0.18 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this._ground = ground;
scene.add(ground);
this._autorotate = this.hasAttribute('autorotate');
controls.autoRotate = this._autorotate;
controls.autoRotateSpeed = 1.2;
controls.addEventListener('start', () => {
controls.autoRotate = false;
});
const fit = () => {
const w = this.clientWidth || 1;
const h = this.clientHeight || 1;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
fit();
this._ro = new ResizeObserver(fit);
this._loop = () => {
controls.update();
renderer.render(scene, camera);
};
// Detached while three.js was fetching? Stay idle — the
// connectedCallback resume starts the loop and observer on
// reattach.
if (this.isConnected) {
this._ro.observe(this);
renderer.setAnimationLoop(this._loop);
}
this._readyResolve({ THREE });
}
disconnectedCallback() {
// Stop rendering and observing while detached; connectedCallback
// resumes both. (The renderer itself is kept — a move within the
// document must not rebuild the scene.)
if (this._renderer) this._renderer.setAnimationLoop(null);
if (this._ro) this._ro.disconnect();
}
/** Show (and own) the object. Replaces any previous object, enables
* shadows on every mesh, rests it on the ground plane, and frames
* the camera to its bounds. */
setObject(object) {
const THREE = this._THREE;
if (!THREE) throw new Error('three-d-stage: not ready — await stage.ready first');
if (this._object) this._scene.remove(this._object);
this._object = object;
object.traverse((o) => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
const box = new THREE.Box3().setFromObject(object);
if (!box.isEmpty()) {
// Rest the object on the ground without moving its origin.
this._ground.position.y = box.min.y;
const sphere = box.getBoundingSphere(new THREE.Sphere());
const dist =
(sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360)) * 1.35;
const dir = new THREE.Vector3(1, 0.55, 1.25).normalize();
this._camera.position
.copy(sphere.center)
.add(dir.multiplyScalar(dist));
this._camera.near = Math.max(dist / 100, 0.01);
this._camera.far = dist * 100;
this._camera.updateProjectionMatrix();
this._controls.target.copy(sphere.center);
this._controls.update();
const span = sphere.radius * 3;
this._key.shadow.camera.left = -span;
this._key.shadow.camera.right = span;
this._key.shadow.camera.top = span;
this._key.shadow.camera.bottom = -span;
this._key.shadow.camera.updateProjectionMatrix();
}
this._scene.add(object);
this._setButtonsEnabled(true);
}
get _basename() {
return (this.getAttribute('name') || 'model').replace(/[^\w.-]+/g, '_');
}
_setButtonsEnabled(on) {
this._objBtn.disabled = !on;
this._glbBtn.disabled = !on;
}
/** Every mesh and material needs a unique name for o/usemtl lines —
* fill in stable fallbacks, and return the unique material list. */
_nameParts() {
const mats = [];
const seen = new Set();
let meshI = 0;
let matI = 0;
this._object.traverse((o) => {
if (!o.isMesh) return;
if (!o.name) o.name = 'part_' + meshI;
meshI += 1;
const list = Array.isArray(o.material) ? o.material : [o.material];
for (const m of list) {
if (!m || mats.includes(m)) continue;
if (!m.name) {
m.name = 'mat_' + matI;
matI += 1;
}
while (seen.has(m.name)) {
m.name = m.name + '_' + matI;
matI += 1;
}
seen.add(m.name);
mats.push(m);
}
});
return mats;
}
/** One export attempt, reported to the host however it settles.
* Rethrows so a failure stays visible on the guest console exactly as
* before. The no-object early return is not an attempt (the toolbar is
* disabled until the model loads) and reports nothing. */
async _runExport(format) {
if (!this._object) return;
try {
await (format === 'obj' ? this._exportObj() : this._exportGlb());
notifyExport(format, true);
} catch (err) {
notifyExport(format, false);
throw err;
}
}
async _exportObj() {
if (!this._object) return;
const mod = await import('three/addons/exporters/OBJExporter.js');
const mats = this._nameParts();
const base = this._basename;
const obj =
'mtllib ' + base + '.mtl\n' + new mod.OBJExporter().parse(this._object);
let mtl = '# Exported by three-d-stage\n';
for (const m of mats) {
const c = m.color || { r: 0.8, g: 0.8, b: 0.8 };
const rough = typeof m.roughness === 'number' ? m.roughness : 0.5;
const opacity = typeof m.opacity === 'number' ? m.opacity : 1;
mtl += 'newmtl ' + m.name + '\n';
mtl +=
'Kd ' + c.r.toFixed(4) + ' ' + c.g.toFixed(4) + ' ' + c.b.toFixed(4) + '\n';
mtl += 'Ks 0.2000 0.2000 0.2000\n';
mtl += 'Ns ' + Math.round((1 - rough) * 200) + '\n';
mtl += 'd ' + opacity.toFixed(4) + '\n\n';
}
download(new Blob([obj], { type: 'text/plain' }), base + '.obj');
download(new Blob([mtl], { type: 'text/plain' }), base + '.mtl');
}
async _exportGlb() {
if (!this._object) return;
const mod = await import('three/addons/exporters/GLTFExporter.js');
this._nameParts();
const base = this._basename;
const buf = await new mod.GLTFExporter().parseAsync(this._object, {
binary: true,
});
download(
new Blob([buf], { type: 'model/gltf-binary' }),
base + '.glb'
);
}
}
customElements.define('three-d-stage', ThreeDStage);
})();

View File

@@ -20,7 +20,9 @@ Assistant integration.
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. | | `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. | | `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). | | `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
| `claude-design/` | The interaction and visual spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. | | `claude-design/` | The interaction and visual specs the web front-end was built from, as standalone HTML mockups with hardcoded data: "Dolphin Beats" (the player), "Mein Zimmer" (the room page) and "Delphin Uhr" (the clock overlay). `web/` is the real implementation of all three; the mockups are kept as the reference for the keyboard model and the styling. |
| `scripts/` | `check.sh` (every check, see below) and `sync-library.sh` (seed a device's library and analysis cache). |
| `docs/` | This file. |
## How the pieces talk to each other ## How the pieces talk to each other
@@ -41,12 +43,18 @@ Assistant integration.
out of step with a figure someone puts on the reader. See out of step with a figure someone puts on the reader. See
`python-backend/README.md` for the endpoint list. `python-backend/README.md` for the endpoint list.
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three - **`python-backend` ↔ Home Assistant**: two separate paths.
discoverable lights, a player sensor, a volume number, transport buttons, device - MQTT, for the mouse itself. The backend publishes three discoverable lights, a
triggers for every button and touch area, and a tag scanner for the RFID reader. It player sensor, a volume number, transport buttons, device triggers for every button
does *not* call Home Assistant services directly any more; behaviour like "the left and touch area, and a tag scanner for the RFID reader. Behaviour like "the left ear
ear turns the room light pink" is an HA automation. See `python-backend/README.md` turns the room light pink" is an HA automation, not backend code. See
for the trigger topics and the old colour mapping. `python-backend/README.md` for the trigger topics and the old colour mapping.
- HTTP, for the web front-end's room page only: `/api/ha/...` proxies state reads and
service calls to Home Assistant's REST API with the token from `general.ha`, so the
token never reaches the browser.
- **`python-backend` ↔ IR remote**: a TCP client for lircd (`general.lirc`), turned into
the same intents as every other front-end.
## Running it ## Running it
@@ -89,16 +97,20 @@ production so one process serves both the UI and the API.
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device, - `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness, min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
audio_extensions, mqtt.*, web.*}` audio_extensions, podcast_episode_limit}`, plus optional sections `mqtt`, `web`, `ha`
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure; (room page), `lirc` (IR remote), `screen` (monitor on/off), `tippen` (typing game)
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or and `clock` (school bus countdown).
`"wNN"`. - `figures.<name>.{id, colors, kind}` — `id` is a 5-byte hex RFID tag, unique per
figure; `colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"`
or `"wNN"`; `kind` is `music` (default) or `book`.
- `remote.<digit>` — what each IR-remote number key plays. Written by the web UI.
`general.library.root` is the one path to the music. The shelves under it are fixed `general.library.root` is the one path to the music. The shelves under it are fixed
names, not settings, because each has quirks the scanner knows about: names, not settings, because each has quirks the scanner knows about:
``` ```
<root>/Figuren/<figure name>/ one folder per figurine <root>/Figuren/<figure name>/ one folder per figurine (or album subfolders
in it, up to six, picked on the LED ring)
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
<root>/Kinderpodcasts/<Show>/ shows, newest episode first <root>/Kinderpodcasts/<Show>/ shows, newest episode first
@@ -109,17 +121,21 @@ reported at once.
## Notes for agents ## Notes for agents
- The backend has `pytest`, `ruff` and `mypy --strict` configured in - There is no CI. `scripts/check.sh` runs every check the repo has - backend
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes. `pytest`, `ruff` and `mypy --strict`, web `vitest`, `tsc` and `eslint` - and is the
gate: run it before proposing changes. A checker that cannot run (mypy once aborted
on numpy's stubs for two weeks without anyone noticing) is a failure, not a skip.
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when - Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
the behaviour is end-to-end — those files are executed by the test suite. the behaviour is end-to-end — those files are executed by the test suite.
- `python-backend/notebooks/` is university course material on chord recognition, not - `python-backend/notebooks/` is LED-effect scratch work, not part of the app.
part of the app.
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects. - The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`; - The front-end has `vitest` over the pure modules (`web/src/lib/`), `tsc` and `eslint`;
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are there is no component-level test harness.
the two checks.
- The library index is cached under `general.library.cache`. It is keyed by file mtime - The library index is cached under `general.library.cache`. It is keyed by file mtime
and size, so **a change to how the scanner derives a title, artist or series is and size, so **a change to how the scanner derives a title, artist or series is
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
`musicmouse/library/cache.py` when you touch that logic. `musicmouse/library/cache.py` when you touch that logic.
- Track analysis (librosa) runs in a pool of worker *processes* - see
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
picklable, and an analyzer that records state in its own instance (a test double
counting calls) only behaves as written with `analysis_workers=1`.

View File

@@ -0,0 +1,121 @@
# Task: password-protect the backend's HTTP API
## Why
The backend's HTTP API has no authentication and listens on `0.0.0.0`. The worst part
is the room page's Home Assistant proxy
(`python-backend/musicmouse/services/web/api.py`, `_proxy` and
`POST /api/ha/services/{domain}/{service}`): it forwards *any* service call on *any*
entity with the stored long-lived HA token. Anything on the LAN can unlock a lock,
disarm an alarm or restart Home Assistant through it. Parent mode (`/api/settings`,
`/api/remote/mapping`, `/api/timer`) is only hidden, not protected.
## What the owner decided
A browser authenticates **once** with a password, and then stays logged in for about
**a year**. The config stores only a **hash** of the password, never the password
itself. Use this design; do not substitute another one (no entity allow-list instead,
no per-endpoint tokens, no OAuth).
## Design
### Config
- Add `general.web.password_hash` (in `WebConfig`, `musicmouse/config.py`). It is
**required** whenever the `web` section is present. Follow the repo's existing
convention for opting out of something important (see `SIMULATE` for `serial_port`
and `alsa_device`): the literal value `"disabled"` turns authentication off, logs a
warning on **every** boot, and is the only way to run without it. A missing key is a
config error. Config errors already say which key is wrong.
- Hash format: `scrypt$<n>$<r>$<p>$<salt b64>$<hash b64>` using `hashlib.scrypt` from
the standard library. No new dependency. Choose parameters that verify in well under
a second on a Raspberry Pi 4 (for example n=2**14, r=8, p=1), and measure on the
desktop to sanity-check. Verify with `hmac.compare_digest`.
- Add a CLI subcommand to set it. For example,
`python -m musicmouse --config config.yml --set-password` prompts twice with
`getpass`, hashes, and writes the key back into `config.yml` using the existing
round-trip helpers in `musicmouse/services/web/settings.py` (`load_document`,
`atomic_write`), so comments survive. Exit afterwards and start nothing else.
- Document the key and the command in `python-backend/config.yml.example`.
### Sessions
- `POST /api/auth/login` with `{"password": "..."}`. On success it sets a cookie and
returns 204; on failure it returns 401 after a fixed delay of about 1 s (cheap brute-force
damping; no lockout state needed).
- The cookie (`mm_session`): `HttpOnly`, `SameSite=Strict`, `Path=/`,
`Max-Age` = 365 days. **Not** `Secure`: the device is served over plain HTTP on the
LAN, and a `Secure` cookie would never be sent back.
- The cookie value is a stateless signed token, `<expiry unix seconds>.<HMAC-SHA256>`.
The HMAC key is derived from a persistent random secret *and* the current
`password_hash`, so **changing the password logs every browser out** without extra
bookkeeping. Keep the secret in a file under `general.library.cache` (for example
`session-secret`, 32 random bytes, created on first start with mode 0600). Deleting
the cache logs everyone out, which is acceptable; say so in the docs.
- Optional, cheap, and nice: re-issue the cookie with a fresh year on any authenticated
request when fewer than 30 days remain, so a device in daily use never expires.
- `POST /api/auth/logout` clears the cookie. `GET /api/auth` returns 204 or 401, so the
front-end can check without side effects.
### What is protected
- **Every** route under `/api`, including the websocket `/api/ws`, except
`/api/auth/*`. Implement it once, as a FastAPI dependency on the router in
`build_router` (or a small middleware), not route by route. The websocket checks the
cookie *before* `accept()` and closes with code 4401 when it is missing or invalid.
- **Not** the static front-end files: the login screen has to load.
- **No localhost exemption.** The kiosk on the device logs in once like everyone else.
An exemption by peer address breaks silently the day a reverse proxy is put in front
of it (every request then looks local).
- MQTT, the IR remote and the serial link are not HTTP and are out of scope.
- Keep the room page's proxy as it is otherwise. Do not add an entity allow-list in
this task.
### Front-end (`web/`)
- `src/api/client.ts`: a 401 from any call raises a dedicated `Unauthorized` error.
`fetchOrNullOn404` / `fetchOrNullOnError` must not swallow a 401 as "not configured".
- A login screen: one password field, German copy ("Passwort", "Anmelden", "Falsches
Passwort"), styled like the existing sheets (`.overlay` / `.sheet`). It must work with
only a keyboard (the kiosk has no pointer). Show it when the first `/api/state` or
`/api/library` call, or the websocket, reports unauthorized. On success, reload the
library, the state and the websocket. The simplest correct way is `location.reload()`.
- `src/hooks/usePlayerState.ts`: on a 4401 close, stop the reconnect loop and show the
login screen instead of retrying every 1.5 s.
- The service worker (`public/sw.js`) must not cache `/api/auth/*`. It already avoids
`/api` state; check it.
## Tests (backend)
- Every existing web test goes through the API. Give the test client fixture in
`tests/test_web.py` (and `test_remote_api.py`, `test_tippen_api.py`) a logged-in
cookie, so the existing tests stay as they are. Add a separate unauthenticated client
fixture for the new tests.
- New tests:
- Each `/api` route returns 401 without the cookie. Generate the list from the router
so a new route cannot slip through unprotected.
- The websocket is refused without the cookie.
- Login succeeds with the right password and fails with a wrong one.
- A token past its expiry is rejected.
- Changing `password_hash` invalidates existing cookies.
- `"disabled"` lets everything through and logs the warning.
- A missing key is a config error.
- The `--set-password` command writes a hash that verifies and keeps the file's
comments.
- The fixed failure delay must not slow the suite down: inject it, as the codebase
injects `Clock`.
## Docs to update
- `python-backend/README.md`: the "Parent mode" paragraph that says there is no
authentication; the route table; a short "Authentication" section.
- `web/README.md`: the "Parent mode" paragraph.
- `docs/REPO_OVERVIEW.md`: the backend ↔ web paragraph and the config summary.
- The comment block above the room-control routes in `api.py`, which justifies the open
proxy with "trusted LAN device".
## Done when
`scripts/check.sh` passes, and a manual run against `--simulate` with a hash set shows
the login screen, logs in, survives a backend restart without asking again, and asks
again after the password is changed.

View File

@@ -0,0 +1,121 @@
# Task: one behaviour per button, whatever it is pressed on
## Why
The backend's README promises that front-ends only translate input into intents and that
`musicmouse/reactions/` is the one place that decides what happens. Three behaviours
break that promise today, so the same request does different things depending on where
it came from:
| Behaviour | Web front-end | IR remote | Mouse buttons / knob | MQTT |
|---|---|---|---|---|
| Next/previous on a podcast | seeks ±30 s (`web/src/App.tsx`, `skipPodcast`) | skips a track | skips a track | skips a track |
| Unmute | jumps to 60 % (`UNMUTE_PERCENT` in `App.tsx`) | restores the previous volume (`LircService._toggle_mute`) | - | - |
| Volume step | J/K: 10 % (`VOLUME_STEP` in `web/src/lib/keyboard.ts`); +/- buttons: `volume_increment` | `volume_increment` | `volume_increment` × detents | absolute only |
There is also a confirmed bug. With `min_volume > 0`, the remote's mute never unmutes:
"mute" asks for volume 0, the player clamps it to `min_volume`, the next press sees
`volume > 0` and mutes again.
## What the owner decided
1. **Next/previous on a podcast episode always seeks 30 s** forward/back, from every
input: web (buttons and keys), IR remote, the mouse's buttons, MQTT.
2. **Unmute always restores the volume from before muting**, from every input.
3. **Parent mode gets three separate volume-step settings**: the web keyboard's J/K,
the mouse's rotary knob, and the IR remote. Each is configured independently.
## Design
### 1. Podcast skip lives in the backend
- In `musicmouse/reactions/playback.py`, `next_track` / `previous_track` look at the
current album (`app.album_for(app.player.playlist)`). For a podcast episode they seek
instead: `position ± PODCAST_SKIP_SECONDS` (30, a module constant), clamped to
`[0, duration]`. Take the duration from the library track when the player does not
know it yet, the way `services/web/state.py` does.
- "Is this a podcast?" is decided by **one** helper. If `docs/agent-tasks/06-podcast-kind.md`
has landed, that is `album.kind == "podcast"`. If not, add `is_podcast(album)` next to
`Album` (`musicmouse/library/models.py`) that checks the section's
`album_unit == "episode"`, and let task 06 change its body.
- `web/src/App.tsx`: the `next` / `previous` actions always call `api.next()` /
`api.previous()`. Delete `skipPodcast` and `PODCAST_SKIP_SECONDS`. Drop the optimistic
`position: 0` patch too: the client no longer knows what the backend will do. If the
≤0.5 s until the next position frame is visibly laggy, let `StateHub` push a
`position` frame right after a next/previous/seek intent has been handled. Don't
re-add client-side policy.
- The mouse's left/right buttons only act while playing (`button_pressed`); keep that.
### 2. Mute is a player state, not a volume
- Add `muted: bool` to the `Player` protocol, plus `set_muted(muted, *, source)`,
implemented in `PlayerBase`. `VlcPlayer` applies it with libVLC's
`audio_set_mute`; `FakePlayer` just records it. Muting does **not** touch `volume`,
so unmuting restores it by construction, and `min_volume` cannot interfere.
- Announce changes with a new `StateEvent` (for example `MuteChanged`) and add it to
`SNAPSHOT_EVENTS` in `services/web/hub.py`. A test fails until every `StateEvent` is
sorted into that list or `IGNORED_EVENTS`.
- A new intent, `ToggleMuteRequested`, handled by a reaction. Any volume change
(`SetVolumeRequested`, `VolumeChangeRequested`, the step intent below) also unmutes;
that is what every TV remote does.
- `POST /api/mute/toggle`. The web's Shift+M and the mute button call it. Delete
`UNMUTE_PERCENT` and `onMute`'s volume arithmetic.
- `LircService`: `KEY_MUTE` emits `ToggleMuteRequested`. Delete `_pre_mute_volume` and
`_toggle_mute`.
- The state snapshot (`PlayerStateOut`, `web/src/api/types.ts`) gets `muted`. The volume
bars show muted distinctly instead of showing volume 0.
- MQTT is out of scope. Its volume number stays absolute, and setting it unmutes via
the rule above.
### 3. Three volume steps
- Config: replace `general.volume_increment` with
`general.volume_steps: {keyboard: int, knob: int, remote: int}`, all in **device
units** like `min_volume` / `max_volume`, each `ge=1, le=100`. Defaults: `keyboard: 10`,
`knob: 5`, `remote: 5` (today's behaviour for knob and remote).
- `volume_increment` in an existing `config.yml` must fail with a message naming the
replacement ("replaced by volume_steps.knob / .remote / .keyboard"). Config
validation rejects unknown keys anyway; make the message specific rather than the
generic "unknown option".
- One intent: `VolumeStepRequested(direction: Literal[-1, 1], count: int = 1,
input: Literal["keyboard", "knob", "remote"])`. One reaction multiplies by the
configured step and calls `change_volume`.
- `rotary_turned` emits it with `input="knob"`, `count=abs(event.increment)`.
- `LircService` volume keys emit it with `input="remote"`.
- `POST /api/volume` takes `{"steps": n, "input": "keyboard" | "remote"}` instead of
bare `steps`. J/K send `input: "keyboard"`. The on-screen −/+ buttons keep taking
the remote's step, as 1254790 deliberately chose, so they send `input: "remote"`.
- `{"percent": n}` stays for the volume bars.
- Parent mode: `SettingsIn` / `SettingsOut` (`services/web/schemas.py`),
`read_settings` / `write_settings` (`services/web/settings.py`, writing the nested
mapping), and `web/src/components/ParentPanel.tsx` get three fields, labelled in
German (for example "Lautstärke-Schritt: Tastatur (J/K)", "… Drehknopf",
"… Fernbedienung"). They replace the single "rotary step". Saving applies live, like
the other settings.
- Update `python-backend/config.yml.example`, the route table in
`python-backend/README.md`, `web/README.md`'s key table, and `HelpOverlay.tsx`.
## Tests
- Reactions (`tests/test_reactions.py`, through the simulator):
- Next/previous on a podcast episode seeks ±30 s and clamps at both ends. Use a
`Kinderpodcasts` folder in the test library (see `tests/conftest.py`).
- Next/previous on music still changes track.
- Mute then unmute restores the exact volume, including with `min_volume > 0` (the
regression).
- A volume change while muted unmutes.
- Each of the three inputs moves by its own configured step.
- `tests/test_lirc.py`: the mute key and the volume keys emit the new intents.
- `tests/test_web.py`: `/api/mute/toggle`; `/api/volume` with `input`; the settings
round-trip for `volume_steps`, which keeps the file's comments.
- `tests/test_config.py`: the defaults, and the specific error for `volume_increment`.
- `web/src/lib/__tests__/keyboard.test.ts`: J/K produce the step action, not a
percentage.
## Done when
`scripts/check.sh` passes, `grep -rn "PODCAST_SKIP\|UNMUTE_PERCENT\|volume_increment"`
finds nothing in `web/src` and `python-backend/musicmouse` apart from the config error
that names the old key, and in `--simulate` a
podcast skips 30 s from the web, from `press right` in the REPL, and from a simulated
remote key.

View File

@@ -0,0 +1,75 @@
# Task: stop the firmware garbling its own messages to the host
## Why
`sendMessageToHost` (`esp-firmware/src/Messages.h`) writes one frame as four separate
`Serial.write` calls: magic, type, size, payload. It runs on two FreeRTOS tasks at once:
- the Arduino `loop()` task, for touch, rotary encoder and button events
(`esp-firmware/src/main.cpp`);
- the RC522 reader's own task (`rc522_task` in `esp-firmware/lib/nfc/rc522.c`, priority
4), which calls `tagHandler` → `sendMessageToHost(MsgRfidTokenRead{...})` directly.
When a tag read coincides with a button or touch event, the two frames interleave on
the wire. The host's decoder (`python-backend/musicmouse/devices/wire.py`,
`FrameDecoder`) resynchronises on the magic token and drops what it cannot parse. So
the symptom is an occasionally **lost figure placement/removal or button press**, with a
"Discarding bad frame from firmware" warning in the backend log.
## What the owner decided
Fix it in whatever way keeps the firmware simplest. A mutex was the first idea. **Use
the queue below instead, unless it turns out not to work**, because it is simpler:
- With a queue, every frame and every log line is written from `loop()` alone, so no
lock is needed anywhere, including around the `Serial.println` log lines that could
otherwise land in the middle of a frame.
- It is exactly the pattern `main.cpp` already uses for the rotary encoder
(`eventQueueRotaryEncoder`, drained in `handleRotaryEncoder()` with
`xQueueReceive(..., 0)`).
A mutex would need wrapping every `Serial.write` / `Serial.print` in the firmware to be
complete. That is more code and easier to get wrong later.
## Design
In `esp-firmware/src/main.cpp`:
1. Create a FreeRTOS queue for tag reads in `setupRfidReader()`, **before**
`rc522_start`, so the callback can never run without it. Item type: the 5-byte tag id
(`MsgRfidTokenRead` itself is fine, it is a packed 5-byte struct). Depth: a handful
(4) is plenty; the reader polls every 125 ms.
2. `tagHandler` (runs on the RC522 task) only fills a `MsgRfidTokenRead`, all zeros
for "removed" exactly as now, and `xQueueSend(queue, &msg, 0)`. If the queue is full,
drop the read; the reader reports the state again on the next change. Nothing in
`tagHandler` touches `Serial` any more.
3. Add `handleRfid()`, called from `loop()` next to `handleRotaryEncoder()`. It drains
the queue with `xQueueReceive(..., 0)` and calls `sendMessageToHost` for each item.
4. Leave `Messages.h` alone. With a single writer, the four `Serial.write` calls are
fine as they are.
Do not change the wire format, message ids or the host. `python-backend/tests/test_wire.py`
parses `Messages.h` and pins the ids, and it must keep passing.
Out of scope: the touch thresholds, debouncing, anything in `lib/`.
## Checking it
There are no firmware unit tests that reach `main.cpp` (the `native` env only builds the
LED effects), and this machine may not have PlatformIO. So:
- It must build: `pio run -e esp32` in `esp-firmware/`. If PlatformIO is not
available, say so in the hand-off instead of claiming it builds.
- `pio run -e native` still builds `src/host_test.cpp`. That env excludes `main.cpp`, so
this only guards against accidental changes to shared headers.
- `scripts/check.sh backend` still passes (`test_wire.py`).
- Manual test on the device, with the backend running at `--log-level DEBUG`. Hold a
hand on an ear touch area while repeatedly placing and removing a figure for a
minute, and turn the knob while doing it. Expect every placement and removal to show
up as `RfidTokenRead`, and **no** "Discarding bad frame from firmware" warnings.
Before the fix, the same exercise should produce some.
## Done when
The firmware builds, the host tests pass, `tagHandler` contains no `Serial` call, and the
manual test shows no discarded frames.

View File

@@ -0,0 +1,89 @@
# Task: make "podcast" a kind of album
## Why
`AlbumKind` is `Literal["music", "book"]` (`python-backend/musicmouse/library/sections.py`),
and the `Kinderpodcasts` shelf is declared `kind="book"`. So "is this a podcast?" is
answered differently in each layer:
- web: `album.section === "Kinderpodcasts"` (`web/src/lib/search.ts`, `groupOf`), and
everything else goes through `groupOf(album) === "podcasts"` (App, PlayView,
BrowseView, MobileBrowse, keyboard.ts, remote.ts, ambience.ts, useTrackDetail);
- backend: `SECTIONS[album.section].album_unit == "episode"` (`MusicLibrary.latest_episode`,
`tippen/rewards.py`);
- typing game: reward kinds `"episode"` and `"podcast_episode"`
(`services/web/tippen_api.py`).
The shelf's folder name is therefore a de-facto API string the front-end depends on.
`todo.md` already asks for a cleaner podcast data model.
## What the owner decided
Add `"podcast"` to the album kind. Podcast-ness is then a property of the album, carried
in the index and the API, and nobody compares section names.
## Design
### Backend
- `AlbumKind = Literal["music", "book", "podcast"]`. `SECTIONS["Kinderpodcasts"]` gets
`kind="podcast"`.
- `Section.album_unit` stays. It is the scanner's structural fact (one album per file),
not a classification. Keep using it in the scanner (`scan_library`) and in
`podcast_feeds.find_feed_shows`. Every place that asks "is this album a podcast?"
switches to `album.kind == "podcast"`:
- `MusicLibrary.latest_episode`;
- `tippen/rewards.py` (`_chronological_episodes`, and the `album_unit == "folder"`
branches that really mean "not a podcast");
- `tippen_api._animation_kind`;
- the `is_podcast` helper, if task 04 added one: change its body, keep its callers.
- `scanner.scan_album` derives `series` only for `kind == "book"`. Check that episode
albums still get `series = folder.name` (they do, in `scan_episodes`) and that
nothing else branches on `"book"` meaning "book or podcast".
`grep -rn '"book"' python-backend/musicmouse` and decide per hit.
- `FigureConfig.kind` stays `Literal["music", "book"]`. A figurine holding a podcast is
not a thing. Type `Config.figure_kinds` accordingly.
- `cache._album_from_json` validates the kind: accept `"podcast"`. Bump
`_INDEX_VERSION` (with a comment line, like the existing ones), so every cached
podcast album is re-scanned with the new kind instead of being read back as
`"book"`.
- `_ANALYZED_KINDS` stays `("music",)`.
- `services/web/schemas.py`: `AlbumOut.kind` and `PlayerStateOut.kind` become the
`AlbumKind` literal instead of `str`, so the OpenAPI schema says what the values are.
### Web
- `src/api/types.ts`: `AlbumKind = "music" | "book" | "podcast"`.
- `src/lib/search.ts`, `groupOf`: map from `kind` alone (`podcast` → `"podcasts"`,
`book` → `"audiobooks"`, else `"music"`). No `section` comparison may remain
anywhere in `web/src`.
- `src/lib/covers.ts`: `isBook` and `aspectOfKind` treat `"book"` specially. Podcasts
are drawn like books today *because* they were `kind: "book"`. Keep them looking
exactly as they do now: decide explicitly whether `isBook` / `aspectOfKind` include
`"podcast"`, and check BrowseView's `mostlyBooks` / `allPodcasts` tiles and the play
view. The result must be visually unchanged; compare screenshots of the podcast
shelf, a podcast category and the podcast play view before and after.
- Anything else that reads `state.kind` or `album.kind`: grep and decide per hit.
### Leave alone
The `section` field stays in the API; it is still useful for display and debugging.
Don't rename the shelves, the folders or the `Group` names.
## Tests
- `tests/test_library.py`: a `Kinderpodcasts` episode scans as `kind == "podcast"`; a
cache written at the previous `_INDEX_VERSION` is discarded; `latest_episode` still
works.
- `tests/test_tippen_rewards.py` / `test_tippen_api.py`: episode rewards still resolve
and still animate as `podcast_episode`.
- `tests/test_web.py`: `/api/library` reports `"podcast"`.
- `web/src/lib/__tests__/search.test.ts`: `groupOf` uses `kind`; an album whose section
says `Kinderpodcasts` but whose kind is `music` is music. That proves nothing compares
the section any more.
## Done when
`scripts/check.sh` passes, `grep -rn "Kinderpodcasts" web/src` finds nothing, the
backend's "is it a podcast" checks all read `album.kind`, and the podcast UI looks the
same as before.

View File

@@ -0,0 +1,222 @@
# Task: eight independent cleanups
Each section below is a separate change: do them as separate commits, in any order, and
run `scripts/check.sh` after each. Each comes from a code review of commit `a55cc4f`;
the "why" says what is wrong and, where known, which commit introduced it. Do not
change behaviour beyond what a section asks for.
---
## A. Races in library updates and concurrent rescans (review item 7)
**Why.** `MusicLibrary.refresh()` and `MusicLibrary._publish_analysis()`
(`python-backend/musicmouse/library/__init__.py`) both do
`self._entries = await asyncio.to_thread(...)` from a snapshot taken before the await.
Whichever finishes last wins, so an analysis batch that started before a rescan
finished can overwrite the freshly scanned index (a new podcast episode disappears
until the next rescan). Both also write `index.json` from worker threads at the same
time. Rescans are not serialised either: `POST /api/library/refresh` fires an
unretained `asyncio.create_task` (`# noqa: RUF006` in `services/web/api.py`), and the
podcast poller rescans on its own (`App.rescan_library`, introduced with the poller in
57afc32).
Separately, `VlcPlayer` (`devices/player.py`) mutates `_playing`, `_index` and
`_playlist_changed` both from libVLC's callback thread (`_on_playing`, `_on_stopped`,
`_on_playlist_end`, `_on_next_item`) and from the event loop (`_poll`, the actions).
**Do.**
- One `asyncio.Lock` in `MusicLibrary` held around every read-modify-write of
`_entries` plus its `store_index`: the whole of `refresh()`, and the fold-swap-store
in `_publish_analysis()`. The analysis workers keep running while a scan holds the
lock; only publishing waits.
- `App.rescan_library` coalesces. While one rescan runs, further calls wait for the
running one plus at most one follow-up, not N parallel scans. Keep a reference to the
task the refresh endpoint starts, and drop the `noqa`.
- `VlcPlayer`'s libVLC callbacks do nothing but hop onto the loop (the bus already
knows the loop; give `PlayerBase` a small `_on_loop(fn)` using
`loop.call_soon_threadsafe`). All state mutation then happens on the loop thread.
Update the comment above the callbacks.
**Test.** A library test with a scan made artificially slow (an injected scanner or a
patched `scan_library`) and an analysis publish racing it. The new album must survive.
Two concurrent `rescan_library` calls run the scanner at most twice.
---
## B. Library cache invalidation gaps (review item 8)
**Why.** `scan_library` (`library/scanner.py`) reuses a cached folder album whenever
its *audio files'* names, sizes and mtimes are unchanged (`Fingerprint`). So:
1. changing a figure's `kind:` in `config.yml` has no effect until the figure's files
change: the cached album keeps the old kind;
2. a `cover.jpg` (or any of `_COVER_NAMES`) added to an album folder later is never
picked up. Podcast episodes include their sidecar covers in the fingerprint; folder
albums do not;
3. covers of albums that no longer exist (deleted folders, episodes pruned by the
podcast poller) stay in `cache/covers/` forever.
**Do.**
- Folder albums: include the existing `_COVER_NAMES` files in the fingerprint, like
`scan_episodes` does for sidecars.
- Figure albums: reuse a cached entry only if its `kind` equals the configured one.
- After a scan, delete `covers/<id>.jpg` and `covers/<id>.thumb.jpg` for ids not in
the new index. Do **not** garbage-collect `analysis/`: it is expensive, content-keyed
and documented as safe to keep.
- Bump `_INDEX_VERSION` with a comment line, because the fingerprint shape changes.
**Test.** One test per gap in `tests/test_library.py`.
---
## C. The LED "off" animation is sent twice when a figure is removed (review item 11)
**Why.** Removing a figure makes `reactions/lighting.figure_placed_or_removed` run
`off_animation`. The player pausing then emits `PlaybackChanged(playing=False)`, and
`web_playback`, whose guard is "no figure on the reader", runs `off_animation` again.
That makes 6 LED writes instead of 3 and restarts the reverse swipe halfway through.
`web_playback` came in with the web front-end (edb6e5e).
**Do.** Make `off_animation` idempotent: skip a zone whose current effect
(`app.mouse.effect(zone)`) is already the off effect it would send, and skip the button
brightness when it is already 0. That keeps both reactions simple and covers any future
third caller.
**Test.** In `tests/test_reactions.py`: removing a figure while playing produces
exactly three `LedEffectChanged` events, one per zone.
---
## D. `config.yml` has no single owner (review item 13)
**Why.**
- `PUT /api/settings` (`services/web/api.py`) applies values with `setattr` on the
pydantic config objects. That skips validation, so the handler re-implements the
min/max/initial volume checks by hand.
- `PUT /api/remote/mapping` replaces `app.config.remote`.
- Both endpoints read, modify and write `config.yml` from worker threads
(`write_settings`, `write_mapping`) with no lock, so two saves at once can lose one.
- Memory is updated *before* the file is written, so a failed write leaves the running
config and the file disagreeing.
**Do.** One small `ConfigStore` (for example `musicmouse/config_store.py`) owns the path,
the in-memory `Config` and a lock. An update:
1. validates the merged values through the real pydantic models, so the
`_check_volumes` validator runs; no duplicated checks in the endpoint;
2. writes the file (round-trip, comments kept, via the existing `load_document` /
`atomic_write`);
3. only then applies the values to the live objects.
Objects that read the config live (`ScreenService` reads `auto_off_minutes`, the player's
volume limits) must keep seeing changes: either keep mutating the same objects after
validation, or have them read through the store. Pick one and say which in the
docstring. The settings and remote-mapping endpoints both go through the store. If
`docs/agent-tasks/01-web-authentication.md` has landed, its `--set-password` command
should use it too.
**Test.** Invalid settings are rejected by the model's own validator, with an
unchanged file and unchanged memory. Two concurrent saves (settings and mapping) both
end up in the file.
---
## E. Generate the front-end's API types (review item 15)
**Why.** `web/src/api/types.ts` (~300 lines) hand-mirrors
`python-backend/musicmouse/services/web/schemas.py`. FastAPI already describes every
route and model at `/api/openapi.json`. (A third copy lived in the Slint front-end,
which has been removed.)
**Do.**
- A backend test (for example `tests/test_openapi.py`) builds the app the way
`tests/test_web.py` does and compares `api.openapi()` with a committed
`web/src/api/openapi.json`. With `UPDATE_OPENAPI=1` it rewrites the file instead. The
test fails whenever the API changes and the snapshot was not refreshed.
- `web`: add `openapi-typescript` as a dev dependency and an `npm run gen:api` script
that generates `src/api/schema.d.ts` from that JSON. Make `npm run check` fail when
the generated file is stale: use the tool's own check mode if it has one (verify),
otherwise regenerate to a temp file and diff.
- `types.ts` keeps its exported names (`Album`, `PlayerState`, ...) as aliases of
`components["schemas"][...]`, so no component changes. Move the useful doc comments
onto the pydantic fields (`Field(description=...)`), where they reach both sides.
- The websocket messages (`ServerMessage`) are not in OpenAPI. Keep them hand-written
in `types.ts` with a comment saying so.
---
## F. Copy-pasted helpers (review item 16)
**Why.**
- `ScreenService.blubb` (`services/screen.py`) is a line-for-line copy of
`sound.play_wav`. `sound.py` was extracted in a55cc4f, but the screen service was
never moved onto it.
- The "did a person do this?" filter (an `IntentEvent`, not `LedEffectRequested`, not
`source == "system"`) is duplicated in `ScreenService._on_event` and
`TimerService._on_event`.
- There are five atomic-write helpers: `library/cache._write_atomic`,
`services/web/settings.atomic_write`, `tippen/progress.save_progress`,
`school_days.save_calendar` and `library/podcast_feeds._save_failed`. Three of them
name the temp file `<name>.tmp<pid>`, which collides when two threads of the same
process write the same file. That really happens: settings and remote-mapping saves
both run in `asyncio.to_thread`.
**Do.**
- `ScreenService.blubb` calls `play_wav(self.alsa_device, wav, timeout=self.config.timeout)`.
- `is_person(event: Event) -> bool` next to the event classes in `events.py`, used by
both services (keep each service's own extra exclusions).
- One `write_atomic(path, text)` (for example `musicmouse/fileio.py`) using
`tempfile.NamedTemporaryFile(dir=path.parent, delete=False)` and `os.replace`, used by
all five. Keep the temp file a dotfile where it sits in the music library, so the
scanner never picks it up. The YAML writer dumps to a string first.
**Test.** The existing tests cover behaviour. Add one for `write_atomic`, two threads
writing the same path at once, which today can fail.
---
## G. Inject time everywhere (review item 17)
**Why.** The backend's rule (see `musicmouse/clock.py`) is that anything that waits takes a
`Clock`, and `TimerService` takes a `wall` callable for the date. Exceptions:
- `PodcastFeedService.run` and `StateHub.run` call `asyncio.sleep` directly;
- `services/web/state.clock_state` uses `date.today()`;
- `POST /api/timer` (`services/web/api.py`) uses `datetime.now()`.
**Do.** Give `PodcastFeedService` and `StateHub` a `clock`, as the other services have,
wired in `__main__._build_services` / `WebService`. Put one `wall: Callable[[],
datetime]` on `App` (default `datetime.now`) and use it in `TimerService`,
`clock_state` and the timer endpoint, so a test can pin "today" in one place.
**Test.** The podcast service test runs on `FakeClock` instead of real sleeps; a
clock-state test pins the date through `App.wall`.
---
## H. Break up the largest files (review item 19)
**Why.** Every feature since the rearchitecture was added to the same few files.
- `web/src/App.tsx` is 909 lines with ~40 hooks, routes pages via flags, and
defines `ToolButton`, `CornerButton`, `ConnectionDot`, `Splash` and the assign toast
inline.
- `BrowseView.tsx` is 932 lines.
- About 300 static `style={{...}}` objects sit next to five CSS files.
- `services/web/api.py` is one router for seven features: library, state, playback,
parent mode, typing game, remote, Home Assistant.
- One sign of insertion rather than placement: the comment
`/** The one place a keyboard action, a click or a tap all end up. */` in `App.tsx`
sits above `saveMapping`, but describes `run`.
**Do** (mechanical moves only, no behaviour change; one commit per bullet):
- `api.py`: one router module per feature under `services/web/routes/`, assembled in
`build_router`. URLs unchanged.
- `App.tsx`: move the four small components into `components/`, the remote-mapping
state into `hooks/useRemoteMapping.ts`, and the kiosk listeners (screen activity,
silencing a ringing timer) into `hooks/useKioskActivity.ts`. Put the misplaced
comment back above `run`.
- `BrowseView.tsx`: split by screen (root shelves, category, results), whichever seams
the file already has.
- Inline styles: move **static** style objects in `App.tsx`, `BrowseView.tsx` and
`ParentPanel.tsx` into classes in the existing CSS files. Leave dynamic ones (colours
from album data, computed sizes) inline.
Check visually in the dev server, at desktop, phone (`usePhoneLayout`) and `?pi=1`
sizes, that nothing moved.

View File

@@ -0,0 +1,20 @@
# Agent tasks
Self-contained instructions for fixes that came out of a code review of commit `a55cc4f`.
Each file says why, what the owner decided, the design, the tests, and when it is done.
The numbers are the review's item numbers.
| File | What |
|---|---|
| `01-web-authentication.md` | Password login, once per browser for a year, in front of the whole `/api` (including the Home Assistant proxy). |
| `04-one-input-policy.md` | Podcast next/previous seeks 30 s from every input; mute restores the previous volume; three separate volume steps in parent mode. |
| `05-firmware-serial-frames.md` | Stop RFID frames interleaving with button frames on the serial link. |
| `06-podcast-kind.md` | `"podcast"` as an album kind instead of comparing the `Kinderpodcasts` folder name. |
| `07-backend-and-frontend-cleanups.md` | Eight independent cleanups: library races, cache invalidation, double LED off-animation, a config owner, generated API types, shared helpers, injected time, splitting the largest files. |
Order: `06` before `04` (04's podcast check uses 06's kind; it says how to proceed if
06 has not landed). `07 D` is easier after `01`. The rest are independent.
For every task: one commit per logical change, `scripts/check.sh` passing before each
commit, no pushing. When a task is done, delete its file in the same commit, so this
folder only ever lists open work.

View File

@@ -2,93 +2,72 @@
#include "drivers/Esp32DriverRGBW.h" #include "drivers/Esp32DriverRGBW.h"
#include <driver/gpio.h> #include <driver/rmt_encoder.h>
#include <driver/rmt.h> #include <esp_check.h>
// Timing constants // Timing constants, in ns
static constexpr uint16_t DIVIDER = 4; static constexpr uint32_t RESOLUTION_HZ = 20 * 1000 * 1000; // one RMT tick = 50 ns
static constexpr double RMT_DURATION_NS = 12.5; // Minimum time of a single RMT duration based on clock ns static constexpr uint32_t TICK_NS = 1000 * 1000 * 1000 / RESOLUTION_HZ;
static constexpr uint32_t T0H = 300; static constexpr uint32_t T0H = 300;
static constexpr uint32_t T0L = 900; static constexpr uint32_t T0L = 900;
static constexpr uint32_t T1H = 600; static constexpr uint32_t T1H = 600;
static constexpr uint32_t T1L = 600; static constexpr uint32_t T1L = 600;
static constexpr uint32_t TRS = 80000;
static constexpr rmt_item32_t bit0Data = {uint32_t(T0H / (RMT_DURATION_NS * DIVIDER)), 1, uint32_t(T0L / (RMT_DURATION_NS * DIVIDER)), 0};
static constexpr rmt_item32_t bit1Data = {uint32_t(T1H / (RMT_DURATION_NS * DIVIDER)), 1, uint32_t(T1L / (RMT_DURATION_NS * DIVIDER)), 0};
// Function registered at the RMT driver that converts regular uint8_t values containing r,g,b,w values static rmt_symbol_word_t symbol(uint32_t highNs, uint32_t lowNs)
// to rmt_item32_t for each bit. (8 bit -> 32 * 8 bit)
static void IRAM_ATTR uint8ToRmtAdaptor(const void *src, rmt_item32_t *dest, size_t srcSize,
size_t wantedNum, size_t *translatedSize, size_t *itemNum)
{ {
if (src == NULL || dest == NULL) rmt_symbol_word_t s = {};
{ s.duration0 = highNs / TICK_NS;
*translatedSize = 0; s.level0 = 1;
*itemNum = 0; s.duration1 = lowNs / TICK_NS;
return; s.level1 = 0;
} return s;
size_t size = 0;
size_t num = 0;
uint8_t *psrc = (uint8_t *)src;
rmt_item32_t *pdest = dest;
while (size < srcSize && num < wantedNum)
{
for (int i = 0; i < 8; i++)
{
// MSB first
const bool isBitSet = *psrc & (1 << (7 - i));
pdest->val = isBitSet ? bit1Data.val : bit0Data.val;
num++;
pdest++;
}
size++;
psrc++;
}
*translatedSize = size;
*itemNum = num;
} }
void Esp32DriverRGBW::begin(int gpio, int rmtChannel) void Esp32DriverRGBW::begin(int gpio)
{ {
rmtChannel_ = rmtChannel;
transmitting_ = false; transmitting_ = false;
rmt_config_t rmt_tx; rmt_tx_channel_config_t channelConfig = {};
rmt_tx.channel = static_cast<rmt_channel_t>(rmtChannel); channelConfig.gpio_num = static_cast<gpio_num_t>(gpio);
rmt_tx.gpio_num = static_cast<gpio_num_t>(gpio); channelConfig.clk_src = RMT_CLK_SRC_DEFAULT;
rmt_tx.rmt_mode = RMT_MODE_TX; channelConfig.resolution_hz = RESOLUTION_HZ;
rmt_tx.mem_block_num = 1; channelConfig.mem_block_symbols = 64; // one block, refilled from the interrupt
rmt_tx.clk_div = DIVIDER; channelConfig.trans_queue_depth = 4;
rmt_tx.tx_config.loop_en = false; ESP_ERROR_CHECK(rmt_new_tx_channel(&channelConfig, &channel_));
rmt_tx.tx_config.carrier_level = RMT_CARRIER_LEVEL_LOW;
rmt_tx.tx_config.carrier_en = false;
rmt_tx.tx_config.idle_level = RMT_IDLE_LEVEL_LOW;
rmt_tx.tx_config.idle_output_en = true;
ESP_ERROR_CHECK(rmt_config(&rmt_tx)); // Every byte goes out MSB first, one RMT symbol per bit - the strip's own byte order
ESP_ERROR_CHECK(rmt_driver_install(rmt_tx.channel, 0, 0)); // (g, r, b, w) is already how LedStripRGBW lays the data out in memory.
rmt_bytes_encoder_config_t encoderConfig = {};
encoderConfig.bit0 = symbol(T0H, T0L);
encoderConfig.bit1 = symbol(T1H, T1L);
encoderConfig.flags.msb_first = 1;
ESP_ERROR_CHECK(rmt_new_bytes_encoder(&encoderConfig, &encoder_));
rmt_translator_init((rmt_channel_t)rmtChannel, uint8ToRmtAdaptor); ESP_ERROR_CHECK(rmt_enable(channel_));
} }
void Esp32DriverRGBW::end() void Esp32DriverRGBW::end()
{ {
ESP_ERROR_CHECK(rmt_driver_uninstall((rmt_channel_t)rmtChannel_)); waitForTransmissionToFinish();
ESP_ERROR_CHECK(rmt_disable(channel_));
ESP_ERROR_CHECK(rmt_del_encoder(encoder_));
ESP_ERROR_CHECK(rmt_del_channel(channel_));
encoder_ = nullptr;
channel_ = nullptr;
} }
void Esp32DriverRGBW::writeSync(const uint32_t *rgbwData, int numLeds) void Esp32DriverRGBW::writeSync(const uint32_t *rgbwData, int numLeds)
{ {
writeAsync(rgbwData, numLeds);
waitForTransmissionToFinish(); waitForTransmissionToFinish();
auto data = reinterpret_cast<const uint8_t *>(rgbwData);
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, true));
} }
void Esp32DriverRGBW::writeAsync(const uint32_t *rgbwData, int numLeds) void Esp32DriverRGBW::writeAsync(const uint32_t *rgbwData, int numLeds)
{ {
waitForTransmissionToFinish(); waitForTransmissionToFinish();
auto data = reinterpret_cast<const uint8_t *>(rgbwData); rmt_transmit_config_t transmitConfig = {};
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, false)); transmitConfig.loop_count = 0;
ESP_ERROR_CHECK(rmt_transmit(channel_, encoder_, rgbwData, numLeds * 4, &transmitConfig));
transmitting_ = true; transmitting_ = true;
} }
@@ -96,8 +75,7 @@ bool Esp32DriverRGBW::waitForTransmissionToFinish(int waitMs)
{ {
if (!transmitting_) if (!transmitting_)
return true; return true;
auto ret = rmt_wait_tx_done((rmt_channel_t)rmtChannel_, waitMs / portTICK_PERIOD_MS); if (rmt_tx_wait_all_done(channel_, waitMs) == ESP_OK)
if (ret == ESP_OK)
{ {
transmitting_ = false; transmitting_ = false;
return true; return true;

View File

@@ -4,10 +4,13 @@
#include "containers/LedStripRGBW.h" #include "containers/LedStripRGBW.h"
#include <driver/rmt_tx.h>
class Esp32DriverRGBW class Esp32DriverRGBW
{ {
public: public:
void begin(int gpio, int rmtChannel); // The RMT channel is picked by the driver; only the pin is ours to choose.
void begin(int gpio);
void end(); void end();
void writeSync(const uint32_t *rgbwData, int numLeds); void writeSync(const uint32_t *rgbwData, int numLeds);
@@ -16,8 +19,9 @@ public:
bool waitForTransmissionToFinish(int waitMs = 1000); bool waitForTransmissionToFinish(int waitMs = 1000);
private: private:
int rmtChannel_; rmt_channel_handle_t channel_ = nullptr;
bool transmitting_; rmt_encoder_handle_t encoder_ = nullptr;
bool transmitting_ = false;
}; };
#endif #endif

View File

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

View File

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

View File

@@ -220,12 +220,12 @@ esp_err_t rotary_encoder_init(rotary_encoder_info_t * info, gpio_num_t pin_a, gp
info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET; info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET;
// configure GPIOs // configure GPIOs
gpio_pad_select_gpio(info->pin_a); gpio_reset_pin(info->pin_a);
gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY); gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY);
gpio_set_direction(info->pin_a, GPIO_MODE_INPUT); gpio_set_direction(info->pin_a, GPIO_MODE_INPUT);
gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE); gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE);
gpio_pad_select_gpio(info->pin_b); gpio_reset_pin(info->pin_b);
gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY); gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY);
gpio_set_direction(info->pin_b, GPIO_MODE_INPUT); gpio_set_direction(info->pin_b, GPIO_MODE_INPUT);
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE); gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);

View File

@@ -13,7 +13,9 @@ data_dir = data
default_envs = esp32 default_envs = esp32
[env:esp32] [env:esp32]
platform = espressif32 ; pioarduino's fork rather than PlatformIO's own espressif32 platform, which stays on
; Arduino core 2.x. Pinned to a release: 55.03.312-1 is Arduino core 3.3.12 on ESP-IDF 5.5.
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312-1/platform-espressif32.zip
board = esp-wrover-kit board = esp-wrover-kit
board_upload.flash_size = "4MB" board_upload.flash_size = "4MB"
build_flags = -DPLATFORM_ESP32 build_flags = -DPLATFORM_ESP32
@@ -21,14 +23,13 @@ framework = arduino
monitor_port = /dev/ttyUSB0 monitor_port = /dev/ttyUSB0
upload_port = /dev/ttyUSB0 upload_port = /dev/ttyUSB0
monitor_speed = 115200 monitor_speed = 115200
src_filter = +<*> -<host_test.cpp> build_src_filter = +<*> -<host_test.cpp>
lib_deps = lib_deps =
miguelbalboa/MFRC522 bxparks/AceButton@1.10.1
bxparks/AceButton@^1.9.1
[env:native] [env:native]
platform = native platform = native@1.2.1
src_filter = +<*> -<main.cpp> build_src_filter = +<*> -<main.cpp>
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
lib_deps = lib_deps =
bxparks/AceButton@^1.9.1 bxparks/AceButton@1.10.1

View File

@@ -4,6 +4,7 @@
#include "effects/ReverseSwipe.h" #include "effects/ReverseSwipe.h"
#include "effects/RandomTwoColorInterpolation.h" #include "effects/RandomTwoColorInterpolation.h"
#include "effects/SwipeAndChange.h" #include "effects/SwipeAndChange.h"
#include "effects/SegmentSelect.h"
#include "Arduino.h" #include "Arduino.h"
#include <cstdint> #include <cstdint>
@@ -120,6 +121,8 @@ enum class MessageHostToFw : uint8_t
PREV_BUTTON_LED = 21, PREV_BUTTON_LED = 21,
NEXT_BUTTON_LED = 22, NEXT_BUTTON_LED = 22,
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
}; };
template <> template <>
@@ -180,7 +183,7 @@ inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageH
} }
template <typename LedTask1, typename LedTask2, typename LedTaskShelf> template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledChannelLeft, uint8_t ledChannelRight) inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledPinLeft, uint8_t ledPinRight)
{ {
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t)) if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
return; return;
@@ -214,6 +217,7 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {} else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {} else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {} else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectSegmentSelectConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SEGMENT_SELECT, msgType, msgBuffer)) {}
// Mouse LEDs // Mouse LEDs
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {} else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
@@ -234,12 +238,12 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
else if (msgType == MessageHostToFw::PREV_BUTTON_LED) else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
{ {
float *val = reinterpret_cast<float *>(msgBuffer); float *val = reinterpret_cast<float *>(msgBuffer);
ledcWrite(ledChannelLeft, uint32_t(255 * (*val))); ledcWrite(ledPinLeft, uint32_t(255 * (*val)));
} }
else if (msgType == MessageHostToFw::NEXT_BUTTON_LED) else if (msgType == MessageHostToFw::NEXT_BUTTON_LED)
{ {
float *val = reinterpret_cast<float *>(msgBuffer); float *val = reinterpret_cast<float *>(msgBuffer);
ledcWrite(ledChannelRight, uint32_t(255 * (*val))); ledcWrite(ledPinRight, uint32_t(255 * (*val)));
} }
else else
Serial.println("Unknown message type"); Serial.println("Unknown message type");

View File

@@ -2,6 +2,7 @@
#include "effects/Circular.h" #include "effects/Circular.h"
#include "effects/Common.h" #include "effects/Common.h"
#include "effects/SegmentSelect.h"
#include "drivers/Esp32DriverRGBW.h" #include "drivers/Esp32DriverRGBW.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
@@ -82,6 +83,7 @@ void _led_task_func(void *params)
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {} else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::SEGMENT_SELECT >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
// clang-format on // clang-format on
timeoutMsForEffect = 0; timeoutMsForEffect = 0;

View File

@@ -3,6 +3,7 @@
#include "effects/AlexaSwipe.h" #include "effects/AlexaSwipe.h"
#include "effects/ReverseSwipe.h" #include "effects/ReverseSwipe.h"
#include "effects/SegmentSelect.h"
#include "helpers/ColorConversions.h" #include "helpers/ColorConversions.h"
#include <iostream> #include <iostream>
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
effectToFile("reverse_swipe.py", effect, strip, 200); effectToFile("reverse_swipe.py", effect, strip, 200);
} }
{
EffectSegmentSelectConfig cfg{};
cfg.count = 4;
cfg.selected = 0;
cfg.previous = 3;
cfg.gapLeds = 1;
cfg.colors[0] = ColorRGBW{255, 0, 0, 0};
cfg.colors[1] = ColorRGBW{255, 160, 0, 0};
cfg.colors[2] = ColorRGBW{0, 200, 0, 0};
cfg.colors[3] = ColorRGBW{0, 60, 255, 0};
cfg.offsetDegrees = 180;
cfg.direction = 1;
cfg.dimFactor = 0.15f;
cfg.wobbleAmplitude = 1.5f;
cfg.wobblePeriodMs = 1000;
cfg.slideMs = 200;
cfg.edgeBlend = 0.25f;
LedStripRGBW<51> strip;
EffectSegmentSelect<decltype(strip)> effect(cfg, strip);
effectToFile("segment_select.py", effect, strip, 200);
}
/* /*
effect.currentPosition_ = 150; effect.currentPosition_ = 150;

View File

@@ -1,7 +1,6 @@
#include <Arduino.h> #include <Arduino.h>
#include "rc522.h" #include "rc522.h"
#include "SPI.h" #include "SPI.h"
#include <MFRC522.h>
#include "rotary_encoder.h" #include "rotary_encoder.h"
#include "containers/LedStripRGBW.h" #include "containers/LedStripRGBW.h"
@@ -21,9 +20,6 @@
#include "TaskLed.h" #include "TaskLed.h"
// -------------------------------------------------- RFID Reader ---------------------------------------- // -------------------------------------------------- RFID Reader ----------------------------------------
MFRC522 rfid;
MFRC522::MIFARE_Key key;
void tagHandler(uint8_t *sn) void tagHandler(uint8_t *sn)
{ {
if (sn != nullptr) if (sn != nullptr)
@@ -117,11 +113,9 @@ void setupButtons()
buttonRight.setEventHandler(handleButtonEvent); buttonRight.setEventHandler(handleButtonEvent);
buttonRotary.setEventHandler(handleButtonEvent); buttonRotary.setEventHandler(handleButtonEvent);
ledcSetup(0, PWM_FREQ, PWM_RESOLUTION); // Arduino core 3 picks the LEDC channel itself; from here on the pin is the handle.
ledcAttachPin(BUTTON_LEFT_LED_PIN, 0); ledcAttach(BUTTON_LEFT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
ledcAttach(BUTTON_RIGHT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
ledcSetup(1, PWM_FREQ, PWM_RESOLUTION);
ledcAttachPin(BUTTON_RIGHT_LED_PIN, 1);
} }
void handleButtons() void handleButtons()
@@ -139,7 +133,7 @@ LedTask<decltype(ledStripCircle)> ledTaskCircle;
void setupLedCircle() void setupLedCircle()
{ {
ledDriverCircle.begin(22, 0); ledDriverCircle.begin(22);
ledTaskCircle.begin(ledStripCircle, ledDriverCircle); ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0})); ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
} }
@@ -152,7 +146,7 @@ LedTask<decltype(ledStripMouse)> ledTaskMouse;
void setupMouseLeds() void setupMouseLeds()
{ {
ledDriverMouse.begin(16, 1); ledDriverMouse.begin(16);
ledTaskMouse.begin(ledStripMouse, ledDriverMouse); ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0}); ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
} }
@@ -165,7 +159,7 @@ LedTask<decltype(ledStripShelf)> ledTaskShelf;
void setupShelfLeds() void setupShelfLeds()
{ {
ledDriverShelf.begin(17, 2); ledDriverShelf.begin(17);
ledTaskShelf.begin(ledStripShelf, ledDriverShelf); ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0}); ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
} }
@@ -234,7 +228,7 @@ void setup()
void loop() void loop()
{ {
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, 0, 1); handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, BUTTON_LEFT_LED_PIN, BUTTON_RIGHT_LED_PIN);
handleTouchInputs(); handleTouchInputs();
handleRotaryEncoder(); handleRotaryEncoder();
handleButtons(); handleButtons();

View File

@@ -1,64 +0,0 @@
#include <SPI.h>
#include <MFRC522.h>
#define RST_PIN 5 // Configurable, see typical pin layout above
#define SS_PIN 23 // Configurable, see typical pin layout above
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
//*****************************************************************************************//
void setup() {
Serial.begin(115200);
SPI.begin(22, 19, 18, 23); // Init SPI bus
mfrc522.PCD_Init(); // Init MFRC522 card
}
/**
* Helper routine to dump a byte array as hex values to Serial.
*/
void PrintHex(byte *buffer, byte bufferSize) {
for (byte i = 0; i < bufferSize; i++) {
Serial.print(buffer[i] < 0x10 ? " 0" : " ");
Serial.print(buffer[i], HEX);
}
}
//*****************************************************************************************//
void loop() {
// Look for new cards
if ( !mfrc522.PICC_IsNewCardPresent()) {
return;
}
if ( !mfrc522.PICC_ReadCardSerial()) {
return;
}
//mfrc522.PICC_DumpDetailsToSerial(&(mfrc522.uid)); //dump some details about the card
Serial.print("NewCard");
PrintHex(mfrc522.uid.uidByte, mfrc522.uid.size);
Serial.println("");
// Check if Card was removed
bool cardRemoved = false;
int counter = 0;
bool current, previous;
previous = !mfrc522.PICC_IsNewCardPresent();
while(!cardRemoved){
current =!mfrc522.PICC_IsNewCardPresent();
if (current && previous) counter++;
previous = current;
cardRemoved = (counter>2);
delay(50);
}
Serial.println("Card was removed");
delay(500); //change value if you want to read cards faster
mfrc522.PICC_HaltA();
mfrc522.PCD_StopCrypto1();
}

View File

@@ -1,4 +1,5 @@
config.yml config.yml
tippen-curriculum.yml tippen-curriculum.yml
tippen-curriculum-dino.yml
tippen-progress.json tippen-progress.json
/.musicmouse-cache /.musicmouse-cache

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

File diff suppressed because one or more lines are too long

View File

@@ -94,7 +94,10 @@ the prompt becomes a regression test by pasting it into a `.txt` file.
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. | | `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. | | `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. | | `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, and parent-mode settings. | | `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, parent-mode settings, the typing game and the Home Assistant proxy. |
| `musicmouse/services/lirc/` | The IR remote, as a lircd TCP client. |
| `musicmouse/services/` (`screen.py`, `timers.py`, `podcasts.py`) | The monitor's on/off and idle auto-off; the parents' timer and school-day calendar; the podcast feed poller. |
| `musicmouse/tippen/` | The typing game's curriculum, progress and track unlocks. |
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. | | `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. | | `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
@@ -106,6 +109,8 @@ ruff check .
mypy # --strict, configured in pyproject.toml mypy # --strict, configured in pyproject.toml
``` ```
`../scripts/check.sh` runs these three plus the web front-end's checks in one go.
`tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python `tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python
message ids drift from the firmware's - the contract is hand-duplicated in two message ids drift from the firmware's - the contract is hand-duplicated in two
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
@@ -133,7 +138,9 @@ tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
Two behaviours used to be hard-coded in `main.py` and now need automations: Two behaviours used to be hard-coded in `main.py` and now need automations:
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device **Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
trigger, action `light.toggle` on `light.kinderzimmer_fluter`. trigger, action `light.toggle` on `light.kinderzimmer_fluter`. While the LED ring's album
picker is open (a figure with several albums was just put down) the knob is the picker's:
its turns do not change the volume and its press is not published.
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and **Touching a body part set a colour** on `light.kinderzimmer_fluter` and
`light.music_mouse_regal_licht`: `light.music_mouse_regal_licht`:
@@ -156,7 +163,7 @@ the scanner has to know about:
| Folder | Shown as | Why it is special | | Folder | Shown as | Why it is special |
|---|---|---| |---|---|---|
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config | | `Figuren/<figure>/` | an album per figurine - or, for one whose folder holds album folders instead of audio, up to six, picked on the LED ring | the folder name *is* the figure name from the config; a sub-album is titled `Fuchs - <folder>` |
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 | | `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully | | `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show | | `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
@@ -182,8 +189,9 @@ different amounts to produce:
``` ```
index.json cheap: tags and structure. Rebuilt freely. index.json cheap: tags and structure. Rebuilt freely.
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame covers/<album_id>.jpg medium: the album's art, downscaled to 384 px
analysis/<track_key>.json expensive: reserved for offline audio analysis covers/<album_id>.thumb.jpg medium: the same at 256 px, for cards and rows
analysis/<track_key>.json expensive: track analysis (plus .beats.json, .curve.json)
``` ```
Deleting the whole directory is safe; deleting it throws away analysis that is minutes Deleting the whole directory is safe; deleting it throws away analysis that is minutes
@@ -195,13 +203,16 @@ change to how the scanner derives a title, artist or series is invisible until t
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
that logic. that logic.
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape **Track analysis** drives the web front-end's ambient background. It is optional: install
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with the `analysis` extra (`pip install -e '.[analysis]'`, librosa and numpy) and
the index, and a beat grid lives in its own file and is fetched per track - so an `musicmouse/library/librosa_analyzer.py` works through the music shelf in the
analyzer can be added later without touching the scanner, the API or the frontend. It background, in a pool of niced worker processes (`musicmouse/library/workers.py`), and
will go behind an optional dependency group, and because results are content-keyed pauses while anything is playing. Without the extra, `build_analyzer()` returns a no-op
files, they can equally well be computed on a workstation and the `analysis/` folder and the background stays static. Scalars (`tempo`, `energy`, `valence`, `brightness`,
copied to the device. `pulse`) travel inline with the index; the beat grid and per-second curves live in their
own files and are fetched only for the playing track. Results are content-keyed, so they
can be computed on a workstation and the `analysis/` folder copied to the device
(`scripts/sync-library.sh` does that).
## The web front-end ## The web front-end
@@ -212,12 +223,19 @@ copied to the device.
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. | | `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. | | `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. | | `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
| `GET /api/state` | Snapshot: what is playing, where, how loud. | | `GET /api/state` | Snapshot: what is playing, where, how loud; plus `clock` - today's school day, the bus, the parents' timer. |
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. | | `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. |
| `POST /api/play` | `{album_id, track_index?}` | | `POST /api/play` | `{album_id, track_index?}` |
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | | `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
| `POST /api/volume` | `{percent}` or `{delta_percent}` | | `POST /api/volume` | `{percent}` or `{steps}` - the IR remote's step (`volume_increment` device units) per step |
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
| `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |
| `GET` `PUT /api/settings` | parent mode | | `GET` `PUT /api/settings` | parent mode |
| `POST` `DELETE /api/timer` | parent mode's countdown: `{minutes}` or `{until: "HH:MM"}`; stop or silence it |
| `GET /api/tracks/{album_id}/{index}/analysis` | beat grid and mood curves of one track, or 404 |
| `GET /api/ha`, `GET /api/ha/states/{entity}`, `POST /api/ha/services/{domain}/{service}` | the room page's Home Assistant proxy; 404 without an `ha` section |
| `GET /api/tippen/curriculum` `/progress`, `PUT /api/tippen/settings`, `POST /api/tippen/runs` | the typing game; 404 without a `tippen` section |
Two decisions worth knowing: Two decisions worth knowing:

View File

@@ -10,7 +10,8 @@ general:
# The music collection. One path; the shelves underneath it are fixed names, not # The music collection. One path; the shelves underneath it are fixed names, not
# settings, because each one has its own quirks the code already knows about: # settings, because each one has its own quirks the code already knows about:
# #
# <root>/Figuren/<figure name>/ one folder per figurine # <root>/Figuren/<figure name>/ one folder per figurine - or, for a figure
# with several albums, one subfolder each
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist # <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character # <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first # <root>/Kinderpodcasts/<Show>/ shows, newest episode first
@@ -23,6 +24,12 @@ general:
# is rebuilt on the next start. Deleting it does throw away track analysis, which # is rebuilt on the next start. Deleting it does throw away track analysis, which
# is expensive to recompute. # is expensive to recompute.
cache: .musicmouse-cache cache: .musicmouse-cache
# How many tracks the background analyzer may work on at once, each in its own
# worker process. Omitted means one per core bar one (capped at 8), which is what
# turns a first-time pass over a whole library from an overnight job into a coffee
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
# a specific number to cap how much of it analysis may take.
# analysis_workers: 4
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal. # Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
# Required - use "simulate" to run without the mouse attached, which is a complete # Required - use "simulate" to run without the mouse attached, which is a complete
@@ -51,10 +58,44 @@ general:
# Backlight of the prev/next buttons while a figure is playing, 0..1. # Backlight of the prev/next buttons while a figure is playing, 0..1.
button_leds_brightness: 0.5 button_leds_brightness: 0.5
# Loudness of the UI sounds - the screen's on/off blubb, the parents' timer, the
# album picker's clicks - in percent of how loud they are made to sit under the
# music. They follow the music's volume, but never drop below what they are at 30 %
# of it. Editable in parent mode.
effects_volume: 100
# The LED ring's album picker, for a figure with several albums (see `figures:`).
# Temporary: for tuning on the device. Every value defaults to a constant in
# musicmouse/reactions/selection.py, which is where the right ones belong once found.
# figure_selection:
# timeout: 8.0 # seconds without a turn before the picker closes
# offset_degrees: 180 # where the first album's section starts on the ring
# ring_direction: 1 # 1 or -1: which way round the sections go
# knob_direction: 1 # 1 or -1: which way the knob turns to the next album
# gap_leds: 1 # dark LEDs between sections
# dim_factor: 0.15 # brightness of the sections not selected, 0..1
# wobble_amplitude: 1.5 # how far the selected section swings, in LEDs
# wobble_period_ms: 1000 # one swing back and forth
# slide_ms: 200 # glide from one section to the next
# edge_blend: 0.25 # 0..0.5: how far section edges shift to their neighbours' hue
# Which files count as music. Anything else - a podcast downloader's archive.json, # Which files count as music. Anything else - a podcast downloader's archive.json,
# a half-finished .tmp - is ignored. # a half-finished .tmp - is ignored.
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"] audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
# How many episodes of each podcast show to keep, newest first. A show that has
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
#
# The same number caps what gets downloaded, which is what makes the folder settle.
# Prune to the newest N but fetch everything the feed offers, and every poll would
# re-download the episodes the last one deleted.
#
# Lowering this DELETES the episodes that fall outside the window on the next poll,
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
# keep every episode and mind the free space yourself.
podcast_episode_limit: 50
# The web front-end. Omit the whole section to run without it. # The web front-end. Omit the whole section to run without it.
# #
# There is no authentication: this is a device on a home network. The settings panel # There is no authentication: this is a device on a home network. The settings panel
@@ -69,15 +110,42 @@ general:
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how # IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
# lircd itself is set up on the Pi). Omit the whole section to run without a remote. # lircd itself is set up on the Pi). Omit the whole section to run without a remote.
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control; # Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
# number keys 0-9 play whatever the "remote:" section below assigns them. # number keys 0-9 play whatever the "remote:" section below assigns them; Guide
# (KEY_GUIDE) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
# button that does nothing.
lirc: lirc:
host: "musicmouse-pi.local" host: "musicmouse-pi.local"
port: 2222 # this deployment's lircd listens on 2222, not its own port: 2222 # this deployment's lircd listens on 2222, not its own
# default of 8765 - see the ansible role # default of 8765 - see the ansible role
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
# remote, say) are ignored # remote, say) are ignored (case-insensitively)
reconnect_interval: 5.0 reconnect_interval: 5.0
# The attached monitor, switched by the remote's power key. Omit the section on a
# device without one and the key does nothing. The command runs on every press, split
# shell-style but not through a shell; it decides itself whether that means on or off
# (ansible's pi_kiosk installs kiosk-screen for this).
#
# With an off_command the screen also switches itself off once nothing has played and
# nobody has touched the remote, the web UI or the kiosk keyboard for auto_off_minutes
# (editable in parent mode; 0 = never). The next press after that runs on_command.
# screen:
# toggle_command: "/usr/local/bin/kiosk-screen toggle"
# off_command: "/usr/local/bin/kiosk-screen off"
# on_command: "/usr/local/bin/kiosk-screen on"
# auto_off_minutes: 15
# timeout: 5.0 # seconds before a hung command is killed
# The clock overlay (Pos1 on the kiosk keyboard, Guide on the remote). Optional; these
# are the defaults. On school days a bus countdown shows for bus_lead_minutes before
# bus_time. School days skip weekends and the school and public holidays of
# `subdivision`, fetched daily from openholidaysapi.org and cached under
# library.cache - a city code such as DE-BY-AU adds that city's own holidays.
# clock:
# subdivision: "DE-BY"
# bus_time: "07:25"
# bus_lead_minutes: 60
# Home Assistant integration. Omit the whole section to run without MQTT. # Home Assistant integration. Omit the whole section to run without MQTT.
# The backend exposes three lights, a player sensor, a volume slider, transport # The backend exposes three lights, a player sensor, a volume slider, transport
# buttons, device triggers for every button/touch area, and a tag scanner. # buttons, device triggers for every button/touch area, and a tag scanner.
@@ -129,6 +197,12 @@ general:
progress_file: tippen-progress.json progress_file: tippen-progress.json
# One entry per figurine. The key is the figure name and the subfolder name. # One entry per figurine. The key is the figure name and the subfolder name.
#
# A figure folder that holds audio itself plays that. One that holds album folders
# instead can play any of them, up to six: putting the figure down plays the one it
# played last (or the first, alphabetically), and the LED ring shows one coloured
# section per album for a few seconds. Turn the knob to move between them and press it
# to play the selected one; leave it alone and the music just carries on.
figures: figures:
fuchs: fuchs:
# RFID tag id, 5 bytes as hex. Must be unique across figures. # RFID tag id, 5 bytes as hex. Must be unique across figures.
@@ -139,7 +213,8 @@ figures:
# "music" (default) or "book". Every other shelf is named after what is on it, so # "music" (default) or "book". Every other shelf is named after what is on it, so
# its type is obvious; a figure folder is named after the figurine, so this is the # its type is obvious; a figure folder is named after the figurine, so this is the
# one thing that has to be said out loud. The web UI draws albums square and # one thing that has to be said out loud. The web UI draws albums square and
# audiobooks taller than wide, so getting it wrong is visible at a glance. # audiobooks taller than wide, so getting it wrong is visible at a glance. It holds
# for every album of a figure that has several.
kind: music kind: music
eule: eule:
@@ -151,8 +226,10 @@ figures:
# or any digit within it, for "unassigned" - a fresh install boots with none of this # or any digit within it, for "unassigned" - a fresh install boots with none of this
# and that is not an error. Editable from the web front-end, which writes back here. # and that is not an error. Editable from the web front-end, which writes back here.
# #
# target_kind: album -> always starts from the first track (music, audiobooks). # target_kind: album -> starts from the first track (music, audiobooks), or from
# target is an album id, as shown at GET /api/library. # track_index (0-based) when given - one song, followed by
# the rest of the album. target is an album id, as shown
# at GET /api/library.
# target_kind: series -> always plays the newest episode of a podcast show, resolved # target_kind: series -> always plays the newest episode of a podcast show, resolved
# fresh on every press - never a fixed episode. target is the # fresh on every press - never a fixed episode. target is the
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah". # show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
@@ -163,3 +240,7 @@ remote:
"2": "2":
target_kind: series target_kind: series
target: "Wissen macht Ah" target: "Wissen macht Ah"
"3":
target_kind: album
target: "3f9a0c12ab44"
track_index: 4

View File

@@ -19,7 +19,7 @@ import logging
import sys import sys
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TypeVar
import httpx2 import httpx2
@@ -32,14 +32,17 @@ from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.null_transport import NullTransport from musicmouse.devices.null_transport import NullTransport
from musicmouse.devices.player import Player, VlcPlayer from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink from musicmouse.devices.serial_link import SerialLink
from musicmouse.library import MusicLibrary from musicmouse.library import MusicLibrary, default_worker_count
from musicmouse.library.analysis import build_analyzer from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all from musicmouse.reactions import register_all
from musicmouse.services.base import Service from musicmouse.services.base import Service
from musicmouse.services.lirc import LircService from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService 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.services.web import WebService
from musicmouse.sound import ui_level
from musicmouse.tippen.curriculum import CurriculumError from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
@@ -283,11 +286,16 @@ def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) ->
async def build_library(config: Config) -> MusicLibrary: async def build_library(config: Config) -> MusicLibrary:
library_config = config.general.library library_config = config.general.library
workers = library_config.analysis_workers
return await MusicLibrary.build( return await MusicLibrary.build(
library_config.root, library_config.root,
library_config.cache, library_config.cache,
frozenset(config.general.audio_extensions), frozenset(config.general.audio_extensions),
analyzer=build_analyzer(), analyzer=build_analyzer(),
# Unset in the config means "use the machine": a first pass over an unanalyzed
# library is hours of DSP, and there is no reason for a desktop to do it one
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
analysis_workers=default_worker_count() if workers is None else workers,
figure_kinds=config.figure_kinds, figure_kinds=config.figure_kinds,
) )
@@ -308,9 +316,6 @@ def _build_app(
mouse=mouse, mouse=mouse,
player=player, player=player,
library=library, library=library,
# One source of truth: the figure path and the web path must hand the player the
# same Playlist object for the same folder, because `play_figure` resumes on an
# identity check.
playlists=library.figure_playlists(), playlists=library.figure_playlists(),
clock=clock, clock=clock,
tippen=tippen, tippen=tippen,
@@ -319,7 +324,10 @@ def _build_app(
return app return app
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None: T = TypeVar("T", bound=Service)
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
return next((s for s in services if isinstance(s, kind)), None) return next((s for s in services if isinstance(s, kind)), None)
@@ -363,6 +371,36 @@ def _build_services(
else: else:
services.append(LircService(app, lirc_config, clock=clock)) services.append(LircService(app, lirc_config, clock=clock))
screen_config = app.config.general.screen
if screen_config is not None:
general = app.config.general
services.append(
ScreenService(
app.bus,
screen_config,
alsa_device=None if general.audio_simulated else general.alsa_device,
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
is_playing=lambda: app.player.is_playing,
clock=clock,
)
)
# Unconditional, like the overlay it serves: the bus timer and the school-day badge
# need nothing configured, and the parents' timer is only started from parent mode.
general = app.config.general
services.append(
TimerService(
app.bus,
app.state,
general.clock,
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: ui_level(app.player.volume / 100, general.effects_volume / 100),
clock=clock,
)
)
# Unconditional: a show only starts downloading once someone drops a `feed.txt` # Unconditional: a show only starts downloading once someone drops a `feed.txt`
# into its folder, so there is nothing to gate here with its own config section. # into its folder, so there is nothing to gate here with its own config section.
web_service = _service_of_type(services, WebService) web_service = _service_of_type(services, WebService)
@@ -373,6 +411,7 @@ def _build_services(
on_change=lambda: app.rescan_library( on_change=lambda: app.rescan_library(
broadcast=web_service.hub.broadcast_library if web_service else None broadcast=web_service.hub.broadcast_library if web_service else None
), ),
episode_limit=app.config.general.podcast_episode_limit,
) )
) )

View File

@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import math
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -18,7 +20,34 @@ from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = ["App", "AppState"] __all__ = ["App", "AppState", "FigureSelector", "ParentTimer"]
@dataclass(frozen=True, slots=True)
class ParentTimer:
"""The countdown parents start from parent mode. Wall-clock Unix seconds, so every
browser can draw it against its own clock."""
started_at: float
ends_at: float
#: Ran out and is chiming; cleared after a minute or by the next thing anyone does.
ringing: bool = False
@dataclass
class FigureSelector:
"""The LED ring offering a figure's albums to pick from. Owned by
:mod:`musicmouse.reactions.selection`."""
figure: str
choices: tuple[Playlist, ...]
#: The highlighted choice - not necessarily the one playing until it is confirmed.
selected: int
#: Clock time at which the ring gives up waiting; pushed back by every turn.
deadline: float
#: Clock time of the last tick sound, so turning fast does not stack them up.
last_tick: float = -math.inf
expiry: asyncio.Task[None] | None = None
@dataclass @dataclass
@@ -29,6 +58,14 @@ class AppState:
#: instead of starting over. Cleared once its playlist runs out. #: instead of starting over. Cleared once its playlist runs out.
last_partially_played_figure: str | None = None last_partially_played_figure: str | None = None
#: Figure -> album id it last played, for figures with more than one album: putting
#: it back picks that one again. Kept when another figure is placed in between -
#: only the position within the album is forgotten then.
figure_albums: dict[str, str] = field(default_factory=dict)
#: The ring's album picker, while it is open.
selector: FigureSelector | None = None
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is #: Whether the MQTT broker is currently reachable. The firmware's equivalent is
#: readable off the transport; a broker's is not, so it is remembered here. #: readable off the transport; a broker's is not, so it is remembered here.
mqtt_connected: bool = False mqtt_connected: bool = False
@@ -36,6 +73,14 @@ class AppState:
#: Whether the lircd TCP link for the IR remote is currently reachable. #: Whether the lircd TCP link for the IR remote is currently reachable.
lirc_connected: bool = False lirc_connected: bool = False
#: Owned by :class:`~musicmouse.services.timers.TimerService`.
parent_timer: ParentTimer | None = None
#: Whether today is a school day; ``None`` on a weekday the holiday calendar could
#: not be fetched for (no internet, no cache yet).
school_day: bool | None = None
#: Why not, when it is not: ``"Wochenende"``, ``"Herbstferien"``, ...
school_day_reason: str | None = None
@dataclass @dataclass
class App: class App:
@@ -44,7 +89,8 @@ class App:
mouse: MusicMouseDevice mouse: MusicMouseDevice
player: Player player: Player
library: MusicLibrary library: MusicLibrary
playlists: dict[str, Playlist] #: Figure -> the albums it can play, in the order the ring offers them.
playlists: dict[str, tuple[Playlist, ...]]
clock: Clock = field(default_factory=RealClock) clock: Clock = field(default_factory=RealClock)
state: AppState = field(default_factory=AppState) state: AppState = field(default_factory=AppState)
#: `None` when `general.tippen` is absent - the typing game is off. #: `None` when `general.tippen` is absent - the typing game is off.
@@ -53,11 +99,21 @@ class App:
def colors(self, figure: str) -> FigureColors: def colors(self, figure: str) -> FigureColors:
return self.config.figures[figure].colors return self.config.figures[figure].colors
def playlist(self, figure: str) -> Playlist | None: def choices(self, figure: str) -> tuple[Playlist, ...]:
playlist = self.playlists.get(figure) """The albums ``figure`` can play; empty, with a warning, if it has none."""
if playlist is None: choices = self.playlists.get(figure, ())
if not choices:
_log.warning("No playlist for figure %r", figure) _log.warning("No playlist for figure %r", figure)
return playlist return choices
def default_choice(self, figure: str) -> int:
"""Which of the figure's albums plays when it is put down: the one it played
last, or else the first."""
remembered = self.state.figure_albums.get(figure)
for index, playlist in enumerate(self.playlists.get(figure, ())):
if playlist.album_id == remembered:
return index
return 0
def album_for(self, playlist: Playlist | None) -> Album | None: def album_for(self, playlist: Playlist | None) -> Album | None:
"""The library album a playlist came from, if any.""" """The library album a playlist came from, if any."""

View File

@@ -0,0 +1,159 @@
"""A synthesised "blubb": two bubbles, for the screen switching on or off.
Built from samples rather than shipped as a file - the same idea as the front-end's
WebAudio blips (``web/src/lib/tippen/pop.ts``), and it lets the loudness follow the
player's volume instead of being fixed in a recording.
A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as
it reaches the surface). Two of them make the "blubb"; switching on the second one is
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. 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", "confirm_wav", "tick_wav"]
RATE = 44100
#: (start Hz, end Hz, start s, length s) of each bubble.
_ON = ((260.0, 620.0, 0.0, 0.11), (380.0, 900.0, 0.13, 0.13))
_OFF = ((380.0, 900.0, 0.0, 0.11), (240.0, 560.0, 0.13, 0.15))
def blubb_wav(*, on: bool, gain: float) -> bytes:
"""A mono 16-bit WAV. ``gain`` is the peak, 0..1 of full scale."""
bubbles = _ON if on else _OFF
length = max(start + duration for _, _, start, duration in bubbles)
samples = [0.0] * (int(length * RATE) + 1)
for start_hz, end_hz, start, duration in bubbles:
_add_bubble(samples, start_hz, end_hz, int(start * RATE), duration)
peak = max(abs(s) for s in samples) or 1.0
scale = 32767 * max(0.0, min(1.0, gain)) / peak
return _wav(samples, scale)
#: (Hz, start s) of each bell strike: a falling major third, "ding-dong".
_CHIME = ((1318.5, 0.0), (1046.5, 0.45))
#: How long each strike rings out.
_CHIME_DECAY = 1.6
def chime_wav(*, gain: float) -> bytes:
"""A soft two-note bell for the parents' timer running out. Same format as
:func:`blubb_wav`."""
length = max(start for _, start in _CHIME) + _CHIME_DECAY
samples = [0.0] * (int(length * RATE) + 1)
for frequency, start in _CHIME:
_add_strike(samples, frequency, int(start * RATE))
peak = max(abs(s) for s in samples) or 1.0
scale = 32767 * max(0.0, min(1.0, gain)) / peak
return _wav(samples, scale)
#: (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:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(RATE)
out.writeframes(pcm.tobytes())
return buffer.getvalue()
def _add_bubble(
samples: list[float], start_hz: float, end_hz: float, offset: int, duration: float
) -> None:
count = int(duration * RATE)
attack = int(0.004 * RATE) # a few ms of fade-in, or the first sample clicks
phase = 0.0
for i in range(count):
t = i / count
# Exponential sweep, fast at first like the real thing.
frequency = start_hz * (end_hz / start_hz) ** (1 - (1 - t) ** 2)
phase += 2 * math.pi * frequency / RATE
envelope = min(1.0, i / attack) * math.exp(-5 * t)
index = offset + i
if index < len(samples):
samples[index] += math.sin(phase) * envelope
def _add_strike(samples: list[float], frequency: float, offset: int) -> None:
"""A bell: the fundamental plus two quieter, faster-fading overtones."""
count = int(_CHIME_DECAY * RATE)
attack = int(0.006 * RATE)
partials = ((1.0, 1.0, 3.0), (2.0, 0.35, 5.0), (3.0, 0.12, 8.0)) # ratio, level, decay
for i in range(count):
t = i / RATE
value = sum(
level * math.exp(-decay * t) * math.sin(2 * math.pi * frequency * ratio * t)
for ratio, level, decay in partials
)
index = offset + i
if index < len(samples):
samples[index] += value * min(1.0, i / attack)

View File

@@ -16,7 +16,7 @@ import contextlib
import inspect import inspect
import logging import logging
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from typing import Any from typing import Any, TypeAlias, TypeVar
from musicmouse.events import Event from musicmouse.events import Event
@@ -24,8 +24,12 @@ _log = logging.getLogger(__name__)
__all__ = ["EventBus", "Handler", "Unsubscribe"] __all__ = ["EventBus", "Handler", "Unsubscribe"]
type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None] #: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
type Unsubscribe = Callable[[], None] #: parameterises it the way the PEP 695 form did.
E = TypeVar("E", bound=Event)
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
Unsubscribe: TypeAlias = Callable[[], None]
class EventBus: class EventBus:
@@ -63,7 +67,7 @@ class EventBus:
# --------------------------------------------------------------- subscription # --------------------------------------------------------------- subscription
def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe: def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
"""Register ``handler`` for ``event_type`` and any subclass of it.""" """Register ``handler`` for ``event_type`` and any subclass of it."""
self._handlers.setdefault(event_type, []).append(handler) self._handlers.setdefault(event_type, []).append(handler)
self._resolved.clear() self._resolved.clear()

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Final, Literal, Self from typing import Annotated, Any, Final, Literal, Self, TypeAlias
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
@@ -26,16 +26,19 @@ from ruamel.yaml.error import YAMLError
from musicmouse.color import ColorRGBW, parse_color from musicmouse.color import ColorRGBW, parse_color
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = [ __all__ = [
"SIMULATE", "SIMULATE",
"ClockConfig",
"Config", "Config",
"ConfigError", "ConfigError",
"Digit", "Digit",
"FigureColors", "FigureColors",
"FigureConfig", "FigureConfig",
"FigureSelectionConfig",
"GeneralConfig", "GeneralConfig",
"HaConfig", "HaConfig",
"HaDeviceConfig", "HaDeviceConfig",
@@ -50,7 +53,7 @@ __all__ = [
] ]
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them. #: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac") DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
@@ -157,14 +160,52 @@ class LircConfig(_Strict):
port: int = Field(default=2222, ge=1, le=65535) port: int = Field(default=2222, ge=1, le=65535)
#: Only button events from this remote are acted on - other remotes registered #: Only button events from this remote are acted on - other remotes registered
#: with the same lircd (an LED remote, say) are ignored. #: with the same lircd (an LED remote, say) are ignored.
#: Compared case-insensitively: irserver lowercases what lircd capitalises.
remote_name: str = "Hauppauge" remote_name: str = "Hauppauge"
reconnect_interval: float = Field(default=5.0, gt=0) reconnect_interval: float = Field(default=5.0, gt=0)
class ScreenConfig(_Strict):
"""The monitor attached to the device, switched from the IR remote's power key.
Omit the whole section on a device without one: the key then does nothing.
"""
#: Run on every press, split shell-style but not through a shell. On musicdolphin
#: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is
#: on and flips it - the backend keeps no screen state of its own.
toggle_command: str
#: Switches the monitor off unconditionally, for the idle auto-off. Without it
#: there is no auto-off.
off_command: str | None = None
#: Switches it back on when someone is about again after an auto-off.
on_command: str | None = None
#: Minutes without music or anyone at the device before ``off_command`` runs.
#: 0 means never. Parent mode edits this.
auto_off_minutes: int = Field(default=15, ge=0)
timeout: float = Field(default=5.0, gt=0)
class ClockConfig(_Strict):
"""The clock overlay's automatic bus countdown, and whose school days it follows.
Always present: leave the section out for a Bavarian school bus at 7:25.
"""
#: ISO 3166-2 code the school and public holidays are looked up for, as the
#: OpenHolidays API knows it. A city-level code (``DE-BY-AU``) also counts that
#: city's own holidays; the state's are always included.
subdivision: str = Field(default="DE-BY", pattern=r"^[A-Z]{2}-[A-Z0-9]+(-[A-Z0-9]+)?$")
#: When the bus leaves, on school days only.
bus_time: str = Field(default="07:25", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
#: How long before ``bus_time`` the countdown appears.
bus_lead_minutes: int = Field(default=60, ge=1, le=240)
class LibraryConfig(_Strict): class LibraryConfig(_Strict):
"""Where the music lives. """Where the music lives.
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``, One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hörbücher``,
``Kinderpodcasts`` - are fixed names, not settings; see ``Kinderpodcasts`` - are fixed names, not settings; see
:mod:`musicmouse.library.sections`. :mod:`musicmouse.library.sections`.
""" """
@@ -172,6 +213,11 @@ class LibraryConfig(_Strict):
root: Path root: Path
#: Scan results, extracted cover art and track analysis. Relative to this file. #: Scan results, extracted cover art and track analysis. Relative to this file.
cache: Path = Path(".musicmouse-cache") cache: Path = Path(".musicmouse-cache")
#: How many tracks background analysis may work on at once, each in its own worker
#: process. Omit for one per core bar one (see
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
#: analysis to a single process on a machine that has other work to do.
analysis_workers: int | None = Field(default=None, ge=1)
@field_validator("root") @field_validator("root")
@classmethod @classmethod
@@ -213,10 +259,10 @@ class HaDeviceConfig(_Strict):
class HaConfig(_Strict): class HaConfig(_Strict):
"""Home Assistant integration for the room-control page ("Mein Zimmer"). """Home Assistant integration for the room-control page ("Mein Zimmer").
The backend never calls Home Assistant itself - it only hands the browser the The browser only gets the two ordered lists. Every state read and service call goes
server URL, the token, and these two ordered lists. Control happens directly from through the backend's ``/api/ha/...`` proxy, which attaches the token - so the token
the browser to Home Assistant's own REST API, so this token grants full HA control never leaves the backend. The proxy itself is unauthenticated, though: anything on
to anything on the LAN that can reach musicmouse. See config.yml.example. the LAN that can reach musicmouse can use it. See config.yml.example.
""" """
url: str url: str
@@ -256,6 +302,36 @@ class TippenConfig(_Strict):
return _resolve_folder(path, info, must_exist=False, kind="file") return _resolve_folder(path, info, must_exist=False, kind="file")
class FigureSelectionConfig(_Strict):
"""Overrides for the LED ring's album picker, for tuning it on the real device.
Temporary by design: every value defaults to a constant in
:mod:`musicmouse.reactions.selection`, which is where the ones that look right
belong once they have been found. ``None`` means "use that constant".
"""
#: Seconds without a turn before the picker closes by itself.
timeout: float | None = Field(default=None, gt=0)
#: Where on the ring the first album's section starts, in degrees from LED 0.
offset_degrees: float | None = None
#: 1 or -1: which way round the ring the sections follow each other.
ring_direction: Literal[1, -1] | None = None
#: 1 or -1: which way the knob has to turn to move to the next album.
knob_direction: Literal[1, -1] | None = None
#: Dark LEDs between neighbouring sections.
gap_leds: int | None = Field(default=None, ge=0, le=10)
#: Brightness of the sections that are not selected, 0..1.
dim_factor: float | None = Field(default=None, ge=0, le=1)
#: How far the selected section swings back and forth, in LEDs.
wobble_amplitude: float | None = Field(default=None, ge=0)
#: How long one swing back and forth takes.
wobble_period_ms: float | None = Field(default=None, gt=0)
#: How long the highlight takes to glide to the next section.
slide_ms: float | None = Field(default=None, ge=0)
#: How far a section's edges shift towards its neighbours' colour, 0..0.5.
edge_blend: float | None = Field(default=None, ge=0, le=0.5)
class GeneralConfig(_Strict): class GeneralConfig(_Strict):
library: LibraryConfig library: LibraryConfig
@@ -273,16 +349,31 @@ class GeneralConfig(_Strict):
web: WebConfig | None = None web: WebConfig | None = None
ha: HaConfig | None = None ha: HaConfig | None = None
lirc: LircConfig | None = None lirc: LircConfig | None = None
screen: ScreenConfig | None = None
tippen: TippenConfig | None = None tippen: TippenConfig | None = None
clock: ClockConfig = Field(default_factory=ClockConfig)
min_volume: int = Field(default=0, ge=0, le=200) min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200) max_volume: int = Field(default=100, ge=0, le=200)
initial_volume: int = Field(default=50, ge=0, le=200) initial_volume: int = Field(default=50, ge=0, le=200)
volume_increment: int = Field(default=5, ge=1, le=100) volume_increment: int = Field(default=5, ge=1, le=100)
button_leds_brightness: float = Field(default=0.5, ge=0, le=1) button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
#: Loudness of the UI sounds - the screen's blubb, the parents' timer, the album
#: picker's clicks - relative to the music, 0..100 %. Editable in parent mode.
effects_volume: int = Field(default=100, ge=0, le=100)
figure_selection: FigureSelectionConfig = Field(default_factory=FigureSelectionConfig)
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
#: How many episodes of each podcast show to keep, newest first. A show that has
#: published for years grows without bound and will eventually fill the device's SD
#: card. ``null`` keeps every episode, and minding the free space is then on you.
#:
#: Lowering this *deletes* the episodes that fall outside the window on the next
#: poll, and an episode that has aged out of its feed cannot be fetched again.
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
@property @property
def serial_simulated(self) -> bool: def serial_simulated(self) -> bool:
return self.serial_port == SIMULATE return self.serial_port == SIMULATE
@@ -318,8 +409,8 @@ class FigureConfig(_Strict):
class RemoteSlotConfig(_Strict): class RemoteSlotConfig(_Strict):
"""What a number key on the IR remote plays. """What a number key on the IR remote plays.
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music ``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
``Album.series``); resolved to that show's newest episode fresh on every press, ``Album.series``); resolved to that show's newest episode fresh on every press,
since a podcast show is not itself one playable thing in this library - each since a podcast show is not itself one playable thing in this library - each
episode is its own album. episode is its own album.
@@ -327,6 +418,15 @@ class RemoteSlotConfig(_Strict):
target_kind: Literal["album", "series"] target_kind: Literal["album", "series"]
target: str target: str
#: ``"album"`` only: start at this track instead of the first, then carry on with
#: the rest of the album - one song assigned to a key.
track_index: int = Field(default=0, ge=0)
@model_validator(mode="after")
def _check_track_index(self) -> Self:
if self.target_kind == "series" and self.track_index:
raise ValueError("track_index only applies to album slots")
return self
class Config(_Strict): class Config(_Strict):

View File

@@ -27,15 +27,17 @@ from musicmouse.devices.wire import (
from musicmouse.effects import OFF, LedEffect from musicmouse.effects import OFF, LedEffect
from musicmouse.events import ( from musicmouse.events import (
ActiveFigureChanged, ActiveFigureChanged,
ButtonEvent,
ConnectionChanged, ConnectionChanged,
DeviceConnected,
DeviceDisconnected,
EventSource, EventSource,
InputEvent, InputEvent,
LedEffectChanged, LedEffectChanged,
RfidTokenRead, RfidTokenRead,
RotaryTurned,
SelectorPressed,
SelectorTurned,
) )
from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone from musicmouse.hardware import NO_FIGURE_TAG, Button, ButtonAction, LedZone, RotaryDirection
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -62,6 +64,7 @@ class MusicMouseDevice:
self._active_figure: str | None = None self._active_figure: str | None = None
self._button_brightness: float = 0.0 self._button_brightness: float = 0.0
self._effects: dict[LedZone, LedEffect] = {} self._effects: dict[LedZone, LedEffect] = {}
self._knob_captured = False
# -------------------------------------------------------------------- state # -------------------------------------------------------------------- state
@@ -81,6 +84,10 @@ class MusicMouseDevice:
def effect(self, zone: LedZone) -> LedEffect | None: def effect(self, zone: LedZone) -> LedEffect | None:
return self._effects.get(zone) return self._effects.get(zone)
@property
def knob_captured(self) -> bool:
return self._knob_captured
# ------------------------------------------------------------------ actions # ------------------------------------------------------------------ actions
def set_effect( def set_effect(
@@ -107,6 +114,17 @@ class MusicMouseDevice:
self._transport.write(encode_button_brightness(button, brightness)) self._transport.write(encode_button_brightness(button, brightness))
_log.debug("Button backlights -> %.2f (%s)", brightness, origin) _log.debug("Button backlights -> %.2f (%s)", brightness, origin)
def capture_knob(self, captured: bool) -> None:
"""Hand the rotary encoder to the ring's album picker, or give it back.
While captured, turns and presses come out as :class:`SelectorTurned` and
:class:`SelectorPressed` instead of :class:`RotaryTurned` and
:class:`ButtonEvent`, so the volume and Home Assistant never see them. Done here,
as the frames are decoded, rather than by each listener checking a flag: the
press that closes the picker would otherwise race whoever handles it after.
"""
self._knob_captured = captured
def all_leds_off(self, *, origin: EventSource = "system") -> None: def all_leds_off(self, *, origin: EventSource = "system") -> None:
for zone in LedZone: for zone in LedZone:
self.set_effect(zone, OFF(), origin=origin) self.set_effect(zone, OFF(), origin=origin)
@@ -116,7 +134,6 @@ class MusicMouseDevice:
def on_connected(self) -> None: def on_connected(self) -> None:
"""Re-apply memorized state, so a reconnect is invisible from the outside.""" """Re-apply memorized state, so a reconnect is invisible from the outside."""
self._bus.emit(DeviceConnected(port=self.port, source="device"))
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device")) self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
for zone, effect in self._effects.items(): for zone, effect in self._effects.items():
@@ -126,8 +143,8 @@ class MusicMouseDevice:
if self._effects: if self._effects:
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects)) _log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
def on_disconnected(self, reason: str = "") -> None: def on_disconnected(self, _reason: str = "") -> None:
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device")) # SerialLink already logs the reason; the event only says the link is down.
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device")) self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
def feed(self, data: bytes) -> None: def feed(self, data: bytes) -> None:
@@ -153,6 +170,20 @@ class MusicMouseDevice:
def _publish(self, event: InputEvent) -> None: def _publish(self, event: InputEvent) -> None:
if isinstance(event, RfidTokenRead): if isinstance(event, RfidTokenRead):
self._publish_tag_read(event) self._publish_tag_read(event)
elif self._knob_captured and isinstance(event, RotaryTurned):
if event.direction is not RotaryDirection.NONE:
# The first turn after boot reports no increment, but it was a detent.
steps = abs(event.increment) or 1
if event.direction is RotaryDirection.DOWN:
steps = -steps
self._bus.emit(SelectorTurned(steps=steps, source="device"))
elif (
self._knob_captured
and isinstance(event, ButtonEvent)
and event.button is Button.ROTARY
):
if event.action is ButtonAction.PRESSED:
self._bus.emit(SelectorPressed(source="device"))
else: else:
self._bus.emit(event) self._bus.emit(event)

View File

@@ -13,15 +13,16 @@ newlines and on the magic token.
from __future__ import annotations from __future__ import annotations
import struct import struct
from dataclasses import dataclass, replace from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from typing import override from typing import TypeAlias
from musicmouse.effects import ( from musicmouse.effects import (
EffectAlexaSwipeConfig, EffectAlexaSwipeConfig,
EffectCircularConfig, EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig, EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe, EffectReverseSwipe,
EffectSegmentSelect,
EffectStaticConfig, EffectStaticConfig,
EffectStaticDetailedConfig, EffectStaticDetailedConfig,
EffectSwipeAndChange, EffectSwipeAndChange,
@@ -114,9 +115,11 @@ class MessageHostToFw(IntEnum):
PREV_BUTTON_LED = 21 PREV_BUTTON_LED = 21
NEXT_BUTTON_LED = 22 NEXT_BUTTON_LED = 22
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring #: Which message id carries which effect, per zone. Note the asymmetry: only the ring
#: accepts AlexaSwipe on its own, and only the shelf accepts StaticDetailed. #: accepts AlexaSwipe and SegmentSelect, and only the shelf accepts StaticDetailed.
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = { _EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
LedZone.RING: { LedZone.RING: {
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC, EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
@@ -127,6 +130,7 @@ _EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
), ),
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE, EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
EffectSegmentSelect: MessageHostToFw.LED_WHEEL_EFFECT_SEGMENT_SELECT,
}, },
LedZone.MOUSE: { LedZone.MOUSE: {
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC, EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
@@ -162,7 +166,7 @@ class FirmwareLog:
text: str text: str
type Decoded = InputEvent | FirmwareLog Decoded: TypeAlias = InputEvent | FirmwareLog
# ------------------------------------------------------------------------- encoding # ------------------------------------------------------------------------- encoding
@@ -341,10 +345,6 @@ class FrameDecoder:
return None return None
def with_figure(event: RfidTokenRead, figure: str | None, *, known: bool) -> RfidTokenRead:
"""Attach a resolved figure name to a decoded tag read."""
return replace(event, figure=figure, known=known)
# ------------------------------------------------- host -> firmware, read back # ------------------------------------------------- host -> firmware, read back
# #
@@ -367,12 +367,11 @@ class SetButtonBrightness:
button: Button button: Button
brightness: float brightness: float
@override
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.button.slug} backlight <- {self.brightness:.2f}" return f"{self.button.slug} backlight <- {self.brightness:.2f}"
type HostCommand = SetEffect | SetButtonBrightness HostCommand: TypeAlias = SetEffect | SetButtonBrightness
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = { _ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
message: (zone, effect_cls) message: (zone, effect_cls)

View File

@@ -22,10 +22,12 @@ from musicmouse.color import ColorHSV, ColorRGBW
__all__ = [ __all__ = [
"OFF", "OFF",
"SEGMENT_SELECT_MAX_SEGMENTS",
"EffectAlexaSwipeConfig", "EffectAlexaSwipeConfig",
"EffectCircularConfig", "EffectCircularConfig",
"EffectRandomTwoColorInterpolationConfig", "EffectRandomTwoColorInterpolationConfig",
"EffectReverseSwipe", "EffectReverseSwipe",
"EffectSegmentSelect",
"EffectStaticConfig", "EffectStaticConfig",
"EffectStaticDetailedConfig", "EffectStaticDetailedConfig",
"EffectSwipeAndChange", "EffectSwipeAndChange",
@@ -41,6 +43,12 @@ _ALEXA_SWIPE = struct.Struct("<fffff?")
_TWO_COLOR = struct.Struct("<i?i??") _TWO_COLOR = struct.Struct("<i?i??")
_CIRCULAR = struct.Struct("<ff") _CIRCULAR = struct.Struct("<ff")
_REVERSE_SWIPE = struct.Struct("<fff") _REVERSE_SWIPE = struct.Struct("<fff")
_SEGMENT_SELECT_HEAD = struct.Struct("<BBBB")
_SEGMENT_SELECT_TAIL = struct.Struct("<fbfffff")
#: ``SEGMENT_SELECT_MAX_SEGMENTS`` in ``SegmentSelect.h``: the colour array is this long
#: on the wire whatever ``count`` is.
SEGMENT_SELECT_MAX_SEGMENTS = 6
class LedEffect(Protocol): class LedEffect(Protocol):
@@ -274,6 +282,86 @@ class EffectReverseSwipe:
) )
@dataclass
class EffectSegmentSelect:
"""The ring as a picker: one section per choice, the selected one bright.
Re-sent on every rotary detent. ``previous`` is where the highlight glides from, so
the fresh effect the firmware builds per message still moves smoothly.
"""
colors: tuple[ColorRGBW, ...]
selected: int = 0
previous: int | None = None # None: same as selected, no glide
gap_leds: int = 1
offset_degrees: float = 180
direction: int = 1
dim_factor: float = 0.15
wobble_amplitude: float = 1.5 # LEDs
wobble_period_ms: float = 1000
slide_ms: float = 200
edge_blend: float = 0.25
SIZE: ClassVar[int] = (
_SEGMENT_SELECT_HEAD.size
+ SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
+ _SEGMENT_SELECT_TAIL.size
)
def __post_init__(self) -> None:
if not 1 <= len(self.colors) <= SEGMENT_SELECT_MAX_SEGMENTS:
raise ValueError(
f"between 1 and {SEGMENT_SELECT_MAX_SEGMENTS} sections, got {len(self.colors)}"
)
def as_bytes(self) -> bytes:
count = len(self.colors)
previous = self.selected if self.previous is None else self.previous
padding = (ColorRGBW(0, 0, 0, 0),) * (SEGMENT_SELECT_MAX_SEGMENTS - count)
return (
_SEGMENT_SELECT_HEAD.pack(count, self.selected, previous, self.gap_leds)
+ b"".join(color.as_bytes() for color in (*self.colors, *padding))
+ _SEGMENT_SELECT_TAIL.pack(
self.offset_degrees,
self.direction,
self.dim_factor,
self.wobble_amplitude,
self.wobble_period_ms,
self.slide_ms,
self.edge_blend,
)
)
@classmethod
def from_bytes(cls, data: bytes) -> EffectSegmentSelect:
_check_size(data, cls.SIZE, cls.__name__)
count, selected, previous, gap = _SEGMENT_SELECT_HEAD.unpack_from(data)
start = _SEGMENT_SELECT_HEAD.size
colors = tuple(
ColorRGBW.from_bytes(data[start + i * _RGBW_SIZE : start + (i + 1) * _RGBW_SIZE])
for i in range(count)
)
offset, direction, dim, amplitude, period, slide, blend = _SEGMENT_SELECT_TAIL.unpack_from(
data, start + SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
)
return cls(
colors=colors,
selected=selected,
previous=None if previous == selected else previous,
gap_leds=gap,
offset_degrees=offset,
direction=direction,
dim_factor=dim,
wobble_amplitude=amplitude,
wobble_period_ms=period,
slide_ms=slide,
edge_blend=blend,
)
def __repr__(self) -> str:
return f"SegmentSelect({self.previous} -> {self.selected} of {len(self.colors)})"
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
"""A fresh "all LEDs off" effect.""" """A fresh "all LEDs off" effect."""
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0)) return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))

View File

@@ -7,28 +7,33 @@ Three flavours, distinguished by base class:
* :class:`StateEvent` - something changed. * :class:`StateEvent` - something changed.
The intent layer is what lets several front-ends drive the same behaviour: a button The intent layer is what lets several front-ends drive the same behaviour: a button
press, an MQTT command and a future web request all emit ``NextTrackRequested`` and a press, an MQTT command, a web request and the IR remote all emit ``NextTrackRequested``
single reaction acts on it. and a single reaction acts on it.
""" """
from __future__ import annotations from __future__ import annotations
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal from typing import TYPE_CHECKING, Literal, TypeAlias
from musicmouse.effects import LedEffect from musicmouse.effects import LedEffect
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.media import Playlist, Track from musicmouse.media import Playlist, Track
if TYPE_CHECKING:
from musicmouse.config import Digit
__all__ = [ __all__ = [
"ActiveFigureChanged", "ActiveFigureChanged",
"ButtonEvent", "ButtonEvent",
"CancelTimerRequested",
"ClockStateChanged",
"ConnectionChanged", "ConnectionChanged",
"DeviceConnected",
"DeviceDisconnected",
"Event", "Event",
"EventSource", "EventSource",
"FigureSelectionChanged",
"FigureSelectorExpired",
"InputEvent", "InputEvent",
"IntentEvent", "IntentEvent",
"LedEffectChanged", "LedEffectChanged",
@@ -37,6 +42,7 @@ __all__ = [
"PauseRequested", "PauseRequested",
"PlayAlbumRequested", "PlayAlbumRequested",
"PlayFigureRequested", "PlayFigureRequested",
"PlayRemoteSlotRequested",
"PlayRequested", "PlayRequested",
"PlaySeriesLatestRequested", "PlaySeriesLatestRequested",
"PlaybackChanged", "PlaybackChanged",
@@ -44,16 +50,22 @@ __all__ = [
"PreviousTrackRequested", "PreviousTrackRequested",
"RfidTokenRead", "RfidTokenRead",
"RotaryTurned", "RotaryTurned",
"SelectorPressed",
"SelectorTurned",
"SetTimerRequested",
"SetVolumeRequested", "SetVolumeRequested",
"StateEvent", "StateEvent",
"ToggleClockRequested",
"ToggleScreenRequested",
"TouchButtonPressed", "TouchButtonPressed",
"TouchButtonReleased", "TouchButtonReleased",
"TrackChanged", "TrackChanged",
"UserActivity",
"VolumeChangeRequested", "VolumeChangeRequested",
"VolumeChanged", "VolumeChanged",
] ]
type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"] EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
@@ -131,22 +143,36 @@ class RotaryTurned(InputEvent):
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})" return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
@dataclass(frozen=True, slots=True, kw_only=True)
class SelectorTurned(InputEvent):
"""The rotary encoder turned while the ring's album picker had it.
Not a :class:`RotaryTurned`: while the picker is open the knob is not a volume
knob, and nothing that listens for one should hear it.
"""
#: Detents, positive for the direction that turns the volume up.
steps: int
@dataclass(frozen=True, slots=True, kw_only=True)
class SelectorPressed(InputEvent):
"""The rotary encoder was pressed while the ring's album picker had it. Instead of,
not as well as, a :class:`ButtonEvent` - so it is not published to Home Assistant."""
@dataclass(frozen=True, slots=True, kw_only=True)
class FigureSelectorExpired(InputEvent):
"""The ring's album picker went untouched for long enough to close by itself."""
figure: str
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class PlaylistFinished(InputEvent): class PlaylistFinished(InputEvent):
"""The player reached the end of the playlist.""" """The player reached the end of the playlist."""
@dataclass(frozen=True, slots=True, kw_only=True)
class DeviceConnected(InputEvent):
port: str
@dataclass(frozen=True, slots=True, kw_only=True)
class DeviceDisconnected(InputEvent):
port: str
reason: str | None = None
# -------------------------------------------------------------------------- intents # -------------------------------------------------------------------------- intents
@@ -197,6 +223,17 @@ class PlaySeriesLatestRequested(IntentEvent):
series: str series: str
@dataclass(frozen=True, slots=True, kw_only=True)
class PlayRemoteSlotRequested(IntentEvent):
"""Play whatever the top-level ``remote:`` mapping assigns to a number key.
The IR remote's digits emit this, and so do the number keys of the web front-end's
keyboard - one reaction resolves the slot for both.
"""
digit: Digit
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class SeekRequested(IntentEvent): class SeekRequested(IntentEvent):
#: Seconds from the start of the current track. #: Seconds from the start of the current track.
@@ -213,6 +250,36 @@ class SetVolumeRequested(IntentEvent):
volume: int volume: int
@dataclass(frozen=True, slots=True, kw_only=True)
class ToggleScreenRequested(IntentEvent):
"""Switch the attached monitor off if it is on, on if it is off (``general.screen``)."""
@dataclass(frozen=True, slots=True, kw_only=True)
class ToggleClockRequested(IntentEvent):
"""Show or hide the clock overlay. Only the kiosk acts on it - the overlay is a
front-end thing, the backend just passes the remote's key along."""
@dataclass(frozen=True, slots=True, kw_only=True)
class SetTimerRequested(IntentEvent):
"""Start the parents' countdown, replacing one already running."""
#: Wall-clock end, as Unix seconds.
ends_at: float
@dataclass(frozen=True, slots=True, kw_only=True)
class CancelTimerRequested(IntentEvent):
"""Stop the parents' countdown, or silence it if it is ringing."""
@dataclass(frozen=True, slots=True, kw_only=True)
class UserActivity(IntentEvent):
"""Someone is at the device - a key press or tap in the kiosk browser. Requests
nothing by itself; it keeps the screen from switching off (``ScreenService``)."""
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class LedEffectRequested(IntentEvent): class LedEffectRequested(IntentEvent):
zone: LedZone zone: LedZone
@@ -249,6 +316,17 @@ class ActiveFigureChanged(StateEvent):
previous: str | None = None previous: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class FigureSelectionChanged(StateEvent):
"""The ring's album picker opened, moved or closed."""
figure: str
#: Index into the figure's albums of the highlighted one.
selected: int
count: int
open: bool
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class LedEffectChanged(StateEvent): class LedEffectChanged(StateEvent):
"""Emitted on *every* write to an LED zone, whatever caused it. """Emitted on *every* write to an LED zone, whatever caused it.
@@ -266,6 +344,12 @@ class LedEffectChanged(StateEvent):
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})" return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
@dataclass(frozen=True, slots=True, kw_only=True)
class ClockStateChanged(StateEvent):
"""The parents' timer started, rang or stopped, or today's school day changed.
The values themselves live on :class:`~musicmouse.app.AppState`."""
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class ConnectionChanged(StateEvent): class ConnectionChanged(StateEvent):
target: Literal["firmware", "mqtt", "lirc"] target: Literal["firmware", "mqtt", "lirc"]

View File

@@ -8,14 +8,14 @@ plain immutable data.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import logging import logging
import os
import time import time
from collections import deque
from collections.abc import Awaitable, Callable, Collection, Mapping from collections.abc import Awaitable, Callable, Collection, Mapping
from concurrent.futures import BrokenExecutor, Executor
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Final from typing import Any, Final
from musicmouse.library.analysis import ( from musicmouse.library.analysis import (
ANALYZER_VERSION, ANALYZER_VERSION,
@@ -29,12 +29,14 @@ from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
from musicmouse.library.scanner import scan_library from musicmouse.library.scanner import scan_library
from musicmouse.library.sections import SECTIONS, AlbumKind from musicmouse.library.sections import SECTIONS, AlbumKind
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
from musicmouse.media import Playlist from musicmouse.media import Playlist
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = [ __all__ = [
"ANALYZER_VERSION", "ANALYZER_VERSION",
"MAX_FIGURE_ALBUMS",
"SECTIONS", "SECTIONS",
"Album", "Album",
"Analyzer", "Analyzer",
@@ -45,9 +47,14 @@ __all__ = [
"NullAnalyzer", "NullAnalyzer",
"TrackCurves", "TrackCurves",
"album_id", "album_id",
"default_worker_count",
"track_key", "track_key",
] ]
#: How many albums one figure can offer. Each is a section of the LED ring, and past six
#: they get too narrow to tell apart; the firmware's colour array is no longer either.
MAX_FIGURE_ALBUMS: Final = 6
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is #: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
#: tens of minutes of narration with no musical mood to extract, and there are far more #: tens of minutes of narration with no musical mood to extract, and there are far more
#: of them in a typical library than there are songs. #: of them in a typical library than there are songs.
@@ -69,19 +76,12 @@ _PUBLISH_BATCH_SIZE: Final = 25
_PROGRESS_INTERVAL_SECONDS: Final = 5.0 _PROGRESS_INTERVAL_SECONDS: Final = 5.0
def _analyze_one( def _discard(future: asyncio.Future[Any]) -> None:
analyzer: Analyzer, path: Path """Drop a result nobody is going to read, without leaving a warning behind."""
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]: if not future.done():
"""Runs in a worker thread. Lowers this thread's own scheduling priority first. future.cancel()
elif not future.cancelled():
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so future.exception()
this makes idle-time analysis yield CPU to anything else without touching threads
used for other work. Niceness only ever increases and clamps at the OS maximum
(19), so calling this repeatedly on a reused pool thread is harmless.
"""
with contextlib.suppress(OSError):
os.nice(1)
return analyzer.analyze(path)
class MusicLibrary: class MusicLibrary:
@@ -94,12 +94,19 @@ class MusicLibrary:
extensions: frozenset[str], extensions: frozenset[str],
*, *,
analyzer: Analyzer | None = None, analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None, figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> None: ) -> None:
self.root = root self.root = root
self.cache = cache self.cache = cache
self.extensions = extensions self.extensions = extensions
self.analyzer: Analyzer = analyzer or NullAnalyzer() self.analyzer: Analyzer = analyzer or NullAnalyzer()
#: How many tracks background analysis may work on at once. The default of 1
#: keeps the analyzer in this process, where an analyzer that holds state
#: still behaves as written; the app passes `default_worker_count()`, which is
#: what makes a first-time pass finish in hours rather than days on a desktop.
#: See `musicmouse.library.workers`.
self.analysis_workers = analysis_workers
#: What each figure holds. The only thing a folder name cannot say. #: What each figure holds. The only thing a folder name cannot say.
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {} self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
self._entries: dict[str, tuple[Album, Fingerprint]] = {} self._entries: dict[str, tuple[Album, Fingerprint]] = {}
@@ -118,17 +125,32 @@ class MusicLibrary:
entry = self._entries.get(identifier) if identifier else None entry = self._entries.get(identifier) if identifier else None
return entry[0] if entry else None return entry[0] if entry else None
def figure_playlists(self) -> dict[str, Playlist]: def figure_playlists(self) -> dict[str, tuple[Playlist, ...]]:
"""One playlist per figure folder, keyed by figure name. """The albums a figure can play, keyed by figure name, in folder order.
The figure path and the web path must hand the player the *same* object for the Usually one. A figure whose folder holds album subfolders rather than audio has
same album: ``reactions.playback.play_figure`` resumes on an identity check. 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`.
""" """
return { by_figure: dict[str, list[Album]] = {}
album.figure: album.to_playlist() for album, _ in self._entries.values():
for album, _ in self._entries.values() if album.figure is not None:
if album.figure is not None by_figure.setdefault(album.figure, []).append(album)
}
out: dict[str, tuple[Playlist, ...]] = {}
for figure, albums in by_figure.items():
albums.sort(key=lambda album: album.folder.parts)
if len(albums) > MAX_FIGURE_ALBUMS:
_log.warning(
"Figure %r has %d albums; only the first %d can be picked on the ring: %s",
figure,
len(albums),
MAX_FIGURE_ALBUMS,
", ".join(album.folder.name for album in albums[:MAX_FIGURE_ALBUMS]),
)
out[figure] = tuple(album.to_playlist() for album in albums[:MAX_FIGURE_ALBUMS])
return out
def latest_episode(self, series: str) -> Album | None: def latest_episode(self, series: str) -> Album | None:
"""The newest episode-unit album of a podcast show, by filename. """The newest episode-unit album of a podcast show, by filename.
@@ -146,9 +168,7 @@ class MusicLibrary:
] ]
if not candidates: if not candidates:
return None return None
return max( return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
)
def beats(self, identifier: str, index: int) -> BeatGrid | None: def beats(self, identifier: str, index: int) -> BeatGrid | None:
album = self.get(identifier) album = self.get(identifier)
@@ -219,10 +239,18 @@ class MusicLibrary:
extensions: frozenset[str], extensions: frozenset[str],
*, *,
analyzer: Analyzer | None = None, analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None, figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> MusicLibrary: ) -> MusicLibrary:
cache = LibraryCache(cache_dir) cache = LibraryCache(cache_dir)
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds) library = cls(
root,
cache,
extensions,
analyzer=analyzer,
analysis_workers=analysis_workers,
figure_kinds=figure_kinds,
)
library._entries = await asyncio.to_thread(cache.load_index) library._entries = await asyncio.to_thread(cache.load_index)
await library.refresh() await library.refresh()
return library return library
@@ -261,64 +289,145 @@ class MusicLibrary:
is_busy: Callable[[], bool] = lambda: False, is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None, on_batch: Callable[[], Awaitable[None]] | None = None,
batch_size: int = _PUBLISH_BATCH_SIZE, batch_size: int = _PUBLISH_BATCH_SIZE,
workers: int | None = None,
) -> int: ) -> int:
"""Run the analyzer over tracks of `kinds` that have no current result. """Run the analyzer over tracks of `kinds` that have no current result.
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
track, `is_busy()` pauses the whole pass rather than one file: analysis must are analyzed at once (`self.analysis_workers` when not given), each in its own
never compete with audio decoding for CPU, and a children's player is idle most worker process - see `musicmouse.library.workers` for why processes and how the
of the day, so the pass simply resumes next time it is. A track the analyzer machine is kept usable while they run. Checked before every track is handed out,
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with `is_busy()` pauses the whole pass rather than one file: analysis must never
every scalar left `None` - so it is never retried forever and the frontend falls compete with audio decoding for CPU, and a children's player is idle most of the
back to the un-analyzed baseline for it. Results are folded into the live index day, so the pass simply resumes next time it is. Tracks already in flight when
and persisted every `batch_size` tracks, so a long first run is visible in open it goes busy are allowed to finish - their results are already paid for - and
browser tabs as it goes rather than only once it finishes. the workers are then shut down for the duration rather than sitting idle with
a librosa apiece resident on a machine that is now playing music.
A track the analyzer fails on (corrupt file, DRM, zero length) is still recorded
as attempted - with every scalar left `None` - so it is never retried forever and
the frontend falls back to the un-analyzed baseline for it. A worker *process*
dying, though, says nothing about the track it was on, so that ends the pass
without recording anything: the next one picks the same tracks up again.
Results are folded into the live index and persisted every `batch_size` tracks,
so a long first run is visible in open browser tabs as it goes rather than only
once it finishes.
""" """
analyzer = self.analyzer analyzer = self.analyzer
if analyzer.version < ANALYZER_VERSION: if analyzer.version < ANALYZER_VERSION:
return 0 return 0
pending = deque(self._pending_tracks(kinds, analyzer.version))
if not pending:
return 0
workers = self.analysis_workers if workers is None else max(1, workers)
total = len(pending)
_log.info("Analyzing %d tracks with %d worker(s)", total, workers)
loop = asyncio.get_running_loop()
in_flight: dict[asyncio.Future[Any], tuple[str, Path]] = {}
done = 0 done = 0
last_report = time.monotonic() last_report = time.monotonic()
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
while is_busy():
await asyncio.sleep(_BUSY_POLL_SECONDS)
key = track_key(track.path)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= analyzer.version:
continue
now = time.monotonic()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
_log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path)
last_report = now
try: try:
analysis, grid, curve = await asyncio.to_thread( while pending:
_analyze_one, analyzer, track.path while is_busy():
# Waited out *between* pools, so a half-hour album is not played
# with a houseful of idle worker processes holding onto librosa.
await asyncio.sleep(_BUSY_POLL_SECONDS)
with analysis_pool(workers) as pool:
try:
while in_flight or (pending and not is_busy()):
while pending and len(in_flight) < workers and not is_busy():
key, path = pending.popleft()
in_flight[self._submit(pool, loop, path)] = (key, path)
if not in_flight:
break # gone busy: drop the pool and wait above
finished, _ = await asyncio.wait(
in_flight, return_when=asyncio.FIRST_COMPLETED
) )
except Exception: for future in finished:
_log.warning( key, path = in_flight.pop(future)
"Analyzer raised on %s; marking it attempted so it is not retried forever", self._store_result(key, path, future)
track.path,
exc_info=True,
)
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
done += 1 done += 1
if done % batch_size == 0: if done % batch_size == 0:
await self._publish_analysis(kinds, on_batch) await self._publish_analysis(kinds, on_batch)
now = time.monotonic()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
_log.info("Analyzing library: %d/%d tracks done", done, total)
last_report = now
finally:
# Nothing is left to read these - without this, a pass that ends
# early (cancelled at shutdown, or a dead pool) leaves "exception
# was never retrieved" behind for every track still in flight.
for future in in_flight:
_discard(future)
in_flight.clear()
except BrokenExecutor:
_log.error(
"An analysis worker process died (out of memory?) after %d of %d tracks; "
"stopping this pass. The rest are retried on the next one.",
done,
total,
)
if done % batch_size: if done % batch_size:
await self._publish_analysis(kinds, on_batch) await self._publish_analysis(kinds, on_batch)
if done: if done:
_log.info("Analyzed %d tracks", done) _log.info("Analyzed %d tracks", done)
return done return done
def _pending_tracks(self, kinds: Collection[AlbumKind], version: int) -> list[tuple[str, Path]]:
"""The whole pass's worklist, as (cache key, path), worked out up front.
Up front rather than per track, because a pool has to have the next file ready
the moment a worker frees up, and because it is what lets the progress line say
"37/412". Deduplicated by cache key: the same file can sit in two albums, and
two workers analyzing it at once would be pure waste.
"""
worklist: list[tuple[str, Path]] = []
seen: set[str] = set()
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
key = track_key(track.path)
if key in seen:
continue
seen.add(key)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= version:
continue
worklist.append((key, track.path))
return worklist
def _submit(
self, pool: Executor | None, loop: asyncio.AbstractEventLoop, path: Path
) -> asyncio.Future[Any]:
"""Start one track, in a worker process or - with no pool - on a thread here."""
if pool is None:
return asyncio.ensure_future(asyncio.to_thread(analyze_one, self.analyzer, path))
return loop.run_in_executor(pool, analyze_one, self.analyzer, path)
def _store_result(self, key: str, path: Path, future: asyncio.Future[Any]) -> None:
"""Persist one finished track. Re-raises only what ends the whole pass."""
error = future.exception()
if isinstance(error, BrokenExecutor):
raise error
if error is not None:
_log.warning(
"Analyzer raised on %s; marking it attempted so it is not retried forever",
path,
exc_info=error,
)
analysis, grid, curve = TrackAnalysis(version=self.analyzer.version), None, None
else:
analysis, grid, curve = future.result()
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
async def _publish_analysis( async def _publish_analysis(
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
) -> None: ) -> None:

View File

@@ -5,7 +5,14 @@ different amounts to produce::
<cache_dir>/ <cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely. ├── index.json cheap: tags and structure. Thrown away freely.
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame ├── covers/<album_id>.thumb.jpg the same art at THUMB_COVER_PX, for cards and rows
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
│ frame, or copied from a cover.jpg in the
│ folder - downscaled to MAX_COVER_PX on
│ the way in. An album's `cover` always
│ points here and never into the library,
│ so nothing can serve full-size art by
│ accident.
└── analysis/<track_key>.json expensive: minutes of DSP per track └── analysis/<track_key>.json expensive: minutes of DSP per track
analysis/<track_key>.beats.json analysis/<track_key>.beats.json
analysis/<track_key>.curve.json analysis/<track_key>.curve.json
@@ -18,12 +25,15 @@ folder or re-sorting a section then costs nothing.
from __future__ import annotations from __future__ import annotations
import io
import json import json
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, Final, cast
from PIL import Image, UnidentifiedImageError
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.models import Album, LibraryTrack from musicmouse.library.models import Album, LibraryTrack
@@ -37,7 +47,21 @@ __all__ = ["Fingerprint", "LibraryCache"]
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its #: worked out - not just when the JSON shape does. A cached entry is reused whenever its
#: files are untouched, so otherwise a change to that logic is invisible until somebody #: files are untouched, so otherwise a change to that logic is invisible until somebody
#: edits their music folder. #: edits their music folder.
_INDEX_VERSION = 4 # 5: every album's `cover` now points into this cache, downscaled, including the ones
# that used to point straight at a `cover.jpg` in the library folder.
# 6: MAX_COVER_PX dropped from 640 to 384. This is what applies it. `shrink_cover` never
# scales art *up*, so a cover already written at some size is only ever rewritten
# 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.
# The same rescan retired `shrink_stored_covers`, which re-read every cover header on
# every scan to catch pre-384 px covers and covers stored before thumbnails existed.
#
# Bumping this is cheap by design - see the module docstring.
_INDEX_VERSION = 7
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -84,9 +108,17 @@ class LibraryCache:
def cover_path(self, album_id: str) -> Path: def cover_path(self, album_id: str) -> Path:
return self.covers / f"{album_id}.jpg" return self.covers / f"{album_id}.jpg"
def thumb_path(self, album_id: str) -> Path:
return self.covers / f"{album_id}.thumb.jpg"
def store_cover(self, album_id: str, data: bytes) -> Path: def store_cover(self, album_id: str, data: bytes) -> Path:
path = self.cover_path(album_id) path = self.cover_path(album_id)
path.write_bytes(data) # The thumb comes from the original art, not from the 384 px copy: one lossy
# generation fewer, and `Image.draft` lets JPEG decode at a fraction of the size.
path.write_bytes(shrink_cover(data))
self.thumb_path(album_id).write_bytes(
shrink_cover(data, max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
)
return path return path
# ------------------------------------------------------------------ analysis # ------------------------------------------------------------------ analysis
@@ -213,3 +245,78 @@ def _album_from_json(data: dict[str, Any]) -> Album:
for track in data["tracks"] for track in data["tracks"]
), ),
) )
# ---------------------------------------------------------------------------- covers
#: Longest edge kept for cached album art, in pixels.
#:
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
#: albums. The browser was decoding nine megapixels - about 36 MB of bitmap - for every
#: cover it painted, to show it in a 185 px card on a Pi with 2 GB of RAM.
#:
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
#: asks for 180 and the player bar for 56), so this only has to beat 340 to be lossless
#: where it shows.
#:
#: It was 640 first, for headroom on a tablet at devicePixelRatio 2, and that headroom
#: turned out to be expensive on the device that actually runs this. Measured on
#: musicdolphin, typing "conni" over a 343-album library, keydown to painted, worst
#: keystroke of three runs: 3.4-9.1 s at 640 px against 0.2 s at 384 px. Decode cost
#: goes with pixel count, and a browse grid paints a card 132 px wide.
#:
#: A tablet at devicePixelRatio 2 therefore gets a slightly soft cover on the play view
#: and nowhere else. That is the trade: a visible sharpness margin nobody asked for, for
#: a search box that answers a keystroke in a frame.
MAX_COVER_PX: Final = 384
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
#: file is a third of the size.
_COVER_JPEG_QUALITY: Final = 85
#: Longest edge of the card/row thumbnail. A grid card is about 217 CSS px wide on a
#: 1080p kiosk (five columns in the 1180 px grid), so 256 covers it at devicePixelRatio 1
#: with a little to spare; only the play view asks for the full-size file. Decode cost
#: and texture upload go with pixel count, and this is 44% of the 384 px file's.
THUMB_COVER_PX: Final = 256
#: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder
#: a browser on a Pi has, so JPEG stays the format; only the dimensions shrink.
_THUMB_JPEG_QUALITY: Final = 80
def shrink_cover(
data: bytes, max_px: int = MAX_COVER_PX, quality: int = _COVER_JPEG_QUALITY
) -> bytes:
"""Downscale cover art to ``max_px`` (default :data:`MAX_COVER_PX`) on its longest edge.
Art that is already small enough is returned untouched rather than re-encoded, so
repeated scans never degrade it. Anything Pillow cannot read is passed through
unchanged: a cover that is too big is a performance problem, a cover that is missing
is a visible one.
The output is always a baseline (non-progressive) 4:2:0 JPEG: the cheapest layout
for a browser to decode.
"""
try:
with Image.open(io.BytesIO(data)) as source:
if max(source.size) <= max_px:
return data
# JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats.
source.draft("RGB", (max_px, max_px))
image = source.convert("RGB")
# `thumbnail` keeps the aspect ratio and never scales up.
image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
image.save(
buffer,
format="JPEG",
quality=quality,
optimize=True,
progressive=False,
subsampling="4:2:0",
)
return buffer.getvalue()
except (OSError, UnidentifiedImageError, ValueError):
return data

View File

@@ -2,7 +2,7 @@
The frontend paints an album card with these and the LED strips run an effect in them, The frontend paints an album card with these and the LED strips run an effect in them,
so a web-started album looks the same on the shelf as it does on the screen. Extraction so a web-started album looks the same on the shelf as it does on the screen. Extraction
happens once per album ever - the result is cached next to the cover. happens when an album is scanned; the result is stored in the cached index.
""" """
from __future__ import annotations from __future__ import annotations

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib import hashlib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TypeAlias
from musicmouse.library.analysis import TrackAnalysis from musicmouse.library.analysis import TrackAnalysis
from musicmouse.library.sections import AlbumKind from musicmouse.library.sections import AlbumKind
@@ -15,7 +16,7 @@ __all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
#: Primary, secondary and accent as ``"#rrggbb"`` - the format #: Primary, secondary and accent as ``"#rrggbb"`` - the format
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new #: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
#: parsing and the frontend gets CSS colours for free. #: parsing and the frontend gets CSS colours for free.
type AlbumColors = tuple[str, str, str] AlbumColors: TypeAlias = tuple[str, str, str]
def album_id(root: Path, folder: Path) -> str: def album_id(root: Path, folder: Path) -> str:

View File

@@ -12,6 +12,12 @@ a feed offers real per-episode artwork, it's saved alongside as a same-named sid
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync. via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
A show folder is also kept to a fixed number of the newest episodes
(:data:`DEFAULT_EPISODE_LIMIT`), so a long-running feed cannot fill the device's disk.
The same limit caps what is downloaded, which is what stops the two halves fighting:
prune what is older than the newest N, download only the newest N, and the folder
settles instead of re-fetching every episode it just deleted.
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
and skipped, not a crash, mirroring ``scanner.py``'s own rule. and skipped, not a crash, mirroring ``scanner.py``'s own rule.
""" """
@@ -37,6 +43,7 @@ from musicmouse.library.sections import SECTIONS
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = [ __all__ = [
"DEFAULT_EPISODE_LIMIT",
"FEED_MARKER_NAME", "FEED_MARKER_NAME",
"AudioExtractionError", "AudioExtractionError",
"Episode", "Episode",
@@ -46,7 +53,9 @@ __all__ = [
"episode_filename", "episode_filename",
"find_feed_shows", "find_feed_shows",
"missing_episodes", "missing_episodes",
"newest_episodes",
"parse_feed", "parse_feed",
"prune_show",
"resolve_episode_cover", "resolve_episode_cover",
"sync_all_shows", "sync_all_shows",
"sync_show", "sync_show",
@@ -54,6 +63,15 @@ __all__ = [
FEED_MARKER_NAME: Final = "feed.txt" FEED_MARKER_NAME: Final = "feed.txt"
#: How many episodes of one show to keep, newest first. A show that has published for
#: years is otherwise unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, which
#: does not fit next to the rest of the library on a Pi's SD card.
#:
#: This caps downloads as well as deletions, and it has to be one number for both. Prune
#: to the newest N but download everything the feed offers, and every poll would
#: re-fetch the episodes the last one deleted, forever.
DEFAULT_EPISODE_LIMIT: Final = 50
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or #: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
#: not a real extension (tracking-redirect URLs are common in the wild). #: not a real extension (tracking-redirect URLs are common in the wild).
_EXTENSION_BY_TYPE: Final[dict[str, str]] = { _EXTENSION_BY_TYPE: Final[dict[str, str]] = {
@@ -98,6 +116,9 @@ _FFMPEG_TIMEOUT: Final = 600.0
#: Where a show folder remembers episodes that failed to download, so a permanently #: Where a show folder remembers episodes that failed to download, so a permanently
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll. #: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json" _FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
#: The same, for episodes on disk whose cover could not be found or fetched - so an
#: episode with no art anywhere doesn't cost a page fetch on every poll.
_MISSING_COVERS_FILENAME: Final = ".missing-covers.json"
#: How long a failed episode is left alone before it's given another chance - long #: How long a failed episode is left alone before it's given another chance - long
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely #: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
#: transient failure (a host outage, a flaky network) still recovers on its own. #: transient failure (a host outage, a flaky network) still recovers on its own.
@@ -287,15 +308,90 @@ def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episod
return out return out
def _load_failed_downloads(folder: Path) -> dict[str, datetime]: def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
"""Filename -> when it last failed to download, for episodes ``sync_show`` should """The ``keep`` most recently published episodes, newest first.
leave alone until :data:`_RETRY_BACKOFF` has passed.
Applied to the *feed* before anything is downloaded. Without it, capping the folder
would be pointless: the next poll would see every pruned episode as missing again.
"""
ordered = sorted(episodes, key=lambda episode: episode.published, reverse=True)
return ordered if keep is None else ordered[:keep]
def _episode_date(name: str) -> datetime | None:
"""The date out of a ``YYYYMMDD - Title.ext`` filename, or ``None`` if it has none.
Deliberately strict. A file that does not follow the convention is one this module
did not write - something dropped in by hand under another name - and it is left
alone rather than guessed at, because the alternative is deleting somebody's file
on a parse that happened to fail.
"""
stem = Path(name).stem
if len(stem) < 8 or not stem[:8].isdigit():
return None
try:
return datetime.strptime(stem[:8], "%Y%m%d").replace(tzinfo=UTC)
except ValueError:
return None
def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None = None) -> int:
"""Delete all but the ``keep`` newest episodes in ``folder``. Returns how many went.
Only touches files named the way :func:`episode_filename` names them, so a
``feed.txt``, a ``folder.jpg``, the failed-download record and any hand-named file
are all safe. An episode's sidecar cover image goes with it - it shares the stem,
and leaving it behind would strand art for a track that no longer exists.
``keep`` of ``None`` disables pruning entirely.
"""
if keep is None:
return 0
audio_extensions = extensions if extensions is not None else _KNOWN_EXTENSIONS
dated: list[tuple[datetime, Path]] = []
for path in folder.iterdir():
if not path.is_file() or path.suffix.lower() not in audio_extensions:
continue
published = _episode_date(path.name)
if published is not None:
dated.append((published, path))
if len(dated) <= keep:
return 0
dated.sort(key=lambda item: (item[0], item[1].name), reverse=True)
removed = 0
for _published, path in dated[keep:]:
try:
path.unlink()
except OSError as exc:
_log.warning("Could not delete old episode %s: %s", path, exc)
continue
removed += 1
for image_extension in _IMAGE_EXTENSIONS:
sidecar = path.with_suffix(image_extension)
try:
sidecar.unlink(missing_ok=True)
except OSError as exc:
_log.debug("Could not delete cover %s: %s", sidecar, exc)
if removed:
_log.info(
"Pruned %d old episode(s) from %s, keeping the newest %d", removed, folder.name, keep
)
return removed
def _load_failed(folder: Path, record_name: str) -> dict[str, datetime]:
"""Filename -> when it last failed, for episodes ``sync_show`` should leave alone
until :data:`_RETRY_BACKOFF` has passed. ``record_name`` picks which record: failed
downloads, or covers that could not be found.
A missing or corrupt record is just an empty one - nothing here is precious enough A missing or corrupt record is just an empty one - nothing here is precious enough
to raise over, matching the module's overall rule that bad input is logged and to raise over, matching the module's overall rule that bad input is logged and
skipped rather than fatal. skipped rather than fatal.
""" """
path = folder / _FAILED_DOWNLOADS_FILENAME path = folder / record_name
try: try:
raw = json.loads(path.read_text(encoding="utf-8")) raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError: except FileNotFoundError:
@@ -310,12 +406,12 @@ def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
return {} return {}
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None: def _save_failed(folder: Path, record_name: str, failed: dict[str, datetime]) -> None:
path = folder / _FAILED_DOWNLOADS_FILENAME path = folder / record_name
if not failed: if not failed:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
return return
temp_path = folder / f".{_FAILED_DOWNLOADS_FILENAME}.tmp" temp_path = folder / f".{record_name}.tmp"
payload = {filename: when.isoformat() for filename, when in failed.items()} payload = {filename: when.isoformat() for filename, when in failed.items()}
temp_path.write_text(json.dumps(payload), encoding="utf-8") temp_path.write_text(json.dumps(payload), encoding="utf-8")
temp_path.replace(path) temp_path.replace(path)
@@ -437,14 +533,87 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
return _extract_og_image(response.text) return _extract_og_image(response.text)
async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool: def _has_sidecar_cover(audio_path: Path) -> bool:
"""Download every episode in ``feed_url`` that ``folder`` doesn't have yet. return any(audio_path.with_suffix(ext).is_file() for ext in _IMAGE_EXTENSIONS)
Returns whether anything changed. Errors - an unreachable feed, a malformed one, a
single broken enclosure - are logged and swallowed here so one bad show never stops async def _sync_covers(
the others or takes down the poll loop. An episode that fails is remembered and left client: httpx2.AsyncClient, folder: Path, episodes: list[Episode], now: datetime
alone for :data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead ) -> bool:
enclosure doesn't get hammered on every poll. """Fetch the sidecar cover of every episode in ``episodes`` that is on disk without
one. Returns whether any cover was added.
Covers every episode on disk, not just the ones this pass downloaded: episodes
placed by hand, or downloaded before a cover route existed (Wissen macht Ah's
``og:image`` fallback), would otherwise never get theirs. An episode whose cover
can't be found or fetched is remembered and left alone for :data:`_RETRY_BACKOFF`,
like a failed download, so a feed with no per-episode art doesn't cost one page
fetch per episode on every poll.
"""
missing = _load_failed(folder, _MISSING_COVERS_FILENAME)
on_disk: set[str] = set()
added = False
for episode in episodes:
filename = episode_filename(
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
)
audio_path = folder / filename
if not audio_path.is_file():
continue
on_disk.add(filename)
if _has_sidecar_cover(audio_path):
missing.pop(filename, None)
continue
last_miss = missing.get(filename)
if last_miss is not None and now - last_miss < _RETRY_BACKOFF:
continue
cover_url = await resolve_episode_cover(client, episode)
if cover_url is None:
missing[filename] = now
continue
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
try:
await download_episode_cover(client, folder, cover_url, cover_filename)
except (httpx2.HTTPError, OSError) as exc:
_log.warning(
"Could not download cover for episode %r in %s: %s",
episode.title,
folder.name,
exc,
)
missing[filename] = now
continue
missing.pop(filename, None)
added = True
missing = {filename: when for filename, when in missing.items() if filename in on_disk}
_save_failed(folder, _MISSING_COVERS_FILENAME, missing)
return added
async def sync_show(
client: httpx2.AsyncClient,
folder: Path,
feed_url: str,
*,
keep: int | None = DEFAULT_EPISODE_LIMIT,
) -> bool:
"""Bring ``folder`` up to date with the newest ``keep`` episodes of ``feed_url``.
Downloads what is missing from that window and deletes what has fallen out of it.
Both halves use the same ``keep``, which is what makes the folder settle - see
:data:`DEFAULT_EPISODE_LIMIT`.
Returns whether anything changed, deletions and backfilled covers included: a
pruned folder needs a rescan just as much as a downloaded episode does, or the
library keeps offering tracks whose files are gone.
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
logged and swallowed here so one bad show never stops the others or takes down the
poll loop. An episode that fails is remembered and left alone for
:data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead enclosure
doesn't get hammered on every poll.
""" """
try: try:
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True) response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
@@ -453,8 +622,9 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc) _log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
return False return False
pending = missing_episodes(folder, parse_feed(response.content)) window = newest_episodes(parse_feed(response.content), keep)
failed = _load_failed_downloads(folder) pending = missing_episodes(folder, window)
failed = _load_failed(folder, _FAILED_DOWNLOADS_FILENAME)
now = datetime.now(UTC) now = datetime.now(UTC)
changed = False changed = False
for episode, filename in pending: for episode, filename in pending:
@@ -473,30 +643,26 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
_log.info("Downloaded new episode %r for %s", episode.title, folder.name) _log.info("Downloaded new episode %r for %s", episode.title, folder.name)
changed = True changed = True
cover_url = await resolve_episode_cover(client, episode)
if cover_url:
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
if not (folder / cover_filename).exists():
try:
await download_episode_cover(client, folder, cover_url, cover_filename)
except (httpx2.HTTPError, OSError) as exc:
_log.warning(
"Could not download cover for episode %r in %s: %s",
episode.title,
folder.name,
exc,
)
pending_filenames = {filename for _episode, filename in pending} pending_filenames = {filename for _episode, filename in pending}
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames} failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
_save_failed_downloads(folder, failed) _save_failed(folder, _FAILED_DOWNLOADS_FILENAME, failed)
if await _sync_covers(client, folder, window, now):
changed = True
# After downloading, not before: an episode that just arrived is one of the newest
# and must be counted when deciding what falls off the end.
if prune_show(folder, keep):
changed = True
return changed return changed
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool: async def sync_all_shows(
client: httpx2.AsyncClient, root: Path, *, keep: int | None = DEFAULT_EPISODE_LIMIT
) -> bool:
"""Poll every show with a feed marker under ``root``. Returns whether any changed.""" """Poll every show with a feed marker under ``root``. Returns whether any changed."""
changed = False changed = False
for _section_name, folder, feed_url in find_feed_shows(root): for _section_name, folder, feed_url in find_feed_shows(root):
if await sync_show(client, folder, feed_url): if await sync_show(client, folder, feed_url, keep=keep):
changed = True changed = True
return changed return changed

View File

@@ -119,15 +119,25 @@ def _most_common(values: list[str]) -> str:
def _cover_for( def _cover_for(
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]: ) -> tuple[Path | None, bytes | None]:
"""The album's art, as a path *into the cache* plus the original bytes.
Every branch goes through :meth:`LibraryCache.store_cover`, including the one that
finds a ``cover.jpg`` already sitting in the folder. Returning that file directly
would be the obvious thing and was the original behaviour, and it quietly undid the
downscaling: a folder cover is whatever the internet gave it, often 1920px or more,
and the browser then decoded all of it to fill a 185px card. The bytes come back
undownscaled either way, because `colors_from_cover` wants the real art.
"""
for name in _COVER_NAMES: for name in _COVER_NAMES:
candidate = folder / name candidate = folder / name
if candidate.is_file(): if candidate.is_file():
return candidate, candidate.read_bytes() art = candidate.read_bytes()
return cache.store_cover(identifier, art), art
for path in paths[:3]: for path in paths[:3]:
# Podcast feeds sometimes art only some episodes; a couple of tries is enough. # Podcast feeds sometimes art only some episodes; a couple of tries is enough.
art = _embedded_art(path) embedded = _embedded_art(path)
if art is not None: if embedded is not None:
return cache.store_cover(identifier, art), art return cache.store_cover(identifier, embedded), embedded
return None, None return None, None
@@ -143,14 +153,16 @@ def _cover_for_episode(
for extension in _SIDECAR_COVER_EXTENSIONS: for extension in _SIDECAR_COVER_EXTENSIONS:
candidate = path.with_suffix(extension) candidate = path.with_suffix(extension)
if candidate.is_file(): if candidate.is_file():
return candidate, candidate.read_bytes() art = candidate.read_bytes()
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art return cache.store_cover(identifier, art), art
embedded = _embedded_art(path)
if embedded is not None:
return cache.store_cover(identifier, embedded), embedded
for name in _COVER_NAMES: for name in _COVER_NAMES:
candidate = folder / name candidate = folder / name
if candidate.is_file(): if candidate.is_file():
return candidate, candidate.read_bytes() shared = candidate.read_bytes()
return cache.store_cover(identifier, shared), shared
return None, None return None, None
@@ -185,7 +197,9 @@ def scan_album(
albums.append(tags.get("album", "")) albums.append(tags.get("album", ""))
artists.append(tags.get("albumartist") or tags.get("artist", "")) artists.append(tags.get("albumartist") or tags.get("artist", ""))
figure = folder.name if section.figures else None # A figure with several albums keeps them in subfolders of its own folder, so the
# figure is the folder directly under the section, however deep the album sits.
figure = folder.relative_to(root / section_name).parts[0] if section.figures else None
# Every other shelf is named after what is on it. A figure folder is named after the # Every other shelf is named after what is on it. A figure folder is named after the
# figurine, so its media type has to be declared in the config. # figurine, so its media type has to be declared in the config.
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
@@ -193,9 +207,13 @@ def scan_album(
folder_artist, folder_title = _split_folder_name(folder.name) folder_artist, folder_title = _split_folder_name(folder.name)
if section.title_from == "folder": if section.title_from == "folder":
# A figure folder is named in lowercase ("fuchs"); it sits next to real album title = folder.name
# titles in the browse grid, so give it a capital. if figure is not None:
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name # A figure folder is named in lowercase ("fuchs"); it sits next to real
# album titles in the browse grid, so give it a capital.
title = figure[:1].upper() + figure[1:]
if folder.name != figure:
title = f"{title} - {folder.name}"
else: else:
title = _most_common(albums) or folder_title or folder.name title = _most_common(albums) or folder_title or folder.name
@@ -250,7 +268,11 @@ def scan_episodes(
out: dict[str, tuple[Album, Fingerprint]] = {} out: dict[str, tuple[Album, Fingerprint]] = {}
for path in paths: for path in paths:
identifier = album_id(root, path) identifier = album_id(root, path)
fingerprint = Fingerprint.of([path]) # The sidecar cover counts too: one fetched after the episode was first scanned
# (``podcast_feeds.py`` backfills them) must invalidate the cached entry, or the
# episode keeps showing the show's shared cover.
sidecars = [path.with_suffix(ext) for ext in _SIDECAR_COVER_EXTENSIONS]
fingerprint = Fingerprint.of([path, *(p for p in sidecars if p.is_file())])
cached = known.get(identifier) cached = known.get(identifier)
if cached is not None and cached[1] == fingerprint: if cached is not None and cached[1] == fingerprint:
out[identifier] = cached out[identifier] = cached

View File

@@ -8,15 +8,15 @@ quirks are not understood yet.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final, Literal from typing import Final, Literal, TypeAlias
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"] __all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
type AlbumKind = Literal["music", "book"] AlbumKind: TypeAlias = Literal["music", "book"]
type TrackOrder = Literal["filename", "newest_first"] TrackOrder: TypeAlias = Literal["filename", "newest_first"]
type TitleSource = Literal["tags", "folder"] TitleSource: TypeAlias = Literal["tags", "folder"]
type ArtistSource = Literal["tags", "folder"] ArtistSource: TypeAlias = Literal["tags", "folder"]
type AlbumUnit = Literal["folder", "episode"] AlbumUnit: TypeAlias = Literal["folder", "episode"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)

View File

@@ -0,0 +1,143 @@
"""Where analysis actually burns CPU, and how it is kept from taking the machine over.
Analyzing one track is a few seconds of single-threaded DSP that the GIL will not let
another Python thread overlap with - librosa's work is numba-jitted and numpy glue, not
long C calls that release it. So a library-sized pass parallelizes across *processes*:
:func:`analysis_pool` hands :meth:`~musicmouse.library.MusicLibrary.analyze_pending` an
executor whose workers are separate interpreters, and a 12-core desktop chews through a
first-time scan roughly an order of magnitude faster than the Raspberry Pi this also
has to stay polite on.
Polite means three things, all of them set here rather than at the call site:
* **One core stays free** (:func:`default_worker_count`), so the audio thread and the
web server never have to fight a full house of analyzers for a timeslice.
* **Workers run niced**, so even the cores they do own yield to playback instantly.
* **Each worker stays single-threaded** - numpy's BLAS and numba would each happily
start one thread per core *inside* every worker, and N x N threads on a 4-core Pi is
slower than N, not faster.
Whatever an analyzer returns has to survive the trip back from a worker process, which
is what keeps :class:`~musicmouse.library.analysis.TrackAnalysis` and friends plain
frozen dataclasses of floats. An analyzer that records state in its own instance -
a test double counting calls, say - only sees that state in the worker, so such an
analyzer must be run with ``workers=1``, where everything stays in this process on a
thread.
"""
from __future__ import annotations
import contextlib
import logging
import multiprocessing
import os
from collections.abc import Iterator
from concurrent.futures import Executor, ProcessPoolExecutor
from pathlib import Path
from typing import Final
from musicmouse.library.analysis import Analyzer, BeatGrid, TrackAnalysis, TrackCurves
_log = logging.getLogger(__name__)
__all__ = ["analysis_pool", "analyze_one", "default_worker_count"]
#: Ceiling on the automatic worker count. Every worker is a fresh interpreter with its
#: own librosa, numpy and a decoded track in memory - a few hundred MB each - so on a
#: big machine the limit that bites first is RAM, not cores. An explicit
#: ``analysis_workers`` in the config overrides this; the default stays conservative.
_MAX_AUTO_WORKERS: Final = 8
#: How much worse than everything else analysis schedules. Niceness clamps at the OS
#: maximum (19) and only ever increases, so re-applying it to a reused worker is
#: harmless.
_NICENESS: Final = 5
#: Forced into every worker *before* it imports numpy, which reads these once at import
#: time. Without them each worker opens its own BLAS thread pool sized for the whole
#: machine and the pool oversubscribes every core several times over.
_SINGLE_THREADED: Final = {
"OMP_NUM_THREADS": "1",
"OPENBLAS_NUM_THREADS": "1",
"MKL_NUM_THREADS": "1",
"NUMEXPR_NUM_THREADS": "1",
"NUMBA_NUM_THREADS": "1",
}
def default_worker_count() -> int:
"""One worker per core bar one, capped at :data:`_MAX_AUTO_WORKERS`.
The core left over is for the rest of the app: audio decoding, the web server and
the serial link all have to stay responsive while a first-time pass runs for hours.
Single-core machines get 1, which :func:`analysis_pool` turns into the in-process
path rather than a pool of one.
"""
return max(1, min(_MAX_AUTO_WORKERS, (os.cpu_count() or 1) - 1))
def analyze_one(
analyzer: Analyzer, path: Path
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Analyze one file off the event loop - in a worker process, or on a thread here.
Lowers the caller's own scheduling priority first. On Linux ``os.nice`` affects only
the calling *thread*, so on the in-process path this makes idle-time analysis yield
CPU without touching threads doing other work; in a worker process there is nothing
else in the process to slow down anyway.
"""
with contextlib.suppress(OSError):
os.nice(_NICENESS)
return analyzer.analyze(path)
def _init_worker() -> None:
"""Runs once per worker process, before it imports librosa or numpy.
A spawned worker starts from a bare interpreter and pulls the analyzer in when it
unpickles its first task, so setting the thread-count variables here still lands
ahead of numpy reading them.
"""
os.environ.update(_SINGLE_THREADED)
with contextlib.suppress(OSError):
os.nice(_NICENESS)
@contextlib.contextmanager
def analysis_pool(workers: int) -> Iterator[Executor | None]:
"""A pool of `workers` analyzer processes, or ``None`` for "stay in this process".
``None`` - for ``workers <= 1``, and as the fallback when a pool cannot be started
at all - means the caller should run each track on a thread instead. That path is
what the tests and single-core devices use, and it is the only one where an
analyzer holding state in its own instance behaves as written.
Workers are *spawned*, never forked: this process has an asyncio loop, a serial
reader and libVLC's own threads running, and forking that is a well-known way to
inherit a held lock and deadlock in a child. The price is one librosa import per
worker, a few seconds paid once per pass - nothing next to the hours of DSP a
first-time pass over a real library costs.
"""
if workers <= 1:
yield None
return
try:
executor = ProcessPoolExecutor(
max_workers=workers,
mp_context=multiprocessing.get_context("spawn"),
initializer=_init_worker,
)
except (OSError, ValueError):
_log.warning(
"Could not start %d analysis workers; analyzing in this process instead",
workers,
exc_info=True,
)
yield None
return
try:
yield executor
finally:
# `wait=False`: shutdown happens on cancellation too (the app is stopping), and
# waiting there would hold it up for however long the tracks in flight take.
executor.shutdown(wait=False, cancel_futures=True)

View File

@@ -1,8 +1,8 @@
"""Playlist model. """Playlist model.
Deliberately a real type rather than a bare ``list[str]``: it is what the browse API What the player plays: a name and the files, in order. The browse API serves
serves, and it keeps track metadata in one place. Building one from a folder is :class:`~musicmouse.library.models.Album` instead, which carries the tag metadata;
:mod:`musicmouse.library`'s job. ``Album.to_playlist()`` builds one of these from it.
""" """
from __future__ import annotations from __future__ import annotations

View File

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

View File

@@ -54,7 +54,10 @@ def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
if event.figure is None: if event.figure is None:
off_animation(app) off_animation(app)
else: else:
start_animation(app, app.colors(event.figure)) # A figure with several albums gets the album picker on the ring instead
# (see ``reactions.selection``), which fades into this ring effect when done.
ring = len(app.playlists.get(event.figure, ())) < 2
start_animation(app, app.colors(event.figure), ring=ring)
app.mouse.set_button_brightness( app.mouse.set_button_brightness(
app.config.general.button_leds_brightness, origin="device" app.config.general.button_leds_brightness, origin="device"
) )
@@ -106,36 +109,42 @@ def touch_released(event: TouchButtonReleased, app: App) -> None:
) )
if colors is None: if colors is None:
return return
fade_to_figure(app, LedZone.MOUSE, colors)
app.mouse.set_effect(
LedZone.MOUSE,
EffectRandomTwoColorInterpolationConfig(
color1=colors.primary, color2=colors.secondary, start_with_existing=True
),
origin="device",
)
# ------------------------------------------------------------------- animations # ------------------------------------------------------------------- animations
def start_animation(app: App, colors: FigureColors) -> None: def start_animation(app: App, colors: FigureColors, *, ring: bool = True) -> None:
ring = EffectSwipeAndChange() swipe = EffectSwipeAndChange()
ring.swipe.primary_color = colors.primary swipe.swipe.primary_color = colors.primary
ring.swipe.secondary_color = colors.secondary swipe.swipe.secondary_color = colors.secondary
ring.swipe.swipe_speed = SWIPE_SPEED swipe.swipe.swipe_speed = SWIPE_SPEED
ring.change.color1 = colors.primary swipe.change.color1 = colors.primary
ring.change.color2 = colors.secondary swipe.change.color2 = colors.secondary
app.mouse.set_effect(LedZone.RING, ring, origin="device") if ring:
app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device") app.mouse.set_effect(LedZone.RING, swipe, origin="device")
app.mouse.set_effect(LedZone.SHELF, deepcopy(swipe), origin="device")
mouse = deepcopy(ring) mouse = deepcopy(swipe)
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device") app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
def fade_to_figure(app: App, zone: LedZone, colors: FigureColors) -> None:
"""Fade ``zone`` from whatever it shows into the figure's resting colours - where
the start animation ends up after its swipe."""
app.mouse.set_effect(
zone,
EffectRandomTwoColorInterpolationConfig(
color1=colors.primary, color2=colors.secondary, start_with_existing=True
),
origin="device",
)
def web_animation(app: App, color: ColorRGBW) -> None: def web_animation(app: App, color: ColorRGBW) -> None:
for zone in LedZone: for zone in LedZone:
app.mouse.set_effect( app.mouse.set_effect(

View File

@@ -1,8 +1,8 @@
"""What the mouse plays, and when. """What the mouse plays, and when.
Physical inputs are turned into *intents*, and the intents are what actually drive the Physical inputs are turned into *intents*, and the intents are what actually drive the
player. That indirection is the point: an MQTT command or a future web request emits player. That indirection is the point: an MQTT command, a web request or an IR remote key
the same intent and lands in the same handler, so there is one place per behaviour. emits the same intent and lands in the same handler, so there is one place per behaviour.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,6 +18,7 @@ from musicmouse.events import (
PlayAlbumRequested, PlayAlbumRequested,
PlayFigureRequested, PlayFigureRequested,
PlaylistFinished, PlaylistFinished,
PlayRemoteSlotRequested,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
@@ -28,6 +29,7 @@ from musicmouse.events import (
) )
from musicmouse.hardware import Button, ButtonAction, RotaryDirection from musicmouse.hardware import Button, ButtonAction, RotaryDirection
from musicmouse.library.models import Album from musicmouse.library.models import Album
from musicmouse.media import Playlist
from musicmouse.reactions.registry import on from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -62,24 +64,46 @@ def _figure_removed(previous: str | None, app: App) -> None:
@on(PlayFigureRequested) @on(PlayFigureRequested)
def play_figure(event: PlayFigureRequested, app: App) -> None: def play_figure(event: PlayFigureRequested, app: App) -> None:
playlist = app.playlist(event.figure) """Play the figure's album - the one it played last, if it has several.
if playlist is None:
return
if not event.restart and app.player.playlist is playlist: 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)
# Equality, not identity: a library rescan (the refresh button, or the podcast
# poller landing an episode) rebuilds every Playlist object, and an `is` check made
# the figure start over after that. Equal means the same album with the same files;
# if the files changed, starting over is the honest thing to do.
if not event.restart and app.player.playlist == playlist:
_log.info("Resuming %r", event.figure) _log.info("Resuming %r", event.figure)
app.player.play() app.player.play()
return return
_log.info("Starting %r from the beginning", event.figure) _log.info("Starting %r (%s) from the beginning", event.figure, playlist.album_id)
app.player.set_playlist(playlist) app.player.set_playlist(playlist)
app.player.play_from_start() app.player.play_from_start()
def remember_figure_album(app: App, figure: str, playlist: Playlist) -> None:
"""Make ``playlist`` the album ``figure`` picks next time it is put down."""
if playlist.album_id is not None:
app.state.figure_albums[figure] = playlist.album_id
def _start_album(app: App, album: Album, track_index: int) -> None: def _start_album(app: App, album: Album, track_index: int) -> None:
# A figure album keeps the figure's own resume bookkeeping honest: it is the same # A figure album started from anywhere becomes the album that figure picks next.
# Playlist object either way, because both come from the library index. playlist = None
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist() if album.figure is not None:
playlist = next(
(p for p in app.playlists.get(album.figure, ()) if p.album_id == album.id), None
)
if playlist is not None:
remember_figure_album(app, album.figure, playlist)
app.player.set_playlist(playlist or album.to_playlist()) app.player.set_playlist(playlist or album.to_playlist())
app.player.play_track(track_index) app.player.play_track(track_index)
@@ -104,6 +128,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
_start_album(app, album, 0) _start_album(app, album, 0)
@on(PlayRemoteSlotRequested)
def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None:
"""Play what a number key is assigned - pressed on the IR remote or a keyboard.
A slot naming one song starts the album there; the rest of the album follows, as
for any other start mid-album.
"""
slot = app.config.remote.get(event.digit)
if slot is None:
_log.debug("Number key %s is not assigned", event.digit)
return
if slot.target_kind == "series":
app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source))
return
album = app.library.get(slot.target)
if album is None:
_log.warning("Number key %s: no album %r in the library", event.digit, slot.target)
return
_start_album(app, album, slot.track_index)
@on(PlaylistFinished) @on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None: def playlist_finished(_event: PlaylistFinished, app: App) -> None:
# Nothing was left half-played, so the next placement starts from the top. # Nothing was left half-played, so the next placement starts from the top.
@@ -122,7 +167,9 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
elif event.button is Button.RIGHT and app.player.is_playing: elif event.button is Button.RIGHT and app.player.is_playing:
app.bus.emit(NextTrackRequested(source="device")) app.bus.emit(NextTrackRequested(source="device"))
# The rotary press is published to Home Assistant by the MQTT service; what it # The rotary press is published to Home Assistant by the MQTT service; what it
# controls is an automation over there, not something this backend decides. # controls is an automation over there, not something this backend decides. While
# the ring's album picker is open the press is the picker's instead, and does not
# arrive here at all (see ``MusicMouseDevice.capture_knob``).
@on(RotaryTurned) @on(RotaryTurned)

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import logging import logging
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from musicmouse.bus import EventBus from musicmouse.bus import EventBus
from musicmouse.events import Event from musicmouse.events import Event
@@ -20,12 +20,14 @@ _log = logging.getLogger(__name__)
__all__ = ["Reaction", "on", "register_all", "registered"] __all__ = ["Reaction", "on", "register_all", "registered"]
type Reaction[E: Event] = Callable[[E, "App"], Coroutine[Any, Any, None] | None] E = TypeVar("E", bound=Event)
Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = [] _REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
def on[E: Event](event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]: def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
"""Register a reaction for ``event_type`` (and any subclass of it).""" """Register a reaction for ``event_type`` (and any subclass of it)."""
def decorator(reaction: Reaction[E]) -> Reaction[E]: def decorator(reaction: Reaction[E]) -> Reaction[E]:
@@ -46,7 +48,7 @@ def register_all(bus: EventBus, app: App) -> None:
_log.debug("Registered %d reactions", len(_REGISTRY)) _log.debug("Registered %d reactions", len(_REGISTRY))
def _bind[E: Event](reaction: Reaction[E], app: App) -> Callable[[E], Any]: def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]:
def handler(event: E) -> Any: def handler(event: E) -> Any:
return reaction(event, app) return reaction(event, app)

View File

@@ -0,0 +1,235 @@
"""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)
# By value: a rescan rebuilds the Playlist objects (see play_figure).
if app.player.playlist == playlist:
_log.info("Album picker: keeping %s", playlist.album_id)
return
_log.info("Album picker: %r -> %s", selector.figure, playlist.album_id)
if playlist.album_id is not None:
# The same way in as the web front-end, which also remembers the album as the
# figure's for next time.
app.bus.emit(PlayAlbumRequested(album_id=playlist.album_id, source="device"))
@on(FigureSelectorExpired)
def selector_expired(event: FigureSelectorExpired, app: App) -> None:
selector = app.state.selector
# Stale if the picker it was for has closed, or a newer one is still in time.
if selector is None or selector.figure != event.figure:
return
if app.clock.now() < selector.deadline:
return
_close(app, fade=True)
@on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
# The off animation takes the ring; the knob goes back to being a volume knob.
if app.state.selector is not None:
_close(app, fade=False)
# ----------------------------------------------------------------------- internals
def _open(app: App, figure: str, choices: tuple[Playlist, ...]) -> None:
selector = FigureSelector(
figure=figure,
choices=choices,
selected=app.default_choice(figure),
deadline=app.clock.now() + tuning(app).timeout,
)
app.state.selector = selector
app.mouse.capture_knob(True)
app.mouse.set_effect(LedZone.RING, _ring(app, selector, None), origin="device")
selector.expiry = asyncio.create_task(_expire(app, selector), name="album-picker")
_log.info("Album picker for %r: %d albums", figure, len(choices))
_announce(app, selector, open_=True)
def _close(app: App, *, fade: bool) -> None:
selector = app.state.selector
if selector is None:
return
app.state.selector = None
app.mouse.capture_knob(False)
if selector.expiry is not None:
selector.expiry.cancel()
if fade:
fade_to_figure(app, LedZone.RING, app.colors(selector.figure))
_announce(app, selector, open_=False)
async def _expire(app: App, selector: FigureSelector) -> None:
# Every turn pushes the deadline back, so sleep until it stops moving.
while (remaining := selector.deadline - app.clock.now()) > 0:
await app.clock.sleep(remaining)
app.bus.emit(FigureSelectorExpired(figure=selector.figure))
def _ring(app: App, selector: FigureSelector, previous: int | None) -> EffectSegmentSelect:
settings = tuning(app)
return EffectSegmentSelect(
colors=PALETTE[: len(selector.choices)],
selected=selector.selected,
previous=previous,
gap_leds=settings.gap_leds,
offset_degrees=settings.offset_degrees,
direction=settings.ring_direction,
dim_factor=settings.dim_factor,
wobble_amplitude=settings.wobble_amplitude,
wobble_period_ms=settings.wobble_period_ms,
slide_ms=settings.slide_ms,
edge_blend=settings.edge_blend,
)
def _announce(app: App, selector: FigureSelector, *, open_: bool) -> None:
app.bus.emit(
FigureSelectionChanged(
figure=selector.figure,
selected=selector.selected,
count=len(selector.choices),
open=open_,
source="device",
)
)
def _play_sound(app: App, make: Callable[..., bytes], base_gain: float) -> None:
general = app.config.general
if general.audio_simulated:
return
gain = base_gain * ui_level(app.player.volume / 100, general.effects_volume / 100)
if gain > 0:
play_wav_soon(general.alsa_device, make(gain=gain))

View File

@@ -0,0 +1,175 @@
"""Whether today is a school day: weekends, public holidays and school holidays.
The holidays come from the OpenHolidays API (https://openholidaysapi.org), which has
both kinds per German state - Bavaria's ``DE-BY`` school holidays include the
Buß- und Bettag, which is a school day off but not a public holiday. Public holidays
carry their subdivisions, so a city-only one like Augsburg's Friedensfest
(``DE-BY-AU``) counts for Augsburg and not for the rest of Bavaria.
Fetched once a day and kept in a JSON file, so a device that loses its internet keeps
knowing the holidays for the rest of the school year.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import httpx2
_log = logging.getLogger(__name__)
__all__ = [
"API_URL",
"Holiday",
"HolidayCalendar",
"fetch_calendar",
"load_calendar",
"parse_holidays",
"save_calendar",
"school_day",
]
API_URL = "https://openholidaysapi.org"
#: How far back and ahead each fetch reaches. Ahead covers a whole school year from
#: any date, so the cache outlives a long outage.
_FETCH_BEFORE = timedelta(days=30)
_FETCH_AHEAD = timedelta(days=400)
_WEEKEND = "Wochenende"
@dataclass(frozen=True, slots=True)
class Holiday:
start: date
end: date
name: str
nationwide: bool
subdivisions: tuple[str, ...]
def covers(self, day: date) -> bool:
return self.start <= day <= self.end
def applies_to(self, subdivision: str) -> bool:
"""``DE-BY-AU`` gets ``DE-BY``'s holidays as well as its own; ``DE-BY`` does
not get ``DE-BY-AU``'s."""
return self.nationwide or any(
subdivision == code or subdivision.startswith(code + "-") for code in self.subdivisions
)
@dataclass(frozen=True, slots=True)
class HolidayCalendar:
#: The day it was fetched, to know when to fetch again.
fetched: date
subdivision: str
school: tuple[Holiday, ...]
public: tuple[Holiday, ...]
def parse_holidays(raw: list[dict[str, Any]]) -> tuple[Holiday, ...]:
"""The API's JSON list, either endpoint. Entries that do not parse are skipped."""
holidays = []
for entry in raw:
try:
names = entry.get("name") or []
name = next(
(n["text"] for n in names if n.get("language") == "DE"),
names[0]["text"] if names else "Feiertag",
)
holidays.append(
Holiday(
start=date.fromisoformat(entry["startDate"]),
end=date.fromisoformat(entry["endDate"]),
name=name,
nationwide=bool(entry.get("nationwide")),
subdivisions=tuple(s["code"] for s in entry.get("subdivisions") or []),
)
)
except (KeyError, TypeError, ValueError, IndexError):
_log.debug("Skipping a holiday entry that does not parse: %r", entry)
return tuple(holidays)
def school_day(day: date, calendar: HolidayCalendar | None) -> tuple[bool | None, str | None]:
"""``(is_school_day, why_not)``. ``None`` when there is no calendar to ask -
the weekend is still known then, a holiday is not."""
if day.weekday() >= 5:
return False, _WEEKEND
if calendar is None:
return None, None
for holiday in (*calendar.public, *calendar.school):
if holiday.covers(day) and holiday.applies_to(calendar.subdivision):
return False, holiday.name
return True, None
async def fetch_calendar(
client: httpx2.AsyncClient, subdivision: str, today: date
) -> HolidayCalendar:
"""Both lists for ``subdivision``'s state. Raises on any network or HTTP error."""
# The API files school holidays under the state; city-level ones are filtered here.
state = "-".join(subdivision.split("-")[:2])
params = {
"countryIsoCode": state.split("-")[0],
"subdivisionCode": state,
"languageIsoCode": "DE",
"validFrom": (today - _FETCH_BEFORE).isoformat(),
"validTo": (today + _FETCH_AHEAD).isoformat(),
}
lists = []
for endpoint in ("SchoolHolidays", "PublicHolidays"):
response = await client.get(f"{API_URL}/{endpoint}", params=params)
response.raise_for_status()
lists.append(parse_holidays(response.json()))
school, public = lists
return HolidayCalendar(fetched=today, subdivision=subdivision, school=school, public=public)
def save_calendar(path: Path, calendar: HolidayCalendar) -> None:
def dump(holidays: tuple[Holiday, ...]) -> list[dict[str, Any]]:
return [
{
"startDate": h.start.isoformat(),
"endDate": h.end.isoformat(),
"name": [{"language": "DE", "text": h.name}],
"nationwide": h.nationwide,
"subdivisions": [{"code": code} for code in h.subdivisions],
}
for h in holidays
]
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched": calendar.fetched.isoformat(),
"subdivision": calendar.subdivision,
"school": dump(calendar.school),
"public": dump(calendar.public),
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload), encoding="utf-8")
temporary.replace(path)
def load_calendar(path: Path, subdivision: str) -> HolidayCalendar | None:
"""The cached calendar, or ``None`` if there is none for this subdivision."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if payload["subdivision"] != subdivision:
return None
return HolidayCalendar(
fetched=date.fromisoformat(payload["fetched"]),
subdivision=subdivision,
school=parse_holidays(payload["school"]),
public=parse_holidays(payload["public"]),
)
except FileNotFoundError:
return None
except (OSError, KeyError, TypeError, ValueError):
_log.warning("Ignoring an unreadable holiday cache at %s", path)
return None

View File

@@ -1,7 +1,8 @@
"""Front-ends: things that mirror state outwards and turn requests into intents. """Front-ends: things that mirror state outwards and turn requests into intents.
``MqttService`` is the reference implementation. A web service would be another file MQTT (Home Assistant), web (the browser), lirc (the IR remote) and the screen, timer and
here plus one line in the app - devices and reactions would not change. podcast services each live here; adding another is a file here plus a line in
``__main__._build_services`` - devices and reactions do not change.
""" """
from musicmouse.services.base import Service from musicmouse.services.base import Service

View File

@@ -7,6 +7,15 @@ A line looks like::
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
the first press and increments while the button is held - lircd has no separate the first press and increments while the button is held - lircd has no separate
key-up event, just repeats stopping. key-up event, just repeats stopping.
irserver (the IRTrans daemon, which serves this protocol on its own port 8765) sends
the same shape but lowercases the button and remote names and always reports repeat
``00``::
b7b2000000000000 00 key_play hauppauge
Button names are therefore uppercased here, so the rest of the code sees lircd's
spelling either way.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,4 +43,4 @@ def parse_line(line: str) -> LircButtonEvent | None:
repeat = int(repeat_hex, 16) repeat = int(repeat_hex, 16)
except ValueError: except ValueError:
return None return None
return LircButtonEvent(code=code, repeat=repeat, button=button, remote=remote) return LircButtonEvent(code=code, repeat=repeat, button=button.upper(), remote=remote)

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from musicmouse.app import App from musicmouse.app import App
from musicmouse.clock import Clock, RealClock from musicmouse.clock import Clock, RealClock
@@ -20,11 +21,12 @@ from musicmouse.events import (
IntentEvent, IntentEvent,
NextTrackRequested, NextTrackRequested,
PauseRequested, PauseRequested,
PlayAlbumRequested, PlayRemoteSlotRequested,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
SetVolumeRequested, SetVolumeRequested,
ToggleClockRequested,
ToggleScreenRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
@@ -43,12 +45,24 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"), "KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"), "KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"), "KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
# Does nothing unless general.screen is configured - see ScreenService.
"KEY_POWER": lambda: ToggleScreenRequested(source="lirc"),
# "Guide": what time is it. Shows the kiosk's clock overlay. KEY_GUIDE is what
# irserver's hauppauge.rem calls it (the ansible repo); lircd's own Hauppauge
# config says KEY_EPG.
"KEY_GUIDE": lambda: ToggleClockRequested(source="lirc"),
} }
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary #: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
#: encoder (see ``reactions.playback.rotary_turned``). #: encoder (see ``reactions.playback.rotary_turned``).
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1} _VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
#: irserver reports every line of a held button as repeat ``00``, so a long-ish press
#: would act several times. A line of the same button arriving within this many seconds
#: of the previous one is counted as a repeat instead. The Hauppauge remote resends
#: about every 0.11s while held; lircd numbers repeats itself and is unaffected.
_REPEAT_GAP = 0.25
_DIGITS: dict[str, Digit] = { _DIGITS: dict[str, Digit] = {
"BTN_0": "0", "BTN_0": "0",
"BTN_1": "1", "BTN_1": "1",
@@ -73,6 +87,9 @@ class LircService:
#: Volume to restore on the next mute press, remembered the way the frontend's #: Volume to restore on the next mute press, remembered the way the frontend's
#: own mute toggle does (App.tsx) rather than via any new Player API. #: own mute toggle does (App.tsx) rather than via any new Player API.
self._pre_mute_volume: int | None = None self._pre_mute_volume: int | None = None
#: The last line acted on, for counting repeats irserver does not count.
self._last: LircButtonEvent | None = None
self._last_at = 0.0
async def run(self) -> None: async def run(self) -> None:
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled.""" """Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
@@ -99,13 +116,27 @@ class LircService:
if not raw: if not raw:
return return
event = parse_line(raw.decode(errors="replace")) event = parse_line(raw.decode(errors="replace"))
if event is None or event.remote != self.config.remote_name: if event is None or event.remote.casefold() != self.config.remote_name.casefold():
continue continue
self._handle(event) self._handle(self._count_repeat(event))
finally: finally:
writer.close() writer.close()
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc")) self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
def _count_repeat(self, event: LircButtonEvent) -> LircButtonEvent:
now = self._clock.now()
last, last_at = self._last, self._last_at
self._last_at = now
if (
event.repeat == 0
and last is not None
and last.button == event.button
and now - last_at < _REPEAT_GAP
):
event = replace(event, repeat=last.repeat + 1)
self._last = event
return event
def _handle(self, event: LircButtonEvent) -> None: def _handle(self, event: LircButtonEvent) -> None:
if event.button in _TRANSPORT: if event.button in _TRANSPORT:
if event.repeat == 0: if event.repeat == 0:
@@ -116,8 +147,13 @@ class LircService:
elif event.button == "KEY_MUTE": elif event.button == "KEY_MUTE":
if event.repeat == 0: if event.repeat == 0:
self._toggle_mute() self._toggle_mute()
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0: elif (digit := _DIGITS.get(event.button)) is not None:
if event.repeat == 0:
self._play_digit(digit) self._play_digit(digit)
elif event.repeat == 0:
# irserver's names do not always match lircd's; this is how to find out
# what a new button is called on the device.
_log.debug("Unmapped IR button %s", event.button)
def _toggle_mute(self) -> None: def _toggle_mute(self) -> None:
player = self.app.player player = self.app.player
@@ -129,12 +165,4 @@ class LircService:
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc")) self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
def _play_digit(self, digit: Digit) -> None: def _play_digit(self, digit: Digit) -> None:
slot = self.app.config.remote.get(digit) self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))
if slot is None:
return
if slot.target_kind == "album":
self.app.bus.emit(
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
)
else:
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))

View File

@@ -13,7 +13,7 @@ from typing import Final
import httpx2 import httpx2
from musicmouse.library import MusicLibrary from musicmouse.library import MusicLibrary
from musicmouse.library.podcast_feeds import sync_all_shows from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -34,11 +34,13 @@ class PodcastFeedService:
client: httpx2.AsyncClient, client: httpx2.AsyncClient,
on_change: Callable[[], Awaitable[None]], on_change: Callable[[], Awaitable[None]],
interval: float = _CHECK_INTERVAL_SECONDS, interval: float = _CHECK_INTERVAL_SECONDS,
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
) -> None: ) -> None:
self.library = library self.library = library
self.client = client self.client = client
self.on_change = on_change self.on_change = on_change
self.interval = interval self.interval = interval
self.episode_limit = episode_limit
async def run(self) -> None: async def run(self) -> None:
"""Checks immediately at startup, then every `interval` seconds. """Checks immediately at startup, then every `interval` seconds.
@@ -51,7 +53,8 @@ class PodcastFeedService:
try: try:
while True: while True:
try: try:
if await sync_all_shows(self.client, self.library.root): keep = self.episode_limit
if await sync_all_shows(self.client, self.library.root, keep=keep):
await self.on_change() await self.on_change()
except Exception: except Exception:
_log.exception("Podcast feed check failed; will retry next interval") _log.exception("Podcast feed check failed; will retry next interval")

View File

@@ -0,0 +1,204 @@
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request,
and switches it off by itself when nothing is going on.
A service rather than a reaction because the command is a subprocess: the bus awaits
async handlers, so running it there would hold every other event up behind ``xset``.
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, 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
it would tell may be asleep. "Doing anything" is any intent a person could have caused:
a remote key, a web command, a key press in the kiosk browser (:class:`UserActivity`).
The first such intent after an auto-off runs ``on_command``, so the monitor wakes up
for the remote too, not only for the keyboard X already wakes it for.
"""
from __future__ import annotations
import asyncio
import logging
import shlex
from collections.abc import Callable
from musicmouse.blubb import blubb_wav
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import ScreenConfig
from musicmouse.events import (
Event,
IntentEvent,
LedEffectRequested,
PlaybackChanged,
ToggleScreenRequested,
)
_log = logging.getLogger(__name__)
__all__ = ["ScreenService"]
#: The blubb's peak at full player volume, as a fraction of full scale. The music is
#: mastered near full scale; a notification sound sits well under it.
_BLUBB_GAIN = 0.35
#: How often the idle auto-off looks at the clock. Coarse on purpose: nobody notices
#: whether a screen went dark after 15 minutes or 15 and a half.
_IDLE_CHECK_INTERVAL = 30.0
class ScreenService:
name = "screen"
def __init__(
self,
bus: EventBus,
config: ScreenConfig,
*,
alsa_device: str | None,
volume: Callable[[], float],
is_playing: Callable[[], bool] = lambda: False,
clock: Clock | None = None,
) -> None:
self.bus = bus
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
#: very object.
self.config = config
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device
#: 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()
self._last_activity = self._clock.now()
#: The monitor was last switched by the idle auto-off, not by a person.
self._auto_off = False
self._running: asyncio.Task[None] | None = None
async def run(self) -> None:
unsubscribe_toggle = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
unsubscribe_all = self.bus.subscribe_all(self._on_event)
try:
while True:
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
self.check_idle()
finally:
unsubscribe_toggle()
unsubscribe_all()
if self._running is not None:
self._running.cancel()
def _on_toggle(self, _event: ToggleScreenRequested) -> None:
# Whichever way the press goes, a person decided it; nothing to wake from.
self._auto_off = False
self._start(self.config.toggle_command, sound=True)
def _on_event(self, event: Event) -> None:
if isinstance(event, PlaybackChanged):
self._last_activity = self._clock.now()
return
# LED intents arrive from Home Assistant automations as well as people, and
# "system" intents are the backend talking to itself.
if (
not isinstance(event, IntentEvent)
or isinstance(event, LedEffectRequested)
or event.source == "system"
):
return
self._last_activity = self._clock.now()
if self._auto_off and not isinstance(event, ToggleScreenRequested):
self._auto_off = False
if self.config.on_command:
self._start(self.config.on_command, sound=True)
def check_idle(self) -> None:
"""Switch the monitor off if it has been idle long enough. Called every
:data:`_IDLE_CHECK_INTERVAL` by :meth:`run`."""
now = self._clock.now()
if self.is_playing():
# The countdown starts when the music stops, not when it started.
self._last_activity = now
return
minutes = self.config.auto_off_minutes
if self._auto_off or not minutes or not self.config.off_command:
return
if now - self._last_activity < minutes * 60:
return
if self._start(self.config.off_command, sound=False):
_log.info("Nothing played for %d minutes; switching the screen off", minutes)
self._auto_off = True
def _start(self, command: str, *, sound: bool) -> bool:
# A second switch while the first command is still running would read the
# monitor's state before the first one changed it, and undo it.
if self._running is not None and not self._running.done():
_log.debug("Screen command still running; ignoring this one")
return False
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
return True
async def _run(self, command: str, *, sound: bool) -> None:
argv = shlex.split(command)
try:
process = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
except OSError as exc:
_log.warning("Screen command %r could not start: %s", command, exc)
return
try:
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
except TimeoutError:
process.kill()
await process.wait()
_log.warning(
"Screen command %r took longer than %gs; killed", command, self.config.timeout
)
return
text = output.decode(errors="replace").strip()
if process.returncode != 0:
_log.warning(
"Screen command %r failed (exit %d): %s", command, process.returncode, text
)
return
_log.info("Screen switched%s", f": {text}" if text else "")
state = text.split()[-1] if text else ""
if sound and state in ("on", "off"):
await self.blubb(on=state == "on")
async def blubb(self, *, on: bool) -> None:
gain = _BLUBB_GAIN * max(0.0, min(1.0, self.volume()))
if self.alsa_device is None or gain <= 0:
return
wav = await asyncio.to_thread(blubb_wav, on=on, gain=gain)
try:
process = await asyncio.create_subprocess_exec(
"aplay",
"-q",
"-D",
self.alsa_device,
"-",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
_log.warning("Could not play the screen sound: %s", exc)
return
try:
_, error = await asyncio.wait_for(process.communicate(wav), self.config.timeout)
except TimeoutError:
process.kill()
await process.wait()
_log.warning("Screen sound took longer than %gs; killed", self.config.timeout)
return
if process.returncode != 0:
_log.warning(
"Screen sound failed (exit %d): %s",
process.returncode,
error.decode(errors="replace").strip(),
)

View File

@@ -0,0 +1,226 @@
"""The clock overlay's backend half: the parents' countdown and today's school day.
The overlay itself lives in the kiosk browser. What has to live here is what several
screens share (a timer set from a parent's phone shows on the kiosk), what has to keep
going with the screen off (the chime), and what needs the internet (the holidays).
The countdown runs on the injected :class:`~musicmouse.clock.Clock` like everything
else that waits; wall time comes from ``wall`` so a test can pin the date.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from datetime import date, datetime, timedelta
from pathlib import Path
import httpx2
from musicmouse.app import AppState, ParentTimer
from musicmouse.blubb import chime_wav
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import ClockConfig
from musicmouse.events import (
CancelTimerRequested,
ClockStateChanged,
Event,
IntentEvent,
LedEffectRequested,
SetTimerRequested,
)
from musicmouse.school_days import (
HolidayCalendar,
fetch_calendar,
load_calendar,
save_calendar,
school_day,
)
from musicmouse.sound import play_wav
_log = logging.getLogger(__name__)
__all__ = ["RING_SECONDS", "TimerService", "timer_end"]
#: How long a timer that ran out keeps showing 00:00 before it clears by itself.
RING_SECONDS = 60.0
#: The chime's peak at full player volume, as a fraction of full scale - soft.
_CHIME_GAIN = 0.3
#: After a failed holiday fetch, try again this much later rather than at midnight.
_RETRY_SECONDS = 3600.0
#: Wake this long after midnight, so the new day has surely begun.
_AFTER_MIDNIGHT = 5.0
def timer_end(now: datetime, *, minutes: int | None = None, until: str | None = None) -> datetime:
"""When a timer started at ``now`` ends: ``minutes`` from now, or at the next
``until`` ("HH:MM") - tomorrow if that time has already passed today."""
if (minutes is None) == (until is None):
raise ValueError("give exactly one of minutes and until")
if minutes is not None:
return now + timedelta(minutes=minutes)
assert until is not None
hour, minute = (int(part) for part in until.split(":"))
end = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
return end if end > now else end + timedelta(days=1)
class TimerService:
name = "timers"
def __init__(
self,
bus: EventBus,
state: AppState,
config: ClockConfig,
*,
cache_file: Path,
client: httpx2.AsyncClient,
alsa_device: str | None,
volume: Callable[[], float],
clock: Clock | None = None,
wall: Callable[[], datetime] = datetime.now,
) -> None:
self.bus = bus
self.state = state
self.config = config
self.cache_file = cache_file
self.client = client
#: Where the chime plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device
#: 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
self._calendar: HolidayCalendar | None = None
self._countdown: asyncio.Task[None] | None = None
self._chime_task: asyncio.Task[None] | None = None
# ------------------------------------------------------------------ lifecycle
async def run(self) -> None:
unsubscribe = [
self.bus.subscribe(SetTimerRequested, self._on_set),
self.bus.subscribe(CancelTimerRequested, self._on_cancel),
self.bus.subscribe_all(self._on_event),
]
try:
while True:
fetched = await self.refresh_school_day()
await self._clock.sleep(self._seconds_to_midnight() if fetched else _RETRY_SECONDS)
finally:
for undo in unsubscribe:
undo()
if self._countdown is not None:
self._countdown.cancel()
await self.client.aclose()
# ----------------------------------------------------------------- school day
async def refresh_school_day(self) -> bool:
"""Work out today's school day, fetching the holidays if the cached ones are
not from today. ``False`` if a fetch was due and failed."""
today = self._wall().date()
ok = True
if self._calendar is None:
self._calendar = load_calendar(self.cache_file, self.config.subdivision)
if self._calendar is None or self._calendar.fetched < today:
try:
self._calendar = await fetch_calendar(self.client, self.config.subdivision, today)
save_calendar(self.cache_file, self._calendar)
except Exception as exc:
ok = False
_log.warning(
"Could not fetch the school holidays (%s); %s",
exc,
"using the cached ones" if self._calendar else "only weekends are known",
)
self._set_school_day(today)
return ok
def _set_school_day(self, today: date) -> None:
is_school_day, reason = school_day(today, self._calendar)
if (is_school_day, reason) == (self.state.school_day, self.state.school_day_reason):
return
self.state.school_day = is_school_day
self.state.school_day_reason = reason
_log.info(
"Today is %s",
"a school day" if is_school_day else f"no school day ({reason or 'unknown'})",
)
self.bus.emit(ClockStateChanged())
def _seconds_to_midnight(self) -> float:
now = self._wall()
midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
return (midnight - now).total_seconds() + _AFTER_MIDNIGHT
# ---------------------------------------------------------------- the timer
def _on_set(self, event: SetTimerRequested) -> None:
self._stop_countdown()
now = self._wall().timestamp()
self.state.parent_timer = ParentTimer(started_at=now, ends_at=event.ends_at)
self.bus.emit(ClockStateChanged())
self._countdown = asyncio.create_task(
self._run_countdown(event.ends_at - now), name="parent-timer"
)
def _on_cancel(self, _event: CancelTimerRequested) -> None:
self._stop_countdown()
self._clear()
def _on_event(self, event: Event) -> None:
"""Anything a person does silences a ringing timer - the same notion of "a
person" as the screen's idle auto-off: intents, minus Home Assistant's LED
writes and the backend talking to itself."""
timer = self.state.parent_timer
if (
timer is None
or not timer.ringing
or not isinstance(event, IntentEvent)
or isinstance(event, (LedEffectRequested, SetTimerRequested, CancelTimerRequested))
or event.source == "system"
):
return
self._stop_countdown()
self._clear()
async def _run_countdown(self, seconds: float) -> None:
await self._clock.sleep(max(0.0, seconds))
timer = self.state.parent_timer
if timer is None:
return
self.state.parent_timer = ParentTimer(
started_at=timer.started_at, ends_at=timer.ends_at, ringing=True
)
_log.info("The parents' timer ran out")
self.bus.emit(ClockStateChanged())
# Its own task: silencing the timer must not cut the chime off mid-note.
self._chime_task = asyncio.create_task(self._chime(), name="parent-timer-chime")
await self._clock.sleep(RING_SECONDS)
self._countdown = None
self._clear()
async def _chime(self) -> None:
if self.alsa_device is None:
return
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)
def _stop_countdown(self) -> None:
if self._countdown is not None:
self._countdown.cancel()
self._countdown = None
def _clear(self) -> None:
if self.state.parent_timer is None:
return
self.state.parent_timer = None
self.bus.emit(ClockStateChanged())

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -22,15 +23,22 @@ from fastapi.responses import FileResponse
from musicmouse.app import App from musicmouse.app import App
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
from musicmouse.events import ( from musicmouse.events import (
CancelTimerRequested,
IntentEvent, IntentEvent,
NextTrackRequested, NextTrackRequested,
PauseRequested, PauseRequested,
PlayAlbumRequested, PlayAlbumRequested,
PlayRemoteSlotRequested,
PlayRequested, PlayRequested,
PreviousTrackRequested, PreviousTrackRequested,
SeekRequested, SeekRequested,
SetTimerRequested,
SetVolumeRequested, SetVolumeRequested,
ToggleScreenRequested,
UserActivity,
VolumeChangeRequested,
) )
from musicmouse.services.timers import timer_end
from musicmouse.services.web.hub import StateHub from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.remote_settings import read_mapping, write_mapping from musicmouse.services.web.remote_settings import read_mapping, write_mapping
from musicmouse.services.web.schemas import ( from musicmouse.services.web.schemas import (
@@ -38,7 +46,6 @@ from musicmouse.services.web.schemas import (
HaConfigOut, HaConfigOut,
HaDeviceOut, HaDeviceOut,
LibraryOut, LibraryOut,
LircConfigOut,
PlayerStateOut, PlayerStateOut,
PlayIn, PlayIn,
RemoteMappingIn, RemoteMappingIn,
@@ -46,6 +53,7 @@ from musicmouse.services.web.schemas import (
SeekIn, SeekIn,
SettingsIn, SettingsIn,
SettingsOut, SettingsOut,
TimerIn,
TippenCurriculumOut, TippenCurriculumOut,
TippenProgressOut, TippenProgressOut,
TippenRunIn, TippenRunIn,
@@ -59,7 +67,6 @@ from musicmouse.services.web.schemas import (
from musicmouse.services.web.settings import ( from musicmouse.services.web.settings import (
read_settings, read_settings,
to_device_volume, to_device_volume,
to_percent,
write_settings, write_settings,
) )
from musicmouse.services.web.state import snapshot from musicmouse.services.web.state import snapshot
@@ -97,16 +104,32 @@ def build_router(
) )
@router.get("/albums/{album_id}/cover") @router.get("/albums/{album_id}/cover")
def get_cover(album_id: str) -> FileResponse: def get_cover(album_id: str, size: str = "full", v: str | None = None) -> FileResponse:
album = app.library.get(album_id) album = app.library.get(album_id)
if album is None or album.cover is None: if album is None or album.cover is None:
# Not an error: the client paints the album's own colours instead. # Not an error: the client paints the album's own colours instead.
raise HTTPException(status_code=404, detail="no cover") raise HTTPException(status_code=404, detail="no cover")
path = album.cover
if size == "thumb":
thumb = path.with_name(f"{path.stem}.thumb.jpg")
if thumb.is_file():
path = thumb
return FileResponse( return FileResponse(
album.cover, path,
# Cover files are content-addressed by album id and rewritten only by a # A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
# rescan, so a long cache is safe and saves 20 requests per page load. # exact set of bytes: reprocessing the art changes the library payload, so
headers={"Cache-Control": "public, max-age=604800"}, # the client asks for a new URL. Those can be cached forever, and the
# browser then never asks again - no conditional request per card.
#
# Without `v` the id alone is not enough (the bytes behind it change whenever
# the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on
# decoding old 3000px files out of their cache), so `no-cache` makes the
# browser revalidate: it keeps the file and usually gets a 304.
headers={
"Cache-Control": (
"public, max-age=31536000, immutable" if v else "no-cache"
)
},
) )
@router.get("/tracks/{album_id}/{index}/analysis") @router.get("/tracks/{album_id}/{index}/analysis")
@@ -173,6 +196,30 @@ def build_router(
def previous_track() -> Response: def previous_track() -> Response:
return emit(PreviousTrackRequested(source="web")) return emit(PreviousTrackRequested(source="web"))
@router.post("/screen/toggle", status_code=204)
def toggle_screen() -> Response:
# The device's own monitor - only the kiosk page on the device calls this (its
# Del key). Without a screen section in the config nothing listens and it is a
# no-op, like the remote's power key.
return emit(ToggleScreenRequested(source="web"))
@router.post("/screen/activity", status_code=204)
def screen_activity() -> Response:
# The kiosk page on the device reports key presses and taps here, so the
# screen's idle auto-off does not blank it under a child who is browsing.
return emit(UserActivity(source="web"))
@router.post("/timer", status_code=204)
def set_timer(body: TimerIn) -> Response:
# The parents' countdown. The new state, ringing and all, arrives over the
# websocket like everything else.
end = timer_end(datetime.now(), minutes=body.minutes, until=body.until)
return emit(SetTimerRequested(ends_at=end.timestamp(), source="web"))
@router.delete("/timer", status_code=204)
def cancel_timer() -> Response:
return emit(CancelTimerRequested(source="web"))
@router.post("/seek", status_code=204) @router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response: def seek(body: SeekIn) -> Response:
return emit(SeekRequested(position=body.position, source="web")) return emit(SeekRequested(position=body.position, source="web"))
@@ -180,16 +227,15 @@ def build_router(
@router.post("/volume", status_code=204) @router.post("/volume", status_code=204)
def set_volume(body: VolumeIn) -> Response: def set_volume(body: VolumeIn) -> Response:
general = app.config.general general = app.config.general
if body.percent is not None: if body.steps is not None:
target = body.percent # The IR remote's own step, in device units - see LircService._handle.
elif body.delta_percent is not None:
target = to_percent(app.player.volume, general) + body.delta_percent
else:
raise HTTPException(status_code=422, detail="percent or delta_percent required")
return emit( return emit(
SetVolumeRequested( VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web")
volume=to_device_volume(max(0, min(100, target)), general), source="web"
) )
if body.percent is None:
raise HTTPException(status_code=422, detail="percent or steps required")
return emit(
SetVolumeRequested(volume=to_device_volume(body.percent, general), source="web")
) )
# ------------------------------------------------------------- parent mode # ------------------------------------------------------------- parent mode
@@ -212,8 +258,13 @@ def build_router(
general = app.config.general general = app.config.general
# Applied live as well as saved: a parent lowering the ceiling expects the next # Applied live as well as saved: a parent lowering the ceiling expects the next
# song to be quieter, not the next boot. # song to be quieter, not the next boot.
for key, value in body.model_dump().items(): values = body.model_dump()
auto_off = values.pop("screen_auto_off_minutes")
for key, value in values.items():
setattr(general, key, value) setattr(general, key, value)
if auto_off is not None and general.screen is not None:
# The same object ScreenService reads on every tick.
general.screen.auto_off_minutes = auto_off
app.player.set_volume_limits(general.min_volume, general.max_volume) app.player.set_volume_limits(general.min_volume, general.max_volume)
await asyncio.to_thread(write_settings, config_path, body) await asyncio.to_thread(write_settings, config_path, body)
await hub.broadcast_state() await hub.broadcast_state()
@@ -260,11 +311,11 @@ def build_router(
# -------------------------------------------------------------- IR remote # -------------------------------------------------------------- IR remote
@router.get("/lirc") @router.post("/remote/press/{digit}", status_code=204)
def get_lirc_config() -> LircConfigOut: def press_remote(digit: Digit) -> Response:
if app.config.general.lirc is None: # A keyboard's number keys, doing what the IR remote's do. An unassigned key
raise HTTPException(status_code=404, detail="lirc not configured") # is not an error - the remote's own press of it is silent too.
return LircConfigOut(connected=app.state.lirc_connected) return emit(PlayRemoteSlotRequested(digit=digit, source="web"))
@router.get("/remote/mapping") @router.get("/remote/mapping")
def get_remote_mapping() -> RemoteMappingOut: def get_remote_mapping() -> RemoteMappingOut:
@@ -284,7 +335,19 @@ def build_router(
status_code=422, status_code=422,
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}", detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
) )
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target) if slot.target_kind == "series" and slot.track_index:
raise HTTPException(
status_code=422,
detail=f"key {digit}: a podcast show has no fixed song to start at",
)
if slot.track_index >= len(found.tracks):
raise HTTPException(
status_code=422,
detail=f"key {digit}: {slot.target!r} has no track {slot.track_index}",
)
resolved[digit] = RemoteSlotConfig(
target_kind=slot.target_kind, target=slot.target, track_index=slot.track_index
)
app.config.remote = resolved app.config.remote = resolved
await asyncio.to_thread(write_mapping, config_path, resolved) await asyncio.to_thread(write_mapping, config_path, resolved)
@@ -313,6 +376,10 @@ def build_router(
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
) )
except httpx2.HTTPError as exc: except httpx2.HTTPError as exc:
# httpx logs a request only once it has a response, so without this a call
# that times out - a Homematic command the device never acknowledges can
# keep Home Assistant from answering - leaves no trace at all.
_log.warning("Home Assistant %s %s failed: %r", method, path, exc)
raise HTTPException( raise HTTPException(
status_code=502, detail=f"Home Assistant unreachable: {exc}" status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc ) from exc

View File

@@ -22,7 +22,19 @@ from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
from musicmouse.app import App from musicmouse.app import App
from musicmouse.events import Event, StateEvent from musicmouse.events import (
ActiveFigureChanged,
ClockStateChanged,
ConnectionChanged,
Event,
FigureSelectionChanged,
LedEffectChanged,
PlaybackChanged,
StateEvent,
ToggleClockRequested,
TrackChanged,
VolumeChanged,
)
from musicmouse.services.web.state import snapshot from musicmouse.services.web.state import snapshot
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -33,22 +45,45 @@ __all__ = ["StateHub"]
#: enough to leave running. #: enough to leave running.
POSITION_INTERVAL = 0.5 POSITION_INTERVAL = 0.5
#: The state events that change something in :func:`~musicmouse.services.web.state.snapshot`
#: - each one costs every open tab a full snapshot. LED writes are the reason this is a
#: list rather than "every StateEvent": a figure placed and removed is 9 LED writes,
#: and the snapshot has no LED state in it.
SNAPSHOT_EVENTS: tuple[type[StateEvent], ...] = (
PlaybackChanged,
TrackChanged,
VolumeChanged,
ActiveFigureChanged,
ConnectionChanged,
ClockStateChanged,
)
#: State events deliberately not pushed to the browser. Together with
#: :data:`SNAPSHOT_EVENTS` this covers every :class:`StateEvent`; a test holds that, so
#: a new state event has to be sorted into one or the other.
#: The ring's album picker is shown on the LED ring, not in the browser.
IGNORED_EVENTS: tuple[type[StateEvent], ...] = (LedEffectChanged, FigureSelectionChanged)
class StateHub: class StateHub:
def __init__(self, app: App) -> None: def __init__(self, app: App) -> None:
self.app = app self.app = app
self._clients: set[WebSocket] = set() self._clients: set[WebSocket] = set()
self._unsubscribe: Any = None self._unsubscribe: list[Any] = []
# ------------------------------------------------------------------- lifecycle # ------------------------------------------------------------------- lifecycle
def start(self) -> None: def start(self) -> None:
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state) self._unsubscribe = [
*(self.app.bus.subscribe(kind, self._on_state) for kind in SNAPSHOT_EVENTS),
# The clock overlay is the kiosk's own; the remote's key only asks for it.
self.app.bus.subscribe(ToggleClockRequested, self._on_clock_toggle),
]
def stop(self) -> None: def stop(self) -> None:
if self._unsubscribe is not None: for unsubscribe in self._unsubscribe:
self._unsubscribe() unsubscribe()
self._unsubscribe = None self._unsubscribe = []
async def run(self) -> None: async def run(self) -> None:
"""Push the playback position while anything is playing.""" """Push the playback position while anything is playing."""
@@ -102,3 +137,6 @@ class StateHub:
async def _on_state(self, _event: Event) -> None: async def _on_state(self, _event: Event) -> None:
await self.broadcast_state() await self.broadcast_state()
async def _on_clock_toggle(self, _event: ToggleClockRequested) -> None:
await self.broadcast({"type": "clock_toggle"})

View File

@@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
target_kind=slot.target_kind, target_kind=slot.target_kind,
target=slot.target, target=slot.target,
resolved_album_id=_resolve(slot, library), resolved_album_id=_resolve(slot, library),
track_index=slot.track_index,
) )
for digit, slot in sorted(remote.items()) for digit, slot in sorted(remote.items())
] ]
@@ -40,9 +41,12 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None: def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
document = load_document(path) document = load_document(path)
remote = { remote: dict[str, dict[str, str | int]] = {}
digit: {"target_kind": slot.target_kind, "target": slot.target} for digit, slot in mapping.items():
for digit, slot in mapping.items() entry: dict[str, str | int] = {"target_kind": slot.target_kind, "target": slot.target}
} # Only written when it says something, so a whole-album slot reads as before.
if slot.track_index:
entry["track_index"] = slot.track_index
remote[digit] = entry
document["remote"] = remote document["remote"] = remote
atomic_write(path, document) atomic_write(path, document)

View File

@@ -8,9 +8,9 @@ configured ceiling is a parent's business, not a child's, so it never crosses th
from __future__ import annotations from __future__ import annotations
from typing import Literal from typing import Literal, Self
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from musicmouse.config import Digit from musicmouse.config import Digit
from musicmouse.library import Album from musicmouse.library import Album
@@ -21,10 +21,11 @@ from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
__all__ = [ __all__ = [
"AlbumOut", "AlbumOut",
"BusOut",
"ClockStateOut",
"HaConfigOut", "HaConfigOut",
"HaDeviceOut", "HaDeviceOut",
"LibraryOut", "LibraryOut",
"LircConfigOut",
"PlayIn", "PlayIn",
"PlayerStateOut", "PlayerStateOut",
"RemoteMappingIn", "RemoteMappingIn",
@@ -34,6 +35,8 @@ __all__ = [
"SeekIn", "SeekIn",
"SettingsIn", "SettingsIn",
"SettingsOut", "SettingsOut",
"TimerIn",
"TimerOut",
"TippenCurriculumOut", "TippenCurriculumOut",
"TippenGhostStrokeOut", "TippenGhostStrokeOut",
"TippenKeyStatOut", "TippenKeyStatOut",
@@ -120,6 +123,15 @@ class TrackOut(BaseModel):
) )
def _cover_version(album: Album) -> int:
if album.cover is None:
return 0
try:
return album.cover.stat().st_mtime_ns // 1_000_000
except OSError:
return 0
class AlbumOut(BaseModel): class AlbumOut(BaseModel):
id: str id: str
section: str section: str
@@ -131,6 +143,9 @@ class AlbumOut(BaseModel):
category: str category: str
colors: list[str] colors: list[str]
has_cover: bool has_cover: bool
#: Cache-busting version of the cover (its mtime in ms; 0 with no cover). Goes in the
#: cover URL's `v` so the browser may cache the image forever.
cover_v: int = 0
duration: float duration: float
tracks: list[TrackOut] tracks: list[TrackOut]
#: Every track is still locked - the browse view shows a question mark instead of #: Every track is still locked - the browse view shows a question mark instead of
@@ -151,6 +166,7 @@ class AlbumOut(BaseModel):
category=album.category, category=album.category,
colors=list(album.colors), colors=list(album.colors),
has_cover=album.cover is not None, has_cover=album.cover is not None,
cover_v=_cover_version(album),
duration=album.duration, duration=album.duration,
locked=lock.locked if lock is not None else False, locked=lock.locked if lock is not None else False,
tracks=[ tracks=[
@@ -189,6 +205,33 @@ class ConnectionOut(BaseModel):
lirc: bool lirc: bool
class BusOut(BaseModel):
#: "HH:MM", when the bus leaves.
time: str
#: How long before ``time`` its countdown shows.
lead_minutes: int
class TimerOut(BaseModel):
#: Wall-clock Unix seconds.
started_at: float
ends_at: float
ringing: bool
class ClockStateOut(BaseModel):
"""What the clock overlay shows besides the time."""
#: ``None`` when the holiday calendar could not be fetched; weekends are known
#: regardless.
school_day: bool | None
#: Why today is no school day: "Wochenende", "Herbstferien", ...
school_day_reason: str | None
#: Today's bus, or ``None`` when there is no school today.
bus: BusOut | None
parent_timer: TimerOut | None
class PlayerStateOut(BaseModel): class PlayerStateOut(BaseModel):
playing: bool playing: bool
album_id: str | None album_id: str | None
@@ -204,6 +247,7 @@ class PlayerStateOut(BaseModel):
volume: int volume: int
active_figure: str | None active_figure: str | None
connected: ConnectionOut connected: ConnectionOut
clock: ClockStateOut
class PlayIn(BaseModel): class PlayIn(BaseModel):
@@ -216,10 +260,28 @@ class SeekIn(BaseModel):
class VolumeIn(BaseModel): class VolumeIn(BaseModel):
"""Either an absolute percentage or a relative step; exactly one of the two.""" """An absolute percentage or a number of steps; exactly one of the two.
``steps`` moves by ``volume_increment`` device units per step - the same step the
IR remote's volume keys and the rotary encoder take - rather than by a percentage.
"""
percent: int | None = Field(default=None, ge=0, le=100) percent: int | None = Field(default=None, ge=0, le=100)
delta_percent: int | None = Field(default=None, ge=-100, le=100) steps: int | None = Field(default=None, ge=-10, le=10)
class TimerIn(BaseModel):
"""Minutes from now, or a time of day ("HH:MM", tomorrow if already past); exactly
one of the two."""
minutes: int | None = Field(default=None, ge=1, le=12 * 60)
until: str | None = Field(default=None, pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
@model_validator(mode="after")
def _exactly_one(self) -> Self:
if (self.minutes is None) == (self.until is None):
raise ValueError("give exactly one of minutes and until")
return self
class SettingsOut(BaseModel): class SettingsOut(BaseModel):
@@ -228,6 +290,10 @@ class SettingsOut(BaseModel):
initial_volume: int initial_volume: int
volume_increment: int volume_increment: int
button_leds_brightness: float button_leds_brightness: float
effects_volume: int
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
#: switch off (no ``screen`` section, or one without an ``off_command``).
screen_auto_off_minutes: int | None
class SettingsIn(BaseModel): class SettingsIn(BaseModel):
@@ -236,6 +302,10 @@ class SettingsIn(BaseModel):
initial_volume: int = Field(ge=0, le=200) initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100) volume_increment: int = Field(ge=1, le=100)
button_leds_brightness: float = Field(ge=0, le=1) button_leds_brightness: float = Field(ge=0, le=1)
#: UI sounds relative to the music, in percent.
effects_volume: int = Field(ge=0, le=100)
#: Ignored without a screen section. ``None`` leaves it as it is.
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
class HaDeviceOut(BaseModel): class HaDeviceOut(BaseModel):
@@ -252,13 +322,6 @@ class HaConfigOut(BaseModel):
scenes: list[HaDeviceOut] scenes: list[HaDeviceOut]
class LircConfigOut(BaseModel):
"""Presence-only, like ``HaConfigOut``: there is nothing secret in a host/port,
but the frontend only needs to know whether the remote is set up and connected."""
connected: bool
class RemoteSlotOut(BaseModel): class RemoteSlotOut(BaseModel):
digit: str digit: str
target_kind: Literal["album", "series"] target_kind: Literal["album", "series"]
@@ -268,6 +331,8 @@ class RemoteSlotOut(BaseModel):
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a #: resolves (a moved/deleted album, an unknown show), so the frontend can show a
#: broken-assignment state instead of silently dropping it. #: broken-assignment state instead of silently dropping it.
resolved_album_id: str | None resolved_album_id: str | None
#: "album" slots only: the song the key starts at; 0 for the whole album.
track_index: int = 0
class RemoteMappingOut(BaseModel): class RemoteMappingOut(BaseModel):
@@ -277,6 +342,7 @@ class RemoteMappingOut(BaseModel):
class RemoteSlotIn(BaseModel): class RemoteSlotIn(BaseModel):
target_kind: Literal["album", "series"] target_kind: Literal["album", "series"]
target: str target: str
track_index: int = Field(default=0, ge=0)
class RemoteMappingIn(BaseModel): class RemoteMappingIn(BaseModel):
@@ -372,7 +438,6 @@ class TippenSettingsIn(BaseModel):
class TippenProgressOut(BaseModel): class TippenProgressOut(BaseModel):
lessons: dict[str, TippenLessonProgressOut] lessons: dict[str, TippenLessonProgressOut]
key_stats: dict[str, TippenKeyStatOut] key_stats: dict[str, TippenKeyStatOut]
pearls: int
aquarium: list[str] aquarium: list[str]
streak: TippenStreakOut streak: TippenStreakOut
settings: TippenSettingsOut settings: TippenSettingsOut
@@ -395,7 +460,6 @@ class TippenRunIn(BaseModel):
animal: AnimalId animal: AnimalId
points: float points: float
passed: bool passed: bool
pearls: int = Field(ge=0)
strokes: list[TippenStrokeIn] = Field(default_factory=list) strokes: list[TippenStrokeIn] = Field(default_factory=list)

View File

@@ -17,6 +17,7 @@ import httpx2
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from musicmouse.app import App from musicmouse.app import App
from musicmouse.config import WebConfig from musicmouse.config import WebConfig
@@ -28,6 +29,18 @@ _log = logging.getLogger(__name__)
__all__ = ["WebService", "build_app"] __all__ = ["WebService", "build_app"]
class _WebFiles(StaticFiles):
"""Vite writes ``assets/<name>-<content hash>.<ext>``: a URL there names one exact
set of bytes, so the browser may keep it forever and never revalidate. Everything
else (``index.html``, ``sw.js``) has a stable name and keeps the default behaviour."""
def file_response(self, full_path, stat_result, scope, status_code=200) -> Response: # type: ignore[no-untyped-def]
response = super().file_response(full_path, stat_result, scope, status_code)
if scope.get("path", "").startswith("/assets/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
def build_app( def build_app(
app: App, app: App,
config: WebConfig, config: WebConfig,
@@ -50,7 +63,7 @@ def build_app(
if config.static_dir.is_dir(): if config.static_dir.is_dir():
# Mounted last and at the root so every /api route still wins; html=True # Mounted last and at the root so every /api route still wins; html=True
# falls back to index.html, which is what a client-side router needs. # falls back to index.html, which is what a client-side router needs.
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web") api.mount("/", _WebFiles(directory=config.static_dir, html=True), name="web")
else: else:
_log.warning( _log.warning(
"web.static_dir %s does not exist; serving the API only " "web.static_dir %s does not exist; serving the API only "

View File

@@ -48,12 +48,17 @@ def to_percent(volume: int, general: GeneralConfig) -> int:
def read_settings(general: GeneralConfig) -> SettingsOut: def read_settings(general: GeneralConfig) -> SettingsOut:
screen = general.screen
return SettingsOut( return SettingsOut(
min_volume=general.min_volume, min_volume=general.min_volume,
max_volume=general.max_volume, max_volume=general.max_volume,
initial_volume=general.initial_volume, initial_volume=general.initial_volume,
volume_increment=general.volume_increment, volume_increment=general.volume_increment,
button_leds_brightness=general.button_leds_brightness, button_leds_brightness=general.button_leds_brightness,
effects_volume=general.effects_volume,
screen_auto_off_minutes=(
screen.auto_off_minutes if screen is not None and screen.off_command else None
),
) )
@@ -91,6 +96,11 @@ def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place.""" """Patch the settings into ``config.yml`` in place."""
document = load_document(path) document = load_document(path)
general = document["general"] general = document["general"]
for key, value in settings.model_dump().items(): values = settings.model_dump()
# The one setting that lives in a subsection rather than flat under `general`.
auto_off = values.pop("screen_auto_off_minutes")
for key, value in values.items():
general[key] = value general[key] = value
if auto_off is not None and general.get("screen") is not None:
general["screen"]["auto_off_minutes"] = auto_off
atomic_write(path, document) atomic_write(path, document)

View File

@@ -7,8 +7,16 @@ read ``mouse.active_figure``, do not keep a second copy of it that can drift.
from __future__ import annotations from __future__ import annotations
from datetime import date
from musicmouse.app import App from musicmouse.app import App
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut from musicmouse.services.web.schemas import (
BusOut,
ClockStateOut,
ConnectionOut,
PlayerStateOut,
TimerOut,
)
from musicmouse.services.web.settings import to_percent from musicmouse.services.web.settings import to_percent
__all__ = ["snapshot"] __all__ = ["snapshot"]
@@ -45,4 +53,27 @@ def snapshot(app: App) -> PlayerStateOut:
mqtt=app.state.mqtt_connected, mqtt=app.state.mqtt_connected,
lirc=app.state.lirc_connected, lirc=app.state.lirc_connected,
), ),
clock=clock_state(app),
)
def clock_state(app: App, today: date | None = None) -> ClockStateOut:
state = app.state
config = app.config.general.clock
# Without a holiday calendar a weekday counts as a school day: a bus countdown on
# a holiday is a smaller surprise than none on the first day back.
today = today or date.today()
school = state.school_day if state.school_day is not None else today.weekday() < 5
timer = state.parent_timer
return ClockStateOut(
school_day=state.school_day,
school_day_reason=state.school_day_reason,
bus=BusOut(time=config.bus_time, lead_minutes=config.bus_lead_minutes)
if school
else None,
parent_timer=TimerOut(
started_at=timer.started_at, ends_at=timer.ends_at, ringing=timer.ringing
)
if timer
else None,
) )

View File

@@ -102,7 +102,6 @@ def progress_out(progress: TypingProgress) -> TippenProgressOut:
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors) key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
for key, stat in progress.key_stats.items() for key, stat in progress.key_stats.items()
}, },
pearls=progress.pearls,
aquarium=list(progress.aquarium), aquarium=list(progress.aquarium),
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played), streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
settings=TippenSettingsOut( settings=TippenSettingsOut(
@@ -159,7 +158,6 @@ def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
animal=body.animal, animal=body.animal,
points=body.points, points=body.points,
passed=body.passed, passed=body.passed,
pearls=body.pearls,
strokes=tuple( strokes=tuple(
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
), ),

View File

@@ -17,6 +17,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypeVar
from musicmouse.app import App from musicmouse.app import App
from musicmouse.clock import Clock, FakeClock from musicmouse.clock import Clock, FakeClock
@@ -329,9 +330,10 @@ def _int(text: str) -> int:
raise ScriptError(f"{text!r} is not a whole number") from None raise ScriptError(f"{text!r} is not a whole number") from None
def _enum_by_name[T: (Button, ButtonAction, TouchButton)]( _EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton)
enum: type[T], name: str, what: str
) -> T:
def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT:
try: try:
return enum[name.upper()] return enum[name.upper()]
except KeyError: except KeyError:

View File

@@ -0,0 +1,73 @@
"""Playing a short synthesised sound on the music's own ALSA device.
``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
import asyncio
import logging
_log = logging.getLogger(__name__)
__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:
"""Play ``wav`` to the end. Failures are logged, not raised."""
try:
process = await asyncio.create_subprocess_exec(
"aplay",
"-q",
"-D",
alsa_device,
"-",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
_log.warning("Could not play a sound: %s", exc)
return
try:
_, error = await asyncio.wait_for(process.communicate(wav), timeout)
except TimeoutError:
process.kill()
await process.wait()
_log.warning("A sound took longer than %gs; killed", timeout)
return
if process.returncode != 0:
_log.warning(
"Playing a sound failed (exit %d): %s",
process.returncode,
error.decode(errors="replace").strip(),
)

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Final, Literal from typing import Final, Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
from ruamel.yaml import YAML from ruamel.yaml import YAML
@@ -37,9 +37,9 @@ __all__ = [
"world_reward", "world_reward",
] ]
type LessonKind = Literal["letters", "fragments", "words", "sentences"] LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"]
type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"] ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"]
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is #: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
#: allowed to grow, since each id names a drawing under ``public/aquarium/``. #: allowed to grow, since each id names a drawing under ``public/aquarium/``.
@@ -52,9 +52,9 @@ CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
) )
#: Which modes make sense for a kind - letters rounds are single keys, so only the #: Which modes make sense for a kind - letters rounds are single keys, so only the
#: arcade modes fit; only words and sentences are long enough for a race. #: arcade mode fits; only words and sentences are long enough for a race.
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = { ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
"letters": ("bubbles", "jellyfish"), "letters": ("bubbles",),
"fragments": ("dive", "feed"), "fragments": ("dive", "feed"),
"words": ("dive", "feed", "race"), "words": ("dive", "feed", "race"),
"sentences": ("dive", "race"), "sentences": ("dive", "race"),
@@ -104,6 +104,10 @@ class _YamlRoot(_Strict):
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}") problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
seen_rewards: set[CreatureId] = set() seen_rewards: set[CreatureId] = set()
# A lesson's `keys` is its spotlight, not only what is new: a round may list
# every key learned so far to drill them evenly. What must stay small is how
# much is *new* in one round - two keys, the mirrored finger pair.
seen_keys: set[str] = set()
for world in self.worlds: for world in self.worlds:
if world.reward in seen_rewards: if world.reward in seen_rewards:
problems.append(f"world {world.number} reuses reward {world.reward!r}") problems.append(f"world {world.number} reuses reward {world.reward!r}")
@@ -121,8 +125,10 @@ class _YamlRoot(_Strict):
problems.append(f'{where}: kind "{kind}" needs a non-empty words list') problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
if lesson.drill and lesson.keys: if lesson.drill and lesson.keys:
problems.append(f"{where}: a drill cannot also introduce keys") problems.append(f"{where}: a drill cannot also introduce keys")
if lesson.keys and len(lesson.keys) > 2: new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
problems.append(f"{where}: at most two keys per lesson") if len(new_keys) > 2:
problems.append(f"{where}: at most two new keys per lesson")
seen_keys.update(lesson.keys or ())
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]: if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"') problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
@@ -230,9 +236,6 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
lessons: list[Lesson] = [] lessons: list[Lesson] = []
active: set[str] = set() active: set[str] = set()
seen_before: set[str] = set() seen_before: set[str] = set()
# Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade
# game never repeats twice in a row even across a fragments/words lesson in between.
last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles
for world in worlds: for world in worlds:
for entry in world.lessons: for entry in world.lessons:
@@ -260,16 +263,10 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
else: else:
emphasis = "mixed" emphasis = "mixed"
primary_mode: ModeId primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive")
if kind == "letters":
default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles"
primary_mode = entry.mode or default_arcade
last_arcade = primary_mode
else:
primary_mode = entry.mode or "dive"
# Bonus replays are only ever feed/race - dive is the plain default, and # Bonus replays are only ever feed/race - dive is the plain default, and
# bubbles/jellyfish already alternate on their own. # bubbles is the only letters-round arcade mode.
bonus_modes = tuple( bonus_modes = tuple(
mode mode
for mode in ELIGIBLE_MODES[kind] for mode in ELIGIBLE_MODES[kind]
@@ -287,7 +284,13 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
subtitle=entry.subtitle, subtitle=entry.subtitle,
kind=kind, kind=kind,
new_keys=new_keys, new_keys=new_keys,
spotlight_keys=keys if kind == "letters" and not is_drill else (), # A round that lists every key learned so far still spotlights only
# what it introduces - a lesson titled "K" must drill K, not spread
# itself evenly over all five keys it happens to name. With nothing
# new, the whole list is the spotlight: that is the "mixed" replay.
spotlight_keys=(
(new_keys or keys) if kind == "letters" and not is_drill else ()
),
emphasis=emphasis, emphasis=emphasis,
active_keys=active_keys, active_keys=active_keys,
primary_mode=primary_mode, primary_mode=primary_mode,

View File

@@ -5,8 +5,8 @@ Same convention as everywhere else this codebase persists something outside
missing or corrupt file is just a fresh start, never a crash: nothing here is precious missing or corrupt file is just a fresh start, never a crash: nothing here is precious
enough to raise over. enough to raise over.
``record_run`` is a near-verbatim port of tippen's own (client-side, until now) ``record_run`` is a port of the ``recordRun`` the standalone typing app had client-side
``recordRun`` in ``tippen/src/lib/progress.ts`` - grading a keystroke-by-keystroke run before it was merged into the web front-end - grading a keystroke-by-keystroke run
into stars/points/an animal stays entirely client-side (see ``TippenRunIn`` in into stars/points/an animal stays entirely client-side (see ``TippenRunIn`` in
``musicmouse.services.web.schemas``); this only owns what happens to progress once a ``musicmouse.services.web.schemas``); this only owns what happens to progress once a
graded result arrives. graded result arrives.
@@ -90,7 +90,6 @@ class RunResult:
animal: AnimalId animal: AnimalId
points: float points: float
passed: bool passed: bool
pearls: int
strokes: tuple[Stroke, ...] strokes: tuple[Stroke, ...]
@@ -143,7 +142,6 @@ class TypingProgress(BaseModel):
version: Literal[1] = 1 version: Literal[1] = 1
lessons: dict[str, LessonProgress] = Field(default_factory=dict) lessons: dict[str, LessonProgress] = Field(default_factory=dict)
key_stats: dict[str, KeyStat] = Field(default_factory=dict) key_stats: dict[str, KeyStat] = Field(default_factory=dict)
pearls: int = 0
#: Pets that have moved into the aquarium, in the order they arrived. #: Pets that have moved into the aquarium, in the order they arrived.
aquarium: list[CreatureId] = Field(default_factory=list) aquarium: list[CreatureId] = Field(default_factory=list)
streak: Streak = Field(default_factory=Streak) streak: Streak = Field(default_factory=Streak)
@@ -265,7 +263,7 @@ def record_run(
curriculum: Curriculum, curriculum: Curriculum,
day: str | None = None, day: str | None = None,
) -> RecordOutcome: ) -> RecordOutcome:
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock. """Record a finished run: stars, animal, key stats, streak, and the unlock.
The unlock rule, in one place: two stars unlocks the next lesson, and so does the The unlock rule, in one place: two stars unlocks the next lesson, and so does the
fifth attempt whatever the score. Speed is nowhere in it. fifth attempt whatever the score. Speed is nowhere in it.
@@ -317,7 +315,6 @@ def record_run(
"lessons": lessons, "lessons": lessons,
"aquarium": aquarium, "aquarium": aquarium,
"key_stats": _fold_key_stats(progress.key_stats, result.strokes), "key_stats": _fold_key_stats(progress.key_stats, result.strokes),
"pearls": progress.pearls + result.pearls,
"streak": _bump_streak(progress.streak, day), "streak": _bump_streak(progress.streak, day),
} }
) )

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,434 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<div>\n",
"<a href=\"http://www.music-processing.de/\"><img style=\"float:left;\" src=\"../data/FMP_Teaser_Cover.png\" width=40% alt=\"FMP\"></a>\n",
"<a href=\"https://www.audiolabs-erlangen.de\"><img src=\"../data/Logo_AudioLabs_Long.png\" width=59% style=\"float: right;\" alt=\"AudioLabs\"></a>\n",
"</div>"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<div>\n",
"<a href=\"../C5/C5.html\"><img src=\"../data/C5_nav.png\" width=\"100\" style=\"float:right;\" alt=\"C5\"></a>\n",
"<h1>Hidden Markov Model (HMM)</h1> \n",
"</div>\n",
"\n",
"<br/>\n",
"\n",
"<p>\n",
"Motivated by the chord recognition problem, we give in this notebook an overview of hidden Markov models (HMMs) and introduce three famous algorithmic problems related with HMMs following Section 5.3 of <a href=\"http://www.music-processing.de/\">[Müller, FMP, Springer 2015]</a>. For a detailed introduction of HMMs, we refer to the famous tutorial paper by Rabiner.\n",
"\n",
"<ul>\n",
"<li><span style=\"color:black\">\n",
"Lawrence R. Rabiner: <strong>A Tutorial on Hidden Markov Models and Selected Applications in Speech Recognition.</strong> Proceedings of the IEEE, 77 (1989), pp. 257&ndash;286. \n",
"<br> \n",
"<a type=\"button\" class=\"btn btn-default btn-xs\" target=\"_blank\" href=\"../data/bibtex/FMP_bibtex_Rabiner89_HMM_IEEE.txt\"> Bibtex </a>\n",
"</span></li>\n",
"</ul>\n",
"</p> "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Markov Chains\n",
"\n",
"Certain transitions from one chord to another are more likely than others. To capture such likelihoods, one can employ a concept called **Markov chains**. Abstracting from our chord recognition scenario, we assume that the chord types to be considered are represented by a set \n",
"\n",
"\\begin{equation}\n",
" \\mathcal{A}:=\\{\\alpha_{1},\\alpha_{2},\\ldots,\\alpha_{I}\\}\n",
"\\end{equation}\n",
"\n",
"of size $I\\in\\mathbb{N}$. The elements $\\alpha_{i}$ for $i\\in[1:I]$ are referred to as **states**. A progression of chords is realized by a system that can be described at any time instance $n=1,2,3,\\ldots$ as being in some state $s_{n}\\in\\mathcal{A}$. The change from one state to another is specified according to a set of probabilities associated with each state. In general, a probabilistic description of such a system can be quite complex. To simplify the model, one often makes the assumption that the probability of a change from the current state $s_{n}$ to the next state $s_{n+1}$ only depends on the current state, and not on the events that preceded it. In terms of conditional probabilities, this property is expressed by\n",
"\n",
"\\begin{equation}\n",
" P[s_{n+1}=\\alpha_{j}|s_{n}=\\alpha_{i},s_{n-1}=\\alpha_{k},\\ldots]\n",
" = P[s_{n+1}=\\alpha_{j}|s_{n}=\\alpha_{i}].\n",
"\\end{equation}\n",
"\n",
"The specific kind of \"amnesia\" is called the **Markov property**. Besides this property, one also often assumes that the system is **invariant under time shifts**, which means by definition that the following coefficients become independent of the index $n$:\n",
"\n",
"\\begin{equation}\n",
" a_{ij} := P[s_{n+1}=\\alpha_{j} | s_{n}=\\alpha_{i}] \\in [0,1]\n",
"\\end{equation}\n",
"\n",
"for $i,j\\in[1:I]$. These coefficients are also called **state transition probabilities**. They obey the standard stochastic constraint $\\sum_{j=1}^{I} a_{ij} = 1$ and can be expressed by an $(I\\times I)$ matrix, which we denote by $A$. A system that satisfies these properties is also called a (discrete-time) **Markov chain**. The following figure illustrates these definitions. It defines a Markov chain that consists of $I=3$ states $\\alpha_{1}$, $\\alpha_{2}$, and $\\alpha_{3}$, which correspond to the major chords $\\mathbf{C}$, $\\mathbf{G}$, and $\\mathbf{F}$, respectively. In the graph representation, the states correspond to the nodes, the transitions to the edges, and the transition probabilities to the labels attached to the edges. For example, the transition probability to remain in the state $\\alpha_{1}=\\mathbf{C}$ is $a_{11}=0.8$, whereas the transition probability of changing from $\\alpha_{1}=\\mathbf{C}$ to $\\alpha_{2}=\\mathbf{G}$ is $a_{12}=0.1$.\n",
"\n",
"<img src=\"../data/C5/FMP_C5_F24.png\" width=\"550px\" align=\"middle\" alt=\"FMP_C5_F24\">\n",
"\n",
"The model expresses the probability of all possible chord changes. To compute the probability of a given chord progression, one also needs the information on how the model gets started. This information is specified\n",
"by additional model parameters referred to as **initial state probabilities**. For a general Markov chain, these probabilities are specified by the numbers\n",
"\n",
"\\begin{equation}\n",
" c_{i} := P[s_{1}=\\alpha_{i}] \\in [0,1]\n",
"\\end{equation}\n",
"\n",
"for $i\\in[1:I]$. These coefficients, which sum up to one, can be expressed by a vector of length $I$ denoted by $C$."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Hidden Markov Models\n",
"\n",
"Based on a Markov chain, one can compute a probability for a given observation consisting of a sequence of states or chord types. In our [chord recognition scenario](../C5/C5S2_ChordRec_Templates.html), however, this is not what we need. Rather than observing a sequence of chord types, we observe a **sequence of chroma vectors** that are somehow related to the chord types. In other words, the state sequence is not directly visible, but only a fuzzier observation sequence that is generated based on the state sequence. This leads to an extension of Markov chains to a statistical model referred to as a **hidden Markov model** (HMM). The idea is to represent the relation between the observed feature vectors and the chord types (the states) using a probabilistic framework. Each state is equipped with a probability function that expresses the likelihood for a given chord type to output or emit a certain feature vector. As a result, we obtain a two-layered process consisting of a **hidden layer** and an **observable layer**. The hidden layer produces a state sequence that is not observable (\"hidden\"), but generates the observation sequence on the basis of the state-dependent probability functions.\n",
"\n",
"The **first layer** of an HMM is a **Markov chain** as introduced above. To define the second layer of an HMM, we need to specify a space of possible output values and a probability function for each state. In general, the output space can be any set including the real numbers, a vector space, or any kind of feature space. For example, in the case of chord recognition, this space may be modeled as the feature space $\\mathcal{F}=\\mathbb{R}^{12}$ consisting of all possible $12$-dimensional chroma vectors. For the sake of simplicity, we only consider the case of a **discrete HMM**, where the output space is assumed to be discrete and even finite. In this case, the space can be modeled as a finite set \n",
"\n",
"\\begin{equation}\n",
" \\mathcal{B} = \\{\\beta_{1},\\beta_{2},\\ldots,\\beta_{K}\\} \n",
"\\end{equation}\n",
"\n",
"of size $K\\in\\mathbb{N}$ consisting of distinct output elements $\\beta_{k}$, $k\\in[1:K]$, which are also referred to as **observation symbols**. An HMM associates with each state a probability function, which is also referred to as the **emission probability** or **output probability**. In the discrete case, the emission probabilities are specified by coefficients\n",
"\n",
"\\begin{equation}\n",
" b_{ik}\\in[0,1]\n",
"\\end{equation}\n",
"\n",
"for $i\\in[1:I]$ and $k\\in[1:K]$. Each coefficient $b_{ik}$ expresses the probability of the system to output the observation symbol $\\beta_{k}$ when in state $\\alpha_{i}$. Similarly to the state transition probabilities, the emission probabilities are required to satisfy the stochastic constraint $\\sum_{k=1}^{K} \\beta_{ik} = 1$ for $i\\in[1:I]$ (thus forming a probability distribution for each state). The coefficients can be expressed by an $(I\\times K)$ matrix, which we denote by $B$. In summary, an HMM is specified by a tuple\n",
"\n",
"\\begin{equation}\n",
" \\Theta:=(\\mathcal{A},A,C,\\mathcal{B},B).\n",
"\\end{equation}\n",
"\n",
"The sets $\\mathcal{A}$ and $\\mathcal{B}$ are usually considered to be fixed components of the model, while the probability values specified by $A$, $B$, and $C$ are the free parameters to be determined. This can be done explicitly by an expert based on his or her musical knowledge or by employing a learning procedure based on suitably labeled training data. Continuing the above example, the following figure illustrates a hidden Markov model, where the state-dependent emission probabilities are indicated by the labels of the dashed arrows.\n",
"\n",
"<img src=\"../data/C5/FMP_C5_F25.png\" width=\"400px\" align=\"middle\" alt=\"FMP_C5_F25\">"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In the following code cell, we define the state transition probability matrix $A$ and the output probability $B$ as specified by the figure. \n",
"\n",
"* Here, we assume that $\\alpha_{1}=\\mathbf{C}$, $\\alpha_{2}=\\mathbf{G}$, and $\\alpha_{3}=\\mathbf{F}$. \n",
"* Furthermore, the elements of the output space $\\mathcal{B} = \\{\\beta_{1},\\beta_{2},\\beta_{3}\\}$ represent the three chroma vectors ordered from left to right. \n",
"* Finally, we assume that the initial state probability vector $C$ is given by the values $c_{1}=0.6$, $c_{2}=0.2$, $c_{3}=0.2$."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"from sklearn.preprocessing import normalize \n",
"\n",
"A = np.array([[0.8, 0.1, 0.1], \n",
" [0.2, 0.7, 0.1], \n",
" [0.1, 0.3, 0.6]])\n",
"\n",
"C = np.array([0.6, 0.2, 0.2])\n",
"\n",
"B = np.array([[0.7, 0.0, 0.3], \n",
" [0.1, 0.9, 0.0], \n",
" [0.0, 0.2, 0.8]])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## HMM-Based Sequence Generation \n",
"\n",
"Once an HMM is specified by $\\Theta:=(\\mathcal{A},A,C,\\mathcal{B},B)$, it can be used for various analysis and synthesis applications. Since it is very instructive, we now discuss how to (artificially) generate, on the basis of a given HMM, an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$ of length $N\\in\\mathbb{N}$ with $o_n\\in \\mathcal{B}$, $n\\in[1:N]$. The generation procedure is as follows:\n",
"\n",
"1. Set $n=1$ and choose an initial state $s_n=\\alpha_i$ for some $i\\in[1:I]$ according to the initial state distribution $C$.\n",
"2. Generate an observation $o_n=\\beta_k$ for some $k\\in[1:K]$ according to the emission probability in state $s_n=\\alpha_i$ (specified by the $i^{\\mathrm{th}}$ row of $B$).\n",
"3. If $n=N$ then terminate the process. Otherwise, if $n<N$, transit to the new state $s_{n+1}=\\alpha_{j}$ according to the state transition probability at state $s_n=\\alpha_i$ (specified by the $i^{\\mathrm{th}}$ row of $A$). Then increase $n$ by one and return to step 2.\n",
"\n",
"In the next code cell, we implement this procedure and apply it to the example HMM specified above. Note that, due to Python conventions, we start in our implementation with index `0`."
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"n = 0, S[0] = 0, O[0] = 0\n",
"n = 1, S[1] = 0, O[1] = 0\n",
"n = 2, S[2] = 0, O[2] = 2\n",
"n = 3, S[3] = 0, O[3] = 0\n",
"n = 4, S[4] = 0, O[4] = 0\n",
"n = 5, S[5] = 0, O[5] = 0\n",
"n = 6, S[6] = 0, O[6] = 2\n",
"n = 7, S[7] = 0, O[7] = 0\n",
"n = 8, S[8] = 0, O[8] = 0\n",
"n = 9, S[9] = 0, O[9] = 0\n",
"State sequence S: [0 0 0 0 0 0 0 0 0 0]\n",
"Observation sequence O: [0 0 2 0 0 0 2 0 0 0]\n"
]
}
],
"source": [
"def generate_sequence_hmm(N, A, C, B, details=False):\n",
" \"\"\"Generate observation and state sequence from given HMM\n",
"\n",
" Notebook: C5/C5S3_HiddenMarkovModel.ipynb\n",
"\n",
" Args:\n",
" N (int): Number of observations to be generated\n",
" A (np.ndarray): State transition probability matrix of dimension I x I\n",
" C (np.ndarray): Initial state distribution of dimension I\n",
" B (np.ndarray): Output probability matrix of dimension I x K\n",
" details (bool): If \"True\" then shows details (Default value = False)\n",
"\n",
" Returns:\n",
" O (np.ndarray): Observation sequence of length N\n",
" S (np.ndarray): State sequence of length N\n",
" \"\"\"\n",
" assert N > 0, \"N should be at least one\"\n",
" I = A.shape[1]\n",
" K = B.shape[1]\n",
" assert I == A.shape[0], \"A should be an I-square matrix\"\n",
" assert I == C.shape[0], \"Dimension of C should be I\"\n",
" assert I == B.shape[0], \"Column-dimension of B should be I\"\n",
"\n",
" O = np.zeros(N, int)\n",
" S = np.zeros(N, int)\n",
" for n in range(N):\n",
" if n == 0:\n",
" i = np.random.choice(np.arange(I), p=C)\n",
" else:\n",
" i = np.random.choice(np.arange(I), p=A[i, :])\n",
" k = np.random.choice(np.arange(K), p=B[i, :])\n",
" S[n] = i\n",
" O[n] = k\n",
" if details:\n",
" print('n = %d, S[%d] = %d, O[%d] = %d' % (n, n, S[n], n, O[n]))\n",
" return O, S\n",
"\n",
"N = 10\n",
"O, S = generate_sequence_hmm(N, A, C, B, details=True)\n",
"print('State sequence S: ', S)\n",
"print('Observation sequence O:', O)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As a sanity check for the plausibility of our sequence generation approach, we now check if the generated sequences reflect well the probabilities of our HMM. To this end, we estimate the original transition probability matrix $A$ and the output probability matrix $B$ from a generated observation sequence $O$ and state sequence $S$.\n",
"\n",
"* To obtain an estimate of the entry $a_{ij}$ of $A$, we count all transitions from $n$ to $n+1$ with $S(n)=\\alpha_i$ and $S(n+1)=\\alpha_j$ and then divide this number by the total number of transitions starting with $\\alpha_i$.\n",
"\n",
"* Similarly, to obtain an estimate of the entry $b_{ik}$ of $B$, we count the number of occurrences $n$ with $S(n)=\\alpha_i$ and $O(n)=\\beta_k$ and divide this number by the total number of occurrences of $\\alpha_i$ in $S$.\n",
"\n",
"When generating longer sequences by increasing the number $N$, the resulting estimates should approach the original values in $A$ and $B$. This is demonstrated by the subsequent experiment. \n",
"\n",
"<div class=\"alert alert-block alert-warning\">\n",
"Note: In practice, when estimating HMM model parameters from training data, only <strong>observation sequences</strong> are typically available, and the state sequences (that reflect the hidden generation process) are generally not known. Learning parameters only from observation sequences leads to much harder estimation problems as discussed below. \n",
"</div> "
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"======== Estimation results when using N = 100 ========\n",
"A =\n",
"[[ 0.800 0.100 0.100]\n",
" [ 0.200 0.700 0.100]\n",
" [ 0.100 0.300 0.600]]\n",
"A_est =\n",
"[[ 0.795 0.091 0.114]\n",
" [ 0.172 0.655 0.172]\n",
" [ 0.154 0.269 0.577]]\n",
"B =\n",
"[[ 0.700 0.000 0.300]\n",
" [ 0.100 0.900 0.000]\n",
" [ 0.000 0.200 0.800]]\n",
"B_est =\n",
"[[ 0.705 0.000 0.295]\n",
" [ 0.167 0.833 0.000]\n",
" [ 0.000 0.423 0.577]]\n",
"======== Estimation results when using N = 10000 ========\n",
"A =\n",
"[[ 0.800 0.100 0.100]\n",
" [ 0.200 0.700 0.100]\n",
" [ 0.100 0.300 0.600]]\n",
"A_est =\n",
"[[ 0.799 0.097 0.104]\n",
" [ 0.198 0.696 0.106]\n",
" [ 0.097 0.306 0.597]]\n",
"B =\n",
"[[ 0.700 0.000 0.300]\n",
" [ 0.100 0.900 0.000]\n",
" [ 0.000 0.200 0.800]]\n",
"B_est =\n",
"[[ 0.708 0.000 0.292]\n",
" [ 0.103 0.897 0.000]\n",
" [ 0.000 0.205 0.795]]\n"
]
}
],
"source": [
"def estimate_hmm_from_o_s(O, S, I, K):\n",
" \"\"\"Estimate the state transition and output probability matrices from\n",
" a given observation and state sequence\n",
"\n",
" Notebook: C5/C5S3_HiddenMarkovModel.ipynb\n",
"\n",
" Args:\n",
" O (np.ndarray): Observation sequence of length N\n",
" S (np.ndarray): State sequence of length N\n",
" I (int): Number of states\n",
" K (int): Number of observation symbols\n",
"\n",
" Returns:\n",
" A_est (np.ndarray): State transition probability matrix of dimension I x I\n",
" B_est (np.ndarray): Output probability matrix of dimension I x K\n",
" \"\"\"\n",
" # Estimate A\n",
" A_est = np.zeros([I, I])\n",
" N = len(S)\n",
" for n in range(N-1):\n",
" i = S[n]\n",
" j = S[n+1]\n",
" A_est[i, j] += 1\n",
" A_est = normalize(A_est, axis=1, norm='l1')\n",
"\n",
" # Estimate B\n",
" B_est = np.zeros([I, K])\n",
" for i in range(I):\n",
" for k in range(K):\n",
" B_est[i, k] = np.sum(np.logical_and(S == i, O == k))\n",
" B_est = normalize(B_est, axis=1, norm='l1')\n",
" return A_est, B_est\n",
"\n",
"N = 100\n",
"print('======== Estimation results when using N = %d ========' % N)\n",
"O, S = generate_sequence_hmm(N, A, C, B, details=False)\n",
"A_est, B_est = estimate_hmm_from_o_s(O, S, A.shape[1], B.shape[1])\n",
"np.set_printoptions(formatter={'float': \"{: 7.3f}\".format})\n",
"print('A =', A, sep='\\n')\n",
"print('A_est =', A_est, sep='\\n')\n",
"print('B =', B, sep='\\n')\n",
"print('B_est =', B_est, sep='\\n')\n",
"\n",
"N = 10000\n",
"print('======== Estimation results when using N = %d ========' % N)\n",
"O, S = generate_sequence_hmm(N, A, C, B, details=False)\n",
"A_est, B_est = estimate_hmm_from_o_s(O, S, A.shape[1], B.shape[1])\n",
"np.set_printoptions(formatter={'float': \"{: 7.3f}\".format})\n",
"print('A =', A, sep='\\n')\n",
"print('A_est =', A_est, sep='\\n')\n",
"print('B =', B, sep='\\n')\n",
"print('B_est =', B_est, sep='\\n')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"\n",
"## Three Problems for HMMs\n",
"\n",
"We have seen how a given HMM can be used to generate an observation sequence. We will now look at three famous algorithmic problems for HMMs that concern the specification of the free model parameters and the evaluation of observation sequences. \n",
"\n",
"### 1. Evaluation Problem\n",
"\n",
"The first problem is known as **evaluation problem**. Given an HMM specified by $\\Theta=(\\mathcal{A},A,C,\\mathcal{B},B)$ and an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$, the task is to compute the probability \n",
"\n",
"\\begin{equation}\n",
" P[O|\\Theta]\n",
"\\end{equation}\n",
"\n",
"of the observation sequence given the model. From a slightly different viewpoint, this probability can be regarded as a score value that expresses how well a given model matches a given observation sequence. This interpretation becomes useful in the case where one is trying to choose among several competing models. The solution would then be to choose the model which best matches the observation sequence. To compute $P[O|\\Theta]$, we first consider a fixed state sequence $S=(s_1,s_2,\\ldots,s_N)$ of length $N$ with $s_n=\\alpha_{i_n}\\in\\mathcal{A}$ for some suitable $i_n\\in[1:I]$, $n\\in[1:N]$. The probability $P[O,S|\\Theta]$ for generating the state sequence $S$ as well as the observation sequence $O$ is given by \n",
"\n",
"$$\n",
"P[O,S|\\Theta] = c_{i_1}\\cdot b_{i_1k_1} \\cdot a_{i_1i_2}\\cdot b_{i_2k_2} \\cdot ...\\cdot a_{i_{N-1}i_N}\\cdot b_{i_Nk_N}\n",
"$$\n",
"\n",
"Next, to obtain the overall probability $P[O|\\Theta]$, one needs to sum up all these probabilities considering all possible state sequences $S$ of length $|S|=N$:\n",
"\n",
"$$\n",
"P[O|\\Theta] = \\sum_{S: |S|=N}P[O,S|\\Theta]\n",
"= \\sum_{i_1=1}^I \\sum_{i_2=1}^I \\ldots \\sum_{i_N=1}^I\n",
"c_{i_1}\\cdot b_{i_1k_1} \\cdot a_{i_1i_2}\\cdot b_{i_2k_2} \\cdot ...\\cdot a_{i_{N-1}i_N}\\cdot b_{i_Nk_N}\n",
"$$\n",
"\n",
"This leads to $I^N$ summands, a number that is exponential in the length $N$ of the observation sequence. Therefore, in practice, this brute-force calculation is computationally infeasible even for a small $N$. The good news is that there is a more efficient way to compute $P[O|\\Theta]$ using an algorithm that is based on the dynamic programming paradigm. This procedure, which is known as [**Forward&ndash;Backward Algorithm**](https://en.wikipedia.org/wiki/Forward%E2%80%93backward_algorithm), requires a number of operations on the order of $I^2N$ (instead of $I^N$). For a detailed description of this algorithm, we refer to the article by [Rabiner](https://ieeexplore.ieee.org/document/18626).\n",
"\n",
"\n",
"### 2. Uncovering Problem\n",
"\n",
"The second problem is the so-called **uncovering problem**. Again, we are given an HMM specified by $\\Theta=(\\mathcal{A},A,C,\\mathcal{B},B)$ and an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$. Instead of finding the overall probability $P[O|\\Theta]$ for $O$, where one needs to consider **all** possible state sequences, the goal of the uncovering problem is to find the **single** state sequence $S=(s_{1},s_{2},\\ldots,s_{N})$ that \"best explains\" the observation sequence. The uncovering problem stated so far is not well defined since, in general, there is not a single \"correct\" state sequence generating the observation sequence. Indeed, one needs a kind of optimization criterion that specifies what is meant when talking about a best possible explanation. There are several reasonable choices for such a criterion, and the actual choice will depend on the intended application. In the [FMP notebook on the Viterbi algorithm](../C5/C5S3_Viterbi.html), we will discuss one possible choice as well as an efficient algorithm (called **Viterbi algorithm**). This algorithm, which can be thought of as a kind of context-sensitive smoothing procedure, will apply in the [FMP notebook on HMM-based chord recognition](../C5/C5S3_ChordRec_HMM.html). \n",
"\n",
"\n",
"### 3. Estimation Problem\n",
"\n",
"Besides the evaluation and uncovering problems, the third basic problem for HMMs is referred to as the **estimation problem**. Given an observation sequence $O$, the objective is to determine the free model parameters of $\\Theta$ (specified by by $A$, $C$, and $B$) that maximize the probability $P[O|\\Theta]$. In other words, the free model parameters are to be estimated so as to best describe the observation sequence. This is a typical instance of an **optimization problem** where a set of observation sequences serves as **training material** for adjusting or learning the HMM parameters. The estimation problem is by far the most difficult problem of HMMs. In fact, there is no known way to explicitly solve the given optimization problem. However, iterative procedures that find locally optimal solutions have been suggested. One of these procedures is known as the [**Baum&ndash;Welch Algorithm**](https://en.wikipedia.org/wiki/Baum%E2%80%93Welch_algorithm). Again, we refer to the article by [Rabiner](https://ieeexplore.ieee.org/document/18626) for more details. "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<div class=\"alert\" style=\"background-color:#F5F5F5; border-color:#C8C8C8\">\n",
"<strong>Acknowledgment:</strong> This notebook was created by <a href=\"https://www.audiolabs-erlangen.de/fau/professor/mueller\">Meinard Müller</a>.\n",
"</div> "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<table style=\"border:none\">\n",
"<tr style=\"border:none\">\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C0/C0.html\"><img src=\"../data/C0_nav.png\" style=\"height:50px\" alt=\"C0\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C1/C1.html\"><img src=\"../data/C1_nav.png\" style=\"height:50px\" alt=\"C1\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C2/C2.html\"><img src=\"../data/C2_nav.png\" style=\"height:50px\" alt=\"C2\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C3/C3.html\"><img src=\"../data/C3_nav.png\" style=\"height:50px\" alt=\"C3\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C4/C4.html\"><img src=\"../data/C4_nav.png\" style=\"height:50px\" alt=\"C4\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C5/C5.html\"><img src=\"../data/C5_nav.png\" style=\"height:50px\" alt=\"C5\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C6/C6.html\"><img src=\"../data/C6_nav.png\" style=\"height:50px\" alt=\"C6\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C7/C7.html\"><img src=\"../data/C7_nav.png\" style=\"height:50px\" alt=\"C7\"></a></td>\n",
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C8/C8.html\"><img src=\"../data/C8_nav.png\" style=\"height:50px\" alt=\"C8\"></a></td>\n",
"</tr>\n",
"</table>"
]
}
],
"metadata": {
"anaconda-cloud": {},
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.10"
}
},
"nbformat": 4,
"nbformat_minor": 4
}

View File

@@ -1,14 +1,10 @@
# Notebooks # Notebooks
Exploratory material, **not part of the running backend** and not imported by it. Scratch work, **not part of the running backend** and not imported by it.
`audio_analysis.py` and the three `C5S*` notebooks are chroma/chord-recognition course `effect_debug.ipynb` is for tinkering with LED effects. It needs `numpy` and `jupyter`,
work (they still reference stale absolute paths). `effect_debug.ipynb` is scratch work which are deliberately not in the backend's dependencies:
for tinkering with LED effects.
They need `librosa`, `numba` and `numpy`, which are deliberately not in the backend's
dependencies:
```sh ```sh
pip install librosa numba numpy jupyter pip install numpy jupyter
``` ```

View File

@@ -1,128 +0,0 @@
import librosa
from numba import jit
import numpy as np
@jit(nopython=True)
def normalize_feature_sequence(X, norm='2', threshold=0.0001, v=None):
"""Normalizes the columns of a feature sequence
Notebook: C3/C3S1_FeatureNormalization.ipynb
Args:
X (np.ndarray): Feature sequence
norm (str): The norm to be applied. '1', '2', 'max' or 'z' (Default value = '2')
threshold (float): An threshold below which the vector ``v`` used instead of normalization
(Default value = 0.0001)
v (float): Used instead of normalization below ``threshold``. If None, uses unit vector for given norm
(Default value = None)
Returns:
X_norm (np.ndarray): Normalized feature sequence
"""
assert norm in ['1', '2', 'max', 'z']
K, N = X.shape
X_norm = np.zeros((K, N))
if norm == '1':
if v is None:
v = np.ones(K, dtype=np.float64) / K
for n in range(N):
s = np.sum(np.abs(X[:, n]))
if s > threshold:
X_norm[:, n] = X[:, n] / s
else:
X_norm[:, n] = v
if norm == '2':
if v is None:
v = np.ones(K, dtype=np.float64) / np.sqrt(K)
for n in range(N):
s = np.sqrt(np.sum(X[:, n]**2))
if s > threshold:
X_norm[:, n] = X[:, n] / s
else:
X_norm[:, n] = v
if norm == 'max':
if v is None:
v = np.ones(K, dtype=np.float64)
for n in range(N):
s = np.max(np.abs(X[:, n]))
if s > threshold:
X_norm[:, n] = X[:, n] / s
else:
X_norm[:, n] = v
if norm == 'z':
if v is None:
v = np.zeros(K, dtype=np.float64)
for n in range(N):
mu = np.sum(X[:, n]) / K
sigma = np.sqrt(np.sum((X[:, n] - mu)**2) / (K - 1))
if sigma > threshold:
X_norm[:, n] = (X[:, n] - mu) / sigma
else:
X_norm[:, n] = v
return X_norm
def compute_chromagram_from_filename(fn_wav,
Fs=22050,
N=4096,
H=2048,
gamma=None,
version='STFT',
norm='2'):
"""Compute chromagram for WAV file specified by filename
Notebook: C5/C5S2_ChordRec_Templates.ipynb
Args:
fn_wav (str): Filenname of WAV
Fs (scalar): Sampling rate (Default value = 22050)
N (int): Window size (Default value = 4096)
H (int): Hop size (Default value = 2048)
gamma (float): Constant for logarithmic compression (Default value = None)
version (str): Technique used for front-end decomposition ('STFT', 'IIS', 'CQT') (Default value = 'STFT')
norm (str): If not 'None', chroma vectors are normalized by norm as specified ('1', '2', 'max')
(Default value = '2')
Returns:
X (np.ndarray): Chromagram
Fs_X (scalar): Feature reate of chromagram
x (np.ndarray): Audio signal
Fs (scalar): Sampling rate of audio signal
x_dur (float): Duration (seconds) of audio signal
"""
x, Fs = librosa.load(fn_wav, sr=Fs)
x_dur = x.shape[0] / Fs
if version == 'STFT':
# Compute chroma features with STFT
X = librosa.stft(x, n_fft=N, hop_length=H, pad_mode='constant', center=True)
if gamma is not None:
X = np.log(1 + gamma * np.abs(X)**2)
else:
X = np.abs(X)**2
X = librosa.feature.chroma_stft(S=X, sr=Fs, tuning=0, norm=None, hop_length=H, n_fft=N)
if version == 'CQT':
# Compute chroma features with CQT decomposition
X = librosa.feature.chroma_cqt(y=x, sr=Fs, hop_length=H, norm=None)
if version == 'IIR':
# Compute chroma features with filter bank (using IIR elliptic filter)
X = librosa.iirt(y=x, sr=Fs, win_length=N, hop_length=H, center=True, tuning=0.0)
if gamma is not None:
X = np.log(1.0 + gamma * X)
X = librosa.feature.chroma_cqt(C=X,
bins_per_octave=12,
n_octaves=7,
fmin=librosa.midi_to_hz(24),
norm=None)
if norm is not None:
X = normalize_feature_sequence(X, norm='2')
Fs_X = Fs / H
return X, Fs_X, x, Fs, x_dur
if __name__ == "__main__":
test_file = "/home/martin/Music/deemix Music/Simone Sommerland - Ki-Ka-Kinderturnen.mp3"
chroma = compute_chromagram_from_filename(test_file)

View File

@@ -2,7 +2,11 @@
name = "musicmouse" name = "musicmouse"
version = "2.0.0" version = "2.0.0"
description = "Host backend for the MusicMouse RFID music player" description = "Host backend for the MusicMouse RFID music player"
requires-python = ">=3.13" # Raspberry Pi OS (Bookworm) ships Python 3.11, and its system interpreter is what the
# device runs. Staying on it means apt and piwheels supply prebuilt armhf wheels for
# the native dependencies (pydantic-core, Pillow), instead of needing a separate
# toolchain to fetch a newer interpreter and compile them on the Pi.
requires-python = ">=3.11"
dependencies = [ dependencies = [
"aiomqtt>=2.0", "aiomqtt>=2.0",
"fastapi>=0.115", "fastapi>=0.115",
@@ -50,8 +54,8 @@ filterwarnings = ["error"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py313" target-version = "py311"
# Course material and scratch work, not part of the backend. See notebooks/README.md. # Scratch work, not part of the backend. See notebooks/README.md.
extend-exclude = ["notebooks"] extend-exclude = ["notebooks"]
[tool.ruff.lint] [tool.ruff.lint]
@@ -63,7 +67,7 @@ select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"]
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"] "musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
[tool.mypy] [tool.mypy]
python_version = "3.13" python_version = "3.11"
strict = true strict = true
files = ["musicmouse"] files = ["musicmouse"]
warn_unreachable = true warn_unreachable = true
@@ -73,3 +77,12 @@ warn_unreachable = true
# checkout must still type-check clean, so it needs the same treatment as librosa. # checkout must still type-check clean, so it needs the same treatment as librosa.
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"] module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"]
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]]
# Recent numpy ships stubs written in 3.12+ syntax (`type X = ...`). Checking against
# the device's 3.11 makes mypy refuse to parse them and abort before checking anything
# at all - which is what happened in a dev venv that has the analysis extra. Treat the
# analysis stack as untyped instead: it is only touched by librosa_analyzer.py.
module = ["numpy", "numpy.*", "librosa", "librosa.*"]
follow_imports = "skip"
follow_imports_for_stubs = true

View File

@@ -96,6 +96,32 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None
assert "remote" in message assert "remote" in message
def test_a_remote_slot_can_name_a_song(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {
"1": {"target_kind": "album", "target": "abc123", "track_index": 4},
"2": {"target_kind": "album", "target": "abc123"},
}
config = load_config(write_config(config_dir, data))
assert config.remote["1"].track_index == 4
assert config.remote["2"].track_index == 0
def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {"7": {"target_kind": "series", "target": "Wissen", "track_index": 1}}
message = _error(config_dir, data)
assert "track_index" in message
def test_the_screen_switches_off_after_15_minutes_by_default(config_dir: Path) -> None:
data = _config(screen={"toggle_command": "kiosk-screen toggle"})
screen = load_config(write_config(config_dir, data)).general.screen
assert screen is not None
assert screen.auto_off_minutes == 15
assert screen.off_command is None # ...but only once there is a way to do it
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None: def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
data = _config( data = _config(
ha={ ha={

View File

@@ -15,6 +15,7 @@ from musicmouse.effects import (
EffectCircularConfig, EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig, EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe, EffectReverseSwipe,
EffectSegmentSelect,
EffectStaticConfig, EffectStaticConfig,
EffectStaticDetailedConfig, EffectStaticDetailedConfig,
EffectSwipeAndChange, EffectSwipeAndChange,
@@ -135,7 +136,54 @@ def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
(EffectRandomTwoColorInterpolationConfig(), 35), (EffectRandomTwoColorInterpolationConfig(), 35),
(EffectReverseSwipe(), 12), (EffectReverseSwipe(), 12),
(EffectSwipeAndChange(), 64), (EffectSwipeAndChange(), 64),
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
], ],
) )
def test_payload_sizes(effect: LedEffect, size: int) -> None: def test_payload_sizes(effect: LedEffect, size: int) -> None:
assert len(effect.as_bytes()) == size assert len(effect.as_bytes()) == size
def test_segment_select() -> None:
effect = EffectSegmentSelect(
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 0, 1, 0)),
selected=1,
previous=0,
gap_leds=2,
offset_degrees=180.0,
direction=-1,
dim_factor=0.5,
wobble_amplitude=1.5,
wobble_period_ms=1000.0,
slide_ms=200.0,
edge_blend=0.25,
)
assert effect.as_bytes().hex() == (
"02" "01" "00" "02" # count, selected, previous, gap
"ff000000" "0000ff00" + "00000000" * 4 # always six colours on the wire
+ "00003443" # offset 180.0
"ff" # direction -1
"0000003f" # dim 0.5
"0000c03f" # wobble 1.5
"00007a44" # period 1000.0
"00004843" # slide 200.0
"0000803e" # edge blend 0.25
)
def test_segment_select_without_previous_does_not_glide() -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * 3, selected=2)
assert effect.as_bytes()[:3] == bytes([3, 2, 2])
def test_segment_select_round_trips() -> None:
# Every float exact in float32, as the firmware has them.
effect = EffectSegmentSelect(
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)), previous=1, dim_factor=0.25
)
assert EffectSegmentSelect.from_bytes(effect.as_bytes()) == effect
@pytest.mark.parametrize("count", [0, 7])
def test_segment_select_takes_one_to_six_sections(count: int) -> None:
with pytest.raises(ValueError, match="between 1 and 6"):
EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * count)

View File

@@ -4,15 +4,25 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import io
import json import json
import os
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from pathlib import Path from pathlib import Path
import pytest import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, Analyzer, MusicLibrary from musicmouse.effects import SEGMENT_SELECT_MAX_SEGMENTS
from musicmouse.library import (
MAX_FIGURE_ALBUMS,
Album,
Analyzer,
MusicLibrary,
default_worker_count,
)
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.cache import LibraryCache from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id from musicmouse.library.colors import colors_from_id
@@ -52,6 +62,19 @@ class FakeAnalyzer:
return analysis, BeatGrid((0.1,), (1.0,)), curves return analysis, BeatGrid((0.1,), (1.0,)), curves
class PidAnalyzer(FakeAnalyzer):
"""Reports *which process* analyzed each track, as `tempo`.
The point of a pool is that this is never the process running the test - and a
class at module scope is also the only kind of analyzer a worker can be handed,
since it has to survive being pickled over to one.
"""
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
analysis, grid, curves = super().analyze(path)
return replace(analysis, tempo=float(os.getpid())), grid, curves
def album_named(library: MusicLibrary, title: str) -> Album: def album_named(library: MusicLibrary, title: str) -> Album:
return next(album for album in library.albums if album.title == title) return next(album for album in library.albums if album.title == title)
@@ -76,15 +99,72 @@ async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> No
playlists = library.figure_playlists() playlists = library.figure_playlists()
assert set(playlists) == {"fuchs", "eule"} assert set(playlists) == {"fuchs", "eule"}
assert len(playlists["fuchs"]) == 3 (fuchs,) = playlists["fuchs"]
assert [track.path.name for track in playlists["fuchs"].tracks] == [ assert len(fuchs) == 3
assert [track.path.name for track in fuchs.tracks] == [
"00 - track.mp3", "00 - track.mp3",
"01 - track.mp3", "01 - track.mp3",
"02 - track.mp3", "02 - track.mp3",
] ]
# The playlist carries the album it came from, which is how a front-end answers # The playlist carries the album it came from, which is how a front-end answers
# "what is playing?" without keeping its own copy of that state. # "what is playing?" without keeping its own copy of that state.
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id assert fuchs.album_id == album_named(library, "Fuchs").id
def _figure_with_albums(config_dir: Path, figure: str, *folders: str) -> None:
"""Replace a figure's audio with one album per folder (relative to the figure)."""
figure_folder = config_dir / "music" / "Figuren" / figure
for track in figure_folder.glob("*.mp3"):
track.unlink()
for folder in folders:
write_track(figure_folder / folder / "01 - track.mp3", title=f"In {folder}")
async def test_a_figure_with_album_subfolders_offers_each_in_folder_order(
config_dir: Path,
) -> None:
# Depth-first and alphabetical, however deep an album sits.
_figure_with_albums(
config_dir, "fuchs", "B Lieder", "A Geschichten/Teil 2", "A Geschichten/Teil 1"
)
library = await build(config_dir)
choices = library.figure_playlists()["fuchs"]
albums = [library.get(playlist.album_id) for playlist in choices]
assert [album.title if album else None for album in albums] == [
"Fuchs - Teil 1",
"Fuchs - Teil 2",
"Fuchs - B Lieder",
]
assert all(album is not None and album.figure == "fuchs" for album in albums)
async def test_a_figure_album_subfolder_takes_the_figures_kind(config_dir: Path) -> None:
_figure_with_albums(config_dir, "eule", "Folge 1", "Folge 2")
library = await build(config_dir)
albums = [library.get(p.album_id) for p in library.figure_playlists()["eule"]]
assert len(albums) == 2
assert {album.kind if album else None for album in albums} == {"book"}
async def test_a_figure_offers_no_more_albums_than_the_ring_has_room_for(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
_figure_with_albums(config_dir, "fuchs", *(f"Album {n}" for n in range(MAX_FIGURE_ALBUMS + 2)))
library = await build(config_dir)
choices = library.figure_playlists()["fuchs"]
assert len(choices) == MAX_FIGURE_ALBUMS
assert "only the first 6" in caplog.text
# Browsing still shows every one of them.
assert sum(album.figure == "fuchs" for album in library.albums) == MAX_FIGURE_ALBUMS + 2
def test_the_album_cap_is_the_rings_section_count() -> None:
assert MAX_FIGURE_ALBUMS == SEGMENT_SELECT_MAX_SEGMENTS
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None: async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
@@ -231,9 +311,14 @@ async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg") Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
album = album_named(await build(config_dir), "Kinderparty Lieder") album = album_named(await build(config_dir), "Kinderparty Lieder")
assert album.cover == folder / "cover.jpg" # Into the cache, not at the library file: a folder cover is whatever size it came
# in at, and serving it directly is what used to put 1920px art in a 185px card.
assert album.cover is not None
assert album.cover.parent == config_dir / ".cache" / "covers"
assert album.cover.stem == album.id
# A solid red cover has one usable colour; the rest fall back to the synthesised # A solid red cover has one usable colour; the rest fall back to the synthesised
# palette rather than repeating it. # palette rather than repeating it - and that still reads the real art, not the
# downscaled copy.
assert album.colors[0] != colors_from_id(album.id)[0] assert album.colors[0] != colors_from_id(album.id)[0]
@@ -248,9 +333,38 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
library = await build(config_dir) library = await build(config_dir)
assert album_named(library, "Neu").cover == podcast / "20260101 - Neu.jpg" # Both covers now live in the cache under their own album id, so which file won is
# no longer visible in the path - read the colour back out instead.
def cover_colour(title: str) -> tuple[int, int, int]:
cover = album_named(library, title).cover
assert cover is not None
with Image.open(cover) as image:
return image.convert("RGB").getpixel((0, 0)) # type: ignore[return-value]
red, green, blue = cover_colour("Neu")
assert red > 150 and green < 90, "the episode's own sidecar art should win"
# No sidecar for this one, so it still falls back to the show's shared cover. # No sidecar for this one, so it still falls back to the show's shared cover.
assert album_named(library, "Alt").cover == podcast / "cover.jpg" red, green, blue = cover_colour("Alt")
assert red < 60 and blue > red, "falls back to the show's shared cover"
async def test_a_sidecar_cover_added_later_replaces_the_cached_one(config_dir: Path) -> None:
"""`podcast_feeds.py` backfills covers for episodes already on disk - the episode's
audio is untouched, so only the sidecar can tell the cache its entry is stale."""
from PIL import Image
podcast = config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah"
Image.new("RGB", (32, 32), (10, 20, 30)).save(podcast / "cover.jpg")
first = album_named(await build(config_dir), "Alt")
alt_audio = first.tracks[0].path
Image.new("RGB", (32, 32), (200, 40, 30)).save(alt_audio.with_suffix(".jpg"))
second = album_named(await build(config_dir), "Alt")
assert second.cover is not None
with Image.open(second.cover) as image:
red, green, _blue = image.convert("RGB").getpixel((0, 0)) # type: ignore[misc]
assert red > 150 and green < 90, "the new sidecar should replace the shared cover"
# --------------------------------------------------------------------------- cache # --------------------------------------------------------------------------- cache
@@ -641,3 +755,164 @@ async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
assert pass_count == 2 assert pass_count == 2
assert len(analyzer.calls) == 5 assert len(analyzer.calls) == 5
# ------------------------------------------------------------------ worker processes
async def test_a_parallel_pass_analyzes_every_track_in_worker_processes(
config_dir: Path,
) -> None:
"""The whole point of `analysis_workers`: a machine with cores gets to use them.
Two workers rather than the real default, because what is being checked is that
the work left this process at all - not how fast a five-track fixture goes.
"""
library = await build(config_dir, analyzer=PidAnalyzer(fails={"00 - lied.mp3"}))
done = await library.analyze_pending(workers=2)
assert done == 5
analyzed = [
track.analysis
for album in library.albums
if album.kind == "music"
for track in album.tracks
]
assert all(a is not None and a.version == ANALYZER_VERSION for a in analyzed)
# The one file the analyzer chokes on is recorded as attempted, exactly as on the
# in-process path - a raise in a worker must not abort the other four.
failed = [a for a in analyzed if a is not None and a.tempo is None]
assert len(failed) == 1
workers = {a.tempo for a in analyzed if a is not None and a.tempo is not None}
assert workers
assert os.getpid() not in workers
async def test_a_parallel_pass_starts_no_workers_while_playback_is_busy(
config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Being busy holds the pass *before* the pool exists, so a device playing an album
is not also hosting a set of idle analyzer processes."""
import musicmouse.library as library_module
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
library = await build(config_dir, analyzer=PidAnalyzer())
busy = True
task = asyncio.create_task(library.analyze_pending(workers=2, is_busy=lambda: busy))
try:
await asyncio.sleep(0.2)
assert list(library.cache.analysis.glob("*.json")) == []
busy = False
assert await asyncio.wait_for(task, timeout=30) == 5
finally:
if not task.done():
await _cancel(task)
async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> None:
"""Shutdown cancels this task like any other, and it must not hang waiting for
worker processes to finish the tracks they are on."""
library = await build(config_dir, analyzer=PidAnalyzer())
task = asyncio.create_task(library.analyze_pending(workers=2))
await asyncio.sleep(0.1) # long enough to have workers starting up
await asyncio.wait_for(_cancel(task), timeout=10)
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
count = default_worker_count()
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
class TestCoverDownscaling:
"""Album art arrives sized for a record sleeve; screens want a fraction of that."""
@staticmethod
def _jpeg(size: tuple[int, int]) -> bytes:
from PIL import Image
buffer = io.BytesIO()
Image.new("RGB", size, (120, 40, 200)).save(buffer, format="JPEG")
return buffer.getvalue()
def test_oversized_art_is_shrunk_to_the_long_edge(self) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, shrink_cover
original = self._jpeg((3000, 2000))
shrunk = shrink_cover(original)
with Image.open(io.BytesIO(shrunk)) as image:
width, height = image.size
assert max(width, height) == MAX_COVER_PX
# Aspect ratio survives, to within the rounding of a whole pixel.
assert abs(width / height - 3000 / 2000) < 0.01
assert len(shrunk) < len(original)
def test_art_already_small_enough_is_returned_untouched(self) -> None:
"""Byte-identical, not merely similar: repeated scans must not re-encode art
over and over, each pass losing a little more to JPEG."""
from musicmouse.library.cache import shrink_cover
original = self._jpeg((300, 300))
assert shrink_cover(original) is original
def test_unreadable_art_is_passed_through_rather_than_dropped(self) -> None:
"""A cover too big is a performance problem; a cover missing is a visible one."""
from musicmouse.library.cache import shrink_cover
assert shrink_cover(b"not an image at all") == b"not an image at all"
def test_store_cover_shrinks_on_the_way_in(self, tmp_path: Path) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
path = cache.store_cover("abc123", self._jpeg((2400, 2400)))
with Image.open(path) as image:
assert max(image.size) == MAX_COVER_PX
def test_a_cover_jpg_in_the_library_folder_is_cached_downscaled_too(
self, tmp_path: Path
) -> None:
"""The branch that quietly undid all of this: an album with art already sitting
beside it used to have `cover` point straight at that file, so it was served at
whatever size the internet gave it - often 1920px into a 185px card."""
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
from musicmouse.library.scanner import _cover_for
cache = LibraryCache(tmp_path / "cache")
cache.prepare()
folder = tmp_path / "album"
folder.mkdir()
(folder / "cover.jpg").write_bytes(self._jpeg((1920, 1920)))
path, art = _cover_for(folder, [], "album1", cache)
assert path is not None
assert path.parent == cache.covers, "cover must point into the cache, not the library"
with Image.open(path) as image:
assert max(image.size) == MAX_COVER_PX
# The untouched original still comes back, because colour extraction wants it.
assert art == (folder / "cover.jpg").read_bytes()
def test_store_cover_also_writes_a_smaller_baseline_thumbnail(self, tmp_path: Path) -> None:
from PIL import Image
from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
cache.store_cover("abc123", self._jpeg((2400, 1800)))
with Image.open(cache.thumb_path("abc123")) as thumb:
assert max(thumb.size) == THUMB_COVER_PX
assert thumb.format == "JPEG"
assert "progressive" not in thumb.info
assert abs(thumb.width / thumb.height - 2400 / 1800) < 0.02

View File

@@ -21,6 +21,8 @@ from musicmouse.events import (
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
ToggleClockRequested,
ToggleScreenRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
from musicmouse.services.lirc import LircService from musicmouse.services.lirc import LircService
@@ -60,6 +62,13 @@ def test_surrounding_whitespace_is_ignored() -> None:
assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None
def test_irservers_lowercase_button_names_are_uppercased() -> None:
# Verbatim from irserver's lircd-compatible port on musicdolphin.
event = parse_line("b7b2000000000000 00 key_play hauppauge\n")
assert event is not None
assert event.button == "KEY_PLAY"
# --------------------------------------------------------------------------- service # --------------------------------------------------------------------------- service
@@ -70,6 +79,7 @@ class ScriptedLircd:
def __init__(self) -> None: def __init__(self) -> None:
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
self._connected = asyncio.Event() self._connected = asyncio.Event()
self._closing = asyncio.Event()
self.connection_count = 0 self.connection_count = 0
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -77,7 +87,14 @@ class ScriptedLircd:
self.connection_count += 1 self.connection_count += 1
self._connected.set() self._connected.set()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await asyncio.Event().wait() # held open until the test drops it await self._closing.wait() # held open until the test drops it
writer.close()
with contextlib.suppress(OSError, asyncio.CancelledError):
await writer.wait_closed()
def shutdown(self) -> None:
"""Let go of every connection still being held open, so the server can close."""
self._closing.set()
async def wait_connected(self) -> None: async def wait_connected(self) -> None:
await self._connected.wait() await self._connected.wait()
@@ -108,8 +125,10 @@ async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Serv
finally: finally:
server.close() server.close()
# The scripted connection handler holds its connection open forever (it does # The scripted connection handler holds its connection open forever (it does
# not know the test is done), so `wait_closed()` alone would hang here. # not know the test is done), so `wait_closed()` alone would hang here. 3.13
server.close_clients() # has Server.close_clients() for exactly this; ask the handlers to let go
# instead, because the device runs the Pi's Python 3.11.
station.shutdown()
await server.wait_closed() await server.wait_closed()
@@ -185,6 +204,26 @@ async def test_events_from_another_remote_are_ignored(
assert not any(isinstance(event, IntentEvent) for event in seen) assert not any(isinstance(event, IntentEvent) for event in seen)
async def test_irservers_lowercase_lines_are_acted_on(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe_all(seen.append)
# The config still says "Hauppauge" (the default); irserver says "hauppauge".
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("b7b2000000000000 00 key_play hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert [type(event) for event in seen if isinstance(event, IntentEvent)] == [PlayRequested]
async def test_transport_buttons_only_act_on_the_first_press( async def test_transport_buttons_only_act_on_the_first_press(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None: ) -> None:
@@ -228,6 +267,76 @@ async def test_volume_buttons_act_on_every_repeat(
assert all(event.delta < 0 for event in seen) assert all(event.delta < 0 for event in seen)
async def test_a_held_button_from_irserver_acts_once(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
# irserver sends every line of a held button as repeat 00.
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(NextTrackRequested, seen.append)
sim.bus.subscribe(VolumeChangeRequested, seen.append)
assert isinstance(sim.clock, FakeClock)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
for _ in range(3):
await station.send("b7b0000000000000 00 key_forward hauppauge")
await sim.clock.advance(0.11)
await sim.clock.advance(1.0)
await station.send("b7b0000000000000 00 key_forward hauppauge")
# Volume still ramps for as long as it is held.
for _ in range(3):
await station.send("b720000000000000 00 key_volumeup hauppauge")
await sim.clock.advance(0.11)
await sim.bus.drain()
finally:
task.cancel()
assert [type(event) for event in seen] == [NextTrackRequested] * 2 + [VolumeChangeRequested] * 3
async def test_the_power_button_toggles_the_screen(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(ToggleScreenRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("b7d0000000000000 00 key_power hauppauge")
await station.send("b7d0000000000000 00 key_power hauppauge") # still held
await sim.bus.drain()
finally:
task.cancel()
assert len(seen) == 1
async def test_the_guide_key_asks_for_the_clock(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(ToggleClockRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("b7c0000000000000 00 key_guide hauppauge")
await station.send("b7c0000000000000 00 key_guide hauppauge") # still held
await sim.bus.drain()
finally:
task.cancel()
assert [event.source for event in seen] == ["lirc"]
async def test_mute_toggles_and_restores( async def test_mute_toggles_and_restores(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None: ) -> None:
@@ -244,6 +353,9 @@ async def test_mute_toggles_and_restores(
await sim.bus.drain() await sim.bus.drain()
assert sim.player.volume == 0 assert sim.player.volume == 0
# A second press, not the first one held - see the repeat counting below.
assert isinstance(sim.clock, FakeClock)
await sim.clock.advance(1.0)
await station.send("0 00 KEY_MUTE Hauppauge") await station.send("0 00 KEY_MUTE Hauppauge")
await sim.bus.drain() await sim.bus.drain()
assert sim.player.volume == 37 assert sim.player.volume == 37
@@ -292,6 +404,29 @@ async def test_an_album_digit_plays_from_the_beginning(
assert sim.player.track_index == 0 assert sim.player.track_index == 0
async def test_a_song_digit_plays_from_that_song(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
sim.app.config.remote = {
"3": RemoteSlotConfig(target_kind="album", target=album.id, track_index=1)
}
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 BTN_3 Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == album.id
assert sim.player.track_index == 1
async def test_a_series_digit_plays_the_latest_episode( async def test_a_series_digit_plays_the_latest_episode(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None: ) -> None:

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import TypeVar
import pytest import pytest
@@ -13,6 +14,7 @@ from musicmouse.effects import (
OFF, OFF,
EffectAlexaSwipeConfig, EffectAlexaSwipeConfig,
EffectCircularConfig, EffectCircularConfig,
EffectSegmentSelect,
EffectStaticConfig, EffectStaticConfig,
) )
from musicmouse.events import ( from musicmouse.events import (
@@ -22,9 +24,12 @@ from musicmouse.events import (
Event, Event,
LedEffectChanged, LedEffectChanged,
RfidTokenRead, RfidTokenRead,
RotaryTurned,
SelectorPressed,
SelectorTurned,
TouchButtonPressed, TouchButtonPressed,
) )
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.simulator import FakeTransport from musicmouse.simulator import FakeTransport
FUCHS = bytes.fromhex("04a1b2c3d4") FUCHS = bytes.fromhex("04a1b2c3d4")
@@ -58,7 +63,10 @@ def seen(bus: EventBus) -> list[Event]:
return events return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]: T = TypeVar("T", bound=Event)
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)] return [e for e in events if isinstance(e, event_type)]
@@ -78,6 +86,56 @@ async def test_button_press_reaches_the_bus(
] ]
def _turn(increment: int, direction: RotaryDirection) -> bytes:
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
def _knob(action: ButtonAction) -> bytes:
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
async def test_a_captured_knob_turns_the_selector_not_the_volume(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.feed(_turn(2, RotaryDirection.UP))
device.feed(_turn(1, RotaryDirection.DOWN))
# The first turn after boot reports no increment, but it was still a detent.
device.feed(_turn(0, RotaryDirection.UP))
await bus.drain()
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
assert not only(seen, RotaryTurned)
async def test_a_captured_knob_press_is_the_selectors_alone(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
device.feed(_knob(action))
# The other buttons are not captured.
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
await bus.drain()
assert len(only(seen, SelectorPressed)) == 1
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
async def test_a_released_knob_is_a_volume_knob_again(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.capture_knob(False)
device.feed(_turn(1, RotaryDirection.UP))
device.feed(_knob(ButtonAction.PRESSED))
await bus.drain()
assert len(only(seen, RotaryTurned)) == 1
assert len(only(seen, ButtonEvent)) == 1
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
async def test_touch_press_reaches_the_bus( async def test_touch_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event] bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None: ) -> None:
@@ -251,6 +309,19 @@ async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
assert "cannot be sent to the mouse LEDs" in caplog.text assert "cannot be sent to the mouse LEDs" in caplog.text
async def test_only_the_ring_can_be_a_selector(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
device.set_effect(LedZone.RING, effect)
device.set_effect(LedZone.SHELF, effect)
await bus.drain()
sent = transport.effect(LedZone.RING)
assert sent is not None and sent.as_bytes() == effect.as_bytes()
assert transport.effect(LedZone.SHELF) is None
async def test_button_brightness_sets_both_backlights( async def test_button_brightness_sets_both_backlights(
device: MusicMouseDevice, transport: FakeTransport device: MusicMouseDevice, transport: FakeTransport
) -> None: ) -> None:

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from typing import TypeVar
import pytest import pytest
@@ -55,7 +56,10 @@ def seen(bus: EventBus) -> list[Event]:
return events return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]: T = TypeVar("T", bound=Event)
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)] return [e for e in events if isinstance(e, event_type)]

View File

@@ -19,7 +19,9 @@ from musicmouse.library.podcast_feeds import (
episode_filename, episode_filename,
find_feed_shows, find_feed_shows,
missing_episodes, missing_episodes,
newest_episodes,
parse_feed, parse_feed,
prune_show,
resolve_episode_cover, resolve_episode_cover,
sync_all_shows, sync_all_shows,
sync_show, sync_show,
@@ -409,6 +411,83 @@ async def test_sync_show_survives_a_failed_cover_download(tmp_path: Path) -> Non
assert not (folder / "20250101 - Unique Cover.jpg").exists() assert not (folder / "20250101 - Unique Cover.jpg").exists()
_FEED_WITH_LINKS = b"""<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Link Show</title>
<link>http://example.com/show</link>
<item>
<title>Old One</title>
<link>http://example.com/episodes/old</link>
<pubDate>Wed, 01 Jan 2025 08:00:00 GMT</pubDate>
<enclosure url="http://example.com/ep1.mp3" length="100" type="audio/mpeg" />
<guid>ep1</guid>
</item>
</channel>
</rss>
"""
@pytest.mark.asyncio
async def test_sync_show_backfills_the_cover_of_an_episode_already_on_disk(
tmp_path: Path,
) -> None:
# Placed by hand (or downloaded before covers were fetched): the audio is there,
# so nothing is downloaded - but its cover still has to be fetched.
folder = tmp_path / "show"
folder.mkdir()
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
requests: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
requests.append(url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED_WITH_LINKS)
if url == "http://example.com/episodes/old":
html = '<meta property="og:image" content="http://example.com/old.png">'
return httpx2.Response(200, text=html)
if url == "http://example.com/old.png":
return httpx2.Response(200, content=b"cover-bytes")
return httpx2.Response(500)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml")
requests.clear()
changed_again = await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is True
assert (folder / "20250101 - Old One.png").read_bytes() == b"cover-bytes"
# Once the sidecar exists, a later poll doesn't go looking for it again.
assert changed_again is False
assert requests == ["http://example.com/feed.xml"]
@pytest.mark.asyncio
async def test_sync_show_does_not_look_for_a_missing_cover_again_within_the_backoff(
tmp_path: Path,
) -> None:
folder = tmp_path / "show"
folder.mkdir()
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
page_requests: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED_WITH_LINKS)
page_requests.append(url)
return httpx2.Response(200, text="<html>no og:image here</html>")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml")
await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is False
assert page_requests == ["http://example.com/episodes/old"]
assert (folder / podcast_feeds._MISSING_COVERS_FILENAME).exists()
# ---------------------------------------------------------------------- video shows # ---------------------------------------------------------------------- video shows
@@ -630,3 +709,148 @@ async def test_sync_show_survives_a_corrupt_failure_record(tmp_path: Path) -> No
assert changed is True assert changed is True
assert (folder / "20250101 - Episode One.mp3").exists() assert (folder / "20250101 - Episode One.mp3").exists()
# --------------------------------------------------------------- episode retention
def _episode(day: int, *, title: str | None = None) -> Episode:
return Episode(
title=title or f"Episode {day}",
published=datetime(2025, 1, day, tzinfo=UTC),
enclosure_url=f"http://example.com/ep{day}.mp3",
enclosure_type="audio/mpeg",
)
def _write_episode(folder: Path, name: str) -> Path:
path = folder / name
path.write_bytes(b"audio")
return path
def test_newest_episodes_takes_the_most_recent_and_orders_them_newest_first() -> None:
episodes = [_episode(1), _episode(5), _episode(3)]
assert [e.published.day for e in newest_episodes(episodes, 2)] == [5, 3]
def test_newest_episodes_keeps_everything_when_the_limit_is_none() -> None:
episodes = [_episode(1), _episode(5), _episode(3)]
assert len(newest_episodes(episodes, None)) == 3
def test_prune_show_deletes_only_the_oldest_beyond_the_limit(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
for day in range(1, 6):
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
assert prune_show(folder, 2) == 3
remaining = sorted(path.name for path in folder.iterdir())
assert remaining == ["20250104 - Episode 4.mp3", "20250105 - Episode 5.mp3"]
def test_prune_show_is_a_noop_below_the_limit(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Episode 1.mp3")
assert prune_show(folder, 50) == 0
assert prune_show(folder, None) == 0
assert (folder / "20250101 - Episode 1.mp3").exists()
def test_prune_show_takes_the_sidecar_cover_with_the_episode(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Old.mp3")
(folder / "20250101 - Old.jpg").write_bytes(b"art")
_write_episode(folder, "20250102 - New.mp3")
(folder / "20250102 - New.jpg").write_bytes(b"art")
assert prune_show(folder, 1) == 1
assert not (folder / "20250101 - Old.jpg").exists()
assert (folder / "20250102 - New.jpg").exists()
def test_prune_show_leaves_everything_it_did_not_name(tmp_path: Path) -> None:
"""A folder holds more than episodes, and a hand-placed file is not ours to delete."""
folder = tmp_path / "show"
folder.mkdir()
for day in range(1, 4):
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
(folder / "feed.txt").write_text("http://example.com/feed.xml\n")
(folder / "folder.jpg").write_bytes(b"art")
(folder / ".failed-downloads.json").write_text("{}")
_write_episode(folder, "Grandpa's tape.mp3")
prune_show(folder, 1)
assert (folder / "feed.txt").exists()
assert (folder / "folder.jpg").exists()
assert (folder / ".failed-downloads.json").exists()
assert (folder / "Grandpa's tape.mp3").exists()
@pytest.mark.asyncio
async def test_sync_show_prunes_to_the_limit_after_downloading(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
def handler(request: httpx2.Request) -> httpx2.Response:
if str(request.url) == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed is True
episodes = sorted(path.name for path in folder.iterdir() if path.suffix == ".mp3")
assert episodes == ["20250103 - Episode Two_ Special_Chars_.mp3"]
@pytest.mark.asyncio
async def test_sync_show_does_not_redownload_what_it_pruned(tmp_path: Path) -> None:
"""The whole point of capping downloads as well as deletions.
Prune to the newest N but fetch everything the feed offers, and each poll would
re-download the episodes the previous one deleted - forever, at full size.
"""
folder = tmp_path / "show"
folder.mkdir()
downloads: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
if str(request.url) == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
downloads.append(str(request.url))
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
first_pass = len(downloads)
changed_again = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed_again is False
assert len(downloads) == first_pass
@pytest.mark.asyncio
async def test_sync_show_reports_a_change_when_it_only_pruned(tmp_path: Path) -> None:
"""A deletion needs a rescan as much as a download does, or the library goes on
offering tracks whose files are gone."""
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Episode One.mp3")
_write_episode(folder, "20250103 - Episode Two_ Special_Chars_.mp3")
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, content=_FEED)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed is True
assert not (folder / "20250101 - Episode One.mp3").exists()

View File

@@ -32,7 +32,9 @@ async def test_run_checks_immediately_and_calls_on_change_only_when_something_ch
) -> None: ) -> None:
calls = 0 calls = 0
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool: async def fake_sync_all_shows(
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
) -> bool:
nonlocal calls nonlocal calls
calls += 1 calls += 1
return calls == 1 # only the very first pass finds something new return calls == 1 # only the very first pass finds something new
@@ -63,7 +65,9 @@ async def test_run_survives_a_failing_pass(
) -> None: ) -> None:
calls = 0 calls = 0
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool: async def fake_sync_all_shows(
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
) -> bool:
nonlocal calls nonlocal calls
calls += 1 calls += 1
if calls == 1: if calls == 1:

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from typing import TypeVar
import pytest import pytest
@@ -49,7 +50,10 @@ def seen(sim: Simulation) -> list[Event]:
return events return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]: T = TypeVar("T", bound=Event)
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)] return [e for e in events if isinstance(e, event_type)]
@@ -122,6 +126,22 @@ async def test_putting_the_same_figure_back_resumes_where_it_left_off(
mouse.check("track", "1") mouse.check("track", "1")
async def test_resuming_survives_a_library_rescan(
mouse: SimulatorDriver, sim: Simulation
) -> None:
"""A rescan rebuilds every Playlist object; resuming must not depend on identity."""
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS + 1)
mouse.check("track", "1")
await mouse.remove()
await sim.app.rescan_library()
await mouse.place("fuchs")
mouse.check("playing", "true")
mouse.check("track", "1")
async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None: async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs") await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS + 1) await mouse.wait(TRACK_SECONDS + 1)

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import httpx2 import httpx2
import pytest import pytest
from musicmouse.config import WebConfig, load_config from musicmouse.config import RemoteSlotConfig, WebConfig, load_config
from musicmouse.services.web.service import build_app from musicmouse.services.web.service import build_app
from musicmouse.simulator.harness import Simulation, build_simulation from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config from tests.conftest import VALID_CONFIG, write_config
@@ -65,13 +65,6 @@ async def album_id(sim: Simulation, title: str) -> str:
return next(a.id for a in sim.app.library.albums if a.title == title) return next(a.id for a in sim.app.library.albums if a.title == title)
# ------------------------------------------------------------------------------ lirc
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
assert (await client.get("/api/lirc")).status_code == 404
# --------------------------------------------------------------------------- reading # --------------------------------------------------------------------------- reading
@@ -93,13 +86,93 @@ async def test_saving_an_album_slot_round_trips(
assert response.status_code == 200 assert response.status_code == 200
slots = response.json()["slots"] slots = response.json()["slots"]
assert slots == [ assert slots == [
{"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target} {
"digit": "3",
"target_kind": "album",
"target": target,
"resolved_album_id": target,
"track_index": 0,
}
] ]
text = (config_dir / "config.yml").read_text(encoding="utf-8") text = (config_dir / "config.yml").read_text(encoding="utf-8")
assert "# The MusicMouse config." in text # comments survive assert "# The MusicMouse config." in text # comments survive
reloaded = load_config(config_dir / "config.yml") reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["3"].target == target assert reloaded.remote["3"].target == target
assert "track_index" not in text # a whole-album slot reads as it always did
async def test_saving_a_song_slot_round_trips(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 1}}},
)
assert response.status_code == 200
assert response.json()["slots"][0]["track_index"] == 1
assert (await client.get("/api/remote/mapping")).json()["slots"][0]["track_index"] == 1
assert sim.app.config.remote["4"].track_index == 1
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["4"].track_index == 1
async def test_a_song_past_the_end_of_the_album_is_rejected(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 2}}},
)
assert response.status_code == 422
assert "no track 2" in response.json()["detail"]
async def test_a_song_of_a_series_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={
"slots": {
"7": {"target_kind": "series", "target": "Wissen macht Ah", "track_index": 1}
}
},
)
assert response.status_code == 422
# --------------------------------------------------------------------------- pressing
async def test_pressing_a_key_plays_its_song_and_then_the_rest(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
sim.app.config.remote = {
"4": RemoteSlotConfig(target_kind="album", target=target, track_index=1)
}
response = await client.post("/api/remote/press/4")
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == target
assert len(sim.player.playlist) == 2 # the whole album, not just the one song
assert sim.player.track_index == 1
async def test_pressing_an_unassigned_key_does_nothing(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
assert (await client.post("/api/remote/press/5")).status_code == 204
await sim.bus.drain()
assert sim.player.playlist is None
async def test_pressing_a_non_digit_is_rejected(client: httpx2.AsyncClient) -> None:
assert (await client.post("/api/remote/press/x")).status_code == 422
async def test_saving_a_series_slot_resolves_to_the_latest_episode( async def test_saving_a_series_slot_resolves_to_the_latest_episode(

View File

@@ -0,0 +1,141 @@
"""Which days are school days, from the OpenHolidays API's two lists."""
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import httpx2
import pytest
from musicmouse.school_days import (
HolidayCalendar,
fetch_calendar,
load_calendar,
parse_holidays,
save_calendar,
school_day,
)
def _entry(
start: str, end: str, name: str, *codes: str, nationwide: bool = False
) -> dict[str, Any]:
"""One entry in the API's shape (trimmed to the fields that are read)."""
return {
"startDate": start,
"endDate": end,
"name": [{"language": "DE", "text": name}],
"nationwide": nationwide,
"subdivisions": [{"code": code, "shortName": code[3:]} for code in codes],
}
#: Verbatim dates from the API for Bavaria, 2026/27.
SCHOOL = [
_entry("2026-11-02", "2026-11-06", "Herbstferien", "DE-BY"),
_entry("2026-11-18", "2026-11-18", "Buß- und Bettag", "DE-BY"),
_entry("2026-12-24", "2027-01-08", "Weihnachtsferien", "DE-BY"),
]
PUBLIC = [
_entry("2026-10-03", "2026-10-03", "Tag der Deutschen Einheit", nationwide=True),
_entry("2027-05-27", "2027-05-27", "Fronleichnam", "DE-HE", "DE-BW", "DE-BY"),
_entry("2027-08-09", "2027-08-09", "Friedensfest", "DE-BY-AU"),
]
def _calendar(subdivision: str = "DE-BY") -> HolidayCalendar:
return HolidayCalendar(
fetched=date(2026, 10, 1),
subdivision=subdivision,
school=parse_holidays(SCHOOL),
public=parse_holidays(PUBLIC),
)
@pytest.mark.parametrize(
("day", "expected"),
[
(date(2026, 10, 5), (True, None)), # a plain Monday
(date(2026, 10, 10), (False, "Wochenende")),
(date(2026, 10, 11), (False, "Wochenende")),
(date(2026, 11, 4), (False, "Herbstferien")),
(date(2026, 11, 9), (True, None)), # the Monday after
(date(2026, 11, 18), (False, "Buß- und Bettag")),
(date(2027, 1, 7), (False, "Weihnachtsferien")), # spans the new year
(date(2027, 5, 27), (False, "Fronleichnam")),
],
)
def test_school_days_in_bavaria(day: date, expected: tuple[bool, str | None]) -> None:
assert school_day(day, _calendar()) == expected
def test_a_nationwide_holiday_on_a_weekend_is_still_the_weekend() -> None:
assert school_day(date(2026, 10, 3), _calendar()) == (False, "Wochenende")
def test_a_city_holiday_counts_only_for_that_city() -> None:
friedensfest = date(2027, 8, 9) # a Monday; pretend it is not in the summer holidays
assert school_day(friedensfest, _calendar("DE-BY")) == (True, None)
assert school_day(friedensfest, _calendar("DE-BY-AU")) == (False, "Friedensfest")
def test_a_city_also_gets_its_states_holidays() -> None:
assert school_day(date(2026, 11, 4), _calendar("DE-BY-AU")) == (False, "Herbstferien")
def test_without_a_calendar_only_the_weekend_is_known() -> None:
assert school_day(date(2026, 10, 5), None) == (None, None)
assert school_day(date(2026, 10, 10), None) == (False, "Wochenende")
def test_entries_that_do_not_parse_are_skipped() -> None:
raw = [{"startDate": "nope"}, {"name": []}, SCHOOL[0]]
assert [holiday.name for holiday in parse_holidays(raw)] == ["Herbstferien"]
def test_the_cache_round_trips(tmp_path: Path) -> None:
path = tmp_path / "cache" / "holidays.json"
save_calendar(path, _calendar())
assert load_calendar(path, "DE-BY") == _calendar()
def test_a_cache_for_another_subdivision_is_not_used(tmp_path: Path) -> None:
path = tmp_path / "holidays.json"
save_calendar(path, _calendar())
assert load_calendar(path, "DE-BW") is None
def test_a_broken_or_missing_cache_is_no_cache(tmp_path: Path) -> None:
path = tmp_path / "holidays.json"
assert load_calendar(path, "DE-BY") is None
path.write_text("{not json")
assert load_calendar(path, "DE-BY") is None
async def test_fetching_asks_for_the_state_and_both_lists() -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
body = SCHOOL if request.url.path == "/SchoolHolidays" else PUBLIC
return httpx2.Response(200, json=body)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
calendar = await fetch_calendar(client, "DE-BY-AU", date(2026, 10, 1))
assert [r.url.path for r in requests] == ["/SchoolHolidays", "/PublicHolidays"]
assert all(r.url.params["subdivisionCode"] == "DE-BY" for r in requests)
assert all(r.url.params["countryIsoCode"] == "DE" for r in requests)
assert calendar.subdivision == "DE-BY-AU"
assert calendar.fetched == date(2026, 10, 1)
assert len(calendar.school) == 3
assert len(calendar.public) == 3
async def test_a_failed_fetch_raises() -> None:
transport = httpx2.MockTransport(lambda _: httpx2.Response(503))
async with httpx2.AsyncClient(transport=transport) as client:
with pytest.raises(httpx2.HTTPStatusError):
await fetch_calendar(client, "DE-BY", date(2026, 10, 1))

View File

@@ -0,0 +1,350 @@
"""The screen service: runs the configured command per toggle, and never takes the bus down."""
from __future__ import annotations
import asyncio
import io
import os
import shlex
import sys
import wave
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.blubb import RATE, blubb_wav
from musicmouse.bus import EventBus
from musicmouse.clock import FakeClock
from musicmouse.color import ColorRGBW
from musicmouse.config import ScreenConfig
from musicmouse.effects import EffectStaticConfig
from musicmouse.events import (
LedEffectRequested,
PlaybackChanged,
PlayRemoteSlotRequested,
ToggleScreenRequested,
UserActivity,
)
from musicmouse.hardware import LedZone
from musicmouse.services.screen import ScreenService
def _python(code: str) -> str:
return f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
def _appending(log: Path, *, then: str = "") -> str:
"""A command that appends a line to ``log`` each time it runs."""
return _python(f"open({str(log)!r}, 'a').write('ran\\n'); {then}")
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as bus:
yield bus
async def _settle(service: ScreenService) -> None:
while service._running is not None and not service._running.done():
await asyncio.sleep(0.01)
async def _started(
bus: EventBus,
config: ScreenConfig,
*,
alsa_device: str | None = None,
volume: float = 0.5,
) -> tuple[ScreenService, asyncio.Task[None]]:
service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume)
task = asyncio.create_task(service.run())
await asyncio.sleep(0) # let it subscribe
return service, task
async def test_each_press_runs_the_command(bus: EventBus, tmp_path: Path) -> None:
log = tmp_path / "ran"
service, task = await _started(bus, ScreenConfig(toggle_command=_appending(log)))
try:
for _ in range(2):
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert log.read_text() == "ran\nran\n"
async def test_a_press_while_the_command_runs_is_ignored(bus: EventBus, tmp_path: Path) -> None:
log = tmp_path / "ran"
command = _appending(log, then="import time; time.sleep(0.3)")
service, task = await _started(bus, ScreenConfig(toggle_command=command))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert log.read_text() == "ran\n"
async def test_a_failing_command_is_logged(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
command = _python("import sys; print('no display'); sys.exit(3)")
service, task = await _started(bus, ScreenConfig(toggle_command=command))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert "exit 3" in caplog.text
assert "no display" in caplog.text
async def test_a_missing_command_is_logged(
bus: EventBus, caplog: pytest.LogCaptureFixture, tmp_path: Path
) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=str(tmp_path / "no-such-command"))
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert "could not start" in caplog.text
async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
command = _python("import time; time.sleep(30)")
service, task = await _started(bus, ScreenConfig(toggle_command=command, timeout=0.2))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await asyncio.wait_for(_settle(service), 5)
finally:
task.cancel()
assert "killed" in caplog.text
# ------------------------------------------------------------------------ the blubb
def test_the_blubb_is_a_short_mono_wav() -> None:
with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav:
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
assert 0.1 < wav.getnframes() / RATE < 0.5
def test_on_and_off_sound_different() -> None:
assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5)
@pytest.fixture
def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An ``aplay`` on PATH that records its arguments and what it was fed."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
record = tmp_path / "aplay"
script = bin_dir / "aplay"
script.write_text(
f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n'
f"cat > {shlex.quote(str(record))}.wav\n"
)
script.chmod(0o755)
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
return record
async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects"
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"]
assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5)
@pytest.mark.parametrize(
("output", "alsa_device", "volume"),
[
("print('on')", None, 0.5), # simulated audio
("print('on')", "softvol_effects", 0.0), # muted
("print('done')", "softvol_effects", 0.5), # no direction to go by
],
)
async def test_no_blubb(
bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float
) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert not Path(f"{fake_aplay}.wav").exists()
# ------------------------------------------------------------------- idle auto-off
class _Idle:
"""A started service with a fake clock and an ``off`` and ``on`` command that log."""
def __init__(self, bus: EventBus, tmp_path: Path, **config: object) -> None:
self.bus = bus
self.off_log = tmp_path / "off"
self.on_log = tmp_path / "on"
self.playing = False
self.clock = FakeClock(idle=bus.drain)
settings: dict[str, object] = {
"toggle_command": _python("print('toggled')"),
"off_command": _appending(self.off_log, then="print('off')"),
"on_command": _appending(self.on_log, then="print('on')"),
"auto_off_minutes": 15,
}
settings.update(config)
self.service = ScreenService(
bus,
ScreenConfig.model_validate(settings),
alsa_device=None,
volume=lambda: 0.5,
is_playing=lambda: self.playing,
clock=self.clock,
)
self.task: asyncio.Task[None] | None = None
async def __aenter__(self) -> _Idle:
self.task = asyncio.create_task(self.service.run())
await asyncio.sleep(0)
return self
async def __aexit__(self, *_: object) -> None:
assert self.task is not None
self.task.cancel()
async def wait(self, minutes: float) -> None:
await self.clock.advance(minutes * 60)
await _settle(self.service)
def offs(self) -> int:
return len(self.off_log.read_text().splitlines()) if self.off_log.exists() else 0
def ons(self) -> int:
return len(self.on_log.read_text().splitlines()) if self.on_log.exists() else 0
async def test_the_screen_goes_off_after_the_idle_time(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(14)
assert idle.offs() == 0
await idle.wait(1)
assert idle.offs() == 1
await idle.wait(60)
assert idle.offs() == 1 # once, not every half minute after
async def test_not_while_music_plays(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.playing = True
await idle.wait(60)
assert idle.offs() == 0
# The countdown starts when the music stops.
idle.playing = False
await bus.emit_and_wait(PlaybackChanged(playing=False, source="player"))
await idle.wait(14)
assert idle.offs() == 0
await idle.wait(1)
assert idle.offs() == 1
@pytest.mark.parametrize(
"event",
[UserActivity(source="web"), PlayRemoteSlotRequested(digit="3", source="lirc")],
)
async def test_activity_restarts_the_countdown(
bus: EventBus, tmp_path: Path, event: UserActivity | PlayRemoteSlotRequested
) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(10)
await bus.emit_and_wait(event)
await idle.wait(10)
assert idle.offs() == 0
await idle.wait(5)
assert idle.offs() == 1
async def test_automations_do_not_count_as_activity(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(10)
effect = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
automation = LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
await bus.emit_and_wait(automation)
await idle.wait(5)
assert idle.offs() == 1
async def test_someone_coming_back_switches_it_on(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(15)
assert idle.offs() == 1
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
assert idle.ons() == 1 # only the first wakes it
await idle.wait(15)
assert idle.offs() == 2 # and the countdown runs again
async def test_the_power_key_after_an_auto_off_is_left_to_the_toggle(
bus: EventBus, tmp_path: Path
) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(15)
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(idle.service)
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
assert idle.ons() == 0
async def test_zero_minutes_means_never(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path, auto_off_minutes=0) as idle:
await idle.wait(600)
assert idle.offs() == 0
async def test_no_off_command_means_never(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path, off_command=None) as idle:
await idle.wait(600)
assert idle.offs() == 0
async def test_a_new_idle_time_applies_at_once(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.service.config.auto_off_minutes = 5 # what parent mode does
await idle.wait(5)
assert idle.offs() == 1
async def test_the_auto_off_is_silent(bus: EventBus, tmp_path: Path, fake_aplay: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.service.alsa_device = "softvol_effects"
await idle.wait(15)
assert idle.offs() == 1
assert not Path(f"{fake_aplay}.wav").exists()

View File

@@ -0,0 +1,371 @@
"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
from __future__ import annotations
from collections.abc import AsyncIterator
from copy import deepcopy
from pathlib import Path
from typing import Any, TypeVar
import pytest
from musicmouse.blubb import confirm_wav, tick_wav
from musicmouse.config import load_config
from musicmouse.effects import (
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectSwipeAndChange,
)
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
from musicmouse.hardware import LedZone
from musicmouse.reactions import selection
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.harness import Simulation, build_simulation
from musicmouse.sound import ui_level
from tests.conftest import VALID_CONFIG, write_config, write_track
TRACK_SECONDS = 10.0
TIMEOUT = DEFAULTS.timeout
T = TypeVar("T", bound=Event)
def _albums(config_dir: Path) -> None:
"""Give the fox three albums of two tracks each; the owl keeps its one."""
fuchs = config_dir / "music" / "Figuren" / "fuchs"
for track in fuchs.glob("*.mp3"):
track.unlink()
for folder in ("A", "B", "C"):
for index in range(2):
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
_albums(config_dir)
return await build_simulation(
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
)
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
simulation = await _simulation(config_dir, VALID_CONFIG)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
def mouse(sim: Simulation) -> SimulatorDriver:
return sim.driver
@pytest.fixture
def seen(sim: Simulation) -> list[Event]:
events: list[Event] = []
sim.bus.subscribe_all(events.append)
return events
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
def ring(sim: Simulation) -> object:
return sim.app.mouse.effect(LedZone.RING)
def picker(sim: Simulation) -> EffectSegmentSelect:
effect = ring(sim)
assert isinstance(effect, EffectSegmentSelect)
return effect
# ------------------------------------------------------------------------- opening
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
mouse.check("playing", "true")
mouse.check("album", "Fuchs - A")
effect = picker(sim)
assert effect.colors == PALETTE[:3]
assert effect.selected == 0
# Only the ring is the picker; the body and shelf run the usual start animation.
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
async def test_a_figure_with_one_album_gets_no_picker(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("eule")
assert isinstance(ring(sim), EffectSwipeAndChange)
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
config = deepcopy(VALID_CONFIG)
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
sim = await _simulation(config_dir, config)
try:
await sim.driver.place("fuchs")
effect = picker(sim)
assert effect.offset_degrees == 90
assert effect.wobble_amplitude == 3
assert effect.slide_ms == DEFAULTS.slide_ms
finally:
await sim.aclose()
# --------------------------------------------------------------------------- knob
async def test_turning_moves_the_selection_instead_of_the_volume(
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
) -> None:
await mouse.place("fuchs")
volume = sim.player.volume
await mouse.turn(1)
effect = picker(sim)
assert (effect.previous, effect.selected) == (0, 1)
assert sim.player.volume == volume
assert not only(seen, RotaryTurned)
assert only(seen, FigureSelectionChanged)[-1].selected == 1
# Turning only moves the highlight; what plays changes on a press.
mouse.check("album", "Fuchs - A")
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
await mouse.turn(-1)
assert picker(sim).selected == 2
await mouse.turn(2)
assert picker(sim).selected == 1
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
) -> None:
await mouse.place("fuchs")
await mouse.turn(2)
await mouse.press("rotary")
mouse.check("album", "Fuchs - C")
mouse.check("track", "0")
mouse.check("playing", "true")
effect = ring(sim)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.start_with_existing # a fade from the sections, not a jump
# The press was the picker's: Home Assistant never hears of it.
assert not only(seen, ButtonEvent)
assert only(seen, FigureSelectionChanged)[-1].open is False
volume = sim.player.volume
await mouse.turn(-1)
assert sim.player.volume < volume
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.wait(3)
await mouse.press("rotary")
mouse.check("album", "Fuchs - A")
assert sim.player.position == pytest.approx(3.0)
assert sim.app.state.selector is None
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.place("fuchs")
await mouse.press("rotary")
await mouse.press("rotary")
assert len(only(seen, ButtonEvent)) == 1
# ------------------------------------------------------------------------ timeout
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.wait(TIMEOUT + 0.1)
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
mouse.check("album", "Fuchs - A")
mouse.check("playing", "true")
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
await mouse.wait(TIMEOUT - 2)
await mouse.turn(1)
await mouse.wait(TIMEOUT - 1)
assert sim.app.state.selector is not None
await mouse.wait(1.1)
assert sim.app.state.selector is None
async def test_lifting_the_figure_closes_the_picker(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
assert isinstance(ring(sim), EffectReverseSwipe)
# ------------------------------------------------------------------------- memory
async def test_putting_the_figure_back_resumes_the_picked_album(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(3)
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
assert sim.player.position == pytest.approx(3.0)
assert picker(sim).selected == 1
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(TRACK_SECONDS + 3)
await mouse.remove()
await mouse.place("eule")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
mouse.check("track", "0")
assert sim.player.position == pytest.approx(0.0)
assert picker(sim).selected == 1
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(2 * TRACK_SECONDS + 1)
mouse.check("playing", "false")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
mouse.check("track", "0")
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.play_album("Fuchs - C")
await mouse.wait(2)
await mouse.place("fuchs")
mouse.check("album", "Fuchs - C")
assert picker(sim).selected == 2
# -------------------------------------------------------------------------- sound
@pytest.fixture
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
played: list[tuple[str, bytes]] = []
monkeypatch.setattr(
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
)
return played
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
config = deepcopy(VALID_CONFIG)
config["general"]["alsa_device"] = "softvol_effects"
config["general"]["effects_volume"] = effects_volume
return await _simulation(config_dir, config)
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir)
try:
await sim.driver.place("fuchs")
await sim.driver.turn(1)
await sim.driver.turn(1) # no time passed: one tick for both
await sim.driver.wait(0.1)
await sim.driver.turn(1)
level = ui_level(sim.player.volume / 100, 1.0)
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
finally:
await sim.aclose()
async def test_picking_an_album_plays_the_confirm_sound(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir, effects_volume=50)
try:
await sim.driver.place("fuchs")
await sim.driver.press("rotary")
level = ui_level(sim.player.volume / 100, 0.5)
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
finally:
await sim.aclose()
async def test_no_picker_sounds_with_effects_turned_off(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir, effects_volume=0)
try:
await sim.driver.place("fuchs")
await sim.driver.turn(1)
await sim.driver.press("rotary")
assert sounds == []
finally:
await sim.aclose()

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import httpx2 import httpx2
import pytest import pytest
from musicmouse.config import WebConfig, load_config from musicmouse.config import ScreenConfig, WebConfig, load_config
from musicmouse.services.web.service import build_app from musicmouse.services.web.service import build_app
from musicmouse.services.web.settings import to_device_volume, to_percent from musicmouse.services.web.settings import to_device_volume, to_percent
from musicmouse.simulator.harness import Simulation, build_simulation from musicmouse.simulator.harness import Simulation, build_simulation
@@ -20,6 +20,7 @@ SETTINGS = {
"initial_volume": 40, "initial_volume": 40,
"volume_increment": 5, "volume_increment": 5,
"button_leds_brightness": 0.5, "button_leds_brightness": 0.5,
"effects_volume": 100,
} }
#: A config file with comments, to prove saving does not flatten them. #: A config file with comments, to prove saving does not flatten them.
@@ -99,8 +100,46 @@ def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None: async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
body = (await client.get("/api/settings")).json() body = (await client.get("/api/settings")).json()
assert set(body) == set(SETTINGS) assert set(body) == {*SETTINGS, "screen_auto_off_minutes"}
assert body["max_volume"] == 60 assert body["max_volume"] == 60
assert body["screen_auto_off_minutes"] is None # no screen to switch off
async def test_the_screen_auto_off_is_read_and_saved_into_its_section(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
path = config_dir / "config.yml"
path.write_text(
COMMENTED.replace(
"\nfigures:",
" screen:\n toggle_command: kiosk-screen toggle\n"
" off_command: kiosk-screen off\n\nfigures:",
),
encoding="utf-8",
)
sim.app.config.general.screen = ScreenConfig(
toggle_command="kiosk-screen toggle", off_command="kiosk-screen off"
)
assert (await client.get("/api/settings")).json()["screen_auto_off_minutes"] == 15
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
assert response.status_code == 200
assert response.json()["screen_auto_off_minutes"] == 30
assert sim.app.config.general.screen.auto_off_minutes == 30 # live, for ScreenService
reloaded = load_config(path)
assert reloaded.general.screen is not None
assert reloaded.general.screen.auto_off_minutes == 30
assert "screen_auto_off_minutes" not in path.read_text(encoding="utf-8")
async def test_the_screen_auto_off_without_a_screen_is_ignored(
client: httpx2.AsyncClient, config_dir: Path
) -> None:
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
assert response.status_code == 200
assert response.json()["screen_auto_off_minutes"] is None
assert "screen" not in (config_dir / "config.yml").read_text(encoding="utf-8")
# -------------------------------------------------------------------------- writing # -------------------------------------------------------------------------- writing

Some files were not shown because too many files have changed in this diff Show More