tonus 0.11.1 → 0.13.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.
@@ -21,16 +21,20 @@ function resolveMasses(feast) {
21
21
  return resolved;
22
22
  return [feast.marian ? AD_LIB.bvm : AD_LIB.standard];
23
23
  }
24
- // The numbered kyriale runs 1–18. The book's appendix — ad libitum settings and
25
- // the like — carries synthetic numbers above that range, which no feast's
26
- // `masses` list ever names; those settings are therefore reachable only as a
27
- // last-resort fallback here, or by direct `ordinarium({ mass })` query.
28
- const NUMBERED_MASS_MAX = 18;
24
+ // Where the Kyriale prints an entry. Only the "mass" section carries a mass
25
+ // number; the others are addressed by section. The appendix used to carry its
26
+ // Graduale page as a "mass" (Clemens Rector was mass 75, the Requiem 94, 101
27
+ // and 102), and the two ad libitum Kyries whose incipits carry a numeral
28
+ // collided with Masses VI and X.
29
+ const KYRIALE_SECTIONS = new Set([
30
+ "mass", "credo", "sprinkling", "ad-libitum", "requiem",
31
+ ]);
32
+ const SPRINKLING_OFFICES = new Set(["as", "va"]);
33
+ // The Cantus ad libitum, which no feast's `masses` list ever names: reachable
34
+ // on the appendix's solemnity turn, as a last-resort fallback here, or by a
35
+ // direct `ordinarium({ section: "ad-libitum" })` query.
29
36
  function isAdLibitum(entry) {
30
- // Most appendix settings sit above the numbered range, but two ad libitum
31
- // Kyries are numbered 6 and 10 in the source data, colliding with the
32
- // numbered masses — for those the incipit is the only discriminator.
33
- return (entry.mass ?? 0) > NUMBERED_MASS_MAX || /\(ad lib\./i.test(entry.incipit);
37
+ return entry.section === "ad-libitum";
34
38
  }
35
39
  /** Prefer a proper numbered setting over an appendix one at equal standing. */
36
40
  function adLibLast(a, b) {
@@ -38,12 +42,13 @@ function adLibLast(a, b) {
38
42
  }
39
43
  // The Missa pro defunctis settings belong to the Requiem, not to the temporal or
40
44
  // sanctoral day. They stay out of every calendar-driven pick and remain
41
- // reachable only by direct `ordinarium({ mass })` query. Note the Requiem's own
42
- // dismissal carries the bare incipit "Requiescant" — no "(in Miss. def.)"
43
- // qualifier — so it must be named here explicitly, or it leaks through the
44
- // last-resort appendix onto every feria whose mass prints no dismissal.
45
+ // reachable only by direct `ordinarium({ section: "requiem" })` query. The
46
+ // Requiem's own dismissal carries the bare incipit "Requiescant", which is why
47
+ // the section is data and not an incipit test: left to the incipit it leaked
48
+ // through the last-resort appendix onto every feria whose mass prints no
49
+ // dismissal.
45
50
  function isRequiem(entry) {
46
- return /in\s+Miss\.\s*def|defunct|requiescant/i.test(entry.incipit);
51
+ return entry.section === "requiem";
47
52
  }
48
53
  /**
49
54
  * Deterministic rotation of a preference list by year.
@@ -104,8 +109,14 @@ function appendixLeadsThisYear(rubric, poolSize, year) {
104
109
  const n = poolSize + 1;
105
110
  return ((year % n) + n) % n === poolSize;
106
111
  }
112
+ // The appendix in the book's own order. Its settings carry no mass number, so
113
+ // the Graduale's page orders them: Kyrie I to XI, then the Glorias, and so on.
114
+ function grPage(entry) {
115
+ const page = /\d+/.exec(entry.pages?.gr?.[0]?.page ?? "")?.[0];
116
+ return page == null ? Number.POSITIVE_INFINITY : Number(page);
117
+ }
107
118
  function appendixFor(office, pool) {
108
- return pool.filter((e) => e.office === office && isAdLibitum(e) && !isRequiem(e)).sort((a, b) => (a.mass ?? 0) - (b.mass ?? 0) || (a.id < b.id ? -1 : 1));
119
+ return pool.filter((e) => e.office === office && isAdLibitum(e) && !isRequiem(e)).sort((a, b) => grPage(a) - grPage(b) || (a.id < b.id ? -1 : 1));
109
120
  }
110
121
  /**
111
122
  * Candidate settings for one ordinary slot, most preferred first.
@@ -183,7 +194,7 @@ function entriesForOffice(office, massNumbers, year, appendixLeads, wholeMass =
183
194
  // is the standing answer — its ad libitum variants must NOT rotate against it
184
195
  // as equals, or the appendix would be sung more often than the rite itself.
185
196
  // They arrive on the appendix turn like every other appendix setting.
186
- const unnumbered = pool.filter((e) => e.office === office && e.mass == null && !isRequiem(e));
197
+ const unnumbered = pool.filter((e) => e.office === office && SPRINKLING_OFFICES.has(office) && !isRequiem(e));
187
198
  const plain = unnumbered.filter((e) => !isAdLibitum(e));
188
199
  if (plain.length)
189
200
  return rotate(plain, year);
@@ -310,6 +321,7 @@ export function entryToOrdinaryChant(entry) {
310
321
  ordinary,
311
322
  ordinarium: ORDINARIA[ordinary] ?? entry.incipit,
312
323
  mass: entry.mass ?? 0,
324
+ section: entry.section,
313
325
  };
314
326
  }
315
327
  // Maundy Thursday (In Cena Domini) is a Triduum exception: it retains a full
@@ -454,7 +466,7 @@ function assertFeasts(feasts, method) {
454
466
  throw new Error(`${method}: feast must be a Feast (from tonus.festum) — got ${typeof f}`);
455
467
  }
456
468
  }
457
- const ORDINARIUM_QUERY_KEYS = new Set([...CANTUS_QUERY_KEYS, "feast", "ordinary", "mass"]);
469
+ const ORDINARIUM_QUERY_KEYS = new Set([...CANTUS_QUERY_KEYS, "feast", "ordinary", "mass", "section"]);
458
470
  /**
459
471
  * Mass ordinary retrieval (`tonus.ordinarium`) from the Kyriale. A feast
460
472
  * drives mass selection; `mass` pins a kyriale number directly.
@@ -472,6 +484,10 @@ export function getOrdinary(query) {
472
484
  if (query.ordinary) {
473
485
  assertOrdinaryCodes(Array.isArray(query.ordinary) ? query.ordinary : [query.ordinary], "ordinarium");
474
486
  }
487
+ if (query.section != null && !KYRIALE_SECTIONS.has(query.section)) {
488
+ throw new Error(`ordinarium: unknown section "${query.section}" ` +
489
+ `(expected ${[...KYRIALE_SECTIONS].join(", ")}).`);
490
+ }
475
491
  const feasts = toArray(query.feast);
476
492
  assertFeasts(feasts, "ordinarium");
477
493
  // `mode` accepts a scalar or an array, with cantus's semantics: match any.
@@ -493,7 +509,7 @@ export function getOrdinary(query) {
493
509
  return ordinaryForFeast(f, query.mass, filterModes, adm);
494
510
  });
495
511
  }
496
- else if (query.mass != null || query.ordinary) {
512
+ else if (query.mass != null || query.ordinary || query.section) {
497
513
  // Direct kyriale query without feast context — same admissibility rule as
498
514
  // cantus, so the two doors cannot disagree.
499
515
  let entries = KYRIALE.slice();
@@ -503,6 +519,8 @@ export function getOrdinary(query) {
503
519
  }
504
520
  if (query.mass != null)
505
521
  entries = entries.filter((e) => e.mass === query.mass);
522
+ if (query.section)
523
+ entries = entries.filter((e) => e.section === query.section);
506
524
  if (query.ordinary)
507
525
  entries = entries.filter((e) => e.office === query.ordinary);
508
526
  if (filterModes)
@@ -515,6 +533,9 @@ export function getOrdinary(query) {
515
533
  return [];
516
534
  }
517
535
  // Apply remaining CantusQuery filters
536
+ if (feasts && query.section) {
537
+ results = results.filter((c) => c.section === query.section);
538
+ }
518
539
  if (query.incipit) {
519
540
  const needle = query.incipit.toLowerCase();
520
541
  results = results.filter((c) => c.incipit.toLowerCase().includes(needle));
@@ -1,4 +1,5 @@
1
1
  import type { Season, Grade, Feast } from "../cal/types.js";
2
+ import type { KyrialeSection } from "../../data/kyriale.js";
2
3
  export type { Season, Grade, Feast };
3
4
  export type OfficeCode = "an" | "al" | "ca" | "co" | "gr" | "hy" | "in" | "of" | "ps" | "re" | "rb" | "se" | "tr" | "tp" | "or" | "ky" | "va" | "im" | "pa" | "su";
4
5
  export type OrdinaryCode = "ke" | "gl" | "cr" | "sa" | "ag" | "be" | "it" | "as" | "va";
@@ -92,11 +93,14 @@ export interface Chant {
92
93
  ordinary?: OrdinaryCode;
93
94
  ordinarium?: string;
94
95
  mass?: number;
96
+ section?: KyrialeSection;
95
97
  }
96
98
  export interface OrdinaryChant extends Chant {
97
99
  ordinary: OrdinaryCode;
98
100
  ordinarium: string;
101
+ /** One of Masses I–XVIII; 0 outside the numbered Masses (see `section`). */
99
102
  mass: number;
103
+ section: KyrialeSection;
100
104
  }
101
105
  export interface GenusCount {
102
106
  office: OfficeCode;
@@ -219,7 +223,10 @@ export interface PropriumQuery extends CantusQuery {
219
223
  export interface OrdinariumQuery extends CantusQuery {
220
224
  feast?: Feast | Feast[];
221
225
  ordinary?: OrdinaryCode;
226
+ /** One of Masses I–XVIII. */
222
227
  mass?: number;
228
+ /** A section of the Kyriale: "mass", "credo", "sprinkling", "ad-libitum", "requiem". */
229
+ section?: KyrialeSection;
223
230
  }
224
231
  export interface OfficiumQuery extends CantusQuery {
225
232
  feast?: Feast | Feast[];
@@ -21,7 +21,7 @@ import { decideBreak } from "./breaking.js";
21
21
  import { trimRuns } from "../lyric.js";
22
22
  import { GLYPHS, GLYPH_UPM } from "../../../data/smufl-glyphs.js";
23
23
  import { computeAccidentals, } from "./accidentals.js";
24
- import { GLYPH, SHAPE_GLYPH, DIVISIO_GLYPH, ligaturaDesc, } from "../../../data/gabc-glyphs.js";
24
+ import { GLYPH, SHAPE_GLYPH, headGlyph, tracedHollow, DIVISIO_GLYPH, ligaturaDesc, } from "../../../data/gabc-glyphs.js";
25
25
  import { buildChironomia, buildProsodia, buildTonarium, trackBands, } from "./tracks.js";
26
26
  function resolveFont(slot, fallback) {
27
27
  if (!slot)
@@ -352,14 +352,19 @@ function yAt(pos, systemY, L, r) {
352
352
  // SMuFL nominal (noteheads use r.noteScale; clefs/divisiones use 1). dyFont
353
353
  // shifts the glyph in font units (y-up) before the flip — used to re-register
354
354
  // base-registered components.
355
- function placeGlyph(code, x, y, r, cls, data = "", factor = 1, dyFont = 0) {
355
+ // `traced` draws the outline alone, at the staff line's weight whatever the
356
+ // scale — the hollow for a head Bravura carries only full.
357
+ function placeGlyph(code, x, y, r, cls, data = "", factor = 1, dyFont = 0, traced = false) {
356
358
  const g = GLYPHS[code];
357
359
  if (!g)
358
360
  return null;
359
361
  const s = r.glyphScale * factor;
360
362
  const yy = y - dyFont * s;
363
+ const paint = traced
364
+ ? `fill="none" stroke="${r.noteColor}" stroke-width="${r.lineWeight.toFixed(2)}" vector-effect="non-scaling-stroke"`
365
+ : `fill="${r.noteColor}"`;
361
366
  const svg = `<g class="${cls}"${data} transform="translate(${x.toFixed(2)} ${yy.toFixed(2)}) scale(${s.toFixed(5)} ${(-s).toFixed(5)})">` +
362
- `<path d="${g.path}" fill="${r.noteColor}"/></g>`;
367
+ `<path d="${g.path}" ${paint}/></g>`;
363
368
  return {
364
369
  svg,
365
370
  advance: g.advance * s,
@@ -484,10 +489,14 @@ export function toSvg(rows, chant, options = {}) {
484
489
  };
485
490
  // Place a notehead glyph for a row at x; returns the placement.
486
491
  const placeNote = (row, atX, code, dyFont = 0) => {
487
- const glyphCode = code ?? SHAPE_GLYPH[row.shape] ?? GLYPH.punctum;
492
+ // A liquescent with a head of its own draws it at full size; one Bravura
493
+ // does not carry (a liquescent virga, a figure component) keeps the small
494
+ // head that stood for every liquescence before the heads were routed.
495
+ const head = code ? null : headGlyph(row);
496
+ const glyphCode = code ?? head ?? SHAPE_GLYPH[row.shape] ?? GLYPH.punctum;
488
497
  const y = yFor(row.staffPosition, L, r);
489
- const sc = row.liquescent ? r.noteScale * 0.66 : r.noteScale;
490
- const p = placeGlyph(glyphCode, atX, y, r, "note", dataAttrs(row), sc, dyFont);
498
+ const sc = row.liquescent && !head ? r.noteScale * 0.66 : r.noteScale;
499
+ const p = placeGlyph(glyphCode, atX, y, r, "note", dataAttrs(row), sc, dyFont, !code && tracedHollow(row));
491
500
  if (!p)
492
501
  return null;
493
502
  ledger(row.staffPosition, p.inkLeft, p.inkRight);
@@ -1126,6 +1135,40 @@ export function toSvg(rows, chant, options = {}) {
1126
1135
  body.push(p.svg);
1127
1136
  }
1128
1137
  }
1138
+ // ── Signa over the note (GABC r1–r5) ──
1139
+ // Bravura carries the accentus and the semicirculus one way round only, so
1140
+ // one of each pair is the glyph mirrored about its own ink. For the accentus
1141
+ // that is the reversus; for the semicirculus it is the plain one — gregorio's
1142
+ // r4 is the cup, r5 the cap, and Bravura's "semicirculus above" is the cap.
1143
+ const SIGNUM = {
1144
+ accentus: { code: GLYPH.accentus },
1145
+ accentusReversus: { code: GLYPH.accentus, flip: "x" },
1146
+ circulus: { code: GLYPH.circulus },
1147
+ semicirculus: { code: GLYPH.semicirculus, flip: "y" },
1148
+ semicirculusReversus: { code: GLYPH.semicirculus },
1149
+ };
1150
+ for (const pl of placements) {
1151
+ const sign = pl.row.signum ? SIGNUM[pl.row.signum] : undefined;
1152
+ const g = sign ? GLYPHS[sign.code] : undefined;
1153
+ if (!sign || !g)
1154
+ continue;
1155
+ const s = r.glyphScale * r.noteScale;
1156
+ const w = (g.bbox[2] - g.bbox[0]) * s;
1157
+ const midX = (pl.inkLeft + pl.inkRight) / 2;
1158
+ const y = yAt(pl.row.staffPosition, pl.systemY, L, r) - r.noteheadH * 0.45;
1159
+ const p = placeGlyph(sign.code, midX - w / 2, y, r, "signum", "", r.noteScale);
1160
+ if (!p)
1161
+ continue;
1162
+ if (!sign.flip) {
1163
+ body.push(p.svg);
1164
+ continue;
1165
+ }
1166
+ const cy = y - ((g.bbox[1] + g.bbox[3]) / 2) * s;
1167
+ const m = sign.flip === "x"
1168
+ ? `translate(${(2 * midX).toFixed(2)} 0) scale(-1 1)`
1169
+ : `translate(0 ${(2 * cy).toFixed(2)}) scale(1 -1)`;
1170
+ body.push(`<g transform="${m}">${p.svg}</g>`);
1171
+ }
1129
1172
  // Close the final system; height reaches the last.
1130
1173
  systemMaxX.push(Math.max(x, prevLyricRight) + r.padding);
1131
1174
  // The canvas is what the CALLER asked for, not what the content happened to
@@ -41,6 +41,10 @@ function rawToNote(raw, scale) {
41
41
  liquescent: raw.liquescent,
42
42
  strophicus: raw.strophicus,
43
43
  oriscus: raw.oriscus,
44
+ liquescence: raw.liquescence,
45
+ oriscusDirection: raw.oriscusDirection,
46
+ hollow: raw.hollow,
47
+ signum: raw.signum,
44
48
  mora: raw.mora,
45
49
  weight: raw.weight,
46
50
  },
@@ -42,6 +42,27 @@ const DIVISIO_DURATIONS = new Map([
42
42
  const STAFF_STEPS = [0, 2, 4, 5, 7, 9, 11];
43
43
  const SYLLABLES_REGEX = /(?=.)((?:[^(])*)(?:\(?([^)]*)\)?)?/g;
44
44
  const NOTATIONS_REGEX = /z0|z|Z|::|:|[,;][1-6]?|`|[cf][1-4]|cb[1-4]|fb[1-4]|\/+| |\!|-?[a-mA-M][oOwWvVrRsxy#~\+><_\.'012345]*(?:\[[^\]]*\]?)*|\{([^}]+)\}?/g;
45
+ // GABC r1–r5, the signs gregorio prints over a note.
46
+ const SIGNA = Object.freeze({
47
+ "1": "accentus",
48
+ "2": "accentusReversus",
49
+ "3": "circulus",
50
+ "4": "semicirculus",
51
+ "5": "semicirculusReversus",
52
+ });
53
+ // An oriscus written without o0/o1 points at the next pitch that differs from
54
+ // its own: up is ascending, down (or nothing after it) descending. Gregorio's
55
+ // rule (gabc_determine_oriscus_orientation), run over the whole score because
56
+ // the next pitch is often in the next syllable.
57
+ function orientOrisci(events) {
58
+ const notes = events.filter((e) => e.type === "note");
59
+ notes.forEach((n, i) => {
60
+ if (!n.oriscus || n.oriscusDirection)
61
+ return;
62
+ const next = notes.slice(i + 1).find((m) => m.step !== n.step);
63
+ n.oriscusDirection = next && next.step > n.step ? "ascending" : "descending";
64
+ });
65
+ }
45
66
  // Helpers
46
67
  function initialAccidentalState(clef) {
47
68
  const state = new Map();
@@ -279,12 +300,22 @@ function parseNeume(notation, context) {
279
300
  // Written note shape (gregorio's S_* vocabulary, simplified) — what the
280
301
  // renderer draws. Priority: the inclinatum diamond and the ornamental
281
302
  // shapes win over the plain punctum/virga forms.
303
+ //
304
+ // The r family is read the way gregorio's lexer reads it: r alone empties
305
+ // the note (cavum), R draws it between two vertical lines, r0 does both,
306
+ // and r1–r5 are signs printed over a FULL note. Testing for a bare "r"
307
+ // made gr1 a hollow head, which is the one thing its accent does not say.
308
+ const marks = modifiers.replace(/\[[^\]]*\]?/g, "");
309
+ const hollow = /r(?![1-8])/.test(marks);
310
+ const signum = SIGNA[/r([1-5])/.exec(marks)?.[1] ?? ""] ?? null;
282
311
  let shape = "punctum";
283
312
  if (isInclinatum)
284
313
  shape = "inclinatum";
285
314
  else if (isQuilisma)
286
315
  shape = "quilisma";
287
- else if (isStrophicus)
316
+ // A lone s is gregorio's stropha too; the analytical `strophicus` flag
317
+ // above stays with the repeated ss/sss, whose weighting it drives.
318
+ else if (isStrophicus || marks.includes("s"))
288
319
  shape = "strophicus";
289
320
  else if (modifiers.includes("V"))
290
321
  shape = "virgaReversa";
@@ -292,10 +323,18 @@ function parseNeume(notation, context) {
292
323
  shape = "virga";
293
324
  else if (isOriscus)
294
325
  shape = "oriscus";
295
- else if (modifiers.includes("r"))
296
- shape = "cavum";
297
- else if (modifiers.includes("="))
298
- shape = "linea";
326
+ else if (marks.includes("R") || marks.includes("r0"))
327
+ shape = "lineaPunctum";
328
+ const liquescence = !isLiquescent ? null
329
+ : marks.includes("~") ? "deminutive"
330
+ : marks.includes("<") ? "ascending"
331
+ : "descending";
332
+ // o0 / o1 write the orientation; a bare o leaves it to the next pitch,
333
+ // resolved after the syllable's notes are known (below).
334
+ const oriscusDirection = !isOriscus ? null
335
+ : marks.includes("o1") ? "ascending"
336
+ : marks.includes("o0") ? "descending"
337
+ : null;
299
338
  intermed.push({
300
339
  step,
301
340
  degree,
@@ -318,6 +357,10 @@ function parseNeume(notation, context) {
318
357
  liquescent: isLiquescent,
319
358
  strophicus: isStrophicus,
320
359
  oriscus: isOriscus,
360
+ liquescence,
361
+ oriscusDirection,
362
+ hollow,
363
+ signum,
321
364
  mora,
322
365
  _weight: w,
323
366
  _durWeight: durWeight,
@@ -385,6 +428,10 @@ function parseNeume(notation, context) {
385
428
  liquescent: note.liquescent,
386
429
  strophicus: note.strophicus,
387
430
  oriscus: note.oriscus,
431
+ liquescence: note.liquescence,
432
+ oriscusDirection: note.oriscusDirection,
433
+ hollow: note.hollow,
434
+ signum: note.signum,
388
435
  mora: note.mora,
389
436
  };
390
437
  });
@@ -519,6 +566,7 @@ export function parseGABC(gabc, options = {}) {
519
566
  if (events.length === 0) {
520
567
  errors.push({ message: "No parseable notation found" });
521
568
  }
569
+ orientOrisci(events);
522
570
  return { events, errors };
523
571
  }
524
572
  //# sourceMappingURL=parse.js.map
@@ -1,6 +1,6 @@
1
1
  import type { ScoreIR, Neume, LyricRun } from "./types.js";
2
2
  import type { Cadence } from "./cadence.js";
3
- import type { ChantType, InterpretationOptions, WrittenShape } from "./types.js";
3
+ import type { ChantType, InterpretationOptions, Liquescence, Signum, WrittenShape } from "./types.js";
4
4
  export type NoteRole = "finalis" | "tenor" | "alia" | null;
5
5
  export interface ChantTabulaRow {
6
6
  phraseIndex: number;
@@ -74,6 +74,14 @@ export interface ChantTabulaRow {
74
74
  liquescent: boolean;
75
75
  strophicus: boolean;
76
76
  oriscus: boolean;
77
+ /** Which way a liquescent bends (see `Liquescence`); null when not liquescent. */
78
+ liquescence: Liquescence | null;
79
+ /** An oriscus's orientation — written (o0/o1) or read off the next pitch; null otherwise. */
80
+ oriscusDirection: "ascending" | "descending" | null;
81
+ /** Written hollow (GABC r, cavum). */
82
+ hollow: boolean;
83
+ /** Editorial sign over the note (GABC r1–r5); null when none. */
84
+ signum: Signum | null;
77
85
  /** Mora vocis count: 0 none, 1 dot '.', 2 double dot '..' (a stronger cadential hold). */
78
86
  mora: 0 | 1 | 2;
79
87
  divisio: string | null;
@@ -122,6 +122,10 @@ export function computeTabula(ir, options = {}) {
122
122
  liquescent: n.context.liquescent,
123
123
  strophicus: n.context.strophicus,
124
124
  oriscus: n.context.oriscus,
125
+ liquescence: n.context.liquescence,
126
+ oriscusDirection: n.context.oriscusDirection,
127
+ hollow: n.context.hollow,
128
+ signum: n.context.signum,
125
129
  mora: n.context.mora,
126
130
  divisio: a.divisio,
127
131
  cadenceRef: cadenceRefByPos.get(`${a.phraseIndex}:${a.syllableIndex}:${a.noteIndex}`) ?? null,
@@ -14,7 +14,14 @@ export interface Performance {
14
14
  rhythmicShape: ArsisThesis;
15
15
  rhythmicIndex: number;
16
16
  }
17
- export type WrittenShape = "punctum" | "inclinatum" | "virga" | "virgaReversa" | "quilisma" | "oriscus" | "strophicus" | "cavum" | "linea";
17
+ export type WrittenShape = "punctum" | "inclinatum" | "virga" | "virgaReversa" | "quilisma" | "oriscus" | "strophicus" | "lineaPunctum";
18
+ /**
19
+ * Which way a liquescent note bends, from its GABC mark: `~` deminutive (the
20
+ * small head), `<` augmented ascending, `>` augmented descending.
21
+ */
22
+ export type Liquescence = "deminutive" | "ascending" | "descending";
23
+ /** An editorial sign printed over the note, GABC r1–r5 (gregorio's names). */
24
+ export type Signum = "accentus" | "accentusReversus" | "circulus" | "semicirculus" | "semicirculusReversus";
18
25
  /**
19
26
  * One styled span of a syllable's lyric — the decoded form of GABC's text
20
27
  * markup (<i>, <b>, <sc>, <c>, elisions). The spans concatenate to the
@@ -71,6 +78,14 @@ export interface Context {
71
78
  liquescent: boolean;
72
79
  strophicus: boolean;
73
80
  oriscus: boolean;
81
+ /** Which way a liquescent bends (see `Liquescence`); null when not liquescent. */
82
+ liquescence: Liquescence | null;
83
+ /** An oriscus's orientation — written (o0/o1) or read off the next pitch; null otherwise. */
84
+ oriscusDirection: "ascending" | "descending" | null;
85
+ /** Written hollow (GABC r, cavum). */
86
+ hollow: boolean;
87
+ /** Editorial sign over the note (GABC r1–r5); null when none. */
88
+ signum: Signum | null;
74
89
  mora: 0 | 1 | 2;
75
90
  weight: number;
76
91
  }
@@ -204,6 +219,14 @@ export interface ParsedNote {
204
219
  liquescent: boolean;
205
220
  strophicus: boolean;
206
221
  oriscus: boolean;
222
+ /** Which way a liquescent bends (see `Liquescence`); null when not liquescent. */
223
+ liquescence: Liquescence | null;
224
+ /** An oriscus's orientation — written (o0/o1) or read off the next pitch; null otherwise. */
225
+ oriscusDirection: "ascending" | "descending" | null;
226
+ /** Written hollow (GABC r, cavum). */
227
+ hollow: boolean;
228
+ /** Editorial sign over the note (GABC r1–r5); null when none. */
229
+ signum: Signum | null;
207
230
  mora: 0 | 1 | 2;
208
231
  }
209
232
  export interface RestEvent {
package/docs/api/chant.md CHANGED
@@ -405,8 +405,12 @@ interface PropriumQuery extends CantusQuery {
405
405
  `ordinarium(query?)` retrieves the fixed chants of the Mass from the
406
406
  Kyriale. A feast drives mass selection through its `masses` list, the
407
407
  masses the day's Kyriale RUBRIC appoints, derived as described in
408
- [calendar.md](calendar.md#the-days-feasts--festum); `mass` pins a kyriale
409
- number directly. Where the rubric names several masses, the year rotates
408
+ [calendar.md](calendar.md#the-days-feasts--festum); `mass` pins one of the
409
+ eighteen numbered Masses directly, and `section` asks for a part of the book:
410
+ `mass` (Masses I–XVIII), `credo` (Credo I–VI), `sprinkling` (Asperges me,
411
+ Vidi aquam), `ad-libitum` (the Cantus ad libitum) or `requiem` (the Missa pro
412
+ defunctis). Only the `mass` section carries a mass number; every other entry
413
+ reads `mass: 0`. Where the rubric names several masses, the year rotates
410
414
  through them (same feast, same year → same answer, every time), and sibling
411
415
  printings under one number (Mass I prints two dismissals; Mass XVII prints
412
416
  Kyrie A/B/C) rotate with it. Slots resolve independently, which the book
@@ -420,7 +424,7 @@ const [easter] = tonus.festum({ date: new Date("2026-04-05") });
420
424
  tonus.ordinarium({ feast: easter });
421
425
  // ky Kyrie I (mass 1) — Lux et origo, Paschal time, every year
422
426
  // gl Gloria I (mass 1)
423
- // cr Credo III (mass 3) — the credo rotates on its own cycle
427
+ // cr Credo III (mass 0) — the credo rotates on its own cycle
424
428
  // sa Sanctus I (mass 1)
425
429
  // ag Agnus Dei I (mass 1)
426
430
  // it Ite Ia (mass 1)
@@ -437,7 +441,7 @@ sings Mass XVI whole with the Mass II Benedicamus. The ad libitum appendix
437
441
  is a **solemnity boost**, reachable only under the festal rubrics (it takes
438
442
  its turn in the rotation once every _n + 1_ years); it never reaches a
439
443
  penitential day or a feria, and the Requiem settings stay out of every
440
- calendar-driven pick (reachable by `ordinarium({ mass: 102 })` only). The
444
+ calendar-driven pick (reachable by `ordinarium({ section: "requiem" })` only). The
441
445
  Triduum returns no ordinary at all: Good Friday has no Mass, and the
442
446
  Vigil's ordinary belongs to Easter. A pinned `mass` overrides the Triduum
443
447
  rule and the rotation both.
@@ -452,6 +456,7 @@ me** (`as`) otherwise.
452
456
  ```js
453
457
  tonus.ordinarium({ ordinary: "ke" }); // every Kyrie
454
458
  tonus.ordinarium({ mass: 9, ordinary: "gl" }); // Gloria of Cum jubilo
459
+ tonus.ordinarium({ section: "ad-libitum", ordinary: "ke" }); // the eleven ad libitum Kyries
455
460
  ```
456
461
 
457
462
  | `ordinary` | `ordinarium` |
@@ -470,13 +475,15 @@ tonus.ordinarium({ mass: 9, ordinary: "gl" }); // Gloria of Cum jubilo
470
475
  interface OrdinaryChant extends Chant {
471
476
  ordinary: OrdinaryCode; // movement code
472
477
  ordinarium: string; // Latin movement name, "Kyrie eleison" …
473
- mass: number;
478
+ mass: number; // 1–18, or 0 outside the numbered Masses
479
+ section: "mass" | "credo" | "sprinkling" | "ad-libitum" | "requiem";
474
480
  }
475
481
 
476
482
  interface OrdinariumQuery extends CantusQuery {
477
483
  feast?: Feast | Feast[];
478
484
  ordinary?: OrdinaryCode;
479
- mass?: number;
485
+ mass?: number; // 1–18
486
+ section?: "mass" | "credo" | "sprinkling" | "ad-libitum" | "requiem";
480
487
  }
481
488
  ```
482
489
 
package/docs/api/score.md CHANGED
@@ -271,10 +271,15 @@ interface Context {
271
271
  liquescent: boolean;
272
272
  strophicus: boolean;
273
273
  oriscus: boolean;
274
+ liquescence: "deminutive" | "ascending" | "descending" | null; // ~ < >
275
+ oriscusDirection: "ascending" | "descending" | null; // o1/o0, else toward the next pitch
276
+ hollow: boolean; // cavum, gabc r
277
+ signum: "accentus" | "accentusReversus" | "circulus"
278
+ | "semicirculus" | "semicirculusReversus" | null; // gabc r1–r5
274
279
  mora: 0 | 1 | 2; // mora vocis: 0 none, 1 dot, 2 double dot
275
280
  staffLetter: string; // the GABC staff letter as written
276
281
  clef: string; // the clef in force at this note ("c3", "f4", …)
277
- shape: string; // the notehead shape (punctum, inclinatum, quilisma, …)
282
+ shape: string; // the notehead shape (punctum, inclinatum, quilisma, lineaPunctum, …)
278
283
  weight: number; // articulation weight
279
284
  }
280
285
  ```
@@ -326,6 +331,11 @@ interface ChantTabulaRow {
326
331
  liquescent: boolean;
327
332
  strophicus: boolean;
328
333
  oriscus: boolean;
334
+ liquescence: "deminutive" | "ascending" | "descending" | null; // ~ < >
335
+ oriscusDirection: "ascending" | "descending" | null; // o1/o0, else toward the next pitch
336
+ hollow: boolean; // cavum, gabc r
337
+ signum: "accentus" | "accentusReversus" | "circulus"
338
+ | "semicirculus" | "semicirculusReversus" | null; // gabc r1–r5
329
339
  mora: 0 | 1 | 2; // mora vocis: 0 none, 1 dot, 2 double dot
330
340
  hz: number;
331
341
  offset: number;
@@ -333,7 +343,7 @@ interface ChantTabulaRow {
333
343
  staffLetter: string; // the GABC staff letter as written
334
344
  staffPosition: number; // vertical staff position (line/space index)
335
345
  clef: string; // the clef in force at this note ("c3", "f4", …)
336
- shape: string; // the notehead shape (punctum, inclinatum, quilisma, …)
346
+ shape: string; // the notehead shape (punctum, inclinatum, quilisma, lineaPunctum, …)
337
347
  bend: number; // 14-bit MIDI pitch bend (8192 = center)
338
348
  velocity: number | null;
339
349
  duration: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonus",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Medieval music analysis: GABC plainchant exports, liturgical calendar, tuning systems, ephemeris, and the harmony of the spheres",
6
6
  "author": "Jeffrey Pierce <jeffrey@jeffreypierce.net>",