Skip to content

Latest commit

 

History

180 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

callnote.app

Fretboard fluency, one beat at a time.

callnote.app is a small React and Vite practice app for guitar fretboard memorization. A metronome clicks, a note name is called on the beat, and you find it on the neck before the next one lands.

callnote.app calling notes on metronome clicks

Pick your notes and a tempo, press start, and it calls a note on the metronome click.

Features

  • Shuffled-bag note calling: every note in the pool appears exactly once per cycle, no repeats — with a NEXT preview and a "note N of M" cycle position
  • Note pool control: 12 tappable pitch-class chips plus presets (all 12, naturals, accidentals, the eight major keys — C, G, D, A, E, F, B♭ and E♭ — the A/E/D minor pentatonics and A minor blues) — plus Custom, which is what the selector reads back once the chips no longer match a preset
  • Enharmonic spelling as flats, sharps, or mixed — the spoken name always matches the displayed one
  • Tempo (30–240 BPM, live-adjustable, tap tempo) split from the note-change rate (every 1/2/4/8/12 beats; the beat a new note lands on gets the accented click)
  • Drift-free Web Audio scheduling: clicks and spoken samples are scheduled at explicit AudioContext times by a look-ahead scheduler
  • "On the neck" fretboard map showing every position of the called note (frets 0–12, standard tuning), hideable from "How it runs"
  • Speed ramp, in the Tempo card: the tempo climbs 2 BPM every completed round until it reaches a target you choose, then holds there — so a session ends on a tempo you reached, not the first one you missed. Routine blocks own their own ramp and ceiling
  • Routines: an ordered list of blocks, where a block sets tempo, note pool, note-change rate and spelling for you. One untimed block is a saved setup that runs until you stop; add timed blocks and it becomes a workout that advances itself, with a proportional timeline and a Skip block button. A shelf of routines is seeded on first load, and touching a control mid-routine is reported as "adjusted, next block resets it" rather than silently overridden
  • Practice log: 14 days of daily bars, current and best streak (a day counts at one minute of practice), and rolling 7-day minutes/notes totals. A History button on the card opens the whole log — a month-by-month heatmap with each day shaded by minutes and tappable to read its minutes and notes out under the calendar (today's until you pick one), totals across everything stored, and JSON backup export/import, where an import merges with what is already there (keeping the longer of any two days) rather than replacing it
  • Listen for my playing (off by default): with the setting on, the app opens the microphone alongside playback and shows the note it hears under the called one, with a tick when it is the note asked for and a cross when it isn't. How in tune the string was is left to a tuner. A note that matches the call is named the way the call named it — E♭ stays E♭ rather than turning into D♯ — and the reading stays up until you play something else or the next note is called, rather than blinking out with the string. The count-in between rounds clears it: no note on screen, so nothing to be right or wrong about. The detector is a hand-rolled Web Audio autocorrelation on the same AudioContext playback uses, and what the app plays through the speakers is suppressed by the cue intervals the engine records, so the readout reports you rather than itself. The mic is released the moment you pause, stop or leave, and a refusal or a browser without one says so and changes nothing else
  • Scoring, with the mic on: every note you actually play banks points, and four bonuses make them climb faster — a streak bonus from the third right note in a row up to a cap, a bonus for finding the called note in two octaves before the next one is called, a small one for striking the string in time with a click, and a flat 50/100/150 points for reaching 10, 20 and 30 minutes of session time. The practice milestones belong to no note, so unlike the other three they are never scaled by the difficulty multiplier below. Two octaves means two pitches and not two places on the neck: the mic hears pitch and nothing else, so finding the note twice in unison earns nothing. The in-time bonus is measured against the clicks that actually sounded, so the speed ramp cannot throw it off — and at one beat per note every beat starts a note, leaving no click under the note to play along with, so it is mostly there to be earned from two beats per note up. The mic is deaf while the click itself sounds, so a string struck on one is not heard until it stops ringing and lateness is allowed for that; above roughly 100 BPM the click covers too much of the beat to tell being in time from being late, and the bonus stops paying rather than paying everyone. Every note — and every bonus earned on it — is priced by the settings in force when it was called: mixed sharps and flats, the fretboard map put away, fewer beats per note and a faster tempo all pay more, while the two most generous note spans pay less than the flat rate. The multiplier shows on the score line whenever it is anything other than ×1, so a discount is as plain as a premium and the line only stays quiet when a note is worth exactly what a note is worth. The price is frozen on the note as it is called, so nudging a setting mid-note moves the next one instead, and a bonus found late is paid at what its own note was worth. The tempo part is deliberately sublinear and capped: you already play more notes per minute at a faster tempo, so paying linearly on top of that would make speed worth roughly its square. Doubling the tempo is worth about 2.4× the points per minute rather than 4×, which is to say a couple of extra minutes of slow, accurate practice catches a faster player up. Points sit beside the running hits/scored accuracy rather than replacing it, they last as long as the session does, and nothing about them is stored or shared
  • "How it runs": keep going (loop past the end of a cycle), a four-beat count-in, listening for your playing, and the fretboard map toggle. The spoken note name is always on
  • Session card with practice goal (5/10/20 min), progress bar, and notes/cycles stats
  • Shared challenges: open the app at /?challenge=<name>, pick a nickname, and a top-ten scoreboard appears under the note. Your session points go up whenever you pause or stop, and the board keeps your best — so re-submitting a score already on it changes nothing. It refreshes itself every 20 seconds while the page is on screen, so other people's rounds appear without a reload, and goes quiet while the tab is hidden. The nickname is remembered in localStorage, and the microphone is asked for on arrival, since the points come from what it hears. Without ?challenge= in the URL none of this exists: no board, no prompt, no request, and no microphone. The prompt can be dismissed, which leaves the board readable without putting you on it
  • Installable PWA: a service worker precaches the app shell and every note clip, so it launches and runs with no network. Chromium gets an Install button in the header, iOS a one-time Add-to-Home-Screen hint, and a cached new build offers a reload chip instead of reloading mid-session
  • Stage layout: installed on a touch device, the app drops to the note, the beat dots and a transport, and moves the setup cards into a full-screen practice sheet — landscape puts the neck alongside the note
  • The screen is kept awake while playing, and playback stops itself after a minute in the background rather than clicking on in a pocket
  • Light/dark theme, and every setting persisted to localStorage
  • Keyboard shortcuts: Space play/pause, ←/→ (or ↑/↓) tempo, T tap tempo, R reset

Run locally

npm install
npm run dev

Node 22 or newer. Audio playback begins only after a user interaction such as pressing the start button.

Build

npm run build

Deploy

The repo ships a multi-stage Dockerfile: node:24-alpine builds the Vite app, and nginx:1.30-alpine serves the static dist/ output on port 80.

docker build -t callnote .
docker run --rm -p 8080:80 callnote                            # http://localhost:8080

docker run --rm -p 8080:80 wolasss/random-scale-trainer:latest # the published image

# ...and with the shared-challenge board kept across restarts
docker run --rm -p 8080:80 -v callnote-data:/var/lib/callnote callnote

The scoreboard service

Shared challenges are the one thing here that is not a static file, so the image also carries a small stdlib-only Node service (src/server/). nginx starts it from /docker-entrypoint.d/50-scoreboard.sh and proxies /api/ to it on loopback, so the run contract is unchanged — still docker run -p 8080:80, still one container. In dev and vite preview the same request handler is mounted as a Vite middleware instead (vite.config.ts), which is what lets the e2e suite exercise a challenge without a second process.

The API is two calls, both under /api/scoreboard/<challenge>:

curl localhost:8080/api/scoreboard/demo
# {"challenge":"demo","scores":[{"nickname":"ada","points":300}]}

curl -X POST -H 'Content-Type: application/json' \
     -d '{"nickname":"ada","points":300}' localhost:8080/api/scoreboard/demo

A POST upserts the nickname's best, so a resubmitted tally is a no-op. SCOREBOARD_DATA (default /var/lib/callnote/scoreboard.json) is where the board is kept — mount it with -v or a restart starts everyone from zero. The service listens on loopback port 8787, which is fixed rather than configurable: nginx proxies to it by number, and only a matching pair works.

The write endpoint is unauthenticated by design: a nickname is not an identity, and anyone who knows a challenge name can post to it. The only defences are the caps in src/server/scoreboard.js — 4 KB bodies, 1,000,000 points, 500 nicknames per challenge, 200 challenges — which bound the damage rather than prevent it. Don't put anything you care about on a public board.

Serving the microphone at all needs Permissions-Policy: microphone=(self), which nginx.conf now sends on every response (it was microphone=(), which forbade getUserMedia outright). It is (self) and never * — this origin only, nothing it embeds.

Every release pushes wolasss/random-scale-trainer to Docker Hub for linux/amd64 and linux/arm64, tagged with the semantic-release version and latest (.github/workflows/release.yml).

nginx.conf sets the caching deliberately. Unknown paths fall back to index.html, so client-side routes resolve. /sw.js is sent no-cache, no-store, must-revalidate, and index.html and manifest.webmanifest are sent no-cache: a cached service worker is a build the browser can never move off, and index.html is what names the hashed bundles, so a stale shell pins everyone to the old ones. Everything else matching the static-file extension list (js, css, images, fonts, mp3) gets expires 7d and Cache-Control: public — that rule is matched on extension, not on the presence of a content hash, so only the three exact-match locations above it escape it.

Tests

npm test             # Vitest unit + integration suite
npm run test:e2e:ci  # Selenium e2e against a production preview (see e2e/README.md)
npm run check        # lint + e2e typecheck + Vitest + build — what CI runs

Notes

  • Spoken note names are pre-rendered MP3s (public/audio/notes/, generated by scripts/generate-note-audio.sh) covering both flat and sharp spellings. A clip that fails to download falls back to SpeechSynthesis for that note.
  • The click is the authoritative beat: everything is scheduled ~250ms ahead at exact AudioContext times, and visuals sync to the audio clock via a rAF queue.
  • The service worker is built from src/sw/service-worker.js by a Vite plugin (vite.config.ts), which fills in the content-hashed precache list and a cache version derived from it, and is registered in production builds only.

Brand

brand/callnote-brand-guide.md is the source of truth for the mark. In the app the lockup is live text (src/components/BrandLockup.tsx + the BRAND block in src/index.css) whose --brand-* tokens alias the active skin's own palette — so the call dot is always the same colour as the Resume button, in every skin, without a per-skin logo.

The exported SVGs in brand/ and the PWA's PNG icons are generated, not drawn:

python3 scripts/generate-brand-assets.py   # brand/*.svg + public/favicon.svg
node scripts/rasterize-icons.mjs           # public/icon-*.png (needs playwright)

Adding a skin means adding a row to THEMES in the first script; there is no second drawing to keep in sync.

License

MIT — see LICENSE. Free for anyone to use, modify and redistribute, personally or commercially, as long as the copyright and permission notice travel with the code.

MIT ships the app as-is: no warranty and no support obligation. It is a copyright licence only — the callnote.app name and the marks in brand/ stay trademarks of the copyright holder, so a fork can copy and change those files but shouldn't pass itself off as Callnote. If your team needs more than that — a support commitment, a warranty, indemnity, or different terms — open an issue at https://github.com/wolasss/random-scale-trainer/issues and we can talk about a commercial agreement.

About

Fretboard fluency, one beat at a time.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages