@barmark/render 0.2.1 → 0.4.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,47 @@ package uses [semantic versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-11-01
10
+
11
+ Chords as Nashville numbers or Roman numerals, and a little air between
12
+ rows. Depends on `barmark` ^0.6.0, for `degreeOf`.
13
+
14
+ ### Added
15
+
16
+ - **A numerals view**: `numerals: "nashville" | "roman"` (the `numerals`
17
+ attribute and property on `<barmark-chart>`). Each chord shows as its
18
+ degree in its bar's key, counted from the tonic against the major
19
+ scale (in A minor, C is ♭3). Nashville keeps the chord's quality and
20
+ superscript (Bm7 in D is 6m⁷, D/F♯ is 1/3); Roman shows minor and
21
+ diminished in lower case (vi⁷, vii°, ♭VII). Bars with no key, note
22
+ sets, N.C., `?` and `--` are unchanged. Screen readers hear the
23
+ numeral and the chord: "bar 5: five, A seven". Uses barmark 0.6.0's
24
+ `degreeOf`; each bar in the model now carries its `key`.
25
+
26
+ ### Changed
27
+
28
+ - **A small gap between rows** (0.35em), so the bar lines of one row no
29
+ longer run into the next.
30
+
31
+ ### Changed
32
+
33
+ - A little space (0.35 of the chord size) between consecutive bar rows,
34
+ and between bars that wrap onto another line on a narrow screen, so
35
+ one row's bar lines no longer run into the next row's.
36
+
37
+ ## [0.3.0] - 2026-11-01
38
+
39
+ Draws gradual tempo changes. Depends on `barmark` ^0.5.0, which reads
40
+ them.
41
+
42
+ ### Added
43
+
44
+ - Gradual tempo changes. A `tempo: rit. to 90` line reads
45
+ "→ Tempo rit. to ♩ = 90" (`a tempo` reads as itself), and every bar
46
+ played inside a rit. or accel. has a dashed line along its top, in
47
+ the directive colour, with `data-tempo-change="rit"` or `"accel"`.
48
+ Uses barmark 0.5.0's `tempoChange`.
49
+
9
50
  ## [0.2.1] - 2026-11-01
10
51
 
11
52
  Two fixes found while moving barmark.dev to the element. Depends on
@@ -117,7 +158,9 @@ that needs no script or as the `<barmark-chart>` web component. Requires
117
158
  zoom without sideways scrolling, the element renders and re-renders, and
118
159
  a screen reader hears "bar 9: D". Run them with `bun run test:browser`.
119
160
 
120
- [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.2.1...HEAD
161
+ [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.4.0...HEAD
162
+ [0.4.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.3.0...render-v0.4.0
163
+ [0.3.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.2.1...render-v0.3.0
121
164
  [0.2.1]: https://github.com/kevinpeckham/barmark/compare/render-v0.2.0...render-v0.2.1
122
165
  [0.2.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.1.0...render-v0.2.0
123
166
  [0.1.0]: https://github.com/kevinpeckham/barmark/releases/tag/render-v0.1.0
package/README.md CHANGED
@@ -75,10 +75,23 @@ the element loads, with `<style data-barmark-font>` holding
75
75
  | `theme` | `theme` (`theme`) | `"auto"` | `"auto"` follows the system; `"light"` or `"dark"` force one |
76
76
  | `barNumbers` | `bar-numbers="off"` (`barNumbers`) | `true` | bar numbers at the start of each row |
77
77
  | `rehearsalLetters` | `rehearsal-letters` (`rehearsalLetters`) | `false` | boxed A, B, C… on each section as played |
78
+ | `numerals` | `numerals` (`numerals`) | none | `"nashville"` (6m⁷) or `"roman"` (vi⁷): each chord as its degree in its bar's key |
78
79
  | `diagnostics` | | `false` | list the chart's errors and warnings below it |
79
80
  | `fontUrl` | | none | emit Edwin's `@font-face` rule before the chart |
80
81
  | (the `text` argument) | (`text`) | `null` | the chart to draw; wins over `src` and the script child |
81
82
 
83
+ ### Numerals
84
+
85
+ With `numerals`, each chord shows as its degree in the key its bar is
86
+ played in, so a modulation renumbers from its next bar. Degrees count
87
+ from the key's tonic against the major scale, in every mode: in `key:
88
+ Am`, Am is 1m and C is ♭3. Nashville keeps the chord's quality and
89
+ superscript after the number (Bm7 in D is 6m⁷, D/F♯ is 1/3); Roman
90
+ shows minor and diminished in lower case (ii, vii°, ♭VII). A bar with
91
+ no key, note sets, N.C., `?` and `--` look as they always do, and a
92
+ screen reader hears both: "bar 5: five, A seven". Numbered chords carry
93
+ `data-numeral`. It's a view: the music and bar numbers don't change.
94
+
82
95
  ## Styling on purpose
83
96
 
84
97
  Set these custom properties on the element or anything above it:
package/dist/chord.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Chords and notes as display pieces: ♯ and ♭ glyphs, the suffix split for superscript. */
2
- import type { ChordParts } from "barmark";
2
+ import { type ChordParts, type Key } from "barmark";
3
3
  export type ChordPieces = {
4
4
  root: string;
5
5
  /** m, dim, aug or 5: set on the baseline. */
@@ -11,6 +11,15 @@ export type ChordPieces = {
11
11
  /** `F#` → `F♯`, `b9` → `♭9`, `Bbb` → `B𝄫`. */
12
12
  export declare function glyphs(text: string): string;
13
13
  export declare function typesetChord(parts: ChordParts): ChordPieces;
14
+ /** How chords read in a key: Nashville numbers (6m⁷) or Roman numerals (vi⁷). */
15
+ export type Numerals = "nashville" | "roman";
16
+ export declare const NUMERALS: readonly Numerals[];
17
+ /**
18
+ * A chord as its degree in `key`: Nashville keeps the chord's quality and
19
+ * superscript after the number; Roman shows minor and diminished in lower
20
+ * case. A slash bass is a number in both. Null if the root has no degree.
21
+ */
22
+ export declare function typesetNumeral(parts: ChordParts, key: Key, style: Numerals): ChordPieces | null;
14
23
  /** A note set's label (`E1`, `F#`) as name and octave. */
15
24
  export declare function splitPitch(label: string): {
16
25
  name: string;
package/dist/chord.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** Chords and notes as display pieces: ♯ and ♭ glyphs, the suffix split for superscript. */
2
+ import { degreeOf } from "barmark";
1
3
  const ACCIDENTALS = [
2
4
  [/##/g, "𝄪"],
3
5
  [/bb(?=\d|$|\))/g, "𝄫"],
@@ -17,6 +19,41 @@ export function typesetChord(parts) {
17
19
  bass: parts.bass ? glyphs(parts.bass) : null,
18
20
  };
19
21
  }
22
+ export const NUMERALS = ["nashville", "roman"];
23
+ const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII"];
24
+ /** In Roman numerals case carries minor, so only diminished and augmented keep a mark. */
25
+ const ROMAN_QUALITY = {
26
+ "": "",
27
+ m: "",
28
+ dim: "°",
29
+ aug: "+",
30
+ "5": "5",
31
+ };
32
+ const accidentals = (alter) => (alter < 0 ? "b" : "#").repeat(Math.abs(alter));
33
+ /** `{ degree: 7, alter: -1 }` → `♭7`. */
34
+ const degreeText = (d) => glyphs(`${accidentals(d.alter)}${d.degree}`);
35
+ /**
36
+ * A chord as its degree in `key`: Nashville keeps the chord's quality and
37
+ * superscript after the number; Roman shows minor and diminished in lower
38
+ * case. A slash bass is a number in both. Null if the root has no degree.
39
+ */
40
+ export function typesetNumeral(parts, key, style) {
41
+ const degree = degreeOf(parts.root, key);
42
+ if (!degree)
43
+ return null;
44
+ const pieces = typesetChord(parts);
45
+ const bass = parts.bass ? degreeOf(parts.bass, key) : null;
46
+ const withBass = { ...pieces, bass: bass ? degreeText(bass) : pieces.bass };
47
+ if (style === "nashville")
48
+ return { ...withBass, root: degreeText(degree) };
49
+ const numeral = ROMAN[degree.degree - 1] ?? "";
50
+ const lower = parts.quality === "m" || parts.quality === "dim";
51
+ return {
52
+ ...withBass,
53
+ root: glyphs(accidentals(degree.alter)) + (lower ? numeral.toLowerCase() : numeral),
54
+ quality: ROMAN_QUALITY[parts.quality],
55
+ };
56
+ }
20
57
  /** A note set's label (`E1`, `F#`) as name and octave. */
21
58
  export function splitPitch(label) {
22
59
  const m = /^([A-G](?:##|bb|#|b)?)(-?\d+)?$/.exec(label);
package/dist/element.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * `<barmark-chart>`: renders itself from its `text` property, a `src` URL
3
+ * or a child `<script type="text/barmark">`, and again whenever one
4
+ * changes. A shadow root rendered on the server is reused. Importing this
5
+ * module registers the element (once) and the font (once per document).
6
+ */
1
7
  import { type RenderOptions } from "./html.js";
2
8
  type Attributes = {
3
9
  getAttribute(name: string): string | null;
package/dist/element.js CHANGED
@@ -4,14 +4,17 @@
4
4
  * changes. A shadow root rendered on the server is reused. Importing this
5
5
  * module registers the element (once) and the font (once per document).
6
6
  */
7
+ import { NUMERALS } from "./chord.js";
7
8
  import { FONT_FAMILY, fontFaceCss } from "./font.js";
8
9
  import { loadErrorContent, shadowContent, THEMES } from "./html.js";
9
10
  export function optionsFromAttributes(el) {
10
11
  const theme = el.getAttribute("theme");
12
+ const numerals = NUMERALS.find((n) => n === el.getAttribute("numerals"));
11
13
  return {
12
14
  theme: THEMES.find((t) => t === theme) ?? "auto",
13
15
  barNumbers: el.getAttribute("bar-numbers") !== "off",
14
16
  rehearsalLetters: el.hasAttribute("rehearsal-letters"),
17
+ ...(numerals ? { numerals } : {}),
15
18
  };
16
19
  }
17
20
  /** What a chart draws: its `text` while that is a string, else `src`, else its script child. */
@@ -45,7 +48,7 @@ async function fetchText(src) {
45
48
  }
46
49
  function defineElement() {
47
50
  class BarmarkChart extends HTMLElement {
48
- static observedAttributes = ["src", "theme", "bar-numbers", "rehearsal-letters"];
51
+ static observedAttributes = ["src", "theme", "bar-numbers", "rehearsal-letters", "numerals"];
49
52
  #observer = new MutationObserver(() => this.#schedule());
50
53
  #renders = 0;
51
54
  #scheduled = false;
@@ -106,6 +109,16 @@ function defineElement() {
106
109
  set rehearsalLetters(v) {
107
110
  this.toggleAttribute("rehearsal-letters", v);
108
111
  }
112
+ /** "nashville", "roman", or null for chord names. */
113
+ get numerals() {
114
+ return optionsFromAttributes(this).numerals ?? null;
115
+ }
116
+ set numerals(v) {
117
+ if (v === null)
118
+ this.removeAttribute("numerals");
119
+ else
120
+ this.setAttribute("numerals", v);
121
+ }
109
122
  /** Every change in one task (an upgrade sets each attribute in turn) renders once. */
110
123
  #schedule() {
111
124
  if (this.#scheduled)
package/dist/html.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Numerals } from "./chord.js";
1
2
  export type RenderOptions = {
2
3
  theme?: "auto" | "light" | "dark";
3
4
  barNumbers?: boolean;
@@ -6,6 +7,8 @@ export type RenderOptions = {
6
7
  diagnostics?: boolean;
7
8
  /** Where the page serves Edwin-Roman.otf: emits its @font-face rule before the chart. */
8
9
  fontUrl?: string;
10
+ /** Show each chord as its degree in the bar's key, where the chart names one. Off by default. */
11
+ numerals?: Numerals;
9
12
  };
10
13
  export declare function renderChart(text: string, options?: RenderOptions): string;
11
14
  export declare const THEMES: readonly ["auto", "light", "dark"];
package/dist/html.js CHANGED
@@ -1,4 +1,4 @@
1
- import { glyphs, splitPitch, typesetChord } from "./chord.js";
1
+ import { glyphs, NUMERALS, splitPitch, typesetChord, typesetNumeral, } from "./chord.js";
2
2
  import { escapeHtml } from "./escape.js";
3
3
  import { fontFaceCss } from "./font.js";
4
4
  import { layoutChart } from "./model.js";
@@ -14,11 +14,13 @@ function elementAttributes(options) {
14
14
  theme === "auto" ? "" : ` theme="${theme}"`,
15
15
  options.barNumbers === false ? ' bar-numbers="off"' : "",
16
16
  options.rehearsalLetters ? " rehearsal-letters" : "",
17
+ numeralsOf(options) ? ` numerals="${numeralsOf(options)}"` : "",
17
18
  ].join("");
18
19
  }
19
20
  export const THEMES = ["auto", "light", "dark"];
20
21
  /** Only a known theme reaches the markup: hosts may pass any string through. */
21
22
  const themeOf = (options) => THEMES.find((t) => t === options.theme) ?? "auto";
23
+ const numeralsOf = (options) => NUMERALS.find((n) => n === options.numerals) ?? null;
22
24
  /** What goes in the shadow root: the styles and the chart. */
23
25
  export const shadowContent = (text, options = {}) => `<style>${stylesheet}</style>${chartHtml(text, options)}`;
24
26
  /** A chart that could not be loaded: the message, shown where the chart would be. */
@@ -29,7 +31,10 @@ export function renderChartUnwrapped(text, options = {}) {
29
31
  }
30
32
  function chartHtml(text, options) {
31
33
  const model = layoutChart(text, { rehearsalLetters: options.rehearsalLetters ?? false });
32
- const settings = { barNumbers: options.barNumbers ?? true };
34
+ const settings = {
35
+ barNumbers: options.barNumbers ?? true,
36
+ numerals: numeralsOf(options),
37
+ };
33
38
  return `<div class="bm-chart" data-theme="${themeOf(options)}"${settings.barNumbers ? "" : ' data-bar-numbers="off"'}>${headerHtml(model)}${model.blocks.map((b) => blockHtml(b, settings)).join("")}${options.diagnostics ? diagnosticsHtml(model) : ""}</div>`;
34
39
  }
35
40
  function headerHtml(model) {
@@ -41,16 +46,27 @@ function headerHtml(model) {
41
46
  return "";
42
47
  return `<header class="bm-header">${title}${meta ? `<p class="bm-meta" part="meta">${meta}</p>` : ""}</header>`;
43
48
  }
49
+ /** "rit. to ♩ = 90", "accel.", "a tempo". */
50
+ function tempoChangeText(c) {
51
+ if (c.kind === "a tempo")
52
+ return c.kind;
53
+ return `${c.kind}.${c.to === null ? "" : ` to ♩ = ${c.to}`}`;
54
+ }
44
55
  /** "D major · 4/4 · ♩ = 145 · swing", escaped; labelled, "Key D major · Time 4/4 · …". */
45
56
  function metaText(d, labelled = false) {
46
57
  return [
47
58
  ["Key", d.key ? `${glyphs(d.key.tonic)} ${d.key.mode}` : ""],
48
59
  ["Time", d.time ? `${d.time.n}/${d.time.d}` : ""],
49
60
  ["Tempo", d.tempo ? `♩ = ${d.tempo}` : ""],
61
+ // "a tempo" names itself; "Tempo a tempo" would stutter.
62
+ [
63
+ d.tempoChange?.kind === "a tempo" ? "" : "Tempo",
64
+ d.tempoChange ? tempoChangeText(d.tempoChange) : "",
65
+ ],
50
66
  ["Feel", d.feel ?? ""],
51
67
  ]
52
68
  .filter(([, value]) => value)
53
- .map(([label, value]) => escapeHtml(labelled ? `${label} ${value}` : (value ?? "")))
69
+ .map(([label, value]) => escapeHtml(labelled && label ? `${label} ${value}` : (value ?? "")))
54
70
  .join(" · ");
55
71
  }
56
72
  function blockHtml(block, settings) {
@@ -129,7 +145,7 @@ function rowHtml(row, columns, settings) {
129
145
  : "";
130
146
  let n = row.barNumber;
131
147
  const cells = row.cells.map((cell, i) => {
132
- const html = cellHtml(cell, n, repeatMarks(row, i));
148
+ const html = cellHtml(cell, n, repeatMarks(row, i), settings.numerals);
133
149
  if (n !== null && cell.kind === "slots")
134
150
  n++;
135
151
  return html;
@@ -150,21 +166,23 @@ function rowRepeatHtml(row) {
150
166
  return row.closes ? `<span class="bm-sr">repeat end, play 2 times</span>` : "";
151
167
  return repeatHtml(row.repeat).trim();
152
168
  }
153
- function cellHtml(cell, barNumber, marks) {
154
- const spoken = `<span class="bm-sr">${escapeHtml(speakBar(barNumber, cell))}</span>`;
169
+ function cellHtml(cell, barNumber, marks, numerals) {
170
+ const view = numerals && cell.kind === "slots" && cell.key ? { style: numerals, key: cell.key } : null;
171
+ const spoken = `<span class="bm-sr">${escapeHtml(speakBar(barNumber, cell, view !== null))}</span>`;
155
172
  if (cell.kind === "fermata")
156
173
  return `<div class="bm-bar bm-fermata" part="bar"${marks}>${spoken}<span aria-hidden="true">${FERMATA}</span></div>`;
157
174
  const meter = cell.meter
158
175
  ? `<span class="bm-meter"><span>${cell.meter.n}</span><span>${cell.meter.d}</span></span>`
159
176
  : "";
160
177
  const cut = cell.cut ? `<span class="bm-cut">cut</span>` : "";
161
- return `<div class="bm-bar" part="bar"${cell.cut ? " data-cut" : ""}${marks}>${spoken}<span class="bm-bar-body" aria-hidden="true">${meter}${cell.slots.map(slotHtml).join("")}${cut}</span></div>`;
178
+ const change = cell.tempoChange ? ` data-tempo-change="${cell.tempoChange}"` : "";
179
+ 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>`;
162
180
  }
163
181
  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>`;
164
- function slotHtml(slot) {
182
+ function slotHtml(slot, view) {
165
183
  switch (slot.kind) {
166
184
  case "chord":
167
- return chordHtml(slot.chord);
185
+ return chordHtml(slot.chord, view);
168
186
  case "hold":
169
187
  return `<span class="bm-hold">/</span>`;
170
188
  case "nc":
@@ -174,17 +192,22 @@ function slotHtml(slot) {
174
192
  case "unknown":
175
193
  return `<span class="bm-unknown">?</span>`;
176
194
  case "group":
177
- return `<span class="bm-group" data-count="${slot.members.length}"><span class="bm-tuplet">${slot.members.length}</span>${slot.members.map(slotHtml).join("")}</span>`;
195
+ return `<span class="bm-group" data-count="${slot.members.length}"><span class="bm-tuplet">${slot.members.length}</span>${slot.members.map((m) => slotHtml(m, view)).join("")}</span>`;
178
196
  }
179
197
  }
180
- function chordHtml(record) {
198
+ function chordHtml(record, view) {
181
199
  if (record.kind === "notes" || !record.parts)
182
200
  return notesHtml(record);
183
- const p = typesetChord(record.parts);
201
+ const numeral = view ? typesetNumeral(record.parts, view.key, view.style) : null;
202
+ if (!numeral)
203
+ return piecesHtml(typesetChord(record.parts), "");
204
+ return piecesHtml(numeral, ` data-numeral="${view?.style}"`);
205
+ }
206
+ function piecesHtml(p, attributes) {
184
207
  const quality = p.quality ? `<span class="bm-quality">${p.quality}</span>` : "";
185
208
  const sup = p.superscript ? `<sup>${escapeHtml(p.superscript)}</sup>` : "";
186
209
  const bass = p.bass ? `<span class="bm-bass">/${escapeHtml(p.bass)}</span>` : "";
187
- return `<span class="bm-chord" part="chord">${escapeHtml(p.root)}${quality}${sup}${bass}</span>`;
210
+ return `<span class="bm-chord" part="chord"${attributes}>${escapeHtml(p.root)}${quality}${sup}${bass}</span>`;
188
211
  }
189
212
  /** A single note is ringed; a set sits in faint braces with its root underlined. */
190
213
  function notesHtml(record) {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type { Numerals } from "./chord.js";
1
2
  export { FONT_FAMILY, fontFaceCss } from "./font.js";
2
3
  export { type RenderOptions, renderChart, renderChartUnwrapped } from "./html.js";
3
4
  export { layoutChart } from "./model.js";
package/dist/model.js CHANGED
@@ -239,10 +239,16 @@ function layoutCell(cell, played, previous) {
239
239
  if (cell.kind === "fermata")
240
240
  return { kind: "fermata" };
241
241
  const from = cell.kind === "slots" ? cell : previous;
242
- return {
242
+ return withPlayed({
243
243
  kind: "slots",
244
244
  meter: from?.meter ?? null,
245
245
  slots: played?.slots ?? (cell.kind === "slots" ? cell.slots : []),
246
246
  cut: false,
247
- };
247
+ }, played);
248
+ }
249
+ /** What the parser says about the bar as played: inside a rit. or accel., and its key. */
250
+ function withPlayed(cell, played) {
251
+ const change = played?.tempoChange?.kind;
252
+ const key = played?.key;
253
+ return { ...cell, ...(change ? { tempoChange: change } : {}), ...(key ? { key } : {}) };
248
254
  }
package/dist/speech.d.ts CHANGED
@@ -2,9 +2,10 @@
2
2
  * Spoken names: how a screen reader reads a chart as music. Built from the
3
3
  * parsed structure, never from the symbol's text.
4
4
  */
5
- import type { ChordRecord, Slot } from "barmark";
5
+ import { type ChordRecord, type Key, type Slot } from "barmark";
6
6
  import type { CellModel } from "./types.js";
7
- export declare function speakChord(record: ChordRecord): string;
8
- export declare function speakSlot(slot: Slot): string;
9
- /** "bar 9: G, hold, D, hold"; a fermata is held until cued. */
10
- export declare function speakBar(barNumber: number | null, cell: CellModel): string;
7
+ /** A chord's name; with a key, its degree first: "five, A seven". */
8
+ export declare function speakChord(record: ChordRecord, key?: Key): string;
9
+ export declare function speakSlot(slot: Slot, key?: Key): string;
10
+ /** "bar 9: G, hold, D, hold"; a fermata is held until cued. With `numerals`, each chord's degree comes first. */
11
+ export declare function speakBar(barNumber: number | null, cell: CellModel, numerals?: boolean): string;
package/dist/speech.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Spoken names: how a screen reader reads a chart as music. Built from the
3
+ * parsed structure, never from the symbol's text.
4
+ */
5
+ import { degreeOf } from "barmark";
1
6
  const NUMBERS = {
2
7
  "1": "one",
3
8
  "2": "two",
@@ -82,15 +87,25 @@ function speakNotes(record) {
82
87
  const spoken = labels.map((l) => l.endsWith("!") ? `${speakPitch(l.slice(0, -1))} (root)` : speakPitch(l));
83
88
  return `${spoken.length === 1 ? "note" : "notes"} ${spoken.join(", ")}`;
84
89
  }
85
- export function speakChord(record) {
90
+ /** "five", "flat seven": a root's degree in a key. */
91
+ function speakDegree(root, key) {
92
+ const d = degreeOf(root, key);
93
+ if (!d)
94
+ return "";
95
+ const accidental = ACCIDENTAL[(d.alter < 0 ? "b" : "#").repeat(Math.abs(d.alter))] ?? "";
96
+ return `${accidental.trim()} ${number(String(d.degree))}`.trim();
97
+ }
98
+ /** A chord's name; with a key, its degree first: "five, A seven". */
99
+ export function speakChord(record, key) {
86
100
  if (record.kind === "notes" || !record.parts)
87
101
  return speakNotes(record);
88
- return speakParts(record.parts);
102
+ const degree = key ? speakDegree(record.parts.root, key) : "";
103
+ return `${degree ? `${degree}, ` : ""}${speakParts(record.parts)}`;
89
104
  }
90
- export function speakSlot(slot) {
105
+ export function speakSlot(slot, key) {
91
106
  switch (slot.kind) {
92
107
  case "chord":
93
- return speakChord(slot.chord);
108
+ return speakChord(slot.chord, key);
94
109
  case "hold":
95
110
  return "hold";
96
111
  case "nc":
@@ -100,14 +115,14 @@ export function speakSlot(slot) {
100
115
  case "unknown":
101
116
  return "unknown chord";
102
117
  case "group":
103
- return `${GROUPS[slot.members.length] ?? `group of ${slot.members.length}`}: ${slot.members.map(speakSlot).join(", ")}`;
118
+ return `${GROUPS[slot.members.length] ?? `group of ${slot.members.length}`}: ${slot.members.map((m) => speakSlot(m, key)).join(", ")}`;
104
119
  }
105
120
  }
106
- /** "bar 9: G, hold, D, hold"; a fermata is held until cued. */
107
- export function speakBar(barNumber, cell) {
121
+ /** "bar 9: G, hold, D, hold"; a fermata is held until cued. With `numerals`, each chord's degree comes first. */
122
+ export function speakBar(barNumber, cell, numerals = false) {
108
123
  if (cell.kind === "fermata")
109
124
  return "fermata: hold until cued";
110
125
  const meter = cell.meter ? `, in ${cell.meter.n}/${cell.meter.d}` : "";
111
126
  const cut = cell.cut ? ", cut on this pass" : "";
112
- return `bar${barNumber === null ? "" : ` ${barNumber}`}${meter}: ${cell.slots.map(speakSlot).join(", ")}${cut}`;
127
+ return `bar${barNumber === null ? "" : ` ${barNumber}`}${meter}: ${cell.slots.map((s) => speakSlot(s, numerals ? cell.key : undefined)).join(", ")}${cut}`;
113
128
  }
package/dist/styles.js CHANGED
@@ -31,6 +31,8 @@ const SIZES = {
31
31
  const GUTTER = 2.06;
32
32
  export const MIN_BAR = 4.68;
33
33
  const HEADING_GAP = 0.645;
34
+ /** Space between consecutive bar rows, so one row's bar lines don't run into the next. */
35
+ const ROW_GAP = 0.35;
34
36
  /**
35
37
  * The triplet bracket, in chord sizes: its line sits this far below the top
36
38
  * of the number, with hooks this long, drawn either side of the number so
@@ -83,6 +85,8 @@ const css = `
83
85
  .bm-grid{display:grid;grid-template-columns:${em(GUTTER)} repeat(var(--bm-cols),auto) auto;width:max-content;max-width:100%}
84
86
  .bm-chart[data-bar-numbers="off"] .bm-grid{grid-template-columns:repeat(var(--bm-cols),auto) auto}
85
87
  .bm-row{display:contents}
88
+ .bm-row+.bm-row>*{margin-top:${em(ROW_GAP)}}
89
+ .bm-row+.bm-row>.bm-bar-number{margin-top:${at(SIZES.barNumber, ROW_GAP)}}
86
90
  .bm-bar-number{display:flex;flex:none;align-items:baseline;width:${at(SIZES.barNumber, GUTTER)};padding-top:${at(SIZES.barNumber, 0.65)};font:${em(SIZES.barNumber)}/1 ${SANS};color:var(--bm-muted)}
87
91
  .bm-bar-number::before{content:"\\200b";font:${em(1 / SIZES.barNumber)}/1.3 ${CHART_FONT}}
88
92
  .bm-chart[data-bar-numbers="off"] :is(.bm-heading,.bm-text,.bm-directive){margin-left:0}
@@ -111,6 +115,7 @@ const css = `
111
115
  .bm-tuplet::after{border-left:none;margin-left:.3em}
112
116
  .bm-meter{display:inline-flex;flex-direction:column;align-items:center;align-self:center;font:700 ${em(SIZES.meter)}/.95 ${SANS};color:var(--bm-muted)}
113
117
  .bm-fermata-glyph{display:block;color:var(--bm-ink)}
118
+ .bm-bar[data-tempo-change]::before{content:"";position:absolute;inset:.15em 0 auto;border-top:.08em dashed var(--barmark-directive,var(--bm-muted))}
114
119
  .bm-bar[data-cut]{outline:.08em dashed var(--bm-muted);outline-offset:-.25em}
115
120
  .bm-cut{position:absolute;top:.3em;right:.45em;font:${em(SIZES.cut)}/1 ${SANS};color:var(--bm-muted)}
116
121
  .bm-text{grid-column:1/-1;margin:.2em 0 .5em ${at(SIZES.text, GUTTER)};font-family:${TEXT};font-size:${em(SIZES.text)}}
@@ -127,7 +132,7 @@ const css = `
127
132
  }
128
133
  ${FIT_STEPS.map((w) => {
129
134
  const g = `.bm-grid[data-fit="${w}"]`;
130
- return `@container bm (max-width:${w}em){${g}{display:block}${g}>.bm-row{display:flex;flex-wrap:wrap}${g} .bm-empty{display:none}${g} .bm-bar{flex:0 1 auto;max-width:100%;border-left:.075em solid var(--bm-rule);margin-left:-.075em}${g} :is(.bm-bar-body,.bm-group,.bm-notes){flex-wrap:wrap}${g} .bm-bar-body>*{flex:1 1 auto;min-width:auto}}`;
135
+ return `@container bm (max-width:${w}em){${g}{display:block}${g}>.bm-row{display:flex;flex-wrap:wrap;row-gap:${em(ROW_GAP)}}${g} .bm-empty{display:none}${g} .bm-bar{flex:0 1 auto;max-width:100%;border-left:.075em solid var(--bm-rule);margin-left:-.075em}${g} :is(.bm-bar-body,.bm-group,.bm-notes){flex-wrap:wrap}${g} .bm-bar-body>*{flex:1 1 auto;min-width:auto}}`;
131
136
  }).join("\n")}
132
137
  `;
133
138
  /** Collapse the readable source above: whitespace around punctuation and between rules. */
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChartDiagnostic, Directives, Meter, Repeat, Slot } from "barmark";
1
+ import type { ChartDiagnostic, Directives, Key, Meter, Repeat, Slot } from "barmark";
2
2
  export type LayoutOptions = {
3
3
  rehearsalLetters?: boolean;
4
4
  };
@@ -51,6 +51,10 @@ export type CellModel = {
51
51
  meter: Meter | null;
52
52
  slots: Slot[];
53
53
  cut: boolean;
54
+ /** Set on a bar played inside a rit. or accel. */
55
+ tempoChange?: "rit" | "accel";
56
+ /** The key the bar is played in, when the chart names one. */
57
+ key?: Key;
54
58
  } | {
55
59
  kind: "fermata";
56
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barmark/render",
3
- "version": "0.2.1",
3
+ "version": "0.4.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.3.0"
50
+ "barmark": "^0.6.0"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "rm -rf dist && tsc -p tsconfig.build.json",