tonus 0.1.6 → 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 (161) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +610 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2669 -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 +24 -0
  21. package/dist/data/corpus-overlap.js +347 -0
  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 +757 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +802 -13419
  31. package/dist/data/lh.js +116 -3473
  32. package/dist/data/lu.js +935 -17632
  33. package/dist/data/nocturnale-romanum.d.ts +5 -0
  34. package/dist/data/nocturnale-romanum.js +3772 -0
  35. package/dist/data/office-ferial.d.ts +4 -0
  36. package/dist/data/office-ferial.js +396 -0
  37. package/dist/data/office-ferial.json +391 -0
  38. package/dist/data/office-monastic.d.ts +17 -1
  39. package/dist/data/office-monastic.js +1403 -466
  40. package/dist/data/office-psalms-monastic.d.ts +13 -1
  41. package/dist/data/office-psalms-monastic.js +9 -0
  42. package/dist/data/propers.js +1 -1
  43. package/dist/data/psalms.js +22919 -5
  44. package/dist/data/psm.d.ts +5 -0
  45. package/dist/data/psm.js +122 -0
  46. package/dist/data/seasonal-respbreve.d.ts +5 -0
  47. package/dist/data/seasonal-respbreve.js +41 -0
  48. package/dist/data/seasonal-respbreve.json +35 -0
  49. package/dist/data/smufl-glyphs.d.ts +17 -0
  50. package/dist/data/smufl-glyphs.js +1546 -0
  51. package/dist/data/smufl-glyphs.json +1530 -0
  52. package/dist/engines/cal/calendar.d.ts +3 -2
  53. package/dist/engines/cal/calendar.js +105 -29
  54. package/dist/engines/cal/data/eras.d.ts +35 -0
  55. package/dist/engines/cal/data/eras.js +128 -0
  56. package/dist/engines/cal/date.js +44 -0
  57. package/dist/engines/cal/types.d.ts +15 -3
  58. package/dist/engines/cal/types.js +5 -5
  59. package/dist/engines/census/census.d.ts +7 -0
  60. package/dist/engines/census/census.js +179 -0
  61. package/dist/engines/census/types.d.ts +55 -0
  62. package/dist/engines/census/types.js +8 -0
  63. package/dist/engines/chant/attest.d.ts +39 -0
  64. package/dist/engines/chant/attest.js +90 -0
  65. package/dist/engines/chant/chant.d.ts +22 -4
  66. package/dist/engines/chant/chant.js +274 -14
  67. package/dist/engines/chant/data/compline.js +2 -1
  68. package/dist/engines/chant/data/masses.d.ts +56 -4
  69. package/dist/engines/chant/data/masses.js +305 -80
  70. package/dist/engines/chant/data/prime.js +1 -1
  71. package/dist/engines/chant/hour.js +279 -58
  72. package/dist/engines/chant/ordinary.d.ts +2 -0
  73. package/dist/engines/chant/ordinary.js +336 -56
  74. package/dist/engines/chant/propers.js +55 -5
  75. package/dist/engines/chant/psalm.d.ts +4 -4
  76. package/dist/engines/chant/psalm.js +25 -11
  77. package/dist/engines/chant/syllabify.d.ts +1 -0
  78. package/dist/engines/chant/syllabify.js +90 -17
  79. package/dist/engines/chant/types.d.ts +145 -10
  80. package/dist/engines/chant/types.js +38 -3
  81. package/dist/engines/harmonia/api.js +4 -0
  82. package/dist/engines/harmonia/data/doctrines.js +3 -1
  83. package/dist/engines/harmonia/tabula.d.ts +3 -0
  84. package/dist/engines/harmonia/tabula.js +1 -0
  85. package/dist/engines/harmonia/voice.d.ts +4 -0
  86. package/dist/engines/harmonia/voice.js +8 -4
  87. package/dist/engines/imprint.js +14 -1
  88. package/dist/engines/planet/orbital.js +4 -4
  89. package/dist/engines/planet/planet.d.ts +10 -0
  90. package/dist/engines/planet/planet.js +30 -3
  91. package/dist/engines/planet/position.js +13 -10
  92. package/dist/engines/planet/types.d.ts +1 -0
  93. package/dist/engines/score/api.d.ts +2 -13
  94. package/dist/engines/score/api.js +21 -8
  95. package/dist/engines/score/articulation.js +2 -2
  96. package/dist/engines/score/cadence.d.ts +76 -0
  97. package/dist/engines/score/cadence.js +96 -0
  98. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  99. package/dist/engines/score/emitters/accidentals.js +88 -0
  100. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  101. package/dist/engines/score/emitters/atramentum.js +239 -0
  102. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  103. package/dist/engines/score/emitters/breaking.js +80 -0
  104. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  105. package/dist/engines/score/emitters/moderna.js +612 -0
  106. package/dist/engines/score/emitters/svg.d.ts +143 -0
  107. package/dist/engines/score/emitters/svg.js +1328 -0
  108. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  109. package/dist/engines/score/emitters/tracks.js +728 -0
  110. package/dist/engines/score/infer.d.ts +3 -3
  111. package/dist/engines/score/infer.js +2 -2
  112. package/dist/engines/score/inscriptio.d.ts +69 -0
  113. package/dist/engines/score/inscriptio.js +138 -0
  114. package/dist/engines/score/ir.d.ts +2 -2
  115. package/dist/engines/score/ir.js +59 -12
  116. package/dist/engines/score/lyric.d.ts +23 -0
  117. package/dist/engines/score/lyric.js +234 -0
  118. package/dist/engines/score/meta.d.ts +2 -2
  119. package/dist/engines/score/modulation.d.ts +12 -0
  120. package/dist/engines/score/modulation.js +49 -0
  121. package/dist/engines/score/neume.js +35 -4
  122. package/dist/engines/score/parse.js +150 -9
  123. package/dist/engines/score/phrasing.js +4 -3
  124. package/dist/engines/score/prosody.d.ts +38 -0
  125. package/dist/engines/score/prosody.js +70 -6
  126. package/dist/engines/score/tabula.d.ts +37 -5
  127. package/dist/engines/score/tabula.js +18 -0
  128. package/dist/engines/score/types.d.ts +88 -1
  129. package/dist/engines/temper/api.d.ts +4 -1
  130. package/dist/engines/temper/api.js +28 -5
  131. package/dist/engines/temper/data/guido.js +6 -2
  132. package/dist/engines/temper/data/modes.d.ts +6 -0
  133. package/dist/engines/temper/data/modes.js +42 -0
  134. package/dist/engines/temper/data/tones.d.ts +1 -1
  135. package/dist/engines/temper/data/tones.js +20 -11
  136. package/dist/engines/temper/interval.js +4 -3
  137. package/dist/engines/temper/modality.d.ts +11 -2
  138. package/dist/engines/temper/modality.js +74 -2
  139. package/dist/engines/temper/modes.d.ts +1 -1
  140. package/dist/engines/temper/pitch.d.ts +1 -1
  141. package/dist/engines/temper/pitch.js +12 -2
  142. package/dist/engines/temper/scale.d.ts +53 -0
  143. package/dist/engines/temper/scale.js +107 -8
  144. package/dist/index.d.ts +28 -6
  145. package/dist/index.js +39 -3
  146. package/docs/api/calendar.md +279 -0
  147. package/docs/api/census.md +288 -0
  148. package/docs/api/chant.md +657 -0
  149. package/docs/api/heavens.md +346 -0
  150. package/docs/api/index.md +263 -0
  151. package/docs/api/score.md +873 -0
  152. package/docs/api/tuning.md +619 -0
  153. package/package.json +11 -5
  154. package/dist/data/office-psalms-roman.d.ts +0 -15
  155. package/dist/data/office-psalms-roman.js +0 -28
  156. package/dist/data/office-roman.d.ts +0 -19
  157. package/dist/data/office-roman.js +0 -13792
  158. package/dist/engines/score/emitters/midi.d.ts +0 -65
  159. package/dist/engines/score/emitters/midi.js +0 -162
  160. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  161. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -1,26 +1,35 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/chant/hour — Divine Office hour retrieval
3
3
  // ---------------------------------------------------------------------------
4
- import { resolveChant, resolveChants } from "./chant.js";
4
+ import { resolveChant, resolveChants, CANTUS_QUERY_KEYS } from "./chant.js";
5
+ import { eraCutoff, chantAdmissible } from "./attest.js";
5
6
  import { intonePortion, officePsalmPortions } from "./psalm.js";
6
7
  import { temporaSundayId } from "../cal/date.js";
7
8
  import { getFeast } from "../cal/calendar.js";
8
- import { OFFICE_ROMAN } from "../../data/office-roman.js";
9
+ import { HORAE } from "./types.js";
9
10
  import { OFFICE_MONASTIC } from "../../data/office-monastic.js";
11
+ import { OFFICE_FERIAL } from "../../data/office-ferial.js";
12
+ import { COMMUNE_OFFICE } from "../../data/commune-office.js";
13
+ import { SEASONAL_RESPBREVE } from "../../data/seasonal-respbreve.js";
14
+ import { FEAST_COMMUNE } from "../../data/commune.js";
10
15
  import { COMPLINE_ORDINARY, COMPLINE_SEASONAL, marianAntiphonFor, } from "./data/compline.js";
11
16
  import { PRIME_ORDINARY, PRIME_SEASONAL } from "./data/prime.js";
12
- let _roman = null;
13
- let _monastic = null;
14
- function officeMap(rite) {
15
- if (rite === "monasticum") {
16
- if (!_monastic)
17
- _monastic = new Map(OFFICE_MONASTIC.map((d) => [d.feastId, d]));
18
- return _monastic;
19
- }
20
- if (!_roman)
21
- _roman = new Map(OFFICE_ROMAN.map((d) => [d.feastId, d]));
22
- return _roman;
17
+ // ONE office table. The Roman one was cut: DO's Roman horas were largely
18
+ // hollow — most rows carried NO office chant — and `romanum` was the DEFAULT
19
+ // rite, so the untold call returned nothing. Epiphany 1098 answered 0 for
20
+ // Matins, Lauds and Vespers under Roman and 16/6/5 under monastic (still true:
21
+ // re-checked 2026-08-11, and the one half of this a shipped package CAN check).
22
+ // The corpus
23
+ // is monastic-flat; the office follows — and with it the Roman little-hours
24
+ // psalmody, whose only consumer was this file. A `rite` option is deliberately
25
+ // absent from the query: there is one cursus, so there is nothing to choose.
26
+ let _office = null;
27
+ function officeMap() {
28
+ if (!_office)
29
+ _office = new Map(OFFICE_MONASTIC.map((d) => [d.feastId, d]));
30
+ return _office;
23
31
  }
32
+ const OFFICIUM_QUERY_KEYS = new Set([...CANTUS_QUERY_KEYS, "feast", "hora"]);
24
33
  // Hours whose result is an ordered sequence (an ordo) rather than a set of
25
34
  // chants — they keep assembly order instead of being sorted by incipit.
26
35
  const ORDERED_ORDO_HOURS = new Set([
@@ -37,16 +46,14 @@ const SEASONAL_ORDO_HOURS = new Set([
37
46
  // tuas), the fixed psalms (from the extracted DO scheme), the invariable spine
38
47
  // (Deus in adjutorium, Nunc dimittis), and the date-driven Marian antiphon.
39
48
  // See ./data/compline.ts.
40
- function complineForFeast(feast, rite) {
49
+ function complineForFeast(feast) {
41
50
  const seasonal = COMPLINE_SEASONAL[feast.season];
42
51
  const results = [];
43
52
  const opening = resolveChant(COMPLINE_ORDINARY.opening);
44
53
  if (opening)
45
54
  results.push(opening);
46
- // Monastic Compline uses a fixed three-psalm set (4, 90, 133); the Roman rite
47
- // adds Ps 30 vv. 2–6. The difference is entirely in the psalm scheme — the
48
- // rest of the ordo (spine, hymn, In manus tuas, Marian antiphon) is shared.
49
- for (const p of officePsalmPortions("Completorium", feast.weekday, rite)) {
55
+ // Monastic Compline is a fixed three-psalm set (4, 90, 133).
56
+ for (const p of officePsalmPortions("Completorium", feast.weekday)) {
50
57
  results.push(...intonePortion(p));
51
58
  }
52
59
  const hymn = seasonal && resolveChant(seasonal.teLucis);
@@ -66,7 +73,7 @@ function complineForFeast(feast, rite) {
66
73
  // Prime, like Compline, is a fixed+seasonal ordo, not per-feast. Covers the
67
74
  // sung parts only (see ./data/prime.ts): opening, fixed psalms, the hymn Iam
68
75
  // lucis, and the seasonal short responsory Christe Fili Dei.
69
- function primeForFeast(feast, rite) {
76
+ function primeForFeast(feast) {
70
77
  const seasonal = PRIME_SEASONAL[feast.season];
71
78
  const results = [];
72
79
  const opening = resolveChant(PRIME_ORDINARY.opening);
@@ -75,9 +82,8 @@ function primeForFeast(feast, rite) {
75
82
  const hymn = resolveChant(PRIME_ORDINARY.hymn);
76
83
  if (hymn)
77
84
  results.push(hymn);
78
- // The monastic Prime psalmody is weekday-varied across the psalter (vs. the
79
- // Roman Ps-118 pattern) — a psalm-scheme difference; the ordo spine is shared.
80
- for (const p of officePsalmPortions("Prima", feast.weekday, rite)) {
85
+ // The monastic Prime psalmody is weekday-varied across the psalter.
86
+ for (const p of officePsalmPortions("Prima", feast.weekday)) {
81
87
  results.push(...intonePortion(p));
82
88
  }
83
89
  const responsory = seasonal && resolveChant(seasonal.responsory);
@@ -85,62 +91,245 @@ function primeForFeast(feast, rite) {
85
91
  results.push(responsory);
86
92
  return results;
87
93
  }
88
- function chantsForFeastHour(feast, hour, rite) {
94
+ /**
95
+ * The antiphons for an hour: the day's own proper, else its COMMUNE, else the
96
+ * FERIAL CYCLE.
97
+ *
98
+ * A monastic weekday in the temporale mostly has no proper antiphons — the
99
+ * office-monastic table has the day but leaves antLaudes/antVespera/antMatutinum
100
+ * empty (292 of its 409 entries), because those antiphons live in the psalter,
101
+ * not in the propers. Without a fallback the hour silently returns a partial
102
+ * ordo, which is why Lauds resolved on 106 of 360 days and essentially never in
103
+ * the temporale.
104
+ *
105
+ * ── WHY THE COMMUNE COMES BEFORE THE FERIAL CYCLE ───────────────────────────
106
+ * A saint's day is not a feria. When the rubrics give a saint no proper
107
+ * antiphons they do not send the choir back to the weekday psalter — they send
108
+ * it to the saint's CATEGORY: the Commune of one Martyr, of Virgins, of a
109
+ * Confessor Bishop. The Mass has always resolved this way (propers.ts:
110
+ * proper → seasonal → commune); the Office simply never had the table. So the
111
+ * commune is tried first, and the ferial cycle covers what remains — days with
112
+ * no saint at all, which is exactly what it was mined for.
113
+ *
114
+ * Monastic only (the ferial cycle we mined is the monastic psalter), and only
115
+ * for a feast carrying a real weekday — the all-days survey path builds a
116
+ * mockFeast with no weekday, where a weekday-keyed lookup would be meaningless.
117
+ * The commune lookup has no such constraint: it is keyed by feast, not weekday.
118
+ */
119
+ function antiphonsFor(proper, hour, feast) {
120
+ // Copy: the OfficeDay arrays are readonly and may hold nulls; resolveChant
121
+ // drops the nulls, resolveChants wants a mutable string[].
122
+ const own = resolveChants([...(proper ?? [])].filter((id) => !!id));
123
+ // All-or-nothing per hour: a feast with ANY proper antiphon for this hour
124
+ // sings only those. Topping a short proper set up from the commune would mix
125
+ // two feasts' chants inside one hour, which no rubric asks for.
126
+ if (own.length)
127
+ return own;
128
+ const fromCommune = communeAntiphons(feast, hour);
129
+ if (fromCommune.length)
130
+ return fromCommune;
131
+ // The ferial cycle used to be gated on rite === "monasticum". With the Roman
132
+ // office gone that gate only ever suppressed the fallback on the DEFAULT
133
+ // call, so it is dropped.
134
+ if (feast.weekday == null || !feast.date)
135
+ return own;
136
+ const byWeekday = OFFICE_FERIAL[hour];
137
+ if (!byWeekday)
138
+ return own;
139
+ const slot = byWeekday[String(feast.weekday)] ?? byWeekday["any"];
140
+ if (!slot)
141
+ return own;
142
+ // Variant preference: the season's own set, else the plain ferial cycle.
143
+ const variant = ferialVariantFor(feast);
144
+ const ids = slot[variant] ?? slot["ferial"];
145
+ return ids ? resolveChants(ids) : own;
146
+ }
147
+ /**
148
+ * The antiphons this feast's COMMUNE appoints for an hour, or empty.
149
+ *
150
+ * FEAST_COMMUNE (mined from DO's `[Rule]`/`[Rank]` headers) says which category
151
+ * a feast belongs to; COMMUNE_OFFICE says what that category sings. A commune
152
+ * is a category of saint, not a cursus, so the table binds to no rite.
153
+ */
154
+ function communeSlot(feast, hour, slot) {
155
+ const commune = communeByFeast().get(feast.id);
156
+ if (!commune)
157
+ return [];
158
+ const ids = COMMUNE_OFFICE[commune]?.[hour]?.[slot];
159
+ return ids?.length ? resolveChants(ids) : [];
160
+ }
161
+ const communeAntiphons = (feast, hour) => communeSlot(feast, hour, "antiphons");
162
+ /**
163
+ * The little hours' short responsory as the SEASON appoints it — the last
164
+ * fallback, after the day's own proper and its commune.
165
+ *
166
+ * This chant is seasonal, not proper: DO carries none at all in its monastic
167
+ * dirs (0 of 278 SanctiM, 0 of 267 TemporaM) and keeps the real cycle in
168
+ * Psalterium/Special keyed by tempus. Only a few dozen feasts important enough
169
+ * to override have one of their own, which is why a feast-driven lookup filled
170
+ * so few days — it was reading the exception and missing the rule.
171
+ *
172
+ * Outside the four proper seasons the responsory is the per-diem default, and
173
+ * Sunday takes its own: `dominica` on a Sunday, `feria` on every other day.
174
+ */
175
+ function seasonalRespBreve(feast, hour) {
176
+ const byHour =
177
+ // DO's `Quad5` is PASSIONTIDE — the last fortnight of Lent — and tonus has
178
+ // no season code for it (`quadp` is Septuagesima, a different thing that
179
+ // falls BEFORE Lent). Rather than mis-map one to the other, Passiontide is
180
+ // left to resolve as ordinary Lent until the calendar models it.
181
+ SEASONAL_RESPBREVE[feast.season] ??
182
+ SEASONAL_RESPBREVE[feast.weekday === 0 ? "dominica" : "feria"];
183
+ const id = byHour?.[hour];
184
+ const chant = id ? resolveChant(id) : null;
185
+ return chant ? [chant] : [];
186
+ }
187
+ /**
188
+ * A little hour's psalmody, from the extracted DO scheme. The Benedictine
189
+ * distribution varies by weekday: Sunday and Monday walk portions of Ps 118
190
+ * (Terce Sunday vv. 33–56, Monday 105–128, and so on through the hours);
191
+ * Tuesday through Saturday sing the gradual psalms (Terce 119–121, Sext
192
+ * 122–124, None 125–127). The psalmody belongs to a specific day, so it is
193
+ * only included for a real feast query — not the all-days survey scan, which
194
+ * has no date and would repeat the psalms once per feast.
195
+ */
196
+ function littleHourPsalmody(feast, hour) {
197
+ if (!feast.date)
198
+ return [];
199
+ const hourName = hour === "tertia" ? "Tertia" : hour === "sexta" ? "Sexta" : "Nona";
200
+ const out = [];
201
+ for (const p of officePsalmPortions(hourName, feast.weekday)) {
202
+ out.push(...intonePortion(p));
203
+ }
204
+ return out;
205
+ }
206
+ let _communeByFeast = null;
207
+ function communeByFeast() {
208
+ if (!_communeByFeast) {
209
+ _communeByFeast = new Map(FEAST_COMMUNE.map((f) => [f.feastId, f.commune]));
210
+ }
211
+ return _communeByFeast;
212
+ }
213
+ /** Which ferial variant a day draws: the season's, else the plain cycle. */
214
+ function ferialVariantFor(feast) {
215
+ if (feast.season === "adv")
216
+ return "advent";
217
+ if (feast.season === "pasc")
218
+ return "paschal";
219
+ if (feast.season === "nat")
220
+ return "nat";
221
+ return "ferial";
222
+ }
223
+ function chantsForFeastHour(feast, hour) {
89
224
  if (hour === "completorium")
90
- return complineForFeast(feast, rite);
225
+ return complineForFeast(feast);
91
226
  if (hour === "prima")
92
- return primeForFeast(feast, rite);
93
- const map = officeMap(rite);
227
+ return primeForFeast(feast);
228
+ const map = officeMap();
94
229
  const sunday = temporaSundayId(feast.id);
95
230
  const day = map.get(feast.id) ?? (sunday ? (map.get(sunday) ?? null) : null);
96
- if (!day)
97
- return [];
231
+ if (!day) {
232
+ // No proper row at all — most sanctoral days and many ferias have none.
233
+ // That is not silence: a monastery still sings the ferial cycle. Return it
234
+ // rather than an empty ordo (this is the other half of the antiphonsFor
235
+ // fallback, which only fires when a row EXISTS but its arrays are empty).
236
+ // The little hours have no antiphon of their own — their proper chant IS the
237
+ // short responsory — so they take the commune's respBreve. The psalmody is
238
+ // still theirs either way: returning ONLY the responsory would silence a day
239
+ // the commune cannot fill, which is worse than what it replaced.
240
+ if (hour === "tertia" || hour === "sexta" || hour === "nona") {
241
+ const fromCommune = communeSlot(feast, hour, "respBreve");
242
+ return [
243
+ ...littleHourPsalmody(feast, hour),
244
+ ...(fromCommune.length ? fromCommune : seasonalRespBreve(feast, hour)),
245
+ ];
246
+ }
247
+ // The commune fills EVERY slot type it ships, not just the antiphons: a
248
+ // saint served by commune sings the commune's invitatory, hymn and
249
+ // responsories too — the table mined them (512 texts across 24 communes)
250
+ // and returning a truncated hour left them silent on the shelf. Slot
251
+ // order mirrors the with-row assembly below.
252
+ if (hour === "matutinum") {
253
+ return [
254
+ ...communeSlot(feast, hour, "invitatorium"),
255
+ ...antiphonsFor(null, hour, feast),
256
+ ...communeSlot(feast, hour, "hymnus"),
257
+ ...communeSlot(feast, hour, "responsories"),
258
+ ];
259
+ }
260
+ if (hour === "laudes" || hour === "vesperae") {
261
+ return [
262
+ ...antiphonsFor(null, hour, feast),
263
+ ...communeSlot(feast, hour, "hymnus"),
264
+ ];
265
+ }
266
+ return antiphonsFor(null, hour, feast);
267
+ }
98
268
  const results = [];
99
269
  if (hour === "matutinum") {
270
+ // Each slot falls to the commune INDEPENDENTLY (own else commune, same
271
+ // all-or-nothing-per-slot rule the antiphons and respBreve always had) —
272
+ // a row with proper responsories but no invitatory borrows only the
273
+ // invitatory.
100
274
  const inv = resolveChant(day.invit);
101
275
  if (inv)
102
276
  results.push(inv);
103
- results.push(...resolveChants(day.antMatutinum));
277
+ else
278
+ results.push(...communeSlot(feast, hour, "invitatorium"));
279
+ results.push(...antiphonsFor(day.antMatutinum, hour, feast));
104
280
  const hy = resolveChant(day.hymnMatutinum);
105
281
  if (hy)
106
282
  results.push(hy);
107
- results.push(...resolveChants(day.respMatutinum));
283
+ else
284
+ results.push(...communeSlot(feast, hour, "hymnus"));
285
+ const ownResp = resolveChants([...day.respMatutinum].filter((id) => !!id));
286
+ if (ownResp.length)
287
+ results.push(...ownResp);
288
+ else
289
+ results.push(...communeSlot(feast, hour, "responsories"));
108
290
  }
109
291
  else if (hour === "laudes") {
110
- results.push(...resolveChants(day.antLaudes));
292
+ results.push(...antiphonsFor(day.antLaudes, hour, feast));
111
293
  const bc = resolveChant(day.antBenedictus);
112
294
  if (bc)
113
295
  results.push(bc);
114
296
  const hy = resolveChant(day.hymnLaudes);
115
297
  if (hy)
116
298
  results.push(hy);
299
+ else
300
+ results.push(...communeSlot(feast, hour, "hymnus"));
117
301
  }
118
302
  else if (hour === "tertia" || hour === "sexta" || hour === "nona") {
119
- // The little hours: their portion of Ps 118 (Terce vv. 33–80, Sext 81–128,
120
- // None 129–176, from the extracted DO scheme), then the responsory breve.
121
- // The psalmody belongs to a specific day, so it is only included for a real
122
- // feast query — not the all-days survey scan (which has no date and would
123
- // repeat the psalms once per feast).
124
- if (feast.date) {
125
- const hourName = hour === "tertia" ? "Tertia" : hour === "sexta" ? "Sexta" : "Nona";
126
- for (const p of officePsalmPortions(hourName, feast.weekday, rite)) {
127
- results.push(...intonePortion(p));
128
- }
129
- }
303
+ // The little hours: the day's psalmody (Ps 118 portions on Sunday and
304
+ // Monday, the gradual psalms the rest of the week — see littleHourPsalmody),
305
+ // then the responsory breve. The psalmody belongs to a specific day, so it
306
+ // is only included for a real feast query — not the all-days survey scan
307
+ // (which has no date and would repeat the psalms once per feast).
308
+ results.push(...littleHourPsalmody(feast, hour));
309
+ // The short responsory, with the same commune fallback the antiphons get.
310
+ // office-monastic fills respBreve on only 84–96 of its 409 rows, which is
311
+ // why Terce/Sext/None sang on 184–196 of 366 days: unlike the antiphons this
312
+ // was a bare lookup with nothing behind it.
130
313
  const rb = resolveChant(hour === "tertia" ? day.respBreveTertia
131
314
  : hour === "sexta" ? day.respBreveSexta
132
315
  : day.respBreveNona);
133
316
  if (rb)
134
317
  results.push(rb);
318
+ else {
319
+ const fromCommune = communeSlot(feast, hour, "respBreve");
320
+ results.push(...(fromCommune.length ? fromCommune : seasonalRespBreve(feast, hour)));
321
+ }
135
322
  }
136
323
  else if (hour === "vesperae") {
137
- results.push(...resolveChants(day.antVespera));
324
+ results.push(...antiphonsFor(day.antVespera, hour, feast));
138
325
  const mc = resolveChant(day.antMagnificat);
139
326
  if (mc)
140
327
  results.push(mc);
141
328
  const hy = resolveChant(day.hymnVespera);
142
329
  if (hy)
143
330
  results.push(hy);
331
+ else
332
+ results.push(...communeSlot(feast, hour, "hymnus"));
144
333
  }
145
334
  return results;
146
335
  }
@@ -149,6 +338,16 @@ function toArray(v) {
149
338
  return undefined;
150
339
  return Array.isArray(v) ? v : [v];
151
340
  }
341
+ /** The feast filter must carry Feast objects (from tonus.festum) — a raw
342
+ * TypeError deep in resolution would otherwise mask the caller bug. */
343
+ function assertFeasts(feasts, method) {
344
+ if (!feasts)
345
+ return;
346
+ for (const f of feasts) {
347
+ if (!f || typeof f !== "object" || typeof f.id !== "string")
348
+ throw new Error(`${method}: feast must be a Feast (from tonus.festum) — got ${typeof f}`);
349
+ }
350
+ }
152
351
  /**
153
352
  * Divine Office retrieval (`tonus.officium`) for a canonical hour
154
353
  * (matutinum … completorium). Without an hour, returns chants for all
@@ -157,44 +356,66 @@ function toArray(v) {
157
356
  export function getHour(query) {
158
357
  if (!query || Object.keys(query).length === 0)
159
358
  return [];
359
+ // officium once accepted any key unexamined, which is how the removed
360
+ // `rite` kept being accepted after it stopped meaning anything: a JS
361
+ // caller asking for rite: "romanum" got monastic chants and no warning. A
362
+ // silently-ignored option is worse than a missing one, because the caller
363
+ // believes they chose. Same guard cantus and proprium carry.
364
+ const unknown = Object.keys(query).filter((k) => !OFFICIUM_QUERY_KEYS.has(k));
365
+ if (unknown.length > 0) {
366
+ throw new Error(`officium: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
367
+ `(expected ${[...OFFICIUM_QUERY_KEYS].join(", ")}).`);
368
+ }
160
369
  const feasts = toArray(query.feast);
370
+ assertFeasts(feasts, "officium");
371
+ // A misspelled hour is a malformed query, not an empty one: without this it
372
+ // matched nothing and returned [], and the caller read that as "no chants at
373
+ // this hour" rather than "there is no such hour."
161
374
  const hour = query.hora;
162
- const rite = query.rite ?? "romanum";
375
+ if (hour != null && !HORAE.includes(hour)) {
376
+ throw new Error(`officium: unknown hora "${hour}" (expected ${HORAE.join(", ")}).`);
377
+ }
163
378
  let results;
164
379
  if (feasts && hour) {
165
380
  // Prime and Compline are seasonal/weekday ordos, identical for every feast
166
381
  // of the day — so concurrent feasts collapse to a single ordo rather than
167
382
  // repeating it. The other hours are genuinely per-feast.
168
383
  results = SEASONAL_ORDO_HOURS.has(hour)
169
- ? feasts[0] ? chantsForFeastHour(feasts[0], hour, rite) : []
170
- : feasts.flatMap((f) => chantsForFeastHour(f, hour, rite));
384
+ ? feasts[0] ? chantsForFeastHour(feasts[0], hour) : []
385
+ : feasts.flatMap((f) => chantsForFeastHour(f, hour));
171
386
  }
172
387
  else if (feasts) {
173
- const hours = [
174
- "matutinum", "laudes", "prima", "tertia", "sexta", "nona",
175
- "vesperae", "completorium",
176
- ];
177
- results = feasts.flatMap((f) => hours.flatMap((h) => chantsForFeastHour(f, h, rite)));
388
+ results = feasts.flatMap((f) => HORAE.flatMap((h) => chantsForFeastHour(f, h)));
178
389
  }
179
390
  else if (hour && SEASONAL_ORDO_HOURS.has(hour)) {
180
391
  // Prime and Compline are seasonal ordos, not per-feast. With no feast,
181
392
  // resolve for the default epoch (Guido d'Arezzo's era) — festum()'s anchor.
182
393
  const [feast] = getFeast();
183
- results = feast ? chantsForFeastHour(feast, hour, rite) : [];
394
+ results = feast ? chantsForFeastHour(feast, hour) : [];
184
395
  }
185
396
  else if (hour) {
186
- // Hour without feast — survey per-feast content across the office entries of
187
- // the chosen rite. mockFeast has no date, so the little hours return only
188
- // their responsories.
189
- const table = rite === "monasticum" ? OFFICE_MONASTIC : OFFICE_ROMAN;
190
- results = table.flatMap((day) => {
397
+ // Hour without feast — survey per-feast content across the office entries.
398
+ // mockFeast has no date, so the little hours return only their responsories.
399
+ results = OFFICE_MONASTIC.flatMap((day) => {
191
400
  const mockFeast = { id: day.feastId };
192
- return chantsForFeastHour(mockFeast, hour, rite);
401
+ return chantsForFeastHour(mockFeast, hour);
193
402
  });
194
403
  }
195
404
  else {
196
405
  return [];
197
406
  }
407
+ // The era view: an own `before` wins; otherwise the view festum({ before })
408
+ // stamped on the feast rides along. Excluded chants degrade to SILENCE here
409
+ // by design — the office's proper → commune → ferial chain triggers on
410
+ // ABSENCE from the tables, not on inadmissibility, so no re-pick is
411
+ // attempted. (Extending the chain to re-pick under a view is deliberate
412
+ // future work, not an accident.)
413
+ {
414
+ const cutoff = eraCutoff(query, feasts, "officium");
415
+ if (cutoff != null || query.cursus) {
416
+ results = results.filter((c) => chantAdmissible(c.id, cutoff, query.cursus));
417
+ }
418
+ }
198
419
  // Apply CantusQuery filters
199
420
  const offices = toArray(query.office);
200
421
  if (offices) {
@@ -1,4 +1,6 @@
1
+ import { type KyrialeEntry } from "../../data/kyriale.js";
1
2
  import { type OrdinaryChant, type OrdinariumQuery } from "./types.js";
3
+ export declare function entryToOrdinaryChant(entry: KyrialeEntry): OrdinaryChant;
2
4
  /**
3
5
  * Mass ordinary retrieval (`tonus.ordinarium`) from the Kyriale. A feast
4
6
  * drives mass selection; `mass` pins a kyriale number directly.