Musical Playground is a suite of browser-based music practice tools:
- Vocal Monitor — real-time pitch visualization on a piano roll, with interactive vocal exercises and rolling-key practice.
- Metronome — rotary BPM dial with subdivisions, accent patterns, tap tempo, skip-pattern training, timed practice sessions, and a mic listen-back mode that scores your timing in real time.
Both tools share dark/light theming via shadcn HSL CSS variables and run entirely in the browser — no audio leaves the device.
# Install dependencies
npm install
# Start development server (http://localhost:3000)
npm run dev
# Build for production
npm run build
# Preview production build
npm run previewnpm test # all unit tests
npm run test:watch # watch mode
npm run test:pitch-engine # only pitch-engine specs (fast)
npm run test:browser # Playwright (requires dev server)src/
├── app/ # React app shell
│ ├── App.jsx - BrowserRouter + routes + page-view tracking
│ ├── AppShell.jsx - Top bar with brand + active-tool icon + back link
│ ├── AppFooter.jsx - Help / GitHub / Fullscreen toggle
│ ├── ToolIndex.jsx - "/" — tool catalog cards
│ ├── ToolFallback.jsx, NotFound.jsx
│ ├── useColorScheme.js - Mirrors prefers-color-scheme onto <html>
│ └── icons/GithubIcon.jsx
│
├── components/ui/ # shadcn/ui primitives (Button, Select, Sheet, …)
├── lib/
│ ├── utils.js - cn() helper
│ └── analytics.js - Tiny gtag wrapper
│
├── tools/
│ ├── registry.js - Tool catalog (single source of truth)
│ │
│ ├── vocal-monitor/
│ │ ├── VocalMonitorPage.jsx
│ │ ├── VocalMonitorController.js - DOM-agnostic canvas controller
│ │ ├── VocalMonitorRenderer.js
│ │ ├── VocalMonitorState.js
│ │ ├── PianoRoll.js, ExerciseRenderer.js
│ │ ├── PitchCanvas.jsx
│ │ ├── Sidebar.jsx, Toolbar.jsx
│ │ ├── ExerciseEngine.js
│ │ ├── ScaleTimeline.js, RollingKeyManager.js
│ │ ├── canvasTheme.js - Reads CSS HSL vars per frame
│ │ ├── useSharedSettings.js
│ │ ├── rollingKeyOptions.js
│ │ └── exercises/ - Individual exercise definitions
│ │
│ └── metronome/
│ ├── MetronomePage.jsx
│ ├── MetronomeEngine.js - Web Audio lookahead scheduler
│ ├── MetronomeDial.jsx - Rotary BPM dial + visual beat ring
│ ├── PracticeTracker.jsx - Session state machine
│ ├── ListenBackPanel.jsx - Unified canvas: waveform / grid / hits
│ ├── MicListener.js - Mic stream + AnalyserNode → detector
│ ├── OnsetDetector.js - Peak-based percussion onset detection
│ ├── HitTracker.js - Beat matching + flam detection + stats
│ ├── clickSamples.js - Synthesized click bank
│ ├── sensitivity.js - Slider ↔ detector threshold
│ └── Sidebar.jsx
│
├── core/ # Shared, non-React systems
│ ├── PitchContext.js, ScaleManager.js, DroneManager.js
│ ├── SharedSettings.js - localStorage-backed observable settings
│ └── index.js
│
├── pitch-engine/ # Pitch detection (CREPE / MPM+YIN)
├── audio/TonePlayer.js
├── config/scales.js
├── index.css # Tailwind base + shadcn HSL theme tokens
└── main.jsx # ReactDOM.createRoot mount
src/tools/registry.js is the single source of truth for both the index page and the router. Each entry:
{
id: 'vocal-monitor',
path: '/vocal-monitor',
name: 'Vocal Monitor',
tagline: '...',
icon: AudioLines, // lucide-react component
Component: lazy(() => import('./vocal-monitor/VocalMonitorPage.jsx')),
}Adding a tool: drop a new entry — the router and the index page both pick it up automatically. The active tool's icon also renders in the AppShell breadcrumb.
Stateful canvas/audio logic lives in a plain class (VocalMonitorController.js); React owns the DOM around it.
VocalMonitorPage.jsxinstantiatesSharedSettings,ScaleManager,PitchContext,DroneManager.PitchCanvas.jsxmounts the controller against a<canvas ref>inuseEffect, disposes on unmount.Sidebar.jsxreads/writesSharedSettingsviauseSharedSettingValues. The controller subscribes to the sameSharedSettingsand reacts (drone, scale lock, exercise restart, rolling-key advance).
Settings are the cross-cutting bus — both React and the controller go through SharedSettings. There's no React→controller imperative API for setting changes. The controller never writes to settings inside its own subscriber (avoids re-entrancy); ephemeral overrides like rolling-key root and scale lock live as controller state and are exposed via effective* getters.
Stateful audio + onset processing lives in plain classes; React composes the UI and lifecycle.
MetronomeEngine.js— Web Audio lookahead scheduler (25ms tick, 120ms-ahead window, anchored toAudioContext.currentTime). Holds bar/beat state, accent pattern, skip pattern, subdivision multiplier. EmitsonBeat({ time, beatIndex, barNumber, kind, skipped })aligned to the audible beat. Click playback uses synthesizedAudioBuffersamples (clickSamples.js) — no audio files shipped. IncludesplayIntervalBeep()for practice-session transitions (a distinct two-tone chime).MetronomeDial.jsx— rotary BPM control. Drag (mouse / touch / wheel), click-to-edit number, optionalnavigator.vibrate(3)haptic per BPM step. Renders the segment-per-beat ring with smooth fill + glow on each beat, accent beats in super-accent color.PracticeTracker.jsx— session state machineidle → countdown(5s) → running → complete. RAF-driven progress bar, distinct chime on every interval transition. Auto-starts/stops the engine.MicListener.js— opens the mic withechoCancellation: false(preserves percussive transients), attaches anAnalyserNodeto the engine's sharedAudioContext. Per-RAF reads time-domain samples, feeds the detector, also firesonLevel({ time, peak })for the live waveform.OnsetDetector.js— peak-based percussion onset detector. Sample-accurate timestamp (finds peak index within the buffer), adaptive ambient via EMA, refractory + rise-ratio gates.HitTracker.js— receives expected beats (from engine) and detected hits (from listener). Anchors each hit to a virtual beat projected from the latest known beat by BPM (handles the "hit just before the next click was emitted" case). Computes:gridOffsetMsagainst the closest grid point (quarter / eighth / sixteenth, plus optional 8th-triplet) → drives the colored hit dotclickOffsetMsagainst the closest audible beat → drives the "Click sync" stat- Flam detection — close-paired hits (5–80ms apart, comparable energy, BPM-aware gap cap) collapse into one main hit flagged
hasFlam: true
ListenBackPanel.jsx— single unified canvas with a 6s rolling window: subdivision grid, beat ticks (audible solid / silent dashed / accent magenta), threshold band, mirrored waveform, hit dots colored by grid status, FLAM markers along the bottom edge. Stats footer: On grid / Click sync / Hit rate.
The roundtrip latency between scheduling a click and detecting it via mic is calibrated by listening to the metronome's own clicks for 5s and recording the median delta. Default 12ms; user-editable in the sidebar.
react-router-dom with BrowserRouter:
/→ tool index/vocal-monitor→ Vocal Monitor/metronome→ Metronome*→ NotFound
SPA fallback is handled by CloudFront (403/404 responses redirected to /index.html) for the production AWS hosting; _redirects (Netlify) and 404.html (GitHub Pages) are also bundled for alternate hosts.
import { PitchDetector, DetectorType } from './pitch-engine/index.js';
const detector = new PitchDetector({
detector: DetectorType.CREPE, // or DetectorType.HYBRID
onPitchDetected: (pitch) => console.log(pitch),
onModelLoading: () => console.log('Loading...'),
onModelReady: () => console.log('Ready!'),
});
await detector.start();const engine = new MetronomeEngine({
onBeat: ({ time, beatIndex, barNumber, kind, skipped }) => { /* … */ },
});
engine.setBpm(120);
engine.setTimeSignature(4, 4);
engine.setAccentPattern(['accent', 'regular', 'regular', 'regular']);
engine.setSubdivision(2); // 1=quarters, 2=eighths, 3=triplets, 4=sixteenths, 6=sextuplets
engine.setSkipPattern(4, 0); // play 4 bars, skip 0
engine.setTimbre('woodblock');
await engine.start();
engine.playIntervalBeep(); // distinct two-tone chime
engine.stop();const tracker = new HitTracker({ outputLatency: 0.012 });
tracker.setBpm(120);
tracker.setGridConfig({ includeTriplets: false });
tracker.addExpectedBeat({ time, beatIndex, barNumber, kind, skipped });
tracker.addHit({ time, energy }); // returns { gridOffsetMs, clickOffsetMs, hasFlam, status, … }
tracker.getStats({ now, windowSeconds: 8 });const scaleManager = new ScaleManager('D3', 'major');
scaleManager.setRootNote('E3');
scaleManager.setScaleType('minor');
const scaleInfo = scaleManager.getScaleInfo();const tonePlayer = new TonePlayer();
tonePlayer.playTone(440, 0.5); // 440Hz for 0.5 seconds
tonePlayer.startDrone(146.83); // sustained drone- Create directory:
src/tools/my-tool/ - Build a
MyToolPage.jsx(own services, lay out toolbar/canvas/sidebar) - Add the tool to
src/tools/registry.js:
import { Activity } from 'lucide-react';
const MyToolPage = lazy(() => import('./my-tool/MyToolPage.jsx'));
export const tools = [
// …existing tools
{
id: 'my-tool',
path: '/my-tool',
name: 'My Tool',
tagline: 'What it does in one phrase',
description: 'Longer card description for the index.',
icon: Activity,
Component: MyToolPage,
},
];The router and the tool index page pick it up automatically — no changes to App.jsx or index.html are needed.
Edit src/config/scales.js:
export const SCALES = {
myNewScale: {
name: 'My New Scale',
intervals: [0, 2, 4, 5, 7, 9, 11, 12],
degrees: ['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Ti', 'Do'],
}
};Then add an entry to the SCALE_GROUPS constant in src/tools/vocal-monitor/Sidebar.jsx.
- Runs every 30ms (configurable)
- Buffer size: 8192 samples
- Target latency: <50ms
- Lookahead scheduler: 25ms timer, 120ms ahead window
- Onset detection on AnalyserNode (1024 samples ≈ 23ms), peak-index timestamp ≈ ±0.5ms accuracy
- Listen-back panel canvas runs on requestAnimationFrame; per-frame stats refresh capped at 4Hz
- ResizeObserver handles responsive canvas
- DPR-aware rendering for retina displays
Production hosting is AWS S3 + CloudFront at flappynote.com. Use the bundled deploy.sh script:
./deploy.shIt builds (npm run build), syncs dist/ to the flappynote.com S3 bucket, and invalidates the matching CloudFront distribution. Requires .env with:
VITE_GA_MEASUREMENT_ID=G-…
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
AWS_ACCOUNT_ID=…
You can also npm run build to produce dist/ and host it elsewhere.
- HTTPS (browsers require it for microphone access)
- SPA routing — fall back any non-asset path to
index.html. On CloudFront this is handled via Custom Error Responses (403/404 →/index.html, status 200)._redirects(Netlify) and404.html(GitHub Pages) are also bundled for alternate hosts.
VITE_GA_MEASUREMENT_ID— Google Analytics ID. When unset, the GA snippet doesn't load andanalytics.jsis a silent no-op.