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.
- package/BIBLIOGRAPHY.md +132 -108
- package/CHANGELOG.md +598 -1
- package/LICENSE +133 -29
- package/README.md +106 -83
- package/dist/data/am.js +2666 -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 +17 -0
- package/dist/data/corpus-overlap.js +299 -5
- 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 +754 -6394
- package/dist/data/kyriale.js +116 -116
- package/dist/data/la.js +799 -13419
- package/dist/data/lh.js +113 -3473
- package/dist/data/lu.js +931 -17631
- package/dist/data/nocturnale-romanum.js +1659 -10411
- 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 +16 -4
- package/dist/engines/chant/chant.js +220 -30
- 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 +115 -12
- 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 +26 -8
- package/dist/index.js +37 -4
- 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-matins-roman.d.ts +0 -19
- package/dist/data/office-matins-roman.js +0 -4383
- 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/chant/matutinum.d.ts +0 -33
- package/dist/engines/chant/matutinum.js +0 -81
- 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,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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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 ? (
|
|
290
|
+
modus: entry.mode ? (MODI[String(entry.mode)] ?? null) : null,
|
|
91
291
|
pages: [],
|
|
92
|
-
source:
|
|
292
|
+
source: KY_SOURCE,
|
|
93
293
|
ordinary,
|
|
94
|
-
ordinarium:
|
|
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,
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
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 =
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
209
|
-
entries = entries.filter((e) => e.mode
|
|
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
|
|
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-
|
|
2
|
-
import { type Chant, type PsalmusQuery
|
|
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
|
|
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
|
|
23
|
+
export declare function officePsalmPortions(hour: OfficePsalmEntry["hour"], weekday: number): OfficePsalmPortion[];
|
|
24
24
|
//# sourceMappingURL=psalm.d.ts.map
|