tonus 0.9.0 → 0.10.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +3 -3
  3. package/dist/census.d.ts +4 -0
  4. package/dist/census.js +23 -0
  5. package/dist/corpus.d.ts +4 -0
  6. package/dist/corpus.js +17 -0
  7. package/dist/data/am.js +10220 -3006
  8. package/dist/data/attestation.js +38640 -4568
  9. package/dist/data/attestation.json +38639 -4567
  10. package/dist/data/census.d.ts +2 -2
  11. package/dist/data/census.js +4 -4
  12. package/dist/data/corpus-overlap.js +5 -118
  13. package/dist/data/gr.d.ts +29 -2
  14. package/dist/data/gr.js +2436 -6411
  15. package/dist/data/kyriale.d.ts +1 -1
  16. package/dist/data/kyriale.js +38 -38
  17. package/dist/data/la.js +12369 -1103
  18. package/dist/data/lh.js +3139 -158
  19. package/dist/data/lu.js +17332 -2379
  20. package/dist/data/nocturnale-romanum.js +10656 -1899
  21. package/dist/data/psm.js +463 -33
  22. package/dist/data/types.d.ts +1 -0
  23. package/dist/engines/chant/chant.js +60 -13
  24. package/dist/engines/chant/data/masses.js +18 -18
  25. package/dist/engines/chant/ordinary.js +12 -5
  26. package/dist/engines/chant/psalm.js +4 -0
  27. package/dist/engines/chant/types.d.ts +32 -4
  28. package/dist/engines/chant/types.js +47 -4
  29. package/dist/engines/score/emitters/moderna.js +150 -12
  30. package/dist/engines/score/infer.js +1 -1
  31. package/dist/engines/temper/gabc.d.ts +12 -0
  32. package/dist/engines/temper/gabc.js +51 -18
  33. package/dist/index.d.ts +5 -9
  34. package/dist/inscriptio.d.ts +5 -0
  35. package/dist/inscriptio.js +23 -0
  36. package/dist/score.d.ts +6 -0
  37. package/dist/score.js +20 -0
  38. package/docs/api/calendar.md +4 -4
  39. package/docs/api/census.md +31 -22
  40. package/docs/api/chant.md +102 -82
  41. package/docs/api/heavens.md +7 -7
  42. package/docs/api/index.md +40 -6
  43. package/docs/api/score.md +44 -44
  44. package/docs/api/tuning.md +14 -14
  45. package/package.json +22 -4
  46. package/dist/data/ams.d.ts +0 -5
  47. package/dist/data/ams.js +0 -122
  48. package/dist/data/cot.d.ts +0 -5
  49. package/dist/data/cot.js +0 -172
  50. package/dist/data/cse.d.ts +0 -5
  51. package/dist/data/cse.js +0 -122
@@ -1,4 +1,4 @@
1
- import { OFFICIA, MODI, CANTUS_QUERY_KEYS } from "./types.js";
1
+ import { OFFICIA, MODI, CANTUS_QUERY_KEYS, assertOrdinaryCodes } from "./types.js";
2
2
  import { CORPUS_OVERLAP, CORPUS_FULL } from "../../data/corpus-overlap.js";
3
3
  import { attestationCutoff, chantAdmissible } from "./attest.js";
4
4
  import { GR_DATA, GR_SOURCE } from "../../data/gr.js";
@@ -9,11 +9,8 @@ import { AM_DATA, AM_SOURCE } from "../../data/am.js";
9
9
  import { NR_DATA, NR_SOURCE } from "../../data/nocturnale-romanum.js";
10
10
  // Office books — the antiphons and short responsories that fill weekday
11
11
  // office slots; see ChantSource in types.ts for why.
12
- import { AMS_DATA, AMS_SOURCE } from "../../data/ams.js";
13
12
  import { PSM_DATA, PSM_SOURCE } from "../../data/psm.js";
14
13
  // Further marked Solesmes books — see ChantSource in types.ts.
15
- import { CSE_DATA, CSE_SOURCE } from "../../data/cse.js";
16
- import { COT_DATA, COT_SOURCE } from "../../data/cot.js";
17
14
  import { KYRIALE } from "../../data/kyriale.js";
18
15
  import { entryToOrdinaryChant } from "./ordinary.js";
19
16
  function modusOf(mode) {
@@ -64,11 +61,12 @@ function chantFromGABC(query) {
64
61
  })();
65
62
  const office = (query.office
66
63
  ? (Array.isArray(query.office) ? query.office[0] : query.office)
67
- : officeFromHeader ?? "or");
64
+ : officeFromHeader ?? "va");
68
65
  return [{
69
66
  id: `gabc:${incipit.toLowerCase().replace(/\s+/g, "_") || "untitled"}`,
70
67
  incipit,
71
68
  gabc: body,
69
+ books: ["user"],
72
70
  office,
73
71
  genus: OFFICIA[office] ?? office,
74
72
  mode: mode ?? null,
@@ -77,10 +75,53 @@ function chantFromGABC(query) {
77
75
  source: { book: "User", year: null, editor: null, code: "user" },
78
76
  }];
79
77
  }
78
+ // Every printing's pages, by chant id then book code. Kept beside the corpus
79
+ // rather than on the Chant: a caller gets the pages for the book they ASKED
80
+ // about (see presentAs), and carrying all of them on every record would invite
81
+ // reading the wrong volume's citation.
82
+ const PAGES_BY_BOOK = new Map();
83
+ /**
84
+ * The same chant, presented as the book the caller asked for.
85
+ *
86
+ * A melody printed in several books is stored once, under one owner, so its
87
+ * `source` and `pages` are the owner's. Ask `cantus({ source: "gr" })` and you
88
+ * mean the Graduale: you want the Graduale's bibliographic record and the
89
+ * Graduale's page numbers, not the Liber Usualis's because that is where the
90
+ * record happens to be filed. `books` is unchanged — the shelf is the shelf.
91
+ */
92
+ // The owner's pages, and the whole map remembered for presentAs. A generated
93
+ // record always carries the map (one key when one book prints it); a hand-built
94
+ // Chant may carry a bare array, which is taken as it stands.
95
+ function pagesFor(c, code) {
96
+ const byBook = c.pages;
97
+ if (Array.isArray(byBook))
98
+ return byBook;
99
+ const map = byBook;
100
+ PAGES_BY_BOOK.set(c.id, map);
101
+ return (code && map[code]) || Object.values(map)[0] || [];
102
+ }
103
+ function presentAs(chant, code) {
104
+ if (chant.source.code === code)
105
+ return chant;
106
+ const src = SOURCES[code];
107
+ if (!src)
108
+ return chant;
109
+ const pages = PAGES_BY_BOOK.get(chant.id)?.[code];
110
+ return { ...chant, source: src, pages: pages ?? chant.pages };
111
+ }
80
112
  function withLabels(c, source) {
113
+ // `books` rides the generated data. The `??` is not defensive padding: the
114
+ // Kyriale is emitted by a different builder that never carried the field, and
115
+ // a chant printed by exactly one book is fully described by its own code. A
116
+ // record arriving without a listing is therefore listed under itself, which is
117
+ // true, rather than under nothing, which would make it unfindable by source.
81
118
  return {
82
119
  ...c,
83
120
  source,
121
+ // The one narrowing point: ChantData carries string[] so the generated
122
+ // literals do not hand tsc a 9-member union ~7,700 times over (TS2590).
123
+ books: (c.books?.length ? c.books : [source.code ?? "user"]),
124
+ pages: pagesFor(c, source.code),
84
125
  genus: OFFICIA[c.office] ?? c.office,
85
126
  modus: modusOf(c.mode),
86
127
  };
@@ -92,10 +133,7 @@ const CORPUS = [
92
133
  ...LH_DATA.map((c) => withLabels(c, LH_SOURCE)),
93
134
  ...AM_DATA.map((c) => withLabels(c, AM_SOURCE)),
94
135
  ...NR_DATA.map((c) => withLabels(c, NR_SOURCE)),
95
- ...AMS_DATA.map((c) => withLabels(c, AMS_SOURCE)),
96
136
  ...PSM_DATA.map((c) => withLabels(c, PSM_SOURCE)),
97
- ...CSE_DATA.map((c) => withLabels(c, CSE_SOURCE)),
98
- ...COT_DATA.map((c) => withLabels(c, COT_SOURCE)),
99
137
  ];
100
138
  // The Kyriale, addressable but not shelved — ruled 2026-08-04.
101
139
  //
@@ -128,8 +166,7 @@ function byId() {
128
166
  export const SOURCES = {
129
167
  gr: GR_SOURCE, lu: LU_SOURCE, la: LA_SOURCE, lh: LH_SOURCE, am: AM_SOURCE, nr: NR_SOURCE,
130
168
  // office books — provenance, not acquisition
131
- ams: AMS_SOURCE, psm: PSM_SOURCE,
132
- cse: CSE_SOURCE, cot: COT_SOURCE,
169
+ psm: PSM_SOURCE,
133
170
  };
134
171
  // Tally a book's genre and mode distribution — computed once per code, cached.
135
172
  const _corpusCache = new Map();
@@ -213,7 +250,7 @@ function corpusLedger() {
213
250
  }
214
251
  _ledger = {
215
252
  count: byId().size,
216
- listings: CORPUS.length,
253
+ listings: CORPUS.reduce((n, c) => n + c.books.filter((b) => b in SOURCES).length, 0),
217
254
  // Sum only what was measured: a book outside GregoBase reports null rather
218
255
  // than a false zero, and adding null in as 0 would understate the shelf
219
256
  // while looking like a total.
@@ -231,7 +268,10 @@ function oneCorpus(code) {
231
268
  const src = SOURCES[code];
232
269
  if (!src)
233
270
  throw new Error(`Unknown corpus code: "${code}" (expected ${Object.keys(SOURCES).join(", ")})`);
234
- const chants = CORPUS.filter((c) => c.source.code === code);
271
+ // What the book PRINTS. Reading `source.code` here would count only the
272
+ // records this book happens to store, which is why the Antiphonarius used to
273
+ // report 1,422 of a book it holds 2,501 of.
274
+ const chants = CORPUS.filter((c) => c.books.includes(code));
235
275
  // Genre distribution — count by office code, descending by count.
236
276
  const officeCounts = new Map();
237
277
  // Mode distribution — count by mode 1–8; everything else (p/d/e, null) into one bucket.
@@ -332,6 +372,8 @@ export function getChants(query) {
332
372
  // source and report an id from any book as belonging to that one.
333
373
  const ids = toArray(query.id);
334
374
  const ordinaries = toArray(query.ordinary);
375
+ if (ordinaries)
376
+ assertOrdinaryCodes(ordinaries, "cantus");
335
377
  // The default pool is the SHELF. The Kyriale is not on it (see KYRIALE_CHANTS
336
378
  // above), so it is reached deliberately: by `id`, which must resolve any
337
379
  // chant tonus holds, or by `ordinary`, which asks for a part of the Mass and
@@ -350,7 +392,12 @@ export function getChants(query) {
350
392
  const sources = toArray(query.source);
351
393
  if (sources) {
352
394
  const set = new Set(sources);
353
- out = out.filter((c) => c.source.code != null && set.has(c.source.code));
395
+ out = out
396
+ .filter((c) => c.books.some((b) => set.has(b)))
397
+ .map((c) => {
398
+ const asked = c.books.find((b) => set.has(b));
399
+ return asked ? presentAs(c, asked) : c;
400
+ });
354
401
  }
355
402
  const offices = toArray(query.office);
356
403
  if (offices) {
@@ -309,123 +309,123 @@ export function massesForRubric(rubric) {
309
309
  export const MASS_CENTURY = {
310
310
  // Mass I — Lux et Origo
311
311
  1: {
312
- ky: { from: 10, to: 10, printed: "X. s." },
312
+ ke: { from: 10, to: 10, printed: "X. s." },
313
313
  gl: { from: 10, to: 10, printed: "X. s." },
314
314
  sa: { from: 10, to: 10, printed: "X. s." },
315
315
  ag: { from: 10, to: 10, printed: "X. s." },
316
316
  },
317
317
  // Mass II — Kyrie fons bonitatis
318
318
  2: {
319
- ky: { from: 10, to: 10, printed: "X. s." },
319
+ ke: { from: 10, to: 10, printed: "X. s." },
320
320
  gl: { from: 13, to: 13, printed: "XIII. s." },
321
321
  ag: { from: 10, to: 10, printed: "X. s." },
322
322
  },
323
323
  // Mass III — Kyrie Deus sempiterne
324
324
  3: {
325
- ky: { from: 11, to: 11, inferred: true, printed: "XI. s." },
325
+ ke: { from: 11, to: 11, inferred: true, printed: "XI. s." },
326
326
  gl: { from: 11, to: 11, printed: "XI. s." },
327
327
  sa: { from: 12, to: 12, alt: 11, printed: "(XI) XII. s." },
328
328
  ag: { from: 11, to: 12, printed: "XI-XII. s." },
329
329
  },
330
330
  // Mass IV — Cunctipotens Genitor Deus
331
331
  4: {
332
- ky: { from: 10, to: 10, printed: "X. s." },
332
+ ke: { from: 10, to: 10, printed: "X. s." },
333
333
  gl: { from: 10, to: 10, printed: "X. s." },
334
334
  sa: { from: 11, to: 11, printed: "XI. s." },
335
335
  ag: { from: 13, to: 13, alt: 12, printed: "(XII) XIII. s." },
336
336
  },
337
337
  // Mass V — Kyrie magnæ Deus potentiæ
338
338
  5: {
339
- ky: { from: 13, to: 13, printed: "XIII. s." },
339
+ ke: { from: 13, to: 13, printed: "XIII. s." },
340
340
  gl: { from: 12, to: 12, printed: "XII. s." },
341
341
  sa: { from: 12, to: 12, printed: "XII. s." },
342
342
  ag: { from: 12, to: 12, printed: "XII. s." },
343
343
  },
344
344
  // Mass VI — Kyrie Rex Genitor
345
345
  6: {
346
- ky: { from: 10, to: 10, printed: "X. s." },
346
+ ke: { from: 10, to: 10, printed: "X. s." },
347
347
  gl: { from: 10, to: 10, printed: "X. s." },
348
348
  sa: { from: 11, to: 11, printed: "XI. s." },
349
349
  ag: { from: 11, to: 11, printed: "XI. s." },
350
350
  },
351
351
  // Mass VII — Kyrie Rex splendes
352
352
  7: {
353
- ky: { from: 10, to: 10, printed: "X. s." },
353
+ ke: { from: 10, to: 10, printed: "X. s." },
354
354
  gl: { from: 12, to: 12, printed: "XII. s." },
355
355
  sa: { from: 11, to: 11, printed: "XI. s." },
356
356
  ag: { from: 15, to: 15, printed: "XV. s." },
357
357
  },
358
358
  // Mass VIII — de Angelis
359
359
  8: {
360
- ky: { from: 15, to: 16, printed: "XV-XVI. s." },
360
+ ke: { from: 15, to: 16, printed: "XV-XVI. s." },
361
361
  gl: { from: 16, to: 16, printed: "XVI. s." },
362
362
  sa: { from: 12, to: 12, alt: 11, printed: "(XI) XII. s." },
363
363
  ag: { from: 15, to: 15, printed: "XV. s." },
364
364
  },
365
365
  // Mass IX — Cum Jubilo
366
366
  9: {
367
- ky: { from: 12, to: 12, printed: "XII. s." },
367
+ ke: { from: 12, to: 12, printed: "XII. s." },
368
368
  gl: { from: 11, to: 11, printed: "XI. s." },
369
369
  sa: { from: 14, to: 14, printed: "XIV. s." },
370
370
  ag: { from: 13, to: 13, alt: 10, printed: "(X) XIII. s." },
371
371
  },
372
372
  // Mass X — Alme Pater
373
373
  10: {
374
- ky: { from: 11, to: 11, printed: "XI. s." },
374
+ ke: { from: 11, to: 11, printed: "XI. s." },
375
375
  gl: { from: 15, to: 15, printed: "XV. s." },
376
376
  sa: { from: null, to: null, printed: "?. s." },
377
377
  ag: { from: 12, to: 12, printed: "XII. s." },
378
378
  },
379
379
  // Mass XI — Orbis factor
380
380
  11: {
381
- ky: { from: 14, to: 16, alt: 10, printed: "(X) XIV-XVI. s." },
381
+ ke: { from: 14, to: 16, alt: 10, printed: "(X) XIV-XVI. s." },
382
382
  gl: { from: 10, to: 10, printed: "X. s." },
383
383
  sa: { from: 10, to: 10, printed: "X. s." },
384
384
  ag: { from: 14, to: 14, printed: "XIV. s." },
385
385
  },
386
386
  // Mass XII — Pater cuncta
387
387
  12: {
388
- ky: { from: 12, to: 12, printed: "XII. s." },
388
+ ke: { from: 12, to: 12, printed: "XII. s." },
389
389
  gl: { from: 12, to: 12, inferred: true, printed: "XII. s." },
390
390
  sa: { from: 13, to: 13, printed: "XIII. s." },
391
391
  ag: { from: 11, to: 11, printed: "XI. s." },
392
392
  },
393
393
  // Mass XIII — Stelliferi Conditor orbis
394
394
  13: {
395
- ky: { from: 11, to: 11, printed: "XI. s." },
395
+ ke: { from: 11, to: 11, printed: "XI. s." },
396
396
  gl: { from: 12, to: 12, printed: "XII. s." },
397
397
  sa: { from: 13, to: 13, printed: "XIII. s." },
398
398
  ag: { from: null, to: null, printed: "? s." },
399
399
  },
400
400
  // Mass XIV — Jesu Redemptor
401
401
  14: {
402
- ky: { from: 10, to: 10, inferred: true, printed: "X. s." },
402
+ ke: { from: 10, to: 10, inferred: true, printed: "X. s." },
403
403
  gl: { from: 10, to: 10, printed: "X. s." },
404
404
  sa: { from: 12, to: 12, inferred: true, printed: "XII. s." },
405
405
  ag: { from: 13, to: 13, printed: "XIII. s." },
406
406
  },
407
407
  // Mass XV — Dominator Deus
408
408
  15: {
409
- ky: { from: 11, to: 13, printed: "XI-XIII. s." },
409
+ ke: { from: 11, to: 13, printed: "XI-XIII. s." },
410
410
  gl: { from: 10, to: 10, printed: "X. s." },
411
411
  sa: { from: 10, to: 10, printed: "X. s." },
412
412
  ag: { from: 14, to: 14, alt: 12, printed: "(XII) XIV. s." },
413
413
  },
414
414
  // Mass XVI
415
415
  16: {
416
- ky: { from: 11, to: 13, printed: "XI-XIII. s." },
416
+ ke: { from: 11, to: 13, printed: "XI-XIII. s." },
417
417
  sa: { from: 13, to: 13, printed: "XIII. s." },
418
418
  ag: { from: 10, to: 11, printed: "X-XI. s." },
419
419
  },
420
420
  // Mass XVII
421
421
  17: {
422
- ky: { from: 15, to: 17, alt: 10, printed: "(X) XV-XVII. s." },
422
+ ke: { from: 15, to: 17, alt: 10, printed: "(X) XV-XVII. s." },
423
423
  sa: { from: 11, to: 11, printed: "XI. s." },
424
424
  ag: { from: 13, to: 13, printed: "XIII. s." },
425
425
  },
426
426
  // Mass XVIII — Deus Genitor alme
427
427
  18: {
428
- ky: { from: 11, to: 11, printed: "XI. s." },
428
+ ke: { from: 11, to: 11, printed: "XI. s." },
429
429
  sa: { from: 13, to: 13, printed: "XIII. s." },
430
430
  ag: { from: 12, to: 12, printed: "XII. s." },
431
431
  },
@@ -5,7 +5,7 @@ import { MASSES, AD_LIB, WHOLE_MASS_RUBRICS, partWithinEra, } from "./data/masse
5
5
  import { KYRIALE } from "../../data/kyriale.js";
6
6
  import { attestationCutoff, eraCutoff, chantAdmissible } from "./attest.js";
7
7
  import { CANTUS_QUERY_KEYS } from "./types.js";
8
- import { KY_SOURCE, MODI, ORDINARIA, } from "./types.js";
8
+ import { KY_SOURCE, MODI, ORDINARIA, OFFICIA, assertOrdinaryCodes, } from "./types.js";
9
9
  const ORDINARY_OFFICES = new Set(Object.keys(ORDINARIA));
10
10
  const MODE_PAIRS = [[1, 2], [3, 4], [5, 6], [7, 8]];
11
11
  const CREDO_PRIORITY = ["IV", "III", "I", "II", "V", "VI"];
@@ -279,13 +279,17 @@ function selectCredoCode(feast, allowed, year) {
279
279
  export function entryToOrdinaryChant(entry) {
280
280
  const ordinary = ORDINARY_OFFICES.has(entry.office)
281
281
  ? entry.office
282
- : "ky";
282
+ : "ke";
283
283
  return {
284
284
  id: entry.id,
285
285
  incipit: entry.incipit,
286
286
  gabc: entry.gabc,
287
- office: "or",
288
- genus: "Ordinarium",
287
+ books: ["ky"],
288
+ office: "ky",
289
+ // The genus follows the office code, as everywhere else — OFFICIA.ky.
290
+ // It read "Ordinarium" while the office read "or", which was tonus's own
291
+ // pairing; GregoBase files these under ky = Kyriale.
292
+ genus: OFFICIA.ky,
289
293
  mode: entry.mode ? String(entry.mode) : null,
290
294
  modus: entry.mode ? (MODI[String(entry.mode)] ?? null) : null,
291
295
  pages: [],
@@ -353,7 +357,7 @@ function ordinaryForFeast(feast, pinMass, filterModes, admissible) {
353
357
  return best ? entryToOrdinaryChant(best) : null;
354
358
  };
355
359
  const results = [];
356
- const ky = pick("ky");
360
+ const ky = pick("ke");
357
361
  if (ky)
358
362
  results.push(ky);
359
363
  // Gloria is omitted under the gloria-less rubrics — penitential Sundays and
@@ -452,6 +456,9 @@ export function getOrdinary(query) {
452
456
  throw new Error(`ordinarium: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
453
457
  `(expected ${[...ORDINARIUM_QUERY_KEYS].join(", ")}).`);
454
458
  }
459
+ if (query.ordinary) {
460
+ assertOrdinaryCodes(Array.isArray(query.ordinary) ? query.ordinary : [query.ordinary], "ordinarium");
461
+ }
455
462
  const feasts = toArray(query.feast);
456
463
  assertFeasts(feasts, "ordinarium");
457
464
  // `mode` accepts a scalar or an array, with cantus's semantics: match any.
@@ -36,6 +36,10 @@ function verseToChant(v, mode, differentia, intonation, inDirectum, solemn) {
36
36
  id: `psalm:${v.psalm}:${v.verse}`,
37
37
  incipit: v.half1.slice(0, 40),
38
38
  gabc,
39
+ // Empty, and meaningfully so: a verse pointed to a tone at request time is
40
+ // not a printing of any book on the shelf, which is exactly why no
41
+ // `cantus({ source })` can ever return one.
42
+ books: [],
39
43
  office: "ps",
40
44
  genus: "Psalmus",
41
45
  mode: String(mode),
@@ -1,7 +1,7 @@
1
1
  import type { Season, Grade, Feast } from "../cal/types.js";
2
2
  export type { Season, Grade, Feast };
3
- export type OfficeCode = "an" | "al" | "ca" | "co" | "gr" | "hy" | "in" | "of" | "ps" | "re" | "rb" | "se" | "tr" | "tp" | "or" | "im" | "pa" | "su";
4
- export type OrdinaryCode = "ky" | "gl" | "cr" | "sa" | "ag" | "be" | "it" | "as" | "va";
3
+ export type OfficeCode = "an" | "al" | "ca" | "co" | "gr" | "hy" | "in" | "of" | "ps" | "re" | "rb" | "se" | "tr" | "tp" | "or" | "ky" | "va" | "im" | "pa" | "su";
4
+ export type OrdinaryCode = "ke" | "gl" | "cr" | "sa" | "ag" | "be" | "it" | "as" | "va";
5
5
  /**
6
6
  * The books a chant can come from. `source` is PROVENANCE, not an acquisition
7
7
  * unit: a book appears here because some chant the liturgy asks for is found in
@@ -24,7 +24,7 @@ export type OrdinaryCode = "ky" | "gl" | "cr" | "sa" | "ag" | "be" | "it" | "as"
24
24
  * dies" — the Friday Vespers antiphon the widening was for. Every chant tonus
25
25
  * now ships carries rhythmic notation.
26
26
  */
27
- export type ChantSource = "gr" | "lu" | "la" | "lh" | "am" | "nr" | "ams" | "psm" | "cse" | "cot";
27
+ export type ChantSource = "gr" | "lu" | "la" | "lh" | "am" | "nr" | "psm";
28
28
  export type CanonicalHour = "matutinum" | "laudes" | "prima" | "tertia" | "sexta" | "nona" | "vesperae" | "completorium";
29
29
  /** The eight canonical hours in the order they are sung, Matins first. The
30
30
  * order is the content: a day's office read out of sequence is not the day's
@@ -38,6 +38,18 @@ export declare const CANTUS_QUERY_KEYS: Set<string>;
38
38
  export declare const MODI: Readonly<Record<string, string>>;
39
39
  export declare const OFFICIA: Readonly<Record<OfficeCode, string>>;
40
40
  export declare const ORDINARIA: Readonly<Record<string, string>>;
41
+ /**
42
+ * An ordinary code the vocabulary above does not carry is a MALFORMED query,
43
+ * not a no-match, and both doors that take one say so here.
44
+ *
45
+ * The query contract makes a no-match `[]` and a malformation a throw, and an
46
+ * unknown code cannot be a no-match: there is no chant it failed to find,
47
+ * because it never named a part of the Mass. The 0.10.0 rename is why this is
48
+ * a guard rather than a comment — `ky` was the Kyrie through 0.9.1 and is the
49
+ * Kyriale roll-up now, so the one code most likely to arrive stale is the one
50
+ * that would otherwise return an empty list and look like an answer.
51
+ */
52
+ export declare function assertOrdinaryCodes(codes: readonly string[], method: string): void;
41
53
  export declare const KY_SOURCE: Chant["source"];
42
54
  export interface Chant {
43
55
  id: string;
@@ -61,6 +73,22 @@ export interface Chant {
61
73
  scanSource?: string | null;
62
74
  code?: ChantSource | "user" | "ky";
63
75
  };
76
+ /**
77
+ * Every book that PRINTS this chant, the one storing the record first.
78
+ *
79
+ * A melody printed in several books is stored once and listed under each, so
80
+ * `books` — not `source.code` — is what `cantus({ source })` and `corpus(code)`
81
+ * read. `source` names the book the record is filed under; `books` names the
82
+ * shelf. Until 2026-08-31 the two were conflated, which stored the 928 chants
83
+ * the Graduale and the Liber Usualis share twice over while hiding the 1,079
84
+ * the Usualis shares with the Antiphonarius from `corpus("la")` entirely.
85
+ *
86
+ * `books[0]` is the owner. A chant that IS a printing carries its own code
87
+ * even when no book on the shelf holds it — "user" for raw GABC, "ky" for a
88
+ * Kyriale printing. It is empty only for a chant that is not a printing at
89
+ * all: a psalm verse pointed to a tone at request time.
90
+ */
91
+ books: (ChantSource | "user" | "ky")[];
64
92
  ordinary?: OrdinaryCode;
65
93
  ordinarium?: string;
66
94
  mass?: number;
@@ -151,7 +179,7 @@ export interface CantusQuery {
151
179
  office?: OfficeCode | OfficeCode[];
152
180
  source?: ChantSource | ChantSource[];
153
181
  /**
154
- * A part of the Mass ordinary — `"ky"` for the Kyries, `"gl"` the Glorias,
182
+ * A part of the Mass ordinary — `"ke"` for the Kyries, `"gl"` the Glorias,
155
183
  * and so on. This is the door to the Kyriale, which is addressable but not
156
184
  * shelved (it is a partition of the Graduale, not a book), so it is absent
157
185
  * from `source` and from an unfiltered search. Asking for an ordinary can
@@ -33,14 +33,36 @@ export const OFFICIA = Object.freeze({
33
33
  se: "Sequentia",
34
34
  // Reported in the pre-cut tallies, not shipped — see OfficeCode above.
35
35
  im: "Improperia", // the Good Friday Reproaches (Popule meus)
36
- pa: "Antiphona Mariana", // Marian antiphons outside the office cycle
36
+ pa: "Prosa",
37
37
  su: "Supplicatio", // litanies and supplications (the Easter Vigil litany)
38
38
  tr: "Tractus",
39
- tp: "Tonus Peregrinus",
40
- or: "Ordinarium",
39
+ // ── Corrected 2026-08-31 against GregoBase's own vocabulary ──────────────
40
+ // These three were read off the two-letter codes rather than off the source,
41
+ // and two of them named the wrong thing entirely. GregoBase's include/txt.php
42
+ // carries $txt['usage']: or = Toni Communes, tp = Tropa, pa = Prosa. `or` was
43
+ // the costly one — tonus stamped "Ordinarium" on it and used the code for the
44
+ // Mass ordinary, which meant a genus tonus invented was sitting on a genus the
45
+ // database already had, and 132 real Toni Communes chants were dropped at the
46
+ // extractor for want of a mapping.
47
+ tp: "Tropa",
48
+ or: "Toni Communes",
49
+ // The Mass ordinary's roll-up, and GregoBase's word for it: the database files
50
+ // every ordinary chant under office-part `ky`. The part itself rides
51
+ // `ordinary` (ke · gl · cr · sa · ag · …), which is the identity every query
52
+ // and the census bin on; this is the genus above them.
53
+ ky: "Kyriale",
54
+ // The catch-all, and GregoBase's own name for it. A chant whose office-part
55
+ // tonus does not recognise used to fall to `or`, which was harmless while `or`
56
+ // was a label tonus had invented and is not now that it means Toni Communes —
57
+ // an unknown chant would have been filed as a recitation formula, and the
58
+ // census excludes those. NOTE for review: `va` is also an ORDINARY code (Vidi
59
+ // aquam). Office codes and ordinary codes are separate vocabularies — `ky`
60
+ // lived in both until today — but if that overlap is unwanted, this is the
61
+ // one to rename, not the ordinary.
62
+ va: "Varia",
41
63
  });
42
64
  export const ORDINARIA = Object.freeze({
43
- ky: "Kyrie eleison",
65
+ ke: "Kyrie eleison",
44
66
  gl: "Gloria",
45
67
  cr: "Credo",
46
68
  sa: "Sanctus",
@@ -50,6 +72,27 @@ export const ORDINARIA = Object.freeze({
50
72
  as: "Asperges",
51
73
  va: "Vidi aquam",
52
74
  });
75
+ /**
76
+ * An ordinary code the vocabulary above does not carry is a MALFORMED query,
77
+ * not a no-match, and both doors that take one say so here.
78
+ *
79
+ * The query contract makes a no-match `[]` and a malformation a throw, and an
80
+ * unknown code cannot be a no-match: there is no chant it failed to find,
81
+ * because it never named a part of the Mass. The 0.10.0 rename is why this is
82
+ * a guard rather than a comment — `ky` was the Kyrie through 0.9.1 and is the
83
+ * Kyriale roll-up now, so the one code most likely to arrive stale is the one
84
+ * that would otherwise return an empty list and look like an answer.
85
+ */
86
+ export function assertOrdinaryCodes(codes, method) {
87
+ const unknown = codes.filter((c) => !(c in ORDINARIA));
88
+ if (unknown.length) {
89
+ throw new Error(`${method}: unknown ordinary code(s) ${unknown.map((c) => `"${c}"`).join(", ")} ` +
90
+ `(expected ${Object.keys(ORDINARIA).join(", ")}).` +
91
+ (unknown.includes("ky")
92
+ ? ` The Kyrie is "ke" from 0.10.0 — "ky" is now the Kyriale genus (OFFICIA.ky).`
93
+ : ""));
94
+ }
95
+ }
53
96
  // The Kyriale's bibliographic identity. NOT a ChantSource: `ky` is a partition
54
97
  // of the Graduale, not a book of its own (chant.ts, CORPUS), so it is not a
55
98
  // value `cantus({ source })` takes and not a row in the shelf. This record