A full-screen music visualizer for the terminal, written in let-go. You give it a wav or mp3 and it plays the track while painting it in truecolor half-blocks: Geiss plasma, MilkDrop feedback, AVS superscopes.
Everything outside ffmpeg and afplay is let-go running on the bytecode VM. The audio is analysed once on load, then each frame turns features into pixels and pixels into one long escape-sequence string.
double-array▀ half-blocks, 24-bit fg/bg, synchronized-update frameA terminal cell is about twice as tall as it is wide. Print the upper-half block ▀ with one truecolor foreground and a different background, and each cell carries two square pixels. A 192 × 54 terminal becomes a 192 × 108 canvas, the resolution of every capture here.
;; one cell: top pixel = fg, bottom pixel = bg ESC[38;2;255;122;61m ; fg sodium orange ESC[48;2;59;42;122m ; bg deep violet ▀
The encoder skips a colour code when it matches the cell to its left. It also wraps each frame in mode 2026 (synchronized output), so terminals that support it swap whole frames instead of tearing mid-draw.
The odd step in the decode chain is there because let-go reads files as strings. Raw PCM bytes above 0x7f came back as U+FFFD replacement characters, so the samples cross the boundary as one decimal integer per line. Perl's unpack turned out about 3× faster than od for that.
A sum of four sines read from a 4096-entry lookup table: two axis waves, a diagonal and a slow ring. A second sine field displaces the coordinates first, and the bass sets both how far it pushes and how fast the clock runs. The value indexes a 256-entry cosine palette with two dark troughs per cycle. Without the troughs it looks like a screensaver; with them it becomes ribbons of light on black, which is the Geiss look. Each frame blends over the last one, so the colour flows.
;; inner loop, written to lower to native Go later (let [s (+ (fsin l (+ u dv)) (fsin l (+ v du)) (fsin l (+ (* 0.5 (+ u v)) t)) (* 0.6 (fsin l (- (* 0.35 r2) (* 2.0 t))))) k (bit-and (int (* 256.0 (+ phase (* 0.125 s)))) 255)] (aset r i (+ (* keep (aget r i)) (* fresh (aget pr k)))) …)
fsin is a macro over the lookup table, so the VM makes no function call per sample.Each frame begins as the previous one, pulled through a 32 × 18 warp mesh and decayed slightly, then a waveform is drawn on top. Zoom in and you get tunnels; rotate and you get vortices; wobble and you get smoke. As in MilkDrop, the per-vertex maths (a preset's zoom, rotation, drift and warp, bent by each vertex's radius and angle) runs on the coarse mesh in plain lg. Only the per-pixel resample is a kernel: it interpolates a source coordinate from the mesh cell, then samples the old frame bilinearly. That bilinear tap is where MilkDrop's softness comes from.
Lightspeed filled the screen with solid cyan within a few seconds. Zooming in spreads the centre outward, and the waveform was added on top every frame, so the loop gain was above 1. MilkDrop blends its waves rather than adding them, and phosphene now does the same with fb/blend!: a pixel moves toward the wave colour and can never pass it. Brightness above 1.0 still exists, but only as a soft clip toward white in the encoder.
In Winamp's AVS a superscope was a few lines of code run once per point: given i (0 to 1 along the scope) and v (the waveform at that point), set x, y and a colour. phosphene keeps that shape. Each preset is a small namespace holding a map of lg functions: :point, and optionally :frame, :beat and :init, drawn as lines or dots over fading or warped trails. A kit adds 3D rotation and perspective.
(def preset {:name "rose bloom" :n 520 :mode :dots :decay 0.86 :zoom 0.985 :rot -0.02 :init {:k 3.0} :beat (fn [s _a] (assoc s :k (nth [2.0 3.0 5.0 7.0 1.5 2.5] (mod (inc (int (:k s))) 6)))) :point (fn [{:keys [k t bass-att beat-env]} i v] (let [th (+ (* i 2.0 k/TAU) (* 0.15 t)) r (* (math/cos (* k th)) (+ 0.7 (* 0.15 bass-att) (* 0.35 v))) [cr cg cb] (k/hue (+ 0.02 (* 0.12 (math/abs r))) (+ 1.0 beat-env))] [(* r (math/cos th)) (* r (math/sin th)) cr cg cb]))})
Because a preset is just data and functions in its own file, and eval and load-string work on the VM, editing one live from inside the visualizer is within reach. That's the stretch goal.
Every effect and preset pair is a scene, ten in auto mode. The director interleaves the engines and changes scene every 32 beats, or every 30 seconds on a track where it can't find onsets. A new scene starts from a copy of the frame on screen, the way MilkDrop morphs one preset into the next, and a 1.5-second smoothstep crossfade covers its first frames.
Post-processing runs on a separate copy of the frame: a half-resolution bright pass with a separable box blur for bloom, a hue-rotation matrix that wobbles with the treble and kicks on beats, a vignette, and a small exposure lift on each beat. Scenes are seeded from the frame before post, so the glow never feeds back into the effects.
Live keys: ← → scene · a auto · b bloom · c colour cycling · space pause · q quit
Everything here runs on let-go's bytecode interpreter. The numbers below were measured on 2026-10-10 on the dev machine, at --scale 2 (half-resolution canvas) in a 160 × 48 terminal.
| Measured | Value | What it means |
|---|---|---|
| Primitive op | ~150 ns | A native call such as aget, nth or .append costs ~0.4 µs. |
| 1024-point FFT | ~6 ms | Fine once per frame, too slow for a whole-track pre-pass. |
| 4 futures vs 1 thread | 1.3 s vs 1.8 s | Futures barely run in parallel, so one thread does the work. |
| ANSI encode, 160 × 48 | 15–45 ms | Depends on how many neighbouring cells share colours. |
| Live frame rate | ~10–20 fps | Superscope is fastest; milk is slowest. |
That's watchable but not smooth. The plan is to get the speed from let-go's AOT compiler rather than from tuning for the VM. Every hot loop is already written as a kernel: flat double-arrays, ^doubles and ^double hints, and if/do loop bodies. That's the shape nooga/let-go#1057 lowers to native typed-array access, measured there at about 230× the VM on a 1M-element sum.
Decode, onset pre-pass, FFT features, half-block renderer, afplay sync (pause is SIGSTOP), HUD, and headless --dump / --bench so every later step could be checked as an image. Found: bytes read as runes, and futures that don't parallelise.
The first palettes looked like a screensaver; dark troughs fixed it. Headless dumps also learned to seed the clock and beat count from track time, so a dump matches what the live run shows at that moment.
Mesh warp, four presets, and the switch from additive to blended waves after lightspeed saturated.
Five presets, a 3D kit, and --preset N. The helix first rendered as solid ribbons; dropping from 220 points to 64 brought back the ladder.
Scenes, phrase-boundary switching, seeded crossfades and the keys to drive them.
Bloom, hue cycling, vignette and beat flash. The first bloom was far too strong. Checking it also caught a test-script bug: zsh doesn't word-split --fx $fx, so three "different" before/after shots were all plasma.
A --frames flag writes one continuous run of frames, so feedback trails carry across the clip. Rendering four clips in parallel turned up a race: runs decoding the same track shared a temp file, and the first to finish deleted the others' samples.
AOT kernels. Lower the plasma, warp, mix, post and encoder kernels once typed-array lowering lands, and drive them from the same lg code.
Live preset editor. A legmacs-style pane to edit a superscope or milk preset's lg and hot-reload it while the music plays.
Additional effects sprint. Extend past the Winamp exemplars into effects of phosphene's own.
Slab. Feed live audio from Slab, the livecoded DAW, instead of a file.