Projects

A music player at an old address

Player took over player.tamasvigh.com from Rustamp, and with it the songs Rustamp had stored there. Why a library belongs to an address, an id that had to stay Rustamp's and became five times faster, an update that is really a takeover, two Hungarian letters, and what is untested.

Player is a music player for the songs on your own device. You add files, or whole folders. They are kept in the browser, play without a network and are never uploaded anywhere. Albums, artists and covers come from the files’ own tags, favourites and the place you stopped are remembered, and the page installs as an app. It is live at player.tamasvigh.com.

That address used to be Rustamp’s. Rustamp now lives at rustamp.tamasvigh.com, and the new player took its place. On a server, moving an app is a change of DNS. In a browser it is not, and most of this post is about why.

A library belongs to an address

Everything a page keeps in the browser is filed under its origin (scheme, host and port) and not under the app that wrote it: IndexedDB, the Origin Private File System, localStorage, the caches, the service worker itself. When Rustamp moved, its code moved and its music did not. Rustamp at rustamp.tamasvigh.com starts with an empty library, and the songs anyone had dropped on it at player.tamasvigh.com stayed there, readable by whatever runs at that address next.

What runs there next is this player, so it reads Rustamp’s store as its own. Its store is Rustamp’s storage.js ported to TypeScript: the same IndexedDB database, rustamp-store, at the same version and with the same two object stores, the same OPFS directory, tracks/, with the same file names, and the same probe that writes one byte, reads it back and deletes it before trusting OPFS, falling back to IndexedDB where that sequence fails. Nothing is copied or converted. The first start lists what is there, says how many songs came from Rustamp, and reads their tags in the background. The player’s own data (tags, covers, favourites) goes in a second database, so Rustamp’s keeps exactly the shape Rustamp wrote.

The port differs in one place. Rustamp reads a file into memory before storing it. This store streams it to disk, which matters for a 200 MB FLAC on a phone, and Rustamp’s own reader accepts what that writes.

The test for all this does not take the port’s word for what Rustamp does. It loads Rustamp’s actual storage.js into a page on the same origin, stores a song with it, and then opens the player, which has to list the song, read its tags, play it, and recognise the same file added again as a song it already has.

The id is the contract

Recognising the same file is the part that could not be designed afresh. Rustamp names every stored file by a hash of its contents: 128-bit FNV-1a over every byte and then over the length, written as 32 hex digits. The player has to compute the same name for the same bytes, or every song Rustamp had stored would come back as a second copy the first time someone added their music again. WebCrypto’s SHA-256 is right there in the browser, native and fast, and would give different names.

Rust has 128-bit integers. JavaScript does not. The 128-bit FNV prime is 2^88 + 315, so multiplying by it is multiplying by 315 and adding the value shifted left by 88 bits, which can be done in pieces. The first port kept the state as four 32-bit limbs and did the arithmetic in doubles. A limb times 315 stays below 2^41, which a double holds exactly, and a comment above it said that BigInt, one multiplication per byte, would be far too slow for a whole album.

Measured in headless Chromium 141 on a four-core cloud machine, over 32 MiB of random bytes: BigInt hashed 18 MiB a second and the four limbs 27. The assumption had bought a factor of one and a half. Replacing the three divisions by 2^32 in each round with multiplications brought the limbs to 31. What changed the picture was the size of the numbers. In eight 16-bit limbs the shift by 88 bits is five limbs and eight bits, a limb times 315 plus its carries stays below 2^25, and every value in the loop is a small integer, the case JavaScript engines run fastest. That version hashes 130 to 140 MiB a second from memory, and 117 read as a stream, the way the app reads a file: five times the first port, seven times BigInt, and in the range of the browser’s native SHA-256 on the same machine, 133 to 170.

The ids did not change. The tests compare against ids printed by Rustamp’s own Rust code: for the empty file, short strings, 100 KB of a pattern that carries through every limb, 65,537 zeros and a run of FF bytes, and all four implementations agreed on 32 MiB of random data. The hashing runs in a worker, and at this rate a gigabyte of FLAC takes about eight seconds on that machine. A phone is slower, by an amount not measured yet.

An update that is really a takeover

A service worker is filed under the origin as well. Every browser that had opened Rustamp at player.tamasvigh.com still had Rustamp’s worker registered there, with its caches, and would go on serving Rustamp from them offline until something replaced it.

Browsers replace workers by themselves: when a page in a worker’s scope is opened, the browser fetches the worker’s script again and, if a single byte differs, installs the new one. So the player’s worker is /sw.js, the path Rustamp’s had. To the browser the takeover is an ordinary update of the same registration, and it happens even when the page that prompted the check came out of Rustamp’s cache. When the player’s worker activates, it deletes Rustamp’s caches, whose names it knows.

Two smaller things follow from its being an update. The player normally announces a new version with a note offering to reload. On the takeover that note would be nonsense, so it is not shown while Rustamp’s caches are still there. And the web app manifest keeps Rustamp’s id, /, so a Rustamp installed on a home screen from the old address is, to the browser, the same app, which should make it the player, not a second icon under a stale name. Should: no phone has tried it yet.

Offline, the other way round

Rustamp’s worker fetches pages from the network first and refreshes its few fixed-name files in the background. Its post explains why the obvious network-first design measured worse. The player can afford something simpler, because its build knows every file it will ever need. A build step lists the output with a SHA-256 per file and bakes the list into sw.js, and the list’s own hash names the cache. One changed byte anywhere makes a new worker. A new version installs all of its files, past the HTTP cache, into a cache of its own while the old one keeps serving, and then everything, the page included, is served from that cache first. The app opens at once with or without a network, two versions never mix, and the price is that a deploy reaches people one start later. The whole app is about 80 KB compressed.

Tags, read by hand

There is no tag library. Adding music reads ID3v1 and all three versions of ID3v2, MP4, FLAC, Ogg Vorbis and Opus, and WAV, and reading a tag costs kilobytes, not the file. The reader asks for the first 64 KB, for the rest of an ID3 tag only when it is longer than that, which a large cover makes it, for the last 128 bytes only when an MP3 has no ID3v2 tag or one with fields missing, and in an MP4 it walks from box header to box header to the metadata without reading the audio. Covers larger than 640 pixels or 350 KB are scaled down, and each is stored under its own hash, so the same scan embedded in each of an album’s twelve files is kept once.

Two things in there are not in the specifications.

ID3v2.4 stores each frame’s size as a “syncsafe” integer, seven bits to a byte, so that no size can contain the byte FF. Some writers put ordinary 32-bit sizes into 2.4 tags anyway: iTunes did, and the Python tag library mutagen still guesses, tag by tag, which reading to believe. Below 128 the two encodings are the same bytes, and text frames are short, so a tag with this mistake reads perfectly until its first frame of 128 bytes or more, which is usually the cover. The reader here takes the syncsafe size unless it lands somewhere that is not a frame while the plain reading lands on one.

Then Hungarian. Hungarian has nine accented letters, and seven of them sit at the same byte in ISO-8859-1, the one single-byte encoding ID3v2 can declare, as in Windows-1250, the code page of Hungarian Windows. The other two do not exist in ISO-8859-1 at all. Software that wrote the code page and declared ISO-8859-1 left tags in which ő reads as õ and ű as û. Hungarian never uses õ or û, so for a browser with Hungarian among its languages the player puts those two pairs back, capitals included. Every other letter in such a tag was right all along. The cost is a French title with û in such a tag, which that browser shows with ű. The interface stays in English either way.

Where you stopped

The queue and the position in the song are saved every five seconds, and at once on pause and whenever the page is hidden. That last one is there because a phone may end a background tab without the page ever being told it is closing. Songs of eight minutes or more, mixes and live sets, also keep a position of their own, so going back to one resumes it. Favourites are a set of ids in IndexedDB: per browser and per device, with no account to carry them anywhere else, which is the point.

What is untested

Real phones. Everything above ran in Chromium on a computer: the lock-screen and headphone controls, the Android share target (a test posts files to it the way Android does, through the service worker), installing to an iPhone home screen, and a Rustamp installed from the old address turning into the player.

Safari. Where OPFS is missing or partial, the store falls back to IndexedDB. That path is Rustamp’s code, but it has not run in this app on an iPhone.

A real takeover. The test stages Rustamp’s store and caches on the origin. It does not install Rustamp’s actual worker and watch this one replace it.

Large libraries. The lists are not virtualised, and no library bigger than a handful of songs has been tried.

Hashing on a phone. The figures above come from a four-core cloud machine.