@barmark/render 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ package uses [semantic versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-11-03
10
+
11
+ ### Added
12
+
13
+ - **Rehearsal marks** draw as a boxed label at the start of their bar
14
+ (`part="mark"`), spoken "rehearsal B". Bar numbers skip them.
15
+ - **Words on a bar** draw small, above the slot they precede, or at the
16
+ bar's end (`part="words"`), and are spoken in place: "bar 1: “solo” D,
17
+ A".
18
+ - **Accents and hits** draw `>` or `^` above the chord
19
+ (`data-articulation`), spoken "accented D" and "D short hit".
20
+ - **Pushes** draw `‹` (an eighth) or `«` (a sixteenth) before the slot
21
+ (`data-push`), spoken "D pushed" and "D pushed a sixteenth".
22
+ - **Omissions** are spoken "C seven no third".
23
+ - **Voicings** draw the chord's name; a screen reader hears the notes:
24
+ "C major seven voiced E3 G3 B3 C4".
25
+
9
26
  ## [0.8.0] - 2026-11-03
10
27
 
11
28
  Requires `barmark` 0.11.0.
@@ -239,7 +256,8 @@ that needs no script or as the `<barmark-chart>` web component. Requires
239
256
  zoom without sideways scrolling, the element renders and re-renders, and
240
257
  a screen reader hears "bar 9: D". Run them with `bun run test:browser`.
241
258
 
242
- [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.8.0...HEAD
259
+ [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.9.0...HEAD
260
+ [0.9.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.8.0...render-v0.9.0
243
261
  [0.8.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.7.3...render-v0.8.0
244
262
  [0.7.3]: https://github.com/kevinpeckham/barmark/compare/render-v0.7.2...render-v0.7.3
245
263
  [0.7.2]: https://github.com/kevinpeckham/barmark/compare/render-v0.7.1...render-v0.7.2
package/dist/html.js CHANGED
@@ -97,13 +97,20 @@ function fitWidth(block) {
97
97
  return FIT_STEPS.find((step) => step >= total) ?? FIT_STEPS.at(-1) ?? 0;
98
98
  }
99
99
  /** Em per bar: padding, plus each slot's text and the gap after it. */
100
- const cellWidth = (cell) => cell.kind === "direction"
101
- ? 1 + 0.55 * printDirection(cell.direction).length
102
- : cell.kind === "fermata"
103
- ? MIN_BAR
104
- : Math.max(MIN_BAR, 1.5 + (cell.meter ? 0.8 : 0) + cell.slots.reduce((w, s) => w + slotWidth(s), 0));
105
- /** Roughly: 0.65em a baseline character, 0.4em a superscript one, 0.6em between slots. */
100
+ function cellWidth(cell) {
101
+ if (cell.kind === "direction")
102
+ return 1 + 0.55 * printDirection(cell.direction).length;
103
+ if (cell.kind === "rehearsal")
104
+ return 1.4 + 0.6 * cell.label.length;
105
+ if (cell.kind === "fermata")
106
+ return MIN_BAR;
107
+ return Math.max(MIN_BAR, 1.5 + (cell.meter ? 0.8 : 0) + cell.slots.reduce((w, s) => w + slotWidth(s), 0));
108
+ }
109
+ /** Roughly: 0.65em a baseline character, 0.4em a superscript one, 0.6em between slots, 0.45em a push mark. */
106
110
  function slotWidth(slot) {
111
+ return ("push" in slot && slot.push ? 0.45 : 0) + unmarkedWidth(slot);
112
+ }
113
+ function unmarkedWidth(slot) {
107
114
  if (slot.kind === "group")
108
115
  return slot.members.reduce((w, m) => w + slotWidth(m), 0.2);
109
116
  if (slot.kind !== "chord")
@@ -198,12 +205,36 @@ function cellHtml(cell, barNumber, marks, numerals) {
198
205
  : "";
199
206
  const cut = cell.cut ? `<span class="bm-cut">cut</span>` : "";
200
207
  const change = cell.tempoChange ? ` data-tempo-change="${cell.tempoChange}"` : "";
201
- return `<div class="bm-bar" part="bar"${cell.cut ? " data-cut" : ""}${change}${marks}>${spoken}<span class="bm-bar-body" aria-hidden="true">${meter}${cell.slots.map((s) => slotHtml(s, view)).join("")}${cut}</span></div>`;
208
+ const [slots, end] = slotsWithWords(cell, view);
209
+ return `<div class="bm-bar" part="bar"${cell.cut ? " data-cut" : ""}${change}${marks}>${spoken}<span class="bm-bar-body" aria-hidden="true">${meter}${slots.join("")}${cut}</span>${end}</div>`;
210
+ }
211
+ /** A bar's slots, each under any words before it, and the words after the last slot. */
212
+ function slotsWithWords(cell, view) {
213
+ const words = (at) => (cell.words ?? [])
214
+ .filter((w) => w.at === at)
215
+ .map((w) => escapeHtml(w.text))
216
+ .join(" ");
217
+ const slots = cell.slots.map((s, i) => {
218
+ const said = words(i);
219
+ const html = slotHtml(s, view);
220
+ return said
221
+ ? `<span class="bm-worded"><span class="bm-words" part="words">${said}</span>${html}</span>`
222
+ : html;
223
+ });
224
+ const after = words(cell.slots.length);
225
+ return [
226
+ slots,
227
+ after
228
+ ? `<span class="bm-words bm-words-end" part="words" aria-hidden="true">${after}</span>`
229
+ : "",
230
+ ];
202
231
  }
203
- /** A fermata or a direction: a place between bars, drawn as its sign. */
232
+ /** A fermata, a rehearsal mark or a direction: a place between bars, drawn as its sign. */
204
233
  function betweenBarsHtml(cell, spoken, marks) {
205
234
  if (cell.kind === "fermata")
206
235
  return `<div class="bm-bar bm-fermata" part="bar"${marks}>${spoken}<span aria-hidden="true">${FERMATA}</span></div>`;
236
+ if (cell.kind === "rehearsal")
237
+ return `<div class="bm-bar bm-mark" part="mark"${marks}>${spoken}<span class="bm-box" aria-hidden="true">${escapeHtml(cell.label)}</span></div>`;
207
238
  return `<div class="bm-bar bm-direction" part="direction" data-direction="${cell.direction.kind}"${marks}>${spoken}<span aria-hidden="true">${directionGlyph(cell.direction)}</span></div>`;
208
239
  }
209
240
  /** Segno and coda as the score's signs; To Coda and the jumps as their words. */
@@ -218,7 +249,26 @@ function directionGlyph(d) {
218
249
  const SEGNO = `<svg class="bm-sign" viewBox="0 0 20 28" width="0.9em" height="1.25em"><path d="M14.5 6.5c-1-3-6.5-3.5-7.5-.5s3 4.5 4.5 5.5 5 3.5 3 7-7.5 2-8-1" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M4 24 16 3" stroke="currentColor" stroke-width="1.6"/><circle cx="4.5" cy="13" r="1.6" fill="currentColor"/><circle cx="15.5" cy="15" r="1.6" fill="currentColor"/></svg>`;
219
250
  const CODA = `<svg class="bm-sign" viewBox="0 0 24 24" width="1em" height="1em"><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 1v22M1 12h22" stroke="currentColor" stroke-width="1.6"/></svg>`;
220
251
  const FERMATA = `<svg class="bm-fermata-glyph" viewBox="0 0 24 14" width="1.4em" height="0.8em"><path d="M2 13 A10 10 0 0 1 22 13" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="11" r="1.8" fill="currentColor"/></svg>`;
252
+ /** `‹` for an eighth push, `«` for a sixteenth. */
253
+ const PUSH_MARK = { eighth: "‹", sixteenth: "«" };
254
+ const ARTICULATION_MARK = { accent: ">", hit: "^" };
255
+ const marksOf = (slot) => ({
256
+ push: "push" in slot ? (slot.push ?? null) : null,
257
+ articulation: slot.kind === "chord" ? (slot.articulation ?? null) : null,
258
+ });
259
+ /** A slot with its push mark before it and its accent or hit above it. */
221
260
  function slotHtml(slot, view) {
261
+ const body = unmarkedSlotHtml(slot, view);
262
+ const { push, articulation } = marksOf(slot);
263
+ if (!push && !articulation)
264
+ return body;
265
+ const data = `${push ? ` data-push="${push}"` : ""}${articulation ? ` data-articulation="${articulation}"` : ""}`;
266
+ const above = articulation
267
+ ? `<span class="bm-artic">${ARTICULATION_MARK[articulation]}</span>`
268
+ : "";
269
+ return `<span class="bm-marked"${data}>${push ? `<span class="bm-push" aria-hidden="true">${PUSH_MARK[push]}</span>` : ""}${above}${body}</span>`;
270
+ }
271
+ function unmarkedSlotHtml(slot, view) {
222
272
  switch (slot.kind) {
223
273
  case "chord":
224
274
  return chordHtml(slot.chord, view);
package/dist/model.js CHANGED
@@ -220,7 +220,7 @@ function layoutRow(l, played, previous, ctx) {
220
220
  let prev = previous;
221
221
  for (const cell of l.cells) {
222
222
  const model = layoutCell(cell, played[bar] ?? written[bar], prev);
223
- if (cell.kind !== "fermata" && cell.kind !== "direction")
223
+ if (cell.kind === "slots" || cell.kind === "simile")
224
224
  bar++;
225
225
  if (model.kind === "slots")
226
226
  prev = model;
@@ -242,16 +242,23 @@ function layoutRow(l, played, previous, ctx) {
242
242
  function layoutCell(cell, played, previous) {
243
243
  if (cell.kind === "fermata")
244
244
  return { kind: "fermata" };
245
- if (cell.kind === "direction")
245
+ if (cell.kind === "direction" || cell.kind === "rehearsal")
246
246
  return cell;
247
- const from = cell.kind === "slots" ? cell : previous;
248
- return withPlayed({
247
+ return withPlayed(layoutBar(cell, played, previous), played);
248
+ }
249
+ /** A bar or `%`, with the slots it plays. */
250
+ function layoutBar(cell, played, previous) {
251
+ const own = cell.kind === "slots" ? cell : null;
252
+ return {
249
253
  kind: "slots",
250
- meter: from?.meter ?? null,
251
- slots: played?.slots ?? (cell.kind === "slots" ? cell.slots : []),
254
+ meter: (own ?? previous)?.meter ?? null,
255
+ slots: played?.slots ?? own?.slots ?? [],
252
256
  cut: false,
253
- }, played);
257
+ ...wordsOf(cell),
258
+ };
254
259
  }
260
+ /** The words written on a bar, as a field to spread; nothing when it has none. */
261
+ const wordsOf = (cell) => cell.words ? { words: cell.words } : {};
255
262
  /** What the parser says about the bar as played: inside a rit. or accel., and its key. */
256
263
  function withPlayed(cell, played) {
257
264
  const change = played?.tempoChange?.kind;
package/dist/speech.js CHANGED
@@ -60,7 +60,10 @@ function speakPitch(label) {
60
60
  return `${m[1]}${ACCIDENTAL[m[2] ?? ""] ?? ""}${octave}`;
61
61
  }
62
62
  /** `b9` → "flat nine"; a natural tone is added: `9` → "add nine". */
63
+ const LEFT_OUT = { no3: "no third", no5: "no fifth" };
63
64
  function speakTone(tone) {
65
+ if (LEFT_OUT[tone])
66
+ return LEFT_OUT[tone];
64
67
  const m = /^(#|b)?(\d+)$/.exec(tone);
65
68
  if (!m)
66
69
  return tone;
@@ -102,10 +105,23 @@ export function speakChord(record, key) {
102
105
  const degree = key ? speakDegree(record.parts.root, key) : "";
103
106
  return `${degree ? `${degree}, ` : ""}${speakParts(record.parts)}`;
104
107
  }
108
+ const PUSHED = { eighth: "pushed", sixteenth: "pushed a sixteenth" };
109
+ /** A chord with its voicing and articulation: "accented D", "G short hit", "C major seven voiced E3 G3". */
110
+ function speakChordSlot(slot, key) {
111
+ const name = speakChord(slot.chord, key);
112
+ const voiced = slot.voicing ? `${name} voiced ${slot.voicing.pitches.join(" ")}` : name;
113
+ if (slot.articulation === "accent")
114
+ return `accented ${voiced}`;
115
+ return slot.articulation === "hit" ? `${voiced} short hit` : voiced;
116
+ }
105
117
  export function speakSlot(slot, key) {
118
+ const said = speakUnpushed(slot, key);
119
+ return "push" in slot && slot.push ? `${said} ${PUSHED[slot.push]}` : said;
120
+ }
121
+ function speakUnpushed(slot, key) {
106
122
  switch (slot.kind) {
107
123
  case "chord":
108
- return speakChord(slot.chord, key);
124
+ return speakChordSlot(slot, key);
109
125
  case "hold":
110
126
  return "hold";
111
127
  case "nc":
@@ -130,7 +146,12 @@ export function speakBar(barNumber, cell, numerals = false) {
130
146
  return "fermata: hold until cued";
131
147
  if (cell.kind === "direction")
132
148
  return speakDirection(cell.direction);
149
+ if (cell.kind === "rehearsal")
150
+ return `rehearsal ${cell.label}`;
133
151
  const meter = cell.meter ? `, in ${cell.meter.n}/${cell.meter.d}` : "";
134
152
  const cut = cell.cut ? ", cut on this pass" : "";
135
- return `bar${barNumber === null ? "" : ` ${barNumber}`}${meter}: ${cell.slots.map((s) => speakSlot(s, numerals ? cell.key : undefined)).join(", ")}${cut}`;
153
+ const words = (at) => (cell.words ?? []).filter((w) => w.at === at).map((w) => `“${w.text}”`);
154
+ const slots = cell.slots.map((s, i) => [...words(i), speakSlot(s, numerals ? cell.key : undefined)].join(" "));
155
+ const spoken = [...slots, ...words(cell.slots.length)].join(", ");
156
+ return `bar${barNumber === null ? "" : ` ${barNumber}`}${meter}: ${spoken}${cut}`;
136
157
  }
package/dist/styles.js CHANGED
@@ -119,6 +119,15 @@ const css = `
119
119
  .bm-row>div.bm-direction{min-width:0;padding-inline:.35em;border-right:none;align-items:flex-start;color:var(--bm-ink)}
120
120
  .bm-sign{display:inline-block;vertical-align:middle}
121
121
  .bm-direction-words{font:italic ${em(SIZES.repeat)}/1.2 Georgia,serif;white-space:nowrap}
122
+ .bm-row>div.bm-mark{min-width:0;padding-inline:.3em;border-right:none;align-items:flex-start}
123
+ .bm-box{display:inline-block;padding:.05em .3em;border:.08em solid var(--bm-ink);border-radius:.15em;font:700 .85em/1.1 system-ui,sans-serif;color:var(--bm-ink)}
124
+ .bm-worded{position:relative}
125
+ .bm-marked{position:relative;display:inline-flex;align-items:baseline}
126
+ .bm-push{font:700 .8em/1 system-ui,sans-serif;color:var(--bm-muted);margin-right:.08em}
127
+ .bm-artic{position:absolute;left:50%;top:-.95em;transform:translateX(-50%);font:700 .7em/1 system-ui,sans-serif;color:var(--bm-ink)}
128
+ .bm-marked[data-push]>.bm-artic{left:calc(50% + .25em)}
129
+ .bm-words{position:absolute;left:0;top:-1.05em;font:italic .62em/1 Georgia,serif;color:var(--bm-muted);white-space:nowrap}
130
+ .bm-bar>.bm-words-end{left:auto;right:.5em;top:.15em}
122
131
  .bm-bar[data-ending]::after{content:attr(data-ending);position:absolute;left:.45em;top:.2em;font:italic .75em/1 Georgia,serif;color:var(--bm-muted)}
123
132
  .bm-ending>.bm-bar{border-top:.06em solid var(--bm-muted)}
124
133
  .bm-ending>.bm-bar[data-ending]{box-shadow:inset .06em 0 var(--bm-muted)}
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChartDiagnostic, Direction, Directives, Key, Meter, Repeat, Slot } from "barmark";
1
+ import type { ChartDiagnostic, Direction, Directives, Key, Meter, Repeat, Slot, Words } from "barmark";
2
2
  export type LayoutOptions = {
3
3
  rehearsalLetters?: boolean;
4
4
  };
@@ -57,6 +57,8 @@ export type CellModel = {
57
57
  tempoChange?: "rit" | "accel";
58
58
  /** The key the bar is played in, when the chart names one. */
59
59
  key?: Key;
60
+ /** Words written on the bar (barmark's grammar 2.6), each before slot `at`. */
61
+ words?: Words[];
60
62
  } | {
61
63
  kind: "fermata";
62
64
  }
@@ -64,4 +66,9 @@ export type CellModel = {
64
66
  | {
65
67
  kind: "direction";
66
68
  direction: Direction;
69
+ }
70
+ /** A rehearsal mark on the bar after it (barmark's grammar 2.5). */
71
+ | {
72
+ kind: "rehearsal";
73
+ label: string;
67
74
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barmark/render",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Render barmark chord charts as a clean, accessible bar grid: HTML with Declarative Shadow DOM, or the <barmark-chart> web component.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,7 +47,7 @@
47
47
  "url": "https://github.com/kevinpeckham/barmark/issues"
48
48
  },
49
49
  "dependencies": {
50
- "barmark": "^0.11.0"
50
+ "barmark": "^0.12.0"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "rm -rf dist && tsc -p tsconfig.build.json",