Note by Note is a browser extension for practicing along with music you didn't record: a YouTube lesson, a backing track, an mp3 on your disk. It opens in the side panel and processes the page's audio in real time, so you can drop a song into your instrument's key, slow a solo to half speed without the chipmunk effect, and loop four bars until they stick.
Chrome 116+ and Firefox 140+ (MV3, side panel). Built with WXT, Svelte 5 and TypeScript; pitch and time-stretching come from the Rubber Band Library realtime R3 engine, compiled to a WASM AudioWorklet.
Pitch and speed. Transpose ±12 semitones, or ±36 with extended range turned on. Fine-tune in cents, or pin everything to a reference pitch if you're playing with a Baroque group at 415 Hz. Speed runs 25–200% and leaves pitch where it is.
Practice structure. Drop markers on the timeline, set a loop range, add a count-in. Clicking a marker tile loops its section; dragging across tiles (or Shift-clicking a second one) loops every section between them. Any loop can be saved as a snippet, and snippets chain into sequences: play the solo at 50%, then 75%, then full speed, repeating each a set number of times, without touching the panel between passes.
Sound. A vocal reducer (STFT center-cut, written for this project) pushes the center-panned voice down so the band comes forward. There's also a 10-band EQ with saveable presets.
Chords. Optional chord and key detection runs a BTC model over the audio and draws a chart under the timeline.
Keeping your place. Settings are stored per track against a normalized URL, so reopening a video brings back its pitch, speed, markers, loops and snippets — however you open it, not just from the library. Favorites and recents live in a library tab, and optional cross-device sync carries saved practice data and favorites to your other browsers through the browser's own sync — no server, no account.
|
|
Chrome, Edge, Brave Install from the Chrome Web Store |
|
|
Firefox Install from Firefox Add-ons |
Or build it yourself and load it unpacked:
pnpm install ; pnpm buildThen open chrome://extensions, turn on Developer mode, choose Load
unpacked, and pick .output/chrome-mv3.
pnpm install # postinstall generates the worklet bundles and WXT types
pnpm dev # launches Chrome with the extension loaded, HMR on
pnpm check # svelte-check / TypeScript; this is the type gate
pnpm build # production build → .output/chrome-mv3
pnpm zip # store packagepnpm install isn't optional before touching anything audio-related: the
worklet bundles are generated, not committed.
WXT_NO_LAUNCH=1 pnpm dev skips the launched browser so you can load
.output/chrome-mv3 unpacked in your own Chrome instead. HMR still connects.
To work on the UI without an extension context, build, serve
.output/chrome-mv3 statically, and open sidepanel.html?mock=1 — mock data
plus an in-memory chrome shim.
pnpm dev:firefox / pnpm build:firefox / pnpm zip:firefox build the
Firefox 140+ add-on (.output/firefox-mv3); it has no tab-capture fallback, and
Chrome is what the E2E harness drives, so smoke-test it by hand after touching
the engine.
pnpm test:dsp runs the unit tests under node --test: the center-cut
math, the CQT, chord decoding, library migration, snapshot selection, backups and sync transport. Fast, no browser.
The e2e harness is the interesting one. It launches Chrome for Testing with the extension installed, plays a 440 Hz tone, and asserts on the processed output — +12 semitones has to come back at 880 Hz. It also covers loop wrapping, snippet sequences, per-track persistence across reloads, the strict-CSP fallback, and the vocal reducer against a stereo mix.
pnpm dlx @puppeteer/browsers install chrome@stable --path ./.browsers # once
node e2e/make-tone.mjs ; node e2e/make-stereo-mix.mjs # once
pnpm wxt build --mode testing # grants <all_urls> so no native prompt blocks the run
node e2e/run.mjs # --headful to watch it happensrc/
core/ engine, audio pipeline, messaging, model, persistence, state
features/ one folder per feature, each with engine/ and/or panel/
ui/ shared presentational components
entrypoints/ WXT composition roots (sidepanel, content, background, offscreen, local-player)
Vertical slices, not layers. The fact worth knowing up front: the audio engine
lives in the page, not in the side panel. The content script owns detection,
transport and the whole DSP chain; the panel mirrors it over a typed
chrome.runtime port at ~30 Hz, which is why closing the panel doesn't stop a
running sequence. Within a feature, engine/ and panel/ never import from each
other, and features register themselves with the composition roots rather than
the other way round.
- Worklet bundles land in
public/worklets/, gitignored and generated — runpnpm installfirst. They rebuild onwxt build, but editing a*.worklet.tsmid-pnpm devdoesn't hot-reload; rerun the matchingscripts/build-*-worklet.mjs. - Those bundles have no
@/alias, and neither do thenode --testfiles. Both need relative imports, with explicit.tsextensions in the tests. - One
MediaElementSourceNodeper element per document, so reloading the extension means reloading the page too. note-by-note-center-cutis a string literal on both sides of the worklet boundary —tscwon't catch a mismatch, you'll get anInvalidStateError.node e2e/run.mjsprints a pass/fail tally for the playback suite;node e2e/library.mjs(pnpm test:e2e:library) additionally checks concurrent library edits, remote updates, restart recovery and sync capacity.
Saved practice data, favorites, EQ presets and settings sync through the browser's own extension storage. Recent, panel layout, last-used parameters and generated chord analysis stay on the device. Audio is never transferred.
One saved song owns its parameters, markers and snippets. Recent and Favorites are views of that song, so there are no saved-settings copies to keep aligned. The background is the only library writer; it handles edits, imports and remote updates even when the panel is closed. Each panel loads and watches one library; settings, UI preferences, presets and song lists read that same copy. An active practice session keeps its loaded configuration when sync arrives. An explicit backup import reloads open songs with the imported practice settings and cancels pending edits from before the import. Sync never reloads the panel.
Local storage and sync use the same shared library snapshot, with one timestamp. The most recently edited snapshot replaces the older copy in full. On equal timestamps the synced copy wins. Edits made on two devices at once can overwrite each other, even when they affect different songs; there are no field merges or deletion markers.
The snapshot is gzip-compressed and split across fixed storage slots to fit the browser's 8 KB item limit. A content hash ensures all parts belong to the same complete snapshot before it is used. If the library exceeds sync capacity, Settings reports an error and retains the complete local library and the last successful synced copy. Songs are not automatically discarded to make it fit. Export a backup to transfer all data, including local history and analysis.
Backups are readable version-2 JSON containing shared and local sections. Imports also accept the released version-1 format, converting it once. Importing a backup replaces the entire library and dates it as a new edit for sync, including removal of songs absent from the file. Old local storage is retained as a recovery copy after the first migration. Upgrade all devices before using the new sync format; older builds cannot read it. Automatic migration recovers valid songs and fields independently of damaged records. If a saved library cannot be opened, the panel offers retry, recovery export and backup import; importing retains a copy of the damaged library locally.
GPL-2.0-or-later — see LICENSE for the full text and NOTICE for the project's copyright and third-party notices.
The copyleft comes from Rubber Band, which is used here under its GPL option. Anything distributed on top of this has to ship its source under the GPL as well, and per Rubber Band's own guidance, GPL builds can't go on the iOS or macOS App Stores. Replacing the pitch engine with a differently-licensed one is the only way out of that.
One wrinkle worth recording, since it looks like a contradiction: the npm
package we consume, @echogarden/rubberband-wasm, declares GPL-2.0-only in
its metadata, but it ships only the bare GPLv2 text with no version-restricting
statement of its own, and upstream Rubber Band is distributed by Breakfast Quay
as GPL "version 2 or later". The npm field is over-restrictive; -or-later is
what actually applies.
Third-party components:
- Rubber Band Library (GPL-2.0-or-later)
— pitch shifting and time stretching, via
@echogarden/rubberband-wasm. - The BTC chord-recognition model
(MIT, © 2019 Jonggwon Park) —
public/models/btc.onnx, license text alongside it in BTC-LICENSE.txt.
The vocal reducer and the rest of the DSP in src/ were written for this
project.
