← All Stories
JMOVE·May 5, 2026May 5, 2026·17 min read

Five Ways to See a Chord

Inside the sync architecture, DFS voicing search, and Viterbi optimization that let JMove show every chord on score, tabs, piano roll, fretboard, and keyboard simultaneously

A guitarist sees Cmaj7 as a shape - fingers arched across four strings in a geometry the hand memorizes before the brain names the notes. A pianist sees a landscape of white keys with a specific spacing, a topography of intervals. A producer sees a cluster of rectangles on a piano roll. Same four notes. Same harmony. Completely different mental image depending on which instrument taught you to think.

This is one of the beautiful complications of being a multi-instrumentalist. When a guitarist says "play the jazz voicing of Cmaj7," a pianist needs a translation. When a pianist says "the voicing has a major seventh on top," a guitarist needs to figure out which string and which fret puts that note in the right place. The notes are universal. The shapes are personal. And in my experience, the gap between those shapes is where understanding either deepens or dies.

JMove shows five views simultaneously, synchronized to the beat. When the cursor passes over a Dm7 chord, you see it on the score as standard notation, on the tab as fret numbers, on the fretboard as lit-up dots, on the keyboard as highlighted keys, and on the piano roll as horizontal bars. All at once. All derived from one shared clock. What follows is the full technical story of how that works - the sync architecture, the voicing algorithms, the rendering decisions, and the problems I had to solve at each layer.

The Translation Problem

Most music software picks one representation and commits to it. Finale and MuseScore show standard score. Guitar Pro shows tabs. DAWs show piano roll. Each choice serves one way of thinking and implicitly ignores the others. If you're a guitarist who wants to understand how your voicing maps to the piano keyboard, you need a second piece of software. If you're a pianist trying to learn guitar voicings, you need a third. The knowledge is fragmented across tools, and the burden of translation falls entirely on you.

The problem is old. Guido d'Arezzo invented staff notation around 1000 AD to help monks sight-read chant melodies they'd previously memorized by ear. That was a single translation layer: sound to symbol. The guitar added a second. Tablature emerged in the Renaissance specifically because lute players couldn't read staff notation efficiently - the instrument's tuning creates too many ways to play the same pitch. A vihuela player in 1530 needed numbers on lines, not notes on staves. Five hundred years later, guitarists and pianists still live in separate notational worlds.

The deeper issue isn't just visual preference. It's cognitive. Research on multimodal encoding shows that information stored through multiple sensory pathways is retained more deeply than information stored through one. When you see a Dm7 on the fretboard and simultaneously see the same notes highlighted on the keyboard, your brain builds a cross-reference between two physical representations. Over time, you start thinking in intervals and relationships rather than instrument-specific shapes. That's the bridge from "I know this voicing on guitar" to "I understand this voicing in music."

The Sync Architecture

The central challenge is this: five views need to agree on what's happening right now. The playback cursor is at 4.37 seconds. Which notes are sounding? Which chord is active? When the user clicks a note in one view, every other view needs to update. When playback advances, every view needs to follow. And it all needs to happen without any view knowing about any other view.

The answer is a Zustand store called useSyncStore. It holds the current playback time, the selected note ID, whether playback is active, and which views are visible. Every view subscribes to the same store. None of them talk to each other directly.

typescript
// syncStore.ts — the single source of truth
export interface SyncState {
  currentTime: number;           // seconds
  isPlaying: boolean;
  duration: number;
  selectedNoteId: string | null;
  viewVisibility: Record<ViewId, boolean>;

  setCurrentTime: (time: number) => void;
  seek: (time: number) => void;
  selectNote: (id: string | null) => void;
  toggleView: (viewId: ViewId) => void;
}

// External seek handler — set by component that owns playback
let seekHandler: ((time: number) => void) | null = null;

export const useSyncStore = create<SyncState>((set, get) => ({
  currentTime: 0,
  isPlaying: false,
  selectedNoteId: null,
  seek: (time) => {
    set({ currentTime: time });
    seekHandler?.(time);     // WaveSurfer jumps to new position
  },
  selectNote: (id) => set({ selectedNoteId: id }),
  // ...
}));

The seek function is the key architectural decision. When the user clicks a note on the fretboard, the fretboard component calls seek(note.startTime). The store updates currentTime. WaveSurfer (the audio engine) receives the new position through registerSeekHandler. And every other view, subscribed to currentTime, re-renders to show the new moment. The fretboard doesn't need to know about the score view. The score view doesn't need to know about the piano roll. They all watch the same clock.

But having five views all re-rendering on every time update would be wasteful. If each view independently scanned the entire score to find which notes are active at time t, you'd have five identical linear scans running in parallel. That's where the useActiveNotes hook comes in.

typescript
// useActiveNotes.ts — computed once, shared to all views via props

export function computeActiveNotes(
  score: QuantizedScore | null, t: number
): ActiveNoteInfo {
  if (!score || t < 0) return EMPTY;

  const notes: QuantizedNote[] = [];
  const pitchSet = new Set<number>();
  const idSet = new Set<string>();

  for (const measure of score.measures) {
    if (measure.endTime < t) continue;   // skip past measures
    if (measure.startTime > t) break;    // stop at future measures

    for (const note of measure.notes) {
      if (note.isRest) continue;
      if (note.startTime <= t && t < note.endTime) {
        notes.push(note);
        idSet.add(note.id);
        // Prefer voicing-derived pitches over raw transcription
        if (note.tabPositions?.length > 0) {
          for (const tp of note.tabPositions)
            pitchSet.add(TAB_TUNING[tp.str] + tp.fret);
        } else {
          for (const p of note.pitches) pitchSet.add(p);
        }
      }
    }
  }
  return { activeNotes: notes, activePitches: [...pitchSet], activeNoteIds: idSet };
}

This runs once in the parent NotationPanel component and passes the result down as props. The early-exit optimization matters: measures are sorted by time, so the loop skips everything before the current moment and stops as soon as it passes it. For a 32-bar standard at 120 BPM, the scan typically touches 1-2 measures instead of 32.

One subtle detail: the pitch computation prefers voicing-derived pitches. When a note has tabPositions (guitar fret assignments), the hook computes MIDI pitches from those positions rather than using the raw transcription pitches. This matters because the voicing engine might place a note on a different octave than the transcriber detected. The tab positions are the ground truth for what the player would actually hear, and every downstream view - fretboard dots, keyboard highlights, piano roll bars - needs to reflect the voicing, not the raw detection.

The Guitar Voicing Problem

The guitar fretboard is one of the most confusing interfaces in music. Unlike the piano keyboard, where every octave looks identical, the guitar repeats the same pitch across multiple strings at different fret positions. Middle C can be played on five different strings. A Cmaj7 can be voiced in at least ten different positions, each with a different character, a different spread, a different voice on top. Ask three guitarists to play Cmaj7 and you'll get three different shapes, all correct.

Allan Holdsworth spent his entire career exploring this problem. He treated the guitar like a wind instrument - long, flowing lines across the neck, voicings stretched over five or six frets that most guitarists would consider physically impossible. Ted Greene went the opposite direction - intimate, close-position voicings on the top four strings, each one a miniature Bach chorale. Joe Pass played walking bass lines and chords simultaneously, constantly shifting between positions. All three were right. The guitar's redundancy isn't a bug, it's the instrument's defining feature. But it means that "assign MIDI pitches to fret positions" is not a lookup table. It's a search problem.

DFS Voicing Search

JMove's fret assignment algorithm is a depth-first search over all valid string/fret combinations for a set of MIDI pitches. For each pitch in a chord, the algorithm first computes every possible fret position across all six strings. Then it searches through all combinations, enforcing two constraints: no two pitches can share a string, and the fretted positions (excluding open strings) must fit within the player's hand span.

The hand span constraint is position-dependent, and this is where the physics of the instrument matter. Fret spacing on a guitar is not uniform - it follows a logarithmic curve. The first fret is about 36mm wide on a standard 25.5-inch scale. The twelfth fret is about 18mm. Your hand can stretch further at higher positions because each fret covers less physical distance. The algorithm encodes this directly.

typescript
// midiToFret.ts — three-layer DFS with position-dependent span

function assignChordPositions(pitches: number[], lastFret: number): TabPosition[] {
  const allCandidates = pitches.map(p => midiToFretPositions(p));

  let bestPositions: TabPosition[] = [];
  let bestScore = Infinity;

  // Layer 1: strict span (TUNING.fretMaxSpan, default 4)
  function dfs(idx, current, usedStrings) {
    if (idx === validIndices.length) {
      const fretted = current.filter(p => p.fret > 0);
      if (fretted.length > 1) {
        const span = Math.max(...fretted.map(p => p.fret))
                   - Math.min(...fretted.map(p => p.fret));
        if (span > TUNING.fretMaxSpan) return;  // reject
      }
      // Score: distance from lastFret + open string penalty
      const avg = current.reduce((s, p) => s + p.fret, 0) / current.length;
      const score = Math.abs(avg - lastFret)
                  + current.filter(p => p.fret === 0).length * TUNING.fretOpenPenalty;
      if (score < bestScore) { bestScore = score; bestPositions = [...current]; }
      return;
    }
    for (const pos of allCandidates[validIndices[idx]]) {
      if (usedStrings.has(pos.str)) continue;
      usedStrings.add(pos.str);
      current.push(pos);
      dfs(idx + 1, current, usedStrings);
      current.pop();
      usedStrings.delete(pos.str);
    }
  }
  dfs(0, [], new Set());

  // Layer 2: position-dependent span (fallback)
  if (bestPositions.length === 0) {
    function maxSpanForPosition(minFret: number): number {
      if (minFret <= 2) return 3;   // frets 0-2: max span 3
      if (minFret <= 5) return 4;   // frets 3-5: max span 4
      return 5;                     // frets 6+:  max span 5
    }
    // ... DFS with position-aware span check
  }

  // Layer 3: pitch reduction (last resort)
  if (bestPositions.length === 0 && validIndices.length > 2) {
    // Drop hardest-to-place pitches one at a time
    // Sort by fewest playable positions (most constrained first)
    // Re-run DFS on reduced pitch set
  }

  return bestPositions;
}

The three layers are a fallback chain. Layer 1 uses the configured maximum span (default 4 frets) and finds the best voicing within that constraint. If nothing is playable - which happens with extended chords like Cmaj7#11 that have five or six distinct pitches - Layer 2 activates with position-dependent limits. At frets 0-2, where the physical spacing is widest, the maximum span tightens to 3 frets. At frets 3-5, it allows 4. At fret 6 and above, it relaxes to 5. This mirrors how an actual guitarist's hand works: you can stretch further as you move up the neck.

Layer 3 is the last resort: drop pitches. If a chord has six notes and no physically playable combination exists even with the relaxed span, the algorithm removes the most constrained pitch (the one with the fewest valid fret positions) and tries again. In jazz, this is standard practice - guitarists routinely omit the fifth, or even the root when a bassist is present. The algorithm does what a real player does: prioritize the guide tones (third and seventh) and let the less essential voices go.

The scoring function within each DFS layer also penalizes open strings. This is a jazz-specific decision. Open strings on guitar ring with a bright, sustaining quality that clashes with the darker, more controlled sound of fretted notes. A Cmaj7 voiced x-3-5-4-5-3 (all fretted, clustered around the 3rd-5th fret) sounds fundamentally different from 0-3-2-0-0-0 (three open strings). The open position voicing is correct - it has all the right notes. But it doesn't sound like jazz. The penalty score (configurable via TUNING.fretOpenPenalty, default 2) pushes the search toward fretted positions without excluding open voicings entirely.

Viterbi Voice Leading

Finding good voicings for individual chords is only half the problem. The other half is choosing voicings that flow smoothly from one chord to the next. A beautiful Dm7 voicing at the 10th fret followed by a G7 voicing at the 1st fret creates a nine-fret jump that no guitarist would actually play. Voice leading - the art of moving each voice by the smallest possible interval - has been central to Western harmony since Palestrina codified it in the 16th century. Bach's chorales are voice-leading textbooks. Bill Evans built an entire piano style on the principle of minimal motion between chord tones.

JMove's voice-leading optimizer is a Viterbi dynamic programming algorithm. Given a chord progression and a set of candidate voicings for each chord, it finds the globally optimal path - the sequence of voicings that minimizes total transition cost across the entire progression. This is the same class of algorithm used in speech recognition (finding the most likely word sequence given acoustic input) and computational biology (gene alignment). Applied to music, it's finding the path of least resistance through a forest of possible voicings.

typescript
// voiceLeadingOptimizer.ts — Viterbi DP (pseudocode)

// Step 1: Filter — reject physically impossible voicings
//   Position-dependent stretch limits (tighter at low frets,
//   relaxed higher up the neck where fret spacing narrows)

// Step 2: Score transitions between consecutive voicings
//   transitionCost(a, b) considers:
//     - fret distance across all six strings
//     - reward for common tones (same note, same string, same fret)
//     - penalty for position jumps on the neck
//     - penalty for strings gaining/losing notes

// Step 3: DP fill
//   dp[i][j] = minimum cost to reach chord i using voicing j
//   For each chord, evaluate all candidate voicings against
//   all candidates from the previous chord.
//   Total cost = transition cost + ergonomic cost (stretch, twist)
//   Track backpointers for optimal path traceback.

// Step 4: Backtrace from final chord to recover
//   the globally optimal voicing sequence.

The cost function balances three concerns. First, transition cost: how far fingers need to move across all six strings between consecutive voicings. Second, ergonomic cost: how physically comfortable a voicing is to play, considering stretch, cross-string twist, and interior muted strings. Third, position stability: preferring voicings that stay in the same neighborhood on the neck. The balance between these components is what makes the output sound like real guitar playing rather than a theoretical exercise.

Common tones are central to the optimization. When two chords share a note, keeping it on the same string at the same fret eliminates a finger movement entirely. In a Dm7 to G7 transition, the F on the high E string (1st fret) can stay put while the other voices move - if the optimizer finds a G7 voicing that also uses fret 1 on the high E string. The algorithm rewards these economical transitions, encoding a principle that voice-leading teachers have stressed since Palestrina.

The playability filter runs before the Viterbi pass. It rejects voicings that are physically impossible - too many unique fret positions for four fingers, or stretches that exceed what the hand can manage at a given position on the neck. Voicings that pass the filter get a playability score reflecting how comfortable they are to actually play. This score feeds into the DP cost function, pushing the optimizer toward shapes that a real guitarist would choose.

After the backtrace, the optimizer handles repeated chords through majority vote: if Dm7 appears in measures 1, 3, and 5, and the optimal path chose voicing index 2 twice and index 4 once, the result is voicing 2 for all appearances of Dm7. This keeps the voicing consistent when the same chord recurs - a musical choice that reflects how guitarists actually play standards. You pick a Dm7 shape and stick with it.

The Engine Behind the Voicings

The frontend DFS handles fret assignment for transcribed audio - mapping detected MIDI pitches to playable positions. But when JMove imports a lead sheet (from iReal Pro, MusicXML, or MIDI), it starts with chord symbols, not pitches. The chord symbols go to a Python voicing engine that uses a different approach: exhaustive search via itertools.product across all six strings.

For each string, the engine computes every fret that produces a pitch class belonging to the chord. String options include "muted" as a valid choice. Then it takes the Cartesian product of all six strings' options and filters by playability: at least 3 sounding strings, stretch within limits, at least 3 unique pitch classes. The surviving combinations get scored for jazz idiom - bass note priority, guide tone presence, interval interest, position on the neck, inner muting penalties.

typescript
// Voicing scoring (from voicing.py, simplified)
//
// Bass note = root:    +3.0      Guide tones (3rd + 7th): +1.5 each
// Extensions present:  +0.8/ea   4-note voicing:          +0.8
// 5-note voicing:      +1.5      All unique pitches:      +0.5
// Jazz position (3-10): +0.5     4th/tritone intervals:   +0.2
// Doubled root:        -0.8/ea   Inner muting:            -1.5/string
// Open strings (ext'd): -0.5/ea  Wide stretch (4+):       -0.5

// Classification:
//   <= 3 sounding strings:           "Shell"
//   open strings + low frets:        "Open"
//   3+ strings at same fret:         "Barre"
//   mostly 4th intervals:            "Quartal"
//   fretted span <= 2:               "Compact"
//   everything else:                 "Spread"

The classification system - Shell, Open, Barre, Quartal, Compact, Spread - maps to real guitar vocabulary. Shell voicings (Bud Powell, Freddie Green) use only root, third, and seventh on three or four strings. Quartal voicings (McCoy Tyner's piano style adapted to guitar by Lage Lund and Ben Monder) stack perfect fourths. The engine doesn't just find voicings that contain the right notes - it categorizes them so you know what style of voicing you're looking at.

Results are diversified across fretboard zones. The engine divides the neck into 5-fret zones and caps how many voicings come from each zone, ensuring the final list covers the full geography of the instrument from open position to the 12th fret. When you're practicing Autumn Leaves and the harmony moves from Cm7 to F7, you can see how those shapes transform at every position on the neck.

Five Renderers, Five Technologies

Each view uses a different rendering technology, chosen for the specific demands of what it needs to draw. This isn't arbitrary - it reflects real tradeoffs between interaction model, performance, and visual fidelity.

The score view uses VexFlow, a JavaScript library that renders standard music notation as SVG. VexFlow handles the genuinely hard problems of music engraving - beam grouping, stem direction, accidental placement, voice spacing - that have occupied typographers since Petrucci printed the first music with movable type in 1501. Each note becomes an SVG element with a stored reference to its QuantizedNote ID. When currentTime changes, the component walks those references and sets fill colors: blue for active notes, orange for the selected note in edit mode. Click handlers on each SVG element call either seek() (jump to that moment) or selectNote() (enter edit mode).

The tab view also uses VexFlow, but through its TabStave and TabNote classes instead of Stave and StaveNote. Same library, different renderer. Tab positions come directly from the DFS fret assignment - each QuantizedNote carries an optional tabPositions array of { str, fret } pairs. The highlighting logic is identical to the score view (SVG element references, fill manipulation), but it targets text elements instead of noteheads because tab numbers are rendered as SVG text.

The piano roll uses Canvas - specifically, a dual-canvas architecture. The bottom canvas draws the static grid (pitch rows, time columns, note bars) once when the score or zoom level changes. The top canvas draws only the playback cursor, updating on every animation frame via requestAnimationFrame. This separation means the expensive grid-and-notes render doesn't re-run 60 times per second during playback. Only the cursor line - a single vertical stroke - redraws each frame.

Canvas was the right choice here because a piano roll can contain hundreds of note rectangles. Each one needs a fill color based on its state (normal, active, selected, preview), rounded corners, and precise pixel positioning on a zoomable grid. SVG would create a DOM element for each rectangle, and at 200+ notes with zoom controls firing on mouse wheel, the DOM overhead would kill performance. Canvas draws everything as pixels - no DOM nodes, no layout recalculation, no garbage collection pressure from element creation and destruction.

The fretboard view uses inline SVG rendered by React. The fretboard is geometrically simple: 6 horizontal lines (strings), 15 vertical lines (frets), dot markers at frets 3, 5, 7, 9, 12, 15, and a grid of invisible clickable rectangles for interaction. Active notes appear as colored circles at the intersection of string and fret, with the fret number rendered as white text inside each dot. The SVG approach works here because the element count is bounded - at most 90 clickable zones plus a handful of dots - and React's declarative rendering makes the highlight logic clean: the activeDots array is computed from activeNoteInfo, and React diffs the previous dots against the new ones.

The piano keyboard view is also inline SVG. White keys are rectangles rendered in a first pass (bottom layer), black keys in a second pass (top layer), so black keys visually overlap white keys without z-index hacks. The active state is a simple Set lookup: activePitchSet.has(midi). If the pitch is active, the key fills blue. The keyboard spans C3 to C6 by default - three octaves, enough to cover the guitar's full range and the practical range of most jazz piano voicings.

The architectural pattern across all five views is identical: subscribe to the sync store, receive activeNoteInfo as a prop, highlight accordingly, and call seek() or selectNote() on user interaction. The views don't know about each other. They don't communicate laterally. They all look up at the same shared state and render their own interpretation of it. This is the observer pattern applied to music notation, and it's the reason adding a sixth view would require zero changes to the existing five.

The Tab Position Pipeline

A detail that took more debugging than I'd like to admit: the coordination between VexFlow's string numbering and the engine's string numbering. VexFlow numbers strings 1-6 from high E to low E (string 1 is the thinnest). The fretboard layout and the voicing engine number strings 0-5 from low E to high E (index 0 is the thickest). Every boundary where data crosses between these systems needs a conversion: stringIdx = 6 - tp.str. Get it wrong and the tab shows the right fret numbers on the wrong strings. The voicing sounds correct when you play it, but the dots on the fretboard are upside down.

The MIDI-to-pitch conversion adds another layer. The useActiveNotes hook computes pitches from tab positions using a tuning table: TAB_TUNING = [0, 64, 59, 55, 50, 45, 40], indexed by VexFlow string number (1-based, index 0 unused). The fretboard component uses a different tuning table: TUNING_MIDI = [40, 45, 50, 55, 59, 64], indexed by engine string number (0-based). Both encode the same physical reality - standard guitar tuning, E2 to E4 - but in opposite order. The duality is necessary because VexFlow and SVG have different coordinate conventions, and forcing one to match the other would create bugs in either the notation rendering or the fretboard rendering.

What Synchronized Views Actually Teach

I built this because I kept having the same experience as a guitarist trying to understand piano voicings. I'd read about Bill Evans' rootless voicings - the "A" form (3-5-7-9 from bottom) and the "B" form (7-9-3-5) - and I could play them on piano. But I couldn't see how they mapped to the guitar. The interval structure was the same. The note names were the same. But the physical shapes were completely different, and my brain couldn't bridge the gap without seeing both simultaneously.

The synchronized views solve this by letting you watch the translation happen in real time. When a rootless A voicing for Dm7 plays, you see the piano keyboard light up F-A-C-E, and at the same instant you see those same notes appear on the fretboard at whatever position the voicing engine chose. Your brain does the mapping automatically because both views are right there, synchronized to the same beat, showing the same harmony through different lenses.

The piano roll adds a dimension that neither the score nor the tab can show: duration. A quarter note and a whole note look different on the score, but the piano roll makes the difference visceral - one is a short bar, the other stretches across the entire measure. For rhythm-heavy music, for understanding how comping patterns work, the piano roll is irreplaceable. It shows time as space, and space is something your eyes can measure instantly.

Five views isn't a feature list item. It's a statement about how harmony works: it's one thing seen from many angles, and every angle teaches you something the others can't. The score shows you the abstract music. The tab shows you the guitarist's hands. The fretboard shows you the geography. The keyboard shows you the intervals. The piano roll shows you the time. Together, they're a complete picture of a chord - not as a symbol, but as a physical, temporal, multi-dimensional event.

Read the full story of why JMove was built
See the Views