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.
- 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).
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 :8420Then 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).
- 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).
| 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.
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.
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.
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 probeThis 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.
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.
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.
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.
-
Install the optional dependency:
pip install sacn. -
Register the strip as a virtual E1.31 device in OpenRGB's settings:
python -m tapoviz openrgb-setup
This edits
OpenRGB.jsondirectly (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. -
Restart OpenRGB, find the new device, and set its mode to Direct.
-
python -m tapoviz openrgb-statusreports 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.
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.
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.
python offline_test.py # whole pipeline against synthetic audio, no hardwareRun 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.
Built on mihai-dinculescu/tapo. See NOTICE.md for attribution on the one file that transcribes data from that project directly.
MIT — see LICENSE.