Jazz ear-training web app with call-and-response practice. The app plays a jazz phrase, you play it back on your instrument via microphone, and it scores your pitch and rhythm accuracy in real time.
Named after Winston "Mankunku" Ngozi's 1968 album Yakhal' Inkomo — one of the greatest South African jazz recordings. Under the hood: real-time pitch detection at 60fps, Dynamic Time Warping for score alignment, a custom AudioWorklet for onset detection, adaptive difficulty that grows with you, and a local-first architecture that keeps your data on-device (page loads need the network).
Practice
- Call-and-response ear training in the day's key and scale, with automatic scoring (
/ear-training) - Lick practice: your own licks through all 12 keys over a generated rhythm section, with per-lick gradual key unlocking and tempo adaptation — sessions pinned to one progression, Daily Practice across every progression you've tagged, and single-lick Deep Practice on a worst-first key rotation; the report's weak-key recommendation starts a focus ramp on that key alone
- Sheet music only where it helps: the newest-unlocked key, while it scores under 75%, gets an engraved lead-sheet row, heralded by a two-bar reading pause and (in continuous mode) held for three passes
- Tricks: parameterized melodic devices — enclosures (major, minor and dominant ladders) and eight triad-pair families — drilled for fluency, scored on conformance to the device rather than note-for-note reproduction, with a mastery ladder of variants
- Tune practice: runtime progression detection inside a song form, mastery-aware lick suggestions at each insertion point, and freestyle solo recognition
- Adaptive difficulty: per-scale and per-key proficiency levels 1-100 over 10 content tiers
- Guided tours of the app, ear training, lick practice, your licks, your tunes and tune practice, replayable from Settings
Your book
- Licks: a step-entry notation editor (with Weimar Jazz Database attribution suggestions while you name a line), record-a-lick — play it over a two-bar count-in and get it transcribed, swung eighths notated straight — and major/minor mode per lick
- Tunes: a songbook with Real Book–style engraving — jazz chord stacking, section markers, repeats, stacked voltas, pickup bars, multi-system reflow — shown in your instrument's written key
- Tune importers: iReal Pro links, Band-in-a-Box
.SGU/.MGU(with a MusicXML fallback), MuseScore.mscz/.mscx, and PDF lead sheets — AI-assisted extraction with a review pass, optionally fused with an OMR transcription from theomr/subsystem - Community sharing for both licks and tunes
- Progress: practice calendar, trend and per-scale charts, period comparisons, per-lick key detail
Listening
- Real-time pitch detection (McLeod method via Pitchy, 60fps)
- Note onset detection via custom AudioWorklet (HFC algorithm)
- Five-tier re-articulation detection down to legato tonguing that leaves no energy evidence — only a waveform-shape break
- Band-limited (250–5000 Hz) evidence so metronome clicks can neither mask nor fake an articulation
- DTW alignment-based scoring (pitch 60% + rhythm 40%, with latency correction)
The band
- A generated rhythm section in four styles — swing, bossa nova, ballad, straight — with planned walking bass in swing, rootless voice-led comping and a composed drum vocabulary, seeded so a replay is identical
- Tenor, alto and soprano sax play bundled multi-layer samples; trumpet and a concert-pitch setting play through a General MIDI SoundFont
Content
- 33 scales and 452 curated jazz licks (ii-V-I, blues, bebop, modal, and more), plus 471 generated by combining scale patterns with rhythm templates
- In-app documentation (
/docs) with an AI docs assistant, and a scales reference (/scales)
Platform
- Concert pitch canonical — transposition to written pitch at display time only
- Fraction-based rhythm representation (no floating-point drift with triplets or dotted notes)
- Local-first: writes to localStorage/IndexedDB, optional Supabase cloud sync
- Installable web app (manifest-based; there is no service worker, so page loads need the network)
- Cross-device progress sync via Supabase auth (optional)
- Dark and light themes
| Technology | Role |
|---|---|
| SvelteKit 2 + Svelte 5 (runes), Vite 8 | Framework, SSR (Node adapter), file-based routing |
| TypeScript 6 (strict mode) | Type safety throughout |
| Tailwind CSS 4 | Utility-first styling, dark/light theming |
| Tone.js 15 + smplr | Audio scheduling, sampled instruments and SoundFont fallback |
| Pitchy | McLeod pitch detection |
| Custom AudioWorklet | Real-time onset detection |
| abcjs | Sheet music notation rendering |
pdf.js (pdfjs-dist) |
PDF text layer and page geometry for tune import |
| Anthropic SDK | Docs assistant and PDF tune extraction (server-side, optional) |
| Supabase | Auth + PostgreSQL + Storage cloud sync (optional) |
| Sentry | Error monitoring and tracing, client and server |
| driver.js | Guided tours |
| Vitest 4 + Playwright | Unit/integration and cross-browser E2E testing |
| Python 3.12 (uv) | The standalone OMR subsystem under omr/ |
Node: package.json requires >=22.12.0 (enforced by engine-strict); .nvmrc pins 26.5.1, the version CI and production run. Production runs under PM2 behind nginx.
Prerequisites: Node.js 22.12+ (nvm use picks up .nvmrc), a modern browser with Web Audio API support
git clone https://github.com/avitus/mankunku.git
cd mankunku
npm install
cp .env.example .env
npm run devThe app opens at http://localhost:5173. The .env step is required even without a backend: the Supabase client reads PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY from $env/static/public, so without them the build fails and every dev-server page answers 500 — the placeholder values in .env.example are enough for local-first practice. Signed out, / is the landing page; the onboarding flow (instrument, microphone) opens the first time you visit a practice route. A microphone is needed for full functionality but not required to explore the codebase.
.env.example also lists two server-only keys: ANTHROPIC_API_KEY (docs assistant and PDF tune import) and SUPABASE_SERVICE_ROLE_KEY (account deletion and the owner-only admin page). See Development Setup for what each one unlocks.
Local Supabase stack (for auth/sync development): Development runs against a local Supabase instance so it never touches production data. Requires Docker.
npm run db:start # boots local Postgres + Auth + Storage via Docker and applies all migrations
npm run dev # dev server now talks to the local stack at http://127.0.0.1:54321Point your local .env PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY (and SUPABASE_SERVICE_ROLE_KEY) at the values from npx supabase status. npx supabase migration up --local applies any migrations added since the stack was started (e.g. after pulling main); npm run db:reset re-applies them from a clean slate; npm run db:stop shuts the stack down. npm run db:types:check verifies the hand-maintained src/lib/supabase/types.ts still matches the schema. The CLI is linked to production, so always pass --local when you mean the local stack. Production is unaffected — its credentials are injected by CI at build time, not read from .env.
src/
lib/
audio/ Web Audio: playback, capture + capture window, pitch/onset detection, segmentation, quantizer, metronome, backing-track engine
scoring/ DTW alignment, pitch/rhythm scoring, fluency, grades
music/ Music theory + engraving policy: scales, keys, transposition, chords, notation, pickups, enharmonic spelling
notation/ DOM-free chart plumbing: hit geometry, abcjs adapter, the one abcjs loader, follow-scroll
phrases/ Lick library loading, combinatorial generator, validation, duplicate detection
tunes/ Tune domain: flatten, book loading, importers, progression detection, lick matching
tricks/ Melodic devices (enclosures, triad pairs): conformance, example generation, mastery ladder
matching/ N-gram melodic matcher (WJazzD attribution + freestyle recognition)
difficulty/ Per-scale/per-key proficiency, 10-tier profiles, lick phases
tonality/ Daily key/scale selection, progressive unlocking, scale compatibility
step-entry/ Manual note-entry helpers (durations, accidentals)
tour/ driver.js guided-tour definitions and config
docs/ In-app docs tree, markdown rendering, assistant context
content/ Shared UI copy (tooltips)
state/ Svelte 5 runes state modules (.svelte.ts) + their plain logic modules
persistence/ localStorage/IndexedDB storage + Supabase sync
components/ Svelte UI components, by domain
ui/ Pure layout and colour helpers used by components
server/ Server-only code: health snapshot, error handler, Anthropic client, account deletion, admin
supabase/ Client factories and hand-maintained DB types
types/ TypeScript interfaces grouped by domain
data/ Curated lick + tune catalogs, pattern tables, progression templates and shapes
util/ Small shared utilities
routes/ SvelteKit pages: ear-training, lick-practice, tricks, licks, tunes, progress, scales,
settings, docs, auth, diagnostics, admin, api/ (chat, tune-parse, lick-match, health, ...)
tests/
unit/ Vitest unit tests, one directory per domain
integration/ Vitest integration tests (pipelines, cloud sync, importers against recorded fixtures)
e2e/ Playwright browser tests (Chromium, Firefox, WebKit)
fixtures/ Recorded takes, lead-sheet corpora, backing-track golden files
supabase/
migrations/ SQL migrations (legacy 00001–00023, then UTC-timestamped)
omr/ Standalone Python OMR subsystem (uv-managed) — see docs/omr/
deploy/ Server-side scripts: app release (deploy/app/release.sh) and nginx deploy
nginx/ Production nginx config
documentation/ Player guides, architecture docs, API reference, contributing guides
docs/ OMR docs, WJazzD attribution
Concert pitch canonical — All MIDI note numbers, scale data, and lick data use concert pitch. Transposition to written pitch (Bb/Eb instruments) happens only at display time in two functions: phraseToAbc() and concertToWritten(). This eliminates an entire class of transposition bugs.
Fraction-based rhythm — Note durations use [numerator, denominator] tuples (e.g., [1, 8] = eighth note, [1, 12] = triplet eighth). No floating-point drift with triplets or dotted rhythms. Conversion to seconds happens only when computing audio timing.
DTW scoring with latency correction — Dynamic Time Warping aligns expected and detected note sequences, handling extra notes, missed notes, and tempo drift. The scorer computes the median timing offset across matched pairs and subtracts it, absorbing constant human and detection latency without affecting relative timing accuracy.
AudioWorklet onset detection — A custom AudioWorklet processor runs on the audio thread for low-latency onset detection using High-Frequency Content weighting with an adaptive threshold. If the worklet can't load, onsets are derived from gaps and pitch changes in the pitch readings instead.
Shared AudioContext — Tone.js and smplr share one AudioContext (audio-context.ts), so Transport scheduling, sample playback, the metronome and the backing band all run on the same timeline.
Local-first with optional cloud — All writes go to localStorage and IndexedDB first for instant feedback and offline resilience. Supabase sync runs in the background when the user signs in. Practice needs no backend at all.
Tunes bridge into the Phrase pipeline — Tunes were added without forking playback, scoring, or notation. flattenTune collapses a tune's sections into one continuous timeline (in notation order or repeat-expanded playback order, with index maps between them), and tuneToPhrase wraps that in a Phrase so the existing engines consume a song form with no new orchestration. See Tune System.
See Architecture Overview for detailed system design documentation, and CLAUDE.md for the densest current reference.
Contributions are welcome — whether that is adding jazz licks, improving the scoring algorithm, fixing bugs, or improving documentation.
Good first contributions:
- Add curated licks to the catalog (guide)
- Add scales to the catalog (guide)
- Improve test coverage in any domain under
tests/unit/ - Report bugs or suggest features via issues
See the full Contributing Guide for setup, code style, commit conventions, the dev → main workflow and CI. In short: Svelte 5 runes only, TypeScript strict mode, Conventional Commits.
npm test # Unit + integration tests (Vitest)
npm run test:watch # Watch mode
npm run test:e2e # Playwright E2E tests (Chromium, Firefox, WebKit)
npm run test:deploy # The server-side release script (bash, stubbed npm/pm2/curl)
npm run check # TypeScript + svelte-check
cd omr && uv run pytest # The Python OMR subsystem (hermetic)Playwright builds and previews the app on port 4173 and locally reuses whatever already holds that port — beside another checkout, set PLAYWRIGHT_PORT to a free port or you will test the other checkout's build.
See the Testing Guide for patterns, audio mocking strategies, and conventions.
Production is a single Node process (SvelteKit's Node adapter) under PM2 on a Digital Ocean droplet, behind nginx. CircleCI deploys every merge to main — test → build → db-migrate → deploy, with the cross-browser E2E job also gating the deploy.
The deploy job rsyncs build/ and the package files into releases/<id>/ on $DEPLOY_HOST, ships ecosystem.config.cjs (checksum-verified) and deploy/app/release.sh, and runs the script on the server. It:
- Installs production dependencies once into
shared/deps, keyed on the lockfile and the Node version, and symlinks them into the release — so most deploys install nothing. - Merges the build's content-hashed
_app/immutablechunks into a shared pool and links the pool into the release, so a tab opened against an older deploy can still load its chunks. - Swaps the
currentsymlink to the new release and restarts the app withpm2 delete+pm2 start ecosystem.config.cjs --env production(runtime secrets load fromshared/runtime.envon the server). - Polls
http://127.0.0.1:3000/api/healthuntil it reports the new release id —pm2 startreturning 0 only means the process spawned. - Prunes old releases (keeps 5). A failed deploy removes its own staged release, unless
currentalready points at it.
Deploys are serialized with flock. The job then fetches the public https://mankunkujazz.com/api/health and requires it to report the commit SHA. /api/health is unauthenticated and returns the commit, the live release id, the Node version and uptime. npm run test:deploy covers release.sh's invariants. To roll back, from the app root on the server: ln -sfn releases/<previous-id> current && pm2 restart mankunku (the rolled-back release runs on the newer shared dependencies).
The production nginx config at nginx/mankunku.conf is the single source of truth. It is deployed to /etc/nginx/sites-available/mankunku on the server by a dedicated CircleCI workflow.
When does it deploy? The nginx-deploy workflow runs only when a push to main changes any of:
nginx/**deploy/nginx/**.circleci/**
App-only changes (under src/, tests/, static/, etc.) do not trigger nginx-deploy. Path filtering is wired via the circleci/path-filtering orb from .circleci/config.yml; the actual jobs live in .circleci/continue-config.yml. Running this workflow requires "Dynamic config using setup workflows" enabled in CircleCI (Project Settings → Advanced).
What happens on the server? The job scp's nginx/mankunku.conf and deploy/nginx/deploy.sh to /tmp, then invokes the script over SSH. The script:
- Backs up the existing
/etc/nginx/sites-available/mankunkuto/etc/nginx/backups/mankunku.<timestamp>.conf. - Installs the new config (mode
0644, ownerroot:root). - Ensures the
sites-enabled/mankunkusymlink exists. - Runs
sudo nginx -t. - On success, warns (without blocking) about any
server_nameits certificate doesn't cover, then reloads nginx withsudo systemctl reload nginx. - On failure, restores the backup and exits non-zero (no reload).
Set in the CircleCI project settings (none of these live in the repo):
| Variable | Purpose |
|---|---|
DEPLOY_HOST |
Server hostname or IP, read by both the app and nginx deploy jobs. A server move is finished only once this is updated — the 2026-09-08 move to a new droplet missed it, and PR #247's deploy landed on the retired box until the public verify step caught it |
DEPLOY_USER |
SSH user (must have passwordless sudo for nginx, systemctl, install, mkdir, cp, ln, rm on the nginx paths) |
DEPLOY_PORT |
(optional, nginx job only) SSH port; defaults to 22 |
SUPABASE_ACCESS_TOKEN |
Lets db-migrate run supabase link + supabase db push --linked — no database password needed |
PRODUCTION_URL |
(optional) Base URL for the public health check; defaults to https://mankunkujazz.com |
The build job — and the e2e job, whose Playwright webServer builds the app itself — also need PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY in the environment, since they are inlined at build time. The SSH key is attached via add_ssh_keys using the fingerprint registered for the deploy jobs.
Manual nginx deploy. Copy nginx/mankunku.conf to the server (e.g. /tmp/mankunku.nginx.staged) and run:
bash deploy/nginx/deploy.sh /tmp/mankunku.nginx.stagedThe script is idempotent and safe to re-run.
License pending. This project does not yet have a license file — one will be added before the first public release.
- Weimar Jazz Database — the lick-naming feature uses a derivative index of the Weimar Jazz Database (CC-BY-NC-SA 4.0). See
docs/wjazzd-attribution.mdfor license details and rebuild instructions. - Fonts (self-hosted in
static/fonts/, SIL Open Font License 1.1, license texts alongside) — Fraunces, the display and chord-letter face; Edwin, MuseScore's engraved text face, which supplies the Δ ♭ ♯ glyphs Fraunces lacks; MuseJazzText, MuseScore's handwritten jazz face by Torsten Hämmerle and Abraham Lee. - Samples (
static/samples/, each set with anATTRIBUTION.md) — saxophones from MTG Solo Sax (Music Technology Group, Universitat Pompeu Fabra; CC-BY 4.0); drums from Virtuosity Drums (Karoryfer Samples / Versilian Studios; CC0); the room impulse response is synthesized for this project (CC0).
Named after Winston "Mankunku" Ngozi (1943-2009), South African jazz tenor saxophonist whose 1968 album Yakhal' Inkomo ("Cry of the Bull") remains a landmark of South African jazz.
Built with SvelteKit, Tone.js, Pitchy, smplr, abcjs, and Supabase.