@pepperhorn/fingering-components
v0.2.2
Published
SVG key shapes and a JSON spec for woodwind and brass fingering charts
Maintainers
Readme
Fingering chart components
SVG key shapes and a JSON spec for building fingering charts across woodwind (and, with the same primitives, valved brass) instruments.
The design splits into three layers, and the split is the whole point:
| Layer | Answers | Changes when |
| --- | --- | --- |
| Shapes (src/keys.js) | what a key looks like | never, more or less |
| Layout (instruments/*.json) | where each key sits, what it's called | you add an instrument |
| Fingering (fingerings/*.json) | which keys are down for this note | constantly |
Because the layout owns geometry, a fingering is just a list of key ids. That makes the note-by-note data terse enough to hand-write, diff in git, or generate from a spreadsheet.
{ "note": "Bb", "octave": 3, "down": ["lh1","lh2","lh3","rh1","rh2","rh3","lh-bb"] }Try it
node verify.mjs # validates every layout and every fingering reference
node build-preview.mjs # rebuilds preview.html
open preview.html # shape legend, state legend, rendered chartsNo dependencies and no build step — plain ES modules, and the JSON is plain
JSON. build-preview.mjs only exists to inline everything into one file you
can open from the filesystem.
From an installed copy
The generator ships with the package, so an app that depends on it can build its own preview at any point — useful as a build step that drops the page into a site's static directory.
npx fingering-preview public/fingerings.htmlThe argument is the output path, resolved against the current directory, and
any missing parent directories are created. With no argument it writes
preview.html next to the package itself.
Shapes
| Shape | Used for |
| --- | --- |
| circle | tone holes, pearl touchpieces, ring keys, thumb holes |
| oval | bis key, alternate F♯, side touchpieces, rollers |
| pill | side keys, trill keys, thumb levers |
| bar | long lever bars (flute B♭ bar, clarinet linkage) |
| spatula | pinky tables — sax LH G♯/C♯/B/B♭ and RH E♭/C, clarinet levers, flute foot |
| lever | stemmed side/trill levers; size sm/md/lg, rod stem with pivot (stemStyle: "line" for the hairline) |
| roller | paired rollers between spatulas |
| teardrop (alias drop) | sax palm keys, and any bulb-and-taper touchpiece |
| taper | long, slim tapered drop — engraved-chart palm keys |
| leaf | almond pad, widest in the middle — realistic palm keys |
| bean | curved, optionally tapered capsule — side keys, clarinet throat A/G♯, sax low B♭ |
| plate | rectangle with per-corner radii — tiles into pinky tables |
| dome | half-ellipse on a flat base — table end caps, split-circle pinky pair |
| cylinder | roller seen side-on — sits between pinky keys |
| pin | teardrop hung from a pivot pearl on an arm |
| lh-hook (alias hook) | lobe pointing left, rod down its right side — clarinet RH pinky and trill keys |
| rh-hook | mirrored hook, rod on the left — clarinet LH pinky F/C |
| flag | stem with a loop curled over the top — flute G♯ key |
| crook | long arm bent down round a heel — flute B♭ thumb lever |
| paddle | slim bar with a bellied head — flute B♮ thumb key |
| ell | boat with an upright arm — flute foot C♯ and C levers (nested) |
| note | round head with a stem — flute D♯ key |
| saucer | cup seen low down, rim and pad — flute foot C/B and D♯ cups |
| saucer-top | the saucer from straight above: rim, face, pad ring; size sm/md/lg (or saucer with view: "top") |
| stacked | cup overlapping a cup behind it — flute G / linked RH cups |
| twin | recorder double hole: ring with a large (part: "a", draws the ring) and small (part: "b") hole; both keys share the ring centre |
| bell | brass bell flare, throat to rim — trombone body |
| slide | trombone outer slide: two tubes, U crook, hand brace — one key per position |
| tick | light dotted marker line, not a key — trombone positions passed |
| club | small head on a slim neck — flute RH trill keys |
circle also takes ringed: true (a ring key's metal ring) and hole: true
(an open tone hole whose centre stays empty until covered; themable with
--fc-hole) and inner: 0.55 (a concentric ring inside the cup, as on flute keys).
Any key takes tag (short text, e.g. a trombone slide position number) and
tagAt: [dx, dy]; tags stay upright when the chart is turned horizontal.
A key with showWith: [ids] is hidden unless one of those keys is in use
(pressed, half, etc.). A key with hint: true is a visual aid and only draws
with the hints: true render option — e.g. the trombone's dotted marks at the
positions before the current one: renderChart(trombone, f, { hints: true }).
Guides take dash: "dotted" (or a dasharray) for paths of travel such as the trombone slide.
Any key takes naStyle: "dotted": in the na state it draws as a dotted
outline instead of a faint one (flute mechanism cups).
Geometry is per-key and optional: r, rx/ry, w/h, rad, rot.
Family defaults live in the layout's defaults block, keyed by shape name.
teardrop takes w (bulb diameter), h (overall length), and tipRound
(how blunt the point is — drop it toward 0 for a needle, raise it for a pear).
The tip points up by default; dir aims it (up, down, left, right, or
the four diagonals) and stacks with any rot. dir works on every shape, so
lever and bar can use it too. If h gets close to w the taper has
nowhere to go and the shape falls back to a plain bulb rather than breaking.
Partial fills follow the shape: a half teardrop fills from the bulb end up,
which is what you want when a chart shows a palm key only partly depressed.
Themes, highlight and two-tone
Colours are CSS custom properties: --fc-ink (pressed), --fc-line
(outline), --fc-key (unpressed surface), --fc-accent, --fc-highlight,
--fc-ink-2 (second tone), --fc-text, --fc-font, --fc-stroke. The
highlight state calls a key out; { twoTone: 'hand' } draws right-hand
keys in --fc-ink-2. Setting --fc-key: none; --fc-stroke: 1.1 with a serif
font gives the original outline look.
Orientation and looks
renderFingering and renderChart take orient: 'horizontal' (the
instrument on its side, mouthpiece left; labels stay upright) and
look: 'ghost' (whole diagram faded via --fc-ghost) or look: 'dotted'
(unpressed keys as dotted, unfilled outlines; pressed keys stay solid). Every
key is wrapped in <g class="fc-key" data-key="…" data-state="…">, so an app
can restyle or animate individual keys — e.g. dotted fingerings falling onto
a ghosted instrument, piano-tiles style.
Layout variants
A layout can carry named, additive overrides under variants. Pass one or
several: renderChart(sax, notes, { variant: 'palm-taper side-levers' }).
The saxophone defaults to leaf palm keys and ships palm-teardrop, palm-bean, palm-taper, side-levers,
side-pills, high-fs-single, rh-table-stacked, lh-table-domes, rh-table-domes,
lh-table-spatulas and rh-table-spatulas; the clarinet ships
throat-pills and ring-keys.
States
| State | Meaning |
| --- | --- |
| open | uncovered / not pressed (outline) |
| closed | covered / pressed (solid) |
| half | half-holed — recorder and whistle |
| quarter, three-q | finer thumb pinch gradations |
| ring | ring or plateau touched, hole stays open (clarinet, open-hole flute) |
| optional | dashed — may be added for tuning or stability |
| alt | hatched — belongs to an alternate fingering being shown alongside |
| trill | caret above the key — flick or trill action |
| na | key absent on this variant (hidden by default) |
fillFrom on a key sets which edge partial fills grow from
(bottom default, or top / left / right).
Positioning: where to tweak
Three ways to place a key. Absolute coordinates still work everywhere — the other two are for the cases where the relationship between shapes is what you're actually adjusting, like a pinky table.
1. Absolute — x, y
The centre point, in viewBox units. Fine for the stacks and one-off keys.
2. Cluster — cluster + flow
A cluster owns an anchor, an optional rotation, shape defaults, and a flow rule
that positions its members. Keys join by naming it. This is the level you want
for pinky tables: nudge the anchor and every lever moves together, change
rotate and the whole table leans.
{
"id": "lh-table",
"anchor": [23, 188], // origin for member offsets
"rotate": -7, // degrees, about the anchor; members inherit it
"flow": { "axis": "y", "pitch": 15.5, "drift": -1.4 },
"defaults": { "shape": "spatula", "w": 15, "h": 13, "rad": 5 }
}Flow rules:
| Field | Effect |
| --- | --- |
| axis | x or y — the direction members march in |
| pitch | centre-to-centre spacing along that axis |
| drift | steady cross-axis lean, per step — a column that slides sideways |
| stagger | alternating cross-axis offset — the sax palm keys |
| shape: "arc" | fan members around the anchor instead of in a line |
| radius, startAngle, step | arc geometry; negative step reverses direction |
| rotateMembers | tilt each member tangentially to the arc |
| memberRotate | constant added to that tilt |
| tilt | multiplier on the tilt, 0–1, when the full tangent is too dramatic |
Member order in the keys array sets flow index. Override with index to
leave a gap (sax side-e sits at index 3, skipping 2), or with dx/dy to
opt out of the flow entirely. ddx/ddy nudge a single member without
leaving the flow — that's how the sax low B and B♭ step further left than
G♯ and C♯.
Arc clusters are what the clarinet lever groups want. The anchor is the pivot outside the fan, so the levers curve away from the body:
{
"id": "lh-table",
"anchor": [66, 176],
"flow": { "shape": "arc", "radius": 46, "startAngle": 195, "step": -16,
"rotateMembers": true, "memberRotate": -180, "tilt": 0.5 }
}3. Relative — relativeTo + place
Butt one key against another's edge. Gaps stay correct when you resize either shape, which absolute coordinates don't.
{ "id": "lh2", "relativeTo": "lh1", "place": "below", "gap": 10 }
{ "id": "bis", "relativeTo": "lh1", "place": "below", "gap": 0, "offset": [16, 0] }place is above / below / left / right / same. gap is edge-to-edge
and may be negative to overlap. offset is a free [x, y] nudge applied after
placement. Rotation is inherited from the reference unless
inheritRotation: false. Chains resolve in any order; a cycle throws.
align — which point of the shape sits on the coordinate
Default is center. Set top, bottom, left, right, or a combination
like top-left, and the coordinate becomes that edge or corner instead. Use it
when spatulas of different heights need to share an edge rather than a centre
line.
Checking your work
contentBounds(layout) returns the bounding box of every key, rotation-safe.
Compare it against viewBox after moving things, or use it to auto-fit.
import { contentBounds, resolveLayout } from './src/render.js';
contentBounds(clarinet); // [x, y, w, h]
resolveLayout(saxophone).byId['lh-b'] // final absolute x, y, rotLayout schema
{
"id": "saxophone",
"name": "Saxophone",
"family": "single-reed",
"transpose": -9, // semitones, written → sounding (required; 0 = concert)
"horn": "alto", // default horn, when the layout is shared
"horns": { // optional: versions of the instrument, each with its transpose
"soprano": { "name": "B♭ soprano", "transpose": -2 },
"alto": { "name": "E♭ alto", "transpose": -9 },
"tenor": { "name": "B♭ tenor", "transpose": -14 },
"baritone": { "name": "E♭ baritone", "transpose": -21 }
},
"viewBox": [0, 0, 108, 286],
"defaults": { "circle": { "r": 9 }, "pill": { "w": 8, "h": 15 } },
"panels": [
{ "id": "front" },
// a rear panel is how the thumb side gets drawn: flute's two back keys,
// clarinet's thumb hole + register key, recorder's thumb hole
{ "id": "rear", "label": "back", "origin": [92, 26], "box": [0, 0, 36, 66] }
],
"keys": [
{
"id": "lh1", // stable, referenced by every fingering
"label": "Left 1 (B)", // human-readable, used for accessibility
"shape": "circle",
"x": 41, "y": 44, // centre point, in viewBox units
"panel": "front", // omit for front
"hand": "L", // L | R — for teaching overlays and filtering
"digit": "1", // 1..4 | thumb | palm
"group": "lh-stack", // for highlighting a whole cluster
"default": "open" // e.g. flute rh-eb defaults to closed
}
]
}Fingering schema
Every file in fingerings/ has the same shape, and every note is at
written pitch — the renderer transposes with the layout's transpose.
{
"instrument": "tin-whistle", // layout id
"horn": "C", // optional: which of the layout's horns
"pitch": "written", // always written
"note": "…", // what the sheet covers
"fingerings": [ … ]
}Each fingering; everything not named is open (or the key's default).
{
"note": "Bb",
"octave": 3, // written octave, C4 = middle C
"note_text": "alternate B♭ fingerings", // small annotation under the label
"down": ["lh1", "bis"],
"half": ["thumb"],
"ring": ["lh2"],
"optional": ["rh-eb"],
"trill": ["trill-d"],
"states": { "lh3": "alt" }, // explicit override, wins over the above
"alternates": [ { "down": ["lh1", "side-bb"] } ]
}Rendering
import { renderFingering, renderChart, sounding } from './src/render.js';
import sax from './instruments/saxophone.json' with { type: 'json' };
import data from './fingerings/saxophone.json' with { type: 'json' };
renderChart(sax, data.fingerings, { columns: 9 }); // full sheet, SVG string
renderFingering(sax, data.fingerings[0]); // single diagram
// labels at sounding pitch, for a given horn
renderChart(sax, data.fingerings, { pitch: 'concert', horn: 'tenor' });
sounding(sax, { note: 'C', octave: 5 }); // { note: 'Eb', octave: 4 } on altoPitch
| Instrument | transpose | Horns |
| --- | --- | --- |
| flute, trombone, Nuvo Dood, Nuvo TooT | 0 | |
| clarinet, trumpet (B♭) | −2 | |
| soprano recorder | +12 (written an octave below sounding) | |
| saxophone | −9 (alto) | soprano −2, alto −9, tenor −14, baritone −21 |
| tin whistle | 0 | D, C, B♭, F, E♭ — one sheet per horn |
verify.mjs enforces the schema: every layout has an integer transpose
(matching its default horn), every fingering file is "pitch": "written",
names a real horn if it names one, and gives every note a written octave.
Ranges
Every layout carries ranges — three nested skill bands at written pitch:
"ranges": { "beginner": { "low": "D4", "high": "C6" }, "intermediate": { … }, "pro": { … } }A horn may override with its own ranges (tin whistles do: each sheet is at
that whistle's pitch). verify.mjs checks the bands nest and that every note
in pro has a fingering.
React wrappers are in src/react.jsx — <Fingering>, <Chart>, and
<FingeringEditor> (click a key to cycle its state, so charts get authored by
clicking rather than by typing key ids).
Theme through CSS custom properties: --fc-ink, --fc-line, --fc-paper,
--fc-font.
Registers
Beside ranges, every layout carries registers — an ordered list of bands
that names a written pitch by register rather than by octave number, so an
app can say "High G" instead of "G5":
"registers": [
{ "from": "Bb3", "name": "Lowest" },
{ "from": "C4", "name": "Low" },
{ "from": "C5", "name": "Middle" },
{ "from": "C6", "name": "High" },
{ "from": "G6", "name": "Altissimo" }
]The bands are half-open: each runs from its from up to — but not including —
the next band's from, and the last runs to the top of the instrument. The
first from is the lowest written note the layout fingers, so every fingering
lands in exactly one band. Pitches are spelled as in ranges: Bb3, C#4,
F7.
The bands follow the written octave, with the boundary at C — not the
instrument's register break. Flute has low C (foot C), middle C (up the
octave), then high C; alto saxophone has low D (all down), middle D (all down
with the octave key), high D (palm key) and then altissimo D. A partial group
below the instrument's first C — the notes under the bottom of its lowest full
octave — is named Lowest; the full octaves above it run Low, Middle,
High, Altissimo in order.
The reason is that a register name is rendered without the octave digit ("Low C", not "C4"). A band wider than an octave therefore holds two notes of the same pitch class and the name stops identifying a note: anchored at the register break, flute C4 and C5 both came out as "Low C", and alto sax, clarinet, trumpet and trombone each had several such pairs. Grouping by written octave keeps every band under an octave, so a register name plus a note letter is unique on every instrument.
Saxophone is the one deliberate exception to the boundary-on-C rule:
Altissimo starts at G6, not C7, because that is exactly where
saxophone-altissimo.json begins. The tier then means "needs an altissimo
fingering" rather than "is in the seventh octave", which is what the word
means to a player. G6–F7 spans eleven semitones, so it still holds each pitch
class once and the names stay unique.
name comes from a fixed set — Lowest, Low, Middle, High,
Altissimo — and not every instrument uses all five:
| Layout | Bands |
| --- | --- |
| saxophone.json | Lowest B♭3 · Low C4 · Middle C5 · High C6 · Altissimo G6 |
| clarinet.json | Lowest E3 · Low C4 · Middle C5 · High C6 |
| flute.json | Low C4 · Middle C5 · High C6 · Altissimo C7 |
| recorder.json | Low C4 · Middle C5 · High C6 |
| trumpet.json | Lowest F♯3 · Low C4 · Middle C5 · High C6 |
| trombone.json | Lowest E2 · Low C3 · Middle C4 · High C5 |
| nuvo-toot.json, nuvo-dood.json | Low C4 · High C5 |
| tin-whistle.json | per horn — see below |
The tin whistle is the documented exception: it has no layout-level
registers, because every whistle is written in its own range, and its bands
are anchored on the horn's tonic rather than on C. Each horn carries its
own list beside its own ranges, with the high band an octave above the bottom
note — D whistle Low D5 · High D6, C whistle Low C5 · High C6, and so on. That
is musically right for a two-register instrument whose bottom note is its
tonic, and it is still collision-free: each band is exactly an octave wide.
verify.mjs checks the bands ascend, that every name is one of the five,
that the first band starts on the lowest note in that layout's (or horn's)
fingerings, and — the check that matters — that no band contains two
fingerings of the same pitch class, walking the real fingerings of every
instrument and every tin-whistle horn and naming the offending notes if it
finds a pair.
Instruments
| Layout | Fingerings |
| --- | --- |
| saxophone.json | written B♭3–F♯6, altissimo G6–F7 in saxophone-altissimo.json |
| flute.json | C4–C7 with alternates |
| clarinet.json | written E3–A6 with alternates |
| recorder.json (soprano, baroque) | written C4–C6 |
| tin-whistle.json | one sheet per horn: D, C, B♭, F, E♭ |
| nuvo-dood.json, nuvo-toot.json | full charts from the maker |
| trumpet.json | written F♯3–C6 |
| trombone.json (tenor) | E2–C5, slide positions 1–7 |
Sax altissimo sits in its own file: it varies by horn, mouthpiece and player.
Files
src/keys.js shape primitives, states, bounding boxes
src/layout.js clusters, flows, alignment, relative placement
src/render.js resolved layout + fingering -> SVG
src/react.jsx React wrappers, including a click-to-author editor
instruments/*.json key layouts
fingerings/*.json note data
build-simple.mjs generates the hole-based layouts on a shared grid
build-preview.mjs bundles everything into preview.html
verify.mjs validates every layout and every fingering reference