tonus 0.11.0 → 0.12.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.
@@ -149,7 +149,19 @@ const CORPUS = [
149
149
  // still resolve to exactly one chant — the census contract says so in as many
150
150
  // words. So they stay addressable by `id`, by `office: "or"`, and by
151
151
  // `ordinary`, and stay out of the shelf's book list and row count.
152
- const KYRIALE_CHANTS = KYRIALE.map(entryToOrdinaryChant);
152
+ //
153
+ // Not shelved, but CITED: each entry carries the books that print it (gr, lu)
154
+ // and their pages, so its by-book map is registered here the same way a corpus
155
+ // record's is. That is what lets `cantus({ id, source: "lu" })` answer with the
156
+ // Liber Usualis's leaf number instead of nothing.
157
+ const KYRIALE_CHANTS = KYRIALE.map((entry) => {
158
+ const chant = entryToOrdinaryChant(entry);
159
+ const byBook = entry.pages;
160
+ if (byBook && !Array.isArray(byBook)) {
161
+ PAGES_BY_BOOK.set(chant.id, byBook);
162
+ }
163
+ return chant;
164
+ });
153
165
  /** Everything nameable: the shelf, plus the ordinary that no shelf holds. */
154
166
  const ADDRESSABLE = [...CORPUS, ...KYRIALE_CHANTS];
155
167
  let _byId = null;
@@ -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);
@@ -280,11 +291,21 @@ export function entryToOrdinaryChant(entry) {
280
291
  const ordinary = ORDINARY_OFFICES.has(entry.office)
281
292
  ? entry.office
282
293
  : "ke";
294
+ // The Kyriale's citation. `ky` is a category, not a volume, so it can never
295
+ // be cited — which is why these chants shipped with books: ["ky"] and no page
296
+ // at all, and nothing downstream could name where to find them. But they ARE
297
+ // printed: the extractor now carries the Graduale's and the Liber Usualis's
298
+ // listings on every entry. `books` is those volumes, owner first; `pages` is
299
+ // the OWNER's, exactly as chant.ts's withLabels() does for a shelf record —
300
+ // presentAs() swaps in the asked-for book's when a caller names one. An entry
301
+ // with no citation still falls back to its own printing, so it stays findable.
302
+ const books = (entry.books?.length ? entry.books : ["ky"]);
303
+ const pages = entry.pages?.[books[0]] ?? [];
283
304
  return {
284
305
  id: entry.id,
285
306
  incipit: entry.incipit,
286
307
  gabc: entry.gabc,
287
- books: ["ky"],
308
+ books,
288
309
  office: "ky",
289
310
  // The genus follows the office code, as everywhere else — OFFICIA.ky.
290
311
  // It read "Ordinarium" while the office read "or", which was tonus's own
@@ -292,11 +313,15 @@ export function entryToOrdinaryChant(entry) {
292
313
  genus: OFFICIA.ky,
293
314
  mode: entry.mode ? String(entry.mode) : null,
294
315
  modus: entry.mode ? (MODI[String(entry.mode)] ?? null) : null,
295
- pages: [],
316
+ pages,
317
+ // Still KY_SOURCE: the bibliographic note says a singer is holding the
318
+ // Kyriale, which is true, and is a different claim from which shelved
319
+ // volumes print it. `books` carries that.
296
320
  source: KY_SOURCE,
297
321
  ordinary,
298
322
  ordinarium: ORDINARIA[ordinary] ?? entry.incipit,
299
323
  mass: entry.mass ?? 0,
324
+ section: entry.section,
300
325
  };
301
326
  }
302
327
  // Maundy Thursday (In Cena Domini) is a Triduum exception: it retains a full
@@ -441,7 +466,7 @@ function assertFeasts(feasts, method) {
441
466
  throw new Error(`${method}: feast must be a Feast (from tonus.festum) — got ${typeof f}`);
442
467
  }
443
468
  }
444
- 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"]);
445
470
  /**
446
471
  * Mass ordinary retrieval (`tonus.ordinarium`) from the Kyriale. A feast
447
472
  * drives mass selection; `mass` pins a kyriale number directly.
@@ -459,6 +484,10 @@ export function getOrdinary(query) {
459
484
  if (query.ordinary) {
460
485
  assertOrdinaryCodes(Array.isArray(query.ordinary) ? query.ordinary : [query.ordinary], "ordinarium");
461
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
+ }
462
491
  const feasts = toArray(query.feast);
463
492
  assertFeasts(feasts, "ordinarium");
464
493
  // `mode` accepts a scalar or an array, with cantus's semantics: match any.
@@ -480,7 +509,7 @@ export function getOrdinary(query) {
480
509
  return ordinaryForFeast(f, query.mass, filterModes, adm);
481
510
  });
482
511
  }
483
- else if (query.mass != null || query.ordinary) {
512
+ else if (query.mass != null || query.ordinary || query.section) {
484
513
  // Direct kyriale query without feast context — same admissibility rule as
485
514
  // cantus, so the two doors cannot disagree.
486
515
  let entries = KYRIALE.slice();
@@ -490,6 +519,8 @@ export function getOrdinary(query) {
490
519
  }
491
520
  if (query.mass != null)
492
521
  entries = entries.filter((e) => e.mass === query.mass);
522
+ if (query.section)
523
+ entries = entries.filter((e) => e.section === query.section);
493
524
  if (query.ordinary)
494
525
  entries = entries.filter((e) => e.office === query.ordinary);
495
526
  if (filterModes)
@@ -502,6 +533,9 @@ export function getOrdinary(query) {
502
533
  return [];
503
534
  }
504
535
  // Apply remaining CantusQuery filters
536
+ if (feasts && query.section) {
537
+ results = results.filter((c) => c.section === query.section);
538
+ }
505
539
  if (query.incipit) {
506
540
  const needle = query.incipit.toLowerCase();
507
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[];
package/docs/api/chant.md CHANGED
@@ -311,6 +311,18 @@ The Kyriale is a **partition of the Graduale**, so it is not a `source` and not
311
311
  a row in the shelf. It stays nameable by `id` and by the part of the Mass it
312
312
  belongs to.
313
313
 
314
+ It is not a book, but it is **printed in** books, and it says which: every
315
+ ordinary chant carries `books: ["lu", "gr"]` and the Graduale's and the Liber
316
+ Usualis's pages, so it can be cited like anything else on the shelf.
317
+
318
+ ```js
319
+ tonus.cantus({ id: "gregobase:31", source: "gr" })[0].pages; // [{ page: "28*", … }]
320
+ tonus.cantus({ id: "gregobase:31", source: "lu" })[0].pages; // [{ page: "37", … }]
321
+ ```
322
+
323
+ `source` stays `ky` when no book is named — the note a singer holding the
324
+ Kyriale would want — and `ky` is still absent from the book registry.
325
+
314
326
  A plain search does not sweep it in: `{ mode: 5 }` returns the shelf. Ask for a
315
327
  Kyrie and you get Kyries.
316
328
 
@@ -393,8 +405,12 @@ interface PropriumQuery extends CantusQuery {
393
405
  `ordinarium(query?)` retrieves the fixed chants of the Mass from the
394
406
  Kyriale. A feast drives mass selection through its `masses` list, the
395
407
  masses the day's Kyriale RUBRIC appoints, derived as described in
396
- [calendar.md](calendar.md#the-days-feasts--festum); `mass` pins a kyriale
397
- 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
398
414
  through them (same feast, same year → same answer, every time), and sibling
399
415
  printings under one number (Mass I prints two dismissals; Mass XVII prints
400
416
  Kyrie A/B/C) rotate with it. Slots resolve independently, which the book
@@ -408,7 +424,7 @@ const [easter] = tonus.festum({ date: new Date("2026-04-05") });
408
424
  tonus.ordinarium({ feast: easter });
409
425
  // ky Kyrie I (mass 1) — Lux et origo, Paschal time, every year
410
426
  // gl Gloria I (mass 1)
411
- // 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
412
428
  // sa Sanctus I (mass 1)
413
429
  // ag Agnus Dei I (mass 1)
414
430
  // it Ite Ia (mass 1)
@@ -425,7 +441,7 @@ sings Mass XVI whole with the Mass II Benedicamus. The ad libitum appendix
425
441
  is a **solemnity boost**, reachable only under the festal rubrics (it takes
426
442
  its turn in the rotation once every _n + 1_ years); it never reaches a
427
443
  penitential day or a feria, and the Requiem settings stay out of every
428
- calendar-driven pick (reachable by `ordinarium({ mass: 102 })` only). The
444
+ calendar-driven pick (reachable by `ordinarium({ section: "requiem" })` only). The
429
445
  Triduum returns no ordinary at all: Good Friday has no Mass, and the
430
446
  Vigil's ordinary belongs to Easter. A pinned `mass` overrides the Triduum
431
447
  rule and the rotation both.
@@ -440,6 +456,7 @@ me** (`as`) otherwise.
440
456
  ```js
441
457
  tonus.ordinarium({ ordinary: "ke" }); // every Kyrie
442
458
  tonus.ordinarium({ mass: 9, ordinary: "gl" }); // Gloria of Cum jubilo
459
+ tonus.ordinarium({ section: "ad-libitum", ordinary: "ke" }); // the eleven ad libitum Kyries
443
460
  ```
444
461
 
445
462
  | `ordinary` | `ordinarium` |
@@ -458,13 +475,15 @@ tonus.ordinarium({ mass: 9, ordinary: "gl" }); // Gloria of Cum jubilo
458
475
  interface OrdinaryChant extends Chant {
459
476
  ordinary: OrdinaryCode; // movement code
460
477
  ordinarium: string; // Latin movement name, "Kyrie eleison" …
461
- mass: number;
478
+ mass: number; // 1–18, or 0 outside the numbered Masses
479
+ section: "mass" | "credo" | "sprinkling" | "ad-libitum" | "requiem";
462
480
  }
463
481
 
464
482
  interface OrdinariumQuery extends CantusQuery {
465
483
  feast?: Feast | Feast[];
466
484
  ordinary?: OrdinaryCode;
467
- mass?: number;
485
+ mass?: number; // 1–18
486
+ section?: "mass" | "credo" | "sprinkling" | "ad-libitum" | "requiem";
468
487
  }
469
488
  ```
470
489
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonus",
3
- "version": "0.11.0",
3
+ "version": "0.12.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>",