OpenCanvas GitHub
On-device • no server • no helper process • no API key • Apache 2.0

The official music video behind any song, locked to the song

OpenCanvas finds a song's official music video, checks that it really is the same recording, measures how the two timelines line up, and keeps the picture on the right second — through seeks, skips and the edits a music video makes to its song. It never loops.

OpenCanvas on Android: restart, seek, seek
Android emulator, 360p · restart → video from 0:00 → seek to 1:40 → seek to 2:20
▶ full 38 s video
OpenCanvas in the BitChord desktop app
Windows desktop (BitChord): the official video behind the player, lyrics beside it
▶ full 20 s video

These are real screen captures. A screen recorder cannot capture an app's audio, so the soundtrack is the music video's own audio laid in at the position the picture shows; the measured drift between the app's song clock and the picture is in How close the picture stays.

0.55 s
historical median stream start
(before publisher checks, 16 songs)
0.95 s
median to the song-to-video map
~0.3 MB
read per file to measure the sync
16 / 16
songs aligned: 6 languages,
2000–2022

How it works

A music video is rarely the album track with pictures added: it has an intro and often edits — a bar repeated, a bar cut, a longer outro. A single constant offset is right for part of the song and wrong for the rest, so OpenCanvas measures a map: one offset per stretch of the song, and nothing for stretches the video has no scene for.

1 · Song starts
title, artist, the audio's id; the host calls prefetch so the work begins before any screen asks.
2 · Search and rank
structured search results scored on channel (the credited artist's own), "official video", length, verified badge; Shorts, lyric videos, live takes and fan edits fall out.
3 · Stream, two tiers
360p (itag 18) in about 0.2 s so the canvas can start; a 720p+ stream from a second client a moment later replaces it. Both asked for at once.
4 · Verify and map
read the frame-size table of the song's and the candidate's MP4, align the loudness curves, keep a measured recording match after independent publisher and song/film checks. Audio alone cannot distinguish a fan dance.

Why frame sizes, not audio

An AAC track is a sequence of frames, and each frame's size follows how loud and busy the music is at that instant. The table of sizes sits in the file's header (moov), so reading about 300 KB of each file yields a loudness-like curve for the song and for the video — nothing is downloaded in full and nothing is decoded. The curves (log of the size, minus a 3 s moving average, resampled to 50 Hz) are cross-correlated by FFT in 10 s windows (hop 5 s); a Viterbi pass picks one offset per window and charges for every change of offset, growing with the size of the change — a chorus a minute away is a different place, not an edit. Runs of windows with one offset become segments. The compute takes 12–63 ms.

Why bounded ranges

YouTube throttles an open-ended download of its adaptive streams to about 140 KiB/s — the 18 MiB 720p file takes ~132 s that way — but serves bounded ranges at line rate (1.8 s for the whole file). ProgressiveRangeSource opens with a small first range that doubles up to 4 MiB, and answers a seek with a small range at the new position first (about 106 ms to the first bytes). On Android the host uses 1 MiB chunks through a data source. Playback never waits for a whole file.

Locked to the song, not looping

CanvasSyncPolicy is the whole playback brain, pure Kotlin: inside each segment videoTime = songTime + offset. Every 250 ms (and at once when seekDetected sees the song jump) it answers Nudge (±5 % speed to absorb small drift), SeekTo (drift over 500 ms, or an edit point), Hold, Hidden (no matching picture here — show the cover) or Ended (the song outlasts the video — never loop). Tolerance is 150 ms.

One code path everywhere

The search, ranking, stream resolution, verification, alignment, cache and policy are one shared Kotlin source set used by Android and the desktop JVM — there is no per-platform sync and no daemon. A measured map is stored per (song, video) pair, so the next play needs no measuring (0–25 ms); sessions are shared, so a UI that restarts its request finds the work its predecessor started.

NEW IN THE KOTLIN CORE SOURCE

Keep the people in the picture.

YuNet faces, YOLOX-Nano people and a shot-aware camera replace the fixed center crop. Follow a subject, keep a group together, and show the whole scene when a tight crop would lose the action.

Fixed center crop
OpenCanvas framing policy

Illustrated detections, real camera policy: this comparison runs the native policy compiled to JavaScript. Face/person inference runs in Android and desktop hosts, not in this browser demo.

The worker consumes uncropped decoded frames, adapts its sampling rate to inference cost and caches trajectories by video, model revision and viewport. Cuts and seeks reset the camera; uncertain scenes use full-frame fit.

Integration, models and validation →
Choose the right video first. Film/album search, title variants and independent publisher evidence precede motion and audio-sync checks. A fan dance matching the song audio is insufficient; previous timing-only choices cannot bypass the new policy.
Measured on one Windows desktop: reference image: 12 faces, 4 people; warm combined median about 24 ms. This is one image/device, not a battery or accuracy benchmark. Physical Android measurements remain outstanding. Current source APIs are newer than the published 1.0.0 artifact. Current validation report: 16-song smoke check and both Godavari tracks →

Benchmarks

Historical sync/stream baseline, measured before publisher-selection and subject-framing updates on one Windows 11 machine from an empty cache. These are not speed claims for the new path. Current framing results and limits appear above; repeat the matrix and stream tests with OPENCANVAS_MANUAL=1.

Cold start — 16 songs, 6 languages, 16 of 16 aligned

Median 550 ms to a playable stream (451–688 ms) and 954 ms to the map (596–1 688 ms, p90 1 139 ms). "Offset" is where the song starts on the video's timeline; many videos carry the same master (offset 0).

SongLanguageVideoLengthOffsetConfidenceStream readyMap ready
The Weeknd — Blinding LightsEnglish4NRXx6U8ABQ262 s+22.10 s0.600.69 s1.69 s
The Kid LAROI — STAYEnglishkTJczUoc26U157 s+19.36 s0.820.58 s1.10 s
Harry Styles — As It WasEnglishH5v3kku4y6Q165 s+0.00 s1.000.51 s0.68 s
Ed Sheeran — Shape of YouEnglishJGwWNGJdvx8263 s+0.00 s1.000.55 s0.88 s
Dua Lipa — LevitatingEnglishTUVcZfQe-Kw230 s+0.00 s1.000.52 s0.73 s
Imagine Dragons — BelieverEnglish7wtfhZwyrcc216 s-0.33 s0.720.58 s0.97 s
Rema — Calm DownEnglish / AfrobeatsWcIcVapfqXw239 s+0.00 s1.000.66 s0.97 s
Coldplay — YellowEnglish (2000)yKNxeF4KMsY272 s+0.00 s1.000.59 s0.77 s
Arijit Singh — Tum Hi HoHindivQ3HKnFVA2A264 s+0.04 s0.640.53 s0.96 s
Badshah — Kala ChashmaHindi / PunjabiYvWKPU2mcLw198 s+16.95 s0.760.55 s1.14 s
Dhanush — Why This Kolaveri DiTamilYR12Z8f1Dh8248 s+0.00 s1.000.45 s0.60 s
Anirudh Ravichander — Vaathi ComingTamilnIYZ4nbWq7w256 s-0.00 s0.970.52 s0.97 s
Armaan Malik — Butta BommaTelugu2mDCVzruYzQ193 s+0.00 s1.000.47 s0.60 s
BTS — DynamiteKoreangdZLi9oWNZg223 s+22.80 s0.860.64 s0.95 s
Luis Fonsi — DespacitoSpanishkJQP7kiw5Fk281 s+0.00 s1.000.53 s0.72 s
PSY — Gangnam StyleKorean (2012)9bZkp7q19f0252 s+3.56 s0.440.58 s1.41 s

Getting the stream

The same 720p file (18 MiB, 263 s).

Way of getting itFirst byteFirst secondWhole file
yt-dlp -g (spawn, get the URL)after 2.3–2.5 s——
yt-dlp full download——3.9–4.0 s
Plain HTTP GET of the file87 ms0.5 s132 s (throttled ≈140 KiB/s)
OpenCanvas ProgressiveRangeSource27 ms27 ms1.8 s
Seek to a random place, nothing bufferedmedian 106 ms to the first bytes of the new position

480p: plain GET 132 s, ours 1.0 s. The 360p muxed stream is not throttled: 10 MiB in 1.1 s, first bytes in 53 ms. Resolving a stream URL takes 60–400 ms (median 143 ms for 360p).

How close the picture stays to the song

Android emulator (4 GB RAM, software video decoding, 360p). Logged song clock and video clock, plus frame-by-frame matching of the capture against the source video.

WhatResult
Video position vs. the map's target, settled (50 samples, restart + 2 seeks)96 % within ±150 ms · median +49 ms · p95 147 ms · max 236 ms
Picture vs. song through the map (frame matching)settled within ≈ 0.1–0.4 s, picture slightly ahead
First picture after a seek≈ 0.5 s after the tap, then smooth
Cold start while the emulator is busy loadingup to ≈ 1 s behind for the first ≈ 8 s, then within ±0.2 s

Emulator numbers: they show that the policy works and what an under-powered device does. They are not a measurement of a phone — we have not yet measured on physical devices, nor 720p decode cost or battery.

Compared with the other ways

We did not benchmark other libraries. This compares approaches and says what each one costs; the OpenCanvas row and the yt-dlp numbers are measured above.

ApproachAny songLocked to the song, survives seeksNeedsBefore the video can start
Label-published loops (Spotify Canvas, Apple motion artwork)No — only what labels uploadedNo — a short loopan account or token for somea lookup
Embed the YouTube playerYesNo — plays its own audioits UI, ads and controlsthe player's own load
yt-dlp + play the fileYesNo — offset and seeks are yours to solvea Python tool on the device2.3–2.5 s to the URL, ≈ 4 s to download
Decode both audio tracks and correlate themYesYesdownloading and decoding two full audio streamsseconds of download and CPU (not measured here)
OpenCanvasYes (16/16 historical baseline)Yes — segment map, seek-awarenothing outside the library0.55 s to a playable stream, ≈ 1 s to the map, 0–25 ms when cached

Quickstart

Not tied to any app: you need the song's title and artist, and the id of the audio you are playing (or a place its audio can be read).

1. Add the dependency

repositories {
    mavenCentral()
    maven { url = uri("https://jitpack.io") }
}
dependencies {
    implementation("com.github.Vijay-Janarthanan:OpenCanvas:1.0.0")
    implementation("com.github.Vijay-Janarthanan.OpenCanvas:opencanvas-compose:1.0.0") // optional

2. Configure once, at app start

OpenCanvas.configure(
    OpenCanvasConfig(
        cacheDirectory = File(context.filesDir, "opencanvas"), // measured maps live here
        log = { Log.d("OpenCanvas", it) },                     // one line per stage, with timings
    ),
)

3. Start when the track starts, show when a screen asks

// when the song starts (returns at once; the work runs on the library's own scope)
OpenCanvas.prefetch(
    title = "Blinding Lights", artist = "The Weeknd", durationSec = 200,
    mode = OpenCanvasMode.FULL_SYNCED_VIDEO,
    resolution = OpenCanvasResolution.HD_720P,
    trackVideoId = "fHI8X4OXluQ",        // YouTube (Music) id of the audio you are playing
)

// in the screen that shows the video
OpenCanvas.resolveFlow(
    title = "Blinding Lights", artist = "The Weeknd", durationSec = 200,
    mode = OpenCanvasMode.FULL_SYNCED_VIDEO,
    resolution = OpenCanvasResolution.HD_720P,
    trackVideoId = "fHI8X4OXluQ",
).collect { track ->
    when (track.syncStage) {
        SyncStage.PENDING  -> player.prepare(track.videoStreamUrl, track.videoHeaders) // buffer, don't show yet
        SyncStage.MEASURED -> { policy = CanvasSyncPolicy(track.syncMap(), track.videoDurationMs); showVideo() }
        SyncStage.NONE     -> showStillArtwork()   // no video lines up with this song
    }
}

The flow emits when the stream is known (PENDING), when the map is ready (MEASURED) and again if a taller stream arrives. Collectors of one request share one measurement; abandon the flow and the work is dropped after a short grace. resolve(...) is the one-shot version.

4. Keep the video on the song

// every 250 ms, and at once when policy.seekDetected(...) is true or the track changes
when (val action = policy.decide(songMs, video.positionMs, songPlaying, video.isReady)) {
    Hidden    -> showStillArtwork()            // no matching picture in this stretch of the song
    Hold      -> Unit
    Ended     -> showStillArtwork()            // the song outlasts the video: never loop
    is SeekTo -> video.seekTo(action.videoMs)
    is Nudge  -> video.setSpeed(action.speed)  // at most ±5 %, imperceptible
}

The policy contains no player: use ExoPlayer, VLC, Skiko — anything that reports a position and seeks. On a track change, build a new policy from the new track.

Songs that are not on YouTube

OpenCanvas.resolveFlow(
    title, artist,
    songAudio = SongAudio.Stream(File("/music/song.m4a")),
    mode = OpenCanvasMode.FULL_SYNCED_VIDEO,
)

Any http(s) URL that honours range requests, or a file, works: the audio is read once.

Your own stream resolver

OpenCanvas.configure(OpenCanvasConfig(
    streamBackend = MyBackend(),         // implements StreamBackend
    // desktop only, optional: YtDlpBackend()
))

StreamBackend has two small methods. The defaults need no setup.

Configuration

OpenCanvasConfigDefaultWhat it is
streamBackendInnerTubeBackend.visionOs()resolves the sharp stream: 720p+, plain URLs, no signature
instantBackendInnerTubeBackend.android()360p at once, and the small MP4 the sync reads; null disables the two-step start
cacheDirectory~/.opencanvas/sync (JVM), null (Android)measured maps persist here, so the next play needs no measuring
communityMapsUrljsDelivr path of the repoprecomputed maps, one JSON per song; null disables
httpClienttuned OkHttpsearch, maps and audio ranges
logsilentone line per stage — the first place to look when a song gets no canvas

Try it

The sync engine is compiled to JavaScript and runs in this page. Pick a real song: its official video from YouTube plays behind it as a vertical canvas, held on the song's second by the same code the apps use. Drag the slider and the picture follows.

Demo not loaded
Blinding Lights
The Weeknd
0:00
The song, as YouTube's audio upload. This is the sound you hear.

What the engine foundNot loaded

The map. Each coloured ribbon is a stretch of the song and where it sits in the video; the dashed line joins the song's position now to the video's.
song
—
video
—
should be at
—
drift
—
decision
—
hard seeks
—
What runs here, and what does not
  • Running in your browser: the library's Kotlin sync code (opencanvas-engine.mjs, 99 KB compressed): MP4 header parsing, envelopes, alignment and the policy that decides seek, hold or still cover. On the second and third tab it measures a new pair live.
  • Real songs: the maps come from the library running on a device against YouTube's own files, because a page is not allowed to read YouTube's media bytes. The page then drives the policy with them.
  • Different from the apps: YouTube's embedded players cannot change speed in fine steps, so the real-songs tab corrects drift by seeking (past 0.3 s). The apps nudge the speed by up to 5 %.
  • Measuring any YouTube link in a page needs a small relay that adds the headers browsers require; the engine itself already runs here.

Install the demo builds

Demo builds of BitChord with OpenCanvas, so you can see it on your own songs without building anything. Test builds, not official BitChord releases.

Windows

Portable zip: unzip and run BitChord.exe. Unsigned, so SmartScreen may warn.

Android

Debug APK for arm64 phones, and an x86_64 one for emulators.

Download for Windows (zip, 308 MB) Download for Android (APK, 86 MB) All files and checksums

Play a song without label artwork, open the full player and seek: the video follows. Checksums and notes are on the release page. BitChord is GPLv3; the source of the builds is the pull request branch.

Use it from your language

The engine is Kotlin, so Android and the JVM get all of it. Everything that is pure maths (reading the MP4 headers, aligning, deciding where the picture belongs) also runs in a browser, and the part every player needs, the map and the policy, is ported to TypeScript and Dart. All of them are tested against the same vectors.

Language / platformWhat you getStatusHow
Kotlin · Android, desktop JVMeverything: search, ranking, stream resolution, measuring, policy, cache🟢 LiveQuickstart
Kotlin/JS · browsers, Nodemeasuring from MP4 headers (measure), policy (WebCanvasPolicy); no search or stream resolution🟢 Live in the lab abovetools/build-web-engine.sh
TypeScript · web, React NativeSyncMap, CanvasSyncPolicy, crop filters, React view🟡 Policy onlypackages/opencanvas-js
Dart · FlutterSyncMap, CanvasSyncPolicy, crop kinematics, a view🟡 Policy onlypackages/opencanvas-flutter
Javacallable through the Kotlin API; there is no Java-friendly facade (callbacks instead of suspend and Flow) yet🔵 Opencontribution welcome
Swift · iOS, macOSnot started: the Kotlin core needs a Kotlin/Native target and an HTTP and storage layer, or a port from the vectors below🔵 Not started—

In a browser: measure two files, keep a <video> on the song

import { measure, WebCanvasPolicy } from './opencanvas-engine.mjs';

// anything that can read a byte range: a File, or fetch() with a Range header
const reader = (file) => ({
  length: file.size,
  read: (from, count) => file.slice(from, from + count).arrayBuffer(),
});

const map = JSON.parse(await measure(reader(songFile), reader(videoFile)));
// { ok, confidence, segments: [{ songStartMs, songEndMs, offsetMs, rate }], ... }

const policy = new WebCanvasPolicy(JSON.stringify(map.segments), map.videoSeconds * 1000);
setInterval(() => {
  const a = JSON.parse(policy.decide(audio.currentTime * 1000, video.currentTime * 1000,
                                     !audio.paused, video.readyState >= 2));
  if (a.kind === 'seek') video.currentTime = a.videoMs / 1000;
  if (a.kind === 'nudge') video.playbackRate = a.speed;
}, 250);

Only the headers are read: about 10 KB of the song and 55 KB of the video in the lab, whatever the files' size. Both must be MP4/M4A with AAC audio.

TypeScript and Dart: the map and the policy

// TypeScript (@opencanvas/core)
const map = SyncMap.fromJson(storedJson);          // the JSON the Kotlin core stores
const policy = new CanvasSyncPolicy(map, videoMs);
const action = policy.decide(songMs, videoMs, playing, ready);
// { kind: 'seek', videoMs } | { kind: 'nudge', speed } | 'hold' | 'hidden' | 'ended'

// Dart (open_canvas)
final policy = CanvasSyncPolicy(SyncMap.fromJson(json), videoMs);
switch (policy.decide(songMs, videoMs, songPlaying: true, videoReady: ready)) {
  case SeekTo(:final videoMs): player.seekTo(videoMs);
  case Nudge(:final speed): player.setSpeed(speed);
  case Hidden() || Ended(): showCover();
  case Hold(): break;
}

These ports take a map; they do not measure one. Get it from the Kotlin core, from a community map, or with the browser engine on the left.

Porting it to another language (Swift, Python, C#, …)

The policy and the map are about 200 lines of plain arithmetic, with no player and no network. A port is done when it passes packages/conformance/sync-vectors.json: 87 checks of offsetAt, decide and seekDetected that the Kotlin, TypeScript and Dart implementations all pass (CI runs the three).

  • A map is a list of stretches { songStartMs, songEndMs, offsetMs, rate }: inside one, video = song + offsetMs + (rate − 1) × (song − songStartMs). Outside every stretch there is no picture.
  • The policy is a pure function of (song position, video position, song playing, video ready): hidden, ended, hold, seek or nudge, with 150 ms tolerance and a hard seek past 500 ms.
  • Measuring (reading AAC frame sizes from the moov box, turning them into 50 Hz envelopes, aligning with FFT and Viterbi) is the larger part; the Kotlin source in packages/opencanvas-core/src/commonMain is the reference and has no platform dependencies.

Status, platforms and limits

ComponentStatusWhere
On-device sync (frame-size envelope, FFT + Viterbi, segment map)🟢 LiveAndroid, desktop JVM (shared code)
Two-tier stream resolution (360p instant, 720p+ sharp)🟢 LiveAndroid, desktop JVM
Search, ranking, audio verification🟢 LiveAndroid, desktop JVM
Ranged progressive source🟢 Livedesktop JVM; Android hosts use a chunked ExoPlayer data source
Position-locked policy (CanvasSyncPolicy)🟢 Livecommon Kotlin
Map cache, prefetch, shared sessions🟢 LiveAndroid, desktop JVM
BitChord integration🟢 LiveAndroid APK, Windows desktop
Compose player (opencanvas-compose)🟡 Betafollows the map; compile-checked on JVM, not yet run in a sample app
Looping canvas mode (YouTube "Most Replayed" window)🟡 Betaunchanged from 1.0, not covered by the new benchmarks
Subject-aware framing (YuNet + YOLOX-Nano, shot-local camera)🟡 Current sourceJVM/Android integration; real inference and policy tests; physical-device validation outstanding
Community maps (opencanvas-db)🟡 Wired inno maps published yet
Browser engine (Kotlin/JS: measure, WebCanvasPolicy)🟢 Livemeasures a pair of MP4 files and keeps a <video> on the song; runs the lab above
@opencanvas/core (TypeScript), open_canvas (Dart)🟡 Partialmap and policy ported and tested against the shared vectors, plus crop kinematics and a view; no resolver or measuring (use the Kotlin core or the browser engine)
iOS🔵 Not startedneeds a Kotlin/Native port of the HTTP and storage parts
tools/opencanvas-stream-server⚪ Optionalthe original Python reference; no app needs it

Limits you should know

Collaborate or ask a question

OpenCanvas is a one-person project and I would like to hear how you would use it.

Hire me

I am Vijay Janarthanan, a Full Stack Developer, available for freelance work and full-time roles.

vijaybfriendly@gmail.com