Consumer Subscription App, Web + iOS + Android

ChordBook Pro

A chord book for guitarists and pianists on the web, iPhone and Android: chords anchored to the syllable they change on, key-aware transposition and capo, setlists for the stage, a tuner built on the McLeod Pitch Method, a theory course, and a free plan whose limits hold.

ChordBook Pro: Chord Sheets and Setlists for Guitar and Piano

Executive summary

Challenge
Chord sheets found online put chords above the wrong syllable, can't move to a singer's key and fall apart on stage. Musicians needed one library that reads right at any text size, transposes the way a musician would, works offline at the gig and is free to start, with paid limits that can't be worked around.
Solution
One JavaScript monorepo: a Vite web app holding the marketing site, the player app and the admin; an Expo app for iOS and Android; an Express and MongoDB API; and five shared packages, led by a chord engine that parses, transposes, imports ChordPro and detects pitch identically on every platform. Firebase handles sign-in, RevenueCat handles purchases, and the API decides what each account may do.
Result
Live at chordbookpro.com, on the App Store and on Google Play.
Our role
Product, design system, web app, iOS and Android app, API, audio engineering, subscriptions and store releases. Designed, built and operated by RiseGravity.
Scope
Web appiOS + Android appREST APIChord & audio engineSubscriptions & paywallsAdmin & moderationMarketing site
Key complexity
Chords anchored to grapheme positions, so they survive edits, line wrapping and any font sizeMcLeod Pitch Method tuner with a native Android microphone moduleRace-safe free-plan quotas and one server-side entitlement across three storesOffline change queue on mobile that can't be used to skip a quota

Technical deep dive

The wide screenshots are the ChordBook Pro web app, captured for this case study on a local copy loaded with the app's own demo catalogue. The phone screens are the iPhone app as published on the App Store. Every lyric on this page is original demonstration content written for the app.

Overview

ChordBook Pro is a chord book for guitarists and pianists. Lyrics, chords, playlists and setlists live in one library that reads the same on a laptop, an iPhone and an Android phone: in the practice room, on stage and offline. It is free to start, with one playlist, 15 songs in it, 5 songs of your own, 3 lessons a day and no ads. Premium removes the limits and adds offline downloads, every tuner tuning, ChordPro import and export, revision history and the full theory course.

We built the whole product across 3 platforms: the web app (marketing site, player app and admin), the iOS and Android app, the API behind them, and a chord engine that all of them share. It is about 63,000 lines of JavaScript in one monorepo. The shared packages have no build step: the same source files run in Vite on the web, in Node on the server and in Metro on the phone.

The iPhone reader: Harbour and Home in A minor, chords above every word Transpose and capo controls above the sheet on iPhone The iPhone tuner reading G3, 3 cents flat, with every string of standard tuning below

Chords that stay on their syllable

Most chord sheets are plain text: the chord sits above the lyric because someone counted spaces. Change the font, narrow the screen or edit a word and the chord drifts onto the wrong syllable. ChordBook stores each chord as a position in the lyric, counted in graphemes (what a reader sees as one character, so an emoji or an accented letter counts once). The renderer splits each line at those positions and stacks every chord on top of its own piece of text. When a long line wraps on a phone, the chord wraps with its word, at any text size.

Editing is where positions get hard. When someone edits a lyric, every chord after the edit has to move by exactly the right amount, and a chord inside a deleted word must not disappear:

export function adjustMarkersForEdit(markers = [], edit, newText = '') {
  const start = Math.max(0, Math.round(Number(edit?.start) || 0));
  // `Number(undefined)` is NaN rather than nullish, so fall back explicitly.
  const rawEnd = Number(edit?.end);
  const end = Math.max(start, Number.isFinite(rawEnd) ? Math.round(rawEnd) : start);
  const insertedLength = Math.max(0, Math.round(Number(edit?.insertedLength) || 0));
  const removedLength = end - start;
  const delta = insertedLength - removedLength;

  const adjusted = markers.map((marker) => {
    const index = Number(marker.index) || 0;

    // Strictly before the edit: unaffected.
    if (index < start) return { ...marker };

    // At or after the end of the removed range: shifted by the net change.
    //
    // For a pure insertion `start === end`, so a marker sitting exactly at the
    // caret lands here and moves forward. That is what keeps a chord attached
    // to the word it was written above when the user types ahead of it.
    if (index >= end) return { ...marker, index: index + delta };

    // Inside the deleted range: collapse to the start of the edit rather than
    // dropping the chord. The user keeps their chord and can move it.
    return { ...marker, index: start, reanchored: true };
  });

  return normalizeMarkers(adjusted, newText);
}

Tap any chord and a sheet opens with its guitar fingering (every curated voicing, plus a left-handed view) and its piano notes in every inversion. The library holds 264 chord definitions and 519 guitar voicings, and a verification script checks every one against the chord's intervals. Piano voicings aren't stored at all: the engine computes them from the chord.

Tap a chord on the web: E7 with its open guitar shape and two barre alternatives The same E7 on piano: root highlighted, chord tones marked, every inversion one tap away
Tap a chord on iPhone: A minor with its open shape, a barre and an E-shape barre C major on the piano keyboard in root position, with every inversion

Transpose and capo, spelled like a musician

Transposition is a one-line function until you have to name the result. Move a song in A minor up three semitones and a musician expects Cm, Bb, Eb and Ab, not Cm, A#, D# and G#. The engine picks sharps or flats from the key the song is moving to:

/** Transposes a key name, preserving its major/minor suffix. */
export function transposeKey(key, semitones, preference = 'auto') {
  if (!key) return key;

  const normalized = normalizeAccidentals(String(key).trim());
  const match = /^([A-G](?:#|b|##|bb)?)(.*)$/i.exec(normalized);
  if (!match) return key;

  const pitchClass = noteToPitchClass(match[1]);
  if (pitchClass === null) return key;

  const targetPitchClass = mod12(pitchClass + semitones);

  // Resolve spelling against the *target* key so that, for example,
  // transposing C up one semitone in "auto" mode yields Db rather than C#.
  const suffix = match[2] ?? '';
  const isMinor = /^(m|min)/i.test(suffix);
  const sharpCandidate = SHARP_NAMES[targetPitchClass] + (isMinor ? 'm' : '');
  const spelling =
    preference === 'auto'
      ? resolveSpelling({ key: sharpCandidate, preference: 'auto' }) === 'flat'
        ? 'flat'
        : preferFlatForTarget(targetPitchClass, isMinor)
      : preference;

  return pitchClassToNote(targetPitchClass, spelling) + suffix;
}

The capo works the other way round. Put a capo on the second fret and the shapes you play move down two semitones, so the song still sounds in the key you chose. The reader says so in its header: "Key of Bm (written Am), Capo 2". The engine can also rank capo positions 0 to 7 by how many open-chord shapes each one leaves you, which is how a beginner gets a hard song into chords they already know. The parser behind all of it matches chord names longest suffix first, splits a slash bass only when the part after the slash is a note (so 6/9 survives), and peels alterations off the end so Cmaj7#11 parses.

Harbour and Home transposed up three semitones: Cm, Bb, G7, Eb and Ab, spelled with flats Transposed up two with a capo on 2: the header reads Key of Bm, written Am, and the shapes stay in A minor

A tuner built on the McLeod Pitch Method

A tuner is a pitch detector with a needle, and the hard case is the low strings. On a plucked low E the fundamental carries little of the energy; the second and third partials carry most of it. Naive methods fail on exactly that signal: picking the loudest frequency jumps an octave up, plain autocorrelation jumps an octave down, and YIN jumps up when the fundamental is weak. We used the McLeod Pitch Method, which normalises each lag by the energy that actually overlaps at that lag, and computed its normalised square difference function through an FFT, so each frame costs O(N log N) instead of O(N²):

// Linear autocorrelation via the Wiener-Khinchin route.
re.fill(0);
im.fill(0);
for (let i = 0; i < frameSamples; i += 1) re[i] = windowed[i];

fft.forward(re, im);
for (let i = 0; i < fftSize; i += 1) {
  re[i] = re[i] * re[i] + im[i] * im[i];
  im[i] = 0;
}
fft.inverse(re, im);

// n'(τ) = 2r'(τ) / m'(τ), with
//   m'(τ) = Σ x[j]² over j<N-τ  +  Σ x[j]² over j≥τ
//         = prefix[N-τ] + (prefix[N] - prefix[τ])
const total = prefix[frameSamples];
for (let lag = 0; lag <= maxLag + 1; lag += 1) {
  const divisor = prefix[frameSamples - lag] + total - prefix[lag];
  nsdf[lag] = divisor > 0 ? (2 * re[lag]) / divisor : 0;
}

const lag = pickKeyMaximum(nsdf, minLag, maxLag);
if (lag === -1) return reject('unclear', rms);

let refined = interpolatePeak(nsdf, lag);

Around the detector:

  • A reading every 16 ms. Audio is analysed at 16 kHz in 2,048-sample frames with a 256-sample hop. Every buffer is allocated once, so the needle never stutters on garbage collection.
  • A needle that doesn't twitch. A 7-frame median throws out stray octave errors, a two-speed moving average snaps to a new note and then settles, and "in tune" only lights after the note has held within 5 cents for 400 ms. It lets go at 9 cents, so a needle resting at 5.5 doesn't blink.
  • The right string, without flicker. Listening to every string at once, the tuner only switches to another string when that string is closer by 0.6 of a semitone for 3 frames in a row.
  • One engine, every platform. The same detector code runs in the browser, on the Web Audio API with echo cancellation, noise suppression and automatic gain all switched off, and on the phone.
  • A native microphone on Android. On several Samsung and Xiaomi phones the default recording source has a high-pass filter around 80 to 100 Hz, and a low E is 82.4 Hz. A small Kotlin module asks Android for the unprocessed source (falling back to the voice-recognition source), switches off gain control, noise suppression and echo cancellation, and reads on an urgent-audio thread.

One Android bug is worth telling. Tapping "Turn on the microphone" sometimes did nothing at all. The permission dialog briefly sends the app to the background, the app's own background handler stopped the microphone, and so the app cancelled the very start it had just asked for. A flag that marks the app as waiting for permission fixed it, and the fix was checked on a physical phone.

The tuner covers 33 tunings across 6, 7 and 8 strings, custom tunings, an adjustable reference pitch and a "Play notes" mode for tuning by ear. Free accounts get standard tuning; the rest is Premium.

The web tuner hearing a low E string 22 cents flat: 81.37 Hz, with the instruction to tighten

Setlists and performance mode

Playlists and setlists hold songs in order, with a note on each one ("Bass starts alone for 4 bars") and a transpose for that set only, so the singer can have a song a tone lower without changing it for anyone else. Reordering is drag and drop. The API accepts a new order only when it lists every item in the playlist, then writes every position in one bulkWrite, so a reorder is never half applied.

Performance mode is for the stage. It keeps the screen awake, auto-scrolls at a speed you adjust in quarter steps, and moves to the previous or next song in the set without leaving the reader.

A gig setlist on the web: six songs in order, each with its key, its own transpose and a note for the band

The iPhone library: playlists and setlists, with the Free plan's active playlist marked Search across the catalogue and your own songs, with each song's chords listed

Three ways to write a song

The editor has three modes over one document, each with a live preview. The structured editor places chords by clicking between letters. The ChordPro view shows the same song as ChordPro source and writes it back deterministically; unknown directives in an imported file become warnings instead of failures. Import takes ChordPro or the plain-text format most musicians already have: a line of chords above a line of lyrics. The importer decides which lines are chord lines by the share of words that parse as chord names (and flags a lone "A", which might be a word), expands tabs, and maps each chord's column onto the lyric below. A sheet pasted in a proportional font comes out aligned.

The structured editor: sections, lines and chords on the left, the live preview on the right The same song as ChordPro source, with the preview beside it

Every save carries the revision number the editor started from. The API updates the song only if that revision still matches, inside the same database filter as the write, so two devices can't silently save over each other. The second device gets a dialog instead: keep editing your version, or load the newer one. Undo and redo keep 50 steps.

Plain-text import: chords over lyrics pasted in a proportional font, detected and previewed with every chord on its word The edit-conflict dialog: your revision 1, server revision 2, keep editing yours or load the newer version

A free plan that can't be gamed

The free plan is generous on purpose, so its limits have to hold. Two requests racing for the last free playlist must not both win, which means the check and the count can't be two separate steps. The claim is one conditional update in MongoDB: increment the counter only if it is still under the limit. It needs no transaction and no replica set, and a failed follow-up write hands the slot back:

export async function assertCanCreatePlaylist(user, entitlements) {
  const limit = getPlanLimits(entitlements.plan).playlists;

  // Unlimited (Premium): still count usage for the dashboard, but never gate.
  if (limit === null) {
    await User.updateOne({ _id: user._id }, { $inc: { 'usage.playlists': 1 } });
    return makeReservation(user._id, 'usage.playlists');
  }

  // When entitlement could not be verified we apply Free limits rather than
  // guessing upward, but only to *mutations*; reads stay unaffected.
  const claimed = await User.findOneAndUpdate(
    { _id: user._id, 'usage.playlists': { $lt: limit } },
    { $inc: { 'usage.playlists': 1 } },
    { new: true },
  );

  if (!claimed) {
    const current = await countPlaylists(user._id);
    throw new QuotaError(
      ERROR_CODE.PLAYLIST_LIMIT_REACHED,
      limit === 1
        ? 'The Free plan includes one playlist. Upgrade for unlimited playlists.'
        : `The Free plan supports up to ${limit} playlists.`,
      {
        plan: entitlements.plan,
        limit,
        current,
        upgradeFeature: FEATURE.UNLIMITED_PLAYLISTS,
        entitlementVerified: entitlements.verified,
      },
    );
  }

  return makeReservation(user._id, 'usage.playlists');
}

The rest of the billing design follows the same rule: the server decides.

  • Premium status comes from the API, never the device. The API keeps an entitlement record for every account, refreshes it from RevenueCat when it is more than 6 hours old, and keeps serving the last known state if RevenueCat is down. After an expiry, offline downloads keep opening for a 48-hour grace period that covers store reporting delays.
  • Webhooks that can't be forged or replayed. The RevenueCat webhook compares its shared secret in constant time, answers at once and processes afterwards, ignores repeat deliveries through a unique index on the event id, and re-reads the entitlement from RevenueCat instead of trusting the event's name.
  • One subscription, every platform. iPhone purchases go through StoreKit and Android purchases through Google Play Billing, and the web app is wired for RevenueCat Web Billing on Stripe. All of them unlock one entitlement, so Premium bought on a phone applies on the web as soon as the purchase syncs.
  • Limits open a paywall, not an error. A quota error carries a code the shared API client recognises, so both apps open the paywall for that exact limit instead of showing an error message.
  • A downgrade deletes nothing. Playlists above the free limit become read-only, and the user picks which one stays editable.

The paywall a Free account sees at its playlist limit: what the Free plan covers and what Premium adds

Offline on stage

On the phone, the library is cached in SQLite and changes made without signal wait in a queue. When the connection returns, the queue replays each change through the normal API routes, so every quota and entitlement check still applies: going offline is not a way around the free plan. Errors that a retry can't fix (a quota, a permission, an edit conflict, any other 4xx) leave the queue and are shown to the user. Network failures retry with jittered backoff, up to 5 attempts.

export async function processQueue({ onRejected } = {}) {
  const due = await listDue();

  let succeeded = 0;
  let failed = 0;
  const rejected = [];

  for (const row of due) {
    const payload = JSON.parse(row.payload);

    try {
      await dispatch(row.kind, payload);
      await remove(row.id);
      succeeded += 1;
    } catch (error) {
      // A 4xx that is not a rate limit will fail identically forever; retrying
      // wastes battery and hides the real problem from the user.
      const permanent =
        error?.isPaywall ||
        error?.isConflict ||
        (error?.status >= 400 && error?.status < 500 && error?.status !== 429);

      if (permanent) {
        await remove(row.id);
        const entry = { kind: row.kind, payload, error };
        rejected.push(entry);
        onRejected?.(entry);
      } else {
        await recordFailure(row.id, row.attempts, error?.message);
        failed += 1;
      }
    }
  }

  return { processed: due.length, succeeded, rejected, failed };
}

Learn, and the admin behind it

Learn is a theory course in 13 units and 80 lessons, from note names and intervals to modes, CAGED and advanced harmony, with XP, levels, streaks, badges, a daily challenge and ear training. The lessons aren't stored question lists: each one stores an exercise generator, and the chord engine produces the questions when the lesson runs. The first three units are free.

Learn on the web: level 4, a 6-day streak, today's challenge done and the path of units The Learn curriculum in the admin: 13 units, each free or Premium, published or not
Learn on iPhone: level, streak, today's challenge and the next lesson

The admin covers users, subscriptions, songs, moderation, reports, the chord library, the Learn curriculum and an audit log. Songs that users submit for publishing wait in a moderation queue, and rejecting one returns it to private, so the author keeps their work. Copyright reports are triaged first.

The moderation queue: an original song awaiting review, with approve, reject and remove The chord library in the admin: 264 guitar definitions with their voicings

One design system, two themes

ChordBook has its own look, "Ebony and Brass": ivory paper and brass chords in light mode, ebony and gold in dark. A design-tokens package generates the CSS for the web and the values for the phone, so both apps change together. Young Serif sets the headings, IBM Plex Mono the chords and lyrics, and Instrument Sans the interface. Users choose light, dark or the system setting.

The reader in dark mode: Long Way Around in G with a capo on 3

Architecture and stack

 Web app (Vite, React 19)              iOS / Android app (Expo, React Native)
 marketing · player app · admin        SQLite library + queue of pending changes
             │                                        │
             └──────────────── HTTPS ─────────────────┘
                                 ▼
                 API (Express 5, MongoDB, Zod)
                 entitlements · quotas · webhooks · moderation
                  │                 │                     │
           Firebase Auth       RevenueCat              MongoDB
           (identity only)     (App Store, Google      (songs, playlists,
                                Play, Web Billing)      usage, Learn)

 Shared packages: chord-engine · shared · api-client · validation · design-tokens
  • Web: Vite, React 19, React Router 7, TanStack Query, Zustand, react-hook-form with Zod, dnd-kit for drag and drop, and CSS Modules over the generated tokens. The marketing site, the player app and the admin are separately loaded route trees, so a visitor to the home page never downloads the editor.
  • Mobile: Expo SDK 57 and React Native 0.86 with the New Architecture, Expo Router, React Native Firebase, RevenueCat, expo-sqlite, expo-audio and the custom Kotlin microphone module.
  • API: Express 5, Mongoose, firebase-admin, Zod, helmet, rate limits and structured logging; 106 routes over 17 models, under PM2 behind nginx.
  • Shared packages: the chord engine, plan limits and app config, the API client with its error types, the Zod schemas both apps validate against, and the design tokens. They are plain ES modules that Vite, Node and Metro import directly.
  • Who owns what: Firebase handles sign-in only (email, Google and Apple), MongoDB holds all app data, RevenueCat owns purchases, and the API decides what each account may do. The Firebase user id is the RevenueCat user id on every platform.

Shipping to the stores

  • Microphone for the tuner only. The permission text says the audio is analysed on the device and is never recorded, stored or sent, and background recording is off.
  • No advertising or analytics SDK in the app.
  • Account deletion in the app and on the web.
  • A minimum-version gate. The API reads the lowest supported app version from Firebase Remote Config. If the policy can't be read for any reason, nobody is blocked: the gate fails open.
  • iPhone, iPad and Android, with landscape allowed because music is often read on stage in landscape.
  • One release script for the mobile app, a script that generates every icon, splash and store graphic, and a check that fails if the app's name or identifiers drift between the mobile config and the shared config.

How we verify it without a test framework

ChordBook has no Jest or Vitest, on purpose: a check fails the build if one is added. It is verified by scripts that run against the real code, chained into one npm run verify:

  • The tuner check (1,197 lines, 136 expect calls) runs the detector on synthesized guitar signals, including a string with a missing fundamental, the case that breaks YIN and spectral peak picking. It also checks the gating and smoothing, and asserts that the detector allocates nothing per frame.
  • The seed check replays every guitar voicing against the interval tables, so a shape that doesn't spell its chord fails the build.
  • Brand parity, the app-version policy and a no-TypeScript rule, then ESLint and the production web build.

The marketing site

The web app's public pages are the marketing site: home, features, guitar and piano pages, pricing, FAQ, download and the legal pages. They live in the same app as the player but load separately, and the home page sets an original demonstration song as a live chord sheet beside the headline, not a picture of one.

chordbookpro.com: "Your songs, your chords, your set." with a live chord sheet beside the headline

What made it work

Two decisions carried this product. First, the music lives in one place: a chord engine written once in plain JavaScript and imported by the web app, the phone app and the server alike. Parsing, transposition, ChordPro, voicings and pitch detection behave identically everywhere because they are the same code. Second, the server is the only authority on what an account may do. Quotas are claimed atomically, entitlements are decided server-side from RevenueCat, the offline queue replays through the same routes as everything else, and a lost connection or a second device can never turn into a free Premium or a lost edit. The result is a chord book a musician can trust on stage, a free plan generous enough to keep, and limits that hold.

Deliverables

  • Web app (React 19, Vite): marketing site, player app and admin, split into separately loaded route trees
  • iOS and Android app (Expo SDK 57, React Native 0.86, New Architecture) with an offline SQLite library and a queue of pending changes
  • Chord engine shared by every platform: chord parser, key-aware transposition, capo maths, ChordPro import and export, plain-text import, guitar and piano voicings
  • Reader with chords over every word, tap-a-chord guitar and piano diagrams, transpose, capo, three display modes and left-handed diagrams
  • Editor with structured, ChordPro and import modes, live preview, 50-step undo and revision-checked saves with a conflict dialog
  • Playlists and setlists with drag-to-reorder, per-song notes and per-song transpose; performance mode with keep-awake and auto-scroll
  • Chromatic tuner: McLeod Pitch Method detector, 33 tunings for 6, 7 and 8 strings, reference tones, native Android microphone capture
  • Learn: 13 units and 80 lessons whose questions the chord engine generates, with XP, levels, streaks, badges and ear training
  • Free plan and Premium subscription through RevenueCat (StoreKit, Google Play Billing, Web Billing), server-side entitlements, webhook processing and paywalls
  • Express 5 and MongoDB API: 106 routes, 17 models, Zod schemas shared with both apps, rate limits, moderation and reports
  • Store releases: privacy answers, microphone permission copy, in-app account deletion and a minimum-version gate