@barmark/render 0.0.0 → 0.2.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/dist/html.js ADDED
@@ -0,0 +1,200 @@
1
+ import { glyphs, splitPitch, typesetChord } from "./chord.js";
2
+ import { escapeHtml } from "./escape.js";
3
+ import { fontFaceCss } from "./font.js";
4
+ import { layoutChart } from "./model.js";
5
+ import { speakBar } from "./speech.js";
6
+ import { FIT_STEPS, MIN_BAR, stylesheet } from "./styles.js";
7
+ export function renderChart(text, options = {}) {
8
+ return `${fontStyle(options)}<barmark-chart><template shadowrootmode="open">${shadowContent(text, options)}</template></barmark-chart>`;
9
+ }
10
+ export const THEMES = ["auto", "light", "dark"];
11
+ /** Only a known theme reaches the markup: hosts may pass any string through. */
12
+ const themeOf = (options) => THEMES.find((t) => t === options.theme) ?? "auto";
13
+ /** What goes in the shadow root: the styles and the chart. */
14
+ export const shadowContent = (text, options = {}) => `<style>${stylesheet}</style>${chartHtml(text, options)}`;
15
+ /** A chart that could not be loaded: the message, shown where the chart would be. */
16
+ export const loadErrorContent = (message, options = {}) => `<style>${stylesheet}</style><div class="bm-chart" data-theme="${themeOf(options)}">${errorHtml("", [message])}</div>`;
17
+ const fontStyle = (options) => options.fontUrl ? `<style>${fontFaceCss(options.fontUrl)}</style>` : "";
18
+ export function renderChartUnwrapped(text, options = {}) {
19
+ return `${fontStyle(options)}${chartHtml(text, options)}`;
20
+ }
21
+ function chartHtml(text, options) {
22
+ const model = layoutChart(text, { rehearsalLetters: options.rehearsalLetters ?? false });
23
+ const settings = { barNumbers: options.barNumbers ?? true };
24
+ 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>`;
25
+ }
26
+ function headerHtml(model) {
27
+ const title = model.title
28
+ ? `<h2 class="bm-title" part="title">${escapeHtml(model.title)}</h2>`
29
+ : "";
30
+ const meta = metaText(model.header);
31
+ if (!title && !meta)
32
+ return "";
33
+ return `<header class="bm-header">${title}${meta ? `<p class="bm-meta" part="meta">${meta}</p>` : ""}</header>`;
34
+ }
35
+ /** "D major · 4/4 · ♩ = 145 · swing", escaped; labelled, "Key D major · Time 4/4 · …". */
36
+ function metaText(d, labelled = false) {
37
+ return [
38
+ ["Key", d.key ? `${glyphs(d.key.tonic)} ${d.key.mode}` : ""],
39
+ ["Time", d.time ? `${d.time.n}/${d.time.d}` : ""],
40
+ ["Tempo", d.tempo ? `♩ = ${d.tempo}` : ""],
41
+ ["Feel", d.feel ?? ""],
42
+ ]
43
+ .filter(([, value]) => value)
44
+ .map(([label, value]) => escapeHtml(labelled ? `${label} ${value}` : (value ?? "")))
45
+ .join(" · ");
46
+ }
47
+ function blockHtml(block, settings) {
48
+ const label = block.heading === null ? "" : ` aria-label="${escapeHtml(block.heading)}"`;
49
+ return `<section class="bm-section"${label}>${headingHtml(block)}<div class="bm-grid" style="--bm-cols:${Math.max(1, block.columns)}" data-fit="${fitWidth(block)}">${block.items.map((i) => itemHtml(i, block.columns, settings)).join("")}</div></section>`;
50
+ }
51
+ /**
52
+ * Roughly how many em a section needs with its columns aligned, rounded up
53
+ * to a step the stylesheet knows; narrower than that, its rows wrap.
54
+ */
55
+ function fitWidth(block) {
56
+ const widths = [];
57
+ for (const item of block.items) {
58
+ if (item.kind !== "row")
59
+ continue;
60
+ item.cells.forEach((cell, i) => {
61
+ widths[i] = Math.max(widths[i] ?? 0, cellWidth(cell));
62
+ });
63
+ }
64
+ const total = 5 + widths.reduce((a, b) => a + b, 0);
65
+ return FIT_STEPS.find((step) => step >= total) ?? FIT_STEPS.at(-1) ?? 0;
66
+ }
67
+ /** Em per bar: padding, plus each slot's text and the gap after it. */
68
+ const cellWidth = (cell) => cell.kind === "fermata"
69
+ ? MIN_BAR
70
+ : Math.max(MIN_BAR, 1.5 + (cell.meter ? 0.8 : 0) + cell.slots.reduce((w, s) => w + slotWidth(s), 0));
71
+ /** Roughly: 0.65em a baseline character, 0.4em a superscript one, 0.6em between slots. */
72
+ function slotWidth(slot) {
73
+ if (slot.kind === "group")
74
+ return slot.members.reduce((w, m) => w + slotWidth(m), 0.2);
75
+ if (slot.kind !== "chord")
76
+ return slot.kind === "nc" ? 1.8 : 1.4;
77
+ const { chord } = slot;
78
+ if (chord.kind === "notes" || !chord.parts)
79
+ return 0.65 * chord.canonical.length + 0.6;
80
+ const p = typesetChord(chord.parts);
81
+ const base = p.root.length + p.quality.length + (p.bass ? p.bass.length + 1 : 0);
82
+ return 0.65 * base + 0.4 * p.superscript.length + 0.6;
83
+ }
84
+ function headingHtml(block) {
85
+ if (block.heading === null)
86
+ return "";
87
+ const letter = block.rehearsal
88
+ ? `<span class="bm-rehearsal" part="rehearsal">${block.rehearsal}</span><span class="bm-sr">,</span> `
89
+ : "";
90
+ return `<h3 class="bm-heading" part="heading">${letter}${escapeHtml(block.heading)}${repeatHtml(block.repeat)}</h3>`;
91
+ }
92
+ /** The visible count and its spoken form: ×2 is "play 2 times". */
93
+ function repeatHtml(repeat) {
94
+ if (repeat === null)
95
+ return "";
96
+ return repeat === "open"
97
+ ? ` <span class="bm-times" aria-hidden="true">× as needed</span><span class="bm-sr">repeat until cued</span>`
98
+ : ` <span class="bm-times" aria-hidden="true">×${repeat}</span><span class="bm-sr">play ${repeat} times</span>`;
99
+ }
100
+ function itemHtml(item, columns, settings) {
101
+ switch (item.kind) {
102
+ case "row":
103
+ return rowHtml(item, columns, settings);
104
+ case "text":
105
+ return `<p class="bm-text${item.note ? " bm-note" : ""}" part="text">${escapeHtml(item.text)}</p>`;
106
+ case "directive":
107
+ // Mid-chart, a change names itself: "→ Time 3/4", never a lone "3/4".
108
+ return `<p class="bm-directive" part="meta"><span class="bm-change" aria-hidden="true">→</span><span>${metaText(item.set, true)}</span></p>`;
109
+ case "error":
110
+ return errorHtml(item.source, item.messages);
111
+ }
112
+ }
113
+ function errorHtml(source, messages) {
114
+ const message = escapeHtml(messages.join("; "));
115
+ return `<div class="bm-error" title="${message}"><code>${escapeHtml(source)}</code><span class="bm-sr">Error: ${message}</span></div>`;
116
+ }
117
+ function rowHtml(row, columns, settings) {
118
+ const number = settings.barNumbers
119
+ ? `<span class="bm-bar-number" part="bar-number" aria-hidden="true">${row.barNumber ?? ""}</span>`
120
+ : "";
121
+ let n = row.barNumber;
122
+ const cells = row.cells.map((cell, i) => {
123
+ const html = cellHtml(cell, n, repeatMarks(row, i));
124
+ if (n !== null && cell.kind === "slots")
125
+ n++;
126
+ return html;
127
+ });
128
+ const empty = `<span class="bm-empty" aria-hidden="true"></span>`.repeat(Math.max(0, columns - row.cells.length));
129
+ const opens = row.opens || typeof row.repeat === "number" || row.repeat === "open";
130
+ return `<div class="bm-row">${opens ? `<span class="bm-sr">repeat start</span>` : ""}${number}${cells.join("")}${empty}<span class="bm-repeat">${rowRepeatHtml(row)}</span></div>`;
131
+ }
132
+ /** Repeat bar lines: ‖: on a row's first bar, :‖ on its last. */
133
+ function repeatMarks(row, i) {
134
+ const whole = row.repeat !== null && !row.closes;
135
+ const start = i === 0 && (row.opens || whole);
136
+ const end = i === row.cells.length - 1 && (row.closes || row.repeat !== null);
137
+ return `${start ? " data-repeat-start" : ""}${end ? " data-repeat-end" : ""}`;
138
+ }
139
+ function rowRepeatHtml(row) {
140
+ if (row.repeat === null)
141
+ return row.closes ? `<span class="bm-sr">repeat end, play 2 times</span>` : "";
142
+ return repeatHtml(row.repeat).trim();
143
+ }
144
+ function cellHtml(cell, barNumber, marks) {
145
+ const spoken = `<span class="bm-sr">${escapeHtml(speakBar(barNumber, cell))}</span>`;
146
+ if (cell.kind === "fermata")
147
+ return `<div class="bm-bar bm-fermata" part="bar"${marks}>${spoken}<span aria-hidden="true">${FERMATA}</span></div>`;
148
+ const meter = cell.meter
149
+ ? `<span class="bm-meter"><span>${cell.meter.n}</span><span>${cell.meter.d}</span></span>`
150
+ : "";
151
+ const cut = cell.cut ? `<span class="bm-cut">cut</span>` : "";
152
+ 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>`;
153
+ }
154
+ 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>`;
155
+ function slotHtml(slot) {
156
+ switch (slot.kind) {
157
+ case "chord":
158
+ return chordHtml(slot.chord);
159
+ case "hold":
160
+ return `<span class="bm-hold">/</span>`;
161
+ case "nc":
162
+ return `<span class="bm-nc">N.C.</span>`;
163
+ case "silence":
164
+ return `<span class="bm-silence"></span>`;
165
+ case "unknown":
166
+ return `<span class="bm-unknown">?</span>`;
167
+ case "group":
168
+ return `<span class="bm-group" data-count="${slot.members.length}"><span class="bm-tuplet">${slot.members.length}</span>${slot.members.map(slotHtml).join("")}</span>`;
169
+ }
170
+ }
171
+ function chordHtml(record) {
172
+ if (record.kind === "notes" || !record.parts)
173
+ return notesHtml(record);
174
+ const p = typesetChord(record.parts);
175
+ const quality = p.quality ? `<span class="bm-quality">${p.quality}</span>` : "";
176
+ const sup = p.superscript ? `<sup>${escapeHtml(p.superscript)}</sup>` : "";
177
+ const bass = p.bass ? `<span class="bm-bass">/${escapeHtml(p.bass)}</span>` : "";
178
+ return `<span class="bm-chord" part="chord">${escapeHtml(p.root)}${quality}${sup}${bass}</span>`;
179
+ }
180
+ /** A single note is ringed; a set sits in faint braces with its root underlined. */
181
+ function notesHtml(record) {
182
+ const labels = record.canonical
183
+ .replace(/^\{|\}$/g, "")
184
+ .split(/\s+/)
185
+ .filter(Boolean);
186
+ const notes = labels.map((label) => {
187
+ const root = label.endsWith("!");
188
+ const { name, octave } = splitPitch(root ? label.slice(0, -1) : label);
189
+ const html = `${escapeHtml(name)}${octave === null ? "" : `<sub>${escapeHtml(octave)}</sub>`}`;
190
+ return root ? `<u>${html}</u>` : html;
191
+ });
192
+ if (notes.length === 1)
193
+ return `<span class="bm-chord" part="chord"><span class="bm-ring">${notes[0]}</span></span>`;
194
+ return `<span class="bm-chord bm-notes" part="chord"><span class="bm-brace">{</span>${notes.map((n) => `<span class="bm-note-name">${n}</span>`).join(" ")}<span class="bm-brace">}</span></span>`;
195
+ }
196
+ function diagnosticsHtml(model) {
197
+ if (!model.diagnostics.length)
198
+ return "";
199
+ return `<ul class="bm-diagnostics">${model.diagnostics.map((d) => `<li class="bm-${d.severity}">line ${d.line}: ${escapeHtml(d.message)}</li>`).join("")}</ul>`;
200
+ }
@@ -0,0 +1,5 @@
1
+ export { FONT_FAMILY, fontFaceCss } from "./font.js";
2
+ export { type RenderOptions, renderChart, renderChartUnwrapped } from "./html.js";
3
+ export { layoutChart } from "./model.js";
4
+ export { stylesheet } from "./styles.js";
5
+ export type * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { FONT_FAMILY, fontFaceCss } from "./font.js";
2
+ export { renderChart, renderChartUnwrapped } from "./html.js";
3
+ export { layoutChart } from "./model.js";
4
+ export { stylesheet } from "./styles.js";
@@ -0,0 +1,2 @@
1
+ import type { ChartModel, LayoutOptions } from "./types.js";
2
+ export declare function layoutChart(text: string, options?: LayoutOptions): ChartModel;
package/dist/model.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The chart model: what to draw, with every musical decision made here
3
+ * (`%` copied out, bar numbers, repeats, errors) so the HTML layer only
4
+ * draws. Built from the parser's document lines; the played bars supply
5
+ * bar numbers and the slots `%` copies.
6
+ */
7
+ import { parseChart, } from "barmark";
8
+ export function layoutChart(text, options = {}) {
9
+ const parse = parseChart(text);
10
+ const { header, body } = splitHeader(parse.lines);
11
+ const groups = groupLines(body);
12
+ const ctx = {
13
+ parse,
14
+ source: text.split(/\r?\n/),
15
+ errors: errorsByLine(parse),
16
+ lastKnownBar: lastKnownBar(parse.events),
17
+ written: new Map(groups.flatMap((g) => (g.heading ? [[g.heading.line, g]] : []))),
18
+ };
19
+ const blocks = groups.map((g) => layoutGroup(g, ctx));
20
+ if (options.rehearsalLetters)
21
+ letterBlocks(blocks);
22
+ return { title: parse.title, header, blocks, diagnostics: parse.diagnostics };
23
+ }
24
+ /** Rehearsal letters mark position: every section occurrence, and leading bars, in play order. */
25
+ function letterBlocks(blocks) {
26
+ let n = 0;
27
+ for (const b of blocks) {
28
+ if (b.form === null && !b.items.some((i) => i.kind === "row"))
29
+ continue;
30
+ b.rehearsal = letters(n++);
31
+ }
32
+ }
33
+ /** 0 → A, 25 → Z, 26 → AA, as spreadsheet columns count. */
34
+ function letters(n) {
35
+ const head = n >= 26 ? letters(Math.floor(n / 26) - 1) : "";
36
+ return head + String.fromCharCode(65 + (n % 26));
37
+ }
38
+ function errorsByLine(parse) {
39
+ const errors = new Map();
40
+ for (const d of parse.diagnostics) {
41
+ if (d.severity !== "error")
42
+ continue;
43
+ errors.set(d.line, [...(errors.get(d.line) ?? []), d.message]);
44
+ }
45
+ return errors;
46
+ }
47
+ /** The count of bars up to the end of the first open repeat (all of them if there is none). */
48
+ function lastKnownBar(events) {
49
+ let count = 0;
50
+ const countBars = (es) => {
51
+ for (const e of es) {
52
+ if (e.kind === "bar")
53
+ count++;
54
+ else if (e.kind === "open")
55
+ countBars(e.events);
56
+ }
57
+ };
58
+ for (const e of events) {
59
+ if (e.kind === "open") {
60
+ countBars(e.events);
61
+ return count;
62
+ }
63
+ if (e.kind === "bar")
64
+ count++;
65
+ }
66
+ return Number.POSITIVE_INFINITY;
67
+ }
68
+ /** The title line, and the directives before any other content, form the header. */
69
+ function splitHeader(lines) {
70
+ const header = {};
71
+ let i = 0;
72
+ for (; i < lines.length; i++) {
73
+ const l = lines[i];
74
+ if (l?.kind === "title")
75
+ continue;
76
+ if (l?.kind !== "directive")
77
+ break;
78
+ Object.assign(header, l.set);
79
+ }
80
+ return { header, body: lines.slice(i).filter((l) => l.kind !== "title") };
81
+ }
82
+ function groupLines(lines) {
83
+ const groups = [];
84
+ let current = { heading: null, lines: [] };
85
+ for (const l of lines) {
86
+ if (l.kind === "section") {
87
+ if (current.heading || current.lines.length)
88
+ groups.push(current);
89
+ current = { heading: l, lines: [] };
90
+ }
91
+ else
92
+ current.lines.push(l);
93
+ }
94
+ if (current.heading || current.lines.length)
95
+ groups.push(current);
96
+ return groups;
97
+ }
98
+ function layoutGroup(group, ctx) {
99
+ const h = group.heading;
100
+ const occurrence = h ? ctx.parse.sections.find((s) => s.line === h.line) : undefined;
101
+ // A heading with an error is drawn as written, like any line with an error.
102
+ const broken = h !== null && ctx.errors.has(h.line);
103
+ const written = occurrence?.replayOf != null ? ctx.written.get(occurrence.replayOf) : undefined;
104
+ const items = [
105
+ ...(broken ? [errorItem(h.line, ctx)] : []),
106
+ ...(written && occurrence
107
+ ? layoutReplay(written, group, occurrence, ctx)
108
+ : layoutLines(group.lines, occurrence, ctx)),
109
+ ];
110
+ return blockOf(broken ? null : h, occurrence, items);
111
+ }
112
+ /**
113
+ * A replay draws its written section with its own bar numbers. Its own
114
+ * text lines replace the written section's in order (extra ones follow the
115
+ * last row); its other lines follow. `->` marks the bar the parser cut.
116
+ */
117
+ function layoutReplay(written, replay, occurrence, ctx) {
118
+ const isText = (l) => l.kind === "text";
119
+ const body = layoutLines(written.lines, occurrence, ctx);
120
+ const ownText = layoutLines(replay.lines.filter(isText), occurrence, ctx);
121
+ const ownOther = layoutLines(replay.lines.filter((l) => !isText(l)), occurrence, ctx);
122
+ const items = [...substituteText(body, ownText), ...ownOther];
123
+ return occurrence.elided ? markCut(items) : items;
124
+ }
125
+ function substituteText(items, own) {
126
+ if (!own.length)
127
+ return items;
128
+ const queue = [...own];
129
+ const out = items.flatMap((i) => {
130
+ if (i.kind !== "text")
131
+ return [i];
132
+ const next = queue.shift();
133
+ return next ? [next] : [];
134
+ });
135
+ // Extra lines continue after the last row and any text already following it.
136
+ const after = Math.max(out.findLastIndex((i) => i.kind === "row"), out.findLastIndex((i) => i.kind === "text"));
137
+ out.splice(after + 1, 0, ...queue);
138
+ return out;
139
+ }
140
+ /** The parser cuts the last bar it would play; a fermata is not a bar. */
141
+ function markCut(items) {
142
+ const r = items.findLastIndex((i) => i.kind === "row" && lastSlots(i.cells) !== null);
143
+ const row = items[r];
144
+ if (row?.kind !== "row")
145
+ return items;
146
+ const c = row.cells.findLastIndex((cell) => cell.kind === "slots");
147
+ const cells = row.cells.map((cell, i) => i === c && cell.kind === "slots" ? { ...cell, cut: true } : cell);
148
+ return items.map((item, i) => (i === r ? { ...row, cells } : item));
149
+ }
150
+ function blockOf(h, occurrence, items) {
151
+ return {
152
+ heading: h?.name ?? null,
153
+ repeat: h?.repeat ?? null,
154
+ form: occurrence?.form ?? null,
155
+ rehearsal: null,
156
+ columns: Math.max(0, ...items.map((i) => (i.kind === "row" ? i.cells.length : 0))),
157
+ items,
158
+ };
159
+ }
160
+ function layoutLines(lines, occurrence, ctx) {
161
+ const items = [];
162
+ let previous = null;
163
+ for (const l of lines) {
164
+ if (ctx.errors.has(l.line)) {
165
+ items.push(errorItem(l.line, ctx));
166
+ continue;
167
+ }
168
+ const item = layoutLine(l, occurrence, previous, ctx);
169
+ if (!item)
170
+ continue;
171
+ if (item.kind === "row")
172
+ previous = lastSlots(item.cells) ?? previous;
173
+ items.push(item);
174
+ }
175
+ return items;
176
+ }
177
+ function layoutLine(l, occurrence, previous, ctx) {
178
+ switch (l.kind) {
179
+ case "bars":
180
+ return layoutRow(l, playedOn(l.line, occurrence, ctx.parse.bars), previous, ctx);
181
+ case "text":
182
+ return textItem(l.text);
183
+ case "directive":
184
+ return { kind: "directive", set: l.set };
185
+ case "invalid":
186
+ return errorItem(l.line, ctx);
187
+ default:
188
+ return null;
189
+ }
190
+ }
191
+ function textItem(text) {
192
+ const note = /^###(\s|$)/.exec(text);
193
+ return note
194
+ ? { kind: "text", text: text.slice(note[0].length).trim(), note: true }
195
+ : { kind: "text", text, note: false };
196
+ }
197
+ function errorItem(line, ctx) {
198
+ return {
199
+ kind: "error",
200
+ line,
201
+ source: (ctx.source[line - 1] ?? "").trimEnd(),
202
+ messages: ctx.errors.get(line) ?? [],
203
+ };
204
+ }
205
+ const lastSlots = (cells) => cells.findLast((c) => c.kind === "slots") ?? null;
206
+ /** The bars a line plays within its section occurrence, in play order. */
207
+ function playedOn(line, occurrence, bars) {
208
+ const first = occurrence?.firstBar ?? 1;
209
+ const last = occurrence?.lastBar ?? Number.POSITIVE_INFINITY;
210
+ return bars.filter((b) => b.line === line && b.index >= first && b.index <= last);
211
+ }
212
+ function layoutRow(l, played, previous, ctx) {
213
+ const cells = [];
214
+ // A cut bar is not played: what it would play is what the line's bar played when written.
215
+ const written = ctx.parse.bars.filter((b) => b.line === l.line);
216
+ let bar = 0;
217
+ let prev = previous;
218
+ for (const cell of l.cells) {
219
+ const model = layoutCell(cell, played[bar] ?? written[bar], prev);
220
+ if (cell.kind !== "fermata")
221
+ bar++;
222
+ if (model.kind === "slots")
223
+ prev = model;
224
+ cells.push(model);
225
+ }
226
+ const firstIndex = played[0]?.index;
227
+ return {
228
+ kind: "row",
229
+ line: l.line,
230
+ barNumber: firstIndex !== undefined && firstIndex <= ctx.lastKnownBar ? firstIndex : null,
231
+ opens: l.opens,
232
+ closes: l.closes,
233
+ repeat: l.repeat,
234
+ cells,
235
+ };
236
+ }
237
+ /** A cell as drawn: `%` becomes the bar it copies, its slots as the parser resolved them; `previous` lends only its meter. */
238
+ function layoutCell(cell, played, previous) {
239
+ if (cell.kind === "fermata")
240
+ return { kind: "fermata" };
241
+ const from = cell.kind === "slots" ? cell : previous;
242
+ return {
243
+ kind: "slots",
244
+ meter: from?.meter ?? null,
245
+ slots: played?.slots ?? (cell.kind === "slots" ? cell.slots : []),
246
+ cut: false,
247
+ };
248
+ }
@@ -0,0 +1,10 @@
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 type { ChordRecord, Slot } from "barmark";
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;
package/dist/speech.js ADDED
@@ -0,0 +1,113 @@
1
+ const NUMBERS = {
2
+ "1": "one",
3
+ "2": "two",
4
+ "3": "three",
5
+ "4": "four",
6
+ "5": "five",
7
+ "6": "six",
8
+ "7": "seven",
9
+ "9": "nine",
10
+ "11": "eleven",
11
+ "13": "thirteen",
12
+ };
13
+ const QUALITY = {
14
+ "": "",
15
+ m: "minor",
16
+ dim: "diminished",
17
+ aug: "augmented",
18
+ "5": "five",
19
+ };
20
+ const EXTENSION = {
21
+ "6": "six",
22
+ "6/9": "six nine",
23
+ "7": "seven",
24
+ maj7: "major seven",
25
+ "9": "nine",
26
+ maj9: "major nine",
27
+ "11": "eleven",
28
+ "13": "thirteen",
29
+ maj13: "major thirteen",
30
+ "7alt": "seven altered",
31
+ };
32
+ const SUS = { sus2: "sus two", sus4: "sus four" };
33
+ const GROUPS = {
34
+ 2: "duplet",
35
+ 3: "triplet",
36
+ 4: "quadruplet",
37
+ 5: "quintuplet",
38
+ 6: "sextuplet",
39
+ };
40
+ const number = (n) => NUMBERS[n] ?? n.split("").join(" ");
41
+ const ACCIDENTAL = {
42
+ "#": " sharp",
43
+ "##": " double sharp",
44
+ b: " flat",
45
+ bb: " double flat",
46
+ };
47
+ /** `F#` → "F sharp", `E1` → "E one". */
48
+ function speakPitch(label) {
49
+ const m = /^([A-G])(##|bb|#|b)?(-?\d+)?$/.exec(label);
50
+ if (!m)
51
+ return label;
52
+ const octave = m[3] === undefined
53
+ ? ""
54
+ : ` ${m[3].startsWith("-") ? "minus " : ""}${number(m[3].replace("-", ""))}`;
55
+ return `${m[1]}${ACCIDENTAL[m[2] ?? ""] ?? ""}${octave}`;
56
+ }
57
+ /** `b9` → "flat nine"; a natural tone is added: `9` → "add nine". */
58
+ function speakTone(tone) {
59
+ const m = /^(#|b)?(\d+)$/.exec(tone);
60
+ if (!m)
61
+ return tone;
62
+ return `${m[1] ? (m[1] === "#" ? "sharp" : "flat") : "add"} ${number(m[2] ?? "")}`;
63
+ }
64
+ function speakParts(p) {
65
+ return [
66
+ speakPitch(p.root),
67
+ QUALITY[p.quality],
68
+ p.extension ? EXTENSION[p.extension] : "",
69
+ p.sus ? SUS[p.sus] : "",
70
+ ...p.tones.map(speakTone),
71
+ p.bass ? `over ${speakPitch(p.bass)}` : "",
72
+ ]
73
+ .filter(Boolean)
74
+ .join(" ");
75
+ }
76
+ /** A note set: "note E", "notes D, E (root), G". */
77
+ function speakNotes(record) {
78
+ const labels = record.canonical
79
+ .replace(/^\{|\}$/g, "")
80
+ .split(/\s+/)
81
+ .filter(Boolean);
82
+ const spoken = labels.map((l) => l.endsWith("!") ? `${speakPitch(l.slice(0, -1))} (root)` : speakPitch(l));
83
+ return `${spoken.length === 1 ? "note" : "notes"} ${spoken.join(", ")}`;
84
+ }
85
+ export function speakChord(record) {
86
+ if (record.kind === "notes" || !record.parts)
87
+ return speakNotes(record);
88
+ return speakParts(record.parts);
89
+ }
90
+ export function speakSlot(slot) {
91
+ switch (slot.kind) {
92
+ case "chord":
93
+ return speakChord(slot.chord);
94
+ case "hold":
95
+ return "hold";
96
+ case "nc":
97
+ return "no chord";
98
+ case "silence":
99
+ return "stop";
100
+ case "unknown":
101
+ return "unknown chord";
102
+ case "group":
103
+ return `${GROUPS[slot.members.length] ?? `group of ${slot.members.length}`}: ${slot.members.map(speakSlot).join(", ")}`;
104
+ }
105
+ }
106
+ /** "bar 9: G, hold, D, hold"; a fermata is held until cued. */
107
+ export function speakBar(barNumber, cell) {
108
+ if (cell.kind === "fermata")
109
+ return "fermata: hold until cued";
110
+ const meter = cell.meter ? `, in ${cell.meter.n}/${cell.meter.d}` : "";
111
+ const cut = cell.cut ? ", cut on this pass" : "";
112
+ return `bar${barNumber === null ? "" : ` ${barNumber}`}${meter}: ${cell.slots.map(speakSlot).join(", ")}${cut}`;
113
+ }
@@ -0,0 +1,16 @@
1
+ export declare const MIN_BAR = 4.68;
2
+ export type Palette = {
3
+ ink: string;
4
+ paper: string;
5
+ muted: string;
6
+ rule: string;
7
+ accent: string;
8
+ };
9
+ /** Both pass WCAG AA on their paper (tests/contrast.test.ts). */
10
+ export declare const PALETTES: {
11
+ light: Palette;
12
+ dark: Palette;
13
+ };
14
+ /** Section widths, in em, at which a section stops aligning columns and wraps its rows. */
15
+ export declare const FIT_STEPS: number[];
16
+ export declare const stylesheet: string;