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.
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
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:
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.
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:
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
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.
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.