DEV Community

Cover image for How I put lyrics between the wallpaper and the windows
Mohit Aasirwal
Mohit Aasirwal

Posted on

How I put lyrics between the wallpaper and the windows

I wanted lyrics to be there when I glance at my desktop and gone the moment I'm working. Not a window. Not a widget in a corner. The desktop itself.

That turned into Undertone: an open-source lyrics lightbox for the Linux desktop. Music plays, the empty desktop becomes time-synced lyrics over the album art with a visualizer locked to the beat. Below every window, above the wallpaper.

Undertone showing synced lyrics with the ring visualizer

This is the engineering story. The parts that looked trivial and weren't.

The layer trick

The whole idea rests on one window-manager feature. On X11, a window with the _NET_WM_WINDOW_TYPE_DESKTOP hint is stacked with the desktop: above the wallpaper, below everything else. GTK exposes it as a type hint, and Cinnamon, GNOME and KDE all honour it. Map a fullscreen, undecorated, focus-refusing window with that hint and it behaves exactly like a second wallpaper that happens to be alive.

One gotcha: the hint only takes effect on map. Undertone has a screensaver mode that lifts the lyrics above every window after N idle minutes, and switching the type on a mapped window does nothing. The window has to be unmapped, retyped, and mapped again, and the keep-above/below states have to be re-sent afterwards because they are client messages, not properties.

On Wayland there is no such hint, so the same window uses gtk-layer-shell's BACKGROUND layer. That path is written but I have not been able to verify it yet.

Knowing what's playing

Every Linux media player publishes its state on the session bus via MPRIS: Spotify, VLC, Firefox, Chromium-based browsers running YouTube Music. Title, artist, album, duration, position, art URL. Poll it four times a second and you know everything you need, with no API keys and no accounts.

Browsers report position in one-second steps, so Undertone interpolates with a monotonic clock between polls. Users can nudge the offset per track, and it persists.

Lyrics come from a source chain: a local .lrc beside the track or in ~/Music, then LRCLIB, then NetEase. LRCLIB is the hero here. Free, no key, and most popular tracks have line-synced lyrics. Word-level timings (<mm:ss.xx> tags) get a karaoke sweep.

Making the visualizer actually listen

I did not want an animation loop that pretends. The visualizer taps the real audio output: parec on the default sink's monitor source, 2048-sample frames, a Hann window, an FFT, then ten log-spaced bands between 40 Hz and 12 kHz mapped to dB above a floor. A slowly decaying running peak gives auto-gain so quiet passages still move.

Beat detection feeds the bass band to an onset detector: positive flux against an adaptive threshold built from the recent median and mean, with a refractory period. The rings kick outward on each onset and the current lyric line scales up a few percent.

My first version detected beats beautifully on a synthetic click track and almost never on real music. The dB mapping that makes the rings look good compresses a kick drum's jump so much that nothing crosses the threshold. The detector now runs on linear bass energy normalised by its own slow peak. Same code path for the visuals, different feed for the beats.

Tempo was the hard part

Median of beat intervals is the textbook answer and it jittered between 117 and 156 bpm from one second to the next on the same song. Real onsets are messy: ghost notes, missed kicks, half-time sections.

The fix that worked: collect every interval between beats up to three apart over the last 30 seconds, fold each into one octave (70 to 140 bpm), histogram them in 3 bpm bins, take the peak with a little smoothing from neighbours, refine with the mean of the intervals in that bin, then unfold toward the raw interval range. On a 40-second capture of real music the per-second readings went from a 99 bpm spread to 6.

Colours from the cover

The accent colour is extracted from the album art: downscale, drop near-grey and near-black pixels, weighted hue histogram, take the dominant bin, then brighten it so it reads on a dark backdrop. A second accent comes from the strongest hue at least 55 degrees away, or a hue-shifted sibling when the cover is monochrome. The lyric line takes the first, the rings and glow take the second. Rammstein comes out red, Gojira gold, Imagine Dragons purple, with zero configuration.

Browsers occasionally hand over their own logo as the art URL. There is no reliable way to tell from the image alone, but the same image showing up for different artists is a dead giveaway, so Undertone keeps a small registry of art hashes per artist and drops anything seen under two.

Keeping it cheap

Two 1080p windows at 30 fps had to stay around 10% CPU. Everything static is cached as a surface: the dimmed art backdrop, the header, the non-current lines, the current line on its own so it can be beat-scaled without re-laying-out text. Per frame only the visualizer region and a band around the current line are invalidated.

When I profiled it, the cost was not where I guessed. Gradients were 2 points. Ten antialiased circle strokes on an X11 surface were most of the rest, so the ring layer is drawn into a half-size server-side surface and scaled by XRender. Painting a scaled client-side image surface, which I tried first, was four times worse.

The small things that took longest

  • Lines glide upward on change with an ease-out, and the outgoing line shrinks and cross-fades into its grey successor.
  • The window fades in on play and holds a dimmed PAUSED state for a few seconds before fading out.
  • A feathered glass card looked great until 8-bit quantisation turned twelve stacked low-alpha layers into visible bands. The fix was painting once through an alpha mask. Then I removed the cards anyway, because the lyrics looked better straight over the art.
  • Everything is a D-Bus call, so hotkeys and a panel applet were nearly free: toggle, cycle visualizer, nudge timing, and a tap-to-mark lyrics editor that stamps the playback position onto the next untimed line and saves a .lrc.

Try it

Ubuntu, Mint, Debian: grab the .deb from the releases page, sudo apt install ./undertone_1.2.0_all.deb, run undertone, press play, minimise your windows. Arch users have a PKGBUILD in the repo. Flathub is next.

Python 3, GTK 3, numpy, MIT, about 120 unit tests plus a fake MPRIS player and a synthetic audio feed for end-to-end testing. If you run Hyprland or Sway and try the Wayland path, I would genuinely like to know what happens.

Repo: https://github.com/Mohit-Aasirwal/undertone

Top comments (0)