Projects

Building a Winamp in Rust and WebAssembly

A Winamp 2.x player in Rust and WebAssembly. Why the skin is drawn at runtime, how the peer-to-peer handshake fits in 102 bytes, and what does not work.

Rustamp is a classic Winamp 2.x player that runs in a browser tab: Rust, compiled to WebAssembly, rendering to a canvas from a sprite atlas the way the original does. There is no DOM behind the windows. No <button>, no CSS approximation of a bevel, and a click is a hit test against a rectangle in a 275x116 coordinate space.

It is live at rustamp.tamasvigh.com. Drop an audio file on it. What follows is the handful of things that turned out to be stranger than expected.

The skin is drawn, not shipped

The original Winamp skin bitmaps are Nullsoft’s artwork and cannot be redistributed. The .wsz format itself is not protected. So the renderer uses the sprite rectangles of the classic layout, the same numbers any skin reader uses, and the bundled skin is painted into them at boot as fourteen offscreen canvases: the three windows, the titlebar, the transport buttons in two states, the sliders at 28 frames each, the digits, the text font. All CanvasRenderingContext2d calls.

The font is the awkward part. A classic skin carries a bitmap font sheet of 5x6 glyphs, three rows of 31, in a fixed order beginning ABCDEFGHIJKLMNOPQRSTUVWXYZ"@. Nothing generates a legible 5x6 glyph from a system font, so every character is placed by hand, pixel by pixel. The grey ladder needed the same care: sunken 42, window face 63, raised face 86, grip 110, highlight 145. A one-pixel bevel only reads at 1x if the two sides of it are that far apart.

Because the generated sheets sit at classic coordinates, a real .wsz dropped on the page replaces the sheets it contains and keeps the generated art for the rest. One renderer, two sources.

Ninety-six percent of startup was one constructor

The obvious suspect for a slow boot was the procedural skin: fourteen sheets painted before the first frame sounds expensive.

Instrumented, the cold load has exactly one long task, and it splits three ways: canvas setup plus the entire skin generation is 5.7 ms, new AudioContext() is 148.7 ms, and everything else is 1.2 ms. Ninety-six percent of a 155 ms startup is one constructor, for a subsystem not needed until the user drops a file. An about:blank control reproduces the cost, and a second AudioContext in the same page costs 0.1 ms: it is one-time audio-subsystem initialisation, not this application’s graph.

The context is still built eagerly. Moving it behind the first gesture is on the list. The ratio is the part worth keeping: the component that looked like the whole problem was four percent of it.

One line of the Web Audio spec shapes the transport

AudioBufferSourceNode is single-use. Once it has been started and stopped it can never play again.

Everything downstream is built once and never rebuilt: preamp, ten peaking biquads from 60 Hz to 16 kHz at a Q of 1.4, volume, a splitter and two gains for balance, analyser, output. Only the source is transient. The decoded AudioBuffer is retained, and every play and every seek creates a fresh source node, connects it and starts it at an offset. Pause is stop plus an offset. There is no designing around this. It falls out of one sentence in the specification.

The retained buffer caused the worst bug the project has had. duration belongs to that buffer and survives stop(), and two call sites used duration > 0 to mean “a track is loaded”, so after the first song ended that test was permanently true. Drop a second file: nothing happens. Press play: you hear the first song again. Measured on the live site, after a finished 880 Hz tone a dropped 1500 Hz tone still played back at 879 Hz.

The handshake is 102 bytes

The player is growing phone-to-phone track sharing with no server in the path: a WebRTC data channel whose handshake travels in a QR code, falling back to an animated QR stream of the file itself.

The design document put the compressed handshake at 500 to 800 bytes and named it as the one part that could fail outright. It came in at 102.

Almost none of an SDP offer is information. The v=, o=, s=, t=, m= and c= lines, plus a=mid:0 and a=sctp-port:5000, are identical in every handshake this application will ever produce, so they are not transmitted at all. They are reconstructed on the far side. What remains is two ICE credentials, a 32-byte certificate fingerprint and a short list of addresses, packed into a binary record: one magic byte, one byte of flags, two length bytes, then the payload. Measured: 102 bytes for two mDNS candidates, 90 for IPv4 plus IPv6, 71 for the single-address floor. 102 bytes is a version-6 QR code at error correction level M, 41x41 modules, and it decodes off 480p camera capture.

There is no compressor, deliberately. Deflate makes the payload larger, at 107 bytes. A deflate with a hand-tuned 230-byte preset dictionary reaches 205, still twice the raw binary, and would drag a compression library into the wasm for the privilege. Once the boilerplate is gone, what is left is close to pure entropy: general-purpose compression has nothing to find and can only add framing.

The bug only Firefox could find

The length fields in that record started out narrower than a byte, two packed into one. Chrome emits a 4-character ICE ufrag and a 24-character password, and Safari emits the same, so every Chrome-to-Chrome and Chrome-to-Safari pairing passed.

Firefox emits 8 and 32, and 32 does not fit in four bits. The password truncated silently and three of the four cross-engine pairings failed inside setRemoteDescription with InvalidAccessError, a rejection that says nothing about which field was wrong. RFC 8839 permits a ufrag of 4 to 256 characters and a password of 22 to 256, so the fix was a whole byte for each, and an error rather than a truncation above 255, because truncating quietly is the bug the format already had once.

Network-first was the obvious answer and it measured worse

Rustamp works offline. The build emits content-hashed filenames, so there is no fixed precache list to write: on install the worker caches /, reads it back, and precaches every same-origin URL it names.

The interesting decision is the files whose names do not change between deploys: storage.js, the manifest, the icons. Cache-first pins them forever, and pinning storage.js is the worst case, because that file is the storage contract. New wasm talking to old storage code is the split brain the versioning exists to prevent. Network-first, then.

Network-first was worse, in two ways that only appeared when measured. On a connection that accepts the socket and then never answers, the application did not boot at all: twenty-five seconds, blank page, no storage object. And when storage.js returned 404 during a broken deploy, the windows drew correctly and the library read as empty, which looks to the user exactly like deleted music. Both are worse than a file one version stale.

So: stale-while-revalidate. Reply from cache at once, refresh in the background, never block on the network. Navigations stay network-first so a deploy still reaches everyone. Hashed assets stay cache-first, which a new name per build makes safe.

What does not work

Touch drags. There are no touch, pointer or keyboard listeners. Taps work, because the browser synthesises a mouse sequence after a stationary touchend, so the first-use flow (eject, pick a file, play) works by tap alone. Anything needing movement between press and release does not: moving windows, fine slider adjustment, scrubbing the position bar.

Several painted controls are decorative. The close box, minimise, shade mode, the equaliser’s preset button, the playlist’s ADD, SEL, MSC and LST menus are drawn because that is what makes the skin look like Winamp, and wired to nothing. The close box should go first: one that does nothing reads as broken rather than as decoration.

The QR transport is slow, and slower than I first wrote down. The arithmetic is worth doing carefully, because it is easy to quote the wrong number. A version-40 code holds 2,953 bytes, which is the figure that gets cited, but a code that dense is not scannable off a phone screen: at 720p it gives a decoder about 2.5 pixels per module against the 3 it needs. Sized for what a camera can actually read, the stream runs at version 15, which is 520 bytes, or 501 once the fountain header is subtracted.

So a three-minute MP3 at 128 kbps is not a thousand frames. It is roughly 6,400, and at the 5 to 15 frames per second screen-to-camera scanning sustains, that is seven to twenty-two minutes of holding two phones still. Transcoding to Opus at 64 kbps roughly halves it. The window states the estimate before you commit to standing there, which matters more than the estimate being flattering. An earlier version was built on an optimistic overhead figure and promised a quarter of the real time.

This transport is for when there is no network at all. When there is one, the handshake above takes its place and the same file moves in seconds.

What ties these together is that each one was a guess before it was a measurement, and each guess was wrong in the same direction: the thing that looked expensive was cheap, and the thing that looked cheap was expensive. Painting fourteen sprite sheets at boot sounds like the slow part of startup and takes six milliseconds. Constructing an audio graph sounds free and takes a hundred and forty-eight. A handshake sounds like it needs a kilobyte and needs a hundred bytes. A stream of QR codes sounds like a couple of minutes and is twenty.

None of that is visible from reading the code. It only shows up when something is pointed at the running thing and asked.