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