48 Commits

Author SHA1 Message Date
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
e243862769 Fix barely-visible music-note tab icon
The glass-bar redesign dropped the explicit icon color, so inactive
icons fell back to the browser's default text color - fine for icons
that render as full-color emoji, but the music note apparently renders
as a plain glyph on at least one platform, making it nearly invisible
against the bar's near-white glass background. Set it explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:34 +02:00
b8f9f6d537 Give the tab rail its own glass bar and reserve space for it
The three tab buttons now share one frosted glass pill instead of each
floating separately, and BrowseView/RoomView reserve TAB_RAIL_CLEARANCE
of right-side padding so a full-width row or grid never renders under
it - verified against the real library, where dense rows (e.g. Conni's
82 audiobooks) previously butted right up against the rail.

Also gitignore the real (non-.example) tippen-curriculum.yml and
tippen-progress.json, matching config.yml's own privacy treatment,
since the curriculum now contains real reward paths into a personal
library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:16:42 +02:00
57ead93497 Apply lock state to category-tile cover previews too
Verified the whole reward-lock feature against a real library with an
actual browser (Playwright + headless Chromium): the album-grid cards
already showed the question-mark placeholder for a locked album, but
its 2x2 preview thumbnail in the category-tile view (the screen before
drilling into an artist/category) still rendered the real cover art,
since that Cover call never received the locked prop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:37:57 +02:00
7f5e2733c2 Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:25:42 +02:00
f7a5d24d8d Add backend support for the typing game: curriculum, progress, and reward unlocks
Moves the typing app's lesson plan and progress from client-side YAML/localStorage
into the backend, and adds a reward system that ties passing a lesson to unlocking
part of the music library. Lock state is always recomputed live from curriculum x
progress x the live library, never persisted separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:03:45 +02:00
a5210fead2 Typing lessons like duolingo & musicmouse cleanup 2026-09-12 18:58:02 +02:00
498243af46 Anglicize tippen's codebase and finish pending UI/curriculum cleanup
- Rename all German identifiers, types, mode ids, file names, CSS classes
  and data-attributes to English throughout tippen/src; only user-facing
  text (lesson titles, word lists, labels, spoken praise) stays German.
- Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row
  lesson in the curriculum.
- Remove the unused HandHint component and speech.ts, and carry forward
  the in-progress App.tsx/component/generator/progress edits from other
  sessions.
- Refresh the regenerated music-library cache index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:07:18 +02:00
f97de193d8 First implementation of 10 finger typing 2026-09-11 21:55:45 +02:00
7e5fd5ab75 Animations 2026-09-11 13:56:02 +02:00
fbf03a9847 UI cleanup, visual and keyboard navigation 2026-09-11 13:32:07 +02:00
df89acd9a8 Fix podcast episode downloads: ffmpeg muxer bug and dead-URL retry storm
_extract_audio wrote transcoded audio to a .tmp temp path, so ffmpeg
couldn't guess a muxer from the filename and aborted on every video
episode whose source was still reachable. Pass -f mp3 explicitly instead
of relying on the extension.

Separately, a permanently 404'd episode was retried on every startup and
every 6-hour poll forever, since nothing remembered past failures. Track
failed downloads per show folder with a 7-day backoff before retrying.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:30:42 +02:00
829ea89386 Fix search: allow spaces and match multi-word queries as separate words
Space was hard-coded as the global play/pause toggle, so it never reached
the search-typing logic - typing "geolino azte" would toggle playback
instead of adding the space. Now a space is appended to an in-progress
search instead.

Search also required the whole query to be one contiguous substring of
title + artist, which misses queries like "conni rad" for "Conni lernt
Rad fahren" or "geolino azte" for a podcast episode titled "Azteken" by
"GEOlino Spezial" (title comes before artist). Each word is now matched
independently, in any order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:11:36 +02:00
a3b2c0ce2f Add a vim-style keyboard layer: Ctrl navigates, Shift controls transport
Ctrl+h/j/k/l move the highlight in whatever list is on screen - the browse
grid, search results, or an open album's track list - the same job the
arrow keys already do, just reachable without leaving the home row.
Ctrl+d/u add vim's own half-page jump. Shift+h/j/k/l/m are transport
(previous/next, volume, mute) from anywhere, including the room page,
matching the muscle memory of other vim-ish media apps; every other
Shift+letter still reaches search untouched; only the shifted letter
form of those five keys is intercepted.

Shift+Enter opens an album's track list (the keyboard equivalent of
clicking its cover, which had no key of its own until now) instead of
playing it outright; Ctrl+j/k then move a visible highlight through that
list, and Enter plays whichever track is highlighted. Falls back to plain
ENTER's behaviour for a category tile or a podcast episode, neither of
which has a track list to show.

Mute (Shift+M) is new - nothing was bound to it before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:40:40 +02:00
ba7f082f48 Rework navigation for a consistent music/room toggle and back button
- Replace the "Mein Zimmer"/"Musik" text pills and the help button with
  two fixed circular corner buttons: a music/room toggle (top-right)
  and a single "back one level" button (top-left) used everywhere -
  search, categories, and the play view.
- Split `view` (browse/play) from a new `page` (music/room) in UiState
  so toggling to the room and back leaves the music side - search,
  selection, even the full-screen play view - exactly as it was.
- Back peels one step at a time (search, then track-search mode, then
  group+category together, since a root shelf tile sets both at once
  and undoing that jump should be one step too).
- Album cards: click the cover to open the track list, click the title
  to start playing immediately (matching what Enter already did from
  the keyboard). Audiobook cards drop the artist line and clamp the
  title to a fixed height so covers stay aligned across a row.
- Root shelf group headings ("Musik", "Hörbücher", "Podcasts") are now
  the link into that group's full category grid, replacing the
  separate search-icon button.
- "Taste zuweisen" is now an icon-only round button, moved to the
  bottom-right so it doesn't sit under the new toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:12:39 +02:00
69119bb72a Add per-episode podcast cover art and fix nested album grouping
- podcast_feeds.py: fetch per-episode cover art from a feed's itunes:image
  (when it's genuinely distinct from the channel image) or, failing that,
  from the og:image on the episode's own linked page; extract audio from
  video-only enclosures via ffmpeg; match podcast-dl's filename convention
  (illegal characters become "_" instead of being dropped) so enabling
  feed.txt on an already-downloaded show doesn't re-download its back catalog
- scanner.py: _cover_for_episode now checks for a same-stem sidecar cover
  image before falling back to embedded ID3 art and the shared folder cover
- scanner.py/__init__.py: fixed a bug where an album folder nested one level
  deeper than usual (an age-range grouping folder, say) was mistaken for an
  empty album and skipped; added periodic progress logging for long scans
  and analysis passes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 09:54:33 +02:00
747c390303 Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/
volume/mute map to the same intents every other front-end already
emits, and number keys 0-9 play an assigned album/audiobook from the
start or a podcast show's newest episode, resolved fresh on every
press. The mapping is configured in config.yml and editable from the
frontend: a small "Taste zuweisen" button on the play screen (or the
A+digit keyboard shortcut) opens a 10-key picker to assign whatever is
currently playing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:31:28 +02:00
57afc32f4a Auto-download new podcast episodes from an RSS feed
A show folder under Kinderpodcasts/ opts in by containing a feed.txt marker
naming its RSS feed. A new PodcastFeedService polls every such feed every 6
hours (and once at startup), downloads any episode not already on disk using
the existing YYYYMMDD - Title.ext convention, and triggers the same
rescan-and-broadcast sequence "Bibliothek neu einlesen" already uses - now
shared via App.rescan_library() instead of duplicated. A show with no
feed.txt is untouched, so there is no new config section for this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:43:50 +02:00
2e0e6ad199 Add librosa beat/mood analysis and an Ambience background driven by it
The background worker now runs a real librosa analyzer (tempo, beat grid,
per-second energy/valence curves) instead of only the null baseline, kept
behind build_analyzer() so a plain checkout without the analysis extra still
runs fine. The web player reads that per-track analysis and drives a new
animated "Ambience" background (bubbles, colour, current) that reacts to the
beat and the mood curve as the track plays, plus a debug overlay for tuning
it. Also adds a one-off script to backfill podcast cover art from iTunes.
2026-09-10 22:42:51 +02:00
8aed3b022b updates 2026-08-27 23:46:19 +02:00
a8ed350aec Add "Mein Zimmer" room-control page (Home Assistant, proxied through the backend)
Implements the room-control page from the design mockup: a scenes row above cards
for shutters, color lamps, and brightness-only lamps, all driven by a new
`general.ha` config section (server URL, token, ordered device/scene lists).

The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...)
rather than the browser calling Home Assistant directly, so the long-lived token
never leaves the LAN device and Home Assistant's own CORS settings don't need to
know about musicmouse at all. Card kind (shutter/color/brightness-only) is
inferred at runtime from what Home Assistant reports about each entity, not
configured explicitly.

Also stops tracking python-backend/config.yml, which had drifted into the repo
despite its own header saying it shouldn't be - it now carries real credentials
locally and needs to stay untracked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:45:29 +02:00
edb6e5e027 Web frontend 2026-08-27 12:32:20 +02:00
d44c24ec97 Full rearchitecture using Claude
- event bus systen
- all components are independent
- preparation for web frontend
2026-08-26 13:22:28 +02:00
280 changed files with 55048 additions and 1010 deletions

7
.gitignore vendored
View File

@@ -1,7 +1,14 @@
generated_3d
venv
.venv
build
*.egg-info
*.FCStd1
*.blend1
__pycache__
.ipynb_checkpoints
.pytest_cache
.mypy_cache
.ruff_cache
.envrc
.direnv

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>

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 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.30.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

@@ -1,42 +1,129 @@
# MusicMouse — repo overview
Orientation doc for AI agents (or humans) working on this repo for the first time. Written from what's actually in the repo — no roadmap speculation beyond `esp-firmware/todo.md`.
Orientation doc for AI agents (or humans) working on this repo for the first time.
## What this is
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each carry an RFID tag. Placing a figurine on the mouse triggers an RFID read, which starts that figure's music playlist. The mouse also has a rotary encoder + touch buttons (ears/feet) for volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home Assistant integration so a "shelf light" shows up as a smart-home device.
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
Assistant integration.
## Repo layout
| Path | What it is |
|---|---|
| `python-backend/` | Python host application — the main runtime. Reads RFID/button/encoder events from the ESP32 over serial, drives playback via VLC, sends LED effect commands, bridges to MQTT/Home Assistant. Start here for backend work. |
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
| `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. |
| `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/` | An unrelated exploratory web-UI mockup ("Dolphin Beats Music Player" / rebrand concept). Not integrated with the rest of the repo — no build system ties it in. Don't assume it reflects current product direction. |
| `.vscode/` | Editor settings (C++ header associations). |
There is no top-level README elsewhere in the repo; this file plus `esp-firmware/todo.md` and `hardware/pinout.md` are the only prose docs.
| `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. |
## How the pieces talk to each other
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial (`pyserial-asyncio`), implemented in `python-backend/host_driver.py`. Messages are framed with magic tokens (`MAGIC_TOKEN_HOST_TO_FW`/`MAGIC_TOKEN_FW_TO_HOST`) and a `struct`-packed header. The message-ID maps and struct formats in `host_driver.py` must stay byte-for-byte in sync with the firmware's `esp-firmware/src/Messages.h` — there's no shared schema or test verifying this cross-language contract, so a firmware protocol change can silently desync the Python side.
- **`python-backend` ↔ MQTT/Home Assistant**: `python-backend/mqtt_json.py` exposes the shelf LED strip as a Home-Assistant-discoverable JSON-schema MQTT light (`ShelveLightMqtt`), and `main.py` also calls Home Assistant services directly (e.g. toggling room lights) via `hass-client`.
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
firmware log text interleaved on the same link. The Python side lives in
`python-backend/musicmouse/devices/wire.py`, the firmware side in
`esp-firmware/src/Messages.h`.
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
exact bytes of every effect payload — so a firmware change that breaks the host now
breaks a test instead of just the LEDs.
- **`python-backend``web/`**: JSON over HTTP for the library and for commands, plus a
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
the physical buttons emit, so the web UI has no privileged path — and no way to get
out of step with a figure someone puts on the reader. See
`python-backend/README.md` for the endpoint list.
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
discoverable lights, a player sensor, a volume number, transport buttons, device
triggers for every button and touch area, and a tag scanner for the RFID reader. It
does *not* call Home Assistant services directly any more; behaviour like "the left
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
for the trigger topics and the old colour mapping.
## Running it
```
python python-backend/main.py <config_dir>
```sh
python -m musicmouse --config /media/musicmouse/config.yml
```
`main.py` expects `<config_dir>/config.yml` (schema documented in the new `python-backend/config.yml.example` — no real config was previously checked in or documented). In production this is deployed as a systemd service reading music from `/media/musicmouse/`; see `esp-firmware/musicmouse.service` for the unit template — **note its `ExecStart` path (`.../espmusicmouse/host_driver/main.py`) is stale**, referencing the pre-reorg directory layout from before the `bd8925a "Cleaned up repository"` commit moved things to `python-backend/`. Update that path before relying on the service file.
On a machine with no mouse attached — real audio and a real web UI, no serial port:
## Config schema (see `python-backend/config.yml.example`)
```sh
python -m musicmouse --config ./config.yml --no-hardware
```
- `general.{alsa_device, serial_port, hass_url, hass_token, mqtt.{server,user,password}, min_volume, max_volume, volume_increment, button_leds_brightness}`
- `figures.<name>.{id, colors, media_files}``id` is a hex RFID tag id, `colors` is a list of 4 colors (`primary, secondary, background, accent`, each `"#rrggbb"` or `"wNN"`), `media_files` is optional (auto-globbed from the config dir by figure name if omitted).
`general.serial_port: simulate` does the same thing from the config, and
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
both keys are required - omitting one is an error rather than an implicit simulation.
## Known gaps / notes for agents
Or with no hardware *and* no audio:
- **No automated tests, no lint/formatter config, no CI** anywhere in the repo (neither `python-backend/` nor `esp-firmware/`, aside from a PlatformIO `native` build env for firmware unit testing).
- `python-backend/audio_analysis.py` and the three chord-recognition notebooks (`C5S2_ChordRec_Templates.ipynb`, `C5S3_ChordRec_HMM.ipynb`, `C5S3_HiddenMarkovModel.ipynb`) are university-course exploratory material (chroma/chord-recognition DSP), **not imported by `main.py`** and not part of the running app. They reference stale personal absolute paths.
```sh
python -m musicmouse --config ./config.yml --simulate
```
The simulator runs the whole app against a fake serial link and a fake player, either
interactively or from a scenario file. `python-backend/musicmouse.service` is the
systemd unit for the device.
For front-end work, run the backend (either way above) and then:
```sh
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
```
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
production so one process serves both the UI and the API.
## Config
`python-backend/config.yml.example` documents the schema. In short:
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
audio_extensions, mqtt.*, web.*}`
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
`"wNN"`.
`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:
```
<root>/Figuren/<figure name>/ one folder per figurine
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
```
Config is validated with pydantic: unknown keys are errors, and every problem is
reported at once.
## Notes for agents
- The backend has `pytest`, `ruff` and `mypy --strict` configured in
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes.
- 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.
- `python-backend/notebooks/` is university course material on chord recognition, not
part of the app.
- 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`;
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
the two checks.
- 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
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
`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

@@ -1,13 +0,0 @@
# Put this into /etc/systemd/system/musicmouse.service
[Unit]
Description=Music Mouse RFID Music Player
After=multi-user.target
[Service]
Type=simple
Restart=always
ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
[Install]
WantedBy=multi-user.target

4
python-backend/.gitignore vendored Normal file
View File

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

241
python-backend/README.md Normal file
View File

@@ -0,0 +1,241 @@
# MusicMouse backend
The host application: it reads RFID tags, buttons and touch areas from the ESP32
firmware over serial, plays music through VLC, drives three LED strips, indexes the
music collection, and exposes everything to Home Assistant over MQTT and to a browser
over HTTP.
```
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in)
browser ⇄ WebService ───────┘ │
reactions/*.py ── call actions on ──► device / player
```
Three objects own the outside world, one bus carries everything, and the *reactions*
are the only place that decides what should happen. Adding a new way to control the
mouse means adding a service that emits the same intents - no device or reaction
changes. The web front-end was added exactly that way.
## Running it
```sh
pip install -e '.[dev]'
python -m musicmouse --config /media/musicmouse/config.yml
```
See `config.yml.example` for the schema and `musicmouse.service` for the systemd unit.
Config problems are reported all at once with the path to each one; unknown keys are
errors, not silent no-ops.
### On a host with no mouse attached
The web front-end is a complete way to drive the player, so the backend is useful on a
machine with no serial port and no sound card worth grabbing. Two config keys say so,
each by taking the literal value `simulate`:
| Setting | `simulate` gives you | Warned about as |
|---|---|---|
| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" |
| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" |
Both keys are **required**. Leaving one out is a config error, not a shortcut to
simulation - running blind or silent has to be asked for, so a config that lost a line
fails loudly instead of booting into something that looks like it is working. The
warnings are repeated on every boot for the same reason.
`--no-hardware` forces the serial half regardless of the config, for a one-off run:
```sh
python -m musicmouse --config ./config.yml --no-hardware
```
Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize
whatever the desktop is playing through, which is the wrong thing to do silently - name
a device (`"default"` is the system one) when the machine is meant to make noise.
### Without hardware
The simulator runs the entire app - real bus, real device, real reactions, real MQTT
if configured - against a fake serial link and a fake player.
```sh
python -m musicmouse --config ./config.yml --simulate
```
```
musicmouse> place fuchs
in RfidTokenRead(04a1b2c3d4, figure='fuchs')
rfid fuchs
led ring: SwipeAndChange(AlexaSwipe(#(1.0, 0.4, 0.0, 0) -> ...))
play playing
musicmouse> press right
play track 1: 01 - Song 1
```
`help` lists the verbs. The same verbs go in a scenario file:
```sh
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
```
Scenario files run on a virtual clock under pytest, so `wait 1s` costs microseconds and
every file in `scenarios/` is part of the test suite. A session reproduced by hand at
the prompt becomes a regression test by pasting it into a `.txt` file.
## Layout
| Path | What it is |
|---|---|
| `musicmouse/bus.py` | One FIFO queue on one loop. Thread-safe `emit()` - which is how libVLC's callback thread stops reaching the serial transport. |
| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). |
| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). |
| `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/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/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. |
## Checks
```sh
pytest # unit + scenario tests
ruff check .
mypy # --strict, configured in pyproject.toml
```
`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
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
side). `tests/test_effects.py` pins the exact bytes of every effect payload.
## LED arbitration
`MusicMouseDevice` is the single writer to each zone, and the most recent effect wins -
whether it came from a figure animation or from Home Assistant. There is no priority
scheme. Every write emits `LedEffectChanged`, and the MQTT light entities publish their
state from that rather than echoing their own commands, so HA keeps showing the strip's
real state when a figure animation overrides a colour it set.
## Home Assistant
The backend no longer calls Home Assistant directly (`hass-client` is gone). It
publishes what happened; the automations live in HA.
MQTT device triggers are published for every button (`pressed`, `double_clicked`,
`long_pressed`), every touch area (touched/released), and the RFID reader appears as a
tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
### Recreating the old room-light behaviour
Two behaviours used to be hard-coded in `main.py` and now need automations:
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
`light.music_mouse_regal_licht`:
| Touch area | Old service data |
|---|---|
| `right_foot` | `rgb_color: [235, 255, 67]` |
| `left_foot` | `color_temp: 469` |
| `right_ear` | `rgb_color: [101, 49, 255]` |
| `left_ear` | `rgb_color: [255, 74, 254]` |
Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with
that data.
## The library
`general.library.root` is the one path to the music. The shelves under it are fixed
names rather than settings (`musicmouse/library/sections.py`), because each has quirks
the scanner has to know about:
| Folder | Shown as | Why it is special |
|---|---|---|
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
a playlist.
Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the
backend becomes that podcast downloader itself: every six hours it checks the feed and
saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed
one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly
as before - the file is the opt-in, there is no separate setting for it.
Each album carries three colours, pulled out of its cover art with Pillow (or
synthesised from a hash of its id when it has none). The frontend paints cards with
them and the LED strips run the first of them, so shelf and screen agree.
### The cache
`general.library.cache` is a directory, not a file, because its contents cost wildly
different amounts to produce:
```
index.json cheap: tags and structure. Rebuilt freely.
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame
analysis/<track_key>.json expensive: reserved for offline audio analysis
```
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
of DSP per track, which is why anything expensive is keyed by *file content* rather than
by album id - renaming a folder or re-sorting a section then costs nothing.
An entry is reused whenever its files' sizes and mtimes are unchanged. That means a
change to how the scanner derives a title, artist or series is invisible until the cache
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
that logic.
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with
the index, and a beat grid lives in its own file and is fetched per track - so an
analyzer can be added later without touching the scanner, the API or the frontend. It
will go behind an optional dependency group, and because results are content-keyed
files, they can equally well be computed on a workstation and the `analysis/` folder
copied to the device.
## The web front-end
`web/` in the repo root, served by this backend when `general.web.static_dir` is set.
| Route | Purpose |
|---|---|
| `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. |
| `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. |
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
| `POST /api/play` | `{album_id, track_index?}` |
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
| `GET` `PUT /api/settings` | parent mode |
Two decisions worth knowing:
**Search is not an endpoint.** The whole index goes to the browser and filtering happens
there, which is what makes the design's type-to-search feel instant.
**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a
child's, so it never crosses into the browser: `100 %` means whatever ceiling is
configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary
encoder and the firmware carry on in device units.
### Parent mode
`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the
button brightness. Saving writes `config.yml` back through ruamel's round-trip loader,
so the file keeps its comments, and a new ceiling applies to the running player rather
than waiting for a restart.
This **hides** the settings; it does not protect them. There is no authentication on any
endpoint, which matches a device on a home network - put it behind a reverse proxy if
that is not good enough.

View File

@@ -1,51 +1,184 @@
# Example config.yml for the MusicMouse python-backend.
# Example config for the MusicMouse backend.
#
# Reverse-engineered from main.py (load_config/Controller) since no schema
# was previously documented. Copy this file to config.yml in the directory
# passed as the CLI argument to main.py, e.g.:
# python -m musicmouse --config /media/musicmouse/config.yml
#
# python main.py /media/musicmouse/
#
# main.py reads "<config_dir>/config.yml". Real credentials (hass_token,
# mqtt.password) should never be committed - keep the real config.yml
# outside the repo (e.g. only on the deployed device).
# Unknown keys are rejected rather than ignored, and every problem in the file is
# reported at once, so a typo fails at startup with the path to the offending line.
# Keep the real config (with credentials) off the repo - on the device only.
general:
# ALSA output device passed to python-vlc, e.g. "hw:0,0"; omit/null for VLC's default.
# The music collection. One path; the shelves underneath it are fixed names, not
# settings, because each one has its own quirks the code already knows about:
#
# <root>/Figuren/<figure name>/ one folder per figurine
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
#
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
# of the files' tags. Relative paths resolve against this file's directory.
library:
root: /home/martin/Music
# Scan results, extracted cover art and track analysis. Safe to delete: the index
# is rebuilt on the next start. Deleting it does throw away track analysis, which
# is expensive to recompute.
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.
# Required - use "simulate" to run without the mouse attached, which is a complete
# setup on its own because the web front-end can drive the player by itself. RFID,
# buttons and LEDs then do nothing, and startup says so every boot.
serial_port: "/dev/ttyUSB0"
baudrate: 115200
reconnect_interval: 5.0
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
# default output. Required - use "simulate" for a player that makes no sound, which
# is handy when working on the web UI on a machine whose audio you would rather not
# commandeer. Startup says so every boot.
#
# Both of these are required rather than optional on purpose: running blind or silent
# has to be asked for, so a config that lost a line fails loudly instead of booting
# into something that looks like it is working.
alsa_device: "softvol_effects"
# Serial port the ESP32 firmware is connected on.
serial_port: "/dev/ttyUSB0"
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
min_volume: 0
max_volume: 60
initial_volume: 40
volume_increment: 5 # per rotary-encoder click
# Home Assistant connection used for light/service calls (hass_service()).
hass_url: "http://homeassistant.local:8123"
hass_token: "REPLACE_WITH_LONG_LIVED_ACCESS_TOKEN"
# Backlight of the prev/next buttons while a figure is playing, 0..1.
button_leds_brightness: 0.5
# MQTT broker used for the Home-Assistant-discoverable "shelf light".
# Which files count as music. Anything else - a podcast downloader's archive.json,
# a half-finished .tmp - is ignored.
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
# 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.
#
# There is no authentication: this is a device on a home network. The settings panel
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
# to this file. Put it behind a reverse proxy if that is not good enough.
web:
host: "0.0.0.0"
port: 8080
# Built frontend to serve at /. Omit to expose only the JSON API.
static_dir: ../web/dist
# 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.
# 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.
lirc:
host: "musicmouse-pi.local"
port: 2222 # this deployment's lircd listens on 2222, not its own
# default of 8765 - see the ansible role
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
# remote, say) are ignored
reconnect_interval: 5.0
# Home Assistant integration. Omit the whole section to run without MQTT.
# The backend exposes three lights, a player sensor, a volume slider, transport
# buttons, device triggers for every button/touch area, and a tag scanner.
mqtt:
server: "homeassistant.local"
port: 1883
user: "musicmouse"
password: "REPLACE_WITH_MQTT_PASSWORD"
base_topic: "musicmouse"
discovery_prefix: "homeassistant"
device_id: "musicmouse"
device_name: "Music Mouse"
reconnect_interval: 10.0
# Optional playback/UI tuning (all have defaults if omitted).
min_volume: 0
max_volume: 32
volume_increment: 5 # per rotary-encoder tick
button_leds_brightness: 0.5 # 0..1, brightness of the prev/next button backlight
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
# entities, not the other way round. The backend proxies every call to Home
# Assistant's REST API with this token attached; the browser never sees it, only
# entity ids and display names. Home Assistant's own CORS settings do not need to
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
ha:
url: "http://homeassistant.local:8123"
# A long-lived access token, created under the HA user's own profile page.
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
# Cards on the room page, in this order. "name" is optional; falls back to the
# entity id if omitted.
devices:
- entity_id: cover.kinderzimmer_rollo
name: "Rollo"
- entity_id: light.kinderzimmer_hue_beyond_links
name: "Hue Beyond links"
- entity_id: light.kinderzimmer_deckenlampe
name: "Deckenlampe"
# Scene pill row above the cards, in this order.
scenes:
- entity_id: scene.kinderzimmer_lesen
name: "Lesen"
- entity_id: scene.kinderzimmer_gute_nacht
name: "Gute Nacht"
# One entry per figurine. The key is an arbitrary figure name (also used as
# the subdirectory name under the config dir when media_files is omitted).
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
# front-end. The lesson plan is content, not device config, so it lives in its own
# file - see tippen-curriculum.yml.example for the format, including the optional
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
tippen:
curriculum_file: tippen-curriculum.yml
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
# app itself - never hand-edited. Relative to this file, like curriculum_file.
progress_file: tippen-progress.json
# One entry per figurine. The key is the figure name and the subfolder name.
figures:
fuchs:
# RFID tag id as a hex string (matched against bytes read from the reader).
# RFID tag id, 5 bytes as hex. Must be unique across figures.
id: "04a1b2c3d4"
# Exactly 4 colors: [primary, secondary, background, accent].
# Accepted formats: "#rrggbb" (RGB hex) or "wNN" (white channel hex, e.g. "wff").
colors: ["#ff6600", "#ffcc00", "#331100", "#ffffff"]
# Optional explicit list of media file paths for this figure's playlist.
# If omitted, main.py globs os.path.join(config_dir, "<figure_name>").
media_files: []
# Exactly four colours: primary, secondary, background, accent.
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
# "music" (default) or "book". Every other shelf is named after what is on it, so
# its type is obvious; a figure folder is named after the figurine, so this is the
# one thing that has to be said out loud. The web UI draws albums square and
# audiobooks taller than wide, so getting it wrong is visible at a glance.
kind: music
eule:
id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
kind: book
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
# 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.
#
# target_kind: album -> always starts from the first track (music, audiobooks).
# target is an album id, as shown at GET /api/library.
# target_kind: series -> always plays the newest episode of a podcast show, resolved
# fresh on every press - never a fixed episode. target is the
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
remote:
"1":
target_kind: album
target: "3f9a0c12ab44"
"2":
target_kind: series
target: "Wissen macht Ah"

View File

@@ -1,48 +0,0 @@
"""Some simple tests/examples for the Home Assistant client."""
import asyncio
import logging
import sys
from hass_client import HomeAssistantClient
LOGGER = logging.getLogger()
if __name__ == "__main__":
logformat = logging.Formatter(
"%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s")
consolehandler = logging.StreamHandler()
consolehandler.setFormatter(logformat)
LOGGER.addHandler(consolehandler)
LOGGER.setLevel(logging.DEBUG)
if len(sys.argv) < 3:
LOGGER.error("usage: test.py <url> <token>")
sys.exit()
url = sys.argv[1]
token = sys.argv[2]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
hass = HomeAssistantClient(url, token)
async def hass_event(event, event_details):
"""Handle hass event callback."""
LOGGER.info("received event %s --> %s\n", event, event_details)
hass.register_event_callback(hass_event)
async def run():
"""Run tests."""
await hass.async_connect()
await asyncio.sleep(10)
await hass.async_close()
loop.stop()
try:
loop.create_task(run())
loop.run_forever()
except KeyboardInterrupt:
loop.stop()
loop.close()

View File

@@ -1,200 +0,0 @@
import asyncio
from enum import Enum
import struct
from led_cmds import (EffectStaticConfig, EffectStaticDetailedConfig, EffectAlexaSwipeConfig,
EffectCircularConfig, EffectRandomTwoColorInterpolationConfig,
EffectSwipeAndChange, EffectReverseSwipe)
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631
class MessageFwToHost(Enum):
RFID_TOKEN_READ = 0
ROTARY_ENCODER = 1
TOUCH_BUTTON_PRESS = 2
TOUCH_BUTTON_RELEASE = 3
class TouchButton(Enum):
LEFT_FOOT = 0
RIGHT_FOOT = 1
LEFT_EAR = 2
RIGHT_EAR = 3
led_ring_effect_to_message_id = {
EffectStaticConfig: 0,
EffectAlexaSwipeConfig: 1,
EffectCircularConfig: 2,
EffectRandomTwoColorInterpolationConfig: 3,
EffectSwipeAndChange: 4,
EffectReverseSwipe: 5,
}
mouse_led_effect_to_message_id = {
EffectStaticConfig: 6,
EffectCircularConfig: 7,
EffectRandomTwoColorInterpolationConfig: 8,
EffectSwipeAndChange: 9,
EffectReverseSwipe: 10,
}
shelve_led_effect_to_message_id = {
EffectStaticConfig: 15,
EffectCircularConfig: 16,
EffectRandomTwoColorInterpolationConfig: 17,
EffectSwipeAndChange: 18,
EffectReverseSwipe: 19,
EffectStaticDetailedConfig: 20,
}
mouse_leds_index_ranges = {
TouchButton.RIGHT_FOOT: (0, 6),
TouchButton.LEFT_FOOT: (6, 6 + 6),
TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16),
TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17),
}
PREV_BUTTON_LED_MSG = 21
NEXT_BUTTON_LED_MSG = 22
class RfidTokenRead:
def __init__(self, id: bytes):
self.id = id
def __repr__(self):
return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")"
class RotaryEncoderEvent:
def __init__(self, msg_content: bytes):
self.position, self.increment, self.direction = struct.unpack("<iiB", msg_content)
def __repr__(self):
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
class TouchButtonPress:
def __init__(self, msg_content: bytes):
val = int(msg_content[0])
self.touch_button = TouchButton(val)
def __repr__(self) -> str:
return "Pressed " + repr(self.touch_button)
class TouchButtonRelease:
def __init__(self, msg_content: bytes):
val = int(msg_content[0])
self.touch_button = TouchButton(val)
def __repr__(self) -> str:
return "Released " + repr(self.touch_button)
class ButtonEvent:
button_name = {1: 'left', 2: 'right', 3: 'rotary'}
event_name = {
0: 'pressed',
1: 'released',
2: 'clicked',
3: 'double_clicked',
4: 'long_pressed',
5: 'repeat_pressed',
6: 'long_released'
}
def __init__(self, msg_content: bytes):
button_nr, event_nr = struct.unpack("<BB", msg_content)
self.button = self.button_name[button_nr]
self.event = self.event_name[event_nr]
def __repr__(self) -> str:
return f"Button {self.button} {self.event}"
incomingMsgMap = {
0: RfidTokenRead,
1: RotaryEncoderEvent,
2: TouchButtonPress,
3: TouchButtonRelease,
4: ButtonEvent,
}
class MusicMouseProtocol(asyncio.Protocol):
def __init__(self):
super()
self._msg_callback = None
def register_message_callback(self, cb):
self._msg_callback = cb
def connection_made(self, transport):
self.transport = transport
self.in_buff = bytes()
def __led_effect(self, effect_cfg, msg_dict):
msg_content = effect_cfg.as_bytes()
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, msg_dict[type(effect_cfg)],
len(msg_content))
self.transport.write(header + msg_content)
def led_ring_effect(self, effect_cfg):
self.__led_effect(effect_cfg, led_ring_effect_to_message_id)
def mouse_led_effect(self, effect_cfg):
self.__led_effect(effect_cfg, mouse_led_effect_to_message_id)
def shelve_led_effect(self, effect_cfg):
self.__led_effect(effect_cfg, shelve_led_effect_to_message_id)
def button_background_led_prev(self, val):
msg_content = struct.pack("<f", val)
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
self.transport.write(header + msg_content)
def button_background_led_next(self, val):
msg_content = struct.pack("<f", val)
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
self.transport.write(header + msg_content)
def data_received(self, data):
self.in_buff += data
self._parse_message()
def connection_lost(self, exc):
print('port closed')
self.transport.loop.stop()
def pause_writing(self):
print('pause writing')
print(self.transport.get_write_buffer_size())
def resume_writing(self):
print(self.transport.get_write_buffer_size())
print('resume writing')
def _parse_message(self):
HEADER_SIZE = 4 + 1 + 2
if len(self.in_buff) == 0:
return
if len(self.in_buff) >= HEADER_SIZE:
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= HEADER_SIZE + msg_size:
self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size])
self.in_buff = self.in_buff[HEADER_SIZE + msg_size:]
else:
idx = self.in_buff.find("\n".encode())
if idx >= 0:
text_msg = self.in_buff[:idx]
print("LOG:", text_msg.decode())
self.in_buff = self.in_buff[idx + 1:]
def _on_msg_receive(self, msg_type, msg_payload):
parsed_msg = incomingMsgMap[msg_type](msg_payload)
if self._msg_callback is not None:
self._msg_callback(self, parsed_msg)

View File

@@ -1,161 +0,0 @@
from dataclasses import dataclass, field
import struct
import colorsys
@dataclass
class ColorRGBW:
r: float
g: float
b: float
w: float
def __repr__(self):
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
def as_bytes(self) -> bytes:
assert self.is_valid(), "Trying to send invalid " + repr(self)
return struct.pack("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
int(self.w * 255))
def is_valid(self):
vals = (self.r, self.g, self.b, self.w)
return all(0 <= v <= 1 for v in vals)
def __mul__(self, other:float):
assert 0<= other <= 1
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
def without_white_channel(self, scale=1):
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
return ColorRGBW(*args, 0)
@dataclass
class ColorHSV:
h: float
s: float
v: float
@staticmethod
def fromRGB(rgb):
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
return ColorHSV(conv[0] * 360, conv[1], conv[2])
def __repr__(self):
return f"ColorHSV({self.h}, {self.s}, {self.v})"
def as_bytes(self) -> bytes:
return struct.pack("<fff", self.h, self.s, self.v)
def is_valid(self):
if not 0 <= self.h <= 360:
return False
if not 0 <= self.s <= 1:
return False
if not 0 <= self.v <= 2:
return False
return True
@dataclass
class EffectStaticConfig:
color: ColorRGBW
begin: int = 0
end: int = 0
def __repr__(self):
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
def as_bytes(self) -> bytes:
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
@dataclass
class EffectStaticDetailedConfig:
color: ColorRGBW
increment: int = 1
begin: float = 0.0
end: float = 1.0
transition_time_in_ms : float = 500
def __repr__(self):
return f"EffectStaticDetailedConfig {str(self.color)}, beg: {self.begin}, end {self.end}, incr {self.increment}, transition in ms {self.transition_time_in_ms}"
def as_bytes(self) -> bytes:
return self.color.as_bytes() + struct.pack("<Hfff", self.increment, self.begin, self.end, self.transition_time_in_ms)
@dataclass
class EffectAlexaSwipeConfig:
primary_color_width: float = 20 # in degrees
transition_width: float = 30 # in degrees
swipe_speed: float = 2 * 360 # in degrees per second
bell_curve_width_in_leds: float = 3
start_position: float = 180 # in degrees
forward: bool = True
primary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
secondary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 200 / 255, 1, 0))
def as_bytes(self) -> bytes:
return struct.pack(
"<fffff?", self.primary_color_width, self.transition_width, self.swipe_speed,
self.bell_curve_width_in_leds, self.start_position,
self.forward) + self.primary_color.as_bytes() + self.secondary_color.as_bytes()
def __repr__(self):
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(self.secondary_color)}"
@dataclass
class EffectRandomTwoColorInterpolationConfig:
cycle_durations_ms: int = 6000
start_with_existing: bool = True
num_segments: int = 3
hue1_random: bool = False
hue2_random: bool = False
color1: ColorHSV = field(default_factory=lambda: ColorHSV(240, 1, 1))
color2: ColorHSV = field(default_factory=lambda: ColorHSV(192, 1, 1))
def as_bytes(self) -> bytes:
c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
return struct.pack("<i?i??", self.cycle_durations_ms, self.start_with_existing,
self.num_segments, self.hue1_random,
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
def __repr__(self):
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
@dataclass
class EffectCircularConfig:
speed: float = 360 # in degrees per second
width: float = 180 # in degrees
color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
def as_bytes(self) -> bytes:
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
@dataclass
class EffectSwipeAndChange:
swipe: EffectAlexaSwipeConfig = field(default_factory=lambda: EffectAlexaSwipeConfig())
change: EffectRandomTwoColorInterpolationConfig = field(default_factory=lambda: EffectRandomTwoColorInterpolationConfig())
def as_bytes(self) -> bytes:
return self.swipe.as_bytes() + self.change.as_bytes()
def __repr__(self) -> str:
return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}"
@dataclass
class EffectReverseSwipe:
swipeSpeed: float = 2 * 360
bellCurveWidthInLeds: float = 3
startPosition: float = 180
def as_bytes(self) -> bytes:
return struct.pack("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
def __repr__(self) -> str:
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"

View File

@@ -1,276 +0,0 @@
#!/usr/bin/env python
import asyncio
import sys
import serial_asyncio
from led_cmds import (ColorRGBW, EffectCircularConfig, EffectStaticConfig,
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
EffectSwipeAndChange, EffectReverseSwipe)
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
from player import AudioPlayer
from glob import glob
from copy import deepcopy
import os
from hass_client import HomeAssistantClient
from ruamel.yaml import YAML
import warnings
from typing import Optional, NamedTuple
from mqtt_json import start_mqtt
yaml = YAML(typ='safe')
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
class FigureColors(NamedTuple):
primary: ColorRGBW
secondary: ColorRGBW
bg: ColorRGBW
accent: ColorRGBW
def parse_color(color_str: str):
if isinstance(color_str, ColorRGBW):
return color_str
elif color_str.startswith("#"):
color_str = color_str.lstrip('#')
t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4))
return ColorRGBW(*t, 0)
elif color_str.startswith("w"):
color_str = color_str.lstrip("w")
return ColorRGBW(0, 0, 0, int(color_str, 16) / 255)
else:
raise ValueError(f"Unrecognized color format: {color_str!r}")
def load_config(config_path):
# Schema documented in config.yml.example.
with open(os.path.join(config_path, "config.yml")) as cfg_file:
cfg = yaml.load(cfg_file)
for figure_name, figure_cfg in cfg["figures"].items():
figure_cfg["colors"] = FigureColors(*(parse_color(c) for c in figure_cfg["colors"]))
if 'media_files' not in figure_cfg:
figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name)))
return cfg
def hass_service(hass, domain, service, **kwargs):
asyncio.create_task(hass.call_service(domain, service, kwargs))
class MusicMouseState:
def __init__(self, protocol: MusicMouseProtocol):
self.active_figure: Optional[
str] = None # None if no figure is placed on the reader, or the name of the figure
self.last_partially_played_figure: Optional[
str] = None # figure whose playlist wasn't played completely and was removed
self.current_mouse_led_effect = None
self.current_led_ring_effect = None
self.protocol: MusicMouseProtocol = protocol
self.button_led_brightness = None
def mouse_led_effect(self, effect_cfg):
self.current_mouse_led_effect = effect_cfg
self.protocol.mouse_led_effect(effect_cfg)
def led_ring_effect(self, effect_cfg):
self.current_led_ring_effect = effect_cfg
self.protocol.led_ring_effect(effect_cfg)
self.protocol.shelve_led_effect(effect_cfg)
def button_leds(self, brightness):
assert 0 <= brightness <= 1
self.protocol.button_background_led_prev(brightness)
self.protocol.button_background_led_next(brightness)
self.button_led_brightness = brightness
def reset(self):
self.mouse_led_effect(EffectStaticConfig(OFF_COLOR))
self.led_ring_effect(EffectStaticConfig(OFF_COLOR))
class Controller:
def __init__(self, protocol, hass, cfg):
self.cfg = cfg
self.audio_player = AudioPlayer(cfg["general"]["alsa_device"])
self.audio_player.set_volume(50)
self.mmstate = MusicMouseState(protocol)
self.protocol = protocol
self.hass = hass
vol_min = self.cfg["general"].get("min_volume", None)
vol_max = self.cfg["general"].get("max_volume", None)
self.audio_player.set_volume_limits(vol_min, vol_max)
protocol.register_message_callback(self.on_firmware_msg)
self.audio_player.on_playlist_end_callback = self._on_playlist_end
self.playlists = {
fig: self.audio_player.create_playlist(fig_cfg['media_files'])
for fig, fig_cfg in cfg['figures'].items()
}
self._rfid_to_figure_name = {
bytes.fromhex(figure_cfg["id"]): figure_name
for figure_name, figure_cfg in cfg["figures"].items()
}
self.protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0.1, 0)))
shelf_eff = EffectCircularConfig()
shelf_eff.color = ColorRGBW(0, 0, 0.4, 0)
shelf_eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
self.protocol.shelve_led_effect(shelf_eff)
def _on_playlist_end(self):
if not self.audio_player.is_playing():
self.mmstate.last_partially_played_figure = None
self._run_off_animation()
else:
print("Playlist end was called, even if player remains playing?!")
def handle_rfid_event(self, tagid):
if tagid == bytes.fromhex("0000000000"):
if self.audio_player.is_playing():
print("Got 000 rfid -> playing off animation")
self._run_off_animation()
self.audio_player.pause()
self.mmstate.last_partially_played_figure = self.mmstate.active_figure
else:
self.mmstate.last_partially_played_figure = None
self.mmstate.active_figure = None
elif tagid in self._rfid_to_figure_name:
newly_placed_figure = self._rfid_to_figure_name[tagid]
colors = self.cfg["figures"][newly_placed_figure]["colors"]
self._start_animation(colors.primary, colors.secondary)
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
if newly_placed_figure in self.cfg['figures']:
if self.mmstate.last_partially_played_figure == newly_placed_figure:
print("Continuing playlist")
self.audio_player.play()
else:
print("Restarting playlist")
self.audio_player.set_playlist(
self.audio_player.create_playlist(self.cfg['figures'][newly_placed_figure]['media_files']))
self.audio_player.play_from_start()
self.mmstate.active_figure = newly_placed_figure
else:
warnings.warn(f"Unknown figure/tag with id {tagid}")
def on_firmware_msg(self, _, message):
print("FW msg:", message)
if isinstance(message, RfidTokenRead):
self.handle_rfid_event(message.id)
elif isinstance(message, RotaryEncoderEvent):
volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(message.increment)
if message.direction == 2:
self.audio_player.change_volume(volume_increment)
elif message.direction == 1:
self.audio_player.change_volume(-volume_increment)
elif isinstance(message, ButtonEvent):
btn = message.button
if btn == "left" and message.event == "pressed" and self.audio_player.is_playing():
self.audio_player.previous()
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
self.audio_player.next()
elif message.button == "rotary" and message.event == "pressed":
hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter")
elif isinstance(message, TouchButtonPress):
figure = self.mmstate.active_figure
if figure and self.audio_player.is_playing():
figure_colors = self.cfg["figures"][figure]["colors"]
self.protocol.mouse_led_effect(
EffectStaticConfig(figure_colors.accent, *mouse_leds_index_ranges[message.touch_button]))
colors = {
TouchButton.RIGHT_FOOT: {
'rgb_color': [235, 255, 67]
},
TouchButton.LEFT_FOOT: {
'color_temp': 469
},
TouchButton.RIGHT_EAR: {
'rgb_color': [101, 49, 255]
},
TouchButton.LEFT_EAR: {
'rgb_color': [255, 74, 254]
},
}
hass_service(
self.hass,
"light",
"turn_on",
entity_id=["light.kinderzimmer_fluter", "light.music_mouse_regal_licht"],
**colors[message.touch_button])
elif isinstance(message, TouchButtonRelease):
figure = self.mmstate.active_figure
eff_change = EffectRandomTwoColorInterpolationConfig()
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
*mouse_leds_index_ranges[message.touch_button])
if figure and self.audio_player.is_playing():
colors = self.cfg["figures"][figure]["colors"]
eff_static.color = colors.primary
self.protocol.mouse_led_effect(eff_static)
if figure and self.audio_player.is_playing():
colors = self.cfg["figures"][figure]["colors"]
eff_change.color1 = colors.primary
eff_change.color2 = colors.secondary
eff_change.start_with_existing = True
self.protocol.mouse_led_effect(eff_change)
def _start_animation(self, primary_color, secondary_color):
ring_eff = EffectSwipeAndChange()
ring_eff.swipe.primary_color = primary_color
ring_eff.swipe.secondary_color = secondary_color
ring_eff.swipe.swipe_speed = 180
ring_eff.change.color1 = primary_color
ring_eff.change.color2 = secondary_color
self.mmstate.led_ring_effect(ring_eff)
mouse_eff = deepcopy(ring_eff)
mouse_eff.swipe.start_position = 6 / 45 * 360
mouse_eff.swipe.bell_curve_width_in_leds = 16
mouse_eff.swipe.swipe_speed = 180
self.mmstate.mouse_led_effect(mouse_eff)
def _run_off_animation(self):
print("Running off animation")
ring_eff = EffectReverseSwipe()
self.mmstate.led_ring_effect(ring_eff)
mouse_eff = EffectReverseSwipe()
mouse_eff.startPosition = 6 / 45 * 360
self.mmstate.mouse_led_effect(mouse_eff)
self.mmstate.button_leds(0)
def main(config_path):
cfg = load_config(config_path)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop=loop)
coro = serial_asyncio.create_serial_connection(loop,
MusicMouseProtocol,
cfg["general"]["serial_port"],
baudrate=115200)
transport, protocol = loop.run_until_complete(coro)
controller = Controller(protocol, hass, cfg)
mqtt_cfg = cfg["general"]["mqtt"]
loop.create_task(start_mqtt(protocol, mqtt_cfg["server"], mqtt_cfg["user"], mqtt_cfg["password"] ))
loop.create_task(hass.connect())
return controller, loop
if __name__ == "__main__":
if len(sys.argv) == 2:
controller, loop = main(config_path=sys.argv[1])
loop.run_forever()
loop.close()
else:
print("Error: run with config file path as first argument")

View File

@@ -1,180 +0,0 @@
from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange
import asyncio
import aiomqtt
import json
class ShelveLightMqtt:
def __init__(self, protocol, client: aiomqtt.Client):
self._protocol = protocol
self._mqtt_client = client
self._state = {
"state": "OFF",
"color": {
"r": 255,
"g": 255,
"b": 255,
"w": 0,
},
"color_mode": "rgbw",
"brightness": 30,
"effect": "static",
}
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
self._discovery_spec = self._create_discovery_msg_light()
async def init(self):
"""Init method, because constructor can't be async"""
self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
await self._send_autodiscovery_msg()
await self._notify_mqtt_state({"state": "OFF"})
async def handle_light_message(self, msg):
if msg.topic.value == self._discovery_spec['command_topic']:
payload = msg.payload.decode()
new_state = json.loads(payload)
print("IN ", new_state)
await self._update_state(new_state)
await self._notify_mqtt_state(new_state)
async def _update_state(self, new_state):
"""Merges current state with new state, updates device"""
# memorize last color - this is used for effects that need 2 colors
if 'color' in new_state:
brightness = new_state.get('brightness', self._state['brightness'])
new_color = self._color_from_json(new_state['color'], brightness)
current_color = self._color_from_json(self._state['color'])
if new_color != current_color:
self._last_color = current_color
print("last color", self._last_color)
self._state.update(new_state)
self._update_device()
@staticmethod
def _color_from_json(json_color, brightness=255):
args = ((json_color[e] / 255) * (brightness / 255) for e in ('r', 'g', 'b', 'w'))
return ColorRGBW(*args)
def _update_device(self):
s = self._state
current_color = self._color_from_json(s['color'], brightness=s["brightness"])
transition = s.get("transition", 0.3) * 1000
print(f"Effect {s['effect']} Transition {transition}")
if s['state'] == "OFF":
if transition > 0:
eff = EffectStaticDetailedConfig(ColorRGBW(0,0,0,0), transition_time_in_ms=transition)
else:
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
elif s['effect'] == 'static':
if transition > 0:
eff = EffectStaticDetailedConfig(current_color, transition_time_in_ms=transition)
else:
eff = EffectStaticConfig(current_color)
elif s['effect'] == 'circular':
eff = EffectCircularConfig(speed=180, width=90, color=current_color)
elif s['effect'] == 'wipeup':
eff = EffectSwipeAndChange()
eff.swipe.secondary_color = current_color
eff.swipe.primary_color = self._last_color
eff.swipe.bell_curve_width_in_leds = 10
eff.swipe.transition_width = 30
eff.swipe.start_position = 0
eff.swipe.swipe_speed = 260
eff.change.color1 = current_color
eff.change.color2 = self._last_color
elif s['effect'] == "twocolor":
eff = EffectRandomTwoColorInterpolationConfig()
eff.color1 = current_color
eff.color2 = self._last_color
eff.start_with_existing = True
elif s['effect'] == "twocolorrandom":
eff = EffectRandomTwoColorInterpolationConfig()
eff.color1 = current_color
eff.color2 = self._last_color
eff.hue1_random = True
eff.hue2_random = True
eff.start_with_existing = True
elif s['effect'] == "side_0.2":
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "side_0.2_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=4, transition_time_in_ms=transition)
elif s['effect'] == "side_0.2_inc8":
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=8, transition_time_in_ms=transition)
elif s['effect'] == "side_0.5":
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "side_0.5_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=4, transition_time_in_ms=transition)
elif s['effect'] == "top_0.2":
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "top_0.2_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=4, transition_time_in_ms=transition)
elif s['effect'] == "top_0.5":
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "top_0.5_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=4, transition_time_in_ms=transition)
else:
print(f"Unknown effect {s['effect']}")
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
self._protocol.shelve_led_effect(eff)
@staticmethod
def _create_discovery_msg_light(base_name="musicmouse_json",
display_name="Music Mouse Regal Licht"):
id = "shelve"
return {
'platform': 'mqtt',
'schema': 'json',
'name': display_name,
'unique_id': f'{base_name}_{id}',
'command_topic': f'{base_name}/lights_{id}/command',
'state_topic': f'{base_name}/lights_{id}/state',
'color_mode': True,
'brightness': True,
#'device': {
# 'manufacturer': 'bauer.tech',
# 'model': "SK6812 LED strip",
#},
'effect': True,
'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom',
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4"],
'supported_color_modes': ['rgbw'],
}
async def _send_autodiscovery_msg(self):
topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config"
await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode(), retain=True)
async def _notify_mqtt_state(self, state):
state_payload = json.dumps(self._state)
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
async def start_mqtt(music_mouse_protocol, server, username, password):
reconnect_interval = 10 # [seconds]
while True:
try:
async with aiomqtt.Client(hostname=server, username=username, password=password) as client:
shelve_light = ShelveLightMqtt(music_mouse_protocol, client)
await shelve_light.init()
await client.subscribe("musicmouse_json/#")
async for message in client.messages:
await shelve_light.handle_light_message(message)
except aiomqtt.MqttError as error:
print(f'Error "{error}". Reconnecting in {reconnect_interval} seconds')
finally:
await asyncio.sleep(reconnect_interval)
if __name__ == "__main__":
class DummyProtocol:
def shelve_led_effect(self, effect):
print("EFF ", repr(effect))
password = ""
asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password))

View File

@@ -0,0 +1,24 @@
# Put this into /etc/systemd/system/musicmouse.service
#
# Assumes the repo is checked out at /opt/musicmouse with a venv at /opt/musicmouse/.venv:
# /opt/musicmouse/.venv/bin/pip install -e /opt/musicmouse/python-backend
#
# A dropped serial link is now handled in-process (SerialLink reconnects), so
# Restart=always is only for genuine crashes.
[Unit]
Description=Music Mouse RFID Music Player
After=multi-user.target sound.target network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=5
WorkingDirectory=/opt/musicmouse/python-backend
ExecStart=/opt/musicmouse/.venv/bin/python -m musicmouse --config /media/musicmouse/config.yml
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
"""MusicMouse backend: an RFID music player for kids."""
__all__ = ["__version__"]
__version__ = "2.0.0"

View File

@@ -0,0 +1,403 @@
"""Entry point and composition root.
python -m musicmouse --config /media/musicmouse/config.yml
python -m musicmouse --config ./config.yml --no-hardware
python -m musicmouse --config ./config.yml --simulate
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
This is the only module that knows which concrete implementations are in play; the
difference between "real mouse", "no mouse attached" and "simulated mouse" is which
transport and which player get built here.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import logging
import sys
from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path
from typing import Any, TypeVar
import httpx2
from musicmouse import __version__
from musicmouse.app import App
from musicmouse.bus import EventBus
from musicmouse.clock import RealClock
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.null_transport import NullTransport
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink
from musicmouse.library import MusicLibrary, default_worker_count
from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all
from musicmouse.services.base import Service
from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.web import WebService
from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
_log = logging.getLogger("musicmouse")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="musicmouse", description="Host backend for the MusicMouse RFID music player."
)
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
parser.add_argument(
"-s",
"--simulate",
action="store_true",
help="run against fake hardware and a fake player (no serial port, no audio)",
)
parser.add_argument(
"--no-hardware",
action="store_true",
help="real audio and a real web front-end, but no serial port: for a host with "
"no mouse attached",
)
parser.add_argument(
"--script",
type=Path,
help="with --simulate: run a scenario file instead of the interactive prompt",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="default: INFO",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = parser.parse_args(argv)
if args.script and not args.simulate:
parser.error("--script only makes sense together with --simulate")
if args.simulate and args.no_hardware:
parser.error("--simulate already runs without hardware; drop --no-hardware")
return args
def setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level),
format="%(asctime)s %(levelname)-7s %(name)-28s %(message)s",
datefmt="%H:%M:%S",
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
setup_logging(args.log_level)
try:
config = load_config(args.config, check_paths=True)
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
tippen: TippenRuntime | None = None
if config.general.tippen is not None:
try:
tippen = build_tippen_runtime(config.general.tippen)
except CurriculumError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
runner = (
run_simulated(config, args.config, args.script, tippen=tippen)
if args.simulate
else run_real(
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
)
)
try:
asyncio.run(runner)
except KeyboardInterrupt:
_log.info("Interrupted")
return 0
# ------------------------------------------------------------------------- real
async def run_real(
config: Config,
config_path: Path,
*,
hardware: bool = True,
tippen: TippenRuntime | None = None,
) -> None:
bus = EventBus()
await bus.start()
clock = RealClock()
general = config.general
link: SerialLink | None = None
if hardware:
link = SerialLink(
general.serial_port,
general.baudrate,
reconnect_interval=general.reconnect_interval,
clock=clock,
)
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
link.attach(mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected)
else:
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
player = _build_player(bus, general, clock=clock)
library = await build_library(config)
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
_log.info(
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
__version__,
len(config.figures),
len(library.albums),
# Report what the config says, with a marker when --no-hardware overrode it,
# so the line never disagrees with the file it was started from.
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
general.alsa_device,
general.mqtt.server if general.mqtt else "disabled",
)
try:
await _run_forever(
[
*([link.run()] if link else []),
player.run(),
# `is_busy` reads `player.is_playing` directly rather than the library
# knowing about playback at all: analysis must never compete with audio
# decoding for CPU, and this device is otherwise idle most of the day, so
# the pass simply resumes once it is. `on_batch` is unset unless the web
# front-end is on - nothing else has a use for the notification.
library.run_analysis(
is_busy=lambda: player.is_playing,
on_batch=_analysis_batch_hook(services),
),
*(service.run() for service in services),
]
)
finally:
player.close()
await bus.stop()
# -------------------------------------------------------------------- simulated
async def run_simulated(
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None
) -> None:
# Imported here so the production path never touches the simulator.
from musicmouse.simulator.harness import build_simulation
from musicmouse.simulator.repl import run_repl
from musicmouse.simulator.script import run_script_file
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
# real clock, so playback ticks along while you watch it.
sim = await build_simulation(
config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen
)
services = _build_services(
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
)
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
tasks.append(
asyncio.create_task(
sim.app.library.run_analysis(
is_busy=lambda: sim.player.is_playing,
on_batch=_analysis_batch_hook(services),
),
name="library-analysis",
)
)
try:
if script is not None:
await run_script_file(sim, script)
else:
await run_repl(sim)
finally:
for task in tasks:
task.cancel()
await sim.aclose()
# ---------------------------------------------------------------------- wiring
def wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
"""Whether to open a serial port at all.
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
for rather than inferred from a missing key, but it is still worth saying out loud
every boot - a mouse whose RFID reader does nothing should say why.
"""
if no_hardware_flag:
return False
if config.general.serial_simulated:
_log.warning(
'general.serial_port is "%s": running without the mouse. '
"The web front-end still works; RFID, buttons and LEDs do not.",
SIMULATE,
)
return False
return True
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
"""A real player, or a silent one when the config asked for that.
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
identically, it just makes no sound. Useful for working on the web UI without
commandeering the machine's audio.
"""
if not general.audio_simulated:
return VlcPlayer(
bus,
alsa_device=general.alsa_device,
clock=clock,
**VlcPlayer.volume_kwargs(general),
)
_log.warning(
'general.alsa_device is "%s": using a simulated player, so nothing will be '
'audible. Set it to "default" for the system default output.',
SIMULATE,
)
# Imported here rather than at module scope so the real audio path never loads the
# simulator - and so this still runs on a machine without libVLC at all.
from musicmouse.simulator.fake_player import FakePlayer
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
async def build_library(config: Config) -> MusicLibrary:
library_config = config.general.library
workers = library_config.analysis_workers
return await MusicLibrary.build(
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
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,
)
def _build_app(
config: Config,
bus: EventBus,
mouse: MusicMouseDevice,
player: Player,
library: MusicLibrary,
*,
clock: RealClock,
tippen: TippenRuntime | None = None,
) -> App:
app = App(
config=config,
bus=bus,
mouse=mouse,
player=player,
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(),
clock=clock,
tippen=tippen,
)
register_all(bus, app)
return app
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)
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
"""The web front-end's own hub, if it is running - so open tabs refetch the library
as background analysis lands, instead of only after a manual reload. `None` when
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
tell".
"""
web_service = _service_of_type(services, WebService)
return web_service.hub.broadcast_library if web_service else None
def _build_services(
app: App,
mouse: MusicMouseDevice,
player: Player,
*,
clock: RealClock,
config_path: Path,
) -> list[Service]:
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
services: list[Service] = []
mqtt_config = app.config.general.mqtt
if mqtt_config is None:
_log.info("No mqtt section in the config: Home Assistant integration is off")
else:
entities = build_entities(app.bus, mqtt_config, mouse, player)
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
web_config = app.config.general.web
if web_config is None:
_log.info("No web section in the config: the web front-end is off")
else:
services.append(WebService(app, web_config, config_path))
lirc_config = app.config.general.lirc
if lirc_config is None:
_log.info("No lirc section in the config: the IR remote is off")
else:
services.append(LircService(app, lirc_config, clock=clock))
# 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.
web_service = _service_of_type(services, WebService)
services.append(
PodcastFeedService(
app.library,
client=httpx2.AsyncClient(timeout=30.0),
on_change=lambda: app.rescan_library(
broadcast=web_service.hub.broadcast_library if web_service else None
),
episode_limit=app.config.general.podcast_episode_limit,
)
)
return services
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
tasks = [asyncio.create_task(coro) for coro in coroutines]
try:
await asyncio.gather(*tasks)
finally:
for task in tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*tasks, return_exceptions=True)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,78 @@
"""What the reactions get handed: the three objects, the bus, and a little shared state."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import Config, FigureColors
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.library import MusicLibrary
from musicmouse.library.models import Album
from musicmouse.media import Playlist
from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__)
__all__ = ["App", "AppState"]
@dataclass
class AppState:
"""State that belongs to no single device but is shared between reactions."""
#: Figure that was taken off the reader mid-playlist, so putting it back resumes
#: instead of starting over. Cleared once its playlist runs out.
last_partially_played_figure: str | None = None
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
#: readable off the transport; a broker's is not, so it is remembered here.
mqtt_connected: bool = False
#: Whether the lircd TCP link for the IR remote is currently reachable.
lirc_connected: bool = False
@dataclass
class App:
config: Config
bus: EventBus
mouse: MusicMouseDevice
player: Player
library: MusicLibrary
playlists: dict[str, Playlist]
clock: Clock = field(default_factory=RealClock)
state: AppState = field(default_factory=AppState)
#: `None` when `general.tippen` is absent - the typing game is off.
tippen: TippenRuntime | None = None
def colors(self, figure: str) -> FigureColors:
return self.config.figures[figure].colors
def playlist(self, figure: str) -> Playlist | None:
playlist = self.playlists.get(figure)
if playlist is None:
_log.warning("No playlist for figure %r", figure)
return playlist
def album_for(self, playlist: Playlist | None) -> Album | None:
"""The library album a playlist came from, if any."""
return self.library.get(playlist.album_id if playlist else None)
async def rescan_library(
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
) -> None:
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
can change what's on disk on its own, such as the podcast feed poller.
"""
await self.library.refresh()
self.playlists.clear()
self.playlists.update(self.library.figure_playlists())
if broadcast is not None:
await broadcast()

View File

@@ -0,0 +1,157 @@
"""The event bus.
Everything in the process is serialised through one FIFO queue on one loop, which is
what makes "last event wins" a well-defined rule for LED zone arbitration and what
makes scenario tests deterministic.
Handlers may be sync or async; async handlers are awaited, so one event is fully
handled before the next is dispatched. A handler that raises is logged and does not
stop the others or the bus.
"""
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
from collections.abc import Callable, Coroutine
from typing import Any, TypeAlias, TypeVar
from musicmouse.events import Event
_log = logging.getLogger(__name__)
__all__ = ["EventBus", "Handler", "Unsubscribe"]
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
#: 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:
def __init__(self) -> None:
self._handlers: dict[type[Event], list[Handler[Any]]] = {}
self._wildcard: list[Handler[Any]] = []
self._resolved: dict[type[Event], tuple[Handler[Any], ...]] = {}
self._queue: asyncio.Queue[Event] = asyncio.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._dispatcher: asyncio.Task[None] | None = None
# ------------------------------------------------------------------ lifecycle
async def start(self) -> None:
if self._dispatcher is not None:
return
self._loop = asyncio.get_running_loop()
self._dispatcher = asyncio.create_task(self._run(), name="event-bus")
async def stop(self) -> None:
if self._dispatcher is None:
return
self._dispatcher.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._dispatcher
self._dispatcher = None
self._loop = None
async def __aenter__(self) -> EventBus:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.stop()
# --------------------------------------------------------------- subscription
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
"""Register ``handler`` for ``event_type`` and any subclass of it."""
self._handlers.setdefault(event_type, []).append(handler)
self._resolved.clear()
def unsubscribe() -> None:
handlers = self._handlers.get(event_type)
if handlers and handler in handlers:
handlers.remove(handler)
self._resolved.clear()
return unsubscribe
def subscribe_all(self, handler: Handler[Event]) -> Unsubscribe:
"""Register ``handler`` for every event. Useful for logging and broadcasting."""
self._wildcard.append(handler)
def unsubscribe() -> None:
if handler in self._wildcard:
self._wildcard.remove(handler)
return unsubscribe
# ---------------------------------------------------------------- publication
def emit(self, event: Event) -> None:
"""Queue ``event`` for dispatch. Safe to call from any thread.
libVLC fires its callbacks on its own thread; this is where that crossing is
made safe instead of reaching the serial transport off-loop.
"""
loop = self._loop
if loop is None:
raise RuntimeError("EventBus.emit() before start()")
try:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
if running is loop:
self._queue.put_nowait(event)
else:
loop.call_soon_threadsafe(self._queue.put_nowait, event)
async def drain(self) -> None:
"""Wait until every queued event, and everything they emitted, is handled."""
await self._queue.join()
async def emit_and_wait(self, event: Event) -> None:
self.emit(event)
await self.drain()
# -------------------------------------------------------------------- internals
async def _run(self) -> None:
while True:
event = await self._queue.get()
try:
await self._dispatch(event)
finally:
self._queue.task_done()
async def _dispatch(self, event: Event) -> None:
for handler in self._handlers_for(type(event)):
try:
result = handler(event)
if inspect.isawaitable(result):
await result
except asyncio.CancelledError:
raise
except Exception:
_log.exception("Handler %s failed on %r", _name(handler), event)
def _handlers_for(self, event_type: type[Event]) -> tuple[Handler[Any], ...]:
cached = self._resolved.get(event_type)
if cached is None:
matched: list[Handler[Any]] = []
for klass in event_type.__mro__:
if klass is object:
continue
matched.extend(self._handlers.get(klass, ()))
cached = tuple(matched)
self._resolved[event_type] = cached
# Wildcards are not cached: they are appended last and change rarely.
return cached + tuple(self._wildcard)
def _name(handler: Handler[Any]) -> str:
return getattr(handler, "__qualname__", repr(handler))

View File

@@ -0,0 +1,94 @@
"""Time, behind a protocol.
Anything that waits takes a :class:`Clock` instead of calling :func:`asyncio.sleep`
directly. Under :class:`RealClock` a scenario runs in real time; under
:class:`FakeClock` the identical scenario runs in microseconds, which is what makes
``wait 1s`` affordable inside the test suite.
"""
from __future__ import annotations
import asyncio
import heapq
import itertools
import time
from collections.abc import Awaitable, Callable
from typing import Protocol
__all__ = ["Clock", "FakeClock", "RealClock"]
class Clock(Protocol):
def now(self) -> float:
"""Monotonic seconds. Only differences are meaningful."""
...
async def sleep(self, seconds: float) -> None:
"""Suspend the calling task for ``seconds``."""
...
async def advance(self, seconds: float) -> None:
"""Let ``seconds`` pass, from the driver's point of view."""
...
class RealClock:
def now(self) -> float:
return time.monotonic()
async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds)
async def advance(self, seconds: float) -> None:
await asyncio.sleep(seconds)
class FakeClock:
"""Virtual time.
``sleep()`` parks the caller until ``advance()`` moves time past its deadline.
``idle`` is awaited after each wake-up so that whatever the woken task emitted has
been fully handled before virtual time moves on - pass ``EventBus.drain``.
"""
def __init__(self, start: float = 0.0, idle: Callable[[], Awaitable[None]] | None = None):
self._now = start
self._idle = idle
self._counter = itertools.count()
self._sleepers: list[tuple[float, int, asyncio.Future[None]]] = []
def now(self) -> float:
return self._now
async def sleep(self, seconds: float) -> None:
if seconds <= 0:
await self._settle()
return
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
heapq.heappush(self._sleepers, (self._now + seconds, next(self._counter), future))
await future
async def advance(self, seconds: float) -> None:
# Settle first: a task created but not yet started has not registered its
# sleep, and would otherwise have its deadline computed from the new time.
await self._settle()
target = self._now + max(0.0, seconds)
while self._sleepers and self._sleepers[0][0] <= target:
deadline, _, future = heapq.heappop(self._sleepers)
self._now = max(self._now, deadline)
if not future.done():
future.set_result(None)
await self._settle()
self._now = target
await self._settle()
@property
def pending_timers(self) -> int:
return len(self._sleepers)
async def _settle(self) -> None:
# Give woken tasks a chance to run, then let their events be handled.
await asyncio.sleep(0)
if self._idle is not None:
await self._idle()
await asyncio.sleep(0)

View File

@@ -0,0 +1,128 @@
"""Colour types shared by the LED wire format and the config schema.
Kept separate from :mod:`musicmouse.devices.effects` so that :mod:`musicmouse.config`
can validate colours without importing anything device-related.
"""
from __future__ import annotations
import colorsys
import struct
from dataclasses import dataclass
__all__ = ["ColorHSV", "ColorRGBW", "parse_color"]
@dataclass(frozen=True, slots=True)
class ColorRGBW:
"""An RGBW colour with all channels normalised to ``0.0 .. 1.0``."""
r: float
g: float
b: float
w: float
def __repr__(self) -> str:
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
@property
def is_valid(self) -> bool:
return all(0 <= v <= 1 for v in (self.r, self.g, self.b, self.w))
def as_bytes(self) -> bytes:
if not self.is_valid:
raise ValueError(f"Channel values must be within 0..1, got {self!r}")
return struct.pack(
"<BBBB",
int(self.r * 255),
int(self.g * 255),
int(self.b * 255),
int(self.w * 255),
)
@classmethod
def from_bytes(cls, data: bytes) -> ColorRGBW:
r, g, b, w = struct.unpack("<BBBB", data)
return cls(r / 255, g / 255, b / 255, w / 255)
def __mul__(self, scale: float) -> ColorRGBW:
if not 0 <= scale <= 1:
raise ValueError(f"Scale must be within 0..1, got {scale}")
return ColorRGBW(self.r * scale, self.g * scale, self.b * scale, self.w * scale)
def without_white_channel(self) -> ColorRGBW:
"""Fold the white channel into RGB, for strips driven without a W channel."""
r, g, b = (min(1.0, c + self.w) for c in (self.r, self.g, self.b))
return ColorRGBW(r, g, b, 0)
@dataclass(frozen=True, slots=True)
class ColorHSV:
"""Hue in degrees (``0..360``), saturation ``0..1``, value ``0..2``."""
h: float
s: float
v: float
def __repr__(self) -> str:
return f"ColorHSV({self.h}, {self.s}, {self.v})"
@staticmethod
def from_rgb(rgb: ColorRGBW) -> ColorHSV:
h, s, v = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
return ColorHSV(h * 360, s, v)
@property
def is_valid(self) -> bool:
return 0 <= self.h <= 360 and 0 <= self.s <= 1 and 0 <= self.v <= 2
def as_bytes(self) -> bytes:
if not self.is_valid:
raise ValueError(f"Out-of-range HSV colour {self!r}")
return struct.pack("<fff", self.h, self.s, self.v)
@classmethod
def from_bytes(cls, data: bytes) -> ColorHSV:
return cls(*struct.unpack("<fff", data))
def parse_color(value: str | ColorRGBW) -> ColorRGBW:
"""Parse ``"#rrggbb"`` (RGB) or ``"wNN"`` (white channel only) into a colour.
Raises:
ValueError: with a message naming the accepted formats.
"""
if isinstance(value, ColorRGBW):
return value
if not isinstance(value, str):
raise ValueError(f"expected a colour string, got {type(value).__name__}")
text = value.strip()
if text.startswith("#"):
digits = text[1:]
if len(digits) != 6:
raise ValueError(
f"unrecognized color format {value!r} "
f"(expected '#rrggbb' with 6 hex digits, got {len(digits)})"
)
try:
r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4))
except ValueError:
raise ValueError(
f"unrecognized color format {value!r} (expected '#rrggbb' with hex digits)"
) from None
return ColorRGBW(r, g, b, 0)
if text.startswith("w"):
digits = text[1:]
try:
white = int(digits, 16)
except ValueError:
raise ValueError(
f"unrecognized color format {value!r} (expected 'wNN' with hex digits)"
) from None
if not 0 <= white <= 255:
raise ValueError(f"white value in {value!r} must be within 00..ff")
return ColorRGBW(0, 0, 0, white / 255)
raise ValueError(f"unrecognized color format {value!r} (expected '#rrggbb' or 'wNN')")

View File

@@ -0,0 +1,436 @@
"""Config schema and loading.
Validation is strict on purpose: unknown keys are rejected (a typo'd setting that is
silently ignored is worse than a startup failure), and every problem in the file is
reported at once rather than one per run.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
from pydantic import (
BaseModel,
ConfigDict,
Field,
PlainValidator,
ValidationError,
ValidationInfo,
field_validator,
model_validator,
)
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
_log = logging.getLogger(__name__)
__all__ = [
"SIMULATE",
"Config",
"ConfigError",
"Digit",
"FigureColors",
"FigureConfig",
"GeneralConfig",
"HaConfig",
"HaDeviceConfig",
"LibraryConfig",
"LircConfig",
"MqttConfig",
"RemoteSlotConfig",
"TippenConfig",
"WebConfig",
"format_validation_error",
"load_config",
]
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
#: error, so a config that lost a line fails loudly instead of booting into a silent
#: mouse that looks like it is working.
SIMULATE: Final = "simulate"
class ConfigError(Exception):
"""Raised with an already human-readable, multi-line message."""
def _parse_tag_id(value: Any) -> bytes:
if isinstance(value, bytes):
raw = value
else:
if not isinstance(value, str):
raise ValueError(f"expected a hex string, got {type(value).__name__}")
text = value.strip().replace(":", "").replace(" ", "")
try:
raw = bytes.fromhex(text)
except ValueError:
raise ValueError(f"{value!r} is not a valid hex string") from None
if len(raw) != RFID_TAG_LENGTH:
raise ValueError(
f"expected {RFID_TAG_LENGTH} bytes ({RFID_TAG_LENGTH * 2} hex digits), "
f"got {len(raw)} ({raw.hex()!r})"
)
if raw == NO_FIGURE_TAG:
raise ValueError("the all-zero tag id is reserved for 'no figure on the reader'")
return raw
Color = Annotated[ColorRGBW, PlainValidator(parse_color)]
TagId = Annotated[bytes, PlainValidator(_parse_tag_id)]
_COLOR_ROLES = ("primary", "secondary", "bg", "accent")
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class FigureColors(_Strict):
"""The four colours of a figure, given in config as a list of colour strings."""
primary: Color
secondary: Color
bg: Color
accent: Color
@model_validator(mode="before")
@classmethod
def _accept_sequence(cls, data: Any) -> Any:
if isinstance(data, (list, tuple)):
if len(data) != len(_COLOR_ROLES):
raise ValueError(
f"expected exactly {len(_COLOR_ROLES)} colors "
f"({', '.join(_COLOR_ROLES)}), got {len(data)}"
)
return dict(zip(_COLOR_ROLES, data, strict=True))
return data
def _resolve_folder(
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
) -> Path:
"""Make a configured path absolute against the config file, and optionally check it."""
context = info.context or {}
base = context.get("config_dir")
if base is not None and not folder.is_absolute():
folder = (Path(base) / folder).resolve()
if must_exist and context.get("check_paths", True):
exists = folder.is_file() if kind == "file" else folder.is_dir()
if not exists:
noun = "file" if kind == "file" else "directory"
raise ValueError(f"no such {noun}: {folder}")
return folder
class MqttConfig(_Strict):
server: str
port: int = Field(default=1883, ge=1, le=65535)
user: str | None = None
password: str | None = None
base_topic: str = "musicmouse"
discovery_prefix: str = "homeassistant"
device_id: str = "musicmouse"
device_name: str = "Music Mouse"
reconnect_interval: float = Field(default=10.0, gt=0)
class LircConfig(_Strict):
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
Omit the whole section to run without an IR remote.
"""
host: str
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
#: default is 8765, so this is worth overriding rather than assuming.
port: int = Field(default=2222, ge=1, le=65535)
#: Only button events from this remote are acted on - other remotes registered
#: with the same lircd (an LED remote, say) are ignored.
remote_name: str = "Hauppauge"
reconnect_interval: float = Field(default=5.0, gt=0)
class LibraryConfig(_Strict):
"""Where the music lives.
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
``Kinderpodcasts`` - are fixed names, not settings; see
:mod:`musicmouse.library.sections`.
"""
root: Path
#: Scan results, extracted cover art and track analysis. Relative to this file.
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")
@classmethod
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
return _resolve_folder(folder, info, must_exist=True)
@field_validator("cache")
@classmethod
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
return _resolve_folder(folder, info, must_exist=False)
@property
def figure_folder(self) -> Path:
return self.root / "Figuren"
class WebConfig(_Strict):
"""The web front-end. Omit the whole section to run without it."""
#: A LAN appliance with no auth; binding to all interfaces is the point.
host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
static_dir: Path | None = None
@field_validator("static_dir")
@classmethod
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
class HaDeviceConfig(_Strict):
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
entity_id: str
name: str | None = None
class HaConfig(_Strict):
"""Home Assistant integration for the room-control page ("Mein Zimmer").
The backend never calls Home Assistant itself - it only hands the browser the
server URL, the token, and these two ordered lists. Control happens directly from
the browser to Home Assistant's own REST API, so this token grants full HA control
to anything on the LAN that can reach musicmouse. See config.yml.example.
"""
url: str
token: str
#: Order is preserved and drives the device card grid on the room page.
devices: list[HaDeviceConfig] = Field(default_factory=list)
#: Order is preserved and drives the scene pill row on the room page.
scenes: list[HaDeviceConfig] = Field(default_factory=list)
@model_validator(mode="after")
def _check_something_configured(self) -> Self:
if not self.devices and not self.scenes:
raise ValueError("configure at least one device or scene, or omit the ha section")
return self
class TippenConfig(_Strict):
"""The typing game. Omit the whole section to run without it.
The curriculum is content, not device settings, so it lives in its own file
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
name next to ``config.yml`` if not given a folder of its own.
"""
curriculum_file: Path
progress_file: Path = Path("tippen-progress.json")
@field_validator("curriculum_file")
@classmethod
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
return _resolve_folder(path, info, must_exist=True, kind="file")
@field_validator("progress_file")
@classmethod
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
return _resolve_folder(path, info, must_exist=False, kind="file")
class GeneralConfig(_Strict):
library: LibraryConfig
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
#: web front-end is a complete way to drive the player on its own. Required.
serial_port: str
baudrate: int = Field(default=115200, gt=0)
reconnect_interval: float = Field(default=5.0, gt=0)
#: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or
#: ``"simulate"`` for a player that makes no sound. Required.
alsa_device: str
mqtt: MqttConfig | None = None
web: WebConfig | None = None
ha: HaConfig | None = None
lirc: LircConfig | None = None
tippen: TippenConfig | None = None
min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200)
initial_volume: int = Field(default=50, ge=0, le=200)
volume_increment: int = Field(default=5, ge=1, le=100)
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
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
def serial_simulated(self) -> bool:
return self.serial_port == SIMULATE
@property
def audio_simulated(self) -> bool:
return self.alsa_device == SIMULATE
@model_validator(mode="after")
def _check_volumes(self) -> Self:
if self.min_volume > self.max_volume:
raise ValueError(
f"min_volume ({self.min_volume}) must not exceed max_volume ({self.max_volume})"
)
if not self.min_volume <= self.initial_volume <= self.max_volume:
raise ValueError(
f"initial_volume ({self.initial_volume}) must lie between "
f"min_volume ({self.min_volume}) and max_volume ({self.max_volume})"
)
return self
class FigureConfig(_Strict):
#: RFID tag id as hex, e.g. "04a1b2c3d4".
id: TagId
colors: FigureColors
#: What this figure holds. Unlike the other shelves a figure folder is named after
#: the figurine rather than its contents, so nothing on disk says whether it is an
#: album or an audiobook - and the browse view draws the two differently.
kind: Literal["music", "book"] = "music"
class RemoteSlotConfig(_Strict):
"""What a number key on the IR remote plays.
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
``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
episode is its own album.
"""
target_kind: Literal["album", "series"]
target: str
class Config(_Strict):
general: GeneralConfig
figures: dict[str, FigureConfig] = Field(min_length=1)
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
#: assignments, and that is not an error.
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
@model_validator(mode="after")
def _check_unique_tag_ids(self) -> Self:
seen: dict[bytes, str] = {}
for name, figure in self.figures.items():
if (other := seen.get(figure.id)) is not None:
raise ValueError(
f"figures {other!r} and {name!r} both use tag id {figure.id.hex()}"
)
seen[figure.id] = name
return self
@property
def tag_map(self) -> dict[bytes, str]:
"""Tag id -> figure name, as handed to the device."""
return {figure.id: name for name, figure in self.figures.items()}
@property
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
"""Figure name -> what it holds, as handed to the library scanner."""
return {name: figure.kind for name, figure in self.figures.items()}
def folder_for(self, figure: str) -> Path:
return self.general.library.figure_folder / figure
def format_validation_error(error: ValidationError) -> str:
"""Render a pydantic error as one short ``path: message`` line per problem."""
lines: list[str] = []
for entry in error.errors():
location = ".".join(
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
).replace(".[", "[")
message = entry["msg"]
for prefix in ("Value error, ", "Assertion failed, "):
message = message.removeprefix(prefix)
if entry["type"] == "extra_forbidden":
message = "unknown option (check the spelling against config.yml.example)"
elif entry["type"] == "missing":
message = "required (see config.yml.example)"
lines.append(f" {location or '<root>'}: {message}")
plural = "s" if len(lines) != 1 else ""
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
def load_config(path: Path, *, check_paths: bool = True) -> Config:
"""Load and validate a config file.
Raises:
ConfigError: with a message that can be printed straight to the terminal.
"""
path = Path(path)
if path.is_dir():
raise ConfigError(
f"{path} is a directory. Pass the config file itself, e.g. {path / 'config.yml'}"
)
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigError(f"Cannot read config file {path}: {exc.strerror}") from exc
try:
data = YAML(typ="safe").load(text)
except YAMLError as exc:
raise ConfigError(f"{path} is not valid YAML:\n {exc}") from exc
if data is None:
raise ConfigError(f"{path} is empty")
if not isinstance(data, dict):
raise ConfigError(
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
)
context = {"config_dir": path.parent, "check_paths": check_paths}
try:
config = Config.model_validate(data, context=context)
except ValidationError as exc:
raise ConfigError(f"{path}\n{format_validation_error(exc)}") from exc
if check_paths:
for name in config.figures:
folder = config.folder_for(name)
if not folder.is_dir():
_log.warning("Figure %r has no media folder at %s", name, folder)
return config

View File

@@ -0,0 +1 @@
"""Objects that own a piece of hardware: the mouse itself and the audio player."""

View File

@@ -0,0 +1,177 @@
"""The mouse itself: the object that talks to the firmware.
It owns the physical state - which figure is on the reader, how bright the button
backlights are, which effect each LED zone is showing - and it is the single writer to
all three LED zones. "Last write wins" is therefore a well-defined rule: whoever sets a
zone last, whether a figure animation or an MQTT command, is what the strip shows.
Every write emits :class:`~musicmouse.events.LedEffectChanged`, so front-ends can
publish the strip's real state instead of echoing their own commands back.
"""
from __future__ import annotations
import logging
from dataclasses import replace
from musicmouse.bus import EventBus
from musicmouse.devices.transport import Transport
from musicmouse.devices.wire import (
FirmwareLog,
FrameDecoder,
ProtocolError,
UnsupportedEffectError,
encode_button_brightness,
encode_effect,
)
from musicmouse.effects import OFF, LedEffect
from musicmouse.events import (
ActiveFigureChanged,
ConnectionChanged,
DeviceConnected,
DeviceDisconnected,
EventSource,
InputEvent,
LedEffectChanged,
RfidTokenRead,
)
from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone
_log = logging.getLogger(__name__)
__all__ = ["MusicMouseDevice"]
_BACKLIT_BUTTONS = (Button.LEFT, Button.RIGHT)
class MusicMouseDevice:
def __init__(
self,
bus: EventBus,
transport: Transport,
tag_map: dict[bytes, str],
*,
port: str = "",
) -> None:
self._bus = bus
self._transport = transport
self._tag_map = dict(tag_map)
self._decoder = FrameDecoder()
self.port = port
self._active_figure: str | None = None
self._button_brightness: float = 0.0
self._effects: dict[LedZone, LedEffect] = {}
# -------------------------------------------------------------------- state
@property
def active_figure(self) -> str | None:
"""The figure currently on the reader, or ``None`` if there is none."""
return self._active_figure
@property
def button_led_brightness(self) -> float:
return self._button_brightness
@property
def connected(self) -> bool:
return self._transport.connected
def effect(self, zone: LedZone) -> LedEffect | None:
return self._effects.get(zone)
# ------------------------------------------------------------------ actions
def set_effect(
self, zone: LedZone, effect: LedEffect, *, origin: EventSource = "system"
) -> None:
"""Show ``effect`` on ``zone``. The most recent call wins."""
try:
frame = encode_effect(zone, effect)
except UnsupportedEffectError as exc:
_log.error("%s", exc)
return
self._effects[zone] = effect
self._transport.write(frame)
self._bus.emit(
LedEffectChanged(zone=zone, effect=effect, origin=origin, source="device")
)
def set_button_brightness(self, brightness: float, *, origin: EventSource = "system") -> None:
"""Set both prev/next button backlights (``0..1``)."""
brightness = min(1.0, max(0.0, brightness))
self._button_brightness = brightness
for button in _BACKLIT_BUTTONS:
self._transport.write(encode_button_brightness(button, brightness))
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
def all_leds_off(self, *, origin: EventSource = "system") -> None:
for zone in LedZone:
self.set_effect(zone, OFF(), origin=origin)
self.set_button_brightness(0.0, origin=origin)
# ------------------------------------------------------------- link callbacks
def on_connected(self) -> None:
"""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"))
for zone, effect in self._effects.items():
self._transport.write(encode_effect(zone, effect))
for button in _BACKLIT_BUTTONS:
self._transport.write(encode_button_brightness(button, self._button_brightness))
if self._effects:
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
def on_disconnected(self, reason: str = "") -> None:
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device"))
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
def feed(self, data: bytes) -> None:
"""Hand bytes from the link to the decoder and publish what comes out."""
self._decoder.push(data)
while True:
try:
item = self._decoder.take()
except ProtocolError as exc:
_log.warning("Discarding bad frame from firmware: %s", exc)
continue
if item is None:
return
if isinstance(item, FirmwareLog):
if item.text:
_log.info("[firmware] %s", item.text)
else:
self._publish(item)
# ---------------------------------------------------------------- internals
def _publish(self, event: InputEvent) -> None:
if isinstance(event, RfidTokenRead):
self._publish_tag_read(event)
else:
self._bus.emit(event)
def _publish_tag_read(self, event: RfidTokenRead) -> None:
if event.tag_id == NO_FIGURE_TAG:
figure, known = None, True
elif (name := self._tag_map.get(event.tag_id)) is not None:
figure, known = name, True
else:
figure, known = None, False
_log.warning("Unknown RFID tag %s - not configured as a figure", event.tag_id.hex())
self._bus.emit(replace(event, figure=figure, known=known))
if not known:
# Leave the active figure alone: an unreadable tag is not a removal.
return
previous, self._active_figure = self._active_figure, figure
if previous != figure:
self._bus.emit(
ActiveFigureChanged(figure=figure, previous=previous, source="device")
)

View File

@@ -0,0 +1,22 @@
"""A transport with nothing on the other end.
The web front-end is a complete way to drive the mouse, so the backend has to be
useful on a machine that has no mouse attached - a spare Pi, a laptop, a container.
Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and
teaching every reaction to cope with its absence, the device is built as usual against
a transport that drops what it is handed. Lighting reactions still run; their bytes go
nowhere.
"""
from __future__ import annotations
__all__ = ["NullTransport"]
class NullTransport:
def write(self, data: bytes) -> None:
pass
@property
def connected(self) -> bool:
return False

View File

@@ -0,0 +1,365 @@
"""Audio playback, behind a protocol.
:class:`VlcPlayer` is the only real implementation; the simulator supplies another.
libVLC fires its callbacks on its own thread, so every one of them goes through
``bus.emit()``, which hops back onto the event loop. The old code called straight into
the serial transport from that thread.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Protocol
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.events import (
EventSource,
PlaybackChanged,
PlaylistFinished,
TrackChanged,
VolumeChanged,
)
from musicmouse.media import Playlist, Track
if TYPE_CHECKING:
from musicmouse.config import GeneralConfig
_log = logging.getLogger(__name__)
__all__ = ["Player", "PlayerBase", "VlcPlayer"]
class Player(Protocol):
"""What reactions and front-ends are allowed to do with the audio player."""
@property
def is_playing(self) -> bool: ...
@property
def volume(self) -> int: ...
@property
def playlist(self) -> Playlist | None: ...
@property
def track_index(self) -> int: ...
@property
def current_track(self) -> Track | None: ...
@property
def position(self) -> float:
"""Seconds into the current track. ``0.0`` when nothing is loaded.
Read on demand rather than announced: a progress bar wants this twice a second,
and an event at that rate would flood the bus, the MQTT service and the log for
the benefit of one front-end.
"""
...
@property
def duration(self) -> float:
"""Length of the current track in seconds, or ``0.0`` when unknown."""
...
def set_playlist(self, playlist: Playlist) -> None: ...
def play(self) -> None: ...
def play_from_start(self) -> None: ...
def play_track(self, index: int) -> None: ...
def pause(self) -> None: ...
def stop(self) -> None: ...
def next_track(self) -> None: ...
def previous_track(self) -> None: ...
def seek(self, position: float) -> None: ...
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
def set_volume_limits(self, minimum: int, maximum: int) -> None: ...
async def run(self) -> None:
"""Long-running task, if the implementation needs one."""
...
def close(self) -> None: ...
class PlayerBase:
"""Volume clamping, playlist bookkeeping and state events, shared by the
real and the simulated player."""
def __init__(
self,
bus: EventBus,
*,
min_volume: int = 0,
max_volume: int = 100,
initial_volume: int = 50,
) -> None:
self._bus = bus
self._min_volume = min_volume
self._max_volume = max_volume
self._volume = self._clamp(initial_volume)
self._playlist: Playlist | None = None
self._index = 0
self._playing = False
self._playlist_changed = False
@classmethod
def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]:
return {
"min_volume": config.min_volume,
"max_volume": config.max_volume,
"initial_volume": config.initial_volume,
}
# -------------------------------------------------------------------- state
@property
def is_playing(self) -> bool:
return self._playing
@property
def volume(self) -> int:
return self._volume
@property
def playlist(self) -> Playlist | None:
return self._playlist
@property
def track_index(self) -> int:
return self._index
@property
def current_track(self) -> Track | None:
if self._playlist is None or not 0 <= self._index < len(self._playlist):
return None
return self._playlist[self._index]
# ------------------------------------------------------------------ actions
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
clamped = self._clamp(volume)
if clamped == self._volume:
return
self._volume = clamped
self._apply_volume(clamped)
self._announce_volume(source)
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
self.set_volume(self._volume + delta, source=source)
def set_volume_limits(self, minimum: int, maximum: int) -> None:
"""Re-clamp to a new allowed range, and pull the current volume into it.
Parent mode edits these while the mouse is playing, so they cannot only be
constructor arguments.
"""
self._min_volume = minimum
self._max_volume = maximum
self.set_volume(self._volume)
def _apply_volume(self, volume: int) -> None:
"""Push the new volume at whatever actually makes sound. No-op by default."""
# ---------------------------------------------------------------- internals
def _clamp(self, volume: int) -> int:
# `if self._min_volume and ...` in the old code silently ignored min_volume: 0,
# which is what config.yml.example shipped with.
return max(self._min_volume, min(self._max_volume, volume))
def _set_playing(self, playing: bool, *, figure: str | None = None) -> None:
if playing == self._playing:
return
self._playing = playing
self._playlist_changed = False
self._bus.emit(
PlaybackChanged(
playing=playing, figure=figure, playlist=self._playlist, source="player"
)
)
def _set_index(self, index: int) -> None:
# A playlist swap while already playing resets the index to 0 without going
# through here (see `_load_playlist`), so the following `play_track(0)` looks
# like a no-op index change. Force it through in that case - `_set_playing`
# will not itself announce anything, since it was already playing before and
# after. Starting from idle needs no such push: `_set_playing`'s own True
# transition already covers the broadcast.
force = self._playlist_changed and self._playing
if index == self._index and not force:
return
self._playlist_changed = False
self._index = index
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
def _load_playlist(self, playlist: Playlist) -> None:
"""Bookkeeping shared by every player's ``set_playlist``."""
self._playlist = playlist
self._index = 0
self._playlist_changed = True
def _announce_volume(self, source: EventSource = "player") -> None:
self._bus.emit(VolumeChanged(volume=self._volume, source=source))
def _announce_playlist_finished(self) -> None:
self._bus.emit(PlaylistFinished(source="player"))
async def run(self) -> None: # pragma: no cover - overridden where needed
return
def close(self) -> None: # pragma: no cover - overridden where needed
return
class VlcPlayer(PlayerBase):
def __init__(
self,
bus: EventBus,
*,
alsa_device: str | None = None,
min_volume: int = 0,
max_volume: int = 100,
initial_volume: int = 50,
poll_interval: float = 1.0,
clock: Clock | None = None,
) -> None:
super().__init__(
bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume
)
# Imported here rather than at module scope: python-vlc loads libvlc eagerly,
# and the simulator must run on machines without it.
import vlc
self._vlc = vlc
self._poll_interval = poll_interval
self._clock = clock or RealClock()
args = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
self._instance = vlc.Instance(*args)
self._list_player = self._instance.media_list_player_new()
self._media_player = self._list_player.get_media_player()
self._mrl_to_index: dict[str, int] = {}
self._attach_events()
self._media_player.audio_set_volume(self._volume)
# -------------------------------------------------------------------- state
@property
def position(self) -> float:
# libVLC reports -1 for both of these until a media is actually opened.
return max(0.0, float(self._media_player.get_time()) / 1000)
@property
def duration(self) -> float:
return max(0.0, float(self._media_player.get_length()) / 1000)
# ------------------------------------------------------------------ actions
def set_playlist(self, playlist: Playlist) -> None:
media_list = self._vlc.MediaList()
self._mrl_to_index.clear()
for index, track in enumerate(playlist.tracks):
media = self._instance.media_new(str(track.path))
media_list.add_media(media)
self._mrl_to_index[media.get_mrl()] = index
self._list_player.set_media_list(media_list)
self._list_player.set_playback_mode(self._vlc.PlaybackMode.default)
self._load_playlist(playlist)
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None:
if self._playing:
return
self._list_player.play()
def play_from_start(self) -> None:
self.play_track(0)
def play_track(self, index: int) -> None:
if self._playlist is None or not self._playlist:
_log.warning("Nothing to play: the playlist is empty")
return
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
def pause(self) -> None:
if not self._playing:
return
self._media_player.set_pause(1)
def stop(self) -> None:
self._list_player.stop()
def next_track(self) -> None:
self._list_player.next()
def previous_track(self) -> None:
self._list_player.previous()
def seek(self, position: float) -> None:
self._media_player.set_time(int(max(0.0, position) * 1000))
def _apply_volume(self, volume: int) -> None:
self._media_player.audio_set_volume(volume)
async def run(self) -> None:
"""Poll for state libVLC does not reliably report by event."""
while True:
await self._clock.sleep(self._poll_interval)
try:
self._poll()
except Exception: # pragma: no cover - defensive around a C library
_log.exception("VLC poll failed")
def close(self) -> None:
self._list_player.stop()
# ---------------------------------------------------------------- internals
def _poll(self) -> None:
volume = self._media_player.audio_get_volume()
if volume >= 0 and volume != self._volume:
self._volume = volume
self._announce_volume()
self._set_playing(bool(self._list_player.is_playing()))
self._sync_index()
def _sync_index(self) -> None:
"""Read which track is playing back off libVLC.
Asking the media player which media it holds works on every build; the
``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare
int rather than a Media, and the index would then never move off zero.
"""
media = self._media_player.get_media()
if media is None:
return
index = self._mrl_to_index.get(media.get_mrl())
if index is not None:
self._set_index(index)
def _attach_events(self) -> None:
vlc = self._vlc
player_events = self._media_player.event_manager()
player_events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
player_events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_stopped)
player_events.event_attach(vlc.EventType.MediaPlayerStopped, self._on_stopped)
list_events = self._list_player.event_manager()
list_events.event_attach(vlc.EventType.MediaListPlayerPlayed, self._on_playlist_end)
list_events.event_attach(vlc.EventType.MediaListPlayerNextItemSet, self._on_next_item)
# These four run on a libVLC thread. bus.emit() is the thread hop; nothing else
# here may touch the loop.
def _on_playing(self, _event: Any) -> None:
self._set_playing(True)
def _on_stopped(self, _event: Any) -> None:
self._set_playing(False)
def _on_playlist_end(self, _event: Any) -> None:
self._set_playing(False)
self._announce_playlist_finished()
def _on_next_item(self, _event: Any) -> None:
# The event says *when* to look; what it carries is not portable, so ignore it.
self._sync_index()

View File

@@ -0,0 +1,122 @@
"""Serial transport with reconnect.
The old ``host_driver.py`` stopped the event loop when the USB cable was pulled and
relied on systemd to restart the whole process. Here a dropped link is just a
reconnect loop, and the device re-applies its LED state when it comes back.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
import serial_asyncio
from musicmouse.clock import Clock, RealClock
_log = logging.getLogger(__name__)
__all__ = ["SerialLink"]
_READ_CHUNK = 1024
class SerialLink:
"""Owns the serial port and keeps trying to hold it open."""
def __init__(
self,
port: str,
baudrate: int = 115200,
*,
reconnect_interval: float = 5.0,
clock: Clock | None = None,
) -> None:
self.port = port
self.baudrate = baudrate
self._reconnect_interval = reconnect_interval
self._clock = clock or RealClock()
self._writer: asyncio.StreamWriter | None = None
self._on_data: Callable[[bytes], None] = lambda _data: None
self._on_connect: Callable[[], None] | None = None
self._on_disconnect: Callable[[str], None] | None = None
def attach(
self,
on_data: Callable[[bytes], None],
*,
on_connect: Callable[[], None] | None = None,
on_disconnect: Callable[[str], None] | None = None,
) -> None:
"""Wire up the device. Separate from ``__init__`` because the device needs the
link as its transport, so one of the two has to be built first."""
self._on_data = on_data
self._on_connect = on_connect
self._on_disconnect = on_disconnect
@property
def connected(self) -> bool:
return self._writer is not None
def write(self, data: bytes) -> None:
writer = self._writer
if writer is None:
_log.debug("Dropping %d bytes: %s is not connected", len(data), self.port)
return
try:
writer.write(data)
except OSError as exc: # pragma: no cover - needs a real port dying mid-write
_log.warning("Write to %s failed: %s", self.port, exc)
async def run(self) -> None:
"""Connect, read until the link drops, wait, repeat. Runs until cancelled."""
while True:
reader = await self._connect()
if reader is None:
await self._clock.sleep(self._reconnect_interval)
continue
reason = await self._pump(reader)
self._writer = None
_log.warning("Lost connection to %s: %s", self.port, reason)
if self._on_disconnect is not None:
self._on_disconnect(reason)
await self._clock.sleep(self._reconnect_interval)
async def _connect(self) -> asyncio.StreamReader | None:
reader: asyncio.StreamReader
writer: asyncio.StreamWriter
try:
reader, writer = await serial_asyncio.open_serial_connection(
url=self.port, baudrate=self.baudrate
)
except (OSError, ValueError) as exc:
_log.warning(
"Cannot open %s (%s); retrying in %gs",
self.port,
exc,
self._reconnect_interval,
)
return None
self._writer = writer
_log.info("Connected to firmware on %s at %d baud", self.port, self.baudrate)
if self._on_connect is not None:
self._on_connect()
return reader
async def _pump(self, reader: asyncio.StreamReader) -> str:
try:
while True:
data = await reader.read(_READ_CHUNK)
if not data:
return "port closed"
self._on_data(data)
except asyncio.CancelledError:
self._writer = None
raise
except (OSError, asyncio.IncompleteReadError) as exc:
return str(exc)

View File

@@ -0,0 +1,21 @@
"""The seam between :class:`~musicmouse.devices.mouse.MusicMouseDevice` and the wire.
Kept free of any serial import so the simulator can substitute a transport without
pulling in ``pyserial-asyncio``.
"""
from __future__ import annotations
from typing import Protocol
__all__ = ["Transport"]
class Transport(Protocol):
def write(self, data: bytes) -> None:
"""Send bytes to the firmware. Dropping them while disconnected is fine:
the device re-applies its memorized state once the link is back."""
...
@property
def connected(self) -> bool: ...

View File

@@ -0,0 +1,425 @@
"""The serial wire protocol, as a pure codec.
No I/O and no asyncio here, so the exact byte layout the firmware depends on can be
pinned by ``tests/test_wire.py``. Everything must stay in sync with
``esp-firmware/src/Messages.h``; that file is the authority.
Frames in both directions are ``uint32 magic | uint8 type | uint16 payload_size``
followed by the payload, little-endian. The firmware also writes plain
``Serial.println`` log text on the same link, so the decoder resynchronises on
newlines and on the magic token.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, replace
from enum import IntEnum
from typing import TypeAlias
from musicmouse.effects import (
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
LedEffect,
)
from musicmouse.events import (
ButtonEvent,
InputEvent,
RfidTokenRead,
RotaryTurned,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import (
RFID_TAG_LENGTH,
Button,
ButtonAction,
LedZone,
RotaryDirection,
TouchButton,
)
__all__ = [
"MAGIC_FW_TO_HOST",
"MAGIC_HOST_TO_FW",
"Decoded",
"FirmwareLog",
"FrameDecoder",
"HostCommand",
"HostFrameDecoder",
"MessageFwToHost",
"MessageHostToFw",
"ProtocolError",
"SetButtonBrightness",
"SetEffect",
"UnsupportedEffectError",
"encode_button_brightness",
"encode_effect",
"encode_input_event",
]
MAGIC_HOST_TO_FW = 0x1D6379E3
MAGIC_FW_TO_HOST = 0x10C65631
_HEADER = struct.Struct("<IBH")
_HEADER_SIZE = _HEADER.size # 7
_MAGIC_FW_BYTES = struct.pack("<I", MAGIC_FW_TO_HOST)
#: Give up on resynchronising rather than buffering forever on a wedged link.
_MAX_BUFFER = 8192
class ProtocolError(Exception):
"""A frame arrived that could not be interpreted."""
class UnsupportedEffectError(Exception):
"""The firmware has no message for this effect on this LED zone."""
class MessageFwToHost(IntEnum):
RFID_TOKEN_READ = 0
ROTARY_ENCODER = 1
TOUCH_BUTTON_PRESS = 2
TOUCH_BUTTON_RELEASE = 3
BUTTON_EVENT = 4
class MessageHostToFw(IntEnum):
LED_WHEEL_EFFECT_STATIC = 0
LED_WHEEL_EFFECT_ALEXA_SWIPE = 1
LED_WHEEL_EFFECT_CIRCULAR = 2
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5
MOUSE_LED_EFFECT_STATIC = 6
MOUSE_LED_EFFECT_CIRCULAR = 7
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10
SHELF_LED_EFFECT_STATIC = 15
SHELF_LED_EFFECT_CIRCULAR = 16
SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 17
SHELF_LED_EFFECT_SWIPE_AND_CHANGE = 18
SHELF_LED_EFFECT_REVERSE_SWIPE = 19
SHELF_LED_EFFECT_STATIC_DETAILED = 20
PREV_BUTTON_LED = 21
NEXT_BUTTON_LED = 22
#: 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.
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
LedZone.RING: {
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
EffectAlexaSwipeConfig: MessageHostToFw.LED_WHEEL_EFFECT_ALEXA_SWIPE,
EffectCircularConfig: MessageHostToFw.LED_WHEEL_EFFECT_CIRCULAR,
EffectRandomTwoColorInterpolationConfig: (
MessageHostToFw.LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
),
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
},
LedZone.MOUSE: {
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
EffectCircularConfig: MessageHostToFw.MOUSE_LED_EFFECT_CIRCULAR,
EffectRandomTwoColorInterpolationConfig: (
MessageHostToFw.MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
),
EffectSwipeAndChange: MessageHostToFw.MOUSE_LED_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.MOUSE_LED_EFFECT_REVERSE_SWIPE,
},
LedZone.SHELF: {
EffectStaticConfig: MessageHostToFw.SHELF_LED_EFFECT_STATIC,
EffectCircularConfig: MessageHostToFw.SHELF_LED_EFFECT_CIRCULAR,
EffectRandomTwoColorInterpolationConfig: (
MessageHostToFw.SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
),
EffectSwipeAndChange: MessageHostToFw.SHELF_LED_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.SHELF_LED_EFFECT_REVERSE_SWIPE,
EffectStaticDetailedConfig: MessageHostToFw.SHELF_LED_EFFECT_STATIC_DETAILED,
},
}
_BUTTON_LED_IDS: dict[Button, MessageHostToFw] = {
Button.LEFT: MessageHostToFw.PREV_BUTTON_LED,
Button.RIGHT: MessageHostToFw.NEXT_BUTTON_LED,
}
@dataclass(frozen=True, slots=True)
class FirmwareLog:
"""A ``Serial.println`` line from the firmware, interleaved with the frames."""
text: str
Decoded: TypeAlias = InputEvent | FirmwareLog
# ------------------------------------------------------------------------- encoding
def _frame(message: MessageHostToFw, payload: bytes) -> bytes:
return _HEADER.pack(MAGIC_HOST_TO_FW, message, len(payload)) + payload
def encode_effect(zone: LedZone, effect: LedEffect) -> bytes:
"""Encode ``effect`` as a frame for ``zone``.
Raises:
UnsupportedEffectError: if the firmware has no message for this combination.
"""
try:
message = _EFFECT_IDS[zone][type(effect)]
except KeyError:
supported = ", ".join(sorted(cls.__name__ for cls in _EFFECT_IDS[zone]))
raise UnsupportedEffectError(
f"{type(effect).__name__} cannot be sent to the {zone} LEDs "
f"(supported there: {supported})"
) from None
return _frame(message, effect.as_bytes())
def encode_button_brightness(button: Button, brightness: float) -> bytes:
"""Encode the backlight brightness (``0..1``) of the prev/next button."""
if not 0 <= brightness <= 1:
raise ValueError(f"brightness must be within 0..1, got {brightness}")
try:
message = _BUTTON_LED_IDS[button]
except KeyError:
raise ValueError(f"{button.slug} has no backlight") from None
return _frame(message, struct.pack("<f", brightness))
def encode_input_event(event: InputEvent) -> bytes:
"""Encode an event as the firmware would send it.
The inverse of the decoder, used by the simulator so that simulated hardware
exercises the real codec rather than bypassing it.
"""
match event:
case RfidTokenRead(tag_id=tag_id):
if len(tag_id) != RFID_TAG_LENGTH:
raise ValueError(f"tag id must be {RFID_TAG_LENGTH} bytes, got {len(tag_id)}")
message, payload = MessageFwToHost.RFID_TOKEN_READ, tag_id
case RotaryTurned(position=position, increment=increment, direction=direction):
message = MessageFwToHost.ROTARY_ENCODER
payload = struct.pack("<iiB", position, increment, direction)
case TouchButtonPressed(button=button):
message, payload = MessageFwToHost.TOUCH_BUTTON_PRESS, struct.pack("<B", button)
case TouchButtonReleased(button=button):
message, payload = MessageFwToHost.TOUCH_BUTTON_RELEASE, struct.pack("<B", button)
case ButtonEvent(button=push_button, action=action):
message = MessageFwToHost.BUTTON_EVENT
payload = struct.pack("<BB", push_button, action)
case _:
raise ValueError(f"{type(event).__name__} is not a firmware message")
return struct.pack("<IBH", MAGIC_FW_TO_HOST, message, len(payload)) + payload
# ------------------------------------------------------------------------- decoding
def decode_message(msg_type: int, payload: bytes) -> InputEvent:
"""Turn one frame payload into an event.
Raises:
ProtocolError: on an unknown message type or a malformed payload.
"""
try:
message = MessageFwToHost(msg_type)
except ValueError:
raise ProtocolError(f"unknown message type {msg_type}") from None
try:
match message:
case MessageFwToHost.RFID_TOKEN_READ:
if len(payload) != RFID_TAG_LENGTH:
raise ProtocolError(
f"RFID payload must be {RFID_TAG_LENGTH} bytes, got {len(payload)}"
)
return RfidTokenRead(tag_id=bytes(payload), source="device")
case MessageFwToHost.ROTARY_ENCODER:
position, increment, direction = struct.unpack("<iiB", payload)
return RotaryTurned(
position=position,
increment=increment,
direction=RotaryDirection(direction),
source="device",
)
case MessageFwToHost.TOUCH_BUTTON_PRESS:
return TouchButtonPressed(button=_touch_button(payload), source="device")
case MessageFwToHost.TOUCH_BUTTON_RELEASE:
return TouchButtonReleased(button=_touch_button(payload), source="device")
case MessageFwToHost.BUTTON_EVENT:
button_nr, event_nr = struct.unpack("<BB", payload)
return ButtonEvent(
button=Button(button_nr), action=ButtonAction(event_nr), source="device"
)
except struct.error as exc:
raise ProtocolError(f"malformed {message.name} payload {payload.hex()}: {exc}") from exc
except ValueError as exc:
raise ProtocolError(f"bad value in {message.name} payload {payload.hex()}: {exc}") from exc
raise AssertionError(f"unhandled message {message}") # pragma: no cover
def _touch_button(payload: bytes) -> TouchButton:
if len(payload) != 1:
raise ProtocolError(f"touch payload must be 1 byte, got {len(payload)}")
return TouchButton(payload[0])
class FrameDecoder:
"""Incremental decoder for the byte stream coming from the firmware.
Handles partial frames, several frames in one chunk, and interleaved log text.
"""
def __init__(self) -> None:
self._buffer = bytearray()
def push(self, data: bytes) -> None:
"""Append freshly read bytes. Call :meth:`take` until it returns ``None``."""
self._buffer += data
@property
def buffered(self) -> int:
return len(self._buffer)
def take(self) -> Decoded | None:
"""Return the next complete item, or ``None`` if more bytes are needed.
Raises:
ProtocolError: on a malformed frame. The offending frame has already been
consumed, so the caller can log it and call ``take()`` again - which is
why this is not a generator: an exception would close one for good.
"""
buffer = self._buffer
if not buffer:
return None
if buffer[:4] == _MAGIC_FW_BYTES:
if len(buffer) < _HEADER_SIZE:
return None
_, msg_type, size = _HEADER.unpack_from(buffer)
end = _HEADER_SIZE + size
if len(buffer) < end:
return None
payload = bytes(buffer[_HEADER_SIZE:end])
del buffer[:end]
return decode_message(msg_type, payload)
if len(buffer) < 4 and _MAGIC_FW_BYTES.startswith(buffer):
return None # could still become a frame header
# Not a frame at offset 0, so it is firmware log text.
if (newline := buffer.find(b"\n")) >= 0:
line = bytes(buffer[:newline])
del buffer[: newline + 1]
return FirmwareLog(line.decode("utf-8", errors="replace").rstrip("\r"))
# No newline yet: skip ahead to the next frame if one has already started.
if (start := buffer.find(_MAGIC_FW_BYTES)) > 0:
skipped = bytes(buffer[:start])
del buffer[:start]
return FirmwareLog(skipped.decode("utf-8", errors="replace").rstrip("\r"))
if len(buffer) > _MAX_BUFFER:
# Nothing recognisable and no end in sight - keep only what could still be
# the beginning of a magic token straddling the next chunk.
del buffer[: -len(_MAGIC_FW_BYTES) + 1]
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
#
# Nothing in the running app needs this direction decoded - the firmware does that.
# The simulator uses it to report what the real device would have been told, which
# also means simulated hardware exercises the encoders rather than bypassing them.
@dataclass(frozen=True, slots=True)
class SetEffect:
zone: LedZone
effect: LedEffect
def __repr__(self) -> str:
return f"{self.zone} <- {self.effect}"
@dataclass(frozen=True, slots=True)
class SetButtonBrightness:
button: Button
brightness: float
def __repr__(self) -> str:
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
HostCommand: TypeAlias = SetEffect | SetButtonBrightness
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
message: (zone, effect_cls)
for zone, effects in _EFFECT_IDS.items()
for effect_cls, message in effects.items()
}
_ID_TO_BUTTON: dict[int, Button] = {
message: button for button, message in _BUTTON_LED_IDS.items()
}
_MAGIC_HOST_BYTES = struct.pack("<I", MAGIC_HOST_TO_FW)
def decode_host_command(msg_type: int, payload: bytes) -> HostCommand:
if (target := _ID_TO_EFFECT.get(msg_type)) is not None:
zone, effect_cls = target
try:
return SetEffect(zone=zone, effect=effect_cls.from_bytes(payload))
except (ValueError, struct.error) as exc:
raise ProtocolError(f"malformed {effect_cls.__name__} payload: {exc}") from exc
if (button := _ID_TO_BUTTON.get(msg_type)) is not None:
try:
(brightness,) = struct.unpack("<f", payload)
except struct.error as exc:
raise ProtocolError(f"malformed button brightness payload: {exc}") from exc
return SetButtonBrightness(button=button, brightness=brightness)
raise ProtocolError(f"unknown host-to-firmware message type {msg_type}")
class HostFrameDecoder:
"""Incremental decoder for the host -> firmware direction."""
def __init__(self) -> None:
self._buffer = bytearray()
def push(self, data: bytes) -> None:
self._buffer += data
def take(self) -> HostCommand | None:
"""Next command, or ``None`` if more bytes are needed. See :meth:`FrameDecoder.take`."""
if len(self._buffer) < _HEADER_SIZE:
return None
magic, msg_type, size = _HEADER.unpack_from(self._buffer)
if magic != MAGIC_HOST_TO_FW:
self._buffer.clear()
raise ProtocolError(f"expected host-to-firmware magic, got {magic:#010x}")
end = _HEADER_SIZE + size
if len(self._buffer) < end:
return None
payload = bytes(self._buffer[_HEADER_SIZE:end])
del self._buffer[:end]
return decode_host_command(msg_type, payload)

View File

@@ -0,0 +1,279 @@
"""LED effect configurations and their firmware wire encoding.
Each dataclass mirrors a ``struct`` in ``esp-firmware/lib/ledtl/effects/`` and its
``as_bytes()`` is the byte-for-byte payload the firmware expects. ``tests/test_effects.py``
pins those layouts.
``from_bytes()`` is the inverse. Nothing in the running app decodes effects - the
firmware does that - but the simulator uses it to show what the real device would have
been told, which also keeps the encoders honest.
Formerly ``led_cmds.py``. ``EffectReverseSwipe``'s fields were camelCase there, copied
from the C++ side; they are snake_case here like every other effect.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from typing import ClassVar, Protocol, Self
from musicmouse.color import ColorHSV, ColorRGBW
__all__ = [
"OFF",
"EffectAlexaSwipeConfig",
"EffectCircularConfig",
"EffectRandomTwoColorInterpolationConfig",
"EffectReverseSwipe",
"EffectStaticConfig",
"EffectStaticDetailedConfig",
"EffectSwipeAndChange",
"LedEffect",
]
_RGBW_SIZE = 4
_HSV_SIZE = 12
_STATIC = struct.Struct("<HH")
_STATIC_DETAILED = struct.Struct("<Hfff")
_ALEXA_SWIPE = struct.Struct("<fffff?")
_TWO_COLOR = struct.Struct("<i?i??")
_CIRCULAR = struct.Struct("<ff")
_REVERSE_SWIPE = struct.Struct("<fff")
class LedEffect(Protocol):
"""Anything that can be sent to an LED zone, and read back off the wire."""
SIZE: ClassVar[int]
def as_bytes(self) -> bytes: ...
@classmethod
def from_bytes(cls, data: bytes) -> Self: ...
def _check_size(data: bytes, expected: int, name: str) -> None:
if len(data) != expected:
raise ValueError(f"{name} payload must be {expected} bytes, got {len(data)}")
@dataclass
class EffectStaticConfig:
color: ColorRGBW
begin: int = 0
end: int = 0
SIZE: ClassVar[int] = _RGBW_SIZE + _STATIC.size
def as_bytes(self) -> bytes:
return self.color.as_bytes() + _STATIC.pack(self.begin, self.end)
@classmethod
def from_bytes(cls, data: bytes) -> EffectStaticConfig:
_check_size(data, cls.SIZE, cls.__name__)
begin, end = _STATIC.unpack(data[_RGBW_SIZE:])
return cls(ColorRGBW.from_bytes(data[:_RGBW_SIZE]), begin, end)
def __repr__(self) -> str:
return f"Static({self.color}, begin={self.begin}, end={self.end})"
@dataclass
class EffectStaticDetailedConfig:
color: ColorRGBW
increment: int = 1
begin: float = 0.0
end: float = 1.0
transition_time_in_ms: float = 500
SIZE: ClassVar[int] = _RGBW_SIZE + _STATIC_DETAILED.size
def as_bytes(self) -> bytes:
return self.color.as_bytes() + _STATIC_DETAILED.pack(
self.increment, self.begin, self.end, self.transition_time_in_ms
)
@classmethod
def from_bytes(cls, data: bytes) -> EffectStaticDetailedConfig:
_check_size(data, cls.SIZE, cls.__name__)
increment, begin, end, transition = _STATIC_DETAILED.unpack(data[_RGBW_SIZE:])
return cls(ColorRGBW.from_bytes(data[:_RGBW_SIZE]), increment, begin, end, transition)
def __repr__(self) -> str:
return (
f"StaticDetailed({self.color}, begin={self.begin}, end={self.end}, "
f"increment={self.increment}, transition={self.transition_time_in_ms}ms)"
)
@dataclass
class EffectAlexaSwipeConfig:
primary_color_width: float = 20 # degrees
transition_width: float = 30 # degrees
swipe_speed: float = 2 * 360 # degrees per second
bell_curve_width_in_leds: float = 3
start_position: float = 180 # degrees
forward: bool = True
primary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
secondary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 200 / 255, 1, 0))
SIZE: ClassVar[int] = _ALEXA_SWIPE.size + 2 * _RGBW_SIZE
def as_bytes(self) -> bytes:
return (
_ALEXA_SWIPE.pack(
self.primary_color_width,
self.transition_width,
self.swipe_speed,
self.bell_curve_width_in_leds,
self.start_position,
self.forward,
)
+ self.primary_color.as_bytes()
+ self.secondary_color.as_bytes()
)
@classmethod
def from_bytes(cls, data: bytes) -> EffectAlexaSwipeConfig:
_check_size(data, cls.SIZE, cls.__name__)
primary_width, transition, speed, bell_width, start, forward = _ALEXA_SWIPE.unpack_from(
data
)
colors = data[_ALEXA_SWIPE.size :]
return cls(
primary_color_width=primary_width,
transition_width=transition,
swipe_speed=speed,
bell_curve_width_in_leds=bell_width,
start_position=start,
forward=forward,
primary_color=ColorRGBW.from_bytes(colors[:_RGBW_SIZE]),
secondary_color=ColorRGBW.from_bytes(colors[_RGBW_SIZE:]),
)
def __repr__(self) -> str:
return f"AlexaSwipe({self.primary_color} -> {self.secondary_color})"
@dataclass
class EffectRandomTwoColorInterpolationConfig:
cycle_durations_ms: int = 6000
start_with_existing: bool = True
num_segments: int = 3
hue1_random: bool = False
hue2_random: bool = False
color1: ColorHSV | ColorRGBW = field(default_factory=lambda: ColorHSV(240, 1, 1))
color2: ColorHSV | ColorRGBW = field(default_factory=lambda: ColorHSV(192, 1, 1))
SIZE: ClassVar[int] = _TWO_COLOR.size + 2 * _HSV_SIZE
def as_bytes(self) -> bytes:
c1 = ColorHSV.from_rgb(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
c2 = ColorHSV.from_rgb(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
return (
_TWO_COLOR.pack(
self.cycle_durations_ms,
self.start_with_existing,
self.num_segments,
self.hue1_random,
self.hue2_random,
)
+ c1.as_bytes()
+ c2.as_bytes()
)
@classmethod
def from_bytes(cls, data: bytes) -> EffectRandomTwoColorInterpolationConfig:
_check_size(data, cls.SIZE, cls.__name__)
cycle, start_with_existing, segments, hue1, hue2 = _TWO_COLOR.unpack_from(data)
colors = data[_TWO_COLOR.size :]
return cls(
cycle_durations_ms=cycle,
start_with_existing=start_with_existing,
num_segments=segments,
hue1_random=hue1,
hue2_random=hue2,
color1=ColorHSV.from_bytes(colors[:_HSV_SIZE]),
color2=ColorHSV.from_bytes(colors[_HSV_SIZE:]),
)
def __repr__(self) -> str:
return f"TwoColor({self.color1}, {self.color2}, segments={self.num_segments})"
@dataclass
class EffectCircularConfig:
speed: float = 360 # degrees per second
width: float = 180 # degrees
color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
SIZE: ClassVar[int] = _CIRCULAR.size + _RGBW_SIZE
def as_bytes(self) -> bytes:
return _CIRCULAR.pack(self.speed, self.width) + self.color.as_bytes()
@classmethod
def from_bytes(cls, data: bytes) -> EffectCircularConfig:
_check_size(data, cls.SIZE, cls.__name__)
speed, width = _CIRCULAR.unpack_from(data)
return cls(speed, width, ColorRGBW.from_bytes(data[_CIRCULAR.size :]))
def __repr__(self) -> str:
return f"Circular({self.color}, speed={self.speed}, width={self.width})"
@dataclass
class EffectSwipeAndChange:
swipe: EffectAlexaSwipeConfig = field(default_factory=EffectAlexaSwipeConfig)
change: EffectRandomTwoColorInterpolationConfig = field(
default_factory=EffectRandomTwoColorInterpolationConfig
)
SIZE: ClassVar[int] = EffectAlexaSwipeConfig.SIZE + EffectRandomTwoColorInterpolationConfig.SIZE
def as_bytes(self) -> bytes:
return self.swipe.as_bytes() + self.change.as_bytes()
@classmethod
def from_bytes(cls, data: bytes) -> EffectSwipeAndChange:
_check_size(data, cls.SIZE, cls.__name__)
split = EffectAlexaSwipeConfig.SIZE
return cls(
EffectAlexaSwipeConfig.from_bytes(data[:split]),
EffectRandomTwoColorInterpolationConfig.from_bytes(data[split:]),
)
def __repr__(self) -> str:
return f"SwipeAndChange({self.swipe}, {self.change})"
@dataclass
class EffectReverseSwipe:
swipe_speed: float = 2 * 360
bell_curve_width_in_leds: float = 3
start_position: float = 180
SIZE: ClassVar[int] = _REVERSE_SWIPE.size
def as_bytes(self) -> bytes:
return _REVERSE_SWIPE.pack(
self.swipe_speed, self.bell_curve_width_in_leds, self.start_position
)
@classmethod
def from_bytes(cls, data: bytes) -> EffectReverseSwipe:
_check_size(data, cls.SIZE, cls.__name__)
return cls(*_REVERSE_SWIPE.unpack(data))
def __repr__(self) -> str:
return (
f"ReverseSwipe(speed={self.swipe_speed}, "
f"width={self.bell_curve_width_in_leds}, start={self.start_position})"
)
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
"""A fresh "all LEDs off" effect."""
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))

View File

@@ -0,0 +1,272 @@
"""The event vocabulary.
Three flavours, distinguished by base class:
* :class:`InputEvent` - something happened (hardware, player).
* :class:`IntentEvent` - something was requested (button, MQTT, web, simulator).
* :class:`StateEvent` - something changed.
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
single reaction acts on it.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Literal, TypeAlias
from musicmouse.effects import LedEffect
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.media import Playlist, Track
__all__ = [
"ActiveFigureChanged",
"ButtonEvent",
"ConnectionChanged",
"DeviceConnected",
"DeviceDisconnected",
"Event",
"EventSource",
"InputEvent",
"IntentEvent",
"LedEffectChanged",
"LedEffectRequested",
"NextTrackRequested",
"PauseRequested",
"PlayAlbumRequested",
"PlayFigureRequested",
"PlayRequested",
"PlaySeriesLatestRequested",
"PlaybackChanged",
"PlaylistFinished",
"PreviousTrackRequested",
"RfidTokenRead",
"RotaryTurned",
"SetVolumeRequested",
"StateEvent",
"TouchButtonPressed",
"TouchButtonReleased",
"TrackChanged",
"VolumeChangeRequested",
"VolumeChanged",
]
EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
@dataclass(frozen=True, slots=True, kw_only=True)
class Event:
"""Base for every event. Keyword-only so subclasses can add required fields."""
source: EventSource = "system"
timestamp: float = field(default_factory=time.monotonic, compare=False)
@dataclass(frozen=True, slots=True, kw_only=True)
class InputEvent(Event):
"""Something happened out in the world."""
@dataclass(frozen=True, slots=True, kw_only=True)
class IntentEvent(Event):
"""Something was requested. May come from any front-end."""
@dataclass(frozen=True, slots=True, kw_only=True)
class StateEvent(Event):
"""Something changed. Front-ends mirror these outwards."""
# --------------------------------------------------------------------------- input
@dataclass(frozen=True, slots=True, kw_only=True)
class RfidTokenRead(InputEvent):
"""A tag was read. ``figure`` is ``None`` for the all-zero "removed" tag and for
tags that match no configured figure (``known`` tells the two apart)."""
tag_id: bytes
figure: str | None = None
known: bool = True
def __repr__(self) -> str:
tag = self.tag_id.hex()
return f"RfidTokenRead({tag}, figure={self.figure!r})"
@dataclass(frozen=True, slots=True, kw_only=True)
class ButtonEvent(InputEvent):
button: Button
action: ButtonAction
def __repr__(self) -> str:
return f"ButtonEvent({self.button.slug}, {self.action.slug})"
@dataclass(frozen=True, slots=True, kw_only=True)
class TouchButtonPressed(InputEvent):
button: TouchButton
def __repr__(self) -> str:
return f"TouchButtonPressed({self.button.slug})"
@dataclass(frozen=True, slots=True, kw_only=True)
class TouchButtonReleased(InputEvent):
button: TouchButton
def __repr__(self) -> str:
return f"TouchButtonReleased({self.button.slug})"
@dataclass(frozen=True, slots=True, kw_only=True)
class RotaryTurned(InputEvent):
position: int
increment: int
direction: RotaryDirection
def __repr__(self) -> str:
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
@dataclass(frozen=True, slots=True, kw_only=True)
class PlaylistFinished(InputEvent):
"""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
@dataclass(frozen=True, slots=True, kw_only=True)
class PlayRequested(IntentEvent):
pass
@dataclass(frozen=True, slots=True, kw_only=True)
class PauseRequested(IntentEvent):
pass
@dataclass(frozen=True, slots=True, kw_only=True)
class NextTrackRequested(IntentEvent):
pass
@dataclass(frozen=True, slots=True, kw_only=True)
class PreviousTrackRequested(IntentEvent):
pass
@dataclass(frozen=True, slots=True, kw_only=True)
class PlayFigureRequested(IntentEvent):
"""Start a figure's playlist. ``restart=False`` resumes where it left off."""
figure: str
restart: bool = True
@dataclass(frozen=True, slots=True, kw_only=True)
class PlayAlbumRequested(IntentEvent):
"""Start any album from the library, figure or not."""
album_id: str
track_index: int = 0
@dataclass(frozen=True, slots=True, kw_only=True)
class PlaySeriesLatestRequested(IntentEvent):
"""Start the newest episode of a podcast show.
The IR remote's number-key mapping assigns a whole show rather than one fixed
episode, so this is resolved to an actual album fresh on every press.
"""
series: str
@dataclass(frozen=True, slots=True, kw_only=True)
class SeekRequested(IntentEvent):
#: Seconds from the start of the current track.
position: float
@dataclass(frozen=True, slots=True, kw_only=True)
class VolumeChangeRequested(IntentEvent):
delta: int
@dataclass(frozen=True, slots=True, kw_only=True)
class SetVolumeRequested(IntentEvent):
volume: int
@dataclass(frozen=True, slots=True, kw_only=True)
class LedEffectRequested(IntentEvent):
zone: LedZone
effect: LedEffect
def __repr__(self) -> str:
return f"LedEffectRequested({self.zone}, {self.effect}, from={self.source})"
# ---------------------------------------------------------------------------- state
@dataclass(frozen=True, slots=True, kw_only=True)
class PlaybackChanged(StateEvent):
playing: bool
figure: str | None = None
playlist: Playlist | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class TrackChanged(StateEvent):
index: int
track: Track | None
@dataclass(frozen=True, slots=True, kw_only=True)
class VolumeChanged(StateEvent):
volume: int
@dataclass(frozen=True, slots=True, kw_only=True)
class ActiveFigureChanged(StateEvent):
figure: str | None
previous: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class LedEffectChanged(StateEvent):
"""Emitted on *every* write to an LED zone, whatever caused it.
Front-ends publish zone state from this rather than echoing their own commands,
so Home Assistant keeps showing the strip's real state when a figure animation
overrides an MQTT-set colour.
"""
zone: LedZone
effect: LedEffect
origin: EventSource
def __repr__(self) -> str:
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
@dataclass(frozen=True, slots=True, kw_only=True)
class ConnectionChanged(StateEvent):
target: Literal["firmware", "mqtt", "lirc"]
connected: bool

View File

@@ -0,0 +1,90 @@
"""Hardware vocabulary: the enums and geometry the firmware and the host agree on.
The integer values of :class:`Button`, :class:`ButtonAction`, :class:`TouchButton` and
:class:`RotaryDirection` are wire values and must match ``esp-firmware/src/Messages.h``.
"""
from __future__ import annotations
from enum import IntEnum, StrEnum
__all__ = [
"MOUSE_LED_RANGES",
"NO_FIGURE_TAG",
"RFID_TAG_LENGTH",
"Button",
"ButtonAction",
"LedZone",
"RotaryDirection",
"TouchButton",
]
#: Length of an RFID tag id in bytes (``uint8_t tagId[5]`` in ``Messages.h``).
RFID_TAG_LENGTH = 5
#: The all-zero tag the firmware reports when nothing is on the reader.
NO_FIGURE_TAG = bytes(RFID_TAG_LENGTH)
class Button(IntEnum):
"""The three physical push buttons."""
LEFT = 1
RIGHT = 2
ROTARY = 3
@property
def slug(self) -> str:
return self.name.lower()
class ButtonAction(IntEnum):
"""AceButton event types, as reported by the firmware."""
PRESSED = 0
RELEASED = 1
CLICKED = 2
DOUBLE_CLICKED = 3
LONG_PRESSED = 4
REPEAT_PRESSED = 5
LONG_RELEASED = 6
@property
def slug(self) -> str:
return self.name.lower()
class TouchButton(IntEnum):
"""The four capacitive touch areas on the mouse body."""
LEFT_FOOT = 0
RIGHT_FOOT = 1
LEFT_EAR = 2
RIGHT_EAR = 3
@property
def slug(self) -> str:
return self.name.lower()
class RotaryDirection(IntEnum):
NONE = 0
DOWN = 1
UP = 2
class LedZone(StrEnum):
"""The three independently addressable LED strips."""
RING = "ring"
MOUSE = "mouse"
SHELF = "shelf"
#: LED index span (begin, end) lit up when a given touch area is touched.
MOUSE_LED_RANGES: dict[TouchButton, tuple[int, int]] = {
TouchButton.RIGHT_FOOT: (0, 6),
TouchButton.LEFT_FOOT: (6, 12),
TouchButton.LEFT_EAR: (12, 28),
TouchButton.RIGHT_EAR: (28, 45),
}

View File

@@ -0,0 +1,417 @@
"""The music collection: what exists, what it is called, and what colour it is.
The scan is the only part of this backend that touches hundreds of files, so it runs
in a worker thread and its results are cached. Everything the rest of the app sees is
plain immutable data.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from collections.abc import Awaitable, Callable, Collection, Mapping
from concurrent.futures import BrokenExecutor, Executor
from dataclasses import replace
from pathlib import Path
from typing import Any, Final
from musicmouse.library.analysis import (
ANALYZER_VERSION,
Analyzer,
BeatGrid,
NullAnalyzer,
TrackAnalysis,
TrackCurves,
)
from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
from musicmouse.library.scanner import scan_library
from musicmouse.library.sections import SECTIONS, AlbumKind
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
from musicmouse.media import Playlist
_log = logging.getLogger(__name__)
__all__ = [
"ANALYZER_VERSION",
"SECTIONS",
"Album",
"Analyzer",
"BeatGrid",
"LibraryCache",
"LibraryTrack",
"MusicLibrary",
"NullAnalyzer",
"TrackCurves",
"album_id",
"default_worker_count",
"track_key",
]
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
#: tens of minutes of narration with no musical mood to extract, and there are far more
#: of them in a typical library than there are songs.
_ANALYZED_KINDS: Final[tuple[AlbumKind, ...]] = ("music",)
#: How often the background worker checks whether it may resume after `is_busy()` said
#: no. A track's own analysis is a couple of seconds of CPU, so overshooting by this
#: much when playback stops is not worth polling harder for.
_BUSY_POLL_SECONDS: Final = 2.0
#: Analysis is folded back into the live index and persisted this often during a long
#: run, so a first-time pass over an unanalyzed library shows up gradually in open
#: browser tabs rather than only after the whole thing finishes.
_PUBLISH_BATCH_SIZE: Final = 25
#: How often a long analysis pass reports where it is, so a first-time run over a large
#: library - minutes of DSP per track - doesn't sit silent with nothing on the console
#: to say it is still going.
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
def _discard(future: asyncio.Future[Any]) -> None:
"""Drop a result nobody is going to read, without leaving a warning behind."""
if not future.done():
future.cancel()
elif not future.cancelled():
future.exception()
class MusicLibrary:
"""An immutable index of albums, rebuilt wholesale rather than mutated in place."""
def __init__(
self,
root: Path,
cache: LibraryCache,
extensions: frozenset[str],
*,
analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> None:
self.root = root
self.cache = cache
self.extensions = extensions
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.
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
#: Set by `request_analysis`, consumed by `run_analysis`. An `Event` rather than
#: a queue: a request raised while one is already pending or running just
#: coalesces into it, which is exactly what "look again" should mean here.
self._analysis_requested = asyncio.Event()
# -------------------------------------------------------------------- reading
@property
def albums(self) -> list[Album]:
return [album for album, _ in self._entries.values()]
def get(self, identifier: str | None) -> Album | None:
entry = self._entries.get(identifier) if identifier else None
return entry[0] if entry else None
def figure_playlists(self) -> dict[str, Playlist]:
"""One playlist per figure folder, keyed by figure name.
The figure path and the web path must hand the player the *same* object for the
same album: ``reactions.playback.play_figure`` resumes on an identity check.
"""
return {
album.figure: album.to_playlist()
for album, _ in self._entries.values()
if album.figure is not None
}
def latest_episode(self, series: str) -> Album | None:
"""The newest episode-unit album of a podcast show, by filename.
Episode files are named ``YYYYMMDD - Title``, so filename order is
chronological - the same fact ``Kinderpodcasts``' ``order="newest_first"``
already relies on at scan time. ``None`` if the show is unknown or empty.
"""
candidates = [
album
for album in self.albums
if album.series == series
and (section := SECTIONS.get(album.section)) is not None
and section.album_unit == "episode"
]
if not candidates:
return None
return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
def beats(self, identifier: str, index: int) -> BeatGrid | None:
album = self.get(identifier)
if album is None or not 0 <= index < len(album.tracks):
return None
return self.cache.load_beats(track_key(album.tracks[index].path))
def curve(self, identifier: str, index: int) -> TrackCurves | None:
album = self.get(identifier)
if album is None or not 0 <= index < len(album.tracks):
return None
return self.cache.load_curve(track_key(album.tracks[index].path))
# -------------------------------------------------------------------- writing
async def refresh(self) -> None:
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic.
Ends by waking the background analyzer: a rescan is exactly when new tracks -
the only ones analysis can be pending for - enter the index, whether that is
the startup scan or a parent tapping "Bibliothek neu einlesen".
"""
known = dict(self._entries)
entries = await asyncio.to_thread(
scan_library,
self.root,
self.extensions,
self.cache,
known=known,
figure_kinds=self.figure_kinds,
)
self._entries = await asyncio.to_thread(self._with_analysis, entries)
await asyncio.to_thread(self.cache.store_index, self._entries)
self.request_analysis()
def _with_analysis(
self,
entries: dict[str, tuple[Album, Fingerprint]],
*,
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
) -> dict[str, tuple[Album, Fingerprint]]:
"""Fold cached analysis results into the freshly scanned index.
Restricted to `kinds` (music by default): stat-ing hundreds of book and podcast
tracks whose analysis can never exist would be pure waste. Skipped entirely
while ``analysis/`` is empty, which is the normal case until an analyzer has
actually been run.
"""
if not any(self.cache.analysis.glob("*.json")):
return entries
out: dict[str, tuple[Album, Fingerprint]] = {}
for identifier, (album, fingerprint) in entries.items():
if album.kind not in kinds:
out[identifier] = (album, fingerprint)
continue
tracks = tuple(
replace(track, analysis=self.cache.load_analysis(track_key(track.path)))
for track in album.tracks
)
out[identifier] = (replace(album, tracks=tracks), fingerprint)
return out
@classmethod
async def build(
cls,
root: Path,
cache_dir: Path,
extensions: frozenset[str],
*,
analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> MusicLibrary:
cache = LibraryCache(cache_dir)
library = cls(
root,
cache,
extensions,
analyzer=analyzer,
analysis_workers=analysis_workers,
figure_kinds=figure_kinds,
)
library._entries = await asyncio.to_thread(cache.load_index)
await library.refresh()
return library
# ------------------------------------------------------------------- analysis
def request_analysis(self) -> None:
"""Wake the background worker to look for tracks with no current analysis.
Idempotent, and safe to call before `run_analysis` has even started a first
time - the request just waits on the event.
"""
self._analysis_requested.set()
async def run_analysis(
self,
*,
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""Analyze pending tracks whenever `request_analysis` wakes this up.
A long-running task, cancelled at shutdown alongside every other one. Loops
forever so a request raised *during* a pass (a rescan mid-analysis) starts
another pass right after, rather than being lost.
"""
while True:
await self._analysis_requested.wait()
self._analysis_requested.clear()
await self.analyze_pending(is_busy=is_busy, on_batch=on_batch)
async def analyze_pending(
self,
*,
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
batch_size: int = _PUBLISH_BATCH_SIZE,
workers: int | None = None,
) -> int:
"""Run the analyzer over tracks of `kinds` that have no current result.
Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
are analyzed at once (`self.analysis_workers` when not given), each in its own
worker process - see `musicmouse.library.workers` for why processes and how the
machine is kept usable while they run. Checked before every track is handed out,
`is_busy()` pauses the whole pass rather than one file: analysis must never
compete with audio decoding for CPU, and a children's player is idle most of the
day, so the pass simply resumes next time it is. Tracks already in flight when
it goes busy are allowed to finish - their results are already paid for - and
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
if analyzer.version < ANALYZER_VERSION:
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
last_report = time.monotonic()
try:
while pending:
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
)
for future in finished:
key, path = in_flight.pop(future)
self._store_result(key, path, future)
done += 1
if done % batch_size == 0:
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:
await self._publish_analysis(kinds, on_batch)
if done:
_log.info("Analyzed %d tracks", 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(
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
) -> None:
self._entries = await asyncio.to_thread(self._with_analysis, self._entries, kinds=kinds)
await asyncio.to_thread(self.cache.store_index, self._entries)
if on_batch is not None:
await on_batch()

View File

@@ -0,0 +1,179 @@
"""Offline audio analysis, and the seam that lets it be optional.
:class:`LibrosaAnalyzer` (``musicmouse.library.librosa_analyzer``) does the real work,
kept in its own module behind :func:`build_analyzer` so that importing *this* module -
which the scanner, the cache and the web API all do - never pulls in librosa or numpy.
:class:`MusicLibrary` runs whichever analyzer it is given in the background, between
tracks, so the reactive background is a thing a library grows into rather than a
migration.
Two rules hold the result shape together:
* **Scalars travel with the index, time series do not.** A 25-minute podcast at 120 BPM
has ~3000 beats; 900 tracks of that inside ``GET /api/library`` would be tens of
megabytes. :class:`TrackAnalysis` is a handful of floats and rides along; the beat
grid and the per-second :class:`TrackCurves` each live in their own file and are
fetched only for the one track that is playing.
* **Every field is optional with a default.** Adding a new scalar later needs no
migration and no cache wipe: unknown keys on disk are dropped on load, missing ones
fall back to the default. Only :data:`ANALYZER_VERSION` moving past what a file
records marks that file stale.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any, Final, Protocol
_log = logging.getLogger(__name__)
__all__ = [
"ANALYZER_VERSION",
"Analyzer",
"BeatGrid",
"NullAnalyzer",
"TrackAnalysis",
"TrackCurves",
"build_analyzer",
]
#: Bumped when an analyzer's output changes meaning. Cached results recorded under a
#: lower version are recomputed; results at or above it are left alone. 2: energy/
#: valence scalars became mean-of-curve instead of whole-track-percentile/heuristic,
#: and every track needs a new TrackCurves artifact producing.
ANALYZER_VERSION: Final = 2
@dataclass(frozen=True, slots=True)
class TrackAnalysis:
"""What an animation may want to know about a track, in a few dozen bytes."""
#: The :data:`ANALYZER_VERSION` that produced this. ``0`` means "never analyzed".
version: int = 0
tempo: float | None = None
#: 0..1 overall loudness and drive.
energy: float | None = None
#: 0..1 mood, dark and calm through bright and happy.
valence: float | None = None
#: 0..1 spectral centroid. Drives the background hue.
brightness: float | None = None
#: 0..1 confidence that :attr:`tempo` is an audible, steady beat rather than an
#: artifact of free-tempo or spoken-word material. Below-threshold tracks should
#: not be pulsed on the beat even though a grid exists for them.
pulse: float | None = None
#: Whether a beat grid for this track exists on disk.
beats: bool = False
@property
def is_analyzed(self) -> bool:
return self.version >= ANALYZER_VERSION
def to_json(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_json(cls, data: dict[str, Any]) -> TrackAnalysis:
"""Load leniently: unknown keys are dropped, missing keys take their default.
This is what makes the cache survive an analyzer that grew a field.
"""
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in data.items() if k in known})
@dataclass(frozen=True, slots=True)
class BeatGrid:
"""Beat onsets in seconds with a 0..1 strength each, for beat-synced animation."""
times: tuple[float, ...]
strengths: tuple[float, ...]
def to_json(self) -> dict[str, Any]:
# Flat pairs: half the JSON punctuation of a list of objects.
return {"beats": [x for pair in zip(self.times, self.strengths, strict=True) for x in pair]}
@classmethod
def from_json(cls, data: dict[str, Any]) -> BeatGrid:
flat: list[float] = data["beats"]
return cls(tuple(flat[0::2]), tuple(flat[1::2]))
@dataclass(frozen=True, slots=True)
class TrackCurves:
"""Per-second samples of the things that vary *within* a track. Regularly
sampled, so just a hop and equal-length arrays, no per-sample timestamps -
contrast :class:`BeatGrid`'s event-based irregular times.
``energy``/``valence`` drive colour; ``drive`` (rhythmic intensity) modulates the
water current's magnitude. Tempo is deliberately absent: measured against a real
library it is flat to within a few percent inside a track, so a per-second tempo
curve would carry estimator noise (including occasional octave errors) and
nothing else. Whole-track tempo keeps setting the current's base magnitude;
``drive`` is the signal that actually varies.
"""
hop_seconds: float
energy: tuple[float, ...]
valence: tuple[float, ...]
drive: tuple[float, ...]
def to_json(self) -> dict[str, Any]:
return {
"hop_seconds": self.hop_seconds,
"energy": list(self.energy),
"valence": list(self.valence),
"drive": list(self.drive),
}
@classmethod
def from_json(cls, data: dict[str, Any]) -> TrackCurves:
return cls(
hop_seconds=float(data["hop_seconds"]),
energy=tuple(data["energy"]),
valence=tuple(data["valence"]),
drive=tuple(data["drive"]),
)
class Analyzer(Protocol):
"""Turns one audio file into cacheable analysis results.
Implementations are CPU-bound and run off the event loop.
"""
version: int
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]: ...
class NullAnalyzer:
"""Analyzes nothing. What :func:`build_analyzer` falls back to without librosa."""
version = 0
def analyze(
self,
path: Path, # noqa: ARG002
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
return TrackAnalysis(), None, None
def build_analyzer() -> Analyzer:
"""The real analyzer if its optional dependency group is installed, else a no-op.
``NullAnalyzer.version`` is ``0``, which is below :data:`ANALYZER_VERSION`, so
:meth:`~musicmouse.library.MusicLibrary.analyze_pending` returns immediately and the
background stays at its static baseline - not a crash, not a degraded mode, just the
feature switched off until ``pip install -e '.[analysis]'`` turns it on.
"""
try:
from musicmouse.library.librosa_analyzer import LibrosaAnalyzer
except ImportError:
_log.info(
"librosa is not installed: the reactive background is off. "
"Install the 'analysis' extra to enable it."
)
return NullAnalyzer()
return LibrosaAnalyzer()

View File

@@ -0,0 +1,362 @@
"""Where scan results live between runs.
A directory rather than a single file, because the three kinds of content cost wildly
different amounts to produce::
<cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely.
├── 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>.beats.json
analysis/<track_key>.curve.json
That split is the whole point. A rescan must be free to rebuild ``index.json`` without
destroying analysis, so everything expensive is keyed by a *content* key (see
:func:`~musicmouse.library.models.track_key`) rather than by album id - renaming a
folder or re-sorting a section then costs nothing.
"""
from __future__ import annotations
import io
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Final, cast
from PIL import Image, UnidentifiedImageError
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.models import Album, LibraryTrack
from musicmouse.library.sections import SECTIONS, AlbumKind
_log = logging.getLogger(__name__)
__all__ = ["Fingerprint", "LibraryCache"]
#: Bump whenever the scanner's *derivation* changes - how a title, artist or series is
#: 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
#: edits their music folder.
# 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.
#
# Bumping this is cheap by design - see the module docstring.
_INDEX_VERSION = 6
@dataclass(frozen=True, slots=True)
class Fingerprint:
"""What makes a folder's cached entry still valid: its files, sizes and mtimes."""
files: tuple[tuple[str, int, int], ...]
@classmethod
def of(cls, paths: list[Path]) -> Fingerprint:
entries: list[tuple[str, int, int]] = []
for path in paths:
stat = path.stat()
entries.append((path.name, stat.st_size, int(stat.st_mtime)))
return cls(tuple(entries))
def to_json(self) -> list[list[Any]]:
return [list(entry) for entry in self.files]
@classmethod
def from_json(cls, data: list[list[Any]]) -> Fingerprint:
return cls(tuple((str(n), int(s), int(m)) for n, s, m in data))
def _write_atomic(path: Path, payload: str) -> None:
"""Write via a sibling temp file so a crash never leaves a half-written cache."""
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
temp.write_text(payload, encoding="utf-8")
temp.replace(path)
class LibraryCache:
def __init__(self, directory: Path) -> None:
self.directory = directory
self.covers = directory / "covers"
self.analysis = directory / "analysis"
def prepare(self) -> None:
for folder in (self.directory, self.covers, self.analysis):
folder.mkdir(parents=True, exist_ok=True)
# -------------------------------------------------------------------- covers
def cover_path(self, album_id: str) -> Path:
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:
path = self.cover_path(album_id)
# 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
def _refresh_thumb(self, cover: Path) -> bool:
"""Make ``<id>.thumb.jpg`` exist and be newer than the cover it came from."""
thumb = cover.with_name(f"{cover.stem}.thumb.jpg")
try:
if thumb.exists() and thumb.stat().st_mtime_ns >= cover.stat().st_mtime_ns:
return False
thumb.write_bytes(
shrink_cover(cover.read_bytes(), max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
return False
return True
def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
by an older version would otherwise never be touched again. Reading a JPEG's
dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else.
Also backfills any missing (or stale) thumbnail. Returns the number of full-size
files actually rewritten.
"""
rewritten = 0
for path in sorted(self.covers.glob("*.jpg")):
if path.name.endswith(".thumb.jpg"):
continue
try:
with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError):
continue
if oversized:
try:
shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue
rewritten += 1
# Covers stored before thumbnails existed get theirs here, without a rescan.
self._refresh_thumb(path)
return rewritten
# ------------------------------------------------------------------ analysis
def load_analysis(self, key: str) -> TrackAnalysis | None:
path = self.analysis / f"{key}.json"
try:
return TrackAnalysis.from_json(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError):
return None
def store_analysis(self, key: str, analysis: TrackAnalysis) -> None:
_write_atomic(self.analysis / f"{key}.json", json.dumps(analysis.to_json()))
def load_beats(self, key: str) -> BeatGrid | None:
path = self.analysis / f"{key}.beats.json"
try:
return BeatGrid.from_json(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, KeyError):
return None
def store_beats(self, key: str, grid: BeatGrid) -> None:
_write_atomic(self.analysis / f"{key}.beats.json", json.dumps(grid.to_json()))
def load_curve(self, key: str) -> TrackCurves | None:
path = self.analysis / f"{key}.curve.json"
try:
return TrackCurves.from_json(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, KeyError):
return None
def store_curve(self, key: str, curve: TrackCurves) -> None:
_write_atomic(self.analysis / f"{key}.curve.json", json.dumps(curve.to_json()))
# --------------------------------------------------------------------- index
def load_index(self) -> dict[str, tuple[Album, Fingerprint]]:
path = self.directory / "index.json"
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
if raw.get("version") != _INDEX_VERSION:
_log.info("Library index is from an older version; rescanning from scratch")
return {}
out: dict[str, tuple[Album, Fingerprint]] = {}
for entry in raw.get("albums", []):
try:
out[entry["id"]] = (_album_from_json(entry), Fingerprint.from_json(entry["files"]))
except (KeyError, TypeError, ValueError):
_log.debug("Dropping unreadable index entry %r", entry.get("id"))
return out
def store_index(self, albums: dict[str, tuple[Album, Fingerprint]]) -> None:
payload = {
"version": _INDEX_VERSION,
"albums": [
_album_to_json(album) | {"files": fingerprint.to_json()}
for album, fingerprint in albums.values()
],
}
self.prepare()
_write_atomic(self.directory / "index.json", json.dumps(payload, ensure_ascii=False))
# ------------------------------------------------------------------ serialisation
def _album_to_json(album: Album) -> dict[str, Any]:
return {
"id": album.id,
"section": album.section,
"kind": album.kind,
"title": album.title,
"artist": album.artist,
"series": album.series,
"figure": album.figure,
"colors": list(album.colors),
"folder": str(album.folder),
"cover": str(album.cover) if album.cover else None,
"tracks": [
{
"path": str(track.path),
"title": track.title,
"duration": track.duration,
"analysis": track.analysis.to_json() if track.analysis else None,
}
for track in album.tracks
],
}
def _album_from_json(data: dict[str, Any]) -> Album:
section = str(data["section"])
if section not in SECTIONS:
raise ValueError(f"unknown section {section!r}")
# Read back what was stored rather than re-deriving it from the section: a figure's
# kind comes from the config, so the section cannot answer for it.
kind = str(data["kind"])
if kind not in ("music", "book"):
raise ValueError(f"unknown kind {kind!r}")
red, green, blue = data["colors"]
return Album(
id=str(data["id"]),
section=section,
kind=cast("AlbumKind", kind),
title=str(data["title"]),
artist=str(data["artist"]),
series=data["series"],
figure=data["figure"],
colors=(str(red), str(green), str(blue)),
folder=Path(data["folder"]),
cover=Path(data["cover"]) if data["cover"] else None,
tracks=tuple(
LibraryTrack(
path=Path(track["path"]),
title=str(track["title"]),
duration=float(track["duration"]),
analysis=(
TrackAnalysis.from_json(track["analysis"]) if track.get("analysis") else None
),
)
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 image:
if max(image.size) <= max_px:
return data
# JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats.
image.draft("RGB", (max_px, max_px))
image = image.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

@@ -0,0 +1,82 @@
"""Three colours per album, from its cover art.
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
happens once per album ever - the result is cached next to the cover.
"""
from __future__ import annotations
import colorsys
import hashlib
import logging
from io import BytesIO
from typing import cast
from musicmouse.library.models import AlbumColors
_log = logging.getLogger(__name__)
__all__ = ["colors_from_cover", "colors_from_id"]
#: Quantize to this many candidates before ranking. More just returns near-duplicates.
_PALETTE_SIZE = 8
#: Downscale first: colour proportions survive, the decode gets ~100x cheaper.
_SAMPLE_SIZE = (64, 64)
#: Greys and near-blacks read as mud on an RGBW strip and as dirt on a card, so a
#: candidate has to clear both bars to count as one of an album's colours.
_MIN_SATURATION = 0.25
_MIN_VALUE = 0.25
def _hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def colors_from_id(album_id: str) -> AlbumColors:
"""Synthesise a palette when there is no cover to take one from.
Analogous plus complementary off one hashed hue: distinct per album, never muddy.
"""
hue = int(hashlib.sha1(album_id.encode()).hexdigest()[:4], 16) % 360
out: list[str] = []
for offset, saturation, value in ((0, 0.72, 0.95), (30, 0.62, 0.80), (180, 0.70, 0.90)):
r, g, b = colorsys.hsv_to_rgb(((hue + offset) % 360) / 360, saturation, value)
out.append(_hex(int(r * 255), int(g * 255), int(b * 255)))
return out[0], out[1], out[2]
def colors_from_cover(image_data: bytes, album_id: str) -> AlbumColors:
"""Rank the cover's dominant colours, dropping the ones that would read as mud.
Falls back to :func:`colors_from_id` for the slots that do not fill - a black-and-
white cover legitimately has no three usable colours.
"""
try:
from PIL import Image
with Image.open(BytesIO(image_data)) as image:
sample = image.convert("RGB").resize(_SAMPLE_SIZE)
quantized = sample.quantize(colors=_PALETTE_SIZE, method=Image.Quantize.MAXCOVERAGE)
palette: list[int] = quantized.getpalette() or []
# On a quantized ("P" mode) image getcolors() yields (count, palette index).
# Pillow's annotation covers every mode, hence the cast.
counts = cast("list[tuple[int, int]]", quantized.getcolors() or [])
except Exception: # pragma: no cover - defensive around arbitrary embedded art
_log.debug("Cover of %s could not be read; using a synthesised palette", album_id)
return colors_from_id(album_id)
picked: list[str] = []
for _count, index in sorted(counts, reverse=True):
r, g, b = palette[index * 3 : index * 3 + 3]
_, saturation, value = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
if saturation < _MIN_SATURATION or value < _MIN_VALUE:
continue
picked.append(_hex(r, g, b))
if len(picked) == 3:
return picked[0], picked[1], picked[2]
fallback = colors_from_id(album_id)
filled = picked + list(fallback[len(picked) :])
return filled[0], filled[1], filled[2]

View File

@@ -0,0 +1,318 @@
"""A concrete :class:`~musicmouse.library.analysis.Analyzer` built on librosa.
Only reached through :func:`musicmouse.library.analysis.build_analyzer`, so nothing
else in the app ever imports librosa or numpy: a device without the ``analysis`` extra
installed never executes this module at all.
Every scalar here is a signal-processing proxy, not a measurement of how a track
actually feels - ``valence`` most of all, see its section below. Good enough to drive
an ambient background; not a music information retrieval research result.
"""
from __future__ import annotations
import logging
import math
import warnings
from collections.abc import Callable
from pathlib import Path
import librosa
import numpy as np
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
_log = logging.getLogger(__name__)
__all__ = ["LibrosaAnalyzer"]
#: librosa's own default. Ample for everything below - the highest band that matters,
#: the brightness ceiling, sits well under this rate's 11025 Hz Nyquist frequency.
_SAMPLE_RATE = 22050
_HOP_LENGTH = 512
#: `energy`: the 80th-percentile RMS frame, in dB, mapped floor..ceil to 0..1. -32 dB is
#: a quiet passage, -8 dB is a hot, compressed master. A percentile rather than the mean
#: or max so a quiet intro or a gap between phrases doesn't drag a loud track down, and
#: one clipped peak doesn't blow it out.
_ENERGY_DB_FLOOR = -32.0
_ENERGY_DB_CEIL = -8.0
#: `brightness`: the median spectral centroid (robust to a single loud transient), in
#: Hz, log-mapped floor..ceil to 0..1. 300 Hz is a dark, bass/vocal-heavy mix; 4000 Hz is
#: bright, sparkly production.
_BRIGHTNESS_HZ_FLOOR = 300.0
_BRIGHTNESS_HZ_CEIL = 4000.0
#: `valence`'s tempo term: 60 BPM reads as a lullaby, 150 BPM as a romp.
_TEMPO_BPM_FLOOR = 60.0
_TEMPO_BPM_CEIL = 150.0
#: Key is a global property of a track; the middle minute is its most representative
#: one and this keeps the most expensive feature (chroma) off the tail of a long track.
_CHROMA_EXCERPT_SECONDS = 120.0
#: Seconds per curve sample. Fixed at analysis time, not user-facing - changing this
#: needs a re-analysis and an ANALYZER_VERSION bump, unlike the frontend's own
#: unrelated "curve sample interval" debug slider, which just smooths already-fetched
#: samples for live preview.
_ANALYSIS_HOP_SECONDS = 1.0
#: `drive`'s blend of onset activity (steadier, measured 33-55% relative spread across
#: a track) and local pulse strength (more dynamic but spikier, 59-189%) - weighted
#: toward activity so a busy chorus reads clearly without the current twitching on
#: every transient.
_DRIVE_ACTIVITY_WEIGHT = 0.6
_DRIVE_PLP_WEIGHT = 0.4
#: Krumhansl-Kessler key profiles (Krumhansl & Kessler 1982), starting from C. Every
#: other key is scored by rotating these twelve weights, not by transposing the audio.
_MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
_MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
def _clip01(value: float) -> float:
return max(0.0, min(1.0, value))
def _normalize(value: float, floor: float, ceil: float) -> float:
"""Linear map ``floor..ceil`` to ``0..1``, clipped at both ends."""
return _clip01((value - floor) / (ceil - floor))
def _center_excerpt(y: np.ndarray, sr: float, seconds: float) -> np.ndarray:
max_samples = int(seconds * sr)
if y.size <= max_samples:
return y
start = (y.size - max_samples) // 2
return y[start : start + max_samples]
def _frames_per_window(sr: float, hop_length: int, window_seconds: float) -> int:
return max(1, round(window_seconds * sr / hop_length))
def _windowed(
values: np.ndarray, frames_per_window: int, reduce: Callable[[np.ndarray], float]
) -> np.ndarray:
"""Buckets an already-computed per-frame array (RMS, spectral centroid, onset
envelope, PLP - anything on the STFT hop grid) into `frames_per_window`-wide
windows, applying `reduce` to each. The last window is short rather than dropped,
so a track's tail is never silently excluded from its own curve."""
n = max(1, math.ceil(values.size / frames_per_window))
out = np.empty(n)
for i in range(n):
w = values[i * frames_per_window : (i + 1) * frames_per_window]
out[i] = reduce(w if w.size else values[-1:])
return out
def _energy_window(w: np.ndarray) -> float:
"""The same 80th-percentile+dB statistic as the whole-track `energy` scalar,
applied to one window."""
db = librosa.amplitude_to_db(np.array([np.percentile(w, 80)]), ref=1.0)[0]
return _normalize(float(db), _ENERGY_DB_FLOOR, _ENERGY_DB_CEIL)
def _brightness_window(w: np.ndarray) -> float:
"""The same median+log statistic as the whole-track `brightness` scalar, applied
to one window."""
hz = max(float(np.median(w)), 1.0) # guard log2(0)
floor, ceil = math.log2(_BRIGHTNESS_HZ_FLOOR), math.log2(_BRIGHTNESS_HZ_CEIL)
return _normalize(math.log2(hz), floor, ceil)
def _norm95(values: np.ndarray) -> np.ndarray:
"""Scale so the array's 95th percentile maps to 1.0 - a robust max that one loud
transient can't blow out, the same reasoning as the beat grid's strengths."""
ceiling = float(np.percentile(values, 95))
return values / ceiling if ceiling > 0 else np.zeros_like(values)
def _majorness(chroma_mean: np.ndarray) -> float:
"""Best major key-profile correlation minus best minor one, over all 12 rotations.
Positive means the track's pitch-class distribution fits a major key better than
any minor one; negative the other way round. `np.corrcoef` is undefined for a
perfectly flat chroma vector (silence, pure noise) - `nan_to_num` turns that into
"no signal either way" rather than raising.
"""
def best_fit(profile: np.ndarray) -> float:
return max(
float(np.nan_to_num(np.corrcoef(chroma_mean, np.roll(profile, i))[0, 1]))
for i in range(12)
)
return best_fit(_MAJOR_PROFILE) - best_fit(_MINOR_PROFILE)
class LibrosaAnalyzer:
"""Turns one music file into :class:`TrackAnalysis` plus an optional beat grid
and :class:`TrackCurves`."""
version = ANALYZER_VERSION
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Never raises: a file this can't make sense of is analyzed as "nothing".
A corrupt file, a DRM'd one, or a zero-length one must not abort a batch of
hundreds - the fallback records `version` so :meth:`MusicLibrary.analyze_pending`
does not retry it forever, while every scalar stays `None` so the frontend falls
back to the un-analyzed baseline look rather than something half-computed.
"""
try:
y, sr = librosa.load(path, sr=_SAMPLE_RATE, mono=True)
except Exception:
_log.warning("Could not decode %s; leaving it unanalyzed", path, exc_info=True)
return TrackAnalysis(version=self.version), None, None
if y.size == 0:
_log.warning("%s decoded to no audio; leaving it unanalyzed", path)
return TrackAnalysis(version=self.version), None, None
try:
return self._analyze(y, sr)
except Exception:
_log.warning("Analysis failed for %s; leaving it unanalyzed", path, exc_info=True)
return TrackAnalysis(version=self.version), None, None
def _analyze(
self, y: np.ndarray, sr: float
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
# One onset envelope feeds tempo, the beat grid's strengths, `pulse` and
# `drive` - the single most expensive shared computation, so it is done once.
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=_HOP_LENGTH)
tempo_raw, beat_frames = librosa.beat.beat_track(
onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH
)
tempo_bpm = float(np.atleast_1d(tempo_raw)[0])
grid = self._beat_grid(onset_env, beat_frames, sr)
frames_per_window = _frames_per_window(sr, _HOP_LENGTH, _ANALYSIS_HOP_SECONDS)
rms = librosa.feature.rms(y=y, hop_length=_HOP_LENGTH)[0]
energy_curve = _windowed(rms, frames_per_window, _energy_window)
energy = float(np.mean(energy_curve))
centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=_HOP_LENGTH)[0]
# Scalar `brightness` stays the whole-track median exactly as before this
# refactor - it no longer drives anything on the frontend (only its curve
# feeds `valence` below), so its own meaning is deliberately left unchanged.
brightness = _brightness_window(centroid)
brightness_curve = _windowed(centroid, frames_per_window, _brightness_window)
majorness_norm, tempo_norm = self._valence_terms(y, sr, tempo_bpm)
valence_curve = np.clip(
0.5 * majorness_norm + 0.3 * brightness_curve + 0.2 * tempo_norm, 0.0, 1.0
)
valence = float(np.mean(valence_curve))
pulse = self._pulse(onset_env, sr, tempo_bpm)
drive_curve = self._drive_curve(onset_env, sr, frames_per_window)
curves = TrackCurves(
hop_seconds=_ANALYSIS_HOP_SECONDS,
energy=tuple(float(v) for v in energy_curve),
valence=tuple(float(v) for v in valence_curve),
drive=tuple(float(v) for v in drive_curve),
)
analysis = TrackAnalysis(
version=self.version,
tempo=tempo_bpm,
energy=energy,
valence=valence,
brightness=brightness,
pulse=pulse,
beats=grid is not None,
)
return analysis, grid, curves
def _beat_grid(
self, onset_env: np.ndarray, beat_frames: np.ndarray, sr: float
) -> BeatGrid | None:
if beat_frames.size == 0:
return None
times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=_HOP_LENGTH)
raw_strengths = onset_env[np.clip(beat_frames, 0, onset_env.size - 1)]
# The 95th percentile rather than the max, so one loud crash does not flatten
# every other beat's strength toward zero.
scale = float(np.percentile(raw_strengths, 95))
strengths = raw_strengths / scale if scale > 0 else np.zeros_like(raw_strengths)
return BeatGrid(
tuple(float(t) for t in times),
tuple(_clip01(float(s)) for s in strengths),
)
def _valence_terms(self, y: np.ndarray, sr: float, tempo_bpm: float) -> tuple[float, float]:
"""majorness_norm, tempo_norm - the two whole-track-constant terms of the
valence formula. See the module docstring for why valence is a heuristic, not
a measurement.
Weighted for a *children's* library specifically: mode (major/minor) is the
strongest and most legible cue in this repertoire - a minor-key children's song
is almost always deliberately sad or spooky, unlike in pop where mode is a much
weaker signal. Tempo adds romp-vs-lullaby. Both stay whole-track constants:
key is a global property of a track, and (unlike brightness) chroma-based key
detection is too expensive and too noisy over a short window to be worth
computing per second for what is now only a secondary, "nudge" contribution to
color - see `TrackCurves.valence`.
"""
excerpt = _center_excerpt(y, sr, _CHROMA_EXCERPT_SECONDS)
chroma = librosa.feature.chroma_cqt(y=excerpt, sr=sr, hop_length=_HOP_LENGTH)
majorness = _majorness(chroma.mean(axis=1))
majorness_norm = _clip01((majorness + 1.0) / 2.0)
tempo_norm = _normalize(tempo_bpm, _TEMPO_BPM_FLOOR, _TEMPO_BPM_CEIL)
return majorness_norm, tempo_norm
def _drive_curve(self, onset_env: np.ndarray, sr: float, frames_per_window: int) -> np.ndarray:
"""Rhythmic intensity per window, 0..1, normalised *within the track*.
This - not tempo - is what the frontend's water current breathes with while a
track plays. Measured against a real library, local tempo is flat to within a
few percent inside a track (recorded children's music is played to a click);
the residual "variation" a per-second tempo curve would show is mostly
estimator noise, including occasional octave errors. Onset activity and local
pulse strength (PLP) both genuinely vary within a track (33-55% and 59-189%
relative spread respectively) and don't carry that failure mode.
Per-track normalisation (each component scaled by its own 95th percentile) is
deliberate: absolute "how energetic is this song" is already carried by
whole-track `tempo` (the current's base magnitude) and by `energy` (colour).
This curve is for relative shape within the track - the intro is calmer than
the chorus - so every track uses its own full 0..1 range rather than a
uniformly quiet song sitting flat near zero throughout.
"""
activity = _windowed(onset_env, frames_per_window, lambda w: float(np.mean(w)))
plp = librosa.beat.plp(onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH)
pulse_curve = _windowed(plp, frames_per_window, lambda w: float(np.mean(w)))
activity_term = _DRIVE_ACTIVITY_WEIGHT * _norm95(activity)
pulse_term = _DRIVE_PLP_WEIGHT * _norm95(pulse_curve)
return np.clip(activity_term + pulse_term, 0.0, 1.0)
def _pulse(self, onset_env: np.ndarray, sr: float, tempo_bpm: float) -> float:
"""0..1 confidence that `tempo` is an audible, steady beat.
The onset envelope's autocorrelation at the beat period, relative to its value
at lag zero: a track that truly pulses at `tempo` has a strong echo of itself
one beat later; free-tempo or spoken-word material does not, even though
`beat_track` always returns *some* grid for it.
"""
if tempo_bpm <= 0 or onset_env.size < 2:
return 0.0
period_frames = round((60.0 / tempo_bpm) * sr / _HOP_LENGTH)
if not 0 < period_frames < onset_env.size:
return 0.0
with warnings.catch_warnings():
# A known-spurious warning from numba's complex-magnitude dufunc under
# this numba/numpy/librosa combination (confirmed: the input here has no
# NaN/Inf, and the result is a normal finite float) - narrowly silenced
# rather than left to spam the log once per track analyzed.
warnings.filterwarnings(
"ignore", message="invalid value encountered in cast", category=RuntimeWarning
)
ac = librosa.autocorrelate(onset_env)
if ac[0] <= 0:
return 0.0
return _clip01(float(ac[period_frames] / ac[0]))

View File

@@ -0,0 +1,89 @@
"""What the browse API serves: albums of tracks, with the colours to draw them in."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import TypeAlias
from musicmouse.library.analysis import TrackAnalysis
from musicmouse.library.sections import AlbumKind
from musicmouse.media import Playlist, Track
__all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
#: parsing and the frontend gets CSS colours for free.
AlbumColors: TypeAlias = tuple[str, str, str]
def album_id(root: Path, folder: Path) -> str:
"""A stable id for an album folder, from its path relative to the library root."""
relative = folder.relative_to(root).as_posix()
return hashlib.sha1(relative.encode()).hexdigest()[:12]
def track_key(path: Path) -> str:
"""Content key for expensive per-track results.
Keyed on the *file*, not the album, so renaming a folder or re-sorting a section
never throws away analysis that took minutes to compute.
"""
stat = path.stat()
material = f"{path.name}:{stat.st_size}:{int(stat.st_mtime)}"
return hashlib.sha1(material.encode()).hexdigest()[:16]
@dataclass(frozen=True, slots=True)
class LibraryTrack:
path: Path
title: str
#: Seconds, from the tags. ``0.0`` when the file carries no duration.
duration: float = 0.0
analysis: TrackAnalysis | None = None
def __repr__(self) -> str:
return f"LibraryTrack({self.title!r}, {self.duration:.0f}s)"
@dataclass(frozen=True, slots=True)
class Album:
id: str
section: str
kind: AlbumKind
title: str
artist: str
#: Grouping key for audiobooks and podcasts. ``None`` for music, which groups by
#: artist instead - the two cases the browse UI's category view distinguishes.
series: str | None
#: Figure name when this folder sits under ``Figuren``, else ``None``.
figure: str | None
colors: AlbumColors
folder: Path
cover: Path | None
tracks: tuple[LibraryTrack, ...]
def __len__(self) -> int:
return len(self.tracks)
@property
def duration(self) -> float:
return sum(track.duration for track in self.tracks)
@property
def category(self) -> str:
"""How the browse view groups this album: series for books, artist for music."""
return self.series or self.artist
def to_playlist(self) -> Playlist:
"""The player's view of this album."""
return Playlist(
name=self.figure or self.title,
tracks=tuple(Track(track.path) for track in self.tracks),
album_id=self.id,
)
def __repr__(self) -> str:
return f"Album({self.title!r}, {self.artist!r}, {len(self.tracks)} tracks)"

View File

@@ -0,0 +1,615 @@
"""Auto-download new episodes for podcast shows that name their RSS feed.
A show folder opts in by containing a small marker file, ``feed.txt``, whose first
non-blank line is the feed's URL. That file is the only thing this module needs to find
a show - nothing here is turned on by config, matching how the rest of the library reads
its shape entirely off the folder tree (see :mod:`musicmouse.library.sections`).
Downloaded episodes land in the show folder using the exact ``YYYYMMDD - Title.ext``
convention ``sections.py`` already documents for ``Kinderpodcasts``, so a freshly
downloaded episode sorts and scans exactly like one a person dropped in by hand. When
a feed offers real per-episode artwork, it's saved alongside as a same-named sidecar
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
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
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import shutil
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Final
from urllib.parse import urlsplit
import feedparser
import httpx2
from musicmouse.library.sections import SECTIONS
_log = logging.getLogger(__name__)
__all__ = [
"DEFAULT_EPISODE_LIMIT",
"FEED_MARKER_NAME",
"AudioExtractionError",
"Episode",
"download_episode",
"download_episode_cover",
"episode_cover_filename",
"episode_filename",
"find_feed_shows",
"missing_episodes",
"newest_episodes",
"parse_feed",
"prune_show",
"resolve_episode_cover",
"sync_all_shows",
"sync_show",
]
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
#: not a real extension (tracking-redirect URLs are common in the wild).
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
"audio/mpeg": ".mp3",
"audio/mp3": ".mp3",
"audio/mp4": ".m4a",
"audio/x-m4a": ".m4a",
"audio/aac": ".aac",
"audio/ogg": ".ogg",
"audio/opus": ".opus",
"audio/wav": ".wav",
"audio/x-wav": ".wav",
}
_DEFAULT_EXTENSION: Final = ".mp3"
_KNOWN_EXTENSIONS: Final = frozenset({".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".flac"})
#: Cover URL suffix -> file extension, for the per-episode sidecar image.
_IMAGE_EXTENSIONS: Final[frozenset[str]] = frozenset({".jpg", ".jpeg", ".png", ".webp"})
_DEFAULT_IMAGE_EXTENSION: Final = ".jpg"
#: Characters illegal (or awkward) in a filename, plus the path separators themselves.
#: Replaced with "_" rather than dropped, matching the convention every episode
#: already downloaded by hand (via the ``podcast-dl`` CLI) was named with - a
#: mismatch here would make every one of them look "new" to `missing_episodes`.
_ILLEGAL_FILENAME_CHARS: Final = re.compile(r'[\\/:*?"<>|]')
_MAX_TITLE_LENGTH: Final = 120
_HTTP_TIMEOUT: Final = 30.0
#: `og:image` scraping is a fallback for a page we don't control; kept short so one
#: slow or hanging host can't stall a whole sync pass.
_OG_IMAGE_TIMEOUT: Final = 15.0
_OG_IMAGE_RE: Final = re.compile(
r'<meta[^>]+property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']'
r'|<meta[^>]+content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']',
re.IGNORECASE,
)
#: How long a video enclosure is given to download-and-transcode before it's treated
#: as failed - generous, since this runs on a Pi and a long video can take a while.
_FFMPEG_TIMEOUT: Final = 600.0
#: 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.
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
#: 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
#: transient failure (a host outage, a flaky network) still recovers on its own.
_RETRY_BACKOFF: Final = timedelta(days=7)
class AudioExtractionError(Exception):
"""Raised when a video enclosure could not be turned into an audio file."""
@dataclass(frozen=True, slots=True)
class Episode:
title: str
published: datetime
enclosure_url: str
enclosure_type: str
#: The feed's own per-episode artwork, when it has one distinct from the show's
#: overall cover. ``None`` when the feed has no image at all, or (as with GEOlino)
#: every item merely repeats the channel's own image.
cover_url: str | None = None
#: The episode's own page, when the feed links to one distinct from the show's
#: general page - the fallback route to real per-episode art for a feed (like
#: Wissen macht Ah) that has no per-item image of its own, via that page's
#: `og:image`. ``None`` when the feed has no such per-episode page.
link: str | None = None
def find_feed_shows(root: Path) -> list[tuple[str, Path, str]]:
"""Every show folder with a feed marker, as ``(section_name, folder, feed_url)``.
Restricted to sections whose ``album_unit`` is ``"episode"`` (today just
``Kinderpodcasts``) - that is what marks a folder as a show rather than a single
release, the same distinction the scanner already makes.
"""
out: list[tuple[str, Path, str]] = []
for section_name, section in SECTIONS.items():
if section.album_unit != "episode":
continue
section_root = root / section_name
if not section_root.is_dir():
continue
for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
if not folder.is_dir() or folder.name.startswith("."):
continue
marker = folder / FEED_MARKER_NAME
if not marker.is_file():
continue
feed_url = _read_feed_url(marker)
if feed_url is not None:
out.append((section_name, folder, feed_url))
return out
def _read_feed_url(marker: Path) -> str | None:
for line in marker.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped:
return stripped
_log.warning("%s is empty; no feed URL to read", marker)
return None
def parse_feed(content: bytes) -> list[Episode]:
"""Every episode in a feed that has both a publish date and a playable enclosure.
Anything else is skipped and logged rather than raising - a malformed or unusual
entry in one feed must never stop the episodes around it from being picked up.
"""
parsed = feedparser.parse(content)
# feedparser exposes an `itunes:image` identically at the channel and item level,
# under the same "image" key an ordinary RSS `<image>` uses - there is no separate
# `itunes_image` field. Read the channel's own image/link once so each entry can
# tell whether it has something genuinely its own, or is just repeating them
# (as GEOlino's per-item `itunes:image` does).
channel_image = parsed.feed.get("image", {}).get("href")
channel_link = parsed.feed.get("link")
episodes: list[Episode] = []
for entry in parsed.entries:
published = _entry_published(entry)
if published is None:
_log.debug("Feed entry %r has no publish date; skipping", entry.get("title"))
continue
enclosure = _entry_enclosure(entry)
if enclosure is None:
_log.debug(
"Feed entry %r has no audio or video enclosure; skipping", entry.get("title")
)
continue
url, enclosure_type = enclosure
item_image = entry.get("image", {}).get("href")
cover_url = item_image if item_image and item_image != channel_image else None
item_link = entry.get("link")
link = item_link if item_link and item_link != channel_link else None
episodes.append(
Episode(
title=entry.get("title") or url,
published=published,
enclosure_url=url,
enclosure_type=enclosure_type,
cover_url=cover_url,
link=link,
)
)
return episodes
def _entry_published(entry: Any) -> datetime | None:
parsed_time = entry.get("published_parsed") or entry.get("updated_parsed")
if parsed_time is None:
return None
year, month, day, hour, minute, second = tuple(parsed_time)[:6]
return datetime(year, month, day, hour, minute, second, tzinfo=UTC)
def _entry_enclosure(entry: Any) -> tuple[str, str] | None:
"""The enclosure to download for one entry, preferring audio when both are offered.
A video enclosure (some shows, like Wissen macht Ah, publish no audio version at
all) is still returned rather than dropped - :func:`download_episode` turns it
into audio via ffmpeg. It's just the least preferred of the three: an audio
enclosure, or one with no declared type at all (assumed audio), both win outright.
"""
video: tuple[str, str] | None = None
for enclosure in entry.get("enclosures", []):
url = enclosure.get("href") or enclosure.get("url")
enclosure_type = enclosure.get("type") or ""
if not url:
continue
if enclosure_type.startswith("audio/") or not enclosure_type:
return str(url), str(enclosure_type)
if video is None and enclosure_type.startswith("video/"):
video = (str(url), str(enclosure_type))
return video
def _episode_stem(published: datetime, title: str) -> str:
"""``YYYYMMDD - Title``, with no extension yet - shared by the audio filename and
its sidecar cover image, so the two always line up."""
sanitized = _ILLEGAL_FILENAME_CHARS.sub("_", title).strip().strip(".")
sanitized = " ".join(sanitized.split())[:_MAX_TITLE_LENGTH] or "Episode"
return f"{published:%Y%m%d} - {sanitized}"
def episode_filename(
published: datetime, title: str, enclosure_type: str, enclosure_url: str
) -> str:
"""``YYYYMMDD - Title.ext``, matching the convention every hand-placed episode
already follows (see ``sections.py``'s module comment on ``Kinderpodcasts``)."""
return f"{_episode_stem(published, title)}{_extension_for(enclosure_type, enclosure_url)}"
def episode_cover_filename(published: datetime, title: str, cover_url: str) -> str:
"""The sidecar image filename for an episode's cover, sharing its stem so the
scanner (``scanner.py``'s ``_cover_for_episode``) can find it next to the audio."""
return f"{_episode_stem(published, title)}{_image_extension_for(cover_url)}"
def _extension_for(enclosure_type: str, url: str) -> str:
suffix = Path(urlsplit(url).path).suffix.lower()
if suffix in _KNOWN_EXTENSIONS:
return suffix
return _EXTENSION_BY_TYPE.get(enclosure_type, _DEFAULT_EXTENSION)
def _image_extension_for(url: str) -> str:
suffix = Path(urlsplit(url).path).suffix.lower()
return suffix if suffix in _IMAGE_EXTENSIONS else _DEFAULT_IMAGE_EXTENSION
def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episode, str]]:
"""Episodes whose target filename isn't already on disk, paired with that filename.
Dedup is purely by filename - no separate manifest of what has been downloaded
before, matching how the rest of the library already treats the filesystem as the
only source of truth (see the fingerprinting in ``cache.py``).
"""
out: list[tuple[Episode, str]] = []
for episode in episodes:
filename = episode_filename(
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
)
if not (folder / filename).exists():
out.append((episode, filename))
return out
def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
"""The ``keep`` most recently published episodes, newest first.
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_downloads(folder: Path) -> dict[str, datetime]:
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
leave alone until :data:`_RETRY_BACKOFF` has passed.
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
skipped rather than fatal.
"""
path = folder / _FAILED_DOWNLOADS_FILENAME
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except (OSError, ValueError) as exc:
_log.debug("Could not read %s; treating as empty: %s", path, exc)
return {}
try:
return {str(filename): datetime.fromisoformat(when) for filename, when in raw.items()}
except (AttributeError, TypeError, ValueError) as exc:
_log.debug("Could not parse %s; treating as empty: %s", path, exc)
return {}
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None:
path = folder / _FAILED_DOWNLOADS_FILENAME
if not failed:
path.unlink(missing_ok=True)
return
temp_path = folder / f".{_FAILED_DOWNLOADS_FILENAME}.tmp"
payload = {filename: when.isoformat() for filename, when in failed.items()}
temp_path.write_text(json.dumps(payload), encoding="utf-8")
temp_path.replace(path)
async def download_episode(
client: httpx2.AsyncClient, folder: Path, episode: Episode, filename: str
) -> None:
"""Get an episode to ``folder / filename`` via a dotfile temp path.
A half-written file must never look like a track: the scanner already skips
dotfiles for exactly this reason (see ``scanner.py``'s ``_audio_files``), so the
rename to the real name only happens once the download is complete. A video
enclosure is routed through ffmpeg instead of being streamed as-is - see
:func:`_extract_audio`.
"""
temp_path = folder / f".downloading-{filename}.tmp"
try:
if episode.enclosure_type.startswith("video/"):
await _extract_audio(episode.enclosure_url, temp_path)
else:
async with client.stream(
"GET", episode.enclosure_url, timeout=_HTTP_TIMEOUT
) as response:
response.raise_for_status()
with temp_path.open("wb") as handle:
async for chunk in response.aiter_bytes():
handle.write(chunk)
temp_path.replace(folder / filename)
finally:
temp_path.unlink(missing_ok=True)
async def _extract_audio(source_url: str, dest: Path) -> None:
"""Pull ``source_url`` (a video enclosure) through ffmpeg, writing just its audio
track to ``dest``. ffmpeg fetches the URL itself, so the video is never stored.
Raises :class:`AudioExtractionError` rather than a bare ``OSError`` or timeout, so
callers can tell a missing/failing ffmpeg apart from an ordinary network error -
but either way this is meant to be logged and skipped, not fatal.
"""
if shutil.which("ffmpeg") is None:
raise AudioExtractionError(
"ffmpeg is not installed; cannot extract audio from a video enclosure"
)
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
source_url,
"-vn",
"-acodec",
"libmp3lame",
"-q:a",
"2",
"-f",
"mp3",
str(dest),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
_, stderr = await asyncio.wait_for(process.communicate(), timeout=_FFMPEG_TIMEOUT)
except TimeoutError:
process.kill()
await process.wait()
raise AudioExtractionError(f"ffmpeg timed out extracting audio from {source_url}") from None
if process.returncode != 0:
raise AudioExtractionError(
f"ffmpeg exited {process.returncode} extracting audio from {source_url}: "
f"{stderr.decode(errors='replace')[-500:]}"
)
async def download_episode_cover(
client: httpx2.AsyncClient, folder: Path, cover_url: str, filename: str
) -> None:
"""Best-effort sidecar cover image fetch, atomic like :func:`download_episode`.
Cover art is small enough that streaming it in chunks isn't worth the extra code.
"""
temp_path = folder / f".downloading-{filename}.tmp"
try:
response = await client.get(cover_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
response.raise_for_status()
temp_path.write_bytes(response.content)
temp_path.replace(folder / filename)
finally:
temp_path.unlink(missing_ok=True)
def _extract_og_image(html: str) -> str | None:
match = _OG_IMAGE_RE.search(html)
if match is None:
return None
return match.group(1) or match.group(2)
async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) -> str | None:
"""The best cover URL available for ``episode``, or ``None`` when there isn't one.
Prefers the feed's own per-episode image. Failing that, when the feed links to a
page of the episode's own (as Wissen macht Ah does, despite having no per-item
image tag), falls back to that page's ``og:image`` - one targeted fetch of a URL
the feed itself provided, not a scrape of a list page. Never raises: an
unreachable page, a redirect loop, or a page with no such tag all just mean no
cover was found here.
"""
if episode.cover_url:
return episode.cover_url
if not episode.link:
return None
try:
response = await client.get(episode.link, timeout=_OG_IMAGE_TIMEOUT, follow_redirects=True)
response.raise_for_status()
except httpx2.HTTPError as exc:
_log.debug("Could not fetch %s for its og:image: %s", episode.link, exc)
return None
return _extract_og_image(response.text)
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 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:
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
response.raise_for_status()
except httpx2.HTTPError as exc:
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
return False
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
failed = _load_failed_downloads(folder)
now = datetime.now(UTC)
changed = False
for episode, filename in pending:
last_failure = failed.get(filename)
if last_failure is not None and now - last_failure < _RETRY_BACKOFF:
continue
try:
await download_episode(client, folder, episode, filename)
except (httpx2.HTTPError, OSError, AudioExtractionError) as exc:
_log.warning(
"Could not download episode %r for %s: %s", episode.title, folder.name, exc
)
failed[filename] = now
continue
failed.pop(filename, None)
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
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}
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
_save_failed_downloads(folder, failed)
# 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
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."""
changed = False
for _section_name, folder, feed_url in find_feed_shows(root):
if await sync_show(client, folder, feed_url, keep=keep):
changed = True
return changed

View File

@@ -0,0 +1,378 @@
"""Turning folders on disk into :class:`~musicmouse.library.models.Album` objects.
One level under each section folder, every directory holding audio files is one album.
Tags come from mutagen; where a section's tags are known to be useless the folder name
wins instead (see :mod:`musicmouse.library.sections`). Nothing here raises on bad
input - an unreadable file loses its metadata, not the boot.
"""
from __future__ import annotations
import logging
import time
from collections import Counter
from collections.abc import Iterator, Mapping
from pathlib import Path
from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.colors import colors_from_cover, colors_from_id
from musicmouse.library.models import Album, LibraryTrack, album_id
from musicmouse.library.sections import SECTIONS, AlbumKind, Section
_log = logging.getLogger(__name__)
__all__ = ["scan_library"]
#: Checked in order for a cover sitting next to the audio.
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
#: Extensions tried for an episode's own same-stem sidecar cover image (see
#: :func:`_cover_for_episode`), matching what `podcast_feeds.py`'s
#: ``episode_cover_filename`` can produce.
_SIDECAR_COVER_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp")
#: How often a long scan reports where it is, so a library of thousands of albums
#: doesn't sit silent for minutes with nothing on the console to say it is still going.
_PROGRESS_INTERVAL_SECONDS = 5.0
def _album_folders(folder: Path, extensions: frozenset[str]) -> Iterator[Path]:
"""Every leaf album folder under ``folder``, however deep it is nested.
A folder that holds audio files directly *is* an album - an artist who groups
their books under an extra "ab 3" / "ab 5" age-range folder, or a series folder
that groups its episodes one directory further down than usual, still bottoms out
here without needing to be special-cased. Only descended into when a folder holds
no audio of its own, so an ordinary album folder is never mistaken for a grouping
folder just because it also happens to contain subfolders.
"""
if _audio_files(folder, extensions):
yield folder
return
for sub in sorted(folder.iterdir(), key=lambda path: path.name):
if sub.is_dir() and not sub.name.startswith("."):
yield from _album_folders(sub, extensions)
def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]:
"""The playable files in ``folder``, alphabetically.
Dotfiles are skipped outright and everything else must match ``audio_extensions``,
which is what keeps a podcast folder's ``archive.json`` and its half-finished
``.podcast-dl-*.download.tmp`` out of the playlist.
"""
return sorted(
(
path
for path in folder.iterdir()
if path.is_file()
and not path.name.startswith(".")
and path.suffix.lower() in extensions
),
key=lambda path: path.name,
)
def _tags(path: Path) -> tuple[dict[str, str], float]:
"""``(tags, duration)`` for one file. Empty and ``0.0`` when it cannot be read."""
try:
import mutagen
audio = mutagen.File(path, easy=True)
if audio is None:
return {}, 0.0
tags = {key: values[0] for key, values in dict(audio).items() if values}
return tags, float(getattr(audio.info, "length", 0.0) or 0.0)
except Exception: # pragma: no cover - mutagen raises freely on damaged files
_log.debug("No readable tags in %s", path)
return {}, 0.0
def _embedded_art(path: Path) -> bytes | None:
try:
import mutagen
raw = mutagen.File(path)
if raw is None or raw.tags is None:
return None
for key in raw.tags:
if key.startswith("APIC"):
data: bytes = raw.tags[key].data
return data
except Exception: # pragma: no cover - defensive
_log.debug("No readable embedded art in %s", path)
return None
def _split_folder_name(name: str) -> tuple[str, str]:
"""``"Conni - Conni in den Bergen"`` -> ``("Conni", "Conni in den Bergen")``."""
artist, separator, title = name.partition(" - ")
return (artist, title) if separator else ("", name)
def _most_common(values: list[str]) -> str:
"""The tag value most files in an album agree on, ignoring blanks."""
counted = Counter(value for value in values if value)
return counted.most_common(1)[0][0] if counted else ""
def _cover_for(
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
) -> 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:
candidate = folder / name
if candidate.is_file():
art = candidate.read_bytes()
return cache.store_cover(identifier, art), art
for path in paths[:3]:
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
return None, None
def _cover_for_episode(
folder: Path, path: Path, identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]:
"""The reverse priority from :func:`_cover_for`: with one album per episode, art
specific to this one episode wins over the folder's shared cover. A same-stem
sidecar image (what ``podcast_feeds.py`` saves when a feed has real per-episode
art) is tried first - it's a plain file stat, cheaper than reading tags - then the
episode's own embedded art, then the folder's shared cover as the last resort for
an episode with neither."""
for extension in _SIDECAR_COVER_EXTENSIONS:
candidate = path.with_suffix(extension)
if candidate.is_file():
art = candidate.read_bytes()
return cache.store_cover(identifier, art), art
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
shared = candidate.read_bytes()
return cache.store_cover(identifier, shared), shared
return None, None
def scan_album(
folder: Path,
*,
root: Path,
section_name: str,
section: Section,
extensions: frozenset[str],
cache: LibraryCache,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> tuple[Album, Fingerprint] | None:
paths = _audio_files(folder, extensions)
if not paths:
_log.debug("No audio files in %s", folder)
return None
if section.order == "newest_first":
paths.reverse()
fingerprint = Fingerprint.of(paths)
identifier = album_id(root, folder)
tracks: list[LibraryTrack] = []
albums: list[str] = []
artists: list[str] = []
for path in paths:
tags, duration = _tags(path)
tracks.append(
LibraryTrack(path=path, title=tags.get("title") or path.stem, duration=duration)
)
albums.append(tags.get("album", ""))
artists.append(tags.get("albumartist") or tags.get("artist", ""))
figure = folder.name if section.figures else None
# Every other shelf is named after what is on it. A figure folder is named after the
# figurine, so its media type has to be declared in the config.
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
folder_artist, folder_title = _split_folder_name(folder.name)
if section.title_from == "folder":
# A figure folder is named in lowercase ("fuchs"); it sits next to real album
# titles in the browse grid, so give it a capital.
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
else:
title = _most_common(albums) or folder_title or folder.name
if section.artist_from == "folder":
artist = folder.name
else:
artist = _most_common(artists) or folder_artist
cover, art = _cover_for(folder, paths, identifier, cache)
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
album = Album(
id=identifier,
section=section_name,
kind=kind,
title=title,
artist=artist,
# Books group by who or what they are about; music groups by artist. The browse
# view's category row is built straight off this, so it takes only the part
# before the first comma - an ``album_artist`` of "Bobo Siebenschlaefer, Markus
# Osterwalder, ..." is a credit list whose first name is the character.
series=artist.split(",")[0].strip() if kind == "book" else None,
figure=figure,
colors=colors,
folder=folder,
cover=cover,
tracks=tuple(tracks),
)
return album, fingerprint
def scan_episodes(
folder: Path,
*,
root: Path,
section_name: str,
section: Section,
extensions: frozenset[str],
cache: LibraryCache,
known: dict[str, tuple[Album, Fingerprint]],
) -> dict[str, tuple[Album, Fingerprint]]:
"""One album per audio file, for a section whose folder is a show rather than a
single release - a podcast feed's hundreds of episodes, most obviously.
Fingerprinted per file rather than per folder, so a new episode landing in an
already-scanned show only costs scanning that one file, not the whole show.
"""
paths = _audio_files(folder, extensions)
if section.order == "newest_first":
paths.reverse()
out: dict[str, tuple[Album, Fingerprint]] = {}
for path in paths:
identifier = album_id(root, path)
fingerprint = Fingerprint.of([path])
cached = known.get(identifier)
if cached is not None and cached[1] == fingerprint:
out[identifier] = cached
continue
tags, duration = _tags(path)
title = tags.get("title") or path.stem
cover, art = _cover_for_episode(folder, path, identifier, cache)
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
album = Album(
id=identifier,
section=section_name,
kind=section.kind,
title=title,
artist=folder.name,
# The folder is the show; every episode in it groups under the same series,
# exactly like an audiobook's chapters group under its book.
series=folder.name,
figure=None,
colors=colors,
folder=folder,
cover=cover,
tracks=(LibraryTrack(path=path, title=title, duration=duration),),
)
out[identifier] = (album, fingerprint)
return out
def scan_library(
root: Path,
extensions: frozenset[str],
cache: LibraryCache,
*,
known: dict[str, tuple[Album, Fingerprint]] | None = None,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> dict[str, tuple[Album, Fingerprint]]:
"""Scan every section under ``root``.
``known`` is the previously cached index: a folder whose files, sizes and mtimes are
unchanged is taken from it without a single tag being read. ``figure_kinds`` maps a
figure name to what it holds, which is the one thing the folders cannot say.
"""
cache.prepare()
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
# and the reuse path below means an unchanged album never rewrites its own. One
# pass here catches them; after the first run every file is already small and this
# is 300-odd header reads.
shrunk = cache.shrink_stored_covers()
if shrunk:
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
known = known or {}
out: dict[str, tuple[Album, Fingerprint]] = {}
last_report = time.monotonic()
for section_name, section in SECTIONS.items():
section_root = root / section_name
if not section_root.is_dir():
_log.warning("Library section %r has no folder at %s", section_name, section_root)
continue
for top in sorted(section_root.iterdir(), key=lambda path: path.name):
if not top.is_dir() or top.name.startswith("."):
continue
if section.album_unit == "episode":
# Fingerprinted per episode inside scan_episodes; the whole-folder
# shortcut below does not apply since one folder yields many albums,
# and an episode show is never nested deeper than this either.
out.update(
scan_episodes(
top,
root=root,
section_name=section_name,
section=section,
extensions=extensions,
cache=cache,
known=known,
)
)
continue
for folder in _album_folders(top, extensions):
now = time.monotonic()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
_log.info(
"Scanning library: %d albums found so far, now in %s",
len(out),
folder.relative_to(root),
)
last_report = now
identifier = album_id(root, folder)
cached = known.get(identifier)
if cached is not None:
paths = _audio_files(folder, extensions)
if paths and Fingerprint.of(paths) == cached[1]:
out[identifier] = cached
continue
scanned = scan_album(
folder,
root=root,
section_name=section_name,
section=section,
extensions=extensions,
cache=cache,
figure_kinds=figure_kinds,
)
if scanned is not None:
out[identifier] = scanned
_log.info("Library: %d albums under %s", len(out), root)
return out

View File

@@ -0,0 +1,52 @@
"""The four shelves of the music library, and how each one differs.
These are folder names, not configuration. The library has one root and the layout
underneath it is fixed - a section that needed configuring would be a section whose
quirks are not understood yet.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final, Literal, TypeAlias
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
AlbumKind: TypeAlias = Literal["music", "book"]
TrackOrder: TypeAlias = Literal["filename", "newest_first"]
TitleSource: TypeAlias = Literal["tags", "folder"]
ArtistSource: TypeAlias = Literal["tags", "folder"]
AlbumUnit: TypeAlias = Literal["folder", "episode"]
@dataclass(frozen=True, slots=True)
class Section:
kind: AlbumKind = "music"
#: Subfolders are figure folders: their name is the figure name from the config.
figures: bool = False
order: TrackOrder = "filename"
title_from: TitleSource = "tags"
artist_from: ArtistSource = "tags"
#: "folder" (default): one album per folder, every audio file a track/chapter of it.
#: "episode": one album per audio *file* - a show folder groups its episodes rather
#: than being one giant album itself. `title_from`/`artist_from` are not consulted
#: for this unit: an episode's title always comes from its own tags (or filename),
#: and the folder always supplies the artist/series, so episodes of the same show
#: still group together everywhere the browse view groups by category.
album_unit: AlbumUnit = "folder"
#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full
#: presenter list ("Thomas Welling, Sarah Schultes, ...") and its ``album`` tag is the
#: feed name, so neither groups usefully - the folder name is the show. And its files
#: are named ``YYYYMMDD - Title.mp3``, so reversing filename order puts the newest
#: episode first, which is the one anybody wants.
SECTIONS: Final[dict[str, Section]] = {
# A figure folder is named after the figure, and its contents are whatever that
# figure should play - often several albums' worth. The folder name is the honest
# title; the tags still supply a useful artist.
"Figuren": Section(figures=True, title_from="folder"),
"Musik": Section(),
"Hörbücher": Section(kind="book"),
"Kinderpodcasts": Section(kind="book", order="newest_first", album_unit="episode"),
}

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

@@ -0,0 +1,46 @@
"""Playlist model.
Deliberately a real type rather than a bare ``list[str]``: it is what the browse API
serves, and it keeps track metadata in one place. Building one from a folder is
:mod:`musicmouse.library`'s job.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
__all__ = ["Playlist", "Track"]
@dataclass(frozen=True, slots=True)
class Track:
path: Path
@property
def title(self) -> str:
return self.path.stem
def __repr__(self) -> str:
return f"Track({self.title!r})"
@dataclass(frozen=True, slots=True)
class Playlist:
name: str
tracks: tuple[Track, ...]
#: Set when this playlist came from a library album. It is how a front-end answers
#: "what is playing?" without keeping its own copy of that state.
album_id: str | None = None
def __len__(self) -> int:
return len(self.tracks)
def __bool__(self) -> bool:
return bool(self.tracks)
def __getitem__(self, index: int) -> Track:
return self.tracks[index]
def __repr__(self) -> str:
return f"Playlist({self.name!r}, {len(self.tracks)} tracks)"

View File

@@ -0,0 +1,21 @@
"""Reactions: the policy layer.
Every behaviour the mouse has lives here as a small function registered against an
event. Nothing else in the codebase decides what should happen - the devices only
report and obey, and the services only translate.
Importing this package is what makes the reactions exist; ``register_all`` binds them
to a bus and an :class:`~musicmouse.app.App`.
Publishing button presses to Home Assistant has no reaction of its own: the MQTT
service subscribes to those events directly.
"""
from musicmouse.reactions import ( # noqa: F401 (import = register)
lighting,
playback,
status,
)
from musicmouse.reactions.registry import Reaction, on, register_all, registered
__all__ = ["Reaction", "on", "register_all", "registered"]

View File

@@ -0,0 +1,174 @@
"""What the LEDs do.
These write straight to the device rather than going through intents: unlike
play/pause, nothing else in the system asks for "the figure's start animation".
Figure animations drive the shelf strip as well as the ring, which means they compete
with Home Assistant for it. That is intentional - the device is the single writer and
the most recent effect wins, whichever side it came from.
"""
from __future__ import annotations
import logging
from copy import deepcopy
from musicmouse.app import App
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.config import FigureColors
from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectStaticConfig,
EffectSwipeAndChange,
)
from musicmouse.events import (
ActiveFigureChanged,
PlaybackChanged,
PlaylistFinished,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__)
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
#: The mouse strip starts 6 LEDs into its 45, so its swipe is offset to match the ring.
MOUSE_SWIPE_START_DEGREES = 6 / 45 * 360
MOUSE_BELL_CURVE_WIDTH = 16
SWIPE_SPEED = 180
#: The web front-end's animation: a three-quarter arc drifting round twice a minute, in
#: the album's own primary colour. Deliberately unlike the figure swipe - the strips
#: should say *which* way the mouse was started, not just that it was.
WEB_CIRCLE_WIDTH = 270.0 # degrees
WEB_CIRCLE_SPEED = 12.0 # degrees per second
@on(ActiveFigureChanged)
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
if event.figure is None:
off_animation(app)
else:
start_animation(app, app.colors(event.figure))
app.mouse.set_button_brightness(
app.config.general.button_leds_brightness, origin="device"
)
@on(PlaybackChanged)
def web_playback(event: PlaybackChanged, app: App) -> None:
"""Light the strips for playback that no figure started.
Reacting to ``PlaybackChanged`` rather than to the play intent covers pause, stop
and playlist-end in one place. The guard is what keeps this off the figure path:
while a figure is on the reader its animation owns all three zones.
"""
if app.mouse.active_figure is not None:
return
if not event.playing:
off_animation(app)
return
album = app.album_for(event.playlist)
if album is None:
return
web_animation(app, parse_color(album.colors[0]))
@on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
off_animation(app)
@on(TouchButtonPressed)
def touch_pressed(event: TouchButtonPressed, app: App) -> None:
colors = _active_colors(app)
if colors is None:
return
app.mouse.set_effect(
LedZone.MOUSE, _range_effect(event.button, colors.accent), origin="device"
)
@on(TouchButtonReleased)
def touch_released(event: TouchButtonReleased, app: App) -> None:
colors = _active_colors(app)
# Clear the touched area first, then restore the whole-body effect over it.
app.mouse.set_effect(
LedZone.MOUSE,
_range_effect(event.button, colors.primary if colors else OFF_COLOR),
origin="device",
)
if colors is None:
return
app.mouse.set_effect(
LedZone.MOUSE,
EffectRandomTwoColorInterpolationConfig(
color1=colors.primary, color2=colors.secondary, start_with_existing=True
),
origin="device",
)
# ------------------------------------------------------------------- animations
def start_animation(app: App, colors: FigureColors) -> None:
ring = EffectSwipeAndChange()
ring.swipe.primary_color = colors.primary
ring.swipe.secondary_color = colors.secondary
ring.swipe.swipe_speed = SWIPE_SPEED
ring.change.color1 = colors.primary
ring.change.color2 = colors.secondary
app.mouse.set_effect(LedZone.RING, ring, origin="device")
app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device")
mouse = deepcopy(ring)
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
def web_animation(app: App, color: ColorRGBW) -> None:
for zone in LedZone:
app.mouse.set_effect(
zone,
EffectCircularConfig(speed=WEB_CIRCLE_SPEED, width=WEB_CIRCLE_WIDTH, color=color),
origin="device",
)
app.mouse.set_button_brightness(app.config.general.button_leds_brightness, origin="device")
def off_animation(app: App) -> None:
_log.info("Running off animation")
app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device")
app.mouse.set_effect(LedZone.SHELF, EffectReverseSwipe(), origin="device")
app.mouse.set_effect(
LedZone.MOUSE,
EffectReverseSwipe(start_position=MOUSE_SWIPE_START_DEGREES),
origin="device",
)
app.mouse.set_button_brightness(0.0, origin="device")
# --------------------------------------------------------------------- helpers
def _active_colors(app: App) -> FigureColors | None:
"""The current figure's colours, or ``None`` if nothing is playing."""
figure = app.mouse.active_figure
if figure is None or not app.player.is_playing:
return None
return app.colors(figure)
def _range_effect(button: TouchButton, color: ColorRGBW) -> EffectStaticConfig:
begin, end = MOUSE_LED_RANGES[button]
return EffectStaticConfig(color, begin, end)

View File

@@ -0,0 +1,174 @@
"""What the mouse plays, and when.
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
the same intent and lands in the same handler, so there is one place per behaviour.
"""
from __future__ import annotations
import logging
from musicmouse.app import App
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayFigureRequested,
PlaylistFinished,
PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested,
RotaryTurned,
SeekRequested,
SetVolumeRequested,
VolumeChangeRequested,
)
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
from musicmouse.library.models import Album
from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__)
# ------------------------------------------------------------------ figure on/off
@on(ActiveFigureChanged)
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
if event.figure is None:
_figure_removed(event.previous, app)
else:
app.bus.emit(
PlayFigureRequested(
figure=event.figure,
restart=app.state.last_partially_played_figure != event.figure,
source="device",
)
)
def _figure_removed(previous: str | None, app: App) -> None:
if app.player.is_playing:
app.player.pause()
# Remember where we were, so putting the same figure back resumes.
app.state.last_partially_played_figure = previous
_log.info("Figure %r removed mid-playlist", previous)
else:
app.state.last_partially_played_figure = None
@on(PlayFigureRequested)
def play_figure(event: PlayFigureRequested, app: App) -> None:
playlist = app.playlist(event.figure)
if playlist is None:
return
if not event.restart and app.player.playlist is playlist:
_log.info("Resuming %r", event.figure)
app.player.play()
return
_log.info("Starting %r from the beginning", event.figure)
app.player.set_playlist(playlist)
app.player.play_from_start()
def _start_album(app: App, album: Album, track_index: int) -> None:
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
# Playlist object either way, because both come from the library index.
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
app.player.set_playlist(playlist or album.to_playlist())
app.player.play_track(track_index)
@on(PlayAlbumRequested)
def play_album(event: PlayAlbumRequested, app: App) -> None:
"""Play any album in the library. This is the web front-end's way in."""
album = app.library.get(event.album_id)
if album is None:
_log.warning("No album %r in the library", event.album_id)
return
_start_album(app, album, event.track_index)
@on(PlaySeriesLatestRequested)
def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
"""Play the newest episode of a podcast show - the IR remote's number-key way in."""
album = app.library.latest_episode(event.series)
if album is None:
_log.warning("No episodes for series %r", event.series)
return
_start_album(app, album, 0)
@on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
# Nothing was left half-played, so the next placement starts from the top.
app.state.last_partially_played_figure = None
# ---------------------------------------------------------------- physical inputs
@on(ButtonEvent)
def button_pressed(event: ButtonEvent, app: App) -> None:
if event.action is not ButtonAction.PRESSED:
return
if event.button is Button.LEFT and app.player.is_playing:
app.bus.emit(PreviousTrackRequested(source="device"))
elif event.button is Button.RIGHT and app.player.is_playing:
app.bus.emit(NextTrackRequested(source="device"))
# The rotary press is published to Home Assistant by the MQTT service; what it
# controls is an automation over there, not something this backend decides.
@on(RotaryTurned)
def rotary_turned(event: RotaryTurned, app: App) -> None:
step = app.config.general.volume_increment * abs(event.increment)
if event.direction is RotaryDirection.UP:
app.bus.emit(VolumeChangeRequested(delta=step, source="device"))
elif event.direction is RotaryDirection.DOWN:
app.bus.emit(VolumeChangeRequested(delta=-step, source="device"))
# ----------------------------------------------------------------------- intents
@on(NextTrackRequested)
def next_track(event: NextTrackRequested, app: App) -> None:
_log.debug("Next track (%s)", event.source)
app.player.next_track()
@on(PreviousTrackRequested)
def previous_track(event: PreviousTrackRequested, app: App) -> None:
_log.debug("Previous track (%s)", event.source)
app.player.previous_track()
@on(PlayRequested)
def play(_event: PlayRequested, app: App) -> None:
app.player.play()
@on(PauseRequested)
def pause(_event: PauseRequested, app: App) -> None:
app.player.pause()
@on(SeekRequested)
def seek(event: SeekRequested, app: App) -> None:
app.player.seek(event.position)
@on(VolumeChangeRequested)
def change_volume(event: VolumeChangeRequested, app: App) -> None:
app.player.change_volume(event.delta, source=event.source)
@on(SetVolumeRequested)
def set_volume(event: SetVolumeRequested, app: App) -> None:
app.player.set_volume(event.volume, source=event.source)

View File

@@ -0,0 +1,57 @@
"""The ``@on`` decorator and the binding step.
Kept in its own module so the reaction modules can import ``on`` without importing the
package that imports them.
"""
from __future__ import annotations
import logging
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from musicmouse.bus import EventBus
from musicmouse.events import Event
if TYPE_CHECKING:
from musicmouse.app import App
_log = logging.getLogger(__name__)
__all__ = ["Reaction", "on", "register_all", "registered"]
E = TypeVar("E", bound=Event)
Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
"""Register a reaction for ``event_type`` (and any subclass of it)."""
def decorator(reaction: Reaction[E]) -> Reaction[E]:
_REGISTRY.append((event_type, reaction))
return reaction
return decorator
def registered() -> list[tuple[type[Event], Reaction[Any]]]:
return list(_REGISTRY)
def register_all(bus: EventBus, app: App) -> None:
"""Subscribe every declared reaction, with ``app`` bound as its second argument."""
for event_type, reaction in _REGISTRY:
bus.subscribe(event_type, _bind(reaction, app))
_log.debug("Registered %d reactions", len(_REGISTRY))
def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]:
def handler(event: E) -> Any:
return reaction(event, app)
# Keep the reaction's name, so a failing handler is identifiable in the log.
handler.__qualname__ = getattr(reaction, "__qualname__", repr(reaction))
return handler

View File

@@ -0,0 +1,20 @@
"""Remembering which links are up.
The firmware's state is readable straight off the transport, but a broker's is not:
:class:`~musicmouse.services.mqtt.service.MqttService` announces it and then forgets.
A front-end that wants to show a connection dot needs somewhere to read it from.
"""
from __future__ import annotations
from musicmouse.app import App
from musicmouse.events import ConnectionChanged
from musicmouse.reactions.registry import on
@on(ConnectionChanged)
def connection_changed(event: ConnectionChanged, app: App) -> None:
if event.target == "mqtt":
app.state.mqtt_connected = event.connected
elif event.target == "lirc":
app.state.lirc_connected = event.connected

View File

@@ -0,0 +1,9 @@
"""Front-ends: things that mirror state outwards and turn requests into intents.
``MqttService`` is the reference implementation. A web service would be another file
here plus one line in the app - devices and reactions would not change.
"""
from musicmouse.services.base import Service
__all__ = ["Service"]

View File

@@ -0,0 +1,26 @@
"""What a front-end has to look like.
A service gets the bus, subscribes to state events to push outward, and emits intents
inward. Nothing else in the app knows which services exist.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
__all__ = ["Publisher", "Service"]
@runtime_checkable
class Service(Protocol):
name: str
async def run(self) -> None:
"""Long-running task. Cancelled on shutdown; may reconnect internally."""
...
class Publisher(Protocol):
"""How an entity sends something out, without knowing about the connection."""
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None: ...

View File

@@ -0,0 +1,6 @@
"""IR remote control, via lircd's TCP socket."""
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
from musicmouse.services.lirc.service import LircService
__all__ = ["LircButtonEvent", "LircService", "parse_line"]

View File

@@ -0,0 +1,37 @@
"""lircd's classic network protocol: one line per button press or repeat.
A line looks like::
0000000000001781 00 BTN_1 Hauppauge
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
key-up event, just repeats stopping.
"""
from __future__ import annotations
from dataclasses import dataclass
__all__ = ["LircButtonEvent", "parse_line"]
@dataclass(frozen=True, slots=True)
class LircButtonEvent:
code: str
repeat: int
button: str
remote: str
def parse_line(line: str) -> LircButtonEvent | None:
"""One broadcast line, or ``None`` if it does not look like one."""
parts = line.strip().split()
if len(parts) != 4:
return None
code, repeat_hex, button, remote = parts
try:
repeat = int(repeat_hex, 16)
except ValueError:
return None
return LircButtonEvent(code=code, repeat=repeat, button=button, remote=remote)

View File

@@ -0,0 +1,140 @@
"""The IR remote: a TCP client for lircd, translated into the same intents every other
front-end emits.
Connect, read lines until the link drops, wait, repeat - the same reconnect shape as
:class:`~musicmouse.devices.serial_link.SerialLink`, over a plain socket instead of a
serial port because lircd speaks its classic protocol on a bare TCP connection.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from musicmouse.app import App
from musicmouse.clock import Clock, RealClock
from musicmouse.config import Digit, LircConfig
from musicmouse.events import (
ConnectionChanged,
IntentEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested,
SetVolumeRequested,
VolumeChangeRequested,
)
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
_log = logging.getLogger(__name__)
__all__ = ["LircService"]
#: Acted on only at repeat 0 (the first press) - holding the button must not replay it.
_TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
"KEY_PLAY": lambda: PlayRequested(source="lirc"),
# No separate "stop" concept exists in this player; the remote's stop button just
# pauses, like its pause button.
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
"KEY_STOP": lambda: PauseRequested(source="lirc"),
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
}
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
#: encoder (see ``reactions.playback.rotary_turned``).
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
_DIGITS: dict[str, Digit] = {
"BTN_0": "0",
"BTN_1": "1",
"BTN_2": "2",
"BTN_3": "3",
"BTN_4": "4",
"BTN_5": "5",
"BTN_6": "6",
"BTN_7": "7",
"BTN_8": "8",
"BTN_9": "9",
}
class LircService:
name = "lirc"
def __init__(self, app: App, config: LircConfig, *, clock: Clock | None = None) -> None:
self.app = app
self.config = config
self._clock = clock or RealClock()
#: 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.
self._pre_mute_volume: int | None = None
async def run(self) -> None:
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
while True:
try:
await self._session()
except OSError as exc:
_log.warning(
"lircd link to %s:%d lost (%s); retrying in %gs",
self.config.host,
self.config.port,
exc,
self.config.reconnect_interval,
)
await self._clock.sleep(self.config.reconnect_interval)
async def _session(self) -> None:
reader, writer = await asyncio.open_connection(self.config.host, self.config.port)
_log.info("Connected to lircd at %s:%d", self.config.host, self.config.port)
self.app.bus.emit(ConnectionChanged(target="lirc", connected=True, source="lirc"))
try:
while True:
raw = await reader.readline()
if not raw:
return
event = parse_line(raw.decode(errors="replace"))
if event is None or event.remote != self.config.remote_name:
continue
self._handle(event)
finally:
writer.close()
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
def _handle(self, event: LircButtonEvent) -> None:
if event.button in _TRANSPORT:
if event.repeat == 0:
self.app.bus.emit(_TRANSPORT[event.button]())
elif event.button in _VOLUME:
step = self.app.config.general.volume_increment * _VOLUME[event.button]
self.app.bus.emit(VolumeChangeRequested(delta=step, source="lirc"))
elif event.button == "KEY_MUTE":
if event.repeat == 0:
self._toggle_mute()
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0:
self._play_digit(digit)
def _toggle_mute(self) -> None:
player = self.app.player
if player.volume > 0:
self._pre_mute_volume = player.volume
self.app.bus.emit(SetVolumeRequested(volume=0, source="lirc"))
else:
restore = self._pre_mute_volume or self.app.config.general.initial_volume
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
def _play_digit(self, digit: Digit) -> None:
slot = self.app.config.remote.get(digit)
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

@@ -0,0 +1,6 @@
"""Home Assistant integration over MQTT."""
from musicmouse.services.mqtt.entity import Entity
from musicmouse.services.mqtt.service import MqttService, build_entities
__all__ = ["Entity", "MqttService", "build_entities"]

View File

@@ -0,0 +1,107 @@
"""Shared plumbing for Home-Assistant-discoverable MQTT entities.
Adding an entity should be about thirty lines: a discovery payload, a state payload,
and whatever bus subscriptions keep it current.
"""
from __future__ import annotations
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, ClassVar
from musicmouse.bus import EventBus
from musicmouse.config import MqttConfig
from musicmouse.services.base import Publisher
_log = logging.getLogger(__name__)
__all__ = ["Entity"]
class Entity(ABC):
#: Home Assistant MQTT component, e.g. "light", "sensor", "device_automation".
component: ClassVar[str]
def __init__(self, bus: EventBus, config: MqttConfig, object_id: str, name: str) -> None:
self.bus = bus
self.config = config
self.object_id = object_id
self.name = name
self._publisher: Publisher | None = None
self.subscribe()
# -------------------------------------------------------------------- topics
@property
def unique_id(self) -> str:
return f"{self.config.device_id}_{self.object_id}"
@property
def base_topic(self) -> str:
return f"{self.config.base_topic}/{self.object_id}"
@property
def state_topic(self) -> str:
return f"{self.base_topic}/state"
@property
def command_topic(self) -> str:
return f"{self.base_topic}/set"
@property
def discovery_topic(self) -> str:
return f"{self.config.discovery_prefix}/{self.component}/{self.unique_id}/config"
def command_topics(self) -> tuple[str, ...]:
"""Topics the service should route to :meth:`handle`."""
return ()
# ------------------------------------------------------------------ contract
@abstractmethod
def discovery_payload(self) -> dict[str, Any]:
"""The retained config Home Assistant reads to create this entity."""
def subscribe(self) -> None:
"""Register bus handlers. Called once, at construction."""
async def handle(self, topic: str, payload: str) -> None:
"""React to a command on one of :meth:`command_topics`."""
async def publish_state(self) -> None:
"""Push current state out. Called on connect and whenever state changes."""
# ------------------------------------------------------------------- runtime
def attach(self, publisher: Publisher | None) -> None:
self._publisher = publisher
@property
def online(self) -> bool:
return self._publisher is not None
async def publish(self, topic: str, payload: Any, *, retain: bool = False) -> None:
"""Send ``payload`` (JSON-encoded unless it is already a string).
A no-op while the broker is unreachable: state is republished on reconnect.
"""
if self._publisher is None:
return
text = payload if isinstance(payload, str) else json.dumps(payload)
await self._publisher.publish(topic, text, retain=retain)
async def announce(self) -> None:
"""Publish discovery, then current state."""
await self.publish(self.discovery_topic, self.discovery_payload(), retain=True)
await self.publish_state()
def device_block(self) -> dict[str, Any]:
"""Ties every entity to one device in Home Assistant's UI."""
return {
"identifiers": [self.config.device_id],
"name": self.config.device_name,
"manufacturer": "bauer.tech",
"model": "MusicMouse",
}

View File

@@ -0,0 +1,250 @@
"""Each LED zone as a Home-Assistant-discoverable JSON light.
Two things changed from the old ``ShelveLightMqtt``:
* The ``side_*``/``top_*`` effect names are parsed rather than enumerated, so adding a
width or an increment is a data change (see :data:`WIDTHS`, :data:`INCREMENTS`).
* State is published from :class:`~musicmouse.events.LedEffectChanged` - the device's
report of what it actually did - instead of echoing back the command. When a figure
animation overrides an MQTT-set colour, Home Assistant now follows along.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from musicmouse.bus import EventBus
from musicmouse.color import ColorRGBW
from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
LedEffect,
)
from musicmouse.events import LedEffectChanged
from musicmouse.hardware import LedZone
from musicmouse.services.mqtt.entity import Entity
_log = logging.getLogger(__name__)
__all__ = ["LightEntity", "effect_names", "parse_positional_effect"]
BLACK = ColorRGBW(0, 0, 0, 0)
#: Effects that are not simply "light this fraction of the strip".
BASE_EFFECTS = ("static", "circular", "wipeup", "twocolor", "twocolorrandom")
#: Fraction of the strip lit by a positional effect.
WIDTHS = (0.2, 0.5)
#: Light every n-th LED. 1 is solid.
INCREMENTS = (1, 4, 8)
_POSITIONAL = re.compile(r"^(?P<position>side|top)_(?P<width>\d+(?:\.\d+)?)(?:_inc(?P<inc>\d+))?$")
DEFAULT_TRANSITION_S = 0.3
def effect_names() -> list[str]:
"""Every effect name this entity accepts, for the discovery ``effect_list``."""
positional = [
f"{position}_{width:g}" + ("" if increment == 1 else f"_inc{increment}")
for position in ("side", "top")
for width in WIDTHS
for increment in INCREMENTS
]
return [*BASE_EFFECTS, *positional]
def parse_positional_effect(name: str) -> tuple[float, float, int] | None:
"""``"side_0.2_inc4"`` -> ``(begin, end, increment)``, or ``None`` if not one.
``side`` lights a band around the far end of the strip and wraps; ``top`` lights a
band centred on the middle.
"""
match = _POSITIONAL.match(name)
if match is None:
return None
width = float(match["width"])
increment = int(match["inc"] or 1)
if match["position"] == "side":
return 1.0 - width / 2, width / 2, increment
return 0.5 - width / 2, 0.5 + width / 2, increment
class LightEntity(Entity):
component = "light"
def __init__(
self,
bus: EventBus,
config: MqttConfig,
mouse: MusicMouseDevice,
zone: LedZone,
name: str,
) -> None:
self.zone = zone
self.mouse = mouse
self._state: dict[str, Any] = {
"state": "OFF",
"color": {"r": 255, "g": 255, "b": 255, "w": 0},
"color_mode": "rgbw",
"brightness": 30,
"effect": "static",
}
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
super().__init__(bus, config, object_id=f"light_{zone}", name=name)
# ---------------------------------------------------------------- discovery
def discovery_payload(self) -> dict[str, Any]:
return {
"schema": "json",
"name": self.name,
"unique_id": self.unique_id,
"command_topic": self.command_topic,
"state_topic": self.state_topic,
"brightness": True,
"color_mode": True,
"supported_color_modes": ["rgbw"],
"effect": True,
"effect_list": effect_names(),
"device": self.device_block(),
}
def command_topics(self) -> tuple[str, ...]:
return (self.command_topic,)
def subscribe(self) -> None:
self.bus.subscribe(LedEffectChanged, self._on_led_changed)
# ----------------------------------------------------------------- commands
async def handle(self, topic: str, payload: str) -> None:
try:
command = json.loads(payload)
except json.JSONDecodeError:
_log.warning("Ignoring non-JSON command on %s: %r", topic, payload[:120])
return
if not isinstance(command, dict):
_log.warning("Ignoring command on %s: expected an object, got %r", topic, command)
return
self._remember_previous_color(command)
self._state.update(command)
self.mouse.set_effect(self.zone, self._build_effect(), origin="mqtt")
# No publish here: LedEffectChanged will report what the device actually did.
def _remember_previous_color(self, command: dict[str, Any]) -> None:
"""Two-colour effects interpolate from the colour that was set before."""
if "color" not in command:
return
brightness = command.get("brightness", self._state["brightness"])
new_color = _color_from_json(command["color"], brightness)
current = _color_from_json(self._state["color"], self._state["brightness"])
if new_color != current:
self._last_color = current
def _build_effect(self) -> LedEffect:
state = self._state
color = _color_from_json(state["color"], state["brightness"])
transition_ms = float(state.get("transition", DEFAULT_TRANSITION_S)) * 1000
effect = str(state.get("effect", "static"))
if state["state"] == "OFF":
return _static(BLACK, transition_ms)
if (positional := parse_positional_effect(effect)) is not None:
begin, end, increment = positional
return EffectStaticDetailedConfig(
color,
increment=increment,
begin=begin,
end=end,
transition_time_in_ms=transition_ms,
)
match effect:
case "static":
return _static(color, transition_ms)
case "circular":
return EffectCircularConfig(speed=180, width=90, color=color)
case "wipeup":
swipe_and_change = EffectSwipeAndChange()
swipe_and_change.swipe.primary_color = self._last_color
swipe_and_change.swipe.secondary_color = color
swipe_and_change.swipe.bell_curve_width_in_leds = 10
swipe_and_change.swipe.transition_width = 30
swipe_and_change.swipe.start_position = 0
swipe_and_change.swipe.swipe_speed = 260
swipe_and_change.change.color1 = color
swipe_and_change.change.color2 = self._last_color
return swipe_and_change
case "twocolor" | "twocolorrandom":
random_hues = effect == "twocolorrandom"
return EffectRandomTwoColorInterpolationConfig(
color1=color,
color2=self._last_color,
hue1_random=random_hues,
hue2_random=random_hues,
start_with_existing=True,
)
case _:
_log.warning("Unknown effect %r on %s, turning it off", effect, self.zone)
return _static(BLACK, transition_ms)
# -------------------------------------------------------------------- state
async def _on_led_changed(self, event: LedEffectChanged) -> None:
if event.zone is not self.zone:
return
if event.origin != "mqtt":
self._reconcile(event.effect)
await self.publish_state()
def _reconcile(self, effect: LedEffect) -> None:
"""Fold an effect this entity did not ask for into the reported state.
The mapping is lossy - the firmware has richer effects than the HA light
schema - so only on/off and a colour are taken. The effect *name* is left
alone, since reporting one outside ``effect_list`` would confuse HA.
"""
color = getattr(effect, "color", None)
if isinstance(effect, EffectStaticConfig | EffectStaticDetailedConfig) and color == BLACK:
self._state["state"] = "OFF"
return
self._state["state"] = "ON"
if isinstance(color, ColorRGBW):
self._state["color"] = _color_to_json(color)
self._state["brightness"] = 255
async def publish_state(self) -> None:
await self.publish(self.state_topic, self._state)
def _static(color: ColorRGBW, transition_ms: float) -> LedEffect:
if transition_ms > 0:
return EffectStaticDetailedConfig(color, transition_time_in_ms=transition_ms)
return EffectStaticConfig(color)
def _color_from_json(color: dict[str, int], brightness: int = 255) -> ColorRGBW:
scale = brightness / 255
r, g, b, w = ((color.get(channel, 0) / 255) * scale for channel in "rgbw")
return ColorRGBW(r, g, b, w)
def _color_to_json(color: ColorRGBW) -> dict[str, int]:
return {
"r": round(color.r * 255),
"g": round(color.g * 255),
"b": round(color.b * 255),
"w": round(color.w * 255),
}

View File

@@ -0,0 +1,171 @@
"""The audio player, exposed to Home Assistant.
Home Assistant has no MQTT ``media_player`` platform, so the player is published as
the pieces that do exist: a sensor for what is going on, a number for the volume, and
buttons for the transport. Commands come back in as intents, which is the same path
the physical buttons take.
"""
from __future__ import annotations
import logging
from typing import Any, ClassVar
from musicmouse.bus import EventBus
from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.events import (
ActiveFigureChanged,
Event,
IntentEvent,
NextTrackRequested,
PauseRequested,
PlaybackChanged,
PlayRequested,
PreviousTrackRequested,
SetVolumeRequested,
TrackChanged,
VolumeChanged,
)
from musicmouse.services.mqtt.entity import Entity
_log = logging.getLogger(__name__)
__all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
class PlayerSensor(Entity):
component = "sensor"
def __init__(
self, bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> None:
self.mouse = mouse
self.player = player
super().__init__(bus, config, object_id="player", name="Music Mouse Player")
def discovery_payload(self) -> dict[str, Any]:
return {
"name": self.name,
"unique_id": self.unique_id,
"state_topic": self.state_topic,
"json_attributes_topic": f"{self.base_topic}/attributes",
"icon": "mdi:music-circle",
"device": self.device_block(),
}
def subscribe(self) -> None:
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
self.bus.subscribe(event_type, self._on_change)
async def _on_change(self, _event: Event) -> None:
await self.publish_state()
async def publish_state(self) -> None:
track = self.player.current_track
playlist = self.player.playlist
await self.publish(self.state_topic, "playing" if self.player.is_playing else "paused")
await self.publish(
f"{self.base_topic}/attributes",
{
"figure": self.mouse.active_figure,
"playlist": playlist.name if playlist else None,
"track_index": self.player.track_index,
"track_count": len(playlist) if playlist else 0,
"title": track.title if track else None,
"volume": self.player.volume,
},
)
class VolumeNumber(Entity):
component = "number"
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
self.player = player
self._min = 0
self._max = 100
super().__init__(bus, config, object_id="volume", name="Music Mouse Volume")
def discovery_payload(self) -> dict[str, Any]:
return {
"name": self.name,
"unique_id": self.unique_id,
"command_topic": self.command_topic,
"state_topic": self.state_topic,
"min": self._min,
"max": self._max,
"step": 1,
"mode": "slider",
"icon": "mdi:volume-high",
"device": self.device_block(),
}
def command_topics(self) -> tuple[str, ...]:
return (self.command_topic,)
def subscribe(self) -> None:
self.bus.subscribe(VolumeChanged, self._on_volume)
async def _on_volume(self, _event: VolumeChanged) -> None:
await self.publish_state()
async def handle(self, topic: str, payload: str) -> None:
try:
volume = int(float(payload))
except ValueError:
_log.warning("Ignoring non-numeric volume on %s: %r", topic, payload[:40])
return
self.bus.emit(SetVolumeRequested(volume=volume, source="mqtt"))
async def publish_state(self) -> None:
await self.publish(self.state_topic, str(self.player.volume))
class TransportButton(Entity):
component = "button"
#: Button object id -> the intent pressing it emits.
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
"next": NextTrackRequested,
"previous": PreviousTrackRequested,
"play": PlayRequested,
"pause": PauseRequested,
}
def __init__(self, bus: EventBus, config: MqttConfig, action: str, name: str) -> None:
self.action = action
super().__init__(bus, config, object_id=f"button_{action}", name=name)
def discovery_payload(self) -> dict[str, Any]:
return {
"name": self.name,
"unique_id": self.unique_id,
"command_topic": self.command_topic,
"device": self.device_block(),
}
def command_topics(self) -> tuple[str, ...]:
return (self.command_topic,)
async def handle(self, topic: str, payload: str) -> None:
# HA publishes "PRESS"; the payload carries no information beyond "it happened".
_log.debug("Transport button %s pressed via %s (%r)", self.action, topic, payload[:20])
self.bus.emit(self.INTENTS[self.action](source="mqtt"))
def player_entities(
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> list[Entity]:
names = {
"next": "Music Mouse Next",
"previous": "Music Mouse Previous",
"play": "Music Mouse Play",
"pause": "Music Mouse Pause",
}
return [
PlayerSensor(bus, config, mouse, player),
VolumeNumber(bus, config, player),
*(TransportButton(bus, config, action, name) for action, name in names.items()),
]

View File

@@ -0,0 +1,134 @@
"""The MQTT connection: reconnect, discovery, and routing commands to entities.
Unlike the old ``start_mqtt``, entities outlive a dropped connection - they keep their
state and simply republish it - and a clean restart is not delayed by a reconnect
sleep it never needed.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
import aiomqtt
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.events import ConnectionChanged
from musicmouse.hardware import LedZone
from musicmouse.services.mqtt.entity import Entity
from musicmouse.services.mqtt.lights import LightEntity
from musicmouse.services.mqtt.player import player_entities
from musicmouse.services.mqtt.triggers import trigger_entities
_log = logging.getLogger(__name__)
__all__ = ["MqttService", "build_entities"]
ZONE_NAMES = {
LedZone.SHELF: "Music Mouse Regal Licht",
LedZone.RING: "Music Mouse Ring",
LedZone.MOUSE: "Music Mouse Body",
}
def build_entities(
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> list[Entity]:
"""Everything this backend exposes to Home Assistant."""
return [
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
*player_entities(bus, config, mouse, player),
*trigger_entities(bus, config),
]
class MqttService:
name = "mqtt"
def __init__(
self,
bus: EventBus,
config: MqttConfig,
entities: Iterable[Entity],
*,
clock: Clock | None = None,
) -> None:
self.bus = bus
self.config = config
self.entities = list(entities)
self._clock = clock or RealClock()
self._client: aiomqtt.Client | None = None
self._routes: dict[str, Entity] = {
topic: entity for entity in self.entities for topic in entity.command_topics()
}
# ------------------------------------------------------------------ Publisher
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None:
client = self._client
if client is None:
return
try:
await client.publish(topic, payload.encode(), retain=retain)
except aiomqtt.MqttError as exc:
_log.debug("Publish to %s failed: %s", topic, exc)
# -------------------------------------------------------------------- runtime
async def run(self) -> None:
while True:
try:
await self._session()
except aiomqtt.MqttError as exc:
_log.warning(
"MQTT connection to %s lost (%s); retrying in %gs",
self.config.server,
exc,
self.config.reconnect_interval,
)
finally:
self._detach()
await self._clock.sleep(self.config.reconnect_interval)
async def _session(self) -> None:
async with aiomqtt.Client(
hostname=self.config.server,
port=self.config.port,
username=self.config.user,
password=self.config.password,
) as client:
self._client = client
_log.info("Connected to MQTT broker %s:%d", self.config.server, self.config.port)
self.bus.emit(ConnectionChanged(target="mqtt", connected=True, source="mqtt"))
for entity in self.entities:
entity.attach(self)
await entity.announce()
await client.subscribe(f"{self.config.base_topic}/#")
async for message in client.messages:
await self._route(message)
def _detach(self) -> None:
if self._client is None:
return
self._client = None
for entity in self.entities:
entity.attach(None)
self.bus.emit(ConnectionChanged(target="mqtt", connected=False, source="mqtt"))
async def _route(self, message: aiomqtt.Message) -> None:
topic = message.topic.value
entity = self._routes.get(topic)
if entity is None:
return # our own state topics come back on the wildcard subscription
payload = message.payload
text = payload.decode(errors="replace") if isinstance(payload, bytes) else str(payload)
try:
await entity.handle(topic, text)
except Exception:
_log.exception("Entity %s failed on %s", entity.unique_id, topic)

View File

@@ -0,0 +1,157 @@
"""Button, touch and RFID events, published for Home Assistant to automate on.
This is what replaces the old direct ``hass-client`` calls. The backend no longer
knows that pressing the rotary encoder toggles ``light.kinderzimmer_fluter`` or that
the left ear means pink - it reports what happened, and the automation lives in Home
Assistant where it can be changed without a deploy.
"""
from __future__ import annotations
from typing import Any, ClassVar
from musicmouse.bus import EventBus
from musicmouse.config import MqttConfig
from musicmouse.events import ButtonEvent, RfidTokenRead, TouchButtonPressed, TouchButtonReleased
from musicmouse.hardware import Button, ButtonAction, TouchButton
from musicmouse.services.mqtt.entity import Entity
__all__ = ["ButtonTrigger", "TagScanner", "TouchTrigger", "trigger_entities"]
#: Which button actions are worth automating on, and the HA trigger type for each.
BUTTON_ACTIONS: dict[ButtonAction, str] = {
ButtonAction.PRESSED: "button_short_press",
ButtonAction.DOUBLE_CLICKED: "button_double_press",
ButtonAction.LONG_PRESSED: "button_long_press",
}
class _Trigger(Entity):
component = "device_automation"
@property
def discovery_topic(self) -> str:
# Device triggers are addressed by node id + object id, not by unique id.
return (
f"{self.config.discovery_prefix}/device_automation/"
f"{self.config.device_id}/{self.object_id}/config"
)
@property
def trigger_topic(self) -> str:
return f"{self.config.base_topic}/trigger/{self.object_id}"
def _payload(self, trigger_type: str, subtype: str) -> dict[str, Any]:
return {
"automation_type": "trigger",
"topic": self.trigger_topic,
"type": trigger_type,
"subtype": subtype,
"device": self.device_block(),
}
class ButtonTrigger(_Trigger):
def __init__(
self, bus: EventBus, config: MqttConfig, button: Button, action: ButtonAction
) -> None:
self.button = button
self.action = action
super().__init__(
bus,
config,
object_id=f"{button.slug}_{action.slug}",
name=f"{button.slug} {action.slug}",
)
def discovery_payload(self) -> dict[str, Any]:
return self._payload(BUTTON_ACTIONS[self.action], self.button.slug)
def subscribe(self) -> None:
self.bus.subscribe(ButtonEvent, self._on_button)
async def _on_button(self, event: ButtonEvent) -> None:
if event.button is self.button and event.action is self.action:
await self.publish(self.trigger_topic, self.action.slug)
class TouchTrigger(_Trigger):
TYPES: ClassVar[dict[bool, str]] = {
True: "button_short_press",
False: "button_short_release",
}
def __init__(
self, bus: EventBus, config: MqttConfig, button: TouchButton, *, pressed: bool
) -> None:
self.button = button
self.pressed = pressed
suffix = "touched" if pressed else "released"
super().__init__(
bus,
config,
object_id=f"{button.slug}_{suffix}",
name=f"{button.slug} {suffix}",
)
def discovery_payload(self) -> dict[str, Any]:
return self._payload(self.TYPES[self.pressed], self.button.slug)
def subscribe(self) -> None:
if self.pressed:
self.bus.subscribe(TouchButtonPressed, self._on_touch)
else:
self.bus.subscribe(TouchButtonReleased, self._on_touch)
async def _on_touch(self, event: TouchButtonPressed | TouchButtonReleased) -> None:
if event.button is self.button:
await self.publish(self.trigger_topic, self.button.slug)
class TagScanner(Entity):
"""The RFID reader, as an HA tag scanner - the natural fit for "a tag was read"."""
component = "tag"
def __init__(self, bus: EventBus, config: MqttConfig) -> None:
super().__init__(bus, config, object_id="tag", name="Music Mouse Reader")
@property
def discovery_topic(self) -> str:
return f"{self.config.discovery_prefix}/tag/{self.config.device_id}/config"
@property
def scan_topic(self) -> str:
return f"{self.config.base_topic}/tag"
def discovery_payload(self) -> dict[str, Any]:
return {
"topic": self.scan_topic,
"value_template": "{{ value_json.tag_id }}",
"device": self.device_block(),
}
def subscribe(self) -> None:
self.bus.subscribe(RfidTokenRead, self._on_tag)
async def _on_tag(self, event: RfidTokenRead) -> None:
await self.publish(
self.scan_topic,
{"tag_id": event.tag_id.hex(), "figure": event.figure, "known": event.known},
)
def trigger_entities(bus: EventBus, config: MqttConfig) -> list[Entity]:
return [
*(
ButtonTrigger(bus, config, button, action)
for button in Button
for action in BUTTON_ACTIONS
),
*(
TouchTrigger(bus, config, button, pressed=pressed)
for button in TouchButton
for pressed in (True, False)
),
TagScanner(bus, config),
]

View File

@@ -0,0 +1,63 @@
"""Front-end for nothing: the only "intent" this service ever produces is new files on
disk. It polls every podcast show that named its feed via a ``feed.txt`` marker (see
:mod:`musicmouse.library.podcast_feeds`) and downloads whatever episode is missing.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import Final
import httpx2
from musicmouse.library import MusicLibrary
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
_log = logging.getLogger(__name__)
__all__ = ["PodcastFeedService"]
#: Podcasts a children's player subscribes to publish at most a few times a week;
#: checking a few times a day is plenty and kind to the feed hosts.
_CHECK_INTERVAL_SECONDS: Final = 6 * 3600
class PodcastFeedService:
name = "podcast-feeds"
def __init__(
self,
library: MusicLibrary,
*,
client: httpx2.AsyncClient,
on_change: Callable[[], Awaitable[None]],
interval: float = _CHECK_INTERVAL_SECONDS,
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
) -> None:
self.library = library
self.client = client
self.on_change = on_change
self.interval = interval
self.episode_limit = episode_limit
async def run(self) -> None:
"""Checks immediately at startup, then every `interval` seconds.
A no-op when no show has a ``feed.txt`` - which is the common case, so this can
run unconditionally instead of needing its own config toggle. Never lets a
single bad pass end the task: `Service.run` is cancelled on shutdown and is
otherwise expected to keep going on its own.
"""
try:
while True:
try:
keep = self.episode_limit
if await sync_all_shows(self.client, self.library.root, keep=keep):
await self.on_change()
except Exception:
_log.exception("Podcast feed check failed; will retry next interval")
await asyncio.sleep(self.interval)
finally:
await self.client.aclose()

View File

@@ -0,0 +1,6 @@
"""The web front-end: a browse-and-play UI over the same intents the buttons emit."""
from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.service import WebService, build_app
__all__ = ["StateHub", "WebService", "build_app"]

View File

@@ -0,0 +1,357 @@
"""The HTTP surface.
Commands are REST and state is a one-way websocket. That split keeps every control path
testable with ``curl`` and means a command needs no new machinery: it emits the same
intent the MQTT service and the buttons emit, and lands in the same reaction.
Search is not here on purpose. The whole index goes to the browser once and filtering
happens there, which is what makes the design's type-to-search feel instant.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
import httpx2
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from musicmouse.app import App
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
from musicmouse.events import (
IntentEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PreviousTrackRequested,
SeekRequested,
SetVolumeRequested,
)
from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
from musicmouse.services.web.schemas import (
AlbumOut,
HaConfigOut,
HaDeviceOut,
LibraryOut,
LircConfigOut,
PlayerStateOut,
PlayIn,
RemoteMappingIn,
RemoteMappingOut,
SeekIn,
SettingsIn,
SettingsOut,
TippenCurriculumOut,
TippenProgressOut,
TippenRunIn,
TippenRunOut,
TippenSettingsIn,
TippenSettingsOut,
TrackCurvesOut,
TrackDetailOut,
VolumeIn,
)
from musicmouse.services.web.settings import (
read_settings,
to_device_volume,
to_percent,
write_settings,
)
from musicmouse.services.web.state import snapshot
from musicmouse.services.web.tippen_api import curriculum_out, progress_out, record_tippen_run
from musicmouse.tippen.rewards import compute_lock_state
from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__)
__all__ = ["build_router"]
def build_router(
app: App, hub: StateHub, config_path: Path, ha_client: httpx2.AsyncClient
) -> APIRouter:
router = APIRouter(prefix="/api")
def emit(intent: IntentEvent) -> Response:
app.bus.emit(intent)
return Response(status_code=204)
# ------------------------------------------------------------------- library
@router.get("/library")
def get_library() -> LibraryOut:
lock_state = (
compute_lock_state(app.tippen.curriculum, app.library, app.tippen.progress)
if app.tippen is not None
else None
)
return LibraryOut(
albums=[
AlbumOut.of(album, lock_state.get(album.id) if lock_state else None)
for album in app.library.albums
]
)
@router.get("/albums/{album_id}/cover")
def get_cover(album_id: str, size: str = "full", v: str | None = None) -> FileResponse:
album = app.library.get(album_id)
if album is None or album.cover is None:
# Not an error: the client paints the album's own colours instead.
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(
path,
# A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
# exact set of bytes: reprocessing the art changes the library payload, so
# 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")
def get_track_analysis(album_id: str, index: int) -> TrackDetailOut:
grid = app.library.beats(album_id, index)
curve = app.library.curve(album_id, index)
if grid is None and curve is None:
raise HTTPException(status_code=404, detail="not analyzed")
return TrackDetailOut(
times=list(grid.times) if grid else [],
strengths=list(grid.strengths) if grid else [],
curve=TrackCurvesOut(**curve.to_json()) if curve else None,
)
@router.post("/library/refresh", status_code=202)
async def refresh_library() -> Response:
# Returns immediately: a cold rescan reads tags from every file.
asyncio.create_task(app.rescan_library(broadcast=hub.broadcast_library)) # noqa: RUF006
return Response(status_code=202)
# --------------------------------------------------------------------- state
@router.get("/state")
def get_state() -> PlayerStateOut:
return snapshot(app)
@router.websocket("/ws")
async def websocket(socket: WebSocket) -> None:
await hub.connect(socket)
try:
while True:
# Push-only. Reading is how we notice the tab closed.
await socket.receive_text()
except WebSocketDisconnect:
pass
finally:
hub.disconnect(socket)
# ------------------------------------------------------------------ commands
@router.post("/play", status_code=204)
def play_album(body: PlayIn) -> Response:
if app.library.get(body.album_id) is None:
raise HTTPException(status_code=404, detail="no such album")
return emit(
PlayAlbumRequested(
album_id=body.album_id, track_index=body.track_index, source="web"
)
)
@router.post("/resume", status_code=204)
def resume() -> Response:
return emit(PlayRequested(source="web"))
@router.post("/pause", status_code=204)
def pause() -> Response:
return emit(PauseRequested(source="web"))
@router.post("/next", status_code=204)
def next_track() -> Response:
return emit(NextTrackRequested(source="web"))
@router.post("/previous", status_code=204)
def previous_track() -> Response:
return emit(PreviousTrackRequested(source="web"))
@router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response:
return emit(SeekRequested(position=body.position, source="web"))
@router.post("/volume", status_code=204)
def set_volume(body: VolumeIn) -> Response:
general = app.config.general
if body.percent is not None:
target = body.percent
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(
SetVolumeRequested(
volume=to_device_volume(max(0, min(100, target)), general), source="web"
)
)
# ------------------------------------------------------------- parent mode
@router.get("/settings")
def get_settings() -> SettingsOut:
return read_settings(app.config.general)
@router.put("/settings")
async def put_settings(body: SettingsIn) -> SettingsOut:
if body.min_volume > body.max_volume:
raise HTTPException(
status_code=422, detail="min_volume must not exceed max_volume"
)
if not body.min_volume <= body.initial_volume <= body.max_volume:
raise HTTPException(
status_code=422, detail="initial_volume must lie between min and max"
)
general = app.config.general
# Applied live as well as saved: a parent lowering the ceiling expects the next
# song to be quieter, not the next boot.
for key, value in body.model_dump().items():
setattr(general, key, value)
app.player.set_volume_limits(general.min_volume, general.max_volume)
await asyncio.to_thread(write_settings, config_path, body)
await hub.broadcast_state()
return read_settings(general)
# ----------------------------------------------------------------- tippen
def _require_tippen() -> TippenRuntime:
if app.tippen is None:
raise HTTPException(status_code=404, detail="tippen not configured")
return app.tippen
@router.get("/tippen/curriculum")
def get_tippen_curriculum() -> TippenCurriculumOut:
runtime = _require_tippen()
return curriculum_out(runtime.curriculum, app.library)
@router.get("/tippen/progress")
def get_tippen_progress() -> TippenProgressOut:
return progress_out(_require_tippen().progress)
@router.put("/tippen/settings")
async def put_tippen_settings(body: TippenSettingsIn) -> TippenSettingsOut:
runtime = _require_tippen()
runtime.progress = runtime.progress.model_copy(
update={"settings": runtime.progress.settings.model_copy(update=body.model_dump())}
)
await asyncio.to_thread(runtime.save)
return TippenSettingsOut(
sound=runtime.progress.settings.sound,
keyboard_hint=runtime.progress.settings.keyboard_hint,
)
@router.post("/tippen/runs")
async def post_tippen_run(body: TippenRunIn) -> TippenRunOut:
runtime = _require_tippen()
if runtime.curriculum.lessons and not any(
lesson.id == body.lesson_id for lesson in runtime.curriculum.lessons
):
raise HTTPException(status_code=422, detail=f"no such lesson {body.lesson_id!r}")
result = await asyncio.to_thread(record_tippen_run, app, body)
await hub.broadcast_library()
return result
# -------------------------------------------------------------- IR remote
@router.get("/lirc")
def get_lirc_config() -> LircConfigOut:
if app.config.general.lirc is None:
raise HTTPException(status_code=404, detail="lirc not configured")
return LircConfigOut(connected=app.state.lirc_connected)
@router.get("/remote/mapping")
def get_remote_mapping() -> RemoteMappingOut:
return read_mapping(app.config.remote, app.library)
@router.put("/remote/mapping")
async def put_remote_mapping(body: RemoteMappingIn) -> RemoteMappingOut:
resolved: dict[Digit, RemoteSlotConfig] = {}
for digit, slot in body.slots.items():
found = (
app.library.get(slot.target)
if slot.target_kind == "album"
else app.library.latest_episode(slot.target)
)
if found is None:
raise HTTPException(
status_code=422,
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
)
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target)
app.config.remote = resolved
await asyncio.to_thread(write_mapping, config_path, resolved)
return read_mapping(app.config.remote, app.library)
# --------------------------------------------------------------- room control
#
# The browser never sees the Home Assistant token: it stays server-side, attached
# to every proxied request below. The browser only gets to know entity ids and
# display names (get_ha_config) and can ask this backend to relay a states read or
# a service call - the same shape of access the token itself grants, just without
# ever leaving the LAN device. Entity ids are not restricted to the configured
# list; that would only stop someone who already has enough access to open this
# unauthenticated API from asking Home Assistant about a different entity, which
# matches the rest of this API's "trusted LAN device" threat model.
def _require_ha() -> HaConfig:
ha = app.config.general.ha
if ha is None:
raise HTTPException(status_code=404, detail="ha not configured")
return ha
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
try:
upstream = await ha_client.request(
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
)
except httpx2.HTTPError as exc:
raise HTTPException(
status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc
return Response(
content=upstream.content,
status_code=upstream.status_code,
media_type=upstream.headers.get("content-type", "application/json"),
)
@router.get("/ha")
def get_ha_config() -> HaConfigOut:
ha = _require_ha()
return HaConfigOut(
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
)
@router.get("/ha/states/{entity_id}")
async def get_ha_state(entity_id: str) -> Response:
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
@router.post("/ha/services/{domain}/{service}")
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
return router

View File

@@ -0,0 +1,104 @@
"""Getting state out to every open browser tab.
State events only fire on *change*, so a tab that connects halfway through a track
would otherwise sit there knowing nothing. The fix is the one
:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting
broker: send a full snapshot on connect, then deltas.
Position is the exception to "everything goes on the bus". A progress bar wants it
twice a second; an event at that rate would flood the queue, the MQTT service and the
log to serve one front-end. So the hub reads it straight off the player on its own
timer, and only while something is playing.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import Any
from fastapi import WebSocket
from musicmouse.app import App
from musicmouse.events import Event, StateEvent
from musicmouse.services.web.state import snapshot
_log = logging.getLogger(__name__)
__all__ = ["StateHub"]
#: Twice a second: smooth enough once the client interpolates between frames, cheap
#: enough to leave running.
POSITION_INTERVAL = 0.5
class StateHub:
def __init__(self, app: App) -> None:
self.app = app
self._clients: set[WebSocket] = set()
self._unsubscribe: Any = None
# ------------------------------------------------------------------- lifecycle
def start(self) -> None:
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state)
def stop(self) -> None:
if self._unsubscribe is not None:
self._unsubscribe()
self._unsubscribe = None
async def run(self) -> None:
"""Push the playback position while anything is playing."""
while True:
await asyncio.sleep(POSITION_INTERVAL)
if not self._clients or not self.app.player.is_playing:
continue
await self.broadcast(
{
"type": "position",
"position": self.app.player.position,
"duration": self.app.player.duration,
}
)
# --------------------------------------------------------------------- clients
async def connect(self, socket: WebSocket) -> None:
await socket.accept()
self._clients.add(socket)
await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()})
def disconnect(self, socket: WebSocket) -> None:
self._clients.discard(socket)
# ------------------------------------------------------------------ publishing
async def broadcast(self, message: dict[str, Any]) -> None:
if not self._clients:
return
payload = json.dumps(message)
for socket in list(self._clients):
try:
await socket.send_text(payload)
except Exception:
# A tab that closed mid-send is normal, not an error worth logging loudly.
_log.debug("Dropping a websocket client that went away")
self._clients.discard(socket)
async def broadcast_state(self) -> None:
await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()})
async def broadcast_library(self) -> None:
await self.broadcast({"type": "library"})
# ------------------------------------------------------------------ internals
async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None:
with contextlib.suppress(Exception):
await socket.send_text(json.dumps(message))
async def _on_state(self, _event: Event) -> None:
await self.broadcast_state()

View File

@@ -0,0 +1,48 @@
"""Reading and writing the IR remote's number-key mapping.
Same job as :mod:`musicmouse.services.web.settings`, for a dict-shaped config section
rather than flat scalars: the ``remote:`` top-level key, not something under
``general``.
"""
from __future__ import annotations
from pathlib import Path
from musicmouse.config import Digit, RemoteSlotConfig
from musicmouse.library import MusicLibrary
from musicmouse.services.web.schemas import RemoteMappingOut, RemoteSlotOut
from musicmouse.services.web.settings import atomic_write, load_document
__all__ = ["read_mapping", "write_mapping"]
def _resolve(slot: RemoteSlotConfig, library: MusicLibrary) -> str | None:
if slot.target_kind == "album":
album = library.get(slot.target)
else:
album = library.latest_episode(slot.target)
return album.id if album else None
def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -> RemoteMappingOut:
slots = [
RemoteSlotOut(
digit=digit,
target_kind=slot.target_kind,
target=slot.target,
resolved_album_id=_resolve(slot, library),
)
for digit, slot in sorted(remote.items())
]
return RemoteMappingOut(slots=slots)
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
document = load_document(path)
remote = {
digit: {"target_kind": slot.target_kind, "target": slot.target}
for digit, slot in mapping.items()
}
document["remote"] = remote
atomic_write(path, document)

View File

@@ -0,0 +1,429 @@
"""What the browser sees.
Two rules shape these. The whole library ships in one response, because the design's
incremental A-Z search has to be instant and ~25 albums of metadata is under 100 kB -
so no track carries anything it does not need. And volume is a *percentage* here: the
configured ceiling is a parent's business, not a child's, so it never crosses this line.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from musicmouse.config import Digit
from musicmouse.library import Album
from musicmouse.library.analysis import TrackAnalysis
from musicmouse.library.models import LibraryTrack
from musicmouse.tippen.progress import AnimalId
from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
__all__ = [
"AlbumOut",
"HaConfigOut",
"HaDeviceOut",
"LibraryOut",
"LircConfigOut",
"PlayIn",
"PlayerStateOut",
"RemoteMappingIn",
"RemoteMappingOut",
"RemoteSlotIn",
"RemoteSlotOut",
"SeekIn",
"SettingsIn",
"SettingsOut",
"TippenCurriculumOut",
"TippenGhostStrokeOut",
"TippenKeyStatOut",
"TippenLessonOut",
"TippenLessonProgressOut",
"TippenProgressOut",
"TippenRewardOut",
"TippenRunIn",
"TippenRunOut",
"TippenSettingsIn",
"TippenSettingsOut",
"TippenStreakOut",
"TippenStrokeIn",
"TippenUnlockedRewardOut",
"TippenWorldOut",
"TrackCurvesOut",
"TrackDetailOut",
"TrackOut",
"UnlockHintOut",
"VolumeIn",
]
class AnalysisOut(BaseModel):
tempo: float | None = None
energy: float | None = None
valence: float | None = None
brightness: float | None = None
#: 0..1 confidence that `tempo` is an audible, steady beat - see `TrackAnalysis`.
pulse: float | None = None
beats: bool = False
@classmethod
def of(cls, analysis: TrackAnalysis | None) -> AnalysisOut | None:
if analysis is None or not analysis.is_analyzed:
return None
return cls(
tempo=analysis.tempo,
energy=analysis.energy,
valence=analysis.valence,
brightness=analysis.brightness,
pulse=analysis.pulse,
beats=analysis.beats,
)
class UnlockHintOut(BaseModel):
"""Which typing lesson unlocks a still-locked track, for "mention when this
unlocks" in the browse view."""
lesson_id: str
lesson_title: str
world_number: int
world_title: str
@classmethod
def of(cls, hint: UnlockHint) -> UnlockHintOut:
return cls(
lesson_id=hint.lesson_id,
lesson_title=hint.lesson_title,
world_number=hint.world_number,
world_title=hint.world_title,
)
class TrackOut(BaseModel):
title: str
duration: float
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
analysis: AnalysisOut | None = None
#: A reward-gated track not yet earned - the browse view shows a placeholder
#: instead of the real title.
locked: bool = False
unlock_hint: UnlockHintOut | None = None
@classmethod
def of(cls, track: LibraryTrack, lock: TrackLock | None = None) -> TrackOut:
return cls(
title=track.title,
duration=track.duration,
analysis=AnalysisOut.of(track.analysis),
locked=lock.locked if lock is not None else False,
unlock_hint=UnlockHintOut.of(lock.hint) if lock is not None and lock.hint else None,
)
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):
id: str
section: str
kind: str
title: str
artist: str
series: str | None
figure: str | None
category: str
colors: list[str]
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
tracks: list[TrackOut]
#: Every track is still locked - the browse view shows a question mark instead of
#: cover art. `False` for an album no typing reward ever targets.
locked: bool = False
@classmethod
def of(cls, album: Album, lock: AlbumLock | None = None) -> AlbumOut:
track_locks = lock.tracks if lock is not None else ()
return cls(
id=album.id,
section=album.section,
kind=album.kind,
title=album.title,
artist=album.artist,
series=album.series,
figure=album.figure,
category=album.category,
colors=list(album.colors),
has_cover=album.cover is not None,
cover_v=_cover_version(album),
duration=album.duration,
locked=lock.locked if lock is not None else False,
tracks=[
TrackOut.of(track, track_locks[i] if i < len(track_locks) else None)
for i, track in enumerate(album.tracks)
],
)
class LibraryOut(BaseModel):
albums: list[AlbumOut]
class TrackCurvesOut(BaseModel):
hop_seconds: float
energy: list[float]
valence: list[float]
drive: list[float]
class TrackDetailOut(BaseModel):
"""Beats + mood/drive curves for the one track currently playing - fetched
together since both are per-track detail the library payload never carries.
``curve`` is ``None`` only when the whole track failed analysis; ``times``/
``strengths`` are empty (not ``None``) for a track with no reliable beat, since a
free-tempo or spoken-word track still has real energy/valence/drive curves."""
times: list[float]
strengths: list[float]
curve: TrackCurvesOut | None = None
class ConnectionOut(BaseModel):
firmware: bool
mqtt: bool
lirc: bool
class PlayerStateOut(BaseModel):
playing: bool
album_id: str | None
album_title: str | None
artist: str | None
kind: str | None
track_index: int
track_title: str | None
track_count: int
position: float
duration: float
#: 0..100. The device's own range is deliberately not exposed.
volume: int
active_figure: str | None
connected: ConnectionOut
class PlayIn(BaseModel):
album_id: str
track_index: int = Field(default=0, ge=0)
class SeekIn(BaseModel):
position: float = Field(ge=0)
class VolumeIn(BaseModel):
"""Either an absolute percentage or a relative step; exactly one of the two."""
percent: int | None = Field(default=None, ge=0, le=100)
delta_percent: int | None = Field(default=None, ge=-100, le=100)
class SettingsOut(BaseModel):
min_volume: int
max_volume: int
initial_volume: int
volume_increment: int
button_leds_brightness: float
class SettingsIn(BaseModel):
min_volume: int = Field(ge=0, le=200)
max_volume: int = Field(ge=0, le=200)
initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100)
button_leds_brightness: float = Field(ge=0, le=1)
class HaDeviceOut(BaseModel):
entity_id: str
name: str | None
class HaConfigOut(BaseModel):
"""No ``url``/``token`` here on purpose - the browser talks to this backend, which
proxies to Home Assistant with the token attached server-side. See
``services/web/api.py``'s room-control section."""
devices: 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):
digit: str
target_kind: Literal["album", "series"]
target: str
#: The album this slot resolves to *right now* - the fixed album for "album" slots,
#: or today's newest episode for "series" slots. `None` when the target no longer
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
#: broken-assignment state instead of silently dropping it.
resolved_album_id: str | None
class RemoteMappingOut(BaseModel):
slots: list[RemoteSlotOut]
class RemoteSlotIn(BaseModel):
target_kind: Literal["album", "series"]
target: str
class RemoteMappingIn(BaseModel):
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
slots: dict[Digit, RemoteSlotIn]
# --------------------------------------------------------------------------- tippen
class TippenRewardOut(BaseModel):
"""What a lesson's ``unlocks:`` resolves to right now - `resolved` is `False` when
the configured path matches nothing in the current library (a moved or mistyped
path), so the frontend can show a broken-reward state instead of silently
dropping it."""
resolved: bool
album_id: str | None = None
has_cover: bool = False
kind: Literal["tracks", "episode"] | None = None
class TippenLessonOut(BaseModel):
id: str
world: int
number: int
title: str
subtitle: str
kind: str
new_keys: list[str]
spotlight_keys: list[str]
emphasis: Literal["isolated", "mixed"] | None
active_keys: list[str]
primary_mode: str
bonus_modes: list[str]
words: list[str]
is_drill: bool
chunks: int
chunk_size: int
reward: TippenRewardOut
class TippenWorldOut(BaseModel):
number: int
title: str
emoji: str
reward: str
class TippenCurriculumOut(BaseModel):
worlds: list[TippenWorldOut]
lessons: list[TippenLessonOut]
class TippenGhostStrokeOut(BaseModel):
key: str
at: float
class TippenLessonProgressOut(BaseModel):
unlocked: bool
runs: int
best_stars: int
best_animal: AnimalId | None
best_points: float
#: Derived, not stored - see ``LessonProgress.earned``.
earned: bool
ghost: list[TippenGhostStrokeOut] | None
class TippenKeyStatOut(BaseModel):
ema: float
attempts: int
errors: int
class TippenStreakOut(BaseModel):
days: int
last_played: str | None
class TippenSettingsOut(BaseModel):
sound: bool
keyboard_hint: Literal["auto", "on", "off"]
class TippenSettingsIn(BaseModel):
sound: bool
keyboard_hint: Literal["auto", "on", "off"]
class TippenProgressOut(BaseModel):
lessons: dict[str, TippenLessonProgressOut]
key_stats: dict[str, TippenKeyStatOut]
aquarium: list[str]
streak: TippenStreakOut
settings: TippenSettingsOut
class TippenStrokeIn(BaseModel):
key: str
expected: str
correct: bool
#: ms timestamp, from the run's own clock.
at: float
class TippenRunIn(BaseModel):
"""A run the client already graded - see ``grading.ts``. Grading itself stays
client-side; this only tells the backend what to do with progress."""
lesson_id: str
stars: Literal[0, 1, 2, 3]
animal: AnimalId
points: float
passed: bool
strokes: list[TippenStrokeIn] = Field(default_factory=list)
class TippenUnlockedRewardOut(BaseModel):
"""The literal track/episode this run's lesson names in its own ``unlocks:`` -
what the unlock animation shows, via the existing ``/api/albums/{id}/cover``."""
album_id: str
title: str
has_cover: bool
kind: Literal["album", "book", "podcast_episode"]
class TippenRunOut(BaseModel):
progress: TippenProgressOut
unlocked_lesson_id: str | None
unlocked_lesson_title: str | None
new_creature: str | None
is_new_best: bool
unlocked_reward: TippenUnlockedRewardOut | None

View File

@@ -0,0 +1,115 @@
"""The web front-end, as a :class:`~musicmouse.services.base.Service`.
Everything runs on the one event loop the rest of the app already has: uvicorn's
``Server.serve()`` is a coroutine, so there is no second loop and no thread. The
service owns no state of its own - it turns HTTP into intents on the bus, and bus
events into websocket frames.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from pathlib import Path
import httpx2
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from musicmouse.app import App
from musicmouse.config import WebConfig
from musicmouse.services.web.api import build_router
from musicmouse.services.web.hub import StateHub
_log = logging.getLogger(__name__)
__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(
app: App,
config: WebConfig,
config_path: Path,
ha_client: httpx2.AsyncClient | None = None,
) -> tuple[FastAPI, StateHub]:
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
calls through; tests inject one on a mock transport, production gets a real one
that :class:`WebService` closes on shutdown.
"""
hub = StateHub(app)
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
api.state.ha_client = ha_client
api.include_router(build_router(app, hub, config_path, ha_client))
if config.static_dir is not None:
if config.static_dir.is_dir():
# 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.
api.mount("/", _WebFiles(directory=config.static_dir, html=True), name="web")
else:
_log.warning(
"web.static_dir %s does not exist; serving the API only "
"(run `npm run build` in web/, or drop the setting)",
config.static_dir,
)
return api, hub
class WebService:
name = "web"
def __init__(self, app: App, config: WebConfig, config_path: Path) -> None:
self.config = config
self.api, self.hub = build_app(app, config, config_path)
async def run(self) -> None:
self.hub.start()
server = uvicorn.Server(
uvicorn.Config(
self.api,
host=self.config.host,
port=self.config.port,
# Access logs for a progress-bar poll every 500ms are noise.
access_log=False,
log_level="warning",
)
)
_log.info("Web front-end on http://%s:%d", self.config.host, self.config.port)
# serve() borrows SIGINT for a graceful shutdown and then re-raises it, so
# Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards.
position = asyncio.create_task(self.hub.run(), name="web-position")
serving = asyncio.create_task(server.serve(), name="web-serve")
try:
await asyncio.shield(serving)
except asyncio.CancelledError:
# Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept
# would leave its listening socket to the garbage collector, so ask it to
# wind down and wait for it to let the port go.
server.should_exit = True
with contextlib.suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(serving, timeout=5.0)
raise
finally:
position.cancel()
serving.cancel()
self.hub.stop()
await self.api.state.ha_client.aclose()

View File

@@ -0,0 +1,96 @@
"""Volume as a percentage, and the handful of settings parent mode may change.
Two jobs that both come down to "keep the config file's numbers out of the child's UI".
The volume mapping lives here rather than in the frontend so that MQTT, the rotary
encoder and the firmware carry on speaking device units, unaware that a browser is
using a different scale.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from musicmouse.config import GeneralConfig
from musicmouse.services.web.schemas import SettingsIn, SettingsOut
_log = logging.getLogger(__name__)
__all__ = [
"atomic_write",
"load_document",
"read_settings",
"to_device_volume",
"to_percent",
"write_settings",
]
def to_device_volume(percent: int, general: GeneralConfig) -> int:
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
span = general.max_volume - general.min_volume
if span <= 0:
return general.min_volume
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
def to_percent(volume: int, general: GeneralConfig) -> int:
"""The inverse, for the state snapshot."""
span = general.max_volume - general.min_volume
if span <= 0:
return 100
return max(0, min(100, round((volume - general.min_volume) / span * 100)))
def read_settings(general: GeneralConfig) -> SettingsOut:
return SettingsOut(
min_volume=general.min_volume,
max_volume=general.max_volume,
initial_volume=general.initial_volume,
volume_increment=general.volume_increment,
button_leds_brightness=general.button_leds_brightness,
)
def _yaml() -> YAML:
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
return yaml
def load_document(path: Path) -> Any:
"""The config file, round-trip parsed so it keeps its comments and formatting.
Shared by every writer that patches ``config.yml`` in place - a config that
explains itself is worth more than one a save could rewrite from scratch.
"""
with path.open(encoding="utf-8") as handle:
return _yaml().load(handle)
def atomic_write(path: Path, document: Any) -> None:
"""Write a round-trip-loaded document back, through a sibling temp file so an
interrupted save cannot truncate the real one."""
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
try:
with temp.open("w", encoding="utf-8") as handle:
_yaml().dump(document, handle)
temp.replace(path)
except BaseException:
temp.unlink(missing_ok=True)
raise
_log.info("Wrote settings to %s", path)
def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place."""
document = load_document(path)
general = document["general"]
for key, value in settings.model_dump().items():
general[key] = value
atomic_write(path, document)

View File

@@ -0,0 +1,48 @@
"""One place that answers "what is the mouse doing right now?".
Assembled on demand rather than accumulated from events. The join between what is
playing and which figure is on the reader is the same one the MQTT player entity makes:
read ``mouse.active_figure``, do not keep a second copy of it that can drift.
"""
from __future__ import annotations
from musicmouse.app import App
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut
from musicmouse.services.web.settings import to_percent
__all__ = ["snapshot"]
def snapshot(app: App) -> PlayerStateOut:
player = app.player
playlist = player.playlist
album = app.album_for(playlist)
index = player.track_index
# The player only knows a file path, and a tagged file's name is rarely its title
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
indexed = (
album.tracks[index] if album and 0 <= index < len(album.tracks) else None
)
track = player.current_track
return PlayerStateOut(
playing=player.is_playing,
album_id=album.id if album else None,
album_title=album.title if album else (playlist.name if playlist else None),
artist=album.artist if album else None,
kind=album.kind if album else None,
track_index=index,
track_title=indexed.title if indexed else (track.title if track else None),
track_count=len(playlist) if playlist else 0,
position=player.position,
# Likewise the duration: the player only has one once libVLC has opened the file.
duration=(indexed.duration if indexed else 0.0) or player.duration,
volume=to_percent(player.volume, app.config.general),
active_figure=app.mouse.active_figure,
connected=ConnectionOut(
firmware=app.mouse.connected,
mqtt=app.state.mqtt_connected,
lirc=app.state.lirc_connected,
),
)

View File

@@ -0,0 +1,185 @@
"""Converting between the typing game's domain objects and what the browser sees.
Same job as ``remote_settings.py`` for the reward mapping: resolving a configured
target against the live library. Unlike ``remote_settings.py``, there is no write-back
half here - the curriculum is a parent-edited file, never PUT by the app.
"""
from __future__ import annotations
from typing import Literal
from musicmouse.app import App
from musicmouse.library import MusicLibrary
from musicmouse.services.web.schemas import (
TippenCurriculumOut,
TippenGhostStrokeOut,
TippenKeyStatOut,
TippenLessonOut,
TippenLessonProgressOut,
TippenProgressOut,
TippenRewardOut,
TippenRunIn,
TippenRunOut,
TippenSettingsOut,
TippenStreakOut,
TippenUnlockedRewardOut,
TippenWorldOut,
)
from musicmouse.tippen.curriculum import Curriculum, Lesson, lesson_by_id
from musicmouse.tippen.progress import RunResult, Stroke, TypingProgress, record_run
from musicmouse.tippen.rewards import resolve_lesson_reward
from musicmouse.tippen.runtime import TippenRuntime
__all__ = ["curriculum_out", "progress_out", "record_tippen_run"]
def _reward_out(lesson: Lesson, library: MusicLibrary) -> TippenRewardOut:
reward = resolve_lesson_reward(lesson, library)
if reward is None:
return TippenRewardOut(resolved=False)
album = library.get(reward.album_id)
return TippenRewardOut(
resolved=True,
album_id=reward.album_id,
has_cover=album.cover is not None if album else False,
kind=reward.kind,
)
def curriculum_out(curriculum: Curriculum, library: MusicLibrary) -> TippenCurriculumOut:
return TippenCurriculumOut(
worlds=[
TippenWorldOut(
number=world.number, title=world.title, emoji=world.emoji, reward=world.reward
)
for world in curriculum.worlds
],
lessons=[
TippenLessonOut(
id=lesson.id,
world=lesson.world,
number=lesson.number,
title=lesson.title,
subtitle=lesson.subtitle,
kind=lesson.kind,
new_keys=list(lesson.new_keys),
spotlight_keys=list(lesson.spotlight_keys),
emphasis=lesson.emphasis,
active_keys=list(lesson.active_keys),
primary_mode=lesson.primary_mode,
bonus_modes=list(lesson.bonus_modes),
words=list(lesson.words),
is_drill=lesson.is_drill,
chunks=lesson.chunks,
chunk_size=lesson.chunk_size,
reward=_reward_out(lesson, library),
)
for lesson in curriculum.lessons
],
)
def progress_out(progress: TypingProgress) -> TippenProgressOut:
return TippenProgressOut(
lessons={
lesson_id: TippenLessonProgressOut(
unlocked=entry.unlocked,
runs=entry.runs,
best_stars=entry.best_stars,
best_animal=entry.best_animal,
best_points=entry.best_points,
earned=entry.earned,
ghost=(
[TippenGhostStrokeOut(key=g.key, at=g.at) for g in entry.ghost]
if entry.ghost
else None
),
)
for lesson_id, entry in progress.lessons.items()
},
key_stats={
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
for key, stat in progress.key_stats.items()
},
aquarium=list(progress.aquarium),
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
settings=TippenSettingsOut(
sound=progress.settings.sound, keyboard_hint=progress.settings.keyboard_hint
),
)
def _animation_kind(
library: MusicLibrary, album_id: str, reward_kind: Literal["tracks", "episode"]
) -> Literal["album", "book", "podcast_episode"]:
if reward_kind == "episode":
return "podcast_episode"
album = library.get(album_id)
return "book" if album is not None and album.kind == "book" else "album"
def _unlocked_reward_out(
runtime: TippenRuntime, library: MusicLibrary, lesson_id: str
) -> TippenUnlockedRewardOut | None:
"""The literal target of `lesson_id`'s own ``unlocks:`` - what the unlock
animation shows. Not "one of several" tracks: exactly the one the lesson names."""
lesson = lesson_by_id(runtime.curriculum, lesson_id)
if lesson is None:
return None
reward = resolve_lesson_reward(lesson, library)
if reward is None:
return None
album = library.get(reward.album_id)
if album is None:
return None
if reward.kind == "episode":
title = album.title
elif reward.until_index < len(album.tracks):
title = album.tracks[reward.until_index].title
else:
title = album.title
return TippenUnlockedRewardOut(
album_id=reward.album_id,
title=title,
has_cover=album.cover is not None,
kind=_animation_kind(library, reward.album_id, reward.kind),
)
def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
"""Grade-agnostic: the client already graded the run (see ``TippenRunIn``); this
only owns what happens to progress, and whether it just revealed a reward."""
assert app.tippen is not None
runtime = app.tippen
result = RunResult(
stars=body.stars,
animal=body.animal,
points=body.points,
passed=body.passed,
strokes=tuple(
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
),
)
outcome = record_run(runtime.progress, body.lesson_id, result, runtime.curriculum)
runtime.progress = outcome.progress
runtime.save()
unlocked_reward = (
_unlocked_reward_out(runtime, app.library, body.lesson_id) if outcome.newly_earned else None
)
unlocked_lesson = (
lesson_by_id(runtime.curriculum, outcome.unlocked_lesson_id)
if outcome.unlocked_lesson_id
else None
)
return TippenRunOut(
progress=progress_out(runtime.progress),
unlocked_lesson_id=outcome.unlocked_lesson_id,
unlocked_lesson_title=unlocked_lesson.title if unlocked_lesson else None,
new_creature=outcome.new_creature,
is_new_best=outcome.is_new_best,
unlocked_reward=unlocked_reward,
)

View File

@@ -0,0 +1,5 @@
"""Stand-ins for the hardware, so the whole app can run with no mouse and no audio."""
from musicmouse.simulator.fake_transport import FakeTransport
__all__ = ["FakeTransport"]

View File

@@ -0,0 +1,346 @@
"""One vocabulary for driving a simulated mouse, shared by three front-ends.
The same verbs are typed at the interactive prompt, listed in a scenario file, and
called from pytest - so a bug reproduced by hand becomes a regression test by pasting
the session into a ``.txt`` file.
place fuchs
wait 1s
press right
expect track 1
Under :class:`~musicmouse.clock.FakeClock` ``wait 1s`` costs microseconds, so scenarios
are cheap enough to run on every commit.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TypeVar
from musicmouse.app import App
from musicmouse.clock import Clock, FakeClock
from musicmouse.events import (
ButtonEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PreviousTrackRequested,
RfidTokenRead,
RotaryTurned,
SeekRequested,
SetVolumeRequested,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import (
NO_FIGURE_TAG,
Button,
ButtonAction,
LedZone,
RotaryDirection,
TouchButton,
)
from musicmouse.simulator.fake_transport import FakeTransport
_log = logging.getLogger(__name__)
__all__ = ["ExpectationError", "ScriptError", "SimulatorDriver"]
class ScriptError(Exception):
"""A scenario line could not be understood."""
class ExpectationError(AssertionError):
"""An ``expect`` line did not hold."""
@dataclass(frozen=True, slots=True)
class Duration:
seconds: float
@classmethod
def parse(cls, text: str) -> Duration:
raw = text.strip().lower()
scale = 1.0
for suffix, factor in (("ms", 0.001), ("s", 1.0), ("m", 60.0)):
if raw.endswith(suffix):
raw = raw.removesuffix(suffix)
scale = factor
break
try:
return cls(float(raw) * scale)
except ValueError:
raise ScriptError(f"{text!r} is not a duration (try '1s', '500ms', '2')") from None
class SimulatorDriver:
def __init__(self, app: App, transport: FakeTransport, clock: Clock) -> None:
self.app = app
self.transport = transport
self.clock = clock
# ------------------------------------------------------------------- inputs
async def place(self, figure: str) -> None:
"""Put a figure on the reader."""
try:
tag = self.app.config.figures[figure].id
except KeyError:
known = ", ".join(sorted(self.app.config.figures))
raise ScriptError(f"unknown figure {figure!r} (configured: {known})") from None
await self.tag(tag)
async def tag(self, tag_id: bytes) -> None:
self.transport.inject(RfidTokenRead(tag_id=tag_id, source="simulator"))
await self.settle()
async def remove(self) -> None:
"""Take whatever is on the reader off it."""
await self.tag(NO_FIGURE_TAG)
async def press(self, button: str, action: str = "pressed") -> None:
self.transport.inject(
ButtonEvent(
button=_enum_by_name(Button, button, "button"),
action=_enum_by_name(ButtonAction, action, "button action"),
source="simulator",
)
)
await self.settle()
async def touch(self, button: str) -> None:
self.transport.inject(
TouchButtonPressed(
button=_enum_by_name(TouchButton, button, "touch button"), source="simulator"
)
)
await self.settle()
async def release(self, button: str) -> None:
self.transport.inject(
TouchButtonReleased(
button=_enum_by_name(TouchButton, button, "touch button"), source="simulator"
)
)
await self.settle()
async def turn(self, steps: int) -> None:
"""Turn the rotary encoder; negative steps turn it down."""
self.transport.inject(
RotaryTurned(
position=0,
increment=abs(steps),
direction=RotaryDirection.UP if steps >= 0 else RotaryDirection.DOWN,
source="simulator",
)
)
await self.settle()
async def disconnect(self) -> None:
self.transport.disconnect()
self.app.mouse.on_disconnected("simulated disconnect")
await self.settle()
async def reconnect(self) -> None:
self.transport.reconnect()
self.app.mouse.on_connected()
await self.settle()
# ------------------------------------------------------------------ intents
async def emit_play(self) -> None:
self.app.bus.emit(PlayRequested(source="simulator"))
await self.settle()
async def emit_pause(self) -> None:
self.app.bus.emit(PauseRequested(source="simulator"))
await self.settle()
async def emit_next(self) -> None:
self.app.bus.emit(NextTrackRequested(source="simulator"))
await self.settle()
async def emit_prev(self) -> None:
self.app.bus.emit(PreviousTrackRequested(source="simulator"))
await self.settle()
async def set_volume(self, volume: int) -> None:
self.app.bus.emit(SetVolumeRequested(volume=volume, source="simulator"))
await self.settle()
async def play_album(self, title: str, track_index: int = 0) -> None:
"""Start a library album by title - what the web front-end does."""
album = next(
(a for a in self.app.library.albums if a.title.lower() == title.lower()), None
)
if album is None:
known = ", ".join(sorted(a.title for a in self.app.library.albums))
raise ScriptError(f"unknown album {title!r} (known: {known})")
self.app.bus.emit(
PlayAlbumRequested(
album_id=album.id, track_index=track_index, source="simulator"
)
)
await self.settle()
async def seek(self, position: float) -> None:
self.app.bus.emit(SeekRequested(position=position, source="simulator"))
await self.settle()
# -------------------------------------------------------------------- time
async def wait(self, seconds: float) -> None:
await self.clock.advance(seconds)
await self.settle()
async def settle(self) -> None:
"""Let every queued event, and everything it triggers, be handled."""
await self.app.bus.drain()
# ------------------------------------------------------------------ queries
def status(self) -> str:
player = self.app.player
track = player.current_track
return (
f"figure={self.app.mouse.active_figure or '-'} "
f"{'playing' if player.is_playing else 'paused'} "
f"track={player.track_index}{f' ({track.title})' if track else ''} "
f"volume={player.volume} "
f"buttons={self.app.mouse.button_led_brightness:.2f}"
)
def leds(self) -> str:
return "\n".join(
f" {zone:>5}: {self.app.mouse.effect(zone) or '-'}" for zone in LedZone
)
def check(self, key: str, value: str) -> None:
"""Assert one property. Raises :class:`ExpectationError` if it does not hold."""
actual = self._lookup(key)
expected = value.strip()
if actual != expected:
raise ExpectationError(f"expected {key} to be {expected!r}, but it is {actual!r}")
def _lookup(self, key: str) -> str:
player = self.app.player
match key:
case "playing":
return "true" if player.is_playing else "false"
case "figure":
return self.app.mouse.active_figure or "none"
case "playlist":
return player.playlist.name if player.playlist else "none"
case "album":
album = self.app.album_for(player.playlist)
return album.title if album else "none"
case "position":
return f"{player.position:.1f}"
case "track":
return str(player.track_index)
case "title":
track = player.current_track
return track.title if track else "none"
case "volume":
return str(player.volume)
case "brightness":
return f"{self.app.mouse.button_led_brightness:.2f}"
case "ring" | "mouse" | "shelf":
effect = self.app.mouse.effect(LedZone(key))
return type(effect).__name__ if effect is not None else "none"
case _:
raise ScriptError(
f"unknown property {key!r} (try: playing, figure, playlist, album, "
f"track, title, position, volume, brightness, ring, mouse, shelf)"
)
# ------------------------------------------------------------------ scripts
async def execute(self, line: str) -> str | None:
"""Run one scenario line. Returns text to show, if any."""
stripped = line.split("#", 1)[0].strip()
if not stripped:
return None
verb, *args = stripped.split()
return await self._dispatch(verb.lower(), args)
async def run_script(self, text: str) -> None:
for number, line in enumerate(text.splitlines(), start=1):
try:
if (output := await self.execute(line)) is not None:
print(output)
except (ScriptError, ExpectationError) as exc:
raise type(exc)(f"line {number}: {exc}\n {line.strip()}") from None
async def _dispatch(self, verb: str, args: list[str]) -> str | None:
match verb, args:
case ("place" | "rfid", [figure]):
await self.place(figure)
case ("remove", []):
await self.remove()
case ("press", [button]):
await self.press(button)
case ("press", [button, action]):
await self.press(button, action)
case ("touch", [button]):
await self.touch(button)
case ("release", [button]):
await self.release(button)
case ("turn", [steps]):
await self.turn(_int(steps))
case ("next", []):
await self.emit_next()
case ("prev" | "previous", []):
await self.emit_prev()
case ("play", []):
await self.emit_play()
case ("pause", []):
await self.emit_pause()
case ("volume", [level]):
await self.set_volume(_int(level))
case ("album", [*words]) if words:
await self.play_album(" ".join(words))
case ("seek", [position]):
await self.seek(Duration.parse(position).seconds)
case ("disconnect", []):
await self.disconnect()
case ("reconnect", []):
await self.reconnect()
case ("wait", [duration]):
await self.wait(Duration.parse(duration).seconds)
case ("expect", [key, *rest]) if rest:
self.check(key, " ".join(rest))
case ("status", []):
return self.status()
case ("leds", []):
return self.leds()
case _:
raise ScriptError(f"don't know how to {' '.join([verb, *args])!r}")
return None
def _int(text: str) -> int:
try:
return int(text)
except ValueError:
raise ScriptError(f"{text!r} is not a whole number") from None
_EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton)
def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT:
try:
return enum[name.upper()]
except KeyError:
options = ", ".join(member.name.lower() for member in enum)
raise ScriptError(f"unknown {what} {name!r} (try: {options})") from None
def fake_clock_for(app: App) -> FakeClock:
"""A clock whose ``advance`` also drains the bus, for deterministic scenarios."""
return FakeClock(idle=app.bus.drain)

View File

@@ -0,0 +1,171 @@
"""A player that behaves like :class:`~musicmouse.devices.player.VlcPlayer` without VLC.
Tracks advance on the injected :class:`~musicmouse.clock.Clock`, so under ``FakeClock``
a three-minute playlist plays out in microseconds and under ``RealClock`` you can watch
it tick along in the interactive simulator.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.devices.player import PlayerBase
from musicmouse.media import Playlist
_log = logging.getLogger(__name__)
__all__ = ["FakePlayer"]
DEFAULT_TRACK_DURATION = 5.0
class FakePlayer(PlayerBase):
def __init__(
self,
bus: EventBus,
*,
min_volume: int = 0,
max_volume: int = 100,
initial_volume: int = 50,
track_duration: float = DEFAULT_TRACK_DURATION,
clock: Clock | None = None,
) -> None:
super().__init__(
bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume
)
self._clock = clock or RealClock()
self.track_duration = track_duration
self._remaining = track_duration
self._started_at: float | None = None
self._timer: asyncio.Task[None] | None = None
# -------------------------------------------------------------------- state
@property
def position(self) -> float:
"""Derived from the clock, so it is exact under ``FakeClock`` too."""
remaining = self._remaining
if self._started_at is not None:
remaining = max(0.0, remaining - (self._clock.now() - self._started_at))
return max(0.0, self.track_duration - remaining)
@property
def duration(self) -> float:
return self.track_duration if self._playlist else 0.0
# ------------------------------------------------------------------ actions
def set_playlist(self, playlist: Playlist) -> None:
self._cancel_timer()
self._load_playlist(playlist)
self._remaining = self.track_duration
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None:
if self._playlist is None or not self._playlist:
_log.warning("Nothing to play: the playlist is empty")
return
if self._playing:
return
self._set_playing(True)
self._start_timer()
def play_from_start(self) -> None:
self.play_track(0)
def play_track(self, index: int) -> None:
if self._playlist is None or not self._playlist:
_log.warning("Nothing to play: the playlist is empty")
return
self._cancel_timer()
self._set_index(max(0, min(index, len(self._playlist) - 1)))
self._remaining = self.track_duration
self._set_playing(True)
self._start_timer()
def pause(self) -> None:
if not self._playing:
return
self._freeze()
self._set_playing(False)
def stop(self) -> None:
self._cancel_timer()
self._remaining = self.track_duration
self._set_playing(False)
def next_track(self) -> None:
self._skip(1)
def previous_track(self) -> None:
self._skip(-1)
def seek(self, position: float) -> None:
if self._playlist is None:
return
self._remaining = max(0.0, self.track_duration - max(0.0, position))
if self._playing:
self._start_timer()
def close(self) -> None:
self._cancel_timer()
# ---------------------------------------------------------------- internals
def _skip(self, offset: int) -> None:
if self._playlist is None or not self._playlist:
return
target = self._index + offset
if target < 0:
target = 0
if target >= len(self._playlist):
self._finish()
return
self._cancel_timer()
self._set_index(target)
self._remaining = self.track_duration
if self._playing:
self._start_timer()
def _finish(self) -> None:
self._cancel_timer()
self._remaining = self.track_duration
self._set_playing(False)
self._announce_playlist_finished()
def _start_timer(self) -> None:
self._cancel_timer()
self._started_at = self._clock.now()
self._timer = asyncio.create_task(self._await_track_end(), name="fake-player-track")
def _cancel_timer(self) -> None:
if self._timer is not None:
self._timer.cancel()
self._timer = None
self._started_at = None
def _freeze(self) -> None:
if self._started_at is not None:
self._remaining = max(0.0, self._remaining - (self._clock.now() - self._started_at))
self._cancel_timer()
async def _await_track_end(self) -> None:
with contextlib.suppress(asyncio.CancelledError):
await self._clock.sleep(self._remaining)
self._timer = None
self._started_at = None
self._on_track_end()
def _on_track_end(self) -> None:
assert self._playlist is not None
if self._index + 1 < len(self._playlist):
self._set_index(self._index + 1)
self._remaining = self.track_duration
self._start_timer()
else:
self._finish()

View File

@@ -0,0 +1,105 @@
"""A :class:`~musicmouse.devices.transport.Transport` that decodes what it is told.
Everything written goes through the real encoder and comes back through the real
decoder, so simulated hardware exercises the wire codec instead of bypassing it - and
what you see reported is what the firmware would actually have been asked to do.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from musicmouse.devices.wire import (
HostCommand,
HostFrameDecoder,
ProtocolError,
SetButtonBrightness,
SetEffect,
encode_input_event,
)
from musicmouse.effects import LedEffect
from musicmouse.events import InputEvent
from musicmouse.hardware import Button, LedZone
_log = logging.getLogger(__name__)
__all__ = ["FakeTransport"]
class FakeTransport:
def __init__(self, *, on_command: Callable[[HostCommand], None] | None = None) -> None:
self._decoder = HostFrameDecoder()
self._connected = True
self._feed: Callable[[bytes], None] | None = None
self.on_command = on_command
self.commands: list[HostCommand] = []
self.dropped_bytes = 0
# ------------------------------------------------------------------ transport
@property
def connected(self) -> bool:
return self._connected
def write(self, data: bytes) -> None:
if not self._connected:
self.dropped_bytes += len(data)
return
self._decoder.push(data)
while True:
try:
command = self._decoder.take()
except ProtocolError:
_log.exception("Simulated firmware could not parse a frame")
continue
if command is None:
return
self.commands.append(command)
_log.debug("FW <- %r", command)
if self.on_command is not None:
self.on_command(command)
# -------------------------------------------------------------- link control
def attach(self, feed: Callable[[bytes], None]) -> None:
"""Register the device's ``feed`` so injected events reach it."""
self._feed = feed
def inject(self, event: InputEvent) -> None:
"""Deliver ``event`` as if the firmware had sent it."""
if self._feed is None:
raise RuntimeError("FakeTransport.inject() before attach()")
if not self._connected:
_log.debug("Dropping injected %r: link is down", event)
return
self._feed(encode_input_event(event))
def disconnect(self) -> None:
self._connected = False
def reconnect(self) -> None:
self._connected = True
# ------------------------------------------------------------------ queries
def effect(self, zone: LedZone) -> LedEffect | None:
"""The most recent effect sent to ``zone`` - last write wins."""
for command in reversed(self.commands):
if isinstance(command, SetEffect) and command.zone == zone:
return command.effect
return None
def brightness(self, button: Button = Button.LEFT) -> float | None:
for command in reversed(self.commands):
if isinstance(command, SetButtonBrightness) and command.button == button:
return command.brightness
return None
def effects_for(self, zone: LedZone) -> list[LedEffect]:
return [c.effect for c in self.commands if isinstance(c, SetEffect) and c.zone == zone]
def clear(self) -> None:
self.commands.clear()
self.dropped_bytes = 0

View File

@@ -0,0 +1,96 @@
"""Assemble the whole app against fake hardware.
This is the real bus, the real device, the real reactions - only the serial link and
VLC are substituted. That is what makes a scenario meaningful: everything between the
tag being read and the LED bytes being written is production code.
"""
from __future__ import annotations
from dataclasses import dataclass
from musicmouse.app import App
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, FakeClock
from musicmouse.config import Config
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.library import MusicLibrary
from musicmouse.reactions import register_all
from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer
from musicmouse.simulator.fake_transport import FakeTransport
from musicmouse.tippen.runtime import TippenRuntime
__all__ = ["Simulation", "build_simulation"]
@dataclass
class Simulation:
app: App
driver: SimulatorDriver
transport: FakeTransport
player: FakePlayer
clock: Clock
bus: EventBus
async def aclose(self) -> None:
self.player.close()
await self.bus.stop()
async def build_simulation(
config: Config,
*,
clock: Clock | None = None,
track_duration: float = DEFAULT_TRACK_DURATION,
library: MusicLibrary | None = None,
tippen: TippenRuntime | None = None,
) -> Simulation:
bus = EventBus()
await bus.start()
if clock is None:
clock = FakeClock(idle=bus.drain)
transport = FakeTransport()
mouse = MusicMouseDevice(bus, transport, config.tag_map, port="simulated")
transport.attach(mouse.feed)
player = FakePlayer(
bus,
clock=clock,
track_duration=track_duration,
**FakePlayer.volume_kwargs(config.general),
)
if library is None:
library_config = config.general.library
library = await MusicLibrary.build(
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
figure_kinds=config.figure_kinds,
)
app = App(
config=config,
bus=bus,
mouse=mouse,
player=player,
library=library,
playlists=library.figure_playlists(),
clock=clock,
tippen=tippen,
)
register_all(bus, app)
mouse.on_connected()
await bus.drain()
return Simulation(
app=app,
driver=SimulatorDriver(app, transport, clock),
transport=transport,
player=player,
clock=clock,
bus=bus,
)

View File

@@ -0,0 +1,96 @@
"""The interactive simulator prompt.
Runs the whole app - bus, device, reactions, MQTT if configured - against fake
hardware, and lets you poke it by hand while watching the events go past.
"""
from __future__ import annotations
import asyncio
import contextlib
from musicmouse.events import (
ActiveFigureChanged,
Event,
InputEvent,
LedEffectChanged,
PlaybackChanged,
StateEvent,
TrackChanged,
VolumeChanged,
)
from musicmouse.simulator.driver import ExpectationError, ScriptError
from musicmouse.simulator.harness import Simulation
__all__ = ["run_repl"]
PROMPT = "musicmouse> "
HELP = """\
place <figure> put a figure on the reader remove
press <left|right|rotary> [action] turn <+n|-n>
touch <left_ear|right_ear|left_foot|right_foot> release <same>
play pause next prev volume <0-100>
disconnect / reconnect simulate the USB cable
wait <1s|500ms|2> let time pass
status one-line summary leds
expect <key> <value> assert (playing, figure, playlist, track, title,
volume, brightness, ring, mouse, shelf)
help this text quit
"""
def _describe(event: Event) -> str | None:
"""A compact one-liner, or None for events too noisy to show."""
match event:
case LedEffectChanged(zone=zone, effect=effect):
return f" led {zone:>5}: {effect}"
case PlaybackChanged(playing=playing, figure=figure):
state = "playing" if playing else "paused"
return f" play {state}{f' [{figure}]' if figure else ''}"
case TrackChanged(index=index, track=track):
return f" play track {index}" + (f": {track.title}" if track else "")
case VolumeChanged(volume=volume):
return f" vol {volume}"
case ActiveFigureChanged(figure=figure):
return f" rfid {figure or '(removed)'}"
case InputEvent():
return f" in {event}"
case StateEvent():
return f" state {event}"
case _:
return None
async def run_repl(sim: Simulation) -> None:
print("MusicMouse simulator - no hardware, no audio. 'help' for commands.\n")
sim.bus.subscribe_all(_print_event)
print(sim.driver.status())
while True:
try:
line = await asyncio.to_thread(input, PROMPT)
except (EOFError, KeyboardInterrupt):
print()
return
command = line.strip().lower()
if command in {"quit", "exit", "q"}:
return
if command in {"help", "?"}:
print(HELP, end="")
continue
try:
if (output := await sim.driver.execute(line)) is not None:
print(output)
except (ScriptError, ExpectationError) as exc:
print(f"! {exc}")
except Exception as exc: # the prompt must survive anything
print(f"! {type(exc).__name__}: {exc}")
def _print_event(event: Event) -> None:
if (text := _describe(event)) is not None:
with contextlib.suppress(OSError):
print(text)

View File

@@ -0,0 +1,24 @@
"""Run a scenario file against a simulated mouse."""
from __future__ import annotations
import logging
from pathlib import Path
from musicmouse.simulator.harness import Simulation
_log = logging.getLogger(__name__)
__all__ = ["run_script_file"]
async def run_script_file(sim: Simulation, path: Path) -> None:
"""Execute every line of ``path``.
Raises:
ScriptError: on a line that cannot be understood.
ExpectationError: on an ``expect`` that does not hold.
"""
_log.info("Running scenario %s", path)
await sim.driver.run_script(path.read_text(encoding="utf-8"))
_log.info("Scenario %s passed", path.name)

View File

@@ -0,0 +1,34 @@
"""The typing game: curriculum, progress and reward-unlock resolution.
See ``curriculum.py`` (the lesson plan, loaded from a YAML file named by
``general.tippen.curriculum_file``), ``progress.py`` (a JSON sidecar recording what has
been played and passed), ``rewards.py`` (turning a lesson's ``unlocks:`` into which
library tracks/episodes are still locked) and ``runtime.py`` (wiring the three
together at startup).
"""
from __future__ import annotations
from musicmouse.tippen.curriculum import Curriculum, CurriculumError, Lesson, World, load_curriculum
from musicmouse.tippen.progress import RecordOutcome, RunResult, Stroke, TypingProgress, record_run
from musicmouse.tippen.rewards import AlbumLock, LockState, ResolvedReward, compute_lock_state
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
__all__ = [
"AlbumLock",
"Curriculum",
"CurriculumError",
"Lesson",
"LockState",
"RecordOutcome",
"ResolvedReward",
"RunResult",
"Stroke",
"TippenRuntime",
"TypingProgress",
"World",
"build_tippen_runtime",
"compute_lock_state",
"load_curriculum",
"record_run",
]

View File

@@ -0,0 +1,339 @@
"""The typing game's lesson plan: a server-side mirror of tippen's ``curriculum.ts``.
Content lives in a YAML file named by ``general.tippen.curriculum_file``; this module
only loads, validates and derives it - the same split the frontend used to do entirely
on its own before progress (and therefore "has this lesson been passed") moved to the
backend, which is also what reward unlocking needs the derived lesson list for.
Validation follows this codebase's own rule (see ``musicmouse.config``): unknown keys
are rejected and every problem in the file is collected and reported at once, not one
``ValueError`` per run.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
_log = logging.getLogger(__name__)
__all__ = [
"CREATURE_IDS",
"CreatureId",
"Curriculum",
"CurriculumError",
"Lesson",
"World",
"first_lesson_id",
"lesson_by_id",
"load_curriculum",
"next_lesson",
"world_reward",
]
LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"]
ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"]
CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
#: 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/``.
CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
"clownfish",
"octopus",
"seahorse",
"turtle",
"pearlmussel",
)
#: Which modes make sense for a kind - letters rounds are single keys, so only the
#: arcade mode fits; only words and sentences are long enough for a race.
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
"letters": ("bubbles",),
"fragments": ("dive", "feed"),
"words": ("dive", "feed", "race"),
"sentences": ("dive", "race"),
}
#: The home row, left to right - see ``tippen/src/lib/fingers.ts``.
HOME_ROW: Final[tuple[str, ...]] = ("a", "s", "d", "f", "j", "k", "l", "ö")
SPACE_KEY: Final = " "
class CurriculumError(Exception):
"""Raised with an already human-readable, multi-line message."""
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid")
class _YamlLesson(_Strict):
title: str
subtitle: str
kind: LessonKind | None = None
keys: tuple[str, ...] | None = None
drill: bool = False
mode: ModeId | None = None
words: tuple[str, ...] | None = None
#: A library path (``~`` allowed) this lesson unlocks, inclusive of the track or
#: episode it names - see ``musicmouse.tippen.rewards``.
unlocks: str | None = None
class _YamlWorld(_Strict):
number: int
title: str
emoji: str
reward: CreatureId
lessons: tuple[_YamlLesson, ...]
class _YamlRoot(_Strict):
worlds: tuple[_YamlWorld, ...]
@model_validator(mode="after")
def _check_plan(self) -> _YamlRoot:
problems: list[str] = []
if len(self.worlds) != len(CREATURE_IDS):
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
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:
if world.reward in seen_rewards:
problems.append(f"world {world.number} reuses reward {world.reward!r}")
seen_rewards.add(world.reward)
for i, lesson in enumerate(world.lessons):
where = f"world {world.number}, lesson {i + 1} ({lesson.title!r})"
words = lesson.words or ()
kind: LessonKind = lesson.kind or "letters"
if words and lesson.kind is None:
problems.append(f"{where}: has words but no explicit kind")
if kind == "letters" and words:
problems.append(f'{where}: kind "letters" cannot have words')
if kind != "letters" and not words:
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
if lesson.drill and lesson.keys:
problems.append(f"{where}: a drill cannot also introduce keys")
new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
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]:
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
if problems:
plural = "s" if len(problems) != 1 else ""
raise ValueError(
f"{len(problems)} problem{plural} in the curriculum file:\n"
+ "\n".join(f" {p}" for p in problems)
)
return self
def _format_curriculum_errors(error: ValidationError) -> str:
lines: list[str] = []
for entry in error.errors():
message = entry["msg"]
for prefix in ("Value error, ", "Assertion failed, "):
message = message.removeprefix(prefix)
location = ".".join(
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
).replace(".[", "[")
if entry["type"] == "extra_forbidden":
message = f"unknown option: {location}"
elif entry["type"] == "missing":
message = f"required: {location}"
lines.append(message)
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class World:
number: int
title: str
emoji: str
reward: CreatureId
@dataclass(frozen=True, slots=True)
class Lesson:
id: str
world: int
number: int
title: str
subtitle: str
kind: LessonKind
new_keys: tuple[str, ...]
spotlight_keys: tuple[str, ...]
emphasis: Literal["isolated", "mixed"] | None
active_keys: tuple[str, ...]
primary_mode: ModeId
bonus_modes: tuple[ModeId, ...]
words: tuple[str, ...]
is_drill: bool
chunks: int
chunk_size: int
#: Raw config value, not yet resolved against the library - see
#: ``musicmouse.tippen.rewards.resolve_lesson_reward``.
unlocks: str | None
@dataclass(frozen=True, slots=True)
class Curriculum:
worlds: tuple[World, ...]
lessons: tuple[Lesson, ...]
def lesson_by_id(curriculum: Curriculum, lesson_id: str) -> Lesson | None:
return next((lesson for lesson in curriculum.lessons if lesson.id == lesson_id), None)
def next_lesson(curriculum: Curriculum, lesson_id: str) -> Lesson | None:
ids = [lesson.id for lesson in curriculum.lessons]
try:
index = ids.index(lesson_id)
except ValueError:
return None
return curriculum.lessons[index + 1] if index + 1 < len(curriculum.lessons) else None
def world_reward(curriculum: Curriculum, world_number: int) -> CreatureId | None:
return next((world.reward for world in curriculum.worlds if world.number == world_number), None)
def first_lesson_id(curriculum: Curriculum) -> str | None:
return curriculum.lessons[0].id if curriculum.lessons else None
def _length_for(world: int, kind: LessonKind) -> tuple[int, int]:
"""Line length by world and kind - a full block of text per round."""
if kind == "fragments":
return 16, 4 # 64
if kind == "sentences":
return 10, 4 # ten whole sentences
if kind == "words":
return 25, 4
# kind == "letters"
if world == 1:
return 24, 3 # 72 characters
if world == 2:
return 25, 4 # 100
return 30, 4 # 120, world 3 onward
def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
lessons: list[Lesson] = []
active: set[str] = set()
seen_before: set[str] = set()
for world in worlds:
for entry in world.lessons:
keys = entry.keys or ()
new_keys = tuple(key for key in keys if key not in seen_before)
for key in keys:
seen_before.add(key)
active.add(key)
# The space-bar lesson activates every home key - belt-and-braces
# confirmation that the four finger-pair lessons before it covered all eight.
if SPACE_KEY in keys:
active.update(HOME_ROW)
# Shift is not a character the generator can emit, so it never enters
# active_keys.
active_keys = tuple(sorted(key for key in active if key != ""))
words = entry.words or ()
kind: LessonKind = entry.kind or "letters"
is_drill = entry.drill
if kind != "letters" or is_drill:
emphasis: Literal["isolated", "mixed"] | None = None
elif new_keys:
emphasis = "isolated"
else:
emphasis = "mixed"
primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive")
# Bonus replays are only ever feed/race - dive is the plain default, and
# bubbles is the only letters-round arcade mode.
bonus_modes = tuple(
mode
for mode in ELIGIBLE_MODES[kind]
if mode in ("feed", "race") and mode != primary_mode
)
chunks, chunk_size = _length_for(world.number, kind)
lessons.append(
Lesson(
id=f"l{len(lessons) + 1:02d}",
world=world.number,
number=len(lessons) + 1,
title=entry.title,
subtitle=entry.subtitle,
kind=kind,
new_keys=new_keys,
# 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,
active_keys=active_keys,
primary_mode=primary_mode,
bonus_modes=bonus_modes,
words=words,
is_drill=is_drill,
chunks=chunks,
chunk_size=chunk_size,
unlocks=entry.unlocks,
)
)
return tuple(lessons)
def load_curriculum(path: Path) -> Curriculum:
"""Load and validate a curriculum file.
Raises:
CurriculumError: with a message that can be printed straight to the terminal.
"""
path = Path(path)
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise CurriculumError(f"Cannot read curriculum file {path}: {exc.strerror}") from exc
try:
data = YAML(typ="safe").load(text)
except YAMLError as exc:
raise CurriculumError(f"{path} is not valid YAML:\n {exc}") from exc
if not isinstance(data, dict):
raise CurriculumError(
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
)
try:
root = _YamlRoot.model_validate(data)
except ValidationError as exc:
raise CurriculumError(f"{path}\n{_format_curriculum_errors(exc)}") from exc
worlds = tuple(
World(number=world.number, title=world.title, emoji=world.emoji, reward=world.reward)
for world in root.worlds
)
return Curriculum(worlds=worlds, lessons=_build_lessons(root.worlds))

View File

@@ -0,0 +1,327 @@
"""Server-side typing progress: a JSON sidecar, atomic-written, defensively loaded.
Same convention as everywhere else this codebase persists something outside
``config.yml`` - see ``musicmouse.library.podcast_feeds``'s failed-downloads sidecar. A
missing or corrupt file is just a fresh start, never a crash: nothing here is precious
enough to raise over.
``record_run`` is a near-verbatim port of tippen's own (client-side, until now)
``recordRun`` in ``tippen/src/lib/progress.ts`` - grading a keystroke-by-keystroke run
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
graded result arrives.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from musicmouse.tippen.curriculum import (
CreatureId,
Curriculum,
first_lesson_id,
lesson_by_id,
next_lesson,
world_reward,
)
_log = logging.getLogger(__name__)
__all__ = [
"DILIGENCE_ATTEMPTS",
"AnimalId",
"KeyStat",
"LessonProgress",
"RecordOutcome",
"RunResult",
"Streak",
"Stroke",
"TippenSettings",
"TypingProgress",
"fresh_progress",
"load_progress",
"record_run",
"save_progress",
"today",
]
#: How many attempts at one lesson unlock the next regardless of score - the safety
#: valve against getting stuck on a single stubborn key.
DILIGENCE_ATTEMPTS = 5
AnimalId = Literal[
"snail",
"crab",
"turtle",
"jellyfish",
"fish",
"penguin",
"seal",
"dolphin",
"shark",
"orca",
]
# ------------------------------------------------------------------------- domain
@dataclass(frozen=True, slots=True)
class Stroke:
key: str
expected: str
correct: bool
#: ms timestamp, from the run's own clock.
at: float
@dataclass(frozen=True, slots=True)
class RunResult:
"""What the client already graded a finished run as - see ``grading.ts``."""
stars: Literal[0, 1, 2, 3]
animal: AnimalId
points: float
passed: bool
strokes: tuple[Stroke, ...]
# --------------------------------------------------------------------------- store
class GhostStroke(BaseModel):
key: str
at: float
class LessonProgress(BaseModel):
unlocked: bool = False
runs: int = 0
best_stars: Literal[0, 1, 2, 3] = 0
best_animal: AnimalId | None = None
best_points: float = 0
#: Best-run keystrokes, replayed as the opponent in race mode.
ghost: list[GhostStroke] | None = None
@property
def earned(self) -> bool:
"""Passed on its own merits, or given up on gracefully after enough tries.
Always derived from ``best_stars``/``runs`` rather than stored, so it can never
drift from the rule that computed it. Two stars is the same "passed" gate the
frontend's ``isPassed`` uses (accuracy >= 93 %); the fifth attempt is the
diligence fallback.
"""
return self.best_stars >= 2 or self.runs >= DILIGENCE_ATTEMPTS
class KeyStat(BaseModel):
#: Smoothed reaction time in ms - keybr's exponential moving average.
ema: float = 0
attempts: int = 0
errors: int = 0
class Streak(BaseModel):
days: int = 0
last_played: str | None = None
class TippenSettings(BaseModel):
sound: bool = True
keyboard_hint: Literal["auto", "on", "off"] = "auto"
class TypingProgress(BaseModel):
version: Literal[1] = 1
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
#: Pets that have moved into the aquarium, in the order they arrived.
aquarium: list[CreatureId] = Field(default_factory=list)
streak: Streak = Field(default_factory=Streak)
settings: TippenSettings = Field(default_factory=TippenSettings)
def fresh_progress(curriculum: Curriculum) -> TypingProgress:
first = first_lesson_id(curriculum)
lessons = {
lesson.id: LessonProgress(unlocked=lesson.id == first) for lesson in curriculum.lessons
}
return TypingProgress(lessons=lessons)
def load_progress(path: Path, curriculum: Curriculum) -> TypingProgress:
"""A missing or corrupt file is just a fresh start - never fatal."""
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return fresh_progress(curriculum)
except OSError as exc:
_log.warning("Could not read %s; starting fresh: %s", path, exc)
return fresh_progress(curriculum)
try:
progress = TypingProgress.model_validate(json.loads(raw))
except (ValueError, ValidationError) as exc:
_log.warning("Could not parse %s; starting fresh: %s", path, exc)
return fresh_progress(curriculum)
# A lesson added to the curriculum since the last save needs an entry too, and the
# first lesson is unlocked by definition - a save that says otherwise is wrong.
fresh = fresh_progress(curriculum)
lessons = dict(progress.lessons)
for lesson_id, blank in fresh.lessons.items():
lessons.setdefault(lesson_id, blank)
first = first_lesson_id(curriculum)
if first is not None and first in lessons:
lessons[first] = lessons[first].model_copy(update={"unlocked": True})
return progress.model_copy(update={"lessons": lessons})
def save_progress(path: Path, progress: TypingProgress) -> None:
"""Write through a sibling temp file so an interrupted save cannot truncate the real
one - the same atomic-write idiom used throughout this codebase."""
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
try:
temp.write_text(progress.model_dump_json(), encoding="utf-8")
temp.replace(path)
except BaseException:
temp.unlink(missing_ok=True)
raise
# --------------------------------------------------------------------------- record
def today(day: date | None = None) -> str:
"""Today (or `day`) as YYYY-MM-DD."""
return (day or date.today()).isoformat()
def _is_better(candidate: RunResult, best_stars: int, best_points: float) -> bool:
"""Stars come first, points break the tie - a careful run is never displaced by a
sloppy fast one."""
if candidate.stars != best_stars:
return candidate.stars > best_stars
return candidate.points > best_points
def _fold_key_stats(stats: dict[str, KeyStat], strokes: tuple[Stroke, ...]) -> dict[str, KeyStat]:
"""Fold the per-key reaction times of a run into the stored averages. The EMA weight
of 0.3 is slow enough that one distracted run does not rewrite what is known."""
next_stats = dict(stats)
previous_at: float | None = None
for stroke in strokes:
key = stroke.expected.lower()
current = next_stats.get(key, KeyStat())
gap = None if previous_at is None else stroke.at - previous_at
previous_at = stroke.at
# Reaction times over two seconds are a pause for thought, not a measure of the
# key, so they are ignored rather than averaged in.
ema = current.ema
if gap is not None and gap < 2000:
ema = gap if current.ema == 0 else current.ema * 0.7 + gap * 0.3
next_stats[key] = KeyStat(
ema=ema,
attempts=current.attempts + 1,
errors=current.errors + (0 if stroke.correct else 1),
)
return next_stats
def _bump_streak(streak: Streak, day: str) -> Streak:
if streak.last_played == day:
return streak
yesterday = (date.fromisoformat(day) - timedelta(days=1)).isoformat()
consecutive = streak.last_played == yesterday
# A missed day restarts at 1, never at 0 - playing today always counts for something.
return Streak(days=streak.days + 1 if consecutive else 1, last_played=day)
@dataclass(frozen=True, slots=True)
class RecordOutcome:
progress: TypingProgress
#: Set when this run unlocked the following lesson, for the lesson-map celebration.
unlocked_lesson_id: str | None
#: Whether *this* lesson's pass threshold was crossed by this run - the signal a
#: reward attached to it (``Lesson.unlocks``) should now be checked.
newly_earned: bool
#: Set when this run finished a world, for the creature that moved in.
new_creature: CreatureId | None
is_new_best: bool
def record_run(
progress: TypingProgress,
lesson_id: str,
result: RunResult,
curriculum: Curriculum,
day: str | None = None,
) -> RecordOutcome:
"""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
fifth attempt whatever the score. Speed is nowhere in it.
"""
day = day or today()
before = progress.lessons.get(lesson_id) or LessonProgress(unlocked=True)
was_earned = before.earned
runs = before.runs + 1
improved = _is_better(result, before.best_stars, before.best_points)
lessons = dict(progress.lessons)
updated = before.model_copy(
update={
"runs": runs,
"best_stars": result.stars if improved else before.best_stars,
"best_animal": result.animal if improved else before.best_animal,
"best_points": result.points if improved else before.best_points,
"ghost": (
[GhostStroke(key=s.key, at=s.at) for s in result.strokes if s.correct]
if improved
else before.ghost
),
}
)
lessons[lesson_id] = updated
newly_earned = updated.earned and not was_earned
nxt = next_lesson(curriculum, lesson_id)
unlocked_lesson_id: str | None = None
if newly_earned and nxt is not None and not lessons.get(nxt.id, LessonProgress()).unlocked:
next_before = lessons.get(nxt.id, LessonProgress())
lessons[nxt.id] = next_before.model_copy(update={"unlocked": True})
unlocked_lesson_id = nxt.id
# Finishing the last lesson of a world releases that world's creature. Checked
# against the aquarium so it is only ever awarded once.
aquarium = list(progress.aquarium)
new_creature: CreatureId | None = None
if unlocked_lesson_id and nxt is not None:
finished = lesson_by_id(curriculum, lesson_id)
if finished is not None and nxt.world != finished.world:
reward = world_reward(curriculum, finished.world)
if reward and reward not in aquarium:
aquarium.append(reward)
new_creature = reward
updated_progress = progress.model_copy(
update={
"lessons": lessons,
"aquarium": aquarium,
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
"streak": _bump_streak(progress.streak, day),
}
)
return RecordOutcome(
progress=updated_progress,
unlocked_lesson_id=unlocked_lesson_id,
newly_earned=newly_earned,
new_creature=new_creature,
is_new_best=improved,
)

View File

@@ -0,0 +1,247 @@
"""Turning a lesson's ``unlocks: <path>`` into which tracks/episodes are still locked.
Two shapes, one config key:
* A music album or audiobook (a "folder" section - one album, several tracks): the path
names one track, and unlocks every track up to and including it - tracks
``0..index`` inclusive, by ordinal position in ``Album.tracks``.
* A podcast show (an "episode" section - one album *per episode file*): the path names
one episode. Its show's episodes are sorted chronologically (oldest first, by the
``YYYYMMDD - Title`` filename convention ``library.sections`` documents - the same
fact ``MusicLibrary.latest_episode`` already relies on), and every episode up to and
including the target unlocks - the same "up to this one" shape, just over episodes
instead of tracks.
Lock state is never persisted: it is always recomputed fresh from (curriculum reward
config) x (current progress) x (the live library), matching how
``services/web/state.py``'s ``snapshot`` assembles player state on demand rather than
keeping a second copy in sync. An album or show no lesson ever names is simply never
locked - the reward system only ever restricts what it explicitly targets.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from musicmouse.library import MusicLibrary
from musicmouse.library.models import Album
from musicmouse.library.sections import SECTIONS
from musicmouse.tippen.curriculum import Curriculum, Lesson
from musicmouse.tippen.progress import TypingProgress
_log = logging.getLogger(__name__)
__all__ = [
"AlbumLock",
"LockState",
"ResolvedReward",
"TrackLock",
"UnlockHint",
"compute_lock_state",
"resolve_all",
"resolve_lesson_reward",
]
@dataclass(frozen=True, slots=True)
class ResolvedReward:
"""Where a lesson's ``unlocks`` path landed in the live library."""
kind: Literal["tracks", "episode"]
#: The album a "tracks" reward lives in, or the specific episode-album an "episode"
#: reward names - either way, the concrete thing to show cover art/title for.
album_id: str
#: The show, for an "episode" reward - `None` for "tracks".
series: str | None
#: 0-based, inclusive: everything up to and including this index unlocks.
until_index: int
def _expand(raw: str, library_root: Path) -> Path:
path = Path(raw).expanduser()
if not path.is_absolute():
path = library_root / path
return path.resolve()
def _chronological_episodes(library: MusicLibrary, series: str | None) -> list[Album]:
"""A show's episode-albums, oldest first."""
candidates = [
album
for album in library.albums
if album.series == series
and (section := SECTIONS.get(album.section)) is not None
and section.album_unit == "episode"
]
return sorted(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
def resolve_lesson_reward(lesson: Lesson, library: MusicLibrary) -> ResolvedReward | None:
"""`None` (logged, not raised) when ``unlocks`` doesn't match anything on disk - a
moved or mistyped path must never take the whole app down, only that one reward."""
if lesson.unlocks is None:
return None
target = _expand(lesson.unlocks, library.root)
for album in library.albums:
section = SECTIONS.get(album.section)
if section is None:
continue
for index, track in enumerate(album.tracks):
if track.path.resolve() != target:
continue
if section.album_unit == "folder":
return ResolvedReward(
kind="tracks", album_id=album.id, series=None, until_index=index
)
# "episode": the reward's range is this episode's position among its show's
# episodes, chronological - not its (always 0) index within its own album.
episodes = _chronological_episodes(library, album.series)
for episode_index, episode_album in enumerate(episodes):
if episode_album.id == album.id:
return ResolvedReward(
kind="episode",
album_id=album.id,
series=album.series,
until_index=episode_index,
)
_log.warning(
"tippen: lesson %r unlocks %r, which matches no track in the library",
lesson.id,
lesson.unlocks,
)
return None
def resolve_all(curriculum: Curriculum, library: MusicLibrary) -> dict[str, ResolvedReward | None]:
return {
lesson.id: resolve_lesson_reward(lesson, library)
for lesson in curriculum.lessons
if lesson.unlocks is not None
}
@dataclass(frozen=True, slots=True)
class UnlockHint:
lesson_id: str
lesson_title: str
world_number: int
world_title: str
@dataclass(frozen=True, slots=True)
class TrackLock:
locked: bool
hint: UnlockHint | None
@dataclass(frozen=True, slots=True)
class AlbumLock:
locked: bool
tracks: tuple[TrackLock, ...]
@dataclass(frozen=True, slots=True)
class LockState:
#: Only albums touched by at least one reward - see the module docstring.
albums: dict[str, AlbumLock] = field(default_factory=dict)
def get(self, album_id: str) -> AlbumLock | None:
return self.albums.get(album_id)
def _world_title(curriculum: Curriculum, world_number: int) -> str:
return next((world.title for world in curriculum.worlds if world.number == world_number), "")
def _hint_for(
curriculum: Curriculum, candidates: list[tuple[int, Lesson]], index: int
) -> UnlockHint | None:
"""The earliest lesson that would unlock `index`, for "mention when this unlocks"."""
eligible = [(until, lesson) for until, lesson in candidates if until >= index]
if not eligible:
return None
_until, lesson = min(eligible, key=lambda pair: pair[0])
return UnlockHint(
lesson_id=lesson.id,
lesson_title=lesson.title,
world_number=lesson.world,
world_title=_world_title(curriculum, lesson.world),
)
def compute_lock_state(
curriculum: Curriculum, library: MusicLibrary, progress: TypingProgress
) -> LockState:
"""Always recomputed fresh - see the module docstring."""
resolved = resolve_all(curriculum, library)
tracks_by_album: dict[str, list[tuple[int, Lesson]]] = {}
episodes_by_series: dict[str, list[tuple[int, Lesson]]] = {}
#: The best (highest) *earned* until_index, same two keys.
earned_album: dict[str, int] = {}
earned_series: dict[str, int] = {}
for lesson in curriculum.lessons:
reward = resolved.get(lesson.id)
if reward is None:
continue
progress_entry = progress.lessons.get(lesson.id)
is_earned = progress_entry.earned if progress_entry is not None else False
if reward.kind == "tracks":
tracks_by_album.setdefault(reward.album_id, []).append((reward.until_index, lesson))
if is_earned:
earned_album[reward.album_id] = max(
earned_album.get(reward.album_id, -1), reward.until_index
)
else:
series = reward.series
assert series is not None
episodes_by_series.setdefault(series, []).append((reward.until_index, lesson))
if is_earned:
earned_series[series] = max(earned_series.get(series, -1), reward.until_index)
albums: dict[str, AlbumLock] = {}
for album in library.albums:
section = SECTIONS.get(album.section)
if section is None:
continue
if section.album_unit == "folder":
if album.id not in tracks_by_album:
continue # never targeted by any lesson: never locked
best = earned_album.get(album.id, -1)
candidates = tracks_by_album[album.id]
track_locks = tuple(
TrackLock(
locked=index > best,
hint=_hint_for(curriculum, candidates, index) if index > best else None,
)
for index in range(len(album.tracks))
)
albums[album.id] = AlbumLock(locked=best < 0, tracks=track_locks)
else:
if album.series not in episodes_by_series:
continue
episodes = _chronological_episodes(library, album.series)
chronological_index = next(
(i for i, a in enumerate(episodes) if a.id == album.id), None
)
if chronological_index is None:
continue
best = earned_series.get(album.series, -1)
locked = chronological_index > best
hint = (
_hint_for(curriculum, episodes_by_series[album.series], chronological_index)
if locked
else None
)
episode_lock = TrackLock(locked=locked, hint=hint)
albums[album.id] = AlbumLock(locked=locked, tracks=(episode_lock,))
return LockState(albums=albums)

View File

@@ -0,0 +1,34 @@
"""The typing game's live in-memory state, built once at startup from config."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from musicmouse.config import TippenConfig
from musicmouse.tippen.curriculum import Curriculum, load_curriculum
from musicmouse.tippen.progress import TypingProgress, load_progress, save_progress
__all__ = ["TippenRuntime", "build_tippen_runtime"]
@dataclass
class TippenRuntime:
curriculum: Curriculum
progress_path: Path
progress: TypingProgress
def save(self) -> None:
save_progress(self.progress_path, self.progress)
def build_tippen_runtime(config: TippenConfig) -> TippenRuntime:
"""Raises :class:`musicmouse.tippen.curriculum.CurriculumError` on a broken
curriculum file - a startup-time failure, same gate as a broken ``config.yml``.
Progress never raises; see ``musicmouse.tippen.progress.load_progress``.
"""
curriculum = load_curriculum(config.curriculum_file)
progress = load_progress(config.progress_file, curriculum)
return TippenRuntime(
curriculum=curriculum, progress_path=config.progress_file, progress=progress
)

View File

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

View File

@@ -1,79 +0,0 @@
import vlc
class AudioPlayer:
def __init__(self, alsa_device=None):
params = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
self.instance = vlc.Instance(*params)
self.media_list_player = self.instance.media_list_player_new()
self.media_player = self.media_list_player.get_media_player()
evm = self.media_player.event_manager()
evm.event_attach(vlc.EventType.MediaPlayerStopped, self._callback)
evm2 = self.media_list_player.event_manager()
evm2.event_attach(vlc.EventType.MediaListPlayerPlayed, self._callback)
evm2.event_attach(vlc.EventType.MediaListPlayerStopped, self._callback)
self.on_playlist_end_callback = None
self.volume_min = None
self.volume_max = None
def create_playlist(self, files):
result = vlc.MediaList()
for e in files:
result.add_media(self.instance.media_new(e))
evm = result.event_manager()
evm.event_attach(vlc.EventType.MediaListEndReached,
lambda e: print("Ml CB", str(e.type)))
evm.event_attach(vlc.EventType.MediaListItemAdded,
lambda e: print("Ml ia CB", str(e.type)))
return result
def set_playlist(self, media_list):
self.media_list_player.set_media_list(media_list)
print("Setting media list of length ", media_list.count())
self.media_list_player.set_playback_mode(vlc.PlaybackMode.default)
def next(self):
return self.media_list_player.next()
def previous(self):
return self.media_list_player.previous()
def play(self):
self.media_list_player.play()
def play_from_start(self):
self.media_list_player.play_item_at_index(0)
def is_playing(self):
return self.media_list_player.is_playing()
def pause(self):
self.media_list_player.pause()
def _callback(self, event, *args, **kwargs):
print(f"Got vlc event type {event.type}")
if event.type == vlc.EventType.MediaPlayerStopped:
if self.on_playlist_end_callback:
print("Calling playlist end cb")
self.on_playlist_end_callback()
def set_volume(self, volume):
if self.volume_min and volume < self.volume_min:
volume = self.volume_min
if self.volume_max and volume > self.volume_max:
volume = self.volume_max
self.media_player.audio_set_volume(volume)
def set_volume_limits(self, vmin, vmax):
self.volume_min = vmin
self.volume_max = vmax
def change_volume(self, amount=1):
vol = self.media_player.audio_get_volume() + amount
self.set_volume(vol)

View File

@@ -0,0 +1,79 @@
[project]
name = "musicmouse"
version = "2.0.0"
description = "Host backend for the MusicMouse RFID music player"
# 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 = [
"aiomqtt>=2.0",
"fastapi>=0.115",
# Podcast RSS feeds in the wild are full of small quirks (odd dates, missing
# namespaces); parsing them by hand invites silently dropping episodes.
"feedparser>=6.0",
# Also what tests drive the ASGI app with - plain httpx is deprecated in favour of
# this for exactly that. Runtime uses it to proxy the room page's Home Assistant
# calls, so the long-lived token never has to leave the backend.
"httpx2>=2.12",
"mutagen>=1.47",
"pillow>=10.4",
"pydantic>=2.7",
"pyserial-asyncio>=0.6",
"python-vlc>=3.0",
"ruamel.yaml>=0.18",
"uvicorn>=0.30",
# uvicorn ships no websocket implementation of its own; without this the
# state-push upgrade is answered with a 404 and the UI never updates.
"websockets>=13",
]
[project.optional-dependencies]
dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
# Off by default: the reactive background is nice, not required, and librosa/numpy are
# a real install cost on a Pi. Missing this group means `build_analyzer()` falls back
# to `NullAnalyzer` and the background stays at its static baseline - never a crash.
analysis = ["librosa>=1.0", "numpy>=2.0"]
[project.scripts]
musicmouse = "musicmouse.__main__:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["musicmouse*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = ["error"]
[tool.ruff]
line-length = 100
target-version = "py311"
# Course material and scratch work, not part of the backend. See notebooks/README.md.
extend-exclude = ["notebooks"]
[tool.ruff.lint]
select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["ARG001"]
# Entity is an ABC with optional hooks: empty bodies and unused args are the point.
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
[tool.mypy]
python_version = "3.11"
strict = true
files = ["musicmouse"]
warn_unreachable = true
[[tool.mypy.overrides]]
# numpy ships its own types, but only when the `analysis` extra is installed - a plain
# checkout must still type-check clean, so it needs the same treatment as librosa.
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"]
ignore_missing_imports = true

View File

@@ -1,5 +0,0 @@
pyserial-asyncio==0.6
python-vlc==3.0.20123
hass-client==0.1.2
ruamel.yaml==0.18.6
aiomqtt==2.0.0

View File

@@ -0,0 +1,19 @@
# Reaching the end of a playlist stops playback and runs the off animation.
# eule has two tracks of 5s each.
place eule
expect playing true
wait 11s
expect playing false
expect ring EffectReverseSwipe
expect brightness 0.00
# the figure is still on the reader, but nothing is playing
expect figure eule
# taking it off and putting it back starts from the top again
remove
place eule
expect playing true
expect track 0

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