Fretly · Personal Project
Practice along to the real recording, not a MIDI backing track
A web app that pairs a Guitar Pro score with the original song and keeps the tab cursor locked to the recording — through every tempo push, drag, and rubato a real band plays.
Role
Solo — Design + Engineering
Timeline
Jun. 2026 - Present (4 months)
Team
Solo project
Stack
TypeScript · Next.js 16 · alphaTab · Python · SyncToolbox
Overview
A score that follows the record
Fretly imports a Guitar Pro or PowerTab score — from a local file or straight out of a Songsterr search — pairs it with backing audio from a file or a YouTube link, and renders the notation and tablature in the browser with track selection, looping, speed control, and separate backing and reference-synth volume.
The part that matters is the cursor. Instead of playing alphaTab's MIDI rendition, the app makes the actual recording the playback clock and warps the score onto it, so you are practicing against the record you know rather than a metronomic approximation of it.

The Problem
Real bands don't play to a click
Tab software plays a synthesized version of the score at a tempo you set. Recordings do something else entirely — they push into choruses, drag on turnarounds, and sit a few milliseconds off the grid everywhere in between. A cursor driven by the score's own tempo map is right at bar one and wrong by bar twenty.
A single global offset does not fix that, because the error is not constant. What is needed is a nonlinear, strictly monotonic map from score time to audio time: a curve that can stretch and compress locally while never running backwards.
Building and trusting that curve is the whole project.
Approach
Import fast, align offline, play against the recording
Import is deliberately impatient. Songsterr routes resolve metadata and convert revisions into .gp files, YouTube routes shell out to yt-dlp and ffmpeg for search and audio, and the player route opens immediately while alignment is queued in the background. Audio Blobs live in IndexedDB; signed-in users also get their files in private Supabase Storage behind row-level security.
Alignment itself is offline preprocessing, never a request-path job. alphaTab converts the score to MIDI and a bar/beat grid using the same parser playback uses; fluidsynth renders a reference track; librosa and SyncToolbox MrMsDTW compare quantized chroma and DLNCO onsets to produce a monotonic warp path. The client resamples that path into alphaTab sync points and stores it per song.
At playback, alphaTab runs in external-media mode and a ~60 Hz loop pumps the audio element's current time into it. The SyncMap translates between the two timelines in both directions, so a new DTW result can replace the map in place without reloading the player.

The Drift Hunt
541 ms hiding in a bitwise OR
On a 170 BPM song the cursor started clean, was visibly out a few measures in, and got worse from there. The on-screen error readout could not localize it — it compares two moving clocks and only ever says that something disagrees.
So I measured four layers separately: the score's own timing model, the raw DTW path, the simplified sync map, and what playback actually implements. The first three came back at 0.000 ms against the score across all 144 bars, and the DTW path sat at a 7.3 ms median residual against 474 detected note attacks. Only the last layer was wrong — and its error was a straight line at −2.6666 ms per second.
A per-second constant points at something counted per event, not per song. alphaTab keeps two clocks for the same score: the cursor accumulates in floating point, while the sync-point path runs through a helper that truncates to whole milliseconds with | 0. At 170 BPM a beat is 352.941 ms and gets banked as 352 — and since audit #2 had moved sampling from bars to beats, that helper ran 576 times a song. 0.941 ms × 2.833 beats/s is 2.667 ms/s; × 576 beats is 542 ms. Measured: 541.
The fix reads alphaTab's own belief back out. Sync points are applied once, the generator is asked where it thinks each point sits, and every offset is re-derived against that time before a second and final apply. One pass is exact, and the two clocks are forced to agree at every beat, so nothing is left to accumulate in between.
The lesson I keep from it: the beat-level sampling that fixed intra-bar drift in the previous audit is exactly what multiplied this one by five. Nothing in the system was measuring the layer where it landed — so I built the tooling that could.
Tech Stack
A browser player with an offline signal-processing tail
App & Rendering
- Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS
- @coderline/alphatab for multi-track notation and tablature, served from same-origin assets so its worker and audio worklet load correctly
- Radix Dialog primitives · lucide-react · cva + clsx + tailwind-merge
Playback & Sync
- alphaTab in EnabledExternalMedia mode — the recording is the clock, not alphaTab's MIDI player
- SyncMap: a bidirectional score-time ↔ audio-time curve driving cursor movement
- Douglas–Peucker simplification at a 20 ms tolerance, plus terminal anchors so the tail can't distort
Media Import
- Songsterr lookup that resolves current revisions and converts fetched parts into Guitar Pro data
- yt-dlp + ffmpeg + ffprobe behind Node route handlers for YouTube search, download, validation, and normalization
- npm-bundled binaries traced into the media routes by next.config.mjs, with env-var overrides per host
Persistence
- IndexedDB for large audio Blobs · localStorage for preferences and sync metadata
- Optional Supabase Auth + Postgres metadata + private Storage, with row-level security per account
Offline Alignment
- alphaTab converts the score to MIDI plus a bar/beat timing grid — the same parser playback uses
- fluidsynth renders a reference track; librosa analyzes at 22.05 kHz mono
- SyncToolbox MrMsDTW over quantized chroma + DLNCO onsets produces a monotonic nonlinear warp path
Testing & Diagnostics
- 22 Vitest suites over sync maps, audio clocks, the alignment queue, waveform/onset logic, and the import helpers
- In-app diagnostics: waveform overlays, bar/beat markers, manual anchors, onset residuals, DTW retry
- probe-playback.mjs reproduces alphaTab's playback math offline so drift can be measured without a browser
Impact
Sync you can measure, not just feel
541 ms
Whole-song drift removed
7.3 ms
Median onset residual
22
Test suites
Live
Shipped and public
- Shipped a working practice tool, live at learn-guitar-beta.vercel.app/, with source on GitHub.
- Removed a whole-song timing drift of 541 ms — an eighth note by the last chorus — by measuring four separate clocks independently instead of guessing at the symptom.
- Turned score-to-recording alignment into an offline preprocessing step, so a DTW pipeline that takes seconds of CPU never runs on the request path in production.
- Made import frictionless: a song can come from a Songsterr search and a YouTube link, with no local files and no API keys, because every binary call stays server-side.
- Kept the alignment honest with instrumentation — onset residuals, waveform overlays, and manual anchors — so sync quality is a number that can be checked, not a feeling.