Beat Grid Geometry
beat-grid-geometryFoundationsThe layout behind beat-grid-scope, usable on its own by anything that needs to place something on a tempo-synced plot. Across: xForBeat, its inverse, xForSeconds at a tempo (the door for a measured buffer, whose samples are timed in seconds rather than beats), and bar/beat/subdivision tick enumerations classified in one pass so the three arrays can never disagree. Down: a one-sided dB axis with 0 pinned at the top and reduction growing downward, gutter labels that count in round decibels (1 / 2 / 3 / 6 / 12) and whose last entry is the depth the axis actually reaches rather than a decorative -infinity — a caller scaling the axis to live data can easily end up three decibels deep, and an infinity symbol down there is a plain falsehood; "-infinity" stays available by name for a plot whose floor really is silence, and a ceiling helper that clamps a budget into the plot instead of drawing it off the surface. The reuse is deliberate and the tests pin it: the x axis IS piano-roll-geometry's, built on the same createLaneLayout, and every beat placement is asserted against a lane layout built from the same numbers — so a beat grid, a drum lane and a piano roll place beat 3 at the same fraction of their plot, and anyone who "improves" one of them breaks the agreement loudly. The dB axis is the piece that could NOT be shared: response-geometry's dbToY is a symmetric range either side of a centred zero, the right shape for a filter curve that swings both ways and the wrong one for gain reduction, which is one-sided and belongs at the top with the whole plot beneath it. So it is restated here in the one-sided form, keeping the one convention that does carry over — the mapping does not clamp, and whether a curve past the axis is clipped or allowed to overflow stays the caller's decision.
| beat | xForBeat | piano roll x | yForDb(beat dB) |
|---|---|---|---|
| 0 | 34.00 | 34.00 | 8.00 |
| 1 | 68.75 | 68.75 | 30.33 |
| 2.5 | 120.88 | 120.88 | 63.83 |
| 3.75 | 164.31 | 164.31 | 91.75 |
| 8 | 312.00 | 312.00 | 142.00 |
The two x columns are the point: the beat grid's xForBeat and the piano roll's are the same numbers, because they are the same function — the grid builds on createLaneLayout rather than growing a second time axis, and the test asserts the agreement at every subdivision. The dB column runs the other way: 0 dB at the top of the plot, reduction downward, and the axis floor labelled −∞ because it is the bottom of what the plot can show rather than a value anyone measured.
Install
Copies the source into your project. Anything it depends on comes with it.
$ npx shadcn add @syntho-ui/beat-grid-geometryPulls in
First time? Add the registry to your components.json once:
{
"registries": {
"@syntho-ui": "https://engine.syntho.app/r/{name}.json"
}
}Usage
Controlled or uncontrolled, whichever suits the surrounding state.
import { createBeatGridLayout } from "@/lib/beat-grid-geometry"
const layout = createBeatGridLayout({ bars: 2, beatsPerBar: 4, axisDb: 24 })
layout.xForBeat(3) // where beat 3 sits - the piano roll's own mapping
layout.xForSeconds(1.5, 128) // where 1.5s sits, at 128 BPM
layout.yForDb(0) // the top of the plot: 0 dB
layout.yForDb(14) // 14 dB of reduction, drawn downward
layout.ceilingY(-14) // the budget line, clamped into the plot
layout.dbTicks(4) // [{ db, y, label }] - round steps, honest floor
layout.dbTicks({ floorLabel: "-∞" }) // only when the floor really IS silence
layout.ticks // [{ beat, x, kind: "bar" | "beat" | "sub" }]Props
| Prop | Type | Default | Description |
|---|---|---|---|
| createBeatGridLayout(options) | (BeatGridLayoutOptions) => BeatGridLayout | — | Resolve a plot from { bars, beatsPerBar, subdivisions, left, right, top, bottom, width, height, axisDb, minAxisDb }. Nonsense inputs resolve to defaults rather than throwing, so a face mounting before its schema resolves renders an empty plot. |
| xForBeat / beatForX | (number) => number | — | The time axis, forward and back. Identical to piano-roll-geometry's — pinned against createLaneLayout in the tests. The inverse is unclamped so a drag can read past the window's end. |
| xForSeconds(seconds, bpm) | (number, number) => number | — | A moment in seconds at a tempo, for a measured buffer. A non-positive or non-finite bpm returns the left edge rather than NaN. |
| yForDb / dbForY | (number) => number | — | The dB axis: 0 dB at the top, reduction downward. Does not clamp — a curve may legitimately run past the axis, and clipping it is the caller's call. |
| ceilingY(db) | (number) => number | — | The y of a hard budget. Sign-agnostic, and unlike yForDb it IS clamped into the plot, so a budget deeper than the axis draws on the floor. |
| dbTicks(count | options) | (number | BeatGridDbTickOptions) => BeatGridDbTick[] | — | { db, y, label } from 0 down to the floor. Steps snap to round decibels by default and the last label is the depth the axis actually reaches — the floor of a three-decibel plot is -3, and printing -infinity there would claim a silence the plot never shows. Options are { count, floorLabel, niceSteps }: floorLabel takes "-infinity" back for a plot whose floor really is silence, or null to keep the rule and drop the text; niceSteps false restores the even axisDb / count division. A bare number is still the count. |
| niceDbStep(axisDb, count) | (number, number) => number | null | — | The round step a gutter should count in — 1 / 2 / 3 / 6 / 12 or a multiple of 12 — or null when none fits. Strict on purpose: the step must divide the axis exactly, because a last labelled tick floating above the floor reads as though the plot continued past it, and it must land near the requested tick count, because round numbers are worth a label either way and not a gutter of fifteen. |
| barX / beatX / subdivisionX / ticks | number[] / BeatGridTick[] | — | The grid lines, classified in one pass so a bar line is always also a beat line and the arrays cannot drift apart. Triplet grids classify on the slot index, not a float remainder. |
| axisDb (resolved) | number | — | The dB span after the minAxisDb floor — the number a caller scaling the axis to live data should read back rather than assume. |
Interaction
- Drag with pointer capture, so the gesture keeps tracking past the edge of the control.
- Shift while dragging for a fine pass; release it mid-gesture and coarse tracking resumes.
- Double-click resets to
defaultValue. - Arrow keys step normally, Shift+arrow steps finely, PageUp/PageDown jump by ten, Home/End go to the extremes.
- Proper
sliderandswitchroles with livearia-valuetext, so a screen reader announces the formatted value rather than the raw number. - The scroll wheel adjusts the value while the pointer is over the control; stepped ranges move at least one declared step per notch by default.