Skip to content

Repository files navigation

tapoviz

Audio-reactive and screen-reactive lighting for TP-Link Tapo L920/L930 LED strips — driven per colour zone, with a local web UI to run it from.

Built on tapo, an independent Rust/Python client for Tapo devices. tapoviz is a separate project; it depends on tapo from PyPI rather than including any of its code (one exception, noted in NOTICE.md).

Status: built and tested against one L920-5 on Windows. It should work with an L930 and with different zone counts, but that's untested — see Platform support.

What it does

  • Audio-reactive effects — a real spectrum analyser, a level meter, onset ("beat") detection with tempo tracking, and a few more, all driven by proper audio DSP (mel-spaced bands, automatic gain control, spectral-flux onsets) rather than a raw volume number.
  • Ambilight — extends whatever is on your screen onto the strip.
  • OpenRGB bridge — receives frames from OpenRGB (via its built-in E1.31 output), so this strip can join the rest of an OpenRGB-managed rig.
  • All 17 of the strip's built-in scenes (Rainbow, Ocean, Lightning, Christmas...), plus a custom per-zone painter and a plain solid-colour mode — for when you just want to control the light, not visualize anything.
  • A local web UI (python -m tapoviz) — start/stop, switch effects, paint zones, tune sensitivity, set the device IP, all from a browser, also reachable from a phone on the same network.
  • Wrapped-strip geometry, for the less common case of a strip wound repeatedly around something rectangular instead of run in a straight line (see Wrapped strips).

Quick start

pip install -r requirements.txt
cp .env.example .env          # fill in your Tapo credentials
cp config.example.yaml config.yaml   # adjust device.ip, or leave the default
                                      # and use `python -m tapoviz discover`

python -m tapoviz              # opens the web UI on :8420

Then open http://localhost:8420. It also prints a LAN address — the same page works from a phone.

Don't know your strip's IP? python -m tapoviz discover sweeps the LAN for it (needs Third-Party Compatibility enabled in the Tapo app, under Me → Third- Party Services — the app will walk you through it if it isn't).

Headless alternatives to the UI: python -m tapoviz run (terminal meters and hotkeys), preview (analysis only, no device touched), devices (list audio sources and check the strip is reachable).

Requirements

  • Python 3.11+
  • A Tapo L920 or L930 LED strip, with Third-Party Compatibility turned on in the Tapo app (Me → Third-Party Services) — without this every API call returns 403 Forbidden.
  • Windows, for audio-reactive effects (see Platform support).

Effects

drives on
spectrum audio Spectrum analyser: bass at one end, treble at the other
vu audio Level meter that fills as it gets louder, with peak hold
ripple audio Onsets throw expanding rings out from one end
flow audio A colour field drifting along the strip, speed following energy
pulse audio Whole strip one colour, hue following the music
ambient audio Low, slow bias lighting — for films, not for looking at directly
ambilight screen Extends your display onto the strip
openrgb network Mirrors whatever OpenRGB is sending
aurora audio Device-animated. Smooth, but the pattern travels — see below
custom Per-zone colours you paint in the UI
solid One colour everywhere

Palettes: spectrum, sunset, ice, neon, forest, ember — interpolated in Oklab so a ramp keeps its chroma through the middle instead of going muddy the way a plain sRGB blend does.

Why aurora is different

Every effect above except aurora works by pushing frames: computing 50 zone colours and sending them to the strip, repeatedly. That path is capped at roughly 3 sends per second (see How fast can it update?), and the device doesn't crossfade between pushed frames — it snaps.

aurora instead hands the strip a built-in animation to run — the same mechanism the built-in scenes use — and only updates its parameters (palette, speed, beat lock) every several seconds. The strip then animates smoothly on its own, far faster than any frame-pushing approach could manage. The trade-off: a travelling animation can't hold a fixed picture, so aurora can't put bass specifically at one end of the strip the way spectrum can.

Built-in scenes

All 17 of the strip's own scenes are available from the UI, running entirely on the device — no audio capture, no CPU, no network traffic after the single apply, and the smoothest thing this hardware does.

Six of them (Aurora, Rainbow, Ocean, Icicle, CandyCane, GrandmasChristmasLights) are Sequence-type and therefore travel — and their built-in spacing has no idea how your strip is mounted. On a wrapped strip, Rainbow's factory spacing (4 colours × 12 zones) drifts across laps of different lengths and never repeats the same way twice. Fit to my wrap resamples each of these onto a spacing that tiles one lap exactly, so the pattern lands in the same place every time round — see NOTICE.md for where the underlying scene data came from. The other 11 scenes are random twinkles or whole-strip fades with no spatial pattern to align, and run unmodified.

Wrapped strips (monitor, desk, etc.)

Most people should skip this section — the default (layout.shape: strip) treats your strip as what it almost certainly is: a straight run from one end to the other.

If instead your strip is wound repeatedly around something rectangular — this project's original use case was 5 m wrapped ~2.2 times around an ultrawide monitor — set layout.shape: wrap in config.yaml. The problem a wrap creates is that raw zone index stops meaning anything: on a strip wrapped 2.2 times, zone 0 and zone 23 can sit a few centimetres apart on the wall, so a pattern drawn against index arrives as several misaligned copies of itself. Wrap mode gives effects a coordinate that folds every lap on top of the others instead, so all of them light up together and the whole thing reads as one shape.

To get this right for your own setup you need three things: which corner the cable starts at, which way it winds, and the perimeter of the outermost lap. Guessing the last one is usually close enough to look right, but if you want it exact:

python -m tapoviz probe

This lights five widely-spaced, unmistakably-coloured zones and leaves everything else off. Tell it where each colour actually lands on the object (edge and roughly how far along) and the order alone is enough to solve for direction, start corner, and true lap length — see the config comments in config.example.yaml for what each layout.* field then means.

python -m tapoviz hold is the companion tool for iterating: it paints a static height gradient (one colour at one end, a different one at the other) and live-reloads config.yaml on every save, so you can tune the geometry against the real wall without restarting anything.

Platform support

Audio-reactive effects need Windows. Loopback capture (hearing what the speakers are playing) is implemented via soundcard's WASAPI support, which is Windows-only. soundcard itself runs on macOS and Linux, but loopback there means a different API entirely (a PulseAudio/PipeWire monitor source, or a virtual device like BlackHole on macOS), and tapoviz/capture.py has not been adapted or tested against either — a PR that does this is welcome; the ring-buffer design around it is what matters, not the loopback lookup.

Everything that doesn't touch audio — ambilight, openrgb, the built-in scenes, custom, solid, and the web UI itself — has no Windows-specific dependency in principle, but has likewise only been tested on Windows so far.

Ambilight

Needs the optional mss dependency (pip install mss, or pip install tapoviz[ambilight] if installed as a package). Run python -m tapoviz screens to list monitor indices, then set screen.monitor in config.yaml.

Colour is sampled a little inward from each screen edge — the outermost pixels are usually a border, taskbar, or letterbox bar — and the sampling point for each zone is precomputed from your actual geometry, so the mapping stays correct whether you're on a plain strip or a wrapped one.

OpenRGB bridge

The openrgb effect receives whatever OpenRGB is sending, over sACN (E1.31), so this strip can join the rest of an OpenRGB-managed setup — its Effects plugin, per-device colours, and hardware sync all reach here with no OpenRGB plugin required, because OpenRGB's own E1.31 output already speaks the protocol this expects.

  1. Install the optional dependency: pip install sacn.

  2. Register the strip as a virtual E1.31 device in OpenRGB's settings:

    python -m tapoviz openrgb-setup

    This edits OpenRGB.json directly (there's no runtime API for adding a network device), so OpenRGB must be closed when you run it — it rewrites that file whole on exit, which would silently discard the edit otherwise.

  3. Restart OpenRGB, find the new device, and set its mode to Direct.

  4. python -m tapoviz openrgb-status reports whether frames are arriving.

On a wrapped strip, openrgb's space parameter (default ring) resamples OpenRGB's flat canvas around the geometry so one pass of its pattern wraps the object exactly once instead of landing as offset copies per lap; set it to strip for the literal one-to-one zone mapping instead.

How fast can it update?

Every write to an L920 (set_lighting_effect, set_brightness, ...) took about 300 ms in testing — payload size and effect type made no measurable difference. That's a hard ceiling around 3.3 updates per second for any effect that pushes frames (see Why aurora is different for the one that doesn't). Run python -m tapoviz bench to measure it against your own strip and network.

One sharp edge, if you ever touch tapoviz/device.py: a LightingEffect id must be 32 hex characters. Anything else is silently discarded — the device returns success in ~30 ms and just keeps showing what it had before. A suspiciously fast write on this hardware means the frame was rejected, not that it worked; TapoStrip.verify() checks for exactly this at startup.

Configuration reference

config.example.yaml documents every commonly-tuned field inline. The full schema — every field, every default, and why — lives in tapoviz/config.py; each @dataclass there corresponds to one top-level section of the YAML.

Development

python offline_test.py    # whole pipeline against synthetic audio, no hardware

Run this after touching analysis or effects. It exercises both layout shapes, every effect (including the device-animation path aurora uses), the AGC across a wide dynamic range, and colour conversion round-trips — no sound card and no strip required.

Acknowledgements

Built on mihai-dinculescu/tapo. See NOTICE.md for attribution on the one file that transcribes data from that project directly.

License

MIT — see LICENSE.

About

Audio-reactive and screen-reactive lighting for TP-Link Tapo L920/L930 LED strips, with a local web UI.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages