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,16 +1,12 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/chant/ordinary — Mass ordinary (kyriale) selection
3
3
  // ---------------------------------------------------------------------------
4
- import { MASSES, AD_LIB } from "./data/masses.js";
4
+ import { MASSES, AD_LIB, WHOLE_MASS_RUBRICS, partWithinEra, } from "./data/masses.js";
5
5
  import { KYRIALE } from "../../data/kyriale.js";
6
- import { MODE_LABELS, ORDINARY_LABELS, } from "./types.js";
7
- import { gradeOrder, PENITENTIAL_SEASONS, } from "../cal/types.js";
8
- // A "high feast" (Duplex II classis or above) prefers the solemn kyriale
9
- // masses 1–9. Threshold expressed against GRADE_ORDER, not a magic number.
10
- function isHighFeast(grade) {
11
- return gradeOrder(grade) <= gradeOrder("duplex-ii");
12
- }
13
- const ORDINARY_OFFICES = new Set(Object.keys(ORDINARY_LABELS));
6
+ import { attestationCutoff, eraCutoff, chantAdmissible } from "./attest.js";
7
+ import { CANTUS_QUERY_KEYS } from "./types.js";
8
+ import { KY_SOURCE, MODI, ORDINARIA, } from "./types.js";
9
+ const ORDINARY_OFFICES = new Set(Object.keys(ORDINARIA));
14
10
  const MODE_PAIRS = [[1, 2], [3, 4], [5, 6], [7, 8]];
15
11
  const CREDO_PRIORITY = ["IV", "III", "I", "II", "V", "VI"];
16
12
  function pairedMode(mode) {
@@ -25,31 +21,205 @@ function resolveMasses(feast) {
25
21
  return resolved;
26
22
  return [feast.marian ? AD_LIB.bvm : AD_LIB.standard];
27
23
  }
28
- function entriesForOffice(office, massNumbers) {
29
- const byMass = KYRIALE.filter((e) => e.office === office && e.mass != null && massNumbers.includes(e.mass));
30
- if (byMass.length)
31
- return byMass;
32
- return KYRIALE.filter((e) => e.office === office && e.mass == null);
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;
29
+ 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);
33
34
  }
34
- function selectBestChant(entries, filterMode, highFeast, massNumbers) {
35
+ /** Prefer a proper numbered setting over an appendix one at equal standing. */
36
+ function adLibLast(a, b) {
37
+ return Number(isAdLibitum(a)) - Number(isAdLibitum(b));
38
+ }
39
+ // The Missa pro defunctis settings belong to the Requiem, not to the temporal or
40
+ // 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
+ function isRequiem(entry) {
46
+ return /in\s+Miss\.\s*def|defunct|requiescant/i.test(entry.incipit);
47
+ }
48
+ /**
49
+ * Deterministic rotation of a preference list by year.
50
+ *
51
+ * `feast.masses` lists every mass COMPATIBLE with the day, ranked — not one
52
+ * right answer and a set of wrong ones. A house that sings the same setting for
53
+ * nineteen years is a house, not the tradition, so the year steps through the
54
+ * compatible set. Keyed on the CIVIL (UTC) year of the feast's date — a pure
55
+ * function of the feast: same feast, same answer, every time it is asked. That
56
+ * means Christmastide's rotation identity flips at Jan 1 mid-liturgical-year;
57
+ * accepted, because the alternative (keying on the liturgical year) trades a
58
+ * cosmetic quirk for a computus dependency in every rotation.
59
+ */
60
+ function rotate(list, year) {
61
+ if (list.length <= 1)
62
+ return [...list];
63
+ const i = ((year % list.length) + list.length) % list.length;
64
+ return [...list.slice(i), ...list.slice(0, i)];
65
+ }
66
+ function feastYear(feast) {
67
+ return feast.date instanceof Date ? feast.date.getUTCFullYear() : 0;
68
+ }
69
+ // "In order to add greater solemnity, one or more of the following 'Chants ad
70
+ // libitum' may be employed." [liber-usualis, Kyriale] — the appendix is a
71
+ // SOLEMNITY boost, so it belongs only to the festal rubrics. Note this is NOT
72
+ // `isHighFeast`: that measures PRECEDENCE, and by it a Lent Sunday
73
+ // (Semiduplex I classis) and Ash Wednesday (Feria privilegiata) both rank high —
74
+ // yet adding solemnity is precisely what those days do not do. Gate on the
75
+ // rubric instead, so the appendix never reaches a penitential or ferial day.
76
+ //
77
+ // "paschal" is deliberately NOT here: it is a TIME, not a rank — every
78
+ // Eastertide day carries it, ordinary Tuesdays included (their grade is
79
+ // semiduplex, so no grade test separates them from a feast either). And Easter
80
+ // itself sings Lux et Origo every year — a fixed point — which an appendix turn
81
+ // on the paschal rubric would break every second year. Mass I is complete in
82
+ // every slot, so paschal days never need the appendix at all.
83
+ const SOLEMN_RUBRICS = new Set([
84
+ "class-i",
85
+ "class-ii",
86
+ "bvm",
87
+ ]);
88
+ // The Gloria follows the day's RANK rubric, not its season: the ferial masses
89
+ // print none (XVI, XVIII) and the penitential-Sunday mass none (XVII) — while a
90
+ // I-class feast inside Advent or Lent (Immaculate Conception, Annunciation)
91
+ // keeps its Gloria. Season-keying had both wrong: green ferias degraded to an
92
+ // ad libitum Gloria, and class feasts in Lent lost theirs. When the Gloria is
93
+ // not sung, the dismissal is Benedicamus Domino — the existing be/it switch.
94
+ const GLORIALESS_RUBRICS = new Set([
95
+ "sunday-penitential",
96
+ "feria-penitential",
97
+ "feria",
98
+ ]);
99
+ // Within a festal rubric the appendix joins the year's rotation as one more
100
+ // member of the pool, taking its turn once every (appointed masses + 1) years.
101
+ function appendixLeadsThisYear(rubric, poolSize, year) {
102
+ if (rubric == null || !SOLEMN_RUBRICS.has(rubric) || poolSize < 1)
103
+ return false;
104
+ const n = poolSize + 1;
105
+ return ((year % n) + n) % n === poolSize;
106
+ }
107
+ 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));
109
+ }
110
+ /**
111
+ * Candidate settings for one ordinary slot, most preferred first.
112
+ *
113
+ * `feast.masses` now holds exactly the masses the Kyriale appoints under the
114
+ * day's own rubric — "For feasts of the II class", "For ferias throughout the
115
+ * Year" — so every candidate here is one the book permits for this day. Where
116
+ * that rubric names several, the book numbers them (II class 1–5) and the year
117
+ * rotates through them; `massNumbers` arrives already rotated. Sibling settings
118
+ * under one number (mass I prints two dismissals, Ite Ia and Ib) rotate with it.
119
+ *
120
+ * Slots resolve independently, which the book licenses outright: "chants from one
121
+ * Mass may be used together with those from others" — so a day normally sings one
122
+ * mass throughout and borrows only where the book leaves a hole. Masses XVII and
123
+ * XVIII carry no dismissal at all, and the LU directs the borrow explicitly
124
+ * ("Benedicamus Domino as in Mass II, p. 22, or ad libitum as below").
125
+ *
126
+ * `wholeMass` carries the rubric's one exception — "the Ferial Masses excepted".
127
+ * Under a ferial rubric the sung movements may NOT be gathered from other masses;
128
+ * only the dismissal may travel, as the book itself directs.
129
+ *
130
+ * The chain degrades: appointed masses → the dismissal borrow (Mass II first,
131
+ * as the book directs) → unnumbered settings (Asperges, Vidi aquam) → the ad
132
+ * libitum appendix, and the appendix only where the day's rubric admits it.
133
+ */
134
+ function entriesForOffice(office, massNumbers, year, appendixLeads, wholeMass = false, appendixAllowed = false, pool = KYRIALE) {
135
+ const rank = new Map(massNumbers.map((m, i) => [m, i]));
136
+ const numbered = pool.filter((e) => e.office === office && e.mass != null && rank.has(e.mass) &&
137
+ !isAdLibitum(e) && !isRequiem(e));
138
+ // Group by mass so the day's rubric orders the groups, and rotate within a
139
+ // group so a mass with two printings is not permanently reduced to its first.
140
+ const groups = [...new Set(numbered.map((e) => e.mass))]
141
+ .sort((a, b) => rank.get(a) - rank.get(b));
142
+ // Under a ferial rubric the sung ordinary is not gathered from several masses.
143
+ // (The dismissal is exempt, but that travels through the explicit borrow
144
+ // below, not by widening this pool.)
145
+ const borrowable = wholeMass ? groups.slice(0, 1) : groups;
146
+ const ranked = borrowable.flatMap((m) => rotate(numbered.filter((e) => e.mass === m), year));
147
+ const appendix = appendixFor(office, pool);
148
+ const appendixPick = appendix.length
149
+ ? [appendix[((year % appendix.length) + appendix.length) % appendix.length]]
150
+ : [];
151
+ if (appendixLeads && appendixPick.length)
152
+ return [...appendixPick, ...ranked];
153
+ if (ranked.length)
154
+ return ranked;
155
+ // The SUNG borrow. A day is often appointed exactly one mass, so
156
+ // when a slot has no candidate left the rubric's own licence is the only way
157
+ // out: "chants from one Mass may be used together with those from others, the
158
+ // Ferial Masses excepted." Before the era bound this branch was unreachable —
159
+ // every appointed mass printed every sung part — but dropping Mass XI's
160
+ // 14th-c Agnus left 115 Sundays a decade with an Agnus-shaped hole and only
161
+ // Mass XI appointed. Numbered masses in book order; never the appendix, which
162
+ // stays a solemnity boost reachable only on its own turn.
163
+ if (!wholeMass) {
164
+ const borrowed = pool.filter((e) => e.office === office && e.mass != null &&
165
+ !isAdLibitum(e) && !isRequiem(e)).sort((a, b) => a.mass - b.mass || (a.id < b.id ? -1 : 1));
166
+ if (borrowed.length)
167
+ return borrowed;
168
+ }
169
+ // The dismissal borrow. A mass with no dismissal of its own is sent elsewhere
170
+ // by the book itself: Masses XVII and XVIII print none and direct
171
+ // "Benedicamus Domino as in Mass II, p. 22, or ad libitum as below"; Mass XVI
172
+ // prints none either. The primary direction wins: Mass II first, then the
173
+ // numbered kyriale in order — never the appendix, so a feria's dismissal
174
+ // cannot smuggle solemnity in through the back of the chain.
175
+ if (office === "it" || office === "be") {
176
+ const borrowed = pool.filter((e) => e.office === office && e.mass != null &&
177
+ !isAdLibitum(e) && !isRequiem(e)).sort((a, b) => (a.mass === 2 ? -1 : a.mass) - (b.mass === 2 ? -1 : b.mass) ||
178
+ (a.id < b.id ? -1 : 1));
179
+ if (borrowed.length)
180
+ return borrowed;
181
+ }
182
+ // The sprinkling rites carry no mass number. The plain Asperges / Vidi aquam
183
+ // is the standing answer — its ad libitum variants must NOT rotate against it
184
+ // as equals, or the appendix would be sung more often than the rite itself.
185
+ // 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));
187
+ const plain = unnumbered.filter((e) => !isAdLibitum(e));
188
+ if (plain.length)
189
+ return rotate(plain, year);
190
+ if (unnumbered.length)
191
+ return unnumbered.sort(adLibLast);
192
+ // Last resort: the appendix — but only where the appendix may go AT ALL.
193
+ // Ungated, this line was the hole in the fence: mass XVI prints no Gloria and
194
+ // no dismissal, so every green feria degraded to an ad libitum Gloria and to
195
+ // "Requiescant in pace" as its Ite. An empty slot is the honest answer for a
196
+ // day whose rubric the appendix may not touch.
197
+ if (!appendixAllowed)
198
+ return [];
199
+ return appendixPick.length ? appendixPick : appendix;
200
+ }
201
+ // The entry list arrives already in the day's preference order — rotation
202
+ // first, the appendix in front when its turn has come. No rank re-sorting
203
+ // happens here: an earlier masses-1–9 preference on high feasts re-imposed
204
+ // PRECEDENCE over the rubric (the confusion the rubric rebuild removed from the
205
+ // gate), silently discarding the appendix's solemnity turn on the very class-i
206
+ // days it was built for and pinning high BVM feasts to mass IX forever.
207
+ function selectBestChant(entries, filterModes) {
35
208
  if (!entries.length)
36
209
  return null;
37
- const modeStr = filterMode != null ? String(filterMode) : null;
38
- let candidates = modeStr
39
- ? entries.filter((e) => e.mode === modeStr)
210
+ let candidates = filterModes
211
+ ? entries.filter((e) => e.mode != null && filterModes.includes(e.mode))
40
212
  : entries;
41
- if (!candidates.length && modeStr) {
42
- const paired = pairedMode(filterMode);
213
+ // The paired-mode fallback serves a single asked mode; a list already
214
+ // states its own alternatives.
215
+ if (!candidates.length && filterModes?.length === 1) {
216
+ const asked = Number(filterModes[0]);
217
+ const paired = Number.isInteger(asked) ? pairedMode(asked) : null;
43
218
  if (paired)
44
219
  candidates = entries.filter((e) => e.mode === String(paired));
45
220
  }
46
221
  if (!candidates.length)
47
222
  candidates = entries;
48
- if (highFeast && candidates.length > 1) {
49
- const preferred = candidates.filter((c) => c.mass != null && c.mass >= 1 && c.mass <= 9);
50
- if (preferred.length)
51
- candidates = preferred;
52
- }
53
223
  return candidates[0];
54
224
  }
55
225
  function allowedCredos(masses) {
@@ -59,9 +229,9 @@ function allowedCredos(masses) {
59
229
  set.add(c);
60
230
  return CREDO_PRIORITY.filter((c) => set.has(c));
61
231
  }
62
- function selectCredoCode(feast, allowed) {
63
- if (!allowed.length)
64
- return null;
232
+ // The preferred credo for the day, where the season or the feast's character
233
+ // argues for one. A BIAS, not a gate — see selectCredoCode.
234
+ function preferredCredoCode(feast, allowed) {
65
235
  const { season, weekday, marian, apostolic } = feast;
66
236
  const isSunday = weekday === 0;
67
237
  if (isSunday && ["adv", "quadp", "quad", "nat"].includes(season) && allowed.includes("IV"))
@@ -74,9 +244,39 @@ function selectCredoCode(feast, allowed) {
74
244
  return "III";
75
245
  if (marian && allowed.includes("IV"))
76
246
  return "IV";
77
- return allowed[0];
247
+ return allowed[0] ?? null;
78
248
  }
79
- function entryToOrdinaryChant(entry) {
249
+ // The fitting credo leads in one year of every two; the other years step
250
+ // through all six in turn — a full circuit every twelve years — so every credo,
251
+ // V included, is heard. The fitting one is heard most.
252
+ const CREDO_BIAS_EVERY = 2;
253
+ /**
254
+ * Which credo the day sings, or null when this mass says none.
255
+ *
256
+ * An empty `allowed` still means NO credo today — that is the mass's own rubric
257
+ * and it is respected. But when a credo IS sung, the choice is drawn from all
258
+ * six, not from `allowed`: the Kyriale prints Credo I–VI as a set any mass may
259
+ * draw on, whereas the `credos` arrays in masses.ts only ever name I, III and
260
+ * IV — which left II, V and VI unsingable on every day of the year, despite
261
+ * CREDO_PRIORITY naming all six. The narrow `credos` data is a separate gap
262
+ * still open; this reads it as a preference rather than the whole permission.
263
+ */
264
+ function selectCredoCode(feast, allowed, year) {
265
+ if (!allowed.length)
266
+ return null;
267
+ const preferred = preferredCredoCode(feast, allowed);
268
+ if (preferred && year % CREDO_BIAS_EVERY === 0)
269
+ return preferred;
270
+ // The off-years advance their OWN cycle. Keyed on the raw year, the
271
+ // rotation's parity was coupled to the bias (2 divides 6): odd years could
272
+ // only ever land on indices 1, 3, 5 — III, II, VI — and Credo V was never
273
+ // sung on any day of any year.
274
+ return rotate(CREDO_PRIORITY, Math.floor(year / CREDO_BIAS_EVERY))[0];
275
+ }
276
+ // Exported for the corpus surface (chant.ts): the kyriale rides `cantus`
277
+ // under source "ky" as exactly these records — one shaping, one identity,
278
+ // whether a chant arrives through the book or through `ordinarium`.
279
+ export function entryToOrdinaryChant(entry) {
80
280
  const ordinary = ORDINARY_OFFICES.has(entry.office)
81
281
  ? entry.office
82
282
  : "ky";
@@ -87,11 +287,11 @@ function entryToOrdinaryChant(entry) {
87
287
  office: "or",
88
288
  genus: "Ordinarium",
89
289
  mode: entry.mode ? String(entry.mode) : null,
90
- modus: entry.mode ? (MODE_LABELS[String(entry.mode)] ?? null) : null,
290
+ modus: entry.mode ? (MODI[String(entry.mode)] ?? null) : null,
91
291
  pages: [],
92
- source: { book: "Graduale Romanum", year: 1961, editor: "Solesmes", code: "gr" },
292
+ source: KY_SOURCE,
93
293
  ordinary,
94
- ordinarium: ORDINARY_LABELS[ordinary] ?? entry.incipit,
294
+ ordinarium: ORDINARIA[ordinary] ?? entry.incipit,
95
295
  mass: entry.mass ?? 0,
96
296
  };
97
297
  }
@@ -99,13 +299,13 @@ function entryToOrdinaryChant(entry) {
99
299
  // Mass with the Gloria (rung with bells, which then fall silent until the
100
300
  // Easter Vigil) despite Lent's penitential omission and the Triduum's
101
301
  // otherwise empty ordinary. The Credo and the Sunday sprinkle rite are not
102
- // part of this evening Mass. See docs/chant.md.
302
+ // part of this evening Mass. See ../../../docs/api/chant.md.
103
303
  const MAUNDY_THURSDAY_ID = "Quad6-4";
104
304
  // The feast carries no numbered Kyriale mass of its own (masses: []); as a
105
305
  // paschally-adjacent solemnity it draws on Mass I (Lux et origo) — the same
106
306
  // mass the Easter Vigil borrows, so both Triduum Masses share a setting.
107
307
  const MAUNDY_THURSDAY_MASS = 1;
108
- function ordinaryForFeast(feast, pinMass, filterMode) {
308
+ function ordinaryForFeast(feast, pinMass, filterModes, admissible) {
109
309
  const isMaundyThursday = feast.id === MAUNDY_THURSDAY_ID;
110
310
  // The Triduum has no Mass-ordinary cycle (Good Friday has no Mass; the
111
311
  // Vigil's ordinary belongs to Easter). Maundy Thursday is the exception —
@@ -116,22 +316,52 @@ function ordinaryForFeast(feast, pinMass, filterMode) {
116
316
  const masses = resolvedMass != null
117
317
  ? (() => { const e = MASSES.get(resolvedMass); return e ? [e] : []; })()
118
318
  : resolveMasses(feast);
119
- const massNumbers = masses.map((m) => m.mass);
120
- const mode = filterMode ?? null;
121
- const highFeast = isHighFeast(feast.grade);
319
+ const mode = filterModes ?? null;
320
+ // The day's OWN rank rubric, from the unpinned resolution — the Gloria and
321
+ // the appendix licence are the day's law, not the pinned mass's.
322
+ const dayRubric = resolveMasses(feast)[0]?.rubric ?? null;
323
+ const appendixAllowed = dayRubric != null && SOLEMN_RUBRICS.has(dayRubric);
324
+ // The Kyriale era rule RE-PICKS rather than silences: unlike a proper, the
325
+ // ordinary offers ranked alternatives by design, so the whole selection —
326
+ // rotation, siblings, borrow, appendix — runs over the admissible pool and
327
+ // the day still sings a permitted setting. Filtering PER PART is what makes
328
+ // that work: Mass XI keeps its 10th-c Kyrie/Gloria/Sanctus and only its
329
+ // 14th-c Agnus borrows. Mass VIII (de Angelis) loses its Kyrie, Gloria and
330
+ // Agnus — the famous late ones — while its Sanctus stays, because the editors
331
+ // print that one "(XI) XII. s." NO mass leaves the pool whole: 7 of 66 parts
332
+ // go, and every day still sings.
333
+ // See partWithinEra() in ./data/masses.ts for the 1324 reasoning.
334
+ const pool = KYRIALE.filter((e) => partWithinEra(e.mass, e.office) && (!admissible || admissible(e.id)));
335
+ // The year steps through the masses the day's rubric appoints. A pinned mass
336
+ // is an explicit request and overrides that — `ordinarium({ mass })` means
337
+ // that mass, this year and every year.
338
+ const year = feastYear(feast);
339
+ const pinned = resolvedMass != null;
340
+ const massNumbers = pinned
341
+ ? masses.map((m) => m.mass)
342
+ : rotate(masses.map((m) => m.mass), year);
343
+ // Every mass the day permits shares one rubric (they were selected by it), so
344
+ // the first one carries the day's category. "The Ferial Masses excepted" —
345
+ // under those two rubrics the sung ordinary is not assembled from several.
346
+ const rubric = masses[0]?.rubric ?? null;
347
+ const wholeMass = !pinned && rubric != null && WHOLE_MASS_RUBRICS.has(rubric);
348
+ const appendixLeads = !pinned &&
349
+ appendixLeadsThisYear(rubric, massNumbers.length, year);
122
350
  const pick = (office) => {
123
- const entries = entriesForOffice(office, massNumbers);
124
- const best = selectBestChant(entries, mode, highFeast, massNumbers);
351
+ const entries = entriesForOffice(office, massNumbers, year, appendixLeads, wholeMass, appendixAllowed, pool);
352
+ const best = selectBestChant(entries, mode);
125
353
  return best ? entryToOrdinaryChant(best) : null;
126
354
  };
127
355
  const results = [];
128
356
  const ky = pick("ky");
129
357
  if (ky)
130
358
  results.push(ky);
131
- // Gloria is omitted in penitential seasons (Advent, Septuagesima, Lent).
132
- // Maundy Thursday keeps it — its Gloria is a deliberate breach of Lenten
133
- // austerity, sung with the bells before they fall silent.
134
- const glOmitted = PENITENTIAL_SEASONS.has(feast.season) && !isMaundyThursday;
359
+ // Gloria is omitted under the gloria-less rubrics — penitential Sundays and
360
+ // every feria — not by season (see GLORIALESS_RUBRICS: a I-class feast inside
361
+ // Advent keeps its Gloria; a green feria has none). Maundy Thursday keeps it
362
+ // regardless — its Gloria is a deliberate breach of Lenten austerity, sung
363
+ // with the bells before they fall silent.
364
+ const glOmitted = dayRubric != null && GLORIALESS_RUBRICS.has(dayRubric) && !isMaundyThursday;
135
365
  if (!glOmitted) {
136
366
  const gl = pick("gl");
137
367
  if (gl)
@@ -140,14 +370,26 @@ function ordinaryForFeast(feast, pinMass, filterMode) {
140
370
  // Credo — In Cena Domini's Mass has no Creed.
141
371
  if (!isMaundyThursday) {
142
372
  const allowed = allowedCredos(masses);
143
- const credoCode = selectCredoCode(feast, allowed);
373
+ const credoCode = selectCredoCode(feast, allowed, year);
144
374
  if (credoCode) {
145
- const credoEntries = KYRIALE.filter((e) => e.office === "cr");
146
- const named = credoEntries.find((e) => e.incipit.includes(credoCode));
147
- const best = named ?? selectBestChant(credoEntries, mode, highFeast, massNumbers);
375
+ const credoEntries = pool.filter((e) => e.office === "cr");
376
+ // Exact numeral match, never substring: `.includes("V")` found
377
+ // "Credo IV" first, so a day asking for Credo V was served IV under a
378
+ // V label — the honest-label fix exposed it, and the cycle test caught
379
+ // the numeral that had silently never sounded.
380
+ const credoNumeral = (incipit) => /Credo\s+([IVX]+)/.exec(incipit)?.[1] ?? null;
381
+ const named = credoEntries.find((e) => credoNumeral(e.incipit) === credoCode);
382
+ const best = named ?? selectBestChant(credoEntries, mode);
148
383
  if (best) {
149
384
  const cr = entryToOrdinaryChant(best);
150
- cr.ordinarium = `Credo ${credoCode}`;
385
+ // `ordinarium` is the PART — "Credo", as ORDINARIA spells it, beside
386
+ // "Gloria" and "Sanctus". Which credo of the six sings is the chant's
387
+ // own identity and is already in its incipit ("Credo II"), which is
388
+ // where the honest-label rule below reads it: when the asked-for
389
+ // credo is not in the pool (an era view excluded it, or the mode
390
+ // filter did), the pool's best sings instead, under its own name.
391
+ // Numbering the part here made Credo the one ordinary whose category
392
+ // was not a category.
151
393
  results.push(cr);
152
394
  }
153
395
  }
@@ -173,8 +415,8 @@ function ordinaryForFeast(feast, pinMass, filterMode) {
173
415
  // evening Mass of In Cena Domini.
174
416
  if (!isMaundyThursday) {
175
417
  const sprinkleType = feast.season === "pasc" ? "va" : "as";
176
- const sprinkleEntries = entriesForOffice(sprinkleType, massNumbers);
177
- const sprinkleBest = selectBestChant(sprinkleEntries, mode, highFeast, massNumbers);
418
+ const sprinkleEntries = entriesForOffice(sprinkleType, massNumbers, year, false, false, appendixAllowed, pool);
419
+ const sprinkleBest = selectBestChant(sprinkleEntries, mode);
178
420
  if (sprinkleBest)
179
421
  results.push(entryToOrdinaryChant(sprinkleBest));
180
422
  }
@@ -185,6 +427,17 @@ function toArray(v) {
185
427
  return undefined;
186
428
  return Array.isArray(v) ? v : [v];
187
429
  }
430
+ /** The feast filter must carry Feast objects (from tonus.festum) — a raw
431
+ * TypeError deep in resolution would otherwise mask the caller bug. */
432
+ function assertFeasts(feasts, method) {
433
+ if (!feasts)
434
+ return;
435
+ for (const f of feasts) {
436
+ if (!f || typeof f !== "object" || typeof f.id !== "string")
437
+ throw new Error(`${method}: feast must be a Feast (from tonus.festum) — got ${typeof f}`);
438
+ }
439
+ }
440
+ const ORDINARIUM_QUERY_KEYS = new Set([...CANTUS_QUERY_KEYS, "feast", "ordinary", "mass"]);
188
441
  /**
189
442
  * Mass ordinary retrieval (`tonus.ordinarium`) from the Kyriale. A feast
190
443
  * drives mass selection; `mass` pins a kyriale number directly.
@@ -192,21 +445,48 @@ function toArray(v) {
192
445
  export function getOrdinary(query) {
193
446
  if (!query || Object.keys(query).length === 0)
194
447
  return [];
448
+ // The same door policy as cantus and officium: an unknown key throws, so a
449
+ // stale or misspelled option is learned immediately, not silently ignored.
450
+ const unknown = Object.keys(query).filter((k) => !ORDINARIUM_QUERY_KEYS.has(k));
451
+ if (unknown.length) {
452
+ throw new Error(`ordinarium: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
453
+ `(expected ${[...ORDINARIUM_QUERY_KEYS].join(", ")}).`);
454
+ }
195
455
  const feasts = toArray(query.feast);
196
- const filterMode = query.mode != null ? Number(query.mode) : undefined;
456
+ assertFeasts(feasts, "ordinarium");
457
+ // `mode` accepts a scalar or an array, with cantus's semantics: match any.
458
+ const filterModes = query.mode == null
459
+ ? null
460
+ : (Array.isArray(query.mode) ? query.mode : [query.mode]).map(String);
197
461
  let results;
198
462
  if (feasts) {
199
- results = feasts.flatMap((f) => ordinaryForFeast(f, query.mass, filterMode));
463
+ // The era view: an own `before` wins; otherwise the view festum({ before })
464
+ // stamped on the feast rides along. The admissibility rule composes with
465
+ // the standing Kyriale era doctrine (partWithinEra, bound at 1324):
466
+ // doctrine bounds what the BOOK may reach for, attestation narrows to what
467
+ // a viewed year can EVIDENCE — and the re-pick machinery serves both.
468
+ results = feasts.flatMap((f) => {
469
+ const cutoff = eraCutoff(query, [f], "ordinarium");
470
+ const adm = cutoff != null || query.cursus
471
+ ? (id) => chantAdmissible(id, cutoff, query.cursus)
472
+ : null;
473
+ return ordinaryForFeast(f, query.mass, filterModes, adm);
474
+ });
200
475
  }
201
476
  else if (query.mass != null || query.ordinary) {
202
- // Direct kyriale query without feast context
477
+ // Direct kyriale query without feast context — same admissibility rule as
478
+ // cantus, so the two doors cannot disagree.
203
479
  let entries = KYRIALE.slice();
480
+ const cutoff = attestationCutoff(query, "ordinarium");
481
+ if (cutoff != null || query.cursus) {
482
+ entries = entries.filter((e) => chantAdmissible(e.id, cutoff, query.cursus));
483
+ }
204
484
  if (query.mass != null)
205
485
  entries = entries.filter((e) => e.mass === query.mass);
206
486
  if (query.ordinary)
207
487
  entries = entries.filter((e) => e.office === query.ordinary);
208
- if (filterMode != null)
209
- entries = entries.filter((e) => e.mode === String(filterMode));
488
+ if (filterModes)
489
+ entries = entries.filter((e) => e.mode != null && filterModes.includes(e.mode));
210
490
  const offset = Math.max(0, query.offset ?? 0);
211
491
  const limit = query.limit == null ? entries.length : Math.max(0, query.limit);
212
492
  results = entries.slice(offset, offset + limit).map(entryToOrdinaryChant);
@@ -2,7 +2,9 @@
2
2
  // engines/chant/propers — Mass proper lookup
3
3
  // ---------------------------------------------------------------------------
4
4
  import { resolveChant } from "./chant.js";
5
+ import { eraCutoff, chantAdmissible } from "./attest.js";
5
6
  import { temporaSundayId } from "../cal/date.js";
7
+ import { PENITENTIAL_SEASONS } from "../cal/types.js";
6
8
  import { PROPERS } from "../../data/propers.js";
7
9
  import { COMMUNE_PROPERS, FEAST_COMMUNE } from "../../data/commune.js";
8
10
  let _byFeastId = null;
@@ -24,17 +26,33 @@ function communePropers() {
24
26
  return _communePropers;
25
27
  }
26
28
  const PROPER_SLOTS = ["in", "gr", "al", "tr", "of", "co"];
27
- function resolveProperChants(feastId) {
29
+ function resolveProperChants(feastId, season) {
28
30
  const map = byFeastId();
29
31
  const proper = map.get(feastId) ?? null;
30
32
  const sunday = temporaSundayId(feastId);
31
33
  const seasonProper = sunday ? (map.get(sunday) ?? null) : null;
32
34
  const commune = communeByFeast().get(feastId);
33
35
  const communeProper = commune ? (communePropers().get(commune) ?? null) : null;
36
+ const ids = {};
37
+ for (const slot of PROPER_SLOTS) {
38
+ ids[slot] = proper?.[slot] ?? seasonProper?.[slot] ?? communeProper?.[slot] ?? null;
39
+ }
40
+ // Alleluia OR Tractus when the formulary carries BOTH: the commune sets
41
+ // serve year-round and print the pair, and serving them together handed
42
+ // 08-26 two gradual-tier chants in one Mass. Penitential seasons silence
43
+ // the Alleluia and sing the Tract; the rest of the year the reverse. A
44
+ // formulary carrying only ONE keeps it regardless — an Ember Saturday's
45
+ // tract is not silenced for falling outside Lent — and a survey call
46
+ // (no season in hand) still reports both.
47
+ if (season != null && ids.al && ids.tr) {
48
+ if (PENITENTIAL_SEASONS.has(season))
49
+ ids.al = null;
50
+ else
51
+ ids.tr = null;
52
+ }
34
53
  const results = [];
35
54
  for (const slot of PROPER_SLOTS) {
36
- const id = proper?.[slot] ?? seasonProper?.[slot] ?? communeProper?.[slot] ?? null;
37
- const chant = resolveChant(id);
55
+ const chant = resolveChant(ids[slot]);
38
56
  if (chant)
39
57
  results.push(chant);
40
58
  }
@@ -45,11 +63,26 @@ function toFeastArray(v) {
45
63
  return undefined;
46
64
  return Array.isArray(v) ? v : [v];
47
65
  }
66
+ /** The feast filter must carry Feast objects (from tonus.festum) — a raw
67
+ * TypeError deep in resolution would otherwise mask the caller bug. */
68
+ function assertFeasts(feasts, method) {
69
+ if (!feasts)
70
+ return;
71
+ for (const f of feasts) {
72
+ if (!f || typeof f !== "object" || typeof f.id !== "string")
73
+ throw new Error(`${method}: feast must be a Feast (from tonus.festum) — got ${typeof f}`);
74
+ }
75
+ }
48
76
  function toArray(v) {
49
77
  if (v === undefined)
50
78
  return undefined;
51
79
  return Array.isArray(v) ? v : [v];
52
80
  }
81
+ const PROPRIUM_QUERY_KEYS = new Set([
82
+ "feast", "id", "gabc", "incipit", "mode", "office", "source",
83
+ "before", "cursus",
84
+ "limit", "offset", "sort",
85
+ ]);
53
86
  /**
54
87
  * Mass proper retrieval (`tonus.proprium`): Introitus, Graduale,
55
88
  * Alleluia/Tractus, Offertorium, Communio. A feast narrows the result;
@@ -58,15 +91,32 @@ function toArray(v) {
58
91
  export function getPropers(query) {
59
92
  if (!query || Object.keys(query).length === 0)
60
93
  return [];
94
+ // The reconciled query contract (as festum/cantus): an unknown key is a
95
+ // caller bug, not a filter that silently matches everything.
96
+ const unknown = Object.keys(query).filter((k) => !PROPRIUM_QUERY_KEYS.has(k));
97
+ if (unknown.length > 0) {
98
+ throw new Error(`proprium: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
99
+ `(expected ${[...PROPRIUM_QUERY_KEYS].join(", ")}).`);
100
+ }
61
101
  const feasts = toFeastArray(query.feast);
102
+ assertFeasts(feasts, "proprium");
62
103
  let results;
63
104
  if (feasts) {
64
- results = feasts.flatMap((f) => resolveProperChants(f.id));
105
+ results = feasts.flatMap((f) => resolveProperChants(f.id, f.season));
65
106
  }
66
107
  else {
67
- // No feast filter — resolve all propers
108
+ // No feast filter — resolve all propers (a survey: no season, both
109
+ // alleluia and tract report).
68
110
  results = PROPERS.flatMap((p) => resolveProperChants(p.feastId));
69
111
  }
112
+ // The era view: an own `before` wins; otherwise the view festum({ before })
113
+ // stamped on the feast rides along. A proper has no pool of alternatives, so
114
+ // an excluded chant degrades to SILENCE by design — the same evidence law as
115
+ // the corpus cut.
116
+ const cutoff = eraCutoff(query, feasts, "proprium");
117
+ if (cutoff != null || query.cursus) {
118
+ results = results.filter((c) => chantAdmissible(c.id, cutoff, query.cursus));
119
+ }
70
120
  const offices = toArray(query.office);
71
121
  if (offices) {
72
122
  const set = new Set(offices);
@@ -1,5 +1,5 @@
1
- import { type OfficePsalmEntry, type OfficePsalmPortion } from "../../data/office-psalms-roman.js";
2
- import { type Chant, type PsalmusQuery, type Rite } from "./types.js";
1
+ import { type OfficePsalmEntry, type OfficePsalmPortion } from "../../data/office-psalms-monastic.js";
2
+ import { type Chant, type PsalmusQuery } from "./types.js";
3
3
  /**
4
4
  * Psalm and canticle retrieval (`tonus.psalmus`) from the Psalterium,
5
5
  * intoned to the psalm tones (modes 1-8 plus tonus peregrinus) as GABC.
@@ -16,9 +16,9 @@ export declare function getPsalmRange(psalm: number, lo: number, hi: number, mod
16
16
  export declare function intonePortion(p: OfficePsalmPortion, mode?: number): Chant[];
17
17
  /**
18
18
  * The little-hours psalmody for one hour on a given weekday (0 = Sunday), from
19
- * the extracted DO Tridentine scheme (`office-psalms-roman.ts`). Prefers the
19
+ * the extracted DO monastic scheme (`office-psalms-monastic.ts`). Prefers the
20
20
  * weekday-specific entry, then the ferial default (weekday null), then the
21
21
  * feast set; returns the psalm portions (not yet intoned).
22
22
  */
23
- export declare function officePsalmPortions(hour: OfficePsalmEntry["hour"], weekday: number, rite?: Rite): OfficePsalmPortion[];
23
+ export declare function officePsalmPortions(hour: OfficePsalmEntry["hour"], weekday: number): OfficePsalmPortion[];
24
24
  //# sourceMappingURL=psalm.d.ts.map