dev log · sprint 1 · 2026-10-10

phosphene

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.

github.com/mparrett/phosphene · ~1,290 lines of lg as of 2026-10-11 · captures rendered headless from the repo

bin/phosphene test.wav — auto mode, 0:13 → 0:18
A scene change on the phrase boundary. At 32 beats the director hands the screen from the plasma to the MilkDrop “event horizon” preset. The new scene starts from the plasma's last frame, so the ribbons get pulled into the vortex while the two crossfade. Every capture on this page includes the matching slice of the 124 BPM test track.

How a frame gets made

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.

decodeffmpeg → mono 22.05 kHz → perl unpack → decimal text → double-array
pre-passone-pole bass filter per 1/60 s hop; onsets where bass jumps 1.35× its trailing mean
listen1024-point FFT per frame → 32 log bands, bass / mid / treb normalised to ≈1.0
directorscene = effect × preset; switches every 32 beats; seeded crossfade
effectplasma, milk or superscope draws into a float RGB buffer
postbloom, hue wobble, vignette, beat flash, on the displayed copy only
encode▀ half-blocks, 24-bit fg/bg, synchronized-update frame

Two pixels per character

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

Why the audio goes through text

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.

Engine 1 · Geiss plasma

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.

--fx plasma
plasma. The palette rotates every 16 beats through dusk teal/coral, sodium amber, ultraviolet, acid chrome and magenta.
;; 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.

Engine 2 · MilkDrop feedback

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.

milk · event horizon
event horizon. Zoom below 1 pulls everything inward; rotation grows with radius.
milk · lightspeed
lightspeed. Zoom outward on the bass; a pseudo-XY scope at the centre.
milk · desert wind
desert wind. A sideways drift carries amber waveforms off the left edge.
milk · quantum foam
quantum foam. The treble drives the wobble; per-vertex zoom ripples by angle.

The first version blew out

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.

Engine 3 · AVS superscope

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.

superscope · double helix
double helix. Points alternate strands, so drawing them as one line traces the rungs.
superscope · torus knot
torus knot. The winding steps 3 → 5 → 7 → 4 on every fourth beat.
superscope · rose bloom
rose bloom. The preset above; the petal count changes on each beat.
superscope · orb
orb. A Fibonacci sphere; the waveform pushes points off the surface.
superscope · aurora
aurora. A full-width waveform; an upward warp turns each frame's line into a rising curtain.

Scenes, transitions and post

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

Running on the VM

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.

MeasuredValueWhat it means
Primitive op~150 nsA native call such as aget, nth or .append costs ~0.4 µs.
1024-point FFT~6 msFine once per frame, too slow for a whole-track pre-pass.
4 futures vs 1 thread1.3 s vs 1.8 sFutures barely run in parallel, so one thread does the work.
ANSI encode, 160 × 4815–45 msDepends on how many neighbouring cells share colours.
Live frame rate~10–20 fpsSuperscope 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.

Build log

  1. a00643b
    Core

    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.

  2. 1ca4312
    Geiss plasma

    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.

  3. 3979dfd
    MilkDrop feedback

    Mesh warp, four presets, and the switch from additive to blended waves after lightspeed saturated.

  4. 2a38d0c
    AVS superscope

    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.

  5. 74c10f9
    Director

    Scenes, phrase-boundary switching, seeded crossfades and the keys to drive them.

  6. 6f147d8
    Post

    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.

  7. 2b6b756
    Captures for this page

    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.

Next

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.