Chrome extension that visualizes audio from browser tabs using 2D Canvas and WebGL scenes.
pnpm run dev # Development build with WXT hot-reload
pnpm run build # Production build
pnpm run compile # TypeScript type-check only (no emit)
pnpm run lint # Lint and format check (Biome) - runs in CI
pnpm run lint:fix # Auto-fix lint and formatting issues
pnpm run zip # Package for Chrome Web Store
pnpm run check:scenes # Verify all scenes are registered
nix develop --command npx playwright test # E2E tests (requires build first)See TESTING.md for full details. Tests use Playwright to load the extension in Chromium, cycle through all 21 scenes with synthetic audio data, and verify canvas creation, WebGL context health, settings propagation, and console errors. Browsers come from Nix (flake.nix), not npm.
| Entrypoint | File Path | Purpose |
|---|---|---|
| background | entrypoints/background.ts |
Service worker - tab capture, messaging |
| sandbox | entrypoints/sandbox/main.ts |
Sandboxed iframe - runs visualizer scenes |
| offscreen | entrypoints/offscreenWindow/main.ts |
Processes audio via Web Audio API |
| settings | entrypoints/settingsWindow/main.ts |
Settings UI using dat.gui |
Tab Audio → background.ts → offscreenWindow (audio processing)
→ sandbox (scene rendering via postMessage)
Audio data sent as NormalAudioDataDto or ButterChurnAudioDataDto with timeByteArray (0-255 values).
captureSource is a session-only setting with background as the single source of truth — it
is NOT stored in localStorage or chrome.storage. The window always opens with Tab capture
because chrome.action.onClicked resets activeCaptureSource = captureSource.tab. The user can
opt into Microphone via the dropdown; that choice applies for the lifetime of the animation window
and round-trips through the service worker: each dropdown change sends a restart-capture message,
and the responding restart-capture-ack carries the authoritative activeSource for the UI to
mirror (success or failure — the mic-denial path auto-restores Tab in background, which the UI
picks up via the ack). Fresh UI instances receive their initial dropdown value threaded through
existing setup messages: the sandbox first-build defaults to Tab; sandbox rebuilds after external
window close receive source on the close-settings-window message; the external popup reads
?source=<value> from its URL. Microphone source calls navigator.mediaDevices.getUserMedia({ audio: true })
in the offscreen document — no streamId, no picker. Routing app/system output into Chromium's mic
input is an OS-mixer concern (e.g., pavucontrol "Monitor of " on Linux). Both sources
support silent hot-reload recovery (the offscreen document echoes the current source back via
initiate-stream).
Location: src/scene/scene.ts
interface IScene {
streamType: streamType; // 'normal' or 'butterChurn'
build(): void; // Create canvas/WebGL context
updateSettings(settings): void; // Apply settings changes
updateAudioData(data): void; // Receive audio data each frame
render(): void; // Draw frame
clean(): void; // Cleanup resources
}Location: src/scene/sceneManager.ts
build()- Create and append canvas to DOMupdateSettings()- Called when user changes settingsupdateAudioData()+render()- Called each animation frameclean()- Remove canvas, null references
-
Create scene folder:
src/scene/scenes/myScene/ -
Create
setting.ts:
import { ISceneSetting } from '@/src/scene/sceneSetting';
export class MySceneSetting implements ISceneSetting {
public someValue: number = 1.0;
public someColor: string = '#ff0000';
}- Create
myScene.ts:
import { IScene } from '@/src/scene/scene';
import { NormalAudioDataDto, streamType } from '@/src/utils/eventMessage';
import { MySceneSetting } from './setting';
export class MyScene implements IScene {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private audioData: NormalAudioDataDto;
private settings: MySceneSetting = new MySceneSetting();
constructor() {
this.audioData = new NormalAudioDataDto([]);
}
streamType = streamType.normal;
build(): void {
this.canvas = document.createElement('canvas');
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.canvas.style.position = 'fixed';
this.canvas.style.left = '0';
this.canvas.style.top = '0';
this.canvas.style.zIndex = '-1';
document.body.insertBefore(this.canvas, document.body.firstChild);
this.ctx = this.canvas.getContext('2d');
}
updateSettings(settings: MySceneSetting): void {
this.settings = settings;
}
updateAudioData(data: NormalAudioDataDto): void {
this.audioData = data;
}
render(): void {
if (!this.canvas || !this.ctx) return;
if (this.canvas.width !== window.innerWidth ||
this.canvas.height !== window.innerHeight) {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
const audioArray = this.audioData.timeByteArray;
// Draw using audioArray values (0-255)
}
clean(): void {
if (this.canvas) {
if (this.ctx) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
this.canvas.remove();
this.canvas = null;
}
this.ctx = null;
}
}- Create settings UI in
src/userInterface/settings/sceneSettings/mySceneSettings.ts:
import { setSceneSettings } from "../settingsManager";
import { MySceneSetting } from "@/src/scene/scenes/myScene/setting";
export function mySceneSettings(
sceneName: string,
settings: MySceneSetting,
settingsFolder: any,
isExternalUi: boolean
): void {
settingsFolder.add(settings, 'someValue', 0, 2).name('Some Value')
.onChange((value: number) => {
settings.someValue = value;
setSceneSettings(settings, sceneName, isExternalUi);
});
settingsFolder.addColor(settings, 'someColor').name('Color')
.onChange((value: string) => {
settings.someColor = value;
setSceneSettings(settings, sceneName, isExternalUi);
});
}-
Register in three files:
src/scene/sceneNames.ts:export enum sceneNames { // ... existing MyScene = 'MyScene', }
entrypoints/sandbox/main.ts:import { MyScene } from '@/src/scene/scenes/myScene/myScene'; scenesMap.set(sceneNames.MyScene.toString(), new MyScene());
src/userInterface/settings/settingsUserInterface.ts:import { MySceneSetting } from '@/src/scene/scenes/myScene/setting'; import { mySceneSettings } from './sceneSettings/mySceneSettings'; // in buildSettings switch: case sceneNames.MyScene: mySceneSettings(sceneName, settings as MySceneSetting, this.sceneSettingsFolder, this.isExternalUI); break;
Location: src/utils/eventMessage.ts
NormalAudioDataDto:timeByteArray(256 bins, 0-255),timestampButterChurnAudioDataDto: addstimeByteArrayLeft,timeByteArrayRightfor stereo
const value = audioArray[binIndex] || 0; // 0-255
const normalized = value / 255; // 0-1
const scaled = normalized * this.settings.audioSensitivity;Most scenes. Use ctx.fillRect(), ctx.arc(), gradients. Semi-transparent background for trails.
Use src/utils/openGl/openGl.ts. Pass audio as uniforms. Examples: chromaWave, circleBurst.
Special Milkdrop scene at src/scene/scenes/butterchurn/. Requires streamType.butterChurn.
| Package | Purpose |
|---|---|
wxt |
Extension build framework |
dat.gui |
Settings UI controls |
butterchurn |
Milkdrop visualizer |
gl-matrix |
Matrix math for 3D scenes |
// HSL colors
`hsl(${hue}, ${saturation}%, ${lightness}%)`
`hsla(${hue}, ${sat}%, ${light}%, ${alpha})`
// Hex to RGB
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
// Trail effect
ctx.fillStyle = `rgba(0, 0, 0, 0.1)`;
ctx.fillRect(0, 0, width, height);| File | Purpose |
|---|---|
src/scene/scene.ts |
IScene interface |
src/scene/sceneManager.ts |
Scene lifecycle, render loop |
src/scene/sceneNames.ts |
Scene name enum |
src/utils/eventMessage.ts |
Message types, audio DTOs |
src/utils/settings.ts |
Chrome storage wrapper |
src/userInterface/settings/settingsUserInterface.ts |
Settings UI (register scenes here) |
- Scenes run in sandboxed iframe - inspect the iframe in devtools
- Audio latency logged every 60 frames
pnpm run compilefor type checking- Scene rendering:
src/scene/sceneManager.ts - Audio processing:
entrypoints/offscreenWindow/main.ts