tonus 0.1.8 → 0.5.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 (164) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +598 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2666 -11196
  6. package/dist/data/ams.d.ts +5 -0
  7. package/dist/data/ams.js +122 -0
  8. package/dist/data/attestation.d.ts +19 -0
  9. package/dist/data/attestation.js +15716 -0
  10. package/dist/data/attestation.json +15711 -0
  11. package/dist/data/cadentiae.d.ts +43 -0
  12. package/dist/data/cadentiae.js +174 -0
  13. package/dist/data/cal.js +48 -0
  14. package/dist/data/census.d.ts +12 -0
  15. package/dist/data/census.js +36 -0
  16. package/dist/data/commune-office.d.ts +4 -0
  17. package/dist/data/commune-office.js +2371 -0
  18. package/dist/data/commune-office.json +2365 -0
  19. package/dist/data/commune.js +181 -11
  20. package/dist/data/corpus-overlap.d.ts +17 -0
  21. package/dist/data/corpus-overlap.js +299 -5
  22. package/dist/data/cot.d.ts +5 -0
  23. package/dist/data/cot.js +172 -0
  24. package/dist/data/cse.d.ts +5 -0
  25. package/dist/data/cse.js +122 -0
  26. package/dist/data/gabc-glyphs.d.ts +45 -0
  27. package/dist/data/gabc-glyphs.js +122 -0
  28. package/dist/data/gr.js +754 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +799 -13419
  31. package/dist/data/lh.js +113 -3473
  32. package/dist/data/lu.js +931 -17631
  33. package/dist/data/nocturnale-romanum.js +1659 -10411
  34. package/dist/data/office-ferial.d.ts +4 -0
  35. package/dist/data/office-ferial.js +396 -0
  36. package/dist/data/office-ferial.json +391 -0
  37. package/dist/data/office-monastic.d.ts +17 -1
  38. package/dist/data/office-monastic.js +1403 -466
  39. package/dist/data/office-psalms-monastic.d.ts +13 -1
  40. package/dist/data/office-psalms-monastic.js +9 -0
  41. package/dist/data/propers.js +1 -1
  42. package/dist/data/psalms.js +22919 -5
  43. package/dist/data/psm.d.ts +5 -0
  44. package/dist/data/psm.js +122 -0
  45. package/dist/data/seasonal-respbreve.d.ts +5 -0
  46. package/dist/data/seasonal-respbreve.js +41 -0
  47. package/dist/data/seasonal-respbreve.json +35 -0
  48. package/dist/data/smufl-glyphs.d.ts +17 -0
  49. package/dist/data/smufl-glyphs.js +1546 -0
  50. package/dist/data/smufl-glyphs.json +1530 -0
  51. package/dist/engines/cal/calendar.d.ts +3 -2
  52. package/dist/engines/cal/calendar.js +105 -29
  53. package/dist/engines/cal/data/eras.d.ts +35 -0
  54. package/dist/engines/cal/data/eras.js +128 -0
  55. package/dist/engines/cal/date.js +44 -0
  56. package/dist/engines/cal/types.d.ts +15 -3
  57. package/dist/engines/cal/types.js +5 -5
  58. package/dist/engines/census/census.d.ts +7 -0
  59. package/dist/engines/census/census.js +179 -0
  60. package/dist/engines/census/types.d.ts +55 -0
  61. package/dist/engines/census/types.js +8 -0
  62. package/dist/engines/chant/attest.d.ts +39 -0
  63. package/dist/engines/chant/attest.js +90 -0
  64. package/dist/engines/chant/chant.d.ts +16 -4
  65. package/dist/engines/chant/chant.js +220 -30
  66. package/dist/engines/chant/data/compline.js +2 -1
  67. package/dist/engines/chant/data/masses.d.ts +56 -4
  68. package/dist/engines/chant/data/masses.js +305 -80
  69. package/dist/engines/chant/data/prime.js +1 -1
  70. package/dist/engines/chant/hour.js +279 -58
  71. package/dist/engines/chant/ordinary.d.ts +2 -0
  72. package/dist/engines/chant/ordinary.js +336 -56
  73. package/dist/engines/chant/propers.js +55 -5
  74. package/dist/engines/chant/psalm.d.ts +4 -4
  75. package/dist/engines/chant/psalm.js +25 -11
  76. package/dist/engines/chant/syllabify.d.ts +1 -0
  77. package/dist/engines/chant/syllabify.js +90 -17
  78. package/dist/engines/chant/types.d.ts +115 -12
  79. package/dist/engines/chant/types.js +38 -3
  80. package/dist/engines/harmonia/api.js +4 -0
  81. package/dist/engines/harmonia/data/doctrines.js +3 -1
  82. package/dist/engines/harmonia/tabula.d.ts +3 -0
  83. package/dist/engines/harmonia/tabula.js +1 -0
  84. package/dist/engines/harmonia/voice.d.ts +4 -0
  85. package/dist/engines/harmonia/voice.js +8 -4
  86. package/dist/engines/imprint.js +14 -1
  87. package/dist/engines/planet/orbital.js +4 -4
  88. package/dist/engines/planet/planet.d.ts +10 -0
  89. package/dist/engines/planet/planet.js +30 -3
  90. package/dist/engines/planet/position.js +13 -10
  91. package/dist/engines/planet/types.d.ts +1 -0
  92. package/dist/engines/score/api.d.ts +2 -13
  93. package/dist/engines/score/api.js +21 -8
  94. package/dist/engines/score/articulation.js +2 -2
  95. package/dist/engines/score/cadence.d.ts +76 -0
  96. package/dist/engines/score/cadence.js +96 -0
  97. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  98. package/dist/engines/score/emitters/accidentals.js +88 -0
  99. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  100. package/dist/engines/score/emitters/atramentum.js +239 -0
  101. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  102. package/dist/engines/score/emitters/breaking.js +80 -0
  103. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  104. package/dist/engines/score/emitters/moderna.js +612 -0
  105. package/dist/engines/score/emitters/svg.d.ts +143 -0
  106. package/dist/engines/score/emitters/svg.js +1328 -0
  107. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  108. package/dist/engines/score/emitters/tracks.js +728 -0
  109. package/dist/engines/score/infer.d.ts +3 -3
  110. package/dist/engines/score/infer.js +2 -2
  111. package/dist/engines/score/inscriptio.d.ts +69 -0
  112. package/dist/engines/score/inscriptio.js +138 -0
  113. package/dist/engines/score/ir.d.ts +2 -2
  114. package/dist/engines/score/ir.js +59 -12
  115. package/dist/engines/score/lyric.d.ts +23 -0
  116. package/dist/engines/score/lyric.js +234 -0
  117. package/dist/engines/score/meta.d.ts +2 -2
  118. package/dist/engines/score/modulation.d.ts +12 -0
  119. package/dist/engines/score/modulation.js +49 -0
  120. package/dist/engines/score/neume.js +35 -4
  121. package/dist/engines/score/parse.js +150 -9
  122. package/dist/engines/score/phrasing.js +4 -3
  123. package/dist/engines/score/prosody.d.ts +38 -0
  124. package/dist/engines/score/prosody.js +70 -6
  125. package/dist/engines/score/tabula.d.ts +37 -5
  126. package/dist/engines/score/tabula.js +18 -0
  127. package/dist/engines/score/types.d.ts +88 -1
  128. package/dist/engines/temper/api.d.ts +4 -1
  129. package/dist/engines/temper/api.js +28 -5
  130. package/dist/engines/temper/data/guido.js +6 -2
  131. package/dist/engines/temper/data/modes.d.ts +6 -0
  132. package/dist/engines/temper/data/modes.js +42 -0
  133. package/dist/engines/temper/data/tones.d.ts +1 -1
  134. package/dist/engines/temper/data/tones.js +20 -11
  135. package/dist/engines/temper/interval.js +4 -3
  136. package/dist/engines/temper/modality.d.ts +11 -2
  137. package/dist/engines/temper/modality.js +74 -2
  138. package/dist/engines/temper/modes.d.ts +1 -1
  139. package/dist/engines/temper/pitch.d.ts +1 -1
  140. package/dist/engines/temper/pitch.js +12 -2
  141. package/dist/engines/temper/scale.d.ts +53 -0
  142. package/dist/engines/temper/scale.js +107 -8
  143. package/dist/index.d.ts +26 -8
  144. package/dist/index.js +37 -4
  145. package/docs/api/calendar.md +279 -0
  146. package/docs/api/census.md +288 -0
  147. package/docs/api/chant.md +657 -0
  148. package/docs/api/heavens.md +346 -0
  149. package/docs/api/index.md +263 -0
  150. package/docs/api/score.md +873 -0
  151. package/docs/api/tuning.md +619 -0
  152. package/package.json +11 -5
  153. package/dist/data/office-matins-roman.d.ts +0 -19
  154. package/dist/data/office-matins-roman.js +0 -4383
  155. package/dist/data/office-psalms-roman.d.ts +0 -15
  156. package/dist/data/office-psalms-roman.js +0 -28
  157. package/dist/data/office-roman.d.ts +0 -19
  158. package/dist/data/office-roman.js +0 -13792
  159. package/dist/engines/chant/matutinum.d.ts +0 -33
  160. package/dist/engines/chant/matutinum.js +0 -81
  161. package/dist/engines/score/emitters/midi.d.ts +0 -65
  162. package/dist/engines/score/emitters/midi.js +0 -162
  163. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  164. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -1,13 +1,23 @@
1
- import { OFFICE_LABELS, MODE_LABELS } from "./types.js";
2
- import { CORPUS_OVERLAP } from "../../data/corpus-overlap.js";
1
+ import { OFFICIA, MODI, CANTUS_QUERY_KEYS } from "./types.js";
2
+ import { CORPUS_OVERLAP, CORPUS_FULL } from "../../data/corpus-overlap.js";
3
+ import { attestationCutoff, chantAdmissible } from "./attest.js";
3
4
  import { GR_DATA, GR_SOURCE } from "../../data/gr.js";
4
5
  import { LU_DATA, LU_SOURCE } from "../../data/lu.js";
5
6
  import { LA_DATA, LA_SOURCE } from "../../data/la.js";
6
7
  import { LH_DATA, LH_SOURCE } from "../../data/lh.js";
7
8
  import { AM_DATA, AM_SOURCE } from "../../data/am.js";
8
9
  import { NR_DATA, NR_SOURCE } from "../../data/nocturnale-romanum.js";
10
+ // Office books — the antiphons and short responsories that fill weekday
11
+ // office slots; see ChantSource in types.ts for why.
12
+ import { AMS_DATA, AMS_SOURCE } from "../../data/ams.js";
13
+ import { PSM_DATA, PSM_SOURCE } from "../../data/psm.js";
14
+ // 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
+ import { KYRIALE } from "../../data/kyriale.js";
18
+ import { entryToOrdinaryChant } from "./ordinary.js";
9
19
  function modusOf(mode) {
10
- return mode != null ? (MODE_LABELS[mode] ?? null) : null;
20
+ return mode != null ? (MODI[mode] ?? null) : null;
11
21
  }
12
22
  const HEADER_FIELD_REGEX = /([A-Za-z0-9_-]+)\s*:\s*([^;]*);/g;
13
23
  function chantFromGABC(query) {
@@ -38,15 +48,29 @@ function chantFromGABC(query) {
38
48
  }
39
49
  const incipit = query.incipit ?? name ?? "";
40
50
  const mode = query.mode != null ? String(query.mode) : headerMode;
51
+ // The office-part header carries either a code ("in") or a Latin genre name
52
+ // ("Introitus", any casing) — normalize to the OfficeCode vocabulary so user
53
+ // chants honour the same contract as corpus chants; unrecognized values fall
54
+ // to "or" like an absent header.
55
+ const officeFromHeader = (() => {
56
+ if (!officePart)
57
+ return null;
58
+ const v = officePart.trim().toLowerCase();
59
+ for (const [code, label] of Object.entries(OFFICIA)) {
60
+ if (v === code || v === label.toLowerCase())
61
+ return code;
62
+ }
63
+ return null;
64
+ })();
41
65
  const office = (query.office
42
66
  ? (Array.isArray(query.office) ? query.office[0] : query.office)
43
- : officePart ?? "or");
67
+ : officeFromHeader ?? "or");
44
68
  return [{
45
69
  id: `gabc:${incipit.toLowerCase().replace(/\s+/g, "_") || "untitled"}`,
46
70
  incipit,
47
71
  gabc: body,
48
72
  office,
49
- genus: OFFICE_LABELS[office] ?? office,
73
+ genus: OFFICIA[office] ?? office,
50
74
  mode: mode ?? null,
51
75
  modus: modusOf(mode ?? null),
52
76
  pages: [],
@@ -57,7 +81,7 @@ function withLabels(c, source) {
57
81
  return {
58
82
  ...c,
59
83
  source,
60
- genus: OFFICE_LABELS[c.office] ?? c.office,
84
+ genus: OFFICIA[c.office] ?? c.office,
61
85
  modus: modusOf(c.mode),
62
86
  };
63
87
  }
@@ -68,30 +92,145 @@ const CORPUS = [
68
92
  ...LH_DATA.map((c) => withLabels(c, LH_SOURCE)),
69
93
  ...AM_DATA.map((c) => withLabels(c, AM_SOURCE)),
70
94
  ...NR_DATA.map((c) => withLabels(c, NR_SOURCE)),
95
+ ...AMS_DATA.map((c) => withLabels(c, AMS_SOURCE)),
96
+ ...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)),
71
99
  ];
100
+ // The Kyriale, addressable but not shelved — ruled 2026-08-04.
101
+ //
102
+ // It was a row in the ledger, and it should not have been: there is no Kyriale
103
+ // in GregoBase. `ky` and `gr` are the same source id, PARTITIONED by the
104
+ // extractor, which pulls `office-part = 'ky'` out of the Graduale query so the
105
+ // ordinary can be routed to per-ordinary codes. Listing that slice beside its
106
+ // parent counted the Graduale twice, and its `total: null` read as "not yet
107
+ // measured" when there was no separate book to measure.
108
+ //
109
+ // But "not a book" and "not nameable" are different claims, and only the first
110
+ // is true. These chants are sung repertoire, they are censused, and an id must
111
+ // still resolve to exactly one chant — the census contract says so in as many
112
+ // words. So they stay addressable by `id`, by `office: "or"`, and by
113
+ // `ordinary`, and stay out of the shelf's book list and row count.
114
+ const KYRIALE_CHANTS = KYRIALE.map(entryToOrdinaryChant);
115
+ /** Everything nameable: the shelf, plus the ordinary that no shelf holds. */
116
+ const ADDRESSABLE = [...CORPUS, ...KYRIALE_CHANTS];
72
117
  let _byId = null;
73
118
  function byId() {
74
119
  if (!_byId)
75
- _byId = new Map(CORPUS.map((c) => [c.id, c]));
120
+ _byId = new Map(ADDRESSABLE.map((c) => [c.id, c]));
76
121
  return _byId;
77
122
  }
78
- const SOURCES = {
123
+ // The book registry, and so the shelf `corpus()` reports. `ky` is deliberately
124
+ // absent: it is a partition of `gr`, not a book (see CORPUS above). Its
125
+ // bibliographic record still rides every kyriale chant's `source` — a caller
126
+ // reading one is told which book it is printed in — but the Kyriale is not a
127
+ // row in the ledger, because the Graduale already is.
128
+ export const SOURCES = {
79
129
  gr: GR_SOURCE, lu: LU_SOURCE, la: LA_SOURCE, lh: LH_SOURCE, am: AM_SOURCE, nr: NR_SOURCE,
130
+ // office books — provenance, not acquisition
131
+ ams: AMS_SOURCE, psm: PSM_SOURCE,
132
+ cse: CSE_SOURCE, cot: COT_SOURCE,
80
133
  };
81
134
  // Tally a book's genre and mode distribution — computed once per code, cached.
82
135
  const _corpusCache = new Map();
136
+ export function getCorpus(arg) {
137
+ if (arg == null)
138
+ return corpusLedger();
139
+ if (typeof arg === "object") {
140
+ const unknown = Object.keys(arg).filter((k) => k !== "book");
141
+ if (unknown.length) {
142
+ throw new Error(`corpus: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} (expected book).`);
143
+ }
144
+ if (arg.book == null)
145
+ return corpusLedger();
146
+ return oneCorpus(arg.book);
147
+ }
148
+ return oneCorpus(arg);
149
+ }
150
+ /** Sort a raw office tally into the GenusCount rows corpus() reports. */
151
+ function generaRows(tally) {
152
+ return Object.entries(tally)
153
+ .map(([office, count]) => ({
154
+ office: office,
155
+ genus: OFFICIA[office] ?? office,
156
+ count,
157
+ }))
158
+ .sort((a, b) => b.count - a.count || (a.office < b.office ? -1 : 1));
159
+ }
160
+ /** Sort a raw mode tally into ModeCount rows: 1–8 in order, then the rest. */
161
+ function modesRows(tally) {
162
+ const rows = [];
163
+ for (const m of ["1", "2", "3", "4", "5", "6", "7", "8"]) {
164
+ const count = tally[m];
165
+ if (count)
166
+ rows.push({ mode: m, modus: MODI[m], count });
167
+ }
168
+ // The extractor buckets differentia forms, tonus peregrinus and unlabelled
169
+ // chants together under "other" — the same bucket the shipped counts report
170
+ // as `mode: null`, so the two tallies stay comparable row for row.
171
+ if (tally.other)
172
+ rows.push({ mode: null, modus: null, count: tally.other });
173
+ return rows;
174
+ }
83
175
  /**
84
- * Metadata and content breakdown for one corpus book (`tonus.corpus`). Pass a
85
- * source code; get the book's bibliographic identity plus its genre and mode
86
- * distributions. Computed on first access from the loaded corpus, then cached.
176
+ * A book's pre-cut tally, or null where it cannot be measured. Only the
177
+ * extractor sees the un-cut book by the time tonus loads, the keep set has
178
+ * already run so this is read from the artifact, never derived here.
87
179
  */
88
- export function getCorpus(code) {
180
+ function fullCount(code) {
181
+ const f = CORPUS_FULL[code];
182
+ if (!f)
183
+ return null;
184
+ return { total: f.total, genera: generaRows(f.genera), modes: modesRows(f.modes) };
185
+ }
186
+ /**
187
+ * The whole shelf: every book, plus the corpus-wide rollup. No argument
188
+ * answers the commonest question — what IS this corpus.
189
+ *
190
+ * `count` is how many chants tonus holds, each counted once — including the
191
+ * ordinary, which is addressable but not shelved. `listings` is how many rows
192
+ * the shelf has, where a melody printed in two books appears under both; the
193
+ * difference between them is the overlap.
194
+ */
195
+ let _ledger = null;
196
+ function corpusLedger() {
197
+ if (_ledger)
198
+ return _ledger;
199
+ // SOURCES is the registry — a book added there joins the ledger without an
200
+ // edit here, which is the failure mode every mirrored book list in this
201
+ // project has hit at least once.
202
+ const codes = Object.keys(SOURCES);
203
+ const books = codes.map((code) => oneCorpus(code));
204
+ // Tally over what tonus HOLDS, one row per chant — the same population
205
+ // `count` reports, so the breakdowns sum to the headline instead of to the
206
+ // listing total. Deduped by id, because a chant in two books is one chant.
207
+ const genera = {};
208
+ const modes = {};
209
+ for (const c of byId().values()) {
210
+ genera[c.office] = (genera[c.office] ?? 0) + 1;
211
+ const m = c.mode != null && MODI[c.mode] ? c.mode : "other";
212
+ modes[m] = (modes[m] ?? 0) + 1;
213
+ }
214
+ _ledger = {
215
+ count: byId().size,
216
+ listings: CORPUS.length,
217
+ // Sum only what was measured: a book outside GregoBase reports null rather
218
+ // than a false zero, and adding null in as 0 would understate the shelf
219
+ // while looking like a total.
220
+ total: codes.reduce((n, code) => n + (CORPUS_FULL[code]?.total ?? 0), 0),
221
+ genera: generaRows(genera),
222
+ modes: modesRows(modes),
223
+ books,
224
+ };
225
+ return _ledger;
226
+ }
227
+ function oneCorpus(code) {
89
228
  const cached = _corpusCache.get(code);
90
229
  if (cached)
91
230
  return cached;
92
231
  const src = SOURCES[code];
93
232
  if (!src)
94
- throw new Error(`Unknown corpus code: "${code}" (expected gr, lu, la, lh, or am)`);
233
+ throw new Error(`Unknown corpus code: "${code}" (expected ${Object.keys(SOURCES).join(", ")})`);
95
234
  const chants = CORPUS.filter((c) => c.source.code === code);
96
235
  // Genre distribution — count by office code, descending by count.
97
236
  const officeCounts = new Map();
@@ -100,7 +239,7 @@ export function getCorpus(code) {
100
239
  let otherModes = 0;
101
240
  for (const c of chants) {
102
241
  officeCounts.set(c.office, (officeCounts.get(c.office) ?? 0) + 1);
103
- if (c.mode != null && MODE_LABELS[c.mode]) {
242
+ if (c.mode != null && MODI[c.mode]) {
104
243
  modeCounts.set(c.mode, (modeCounts.get(c.mode) ?? 0) + 1);
105
244
  }
106
245
  else {
@@ -108,22 +247,26 @@ export function getCorpus(code) {
108
247
  }
109
248
  }
110
249
  const genera = [...officeCounts.entries()]
111
- .map(([office, count]) => ({ office, genus: OFFICE_LABELS[office] ?? office, count }))
250
+ .map(([office, count]) => ({ office, genus: OFFICIA[office] ?? office, count }))
112
251
  .sort((a, b) => b.count - a.count);
113
252
  const modes = [];
114
253
  for (const m of ["1", "2", "3", "4", "5", "6", "7", "8"]) {
115
254
  const count = modeCounts.get(m);
116
255
  if (count)
117
- modes.push({ mode: m, modus: MODE_LABELS[m], count });
256
+ modes.push({ mode: m, modus: MODI[m], count });
118
257
  }
119
258
  if (otherModes > 0)
120
259
  modes.push({ mode: null, modus: null, count: otherModes });
121
260
  // Pre-dedup relationships (precomputed in tonus-corpus — tonus can't derive
122
- // them, since it stores only one copy of each shared chant).
261
+ // them, since it stores only one copy of each shared chant). Measured only for
262
+ // the GregoBase-sourced books; a book outside GregoBase (e.g. `nr`) has no
263
+ // entry, so overlap is reported as *unmeasured* (null) rather than a false zero.
123
264
  const ov = CORPUS_OVERLAP[code];
124
- const shared = Object.entries(ov?.shared ?? {})
125
- .map(([c, count]) => ({ code: c, count }))
126
- .sort((a, b) => b.count - a.count);
265
+ const shared = ov
266
+ ? Object.entries(ov.shared)
267
+ .map(([c, count]) => ({ code: c, count }))
268
+ .sort((a, b) => b.count - a.count)
269
+ : null;
127
270
  const result = {
128
271
  code,
129
272
  book: src.book,
@@ -133,11 +276,12 @@ export function getCorpus(code) {
133
276
  editor: src.editor,
134
277
  scanSource: src.scanSource ?? null,
135
278
  count: chants.length,
136
- total: ov?.total ?? chants.length,
137
- unique: ov?.unique ?? chants.length,
279
+ total: ov?.total ?? null,
280
+ unique: ov?.unique ?? null,
138
281
  shared,
139
282
  genera,
140
283
  modes,
284
+ full: fullCount(code),
141
285
  };
142
286
  _corpusCache.set(code, result);
143
287
  return result;
@@ -155,23 +299,54 @@ export function resolveChant(id) {
155
299
  export function resolveChants(ids) {
156
300
  return ids.map(resolveChant).filter((c) => c !== null);
157
301
  }
302
+ // The key set lives in types.ts (cycle-free); re-exported here for the verbs
303
+ // that extend it (hour.ts).
304
+ export { CANTUS_QUERY_KEYS };
158
305
  /**
159
- * Cross-corpus chant retrieval (`tonus.cantus`) over GR, LA, LH, and LU.
160
- * A `gabc` field bypasses the corpus and returns a single user
161
- * chant parsed from raw GABC (body or full file with headers).
306
+ * Cross-corpus chant retrieval (`tonus.cantus`) over every corpus book
307
+ * (the codes in SOURCES). A `gabc` field bypasses the corpus and returns a
308
+ * single user chant parsed from raw GABC (body or full file with headers).
162
309
  */
163
310
  export function getChants(query) {
164
- if (!query || Object.keys(query).length === 0)
165
- return [];
311
+ // A no-match returns []; a malformed query is a caller bug and throws with
312
+ // guidance (the reconciled query contract — see CODE-STANDARDS → Boundaries).
313
+ //
314
+ // An empty query is a MALFORMATION here, not a no-match, and that is
315
+ // deliberate: the filters below narrow from CORPUS, so an empty query has no
316
+ // honest answer. Returning [] would claim a search found nothing when none
317
+ // ran; returning the corpus would dress 2,767 listings as a result. The whole
318
+ // shelf is `corpus()`, which says so in its name.
319
+ if (!query || Object.keys(query).length === 0) {
320
+ throw new Error("cantus: an empty query matches nothing meaningful — pass a filter " +
321
+ `(one of ${[...CANTUS_QUERY_KEYS].join(", ")}), or a gabc string to parse.`);
322
+ }
323
+ const unknown = Object.keys(query).filter((k) => !CANTUS_QUERY_KEYS.has(k));
324
+ if (unknown.length > 0) {
325
+ throw new Error(`cantus: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
326
+ `(expected ${[...CANTUS_QUERY_KEYS].join(", ")}).`);
327
+ }
166
328
  if (query.gabc)
167
329
  return chantFromGABC(query);
330
+ // `id` is a fast path, but it must still honour the other filters — returning
331
+ // a chant that fails them makes `cantus({ source, id })` silently ignore the
332
+ // source and report an id from any book as belonging to that one.
168
333
  const ids = toArray(query.id);
334
+ const ordinaries = toArray(query.ordinary);
335
+ // The default pool is the SHELF. The Kyriale is not on it (see KYRIALE_CHANTS
336
+ // above), so it is reached deliberately: by `id`, which must resolve any
337
+ // chant tonus holds, or by `ordinary`, which asks for a part of the Mass and
338
+ // could not mean anything else. A plain `cantus({ mode: 5 })` does not sweep
339
+ // the ordinary in, because the ordinary is not repertoire of that kind — you
340
+ // ask for a Kyrie, you do not stumble onto one.
341
+ let out = ordinaries ? KYRIALE_CHANTS : CORPUS;
169
342
  if (ids) {
170
343
  const map = byId();
171
- const found = ids.map((id) => map.get(id)).filter((c) => !!c);
172
- return found;
344
+ out = ids.map((id) => map.get(id)).filter((c) => !!c);
345
+ }
346
+ if (ordinaries) {
347
+ const set = new Set(ordinaries);
348
+ out = out.filter((c) => c.ordinary != null && set.has(c.ordinary));
173
349
  }
174
- let out = CORPUS;
175
350
  const sources = toArray(query.source);
176
351
  if (sources) {
177
352
  const set = new Set(sources);
@@ -191,6 +366,21 @@ export function getChants(query) {
191
366
  const needle = query.incipit.toLowerCase();
192
367
  out = out.filter((c) => c.incipit.toLowerCase().includes(needle));
193
368
  }
369
+ // ── Attestation: the repertoire AS OF a date ───────────────────────────────
370
+ // The analogue of `festum({ before })` over the calendar. The corpus ships
371
+ // 20th-century Solesmes editions, so the BOOK dates nothing; CANTUS's
372
+ // manuscript index does. `century` is the earliest surviving witness — a
373
+ // terminus ante quem, so this answers "what is ATTESTED by then", never "what
374
+ // existed then". A chant CANTUS cannot date is excluded rather than assumed
375
+ // old: the filter states what is evidenced, and silence is not evidence.
376
+ // One rule, one door-keeper: the same chantAdmissible() the day verbs use,
377
+ // so `cantus({ before })` and `proprium({ feast, before })` can never drift.
378
+ {
379
+ const cutoff = attestationCutoff(query, "cantus");
380
+ if (cutoff != null || query.cursus) {
381
+ out = out.filter((c) => chantAdmissible(c.id, cutoff, query.cursus));
382
+ }
383
+ }
194
384
  const sort = query.sort ?? "incipit";
195
385
  const sorted = [...out].sort((a, b) => {
196
386
  if (sort === "id")
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // Ordo structure after the traditional Roman Compline; seasonal assignment and
11
11
  // the Marian rotation follow standard practice, cross-checked against
12
- // bbloomf/compline (public domain). See docs/chant.md and BIBLIOGRAPHY.md.
12
+ // [biblio: bloomfield-compline] (public domain). See
13
+ // ../../../../docs/api/chant.md and ../../../../BIBLIOGRAPHY.md.
13
14
  // Compline's psalmody (Ps 4, 30 vv. 2–6, 90, 133) is not hand-listed here — it
14
15
  // comes from the extracted DO Tridentine scheme via
15
16
  // `officePsalmPortions("Completorium", …)`. Only the ordo structure and the
@@ -1,18 +1,70 @@
1
- import { type Season, type Grade } from "../../cal/types.js";
1
+ import { type Grade } from "../../cal/types.js";
2
+ /**
3
+ * The Kyriale's rubric categories, in the book's own order. A day resolves to
4
+ * exactly one; the masses carrying that rubric are the masses it may sing.
5
+ */
6
+ export type MassRubric = "paschal" | "class-i" | "class-ii" | "class-iii" | "bvm" | "sunday" | "commemoration" | "feria" | "sunday-penitential" | "feria-penitential";
7
+ export declare const WHOLE_MASS_RUBRICS: ReadonlySet<MassRubric>;
2
8
  export interface MassEntry {
3
9
  id: string;
4
10
  mass: number;
5
11
  title: string;
6
- seasons: Season[];
7
- grades: Grade[];
8
- days: ("dominica" | "feria")[];
12
+ rubric: MassRubric;
13
+ heading: string;
9
14
  bvm: boolean;
10
15
  credos: string[];
11
16
  notes: string;
12
17
  }
18
+ export declare const CLASS_I_GRADES: readonly Grade[];
19
+ export declare const CLASS_II_GRADES: readonly Grade[];
20
+ export declare const CLASS_III_GRADES: readonly Grade[];
21
+ export declare const FERIA_GRADES: readonly Grade[];
13
22
  export declare const AD_LIB: {
14
23
  standard: MassEntry;
15
24
  bvm: MassEntry;
16
25
  };
17
26
  export declare const MASSES: Map<number, MassEntry>;
27
+ /** The masses the book appoints under one rubric, in mass-number order. */
28
+ export declare function massesForRubric(rubric: MassRubric): MassEntry[];
29
+ export interface MassCentury {
30
+ /** Earliest century the editors give (10 = the 900s); null where they print "?". */
31
+ from: number | null;
32
+ /** Latest, for a printed span like "XI-XIII. s."; equals `from` for a single. */
33
+ to: number | null;
34
+ /** A parenthesized alternative reading, e.g. "(X) XIV-XVI. s." → 10. */
35
+ alt?: number;
36
+ /** The part's century was read from position, not from a legible incipit. */
37
+ inferred?: true;
38
+ /** The token exactly as printed, so the claim stays auditable. */
39
+ printed: string;
40
+ }
41
+ /** mass number → ordinary code → what the Kyriale prints. */
42
+ export declare const MASS_CENTURY: Record<number, Partial<Record<string, MassCentury>>>;
43
+ /** Credo number (roman, as the Kyriale names them) → its ascription. */
44
+ export declare const CREDO_CENTURY: Record<string, MassCentury>;
45
+ export declare const ORDINARY_LATEST_YEAR = 1324;
46
+ /**
47
+ * Whether a Kyriale setting is early enough to sing.
48
+ *
49
+ * PER PART, never per mass. The Kyriale is a 19th-c Solesmes GROUPING of chants
50
+ * from different centuries — which is why its ascriptions are per-part in the
51
+ * first place. Mass XI "Orbis factor" is a 10th-c Kyrie + 10th Gloria + 10th
52
+ * Sanctus with a 14th-c Agnus bolted on; a whole-mass test would lose the
53
+ * ordinary Sunday mass over one late part. The book licenses the per-part
54
+ * result outright — "chants from one Mass may be used together with those from
55
+ * others, the Ferial Masses excepted" — so a dropped part simply borrows, which
56
+ * is the machinery entriesForOffice() already runs.
57
+ *
58
+ * `alt` wins where the editors print one. Mass XI's Kyrie reads "(X) XIV-XVI.
59
+ * s." — the parenthetical is the melody, the late reading is whichever
60
+ * manuscript they transcribed. Modern scholarship agrees: Corpus Monodicum
61
+ * (Brill, 2024) dates Orbis factor to the 10th-12th c.
62
+ *
63
+ * An undated part (the editors' own "?. s.") is ADMITTED, not excluded —
64
+ * undated is not late, and excluding it would repeat the mistake that killed
65
+ * the attestation filter. Same for a part with no entry at all: this rule can
66
+ * only remove a setting the Kyriale itself dates late, never one it is silent
67
+ * about.
68
+ */
69
+ export declare function partWithinEra(mass: number | null | undefined, office: string | null | undefined, toYear?: number): boolean;
18
70
  //# sourceMappingURL=masses.d.ts.map