From 2677f8f6c52df45b28b03bac8375a231bc3c3cfd Mon Sep 17 00:00:00 2001 From: Patrick Demichiel Date: Tue, 25 Aug 2026 17:42:59 +0200 Subject: [PATCH 1/2] feat: implement tab-specific side panel behavior and enhance local player opening --- CLAUDE.md | 2 +- src/core/messaging/protocol.ts | 1 - src/core/side-panel.ts | 101 +++++++++++++++++++++++++++ src/core/state/connect.svelte.ts | 22 +++--- src/core/state/track-sync.svelte.ts | 5 +- src/dev/browser-shim.ts | 6 ++ src/entrypoints/background.ts | 45 +++++++++--- src/entrypoints/sidepanel/App.svelte | 6 +- 8 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 src/core/side-panel.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2236305..d8088bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ The tree is organized by **feature**, not by layer: - **`sidepanel/`** — the Svelte UI. Holds no engine state of its own; mirrors the active tab's engine. - **`content.ts` → `src/core/engine/`** — the engine. Media detection, connection state machine, transport, and the Web Audio DSP chain; the loop/sequence/count-in schedulers it drives live in `src/features/*/engine/`. Guards against double-boot via `window.__noteByNote`; tears down on `ctx.onInvalidated` (extension reload) to avoid orphaned instances fighting the page. - **`offscreen/`** — hosts the tab-capture DSP pipeline (one `AudioContext` per captured tab). Same shared pipeline as direct mode. -- **`background.ts`** — service worker broker: per-origin permission grants, content-script registration/injection, tab-capture stream brokering, offscreen document lifecycle. Reconciles the persistent content-script registration from the actual `permissions` API (single source of truth) on every permission change or worker restart. +- **`background.ts`** — service worker broker: per-origin permission grants, content-script registration/injection, tab-capture stream brokering, offscreen document lifecycle. Reconciles the persistent content-script registration from the actual `permissions` API (single source of truth) on every permission change or worker restart. Also owns tab-scoped panel visibility (Chromium): the side panel is disabled by default and the icon click toggles it per tab, so Chrome hides it on other tabs and re-shows it on return. Chrome gives each tab its own panel document, opened at `sidepanel.html?tabId=N` so the panel pins itself to that tab and the worker can recognise the document in `runtime.getContexts` (which reports `tabId: -1` for side panels). Measured gotchas, all documented in [core/side-panel.ts](src/core/side-panel.ts): the toolbar click's gesture dies at the first `await` in the worker, so the toggle snapshots "is it showing" via `getContexts`, fires `setOptions` + `open` synchronously (a no-op when already open), and disables the tab afterwards if the snapshot said it was showing; enabling a tab does not show the panel there, so panel-initiated new tabs (local player, Songs list) go through `openTabWithPanel` **from the panel document** (whose click activation survives awaits), not via the background. Firefox's sidebar is window-global — no per-tab behavior. - **`local-player/`** — a full extension page that plays local files and speaks the same engine protocol via its own tab. ### Three connection modes (the fallback chain) diff --git a/src/core/messaging/protocol.ts b/src/core/messaging/protocol.ts index 270caac..6f6439e 100644 --- a/src/core/messaging/protocol.ts +++ b/src/core/messaging/protocol.ts @@ -131,5 +131,4 @@ export interface ProtocolMap { captureVolume(data: { tabId: number; volume: number }): Promise; /** Revoke Permissions. */ revokeAllPermissions(): Promise; - openLocalPlayer(): Promise<{ tabId: number }>; } diff --git a/src/core/side-panel.ts b/src/core/side-panel.ts new file mode 100644 index 0000000..466a30d --- /dev/null +++ b/src/core/side-panel.ts @@ -0,0 +1,101 @@ +import { HAS_SIDE_PANEL_API } from '@/core/platform'; + +/** Tab-scoped side panel (Chromium). + * + * The manifest's `side_panel.default_path` enables the panel on every tab, so + * once opened it followed the user to every tab they switched to. Instead the + * background disables the panel by default and it is enabled per tab, only + * where the user summoned it — Chrome then hides the panel while a tab without + * it is active and brings it back when the user returns to one that has it. + * Per-tab options die with their tab, so nothing accumulates. + * + * Facts that shape the call sites (all measured against Chrome 151): + * - `sidePanel.open()` needs a user gesture, and the gesture of a toolbar + * click in the service worker dies at the first `await` — the background + * must call it synchronously from `action.onClicked`. A click inside an + * extension *page* instead grants transient activation that survives awaits. + * - Enabling the panel on a tab does not show it there: a freshly enabled tab + * that becomes active still needs an explicit `open()`. + * - Every tab gets its own panel document, which lives while the panel is + * open for that tab (it survives being hidden behind another tab) and dies + * when the panel is closed there — by the ✕, by `sidePanel.close()`, or by + * disabling the tab. Disabling is the close primitive used here: unlike + * `sidePanel.close()` it exists on every Chrome we support, and it tears + * the document down at once (~4 ms), so an immediate re-click reopens. + * After the ✕ the document lingers ~360 ms — too short for a hand to reach + * the toolbar, so the icon-click toggle below can treat "document exists" + * as "showing". + * - `runtime.getContexts` lists those documents but reports `tabId: -1` / + * `windowId: -1` for all of them, so the tab is encoded in the path + * instead (`?tabId=`) and read back from `documentUrl`. The panel honors + * that parameter by pinning itself to the tab — the right thing for a + * per-tab document, which must not follow tab activation while hidden. + * + * Firefox has no per-tab sidebar (`sidebar_action` is window-global), so these + * helpers reduce to plain tab handling there. + */ + +/** The built sidepanel entrypoint — must match the `side_panel.default_path` + * WXT emits into the manifest. */ +const PANEL_PATH = 'sidepanel.html'; + +/** The per-tab panel URL: the tab is carried in the query so the panel pins + * itself to it and `isPanelShowing` can recognise its document. */ +function panelPath(tabId: number): string { + return `${PANEL_PATH}?tabId=${tabId}`; +} + +function tabOfPanelDocument(documentUrl: string | undefined): number | null { + if (!documentUrl) return null; + const raw = new URL(documentUrl).searchParams.get('tabId'); + return raw == null ? null : Number(raw); +} + +/** Enable the panel on one tab. No-op on Firefox. Returns the API promise + * without awaiting anything first, so a caller inside a user-gesture callback + * can fire this and `sidePanel.open()` back to back. */ +export function enablePanelForTab(tabId: number): Promise { + if (!HAS_SIDE_PANEL_API) return Promise.resolve(); + return browser.sidePanel.setOptions({ tabId, path: panelPath(tabId), enabled: true }); +} + +/** Close the panel on one tab by disabling it there. No-op on Firefox. */ +export function disablePanelForTab(tabId: number): Promise { + if (!HAS_SIDE_PANEL_API) return Promise.resolve(); + return browser.sidePanel.setOptions({ tabId, enabled: false }); +} + +/** Whether the panel is open for `tabId`: its document exists. For the + * active tab that means showing. The read is dispatched immediately, so a + * caller in a click handler can fire this ahead of the synchronous enable + + * open and still get the pre-click state — the browser handles extension API + * calls in dispatch order. */ +export async function isPanelShowing(tabId: number): Promise { + if (!HAS_SIDE_PANEL_API) return false; + const contexts = await browser.runtime.getContexts({ + contextTypes: ['SIDE_PANEL' as Browser.runtime.ContextType], + }); + return contexts.some((c) => tabOfPanelDocument(c.documentUrl) === tabId); +} + +/** Open `url` in a new foreground tab that the side panel follows. For flows + * started from a click inside the open panel (local player, Songs list): a + * plain `tabs.create` would activate a tab the panel isn't enabled on and hide + * it. Must be called from the panel document itself, inside the click's + * transient activation — not routed through the background, where + * `sidePanel.open()` has no gesture to run on. */ +export async function openTabWithPanel(url: string): Promise { + if (!HAS_SIDE_PANEL_API) return browser.tabs.create({ url }); + // The tab starts inactive: activating it before the panel is enabled and + // opened there would hide the panel — the very document running this code. + const tab = await browser.tabs.create({ url, active: false }); + const tabId = tab.id!; + await enablePanelForTab(tabId); + // The activation below must run either way; without the open the panel is + // merely hidden on the new tab and the icon brings it back. + await browser.sidePanel + .open({ tabId }) + .catch((err: unknown) => console.error('sidePanel open for new tab', err)); + await browser.tabs.update(tabId, { active: true }); + return tab; +} diff --git a/src/core/state/connect.svelte.ts b/src/core/state/connect.svelte.ts index 9f7a04e..0f5d3a6 100644 --- a/src/core/state/connect.svelte.ts +++ b/src/core/state/connect.svelte.ts @@ -41,22 +41,22 @@ class ConnectionManager { async init() { settings.onChange = pushSettings; - // Test/debug override: pin the panel to one tab (?tabId=N). + // Chromium opens one panel document per tab at `sidepanel.html?tabId=N` + // (see core/side-panel.ts): pin to that tab. Hidden behind another tab + // this document stays alive, and following activation there would leave + // it mirroring the wrong tab when Chrome shows it again. The E2E harness + // uses the same parameter to pin a panel opened as a plain tab. Firefox's + // window-global sidebar has no such parameter and follows the active tab. const pinned = new URLSearchParams(location.search).get('tabId'); if (pinned) { await this.bindTab(Number(pinned)); - browser.tabs.onUpdated.addListener((tabId, info) => { - if (tabId === this.tabId && info.status === 'complete') void this.bindTab(tabId); + } else { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (tab?.id != null) await this.bindTab(tab.id); + browser.tabs.onActivated.addListener(({ tabId }) => { + void this.bindTab(tabId); }); - return; } - - const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); - if (tab?.id != null) await this.bindTab(tab.id); - - browser.tabs.onActivated.addListener(({ tabId }) => { - void this.bindTab(tabId); - }); browser.tabs.onUpdated.addListener((tabId, info) => { if (tabId !== this.tabId) return; // Navigation completed: the engine needs re-injection + reconnect. diff --git a/src/core/state/track-sync.svelte.ts b/src/core/state/track-sync.svelte.ts index d3924d5..28938c9 100644 --- a/src/core/state/track-sync.svelte.ts +++ b/src/core/state/track-sync.svelte.ts @@ -5,6 +5,7 @@ import { touchFavorite } from '../../features/library/persist/favorites'; import { removeHistoryEntry, upsertHistory } from '../../features/library/persist/history'; import { findSavedEntry } from '../../features/library/panel/saved-settings'; import { loadTrackData, saveTrackData } from '../persist/storage'; +import { openTabWithPanel } from '../side-panel'; import { trackDataDescriptors } from '../persist/track-data'; import { session } from './session.svelte'; import { settings } from '../../features/settings/panel/settings.svelte'; @@ -253,7 +254,9 @@ class TrackSync { if (tabId != null) { await browser.tabs.update(tabId, { url: entry.pageUrl }); } else { - await browser.tabs.create({ url: entry.pageUrl }); + // No engine tab to reuse — a fresh one the panel follows (a plain create + // would activate a tab the panel isn't enabled on and hide it). + await openTabWithPanel(entry.pageUrl); } } } diff --git a/src/dev/browser-shim.ts b/src/dev/browser-shim.ts index 1baae1b..c868ece 100644 --- a/src/dev/browser-shim.ts +++ b/src/dev/browser-shim.ts @@ -96,6 +96,8 @@ if (!existing?.storage) { tabs: { query: async () => [], get: async () => ({}), + create: async () => ({ id: -1 }), + update: async () => ({}), connect: () => ({ name: 'shim', postMessage: () => {}, @@ -106,6 +108,10 @@ if (!existing?.storage) { onActivated: makeEvent(), onUpdated: makeEvent(), }, + sidePanel: { + setOptions: async () => {}, + open: async () => {}, + }, permissions: { contains: async () => false, request: async () => false, diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index b638aed..d7e71ed 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -2,6 +2,7 @@ import type { OffscreenCommand } from '@/core/messaging/protocol'; import { onMessage } from '@/core/messaging/rpc'; import { grantedOriginsItem } from '@/core/persist/storage'; import { CAN_CAPTURE_TAB, HAS_SIDE_PANEL_API } from '@/core/platform'; +import { disablePanelForTab, enablePanelForTab, isPanelShowing } from '@/core/side-panel'; /** Firefox's sidebar API. WXT's `browser` types are Chromium-shaped and don't * declare it, so reach it through a narrow cast — only ever on the Firefox @@ -68,6 +69,19 @@ async function syncFromPermissions() { } export default defineBackground(() => { + // Scope the panel to the tabs it was opened on: the manifest's + // `side_panel.default_path` enables it everywhere, so once opened it would + // follow the user to every tab. With the default disabled and the click + // handler enabling it per tab, Chrome hides the panel while a tab without it + // is active and brings it back when the user returns to one that has it. + // Idempotent, so re-applying on every worker start is fine (per-tab options + // live browser-side and survive worker restarts). + if (HAS_SIDE_PANEL_API) { + browser.sidePanel + .setOptions({ enabled: false }) + .catch((err: unknown) => console.error('sidePanel default disable', err)); + } + // Open the side panel from an explicit action.onClicked handler rather than // setPanelBehavior({ openPanelOnActionClick: true }). With openPanelOnActionClick // the click is consumed by the panel and action.onClicked never fires, so the @@ -75,9 +89,13 @@ export default defineBackground(() => { // has not been invoked for the current page". Handling the click ourselves is // what grants activeTab on the current tab, which tab capture relies on. browser.action.onClicked.addListener((tab) => { - // Firefox serves the same page as a sidebar_action. `toggle()` has to be - // reached synchronously from the click for Firefox to count it as a user - // gesture, so neither branch may await anything first. + // Both branches must reach their API call synchronously from the click: + // Firefox counts `toggle()` as a user gesture only then, and on Chromium + // the action's gesture does not survive an `await` — `sidePanel.open()` + // chained after `setOptions` is rejected with "may only be called in + // response to a user gesture". Fire both back to back instead; the calls + // are dispatched in order, so the per-tab option is in place when the + // open is evaluated (verified against Chrome 151). if (!HAS_SIDE_PANEL_API) { sidebarAction() .toggle() @@ -85,9 +103,21 @@ export default defineBackground(() => { return; } if (tab.id != null) { + const tabId = tab.id; + // The click toggles. Its verdict can't gate the open (the gesture would + // be gone by the time it resolves), so snapshot the pre-click state, + // then enable + open unconditionally — a no-op when the panel is already + // showing — and close once the snapshot says it was. + const wasShowing = isPanelShowing(tabId); + enablePanelForTab(tabId).catch((err: unknown) => + console.error('sidePanel enable for tab', err), + ); browser.sidePanel - .open({ tabId: tab.id }) + .open({ tabId }) .catch((err: unknown) => console.error('sidePanel open', err)); + wasShowing + .then((showing) => (showing ? disablePanelForTab(tabId) : undefined)) + .catch((err: unknown) => console.error('sidePanel toggle', err)); } }); @@ -149,13 +179,6 @@ export default defineBackground(() => { await syncFromPermissions(); }); - onMessage('openLocalPlayer', async () => { - const tab = await browser.tabs.create({ - url: browser.runtime.getURL('/local-player.html'), - }); - return { tabId: tab.id! }; - }); - // ─── Tab capture (Chromium only) ─────────────────────────── // Firefox implements neither `tabCapture` nor `offscreen`, so there is no diff --git a/src/entrypoints/sidepanel/App.svelte b/src/entrypoints/sidepanel/App.svelte index de29da3..30a4d0f 100644 --- a/src/entrypoints/sidepanel/App.svelte +++ b/src/entrypoints/sidepanel/App.svelte @@ -6,6 +6,7 @@ import SettingsView from '@/features/settings/panel/SettingsView.svelte'; import TooltipLayer from '@/ui/shared/TooltipLayer.svelte'; import { sendMessage } from '@/core/messaging/rpc'; + import { openTabWithPanel } from '@/core/side-panel'; import { installMockState, installMockTicker } from '@/dev/mock'; import { connection } from '@/core/state/connect.svelte'; import { CAN_CAPTURE_TAB } from '@/core/platform'; @@ -52,8 +53,11 @@ }, ); + // Opened from here rather than via the background: the side panel has to + // follow the user to the player tab, and only this document holds the + // click's activation that `sidePanel.open()` requires. async function openLocalFile() { - await sendMessage('openLocalPlayer', undefined); + await openTabWithPanel(browser.runtime.getURL('/local-player.html')); view.close(); } From 6030788f38f91e1e641b8d2bffbb985a2668bae7 Mon Sep 17 00:00:00 2001 From: Patrick Demichiel Date: Tue, 25 Aug 2026 21:02:09 +0200 Subject: [PATCH 2/2] feat: remove outdated promotional markdown files for Reddit posts --- promo/reddit/01-main-guitarlessons.md | 55 --------------------------- promo/reddit/03-chrome-extensions.md | 35 ----------------- promo/reddit/04-instrument-subs.md | 38 ------------------ 3 files changed, 128 deletions(-) delete mode 100644 promo/reddit/01-main-guitarlessons.md delete mode 100644 promo/reddit/03-chrome-extensions.md delete mode 100644 promo/reddit/04-instrument-subs.md diff --git a/promo/reddit/01-main-guitarlessons.md b/promo/reddit/01-main-guitarlessons.md deleted file mode 100644 index 92ff613..0000000 --- a/promo/reddit/01-main-guitarlessons.md +++ /dev/null @@ -1,55 +0,0 @@ -# Main post — r/guitarlessons (Wave 2, day 3–5) - -> Verify the sidebar rules and flair on posting day; modmail first if unsure. -> Replace `[CHROME_WEB_STORE_LINK]` and attach the screencast as a **native Reddit video**. - -## Title (pick one — short personal ones first) - -Short & personal (preferred): - -- A year of evenings later: my YouTube practice tool, free and open source -- I made the practice tool I didn't want to rent -- Loop four bars until they stick — free, open source, no catch -- My €5/month loop button is now free for everyone -- Free practice tool that also reads the chords off any YouTube video (beta) - -Longer personal (the "I got tired of X, so I built Y" template is itself a Reddit cliché by now): - -- My entire practice routine is looping four bars of a YouTube lesson until they stick. I spent a year building a free tool around exactly that habit. -- A year of evenings fighting WebAssembly later, the practice tool I always wanted for YouTube lessons exists — and it's free and GPL, forever -- I put every paid feature of my old practice extension into a free open-source one. Here's 75 seconds of it slowing down a solo. - -Formula fallbacks: - -- I got tired of paying a subscription for practice markers on YouTube videos, so I built a free open-source alternative -- The practice features I needed in Transpose were Pro-only, so I built a free open-source alternative - -## Body — short version (use this one; the video carries the post) - -For years my practice setup was YouTube + the Transpose extension. The basics are free there, but the actual practice features — markers, saved setups, sequences, vocal reducer, EQ — are €4.99/month. Fair enough, it's their product. But it bugged me enough that I spent the past year building my own, and I made it free and open source (GPL), permanently — nobody can ever put the loop button behind a paywall again. - -The screencast shows most of it: transpose into your key, half speed without the chipmunk effect, markers and loops with a count-in, snippet chains (solo 4× at 50%, then 75%, then full speed, hands-free), vocal reducer, and EQ. And one thing my old tool doesn't do at any price: chord recognition (beta) — an ML model listens to the audio on your machine and draws the chord chart under the timeline. It's not always right yet, but it's often enough to get you playing along. Everything is saved per video. No accounts, no telemetry, no ads — audio never leaves your device. - -Chrome-only for now (Firefox in review). Feedback from people who practice with YouTube lessons is exactly what I'm here for. - -Chrome Web Store: [CHROME_WEB_STORE_LINK] · Source: https://github.com/patrickiel/note-by-note - -## Body — extended version (fallback, if the sub skews text-heavy) - -For a few years my practice setup was YouTube + the Transpose extension: drop a lesson into my key, slow the solo down, loop the hard part. The basics are free there, but everything that makes it a *practice* tool — markers, saved setups, clip sequences, vocal reducer, EQ — sits behind a €4.99/month subscription. Fair enough, it's their product. But setting markers on a YouTube video didn't feel like it should be a monthly bill. - -So I built my own. It took a lot longer than I expected (real-time pitch shifting in a browser is... a rabbit hole), and at some point I decided that if I'm doing this, it should be free for everyone and open source, permanently — it's GPL, so nobody can take it and put the loop button behind a paywall again. - -What it does, in one screencast: [video above] - -- **Pitch & speed, independently** — transpose ±12 semitones (±36 if you're weird like that), speed 25–200%, no chipmunk. The pitch engine is Rubber Band, the same library desktop DAWs use, compiled to WebAssembly. -- **Loops, markers, and "snippets"** — drop markers as you listen, loop between any two, add a count-in. Save a loop as a snippet and chain them: solo 4× at 50%, then 3× at 75%, then full speed, hands-free. -- **Vocal reducer & 10-band EQ** — push the vocal down so the band comes forward, or lean the mix toward the guitar. -- **Chord detection** — an ML model runs over the audio *on your machine* and draws a chord chart under the timeline. -- **It remembers** — markers, loops and settings are saved per video, so reopening a lesson brings your setup back. - -Privacy stuff, because extensions have a reputation: no accounts, no telemetry, no ads, audio never leaves your device. The whole thing including the audio engine is on GitHub. - -It's Chrome-only for now (Firefox is in review). Would genuinely love feedback from people who practice with YouTube lessons — what's missing, what's broken, what's confusing. - -Chrome Web Store: [CHROME_WEB_STORE_LINK] · Source: https://github.com/patrickiel/note-by-note diff --git a/promo/reddit/03-chrome-extensions.md b/promo/reddit/03-chrome-extensions.md deleted file mode 100644 index df642f9..0000000 --- a/promo/reddit/03-chrome-extensions.md +++ /dev/null @@ -1,35 +0,0 @@ -# Technical post — r/chrome_extensions (Wave 1, day 1) - -> Use the "Self Promotion" flair. Replace `[CHROME_WEB_STORE_LINK]`. - -## Title (short personal options first) - -Short & personal (preferred): - -- The MV3 CSP/AudioWorklet dance cost me weeks — notes from shipping -- Real-time pitch shifting in an MV3 side panel, free and GPL - -Longer personal: - -- The CSP/AudioWorklet dance cost me weeks: notes from shipping real-time pitch shifting in an MV3 side panel (free, GPL) -- Things MV3 taught me the hard way while pitch-shifting YouTube in real time: no Blob worklets, one MediaElementSource per element, ever - -Fallback: - -- I built an MV3 side-panel extension that pitch-shifts any page's audio in real time (Rubber Band → WASM AudioWorklet). Free and GPL. - -## Body - -Note by Note is a music-practice extension: open the side panel on a YouTube lesson, transpose it into your key, slow it to half speed without the chipmunk effect, loop sections, chain practice snippets at increasing speeds. Free, open source, no accounts or telemetry. - -Some of the MV3 problems that turned out to be interesting, in case anyone's building in this space: - -- **The engine lives in the content script, not the panel.** The side panel is a thin mirror over a typed `chrome.runtime` Port, so closing the panel doesn't stop a running practice sequence. -- **AudioWorklets vs CSP.** Blob-URL worklets are blocked by extension CSP and by many sites, so the worklet processors ship as static files loaded from `chrome-extension://` URLs, and the WASM binary is fetched on the main thread and handed to the worklet via `processorOptions` — no fetch/eval inside the worklet. -- **Fallback chain.** Direct Web Audio attachment where possible; when the media element is CORS-tainted or DRM'd, it falls back to `tabCapture` processed in an offscreen document; local files get their own extension-page player where nothing is restricted. -- **Permissions.** Nothing at install; one optional-host-permission prompt from a user gesture on first Connect, revocable in settings. - -Store: [CHROME_WEB_STORE_LINK] -Source: https://github.com/patrickiel/note-by-note - -Happy to answer questions about any of it — the CSP/worklet dance especially cost me weeks. diff --git a/promo/reddit/04-instrument-subs.md b/promo/reddit/04-instrument-subs.md deleted file mode 100644 index 81665cb..0000000 --- a/promo/reddit/04-instrument-subs.md +++ /dev/null @@ -1,38 +0,0 @@ -# Short variant — small instrument subs (Wave 3, week 2) - -> Targets: r/Saxophonics, r/trumpet, r/ukulele, r/Bass (verify r/Bass sidebar first). -> Lead with the screencast; keep it short. Swap the angle line per sub. - -## Title (short personal options first) - -Short & personal (preferred): - -- Sax/trumpet: The YouTube video transposes to Bb/Eb now, not my head -- Bass: Loop eight bars at 60%, duck the vocals — free tool I made -- Ukulele: Slow down, change key, loop the hard bar — free & open source - -Longer personal: - -- Sax/trumpet: Transposing in my head while sight-reading along to YouTube finally broke me — now the video shifts to Bb/Eb instead (free tool, open source) -- Bass: I made the tool for how I actually transcribe: loop eight bars at 60%, duck the vocals, EQ the low end forward. Free and open source. -- Ukulele: My practice tool for YouTube play-alongs is done and free for everyone: slow down, change key, loop the hard bar - -Fallbacks: - -- Sax/trumpet: I made a free extension that transposes any YouTube video in real time — play along in concert pitch or let it come to you -- Ukulele: Free open-source extension: slow down, transpose, and loop any YouTube video for practice -- Bass: Free open-source extension: loop any YouTube section, drop the speed, and EQ the mix toward the bass - -## Body - -I built this for my own practice and made it free and open source: a Chrome extension that processes any page's audio in real time — transpose up/down (Eb and Bb players: shift the *video* instead of transposing in your head), speed 25–200% without pitch change, loop any section with markers and a count-in, and chain snippets so a hard passage plays 4× at 50%, then 3× at 75%, then full speed, hands-free. - -Angle lines (pick per sub): -- **Bass:** There's also a vocal reducer and a 10-band EQ with a bass-forward preset, so the line you're transcribing actually sits on top. -- **Singing-adjacent:** The vocal reducer can also be inverted to isolate the vocal. - -No accounts, no ads, no telemetry; audio never leaves your machine. Everything is saved per video, so your loops come back when you reopen a lesson. - -Chrome Web Store: [CHROME_WEB_STORE_LINK] · Source: https://github.com/patrickiel/note-by-note - -Would love to hear what's missing for [instrument] practice specifically.