diff --git a/.agents/skills/render-musicxml/SKILL.md b/.agents/skills/render-musicxml/SKILL.md deleted file mode 100644 index 89d2a6776..000000000 --- a/.agents/skills/render-musicxml/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: render-musicxml -description: Render a MusicXML file in vexml by running `vex render -i `, optionally pass render configuration with `--config `, inspect the generated screenshot when needed, and delete ephemeral render output when finished. ---- - -# Render MusicXML - -Use this skill when the user asks to render a MusicXML file, inspect how a MusicXML fixture looks, generate a quick screenshot from MusicXML, or verify rendering behavior visually without adding or updating an integration test. - -## Command - -Run the render command from the project root: - -```sh -vex render -i -``` - -Replace `` with the project-relative or absolute path to the MusicXML file the user wants rendered. - -To override render settings, pass a partial render config as JSON with `-c`/`--config`: - -```sh -vex render -i --config '{"noteSpacing":40,"showPartLabels":true}' -``` - -The JSON object corresponds to `Partial` from `src/config.ts`; use that file as the source of truth for available options and defaults. - -## Workflow - -1. Identify the MusicXML input path from the user's request or from the repository. -2. Run `vex render -i ` from `vexml`. If the user asks for non-default render settings, include `-c`/`--config ` using option names from `src/config.ts`. -3. Read the command output to find the generated screenshot path. -4. If visual inspection is needed, open or inspect the generated screenshot with available tools. -5. When finished, delete the generated screenshot if it was only meant to be ephemeral. - -## Ephemeral Output - -Treat the generated screenshot as ephemeral when it was created only for quick inspection, debugging, or answering a one-off question. - -Do **not** delete the screenshot when the user explicitly asks to keep it, save it, compare it later, attach it to a report, or use it as a baseline/test artifact. - -When deleting an ephemeral screenshot, remove only the screenshot generated by the `vex render` command you just ran. Do not remove existing fixtures, baselines, or unrelated image files. diff --git a/AGENTS.md b/AGENTS.md index d8f8d2ead..c34bcdadf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,11 @@ After making code changes: -- Run `vex fix` to typecheck, format, and lint the project. -- Run `vex test` to test the project. +- `vex fix` typecheck, format, and lint the project. +- `vex test` test the project. + +MusicXML tools: + +- `vex validate -i ` validate a MusicXML file +- `vex render -i ` render a MusicXML file to a PNG + +Please delete screenshots when you are done, unless you're showing the user something. diff --git a/cli/index.ts b/cli/index.ts index 353031c6f..cc2737bd1 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -4,6 +4,7 @@ import { dev } from './dev'; import { fix } from './fix'; import { render } from './render'; import { test } from './test'; +import { validate } from './validate'; // Where the user actually ran `vex`, before we chdir to the repo root below. const invocationDir = process.cwd(); @@ -66,4 +67,12 @@ program }); }); +program + .command('validate') + .description('validate a musicxml file against the MusicXML XSD with xmllint') + .requiredOption('-i, --input ', 'input musicxml file') + .action(async (opts) => { + await validate({ input: opts.input, cwd: invocationDir }); + }); + program.parse(); diff --git a/cli/validate.ts b/cli/validate.ts new file mode 100644 index 000000000..1e94f2586 --- /dev/null +++ b/cli/validate.ts @@ -0,0 +1,10 @@ +import { isAbsolute, resolve } from 'node:path'; +import { run } from './run'; + +export async function validate(opts: { input: string; cwd: string }) { + // index.ts chdir'd to the repo root, so resolve the user path against their cwd. + const at = isAbsolute(opts.input) + ? opts.input + : resolve(opts.cwd, opts.input); + await run('./xmllint/validate.sh', [at]); +} diff --git a/site/src/App.tsx b/site/src/App.tsx index 9e36716a1..39cba2fee 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -77,6 +77,7 @@ export default function App() { const [dragging, setDragging] = useState(false); const [debouncing, setDebouncing] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); + const [dark, setDark] = useState(false); const [stored, setStored] = useState( () => localStorage.getItem(STORAGE_KEY) !== null, ); @@ -90,9 +91,13 @@ export default function App() { const noteSpacing = config.noteSpacing ?? 36; const softmaxFactor = config.softmaxFactor ?? 10; const systemSpacing = config.systemSpacing ?? 30; + const maxSystemFill = config.maxSystemFill ?? 0.9; + const width = + config.layout?.type === 'standard' ? (config.layout.width ?? 900) : 900; const notationFont = config.fonts?.notation?.family ?? 'Bravura'; - const reset = (key: 'noteSpacing' | 'softmaxFactor' | 'systemSpacing') => - setConfig(({ [key]: _, ...rest }) => rest); + const reset = ( + key: 'noteSpacing' | 'softmaxFactor' | 'systemSpacing' | 'maxSystemFill', + ) => setConfig(({ [key]: _, ...rest }) => rest); // `config` stays live so the sliders/reset respond instantly; `renderConfig` lags // behind it by the debounce so dragging a slider re-renders once it settles, not on @@ -119,10 +124,17 @@ export default function App() { } setError(null); const start = performance.now(); - // Engrave once at the default (8.5in) width; CSS then scales the canvas to fit its - // container — down when narrow, never past 100% when wide — so resizing the window - // re-scales instantly without re-rendering. - render(input, canvas, { ...renderConfig, layout: { type: 'standard' } }) + // Engrave once at the configured reference width; CSS then scales the canvas to fit + // its container — down when narrow, never past 100% when wide — so resizing the + // window re-scales instantly without re-rendering. + const layoutWidth = + renderConfig.layout?.type === 'standard' + ? renderConfig.layout.width + : undefined; + render(input, canvas, { + ...renderConfig, + layout: { type: 'standard', width: layoutWidth }, + }) .then(() => { canvas.style.width = '100%'; canvas.style.height = 'auto'; @@ -360,6 +372,18 @@ export default function App() { Config +
+ +
+ + + setConfig((c) => ({ + ...c, + maxSystemFill: e.target.valueAsNumber, + })) + } + /> +

+ How full a system gets before the next measure wraps to a new + line. Lower leaves more air; 1 packs each line to the edge. +

+
+ +
+ + + setConfig((c) => ({ + ...c, + layout: { + type: 'standard', + width: e.target.valueAsNumber, + }, + })) + } + /> +

+ The width the score is engraved to; the rendering then scales + up or down to fit its container. Wider fits more measures per + system before wrapping. +

+
@@ -542,10 +652,15 @@ export default function App() { ) )} {input != null && ( - // White page capped at 8.5in (US Letter). The canvas is engraved at that width - // and CSS-scaled to fit, shrinking on narrow viewports, never past 100%. -
- + // The canvas is engraved at that width and CSS-scaled to fit, shrinking on narrow viewports, never past 100%. +
+ {/* ponytail: invert the black glyphs to light for dark mode instead of re-engraving in a light color. */} +
)} {debouncing && ( diff --git a/src/chord-diagram.ts b/src/chord-diagram.ts new file mode 100644 index 000000000..ddc284ae8 --- /dev/null +++ b/src/chord-diagram.ts @@ -0,0 +1,365 @@ +import type { RenderContext } from 'vexflow'; + +/* + * A guitar chord-diagram (fret box): strings, frets, finger-position dots, open/muted + * markers, barres, and an optional fret-position label and title. A self-contained + * widget drawn onto a VexFlow RenderContext at a given top-left (x, y) — ported from + * 0xfe/vexchords (chordbox.js), which is unmaintained and pulled in svg.js; here the + * svg.js primitives are swapped for RenderContext calls (the surface the rest of vexml + * draws on). Geometry is faithful to vexchords except dots are centered ON the + * string/fret intersection rather than offset right by their radius (svg.js .move + * positions a bounding-box corner; we mean the center). + * + * ponytail: a single foreground `color` + `bgColor`, not vexchords' six per-element + * color knobs (string/fret/stroke/text/bridge) — score rendering uses one color. Add + * the split palette if a caller needs it. + */ + +export type ChordDiagramOptions = { + /** Overall widget width in px (board is 75% of this, centered). */ + width?: number; + /** Overall widget height in px. */ + height?: number; + /** Finger-dot radius; defaults to board-width / 18. */ + circleRadius?: number; + numStrings?: number; + numFrets?: number; + /** Draw the tuning letters under the board. */ + showTuning?: boolean; + /** Line/dot stroke width. */ + strokeWidth?: number; + /** Foreground (lines, dots, text). */ + color?: string; + /** Background (open-string circle fill, page). */ + bgColor?: string; + fontFamily?: string; + /** Base font size; defaults to board-width / 7. */ + fontSize?: number; +}; + +/** A `[string, fret]` pair: fret `0` = open, `'x'` = muted, else a (relative) fret number. */ +export type ChordNote = [number, number | 'x']; + +export type Barre = { fromString: number; toString: number; fret: number }; + +export type ChordSpec = { + chord: ChordNote[]; + /** Absolute fret of the top displayed fret line; drawn as a label when > 1. */ + position?: number; + positionText?: number; + barres?: Barre[]; + tuning?: string[]; + /** Chord name drawn centered above the board (e.g. "G♯m7♭5"). */ + title?: string; +}; + +const DEFAULT_TUNING = ['E', 'A', 'D', 'G', 'B', 'E']; + +export class ChordDiagram { + private readonly opts: Required< + Omit + > & + Pick; + private readonly numStrings: number; + private readonly numFrets: number; + private readonly width: number; // board width + private readonly spacing: number; // gap between strings + private readonly fretSpacing: number; + private readonly originX: number; // leftmost string x + private readonly originY: number; // top fret-line y (nut) + private readonly circleRadius: number; + private readonly fontSize: number; + private readonly barShiftX: number; + private readonly bridgeWidth: number; + + constructor( + private readonly x: number, + private readonly y: number, + options: ChordDiagramOptions = {}, + ) { + this.opts = { + width: 100, + height: 120, + numStrings: 6, + numFrets: 5, + showTuning: true, + strokeWidth: 1, + color: '#000', + bgColor: '#fff', + fontFamily: 'Arial, sans-serif', + ...options, + }; + + this.numStrings = this.opts.numStrings; + this.numFrets = this.opts.numFrets; + this.width = this.opts.width * 0.75; + this.spacing = this.width / this.numStrings; + // Vertical bands filling `height`: 1.5 fret-rows of headroom for the open/mute + // markers above the nut, numFrets fret rows, then a tuning row when shown plus a + // little bottom pad. The title (when present) is drawn ABOVE y, outside this box, + // so it never shares the marker band. See the `top` getter. + const vRows = this.numFrets + 3 + (this.opts.showTuning ? 1 : 0); + this.fretSpacing = this.opts.height / vRows; + // Inset so the dots on the outer strings have room on either side. + this.originX = x + this.opts.width * 0.15 + this.spacing / 2; + this.originY = y + this.fretSpacing * 1.5; + this.circleRadius = options.circleRadius ?? this.width / 18; + this.fontSize = options.fontSize ?? Math.ceil(this.width / 7); + this.barShiftX = this.width / 28; + this.bridgeWidth = Math.max(2, Math.ceil(this.fretSpacing / 4)); + } + + /** Title font size — readable regardless of how small the box's own labels get. */ + private get titleSize(): number { + return Math.max(this.fontSize, 11); + } + + /** Topmost y the widget draws to (set by draw), so callers can grow the page crop. */ + get top(): number { + return this.drawnTop ?? this.y - this.titleSize * 2; + } + + private drawnTop: number | null = null; + + draw(context: RenderContext, spec: ChordSpec): void { + const chord = spec.chord; + const position = spec.position ?? 0; + const positionText = spec.positionText ?? 0; + const barres = spec.barres ?? []; + const tuning = spec.tuning ?? DEFAULT_TUNING; + const { spacing, fretSpacing, originX, originY } = this; + + // The strings overhang past the last fret line at the bottom — and, when a position + // label is shown (no nut), past the top fret line too — so the neck reads as + // continuing beyond the diagram. + const overhang = fretSpacing * 0.4; + const topOverhang = position > 1 ? overhang : 0; + // A marker (X muted / O open) is drawn for any muted or open string. With none, the + // top band is empty whitespace, so drop the title down next to the board instead of + // floating it above an empty row. + const hasMarkers = chord.some(([, fret]) => fret === 'x' || fret === 0); + + if (spec.title) { + const size = this.titleSize; + const topY = hasMarkers + ? this.y - size * 1.4 // above the box, clearing the marker row + : originY - topOverhang - fretSpacing * 0.5 - size * 0.73; // just above the board + this.titleText( + context, + this.x + this.opts.width / 2, + topY, + spec.title, + size, + ); + this.drawnTop = Math.min(topY, originY - topOverhang); + } else { + this.drawnTop = originY - topOverhang; + } + + // Nut (open position) or fret-position label. + if (position <= 1) { + const w = spacing * (this.numStrings - 1) + this.opts.strokeWidth; + context.save(); + context.setFillStyle(this.opts.color); + context.fillRect( + originX - this.opts.strokeWidth / 2, + originY - this.bridgeWidth, + w, + this.bridgeWidth, + ); + context.restore(); + } else { + this.text( + context, + originX - spacing / 2 - spacing * 0.45, + originY + fretSpacing * positionText, + String(position), + ); + } + + // Strings (vertical) and frets (horizontal); see `overhang` above. + for (let i = 0; i < this.numStrings; i += 1) { + this.line( + context, + originX + spacing * i, + originY - topOverhang, + originX + spacing * i, + originY + fretSpacing * this.numFrets + overhang, + ); + } + for (let i = 0; i < this.numFrets + 1; i += 1) { + this.line( + context, + originX, + originY + fretSpacing * i, + originX + spacing * (this.numStrings - 1), + originY + fretSpacing * i, + ); + } + + if (this.opts.showTuning && tuning.length > 0) { + for (let i = 0; i < Math.min(this.numStrings, tuning.length); i += 1) { + this.text( + context, + originX + spacing * i, + originY + this.numFrets * fretSpacing + fretSpacing / 2, + tuning[i] ?? '', + ); + } + } + + for (const [string, fret] of chord) { + this.lightUp(context, string, fret, position, positionText); + } + for (const barre of barres) { + this.lightBar(context, barre, position, positionText); + } + } + + private lightUp( + context: RenderContext, + string: number, + fret: number | 'x', + position: number, + positionText: number, + ): void { + const stringNum = this.numStrings - string; + const shift = position === 1 && positionText === 1 ? positionText : 0; + const mute = fret === 'x'; + const fretNum = mute ? 0 : (fret as number) - shift; + + const x = this.originX + this.spacing * stringNum; + let y = this.originY + this.fretSpacing * fretNum; + if (fretNum === 0) { + y -= this.bridgeWidth; + } + + if (mute) { + this.text(context, x, y - this.fretSpacing, 'X'); + return; + } + + // Dot centered on the string, in the middle of the fret space. Fretted = filled, + // open (fret 0) = hollow ring. + const cy = y - this.fretSpacing / 2; + context.save(); + context.setLineWidth(this.opts.strokeWidth); + context.setStrokeStyle(this.opts.color); + context.setFillStyle(fretNum > 0 ? this.opts.color : this.opts.bgColor); + context.beginPath(); + context.arc(x, cy, this.circleRadius, 0, Math.PI * 2, false); + context.fill(); + context.stroke(); + context.restore(); + } + + private lightBar( + context: RenderContext, + barre: Barre, + position: number, + positionText: number, + ): void { + let fretNum = barre.fret; + if (position === 1 && positionText === 1) { + fretNum -= positionText; + } + const fromNum = this.numStrings - barre.fromString; + const toNum = this.numStrings - barre.toString; + const x = this.originX + this.spacing * fromNum - this.barShiftX; + const xTo = this.originX + this.spacing * toNum + this.barShiftX; + const y = + this.originY + this.fretSpacing * (fretNum - 1) + this.fretSpacing / 4; + const yTo = + this.originY + + this.fretSpacing * (fretNum - 1) + + (this.fretSpacing / 4) * 3; + // ponytail: square-ended bar. Add rounded caps if it reads poorly at small sizes. + context.save(); + context.setFillStyle(this.opts.color); + context.fillRect(x, y, xTo - x, yTo - y); + context.restore(); + } + + private line( + context: RenderContext, + x1: number, + y1: number, + x2: number, + y2: number, + ): void { + context.save(); + context.setLineWidth(this.opts.strokeWidth); + context.setStrokeStyle(this.opts.color); + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + context.restore(); + } + + // Draw `msg` horizontally centered on `x`, with `topY` the intended top of the text + // (RenderContext.fillText takes a baseline, so add the ascent). + private text( + context: RenderContext, + x: number, + topY: number, + msg: string, + size = this.fontSize, + ): void { + context.save(); + context.setFont(this.opts.fontFamily, size); + context.setFillStyle(this.opts.color); + const w = context.measureText(msg).width; + context.fillText(msg, x - w / 2, topY + size * 0.73); + context.restore(); + } + + // Like `text`, but draws char by char so the ♯/♭/♮ glyphs — which carry wide + // side-bearings in text fonts and would otherwise read as "G ♯ m7 ♭ 5" — are pulled + // tight against their neighbours and rendered a touch smaller, matching the chord + // symbols drawn elsewhere (drawHarmony). + private titleText( + context: RenderContext, + centerX: number, + topY: number, + msg: string, + size: number, + ): void { + const accSize = size - 2; + const kern = size * 0.18; + const chars = [...msg]; + const fontFor = (ch: string) => + context.setFont( + this.opts.fontFamily, + ACCIDENTALS.has(ch) ? accSize : size, + ); + + context.save(); + context.setFillStyle(this.opts.color); + // Measure the kerned advance first so the whole string lands centered on centerX. + let total = 0; + for (const ch of chars) { + fontFor(ch); + total += + context.measureText(ch).width - (ACCIDENTALS.has(ch) ? kern * 2 : 0); + } + const baseline = topY + size * 0.73; + let x = centerX - total / 2; + for (const ch of chars) { + const acc = ACCIDENTALS.has(ch); + fontFor(ch); + if (acc) { + x -= kern; + } + context.fillText(ch, x, baseline); + x += context.measureText(ch).width; + if (acc) { + x -= kern; + } + } + context.restore(); + } +} + +// Accidental glyphs in a chord title; pulled tight against their root letter. +const ACCIDENTALS = new Set(['♯', '♭', '♮']); diff --git a/src/config.ts b/src/config.ts index 9453ad781..113668497 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,17 @@ export type Config = { /** Print the "sl." label on tablature slides (default: false). The slide line always * draws; this only toggles the label above it. */ showTabSlideText: boolean; + /** Fraction (0–1) of the reference width the last system's measures must already fill + * before it is justified to the page edge (default: 0.75). Below it the trailing line + * stays ragged at its natural width; at or above it the line stretches to fill, so a + * nearly-full last system snaps flush instead of leaving an awkward sliver of margin. + * 0 always stretches, 1 never does. */ + minLastSystemFill: number; + /** Fraction (0–1) of the reference width a system may fill before the breaker bumps + * the next measure to a new system (default: 0.9). Lower leaves more air; 1 packs each + * system to the edge (the old greedy behavior). Only affects near-full systems — a line + * whose measures already sit below this fill breaks at the same place either way. */ + maxSystemFill: number; }; /** Default fonts: bundled Bravura for notation, Source Sans 3 for text. Families only — @@ -61,4 +72,6 @@ export const DEFAULT_CONFIG: Config = { measureNumbering: 'system', showTabHammerPullText: false, showTabSlideText: false, + minLastSystemFill: 0.75, + maxSystemFill: 0.9, }; diff --git a/src/constants.ts b/src/constants.ts index 28f8a7ae0..944bcdae6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,12 +1,11 @@ // Tunable magic numbers, centralized. Spacing/margins are px at the reference layout // width; the finished result is then scaled to its container. -/** Default standard-layout width: US Letter portrait (8.5in) at 96dpi = 816px. Also - * the panoramic mode's starting width / page-width floor. */ -export const LETTER_WIDTH = 8.5 * 96; +/** Default standard-layout width. */ +export const DEFAULT_WIDTH = 900; /** Left/right page margin. Leaves room for the brace/bracket drawn left of the stave. */ -export const PAGE_MARGIN_X = 30; +export const PAGE_MARGIN_X = 15; /** Top margin: the first system's y. */ export const PAGE_MARGIN_TOP = 40; @@ -18,6 +17,12 @@ export const PAGE_MARGIN_TOP_WITH_TEMPO = 70; /** Bottom whitespace kept below the lowest drawn content. */ export const PAGE_MARGIN_BOTTOM = 40; +/** Pedal "Ped…*" text / bracket draws at the stave's bottom-text line 3 (vexflow's + * PedalMarking adds 3 to its line=0). The crop grows to this line plus the glyph + * descent below so the mark isn't clipped. */ +export const PEDAL_BOTTOM_TEXT_LINE = 3; +export const PEDAL_BOTTOM_MARGIN = 12; + /** Vertical gap between stacked systems, plus room for the next system's notes that * rise above its top staff. The default for `Config.systemSpacing`. */ export const SYSTEM_GAP = 30; @@ -80,6 +85,15 @@ export const TAB_MIN_NOTE_SPACING = 32; /** Bump tab fret digits bolder/larger than vexflow's thin default (1.258 * 9pt ≈ 15px). */ export const TAB_FRET_SCALE = 1.258; +/** Lead clearance (px) before a notation grace cluster: padded onto the RIGHT of the note + * that precedes the grace's host, so the host and its attached grace shift right together + * and the grace gets breathing room from that note while staying snug to its host. The + * measure's width allocation grows by this too (see graceWidthOf) so the clearance is real + * space, not stolen from the bar's other notes. Skipped when the preceding note carries + * augmentation dots — vexflow draws those after the displaced-head gap, so padding it would + * fling the dots out to the right. */ +export const GRACE_SPACING = 16; + /** Grace tab frets, as a fraction of the (enlarged) main-note size. */ export const TAB_GRACE_SCALE = 2 / 3; @@ -107,6 +121,21 @@ export const HARMONY_Y_OFFSET = 14; * sits over, so the symbol lifts clear instead of colliding with the notehead. */ export const HARMONY_NOTE_CLEARANCE = 8; +/** How far a single note's tie ribbon peaks above the notehead center when it bows + * upward (stem-down note). Vexflow draws the tie as a bezier whose outer edge clears + * the notehead by its yShift (7) plus the deeper control-point excursion (cp2 12) — + * ~13px. A chord symbol over a tied note lifts past this so it doesn't touch the arc. */ +export const TIE_APEX_RISE = 13; + +/** Chord-diagram (fret box from a ``) overall width/height, drawn + * above the stave at the lead note's x. Smaller than vexchords' 100×120 default so the + * box reads as an inline annotation over the music. */ +export const CHORD_DIAGRAM_WIDTH = 88; +export const CHORD_DIAGRAM_HEIGHT = 84; + +/** Gap kept between the bottom of a chord diagram and the top staff line. */ +export const CHORD_DIAGRAM_GAP = 6; + /** Words-direction (e.g. "ritardando") text size — matches the chord-symbol size so * both read as annotations above the notes. */ export const WORDS_FONT_SIZE = 13; diff --git a/src/draw.ts b/src/draw.ts index e885c3723..c0ebfc32f 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -9,20 +9,27 @@ import { Barline, Bend, Formatter, + GraceNoteGroup, + Modifier, type RenderContext, Renderer, Stave, StaveConnector, type StaveNote, StaveTempo, + Stem, type TabNote, TabStave, Vibrato, - Voice, + type Voice, } from 'vexflow'; +import { ChordDiagram, type ChordSpec } from './chord-diagram'; import type { Config } from './config'; import { BRACKET_X_SHIFT, + CHORD_DIAGRAM_GAP, + CHORD_DIAGRAM_HEIGHT, + CHORD_DIAGRAM_WIDTH, HARMONY_FONT_SIZE, HARMONY_NOTE_CLEARANCE, HARMONY_Y_OFFSET, @@ -31,8 +38,11 @@ import { LEDGER_HEADROOM, PAGE_MARGIN_BOTTOM, PAGE_MARGIN_TOP, + PEDAL_BOTTOM_MARGIN, + PEDAL_BOTTOM_TEXT_LINE, TEMPO_NOTE_CLEARANCE, TEMPO_SCALE, + TIE_APEX_RISE, WORDS_FONT_SIZE, WORDS_NOTE_CLEARANCE, WORDS_Y_OFFSET, @@ -40,9 +50,13 @@ import { import type { MeasureNumbering, ScoreLayout } from './layout'; import { endBeatOf, + findModifier, getNoteheadHalfWidth, harmoniesOf, meterBeats, + type PedalMark, + pedalsOf, + softVoice, staffVoices, type TempoMark, tempoOf, @@ -54,6 +68,7 @@ import { import { buildBeams, buildHammerPulls, + buildPedals, buildSlides, buildSlurs, buildTies, @@ -61,9 +76,11 @@ import { groupBeams, } from './spanners'; -// MusicXML