krishna@
9 min read#web#lyricism

Timing lyrics in the browser, one Space bar at a time

Stamping 41 lines of a gazal by hand took three hours and drifted half a second. So I built the tool instead: LRC's quirks, rAF drift, keyboard-first tapping, and no server anywhere.

share
LRC

forty-one lines, one text editor, three hours

I had a recording of a gazal and I wanted the lyrics to scroll with it on the music page. That means an LRC file: every line stamped with the second it starts.

So I did it the way you do it the first time. VS Code on the left, audio in a browser tab on the right, pause, squint at the timecode, type [01:07.42], unpause, miss the next line, scrub back four seconds, try again. Forty-one lines, three hours, and at the end the second half of the song ran half a second early because somewhere around line twenty-six I'd started stamping where I thought the line was instead of where I'd heard it.

So I built the tool instead. It lives at /tools/lyrics-sync, it took a weekend plus several evenings of the fixes you only find by using the thing, and this is what's in it.

the format is older than it looks

LRC is a text file where each line is a timestamp followed by words:

[ti:Shishirko Joon]
[ar:Krishna Adhikari]

[00:12.40]तिमी त शिशिरको जून भयौ
[00:18.06]देखिने तर कहिल्यै छुन नसकिने

Simple until you meet real files. Three quirks cost me an afternoon each.

The fraction is not a fixed unit. [00:12.40] is centiseconds — 12.40 seconds. [00:12.400] is milliseconds. Same number, same meaning here, but [00:12.4] is 12.4 and [00:12.04] is 12.04, and if you parse the fraction as an integer without looking at how many digits it has you will be wrong by a factor of ten on half the files on the internet. The denominator comes from the length:

const denom = 10 ** frac.length;
fractional = Number.parseInt(frac, 10) / denom;

A line can carry several timestamps. [00:42.10][02:15.80]same refrain is how the format says "this happens twice", and it's common in karaoke exports. My parser emits one timed line per stamp, so a round-trip through the editor expands the shorthand and never puts it back. I'm fine with that.

And the square bracket means three things at once. [ti:...] is metadata, [00:12.40] is a timestamp, and [ is also how a JSON array starts — which matters because the paste box accepts LRC, SRT, JSON or plain text and has to work it out without asking. The sniffer runs JSON, then SRT (its --> is unmistakable), then LRC, then falls through to one-line-per-row. JSON is the fiddly one: a leading [ followed by a digit could be either, so it peeks for a colon shortly after the digits.

if (next && next >= '0' && next <= '9') {
  return !/^\d{1,3}:/.test(rest);
}

That's a heuristic and it will eventually meet a file it gets wrong. It hasn't yet.

The last quirk is what LRC doesn't have: end times. A line starts and then the next line starts. That's the whole model. It makes the editor simple — every line holds one number or null — and it makes SRT export a guess, because SRT needs an out-point. Mine uses the next line's start, or start plus five seconds for the final cue, which is right until a song ends on a line followed by forty seconds of outro.

the <audio> element wins

The obvious instinct for anything audio in a browser is Web Audio. AudioContext.currentTime is sample-accurate; HTMLMediaElement.currentTime is a float the browser updates when it feels like it, and Chrome deliberately fuzzes high-resolution timers for Spectre reasons.

I built it on a plain <audio> element anyway.

Route playback through Web Audio and you own the transport. Seeking means stopping a source node and starting a new one at an offset. Pausing becomes bookkeeping. Playback rate becomes chipmunk audio or your own time-stretching. Buffering and media keys are yours now too. The element hands you all of it, pitch-preserved playbackRate included.

And the precision argument evaporates once you measure the right thing. currentTime jitter is a few milliseconds. Human reaction time on a keypress is 100 to 150. Chasing the 5ms while the 120ms sits uncorrected isn't engineering, it's fidgeting.

Web Audio still runs, just not for playback. The file gets decoded once with decodeAudioData purely to compute waveform peaks — 600 buckets of RMS, normalised so quiet recordings still show shape. That decode carries a trap worth knowing: in some browsers decodeAudioData detaches the ArrayBuffer you hand it, so if you keep a reference for anything else it's now empty. Clone it going in.

const arr = await file.arrayBuffer();
const buffer = await ctx.decodeAudioData(arr.slice(0));

drift, and the clock I stopped keeping

The element fires timeupdate about four times a second. A playhead that moves four times a second looks broken, so there's a requestAnimationFrame loop while playing.

My first version had the rAF loop keep its own clock — take the last known currentTime, add the frame delta, render. It drifted. Slowly at 1x, and much faster once I added the speed control, because I'd forgotten to multiply the delta by playbackRate, so at 0.8x the display gained about twenty percent on the audio and my carefully timed lines were nonsense.

The fix was to delete the clock. The rAF loop is a sampler now and nothing else:

const tick = () => {
  setCurrent(el.currentTime);
  raf = requestAnimationFrame(tick);
};

No accumulation, nothing that can diverge. It reads the only source of truth sixty times a second instead of four. When the browser throttles rAF in a background tab the playhead freezes while the audio keeps going and then snaps forward on return, which is correct — the display was always downstream of the audio, never a parallel estimate of it.

There's a residual error I haven't fixed. When you tap Space, the timestamp stored is the last value React rendered, up to one frame stale — around 16ms at 60Hz, worse on a busy tab. The keydown handler has the audio element right there and should read el.currentTime at the instant of the press. It's far below my tap error, so it has never mattered, and I still don't like it.

why the keyboard is the whole interface

You're tapping to a beat. Anything that makes you aim is disqualified.

Space marks the current line and moves to the next. Shift+Space marks without advancing, for fixing one line and staying put. K plays and pauses, R restarts, arrows seek two seconds or move the cursor, Backspace clears the line under the cursor, Tab jumps to the next untimed line, [ and ] walk a fixed ladder of rates from 0.5x to 1.25x, and ? lists all of it.

The real argument against the mouse isn't that clicking is slow. It's that clicking is inconsistently slow. A constant error is a gift — you measure it once, put it in the offset field, and it disappears. A variable error is noise you can never subtract. Keyboard latency through the same key, in the same posture, over forty lines is tight enough that a single global offset genuinely fixes the whole file.

Which is what "calibrate from taps" does. It walks every timed line, looks for the loudest waveform bucket within 300ms, takes the median distance between your tap and that peak, and offers it as an offset. Three samples minimum, and it reports the median absolute deviation next to the number so you can see whether your tapping was tight or whether you were guessing.

Writing this post, I went to check that code and found a real bug in it. 600 buckets across a four-minute track is 400ms per bucket — coarser than the ±300ms window it's searching inside. So it often examines one or two buckets and returns a bucket centre, quantising the answer to 400ms increments. It has been returning plausible numbers by luck. Peaks should be computed at a finer resolution for calibration than for drawing, and while I'm in there: the loudest moment isn't the onset anyway. What I actually want is a rise in energy — spectral flux — not a maximum. That's the next evening's work.

Two smaller details took longer than they should have. Space scrolls the page, so it needs preventDefault — but only when you aren't typing a lyric, so the handler checks whether the target is an input, a textarea, or contenteditable first. And there's a 120ms floor between taps, because a Space bar that bounces eats two lines and you won't notice until the export. That floor is also a limit: lines closer than 120ms can't both be tapped. For sung lyrics it's never come up. For a rap verse it would.

no backend, no account, no upload

Nothing leaves the machine. The audio becomes an object URL and stays in the tab. There's no server, no sign-in, and no database anywhere in the feature.

That's a product decision more than a technical one. People time unreleased demos. Handing an unfinished recording to a stranger's server in exchange for a text file is a bad trade, and every free online LRC generator asks you to make it.

So persistence is localStorage, and the audio can't be part of it. Projects live under lyrics-sync:projects-v2 — lyrics, timings, metadata, all JSON. The track is represented only by a key built from name::size::lastModified, enough to recognise the same file if you drop it in again. A refresh keeps your work and loses your audio, and a banner says so. The current stage sits in sessionStorage so reloading doesn't dump you back at step one.

The v2 in that key is a scar. The first version autosaved into a single slot, so opening a second song silently overwrote the first. I found out the way you'd expect. A one-shot migration lifts any surviving v1 blob into a named project, and it'll stay in the code long after the last person who needs it is gone.

what it still can't do

No word-level timing. Enhanced LRC puts <00:12.34> tags inside a line so karaoke apps can highlight syllables; the model here has exactly one number per line. That isn't a feature, it's a different schema.

No end times, so SRT export keeps guessing. No onset detection, so calibration is a median of approximations. Nothing on mobile worth using — the on-screen tap button works, but its latency is worse and far less consistent than a key, and inconsistency is the one thing calibration can't rescue. And it's no help at all with the genuinely hard case, a line you can't place because two vocal takes overlap. That wants stem separation, which is a much larger tool than this one.

What it does do is turn three hours into about eleven minutes. Six tracks since. The last one went in a single pass at 0.9x, tapping Space, and I didn't open a text editor once.


by Krishna Adhikari · Jul 31, 2026
share
// related.transmissions

Keep reading.