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.
- package/BIBLIOGRAPHY.md +132 -108
- package/CHANGELOG.md +610 -1
- package/LICENSE +133 -29
- package/README.md +106 -83
- package/dist/data/am.js +2669 -11196
- package/dist/data/ams.d.ts +5 -0
- package/dist/data/ams.js +122 -0
- package/dist/data/attestation.d.ts +19 -0
- package/dist/data/attestation.js +15716 -0
- package/dist/data/attestation.json +15711 -0
- package/dist/data/cadentiae.d.ts +43 -0
- package/dist/data/cadentiae.js +174 -0
- package/dist/data/cal.js +48 -0
- package/dist/data/census.d.ts +12 -0
- package/dist/data/census.js +36 -0
- package/dist/data/commune-office.d.ts +4 -0
- package/dist/data/commune-office.js +2371 -0
- package/dist/data/commune-office.json +2365 -0
- package/dist/data/commune.js +181 -11
- package/dist/data/corpus-overlap.d.ts +24 -0
- package/dist/data/corpus-overlap.js +347 -0
- package/dist/data/cot.d.ts +5 -0
- package/dist/data/cot.js +172 -0
- package/dist/data/cse.d.ts +5 -0
- package/dist/data/cse.js +122 -0
- package/dist/data/gabc-glyphs.d.ts +45 -0
- package/dist/data/gabc-glyphs.js +122 -0
- package/dist/data/gr.js +757 -6394
- package/dist/data/kyriale.js +116 -116
- package/dist/data/la.js +802 -13419
- package/dist/data/lh.js +116 -3473
- package/dist/data/lu.js +935 -17632
- package/dist/data/nocturnale-romanum.d.ts +5 -0
- package/dist/data/nocturnale-romanum.js +3772 -0
- package/dist/data/office-ferial.d.ts +4 -0
- package/dist/data/office-ferial.js +396 -0
- package/dist/data/office-ferial.json +391 -0
- package/dist/data/office-monastic.d.ts +17 -1
- package/dist/data/office-monastic.js +1403 -466
- package/dist/data/office-psalms-monastic.d.ts +13 -1
- package/dist/data/office-psalms-monastic.js +9 -0
- package/dist/data/propers.js +1 -1
- package/dist/data/psalms.js +22919 -5
- package/dist/data/psm.d.ts +5 -0
- package/dist/data/psm.js +122 -0
- package/dist/data/seasonal-respbreve.d.ts +5 -0
- package/dist/data/seasonal-respbreve.js +41 -0
- package/dist/data/seasonal-respbreve.json +35 -0
- package/dist/data/smufl-glyphs.d.ts +17 -0
- package/dist/data/smufl-glyphs.js +1546 -0
- package/dist/data/smufl-glyphs.json +1530 -0
- package/dist/engines/cal/calendar.d.ts +3 -2
- package/dist/engines/cal/calendar.js +105 -29
- package/dist/engines/cal/data/eras.d.ts +35 -0
- package/dist/engines/cal/data/eras.js +128 -0
- package/dist/engines/cal/date.js +44 -0
- package/dist/engines/cal/types.d.ts +15 -3
- package/dist/engines/cal/types.js +5 -5
- package/dist/engines/census/census.d.ts +7 -0
- package/dist/engines/census/census.js +179 -0
- package/dist/engines/census/types.d.ts +55 -0
- package/dist/engines/census/types.js +8 -0
- package/dist/engines/chant/attest.d.ts +39 -0
- package/dist/engines/chant/attest.js +90 -0
- package/dist/engines/chant/chant.d.ts +22 -4
- package/dist/engines/chant/chant.js +274 -14
- package/dist/engines/chant/data/compline.js +2 -1
- package/dist/engines/chant/data/masses.d.ts +56 -4
- package/dist/engines/chant/data/masses.js +305 -80
- package/dist/engines/chant/data/prime.js +1 -1
- package/dist/engines/chant/hour.js +279 -58
- package/dist/engines/chant/ordinary.d.ts +2 -0
- package/dist/engines/chant/ordinary.js +336 -56
- package/dist/engines/chant/propers.js +55 -5
- package/dist/engines/chant/psalm.d.ts +4 -4
- package/dist/engines/chant/psalm.js +25 -11
- package/dist/engines/chant/syllabify.d.ts +1 -0
- package/dist/engines/chant/syllabify.js +90 -17
- package/dist/engines/chant/types.d.ts +145 -10
- package/dist/engines/chant/types.js +38 -3
- package/dist/engines/harmonia/api.js +4 -0
- package/dist/engines/harmonia/data/doctrines.js +3 -1
- package/dist/engines/harmonia/tabula.d.ts +3 -0
- package/dist/engines/harmonia/tabula.js +1 -0
- package/dist/engines/harmonia/voice.d.ts +4 -0
- package/dist/engines/harmonia/voice.js +8 -4
- package/dist/engines/imprint.js +14 -1
- package/dist/engines/planet/orbital.js +4 -4
- package/dist/engines/planet/planet.d.ts +10 -0
- package/dist/engines/planet/planet.js +30 -3
- package/dist/engines/planet/position.js +13 -10
- package/dist/engines/planet/types.d.ts +1 -0
- package/dist/engines/score/api.d.ts +2 -13
- package/dist/engines/score/api.js +21 -8
- package/dist/engines/score/articulation.js +2 -2
- package/dist/engines/score/cadence.d.ts +76 -0
- package/dist/engines/score/cadence.js +96 -0
- package/dist/engines/score/emitters/accidentals.d.ts +21 -0
- package/dist/engines/score/emitters/accidentals.js +88 -0
- package/dist/engines/score/emitters/atramentum.d.ts +107 -0
- package/dist/engines/score/emitters/atramentum.js +239 -0
- package/dist/engines/score/emitters/breaking.d.ts +62 -0
- package/dist/engines/score/emitters/breaking.js +80 -0
- package/dist/engines/score/emitters/moderna.d.ts +38 -0
- package/dist/engines/score/emitters/moderna.js +612 -0
- package/dist/engines/score/emitters/svg.d.ts +143 -0
- package/dist/engines/score/emitters/svg.js +1328 -0
- package/dist/engines/score/emitters/tracks.d.ts +104 -0
- package/dist/engines/score/emitters/tracks.js +728 -0
- package/dist/engines/score/infer.d.ts +3 -3
- package/dist/engines/score/infer.js +2 -2
- package/dist/engines/score/inscriptio.d.ts +69 -0
- package/dist/engines/score/inscriptio.js +138 -0
- package/dist/engines/score/ir.d.ts +2 -2
- package/dist/engines/score/ir.js +59 -12
- package/dist/engines/score/lyric.d.ts +23 -0
- package/dist/engines/score/lyric.js +234 -0
- package/dist/engines/score/meta.d.ts +2 -2
- package/dist/engines/score/modulation.d.ts +12 -0
- package/dist/engines/score/modulation.js +49 -0
- package/dist/engines/score/neume.js +35 -4
- package/dist/engines/score/parse.js +150 -9
- package/dist/engines/score/phrasing.js +4 -3
- package/dist/engines/score/prosody.d.ts +38 -0
- package/dist/engines/score/prosody.js +70 -6
- package/dist/engines/score/tabula.d.ts +37 -5
- package/dist/engines/score/tabula.js +18 -0
- package/dist/engines/score/types.d.ts +88 -1
- package/dist/engines/temper/api.d.ts +4 -1
- package/dist/engines/temper/api.js +28 -5
- package/dist/engines/temper/data/guido.js +6 -2
- package/dist/engines/temper/data/modes.d.ts +6 -0
- package/dist/engines/temper/data/modes.js +42 -0
- package/dist/engines/temper/data/tones.d.ts +1 -1
- package/dist/engines/temper/data/tones.js +20 -11
- package/dist/engines/temper/interval.js +4 -3
- package/dist/engines/temper/modality.d.ts +11 -2
- package/dist/engines/temper/modality.js +74 -2
- package/dist/engines/temper/modes.d.ts +1 -1
- package/dist/engines/temper/pitch.d.ts +1 -1
- package/dist/engines/temper/pitch.js +12 -2
- package/dist/engines/temper/scale.d.ts +53 -0
- package/dist/engines/temper/scale.js +107 -8
- package/dist/index.d.ts +28 -6
- package/dist/index.js +39 -3
- package/docs/api/calendar.md +279 -0
- package/docs/api/census.md +288 -0
- package/docs/api/chant.md +657 -0
- package/docs/api/heavens.md +346 -0
- package/docs/api/index.md +263 -0
- package/docs/api/score.md +873 -0
- package/docs/api/tuning.md +619 -0
- package/package.json +11 -5
- package/dist/data/office-psalms-roman.d.ts +0 -15
- package/dist/data/office-psalms-roman.js +0 -28
- package/dist/data/office-roman.d.ts +0 -19
- package/dist/data/office-roman.js +0 -13792
- package/dist/engines/score/emitters/midi.d.ts +0 -65
- package/dist/engines/score/emitters/midi.js +0 -162
- package/dist/engines/score/emitters/musicxml.d.ts +0 -18
- 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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
47
|
-
|
|
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
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
225
|
+
return complineForFeast(feast);
|
|
91
226
|
if (hour === "prima")
|
|
92
|
-
return primeForFeast(feast
|
|
93
|
-
const map = officeMap(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(...
|
|
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:
|
|
120
|
-
//
|
|
121
|
-
// The psalmody belongs to a specific day, so it
|
|
122
|
-
// feast query — not the all-days survey scan
|
|
123
|
-
// repeat the psalms once per feast).
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(...
|
|
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
|
-
|
|
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
|
|
170
|
-
: feasts.flatMap((f) => chantsForFeastHour(f, hour
|
|
384
|
+
? feasts[0] ? chantsForFeastHour(feasts[0], hour) : []
|
|
385
|
+
: feasts.flatMap((f) => chantsForFeastHour(f, hour));
|
|
171
386
|
}
|
|
172
387
|
else if (feasts) {
|
|
173
|
-
|
|
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
|
|
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
|
|
187
|
-
//
|
|
188
|
-
|
|
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
|
|
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.
|