tonus 0.1.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +610 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2669 -11196
  6. package/dist/data/ams.d.ts +5 -0
  7. package/dist/data/ams.js +122 -0
  8. package/dist/data/attestation.d.ts +19 -0
  9. package/dist/data/attestation.js +15716 -0
  10. package/dist/data/attestation.json +15711 -0
  11. package/dist/data/cadentiae.d.ts +43 -0
  12. package/dist/data/cadentiae.js +174 -0
  13. package/dist/data/cal.js +48 -0
  14. package/dist/data/census.d.ts +12 -0
  15. package/dist/data/census.js +36 -0
  16. package/dist/data/commune-office.d.ts +4 -0
  17. package/dist/data/commune-office.js +2371 -0
  18. package/dist/data/commune-office.json +2365 -0
  19. package/dist/data/commune.js +181 -11
  20. package/dist/data/corpus-overlap.d.ts +24 -0
  21. package/dist/data/corpus-overlap.js +347 -0
  22. package/dist/data/cot.d.ts +5 -0
  23. package/dist/data/cot.js +172 -0
  24. package/dist/data/cse.d.ts +5 -0
  25. package/dist/data/cse.js +122 -0
  26. package/dist/data/gabc-glyphs.d.ts +45 -0
  27. package/dist/data/gabc-glyphs.js +122 -0
  28. package/dist/data/gr.js +757 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +802 -13419
  31. package/dist/data/lh.js +116 -3473
  32. package/dist/data/lu.js +935 -17632
  33. package/dist/data/nocturnale-romanum.d.ts +5 -0
  34. package/dist/data/nocturnale-romanum.js +3772 -0
  35. package/dist/data/office-ferial.d.ts +4 -0
  36. package/dist/data/office-ferial.js +396 -0
  37. package/dist/data/office-ferial.json +391 -0
  38. package/dist/data/office-monastic.d.ts +17 -1
  39. package/dist/data/office-monastic.js +1403 -466
  40. package/dist/data/office-psalms-monastic.d.ts +13 -1
  41. package/dist/data/office-psalms-monastic.js +9 -0
  42. package/dist/data/propers.js +1 -1
  43. package/dist/data/psalms.js +22919 -5
  44. package/dist/data/psm.d.ts +5 -0
  45. package/dist/data/psm.js +122 -0
  46. package/dist/data/seasonal-respbreve.d.ts +5 -0
  47. package/dist/data/seasonal-respbreve.js +41 -0
  48. package/dist/data/seasonal-respbreve.json +35 -0
  49. package/dist/data/smufl-glyphs.d.ts +17 -0
  50. package/dist/data/smufl-glyphs.js +1546 -0
  51. package/dist/data/smufl-glyphs.json +1530 -0
  52. package/dist/engines/cal/calendar.d.ts +3 -2
  53. package/dist/engines/cal/calendar.js +105 -29
  54. package/dist/engines/cal/data/eras.d.ts +35 -0
  55. package/dist/engines/cal/data/eras.js +128 -0
  56. package/dist/engines/cal/date.js +44 -0
  57. package/dist/engines/cal/types.d.ts +15 -3
  58. package/dist/engines/cal/types.js +5 -5
  59. package/dist/engines/census/census.d.ts +7 -0
  60. package/dist/engines/census/census.js +179 -0
  61. package/dist/engines/census/types.d.ts +55 -0
  62. package/dist/engines/census/types.js +8 -0
  63. package/dist/engines/chant/attest.d.ts +39 -0
  64. package/dist/engines/chant/attest.js +90 -0
  65. package/dist/engines/chant/chant.d.ts +22 -4
  66. package/dist/engines/chant/chant.js +274 -14
  67. package/dist/engines/chant/data/compline.js +2 -1
  68. package/dist/engines/chant/data/masses.d.ts +56 -4
  69. package/dist/engines/chant/data/masses.js +305 -80
  70. package/dist/engines/chant/data/prime.js +1 -1
  71. package/dist/engines/chant/hour.js +279 -58
  72. package/dist/engines/chant/ordinary.d.ts +2 -0
  73. package/dist/engines/chant/ordinary.js +336 -56
  74. package/dist/engines/chant/propers.js +55 -5
  75. package/dist/engines/chant/psalm.d.ts +4 -4
  76. package/dist/engines/chant/psalm.js +25 -11
  77. package/dist/engines/chant/syllabify.d.ts +1 -0
  78. package/dist/engines/chant/syllabify.js +90 -17
  79. package/dist/engines/chant/types.d.ts +145 -10
  80. package/dist/engines/chant/types.js +38 -3
  81. package/dist/engines/harmonia/api.js +4 -0
  82. package/dist/engines/harmonia/data/doctrines.js +3 -1
  83. package/dist/engines/harmonia/tabula.d.ts +3 -0
  84. package/dist/engines/harmonia/tabula.js +1 -0
  85. package/dist/engines/harmonia/voice.d.ts +4 -0
  86. package/dist/engines/harmonia/voice.js +8 -4
  87. package/dist/engines/imprint.js +14 -1
  88. package/dist/engines/planet/orbital.js +4 -4
  89. package/dist/engines/planet/planet.d.ts +10 -0
  90. package/dist/engines/planet/planet.js +30 -3
  91. package/dist/engines/planet/position.js +13 -10
  92. package/dist/engines/planet/types.d.ts +1 -0
  93. package/dist/engines/score/api.d.ts +2 -13
  94. package/dist/engines/score/api.js +21 -8
  95. package/dist/engines/score/articulation.js +2 -2
  96. package/dist/engines/score/cadence.d.ts +76 -0
  97. package/dist/engines/score/cadence.js +96 -0
  98. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  99. package/dist/engines/score/emitters/accidentals.js +88 -0
  100. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  101. package/dist/engines/score/emitters/atramentum.js +239 -0
  102. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  103. package/dist/engines/score/emitters/breaking.js +80 -0
  104. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  105. package/dist/engines/score/emitters/moderna.js +612 -0
  106. package/dist/engines/score/emitters/svg.d.ts +143 -0
  107. package/dist/engines/score/emitters/svg.js +1328 -0
  108. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  109. package/dist/engines/score/emitters/tracks.js +728 -0
  110. package/dist/engines/score/infer.d.ts +3 -3
  111. package/dist/engines/score/infer.js +2 -2
  112. package/dist/engines/score/inscriptio.d.ts +69 -0
  113. package/dist/engines/score/inscriptio.js +138 -0
  114. package/dist/engines/score/ir.d.ts +2 -2
  115. package/dist/engines/score/ir.js +59 -12
  116. package/dist/engines/score/lyric.d.ts +23 -0
  117. package/dist/engines/score/lyric.js +234 -0
  118. package/dist/engines/score/meta.d.ts +2 -2
  119. package/dist/engines/score/modulation.d.ts +12 -0
  120. package/dist/engines/score/modulation.js +49 -0
  121. package/dist/engines/score/neume.js +35 -4
  122. package/dist/engines/score/parse.js +150 -9
  123. package/dist/engines/score/phrasing.js +4 -3
  124. package/dist/engines/score/prosody.d.ts +38 -0
  125. package/dist/engines/score/prosody.js +70 -6
  126. package/dist/engines/score/tabula.d.ts +37 -5
  127. package/dist/engines/score/tabula.js +18 -0
  128. package/dist/engines/score/types.d.ts +88 -1
  129. package/dist/engines/temper/api.d.ts +4 -1
  130. package/dist/engines/temper/api.js +28 -5
  131. package/dist/engines/temper/data/guido.js +6 -2
  132. package/dist/engines/temper/data/modes.d.ts +6 -0
  133. package/dist/engines/temper/data/modes.js +42 -0
  134. package/dist/engines/temper/data/tones.d.ts +1 -1
  135. package/dist/engines/temper/data/tones.js +20 -11
  136. package/dist/engines/temper/interval.js +4 -3
  137. package/dist/engines/temper/modality.d.ts +11 -2
  138. package/dist/engines/temper/modality.js +74 -2
  139. package/dist/engines/temper/modes.d.ts +1 -1
  140. package/dist/engines/temper/pitch.d.ts +1 -1
  141. package/dist/engines/temper/pitch.js +12 -2
  142. package/dist/engines/temper/scale.d.ts +53 -0
  143. package/dist/engines/temper/scale.js +107 -8
  144. package/dist/index.d.ts +28 -6
  145. package/dist/index.js +39 -3
  146. package/docs/api/calendar.md +279 -0
  147. package/docs/api/census.md +288 -0
  148. package/docs/api/chant.md +657 -0
  149. package/docs/api/heavens.md +346 -0
  150. package/docs/api/index.md +263 -0
  151. package/docs/api/score.md +873 -0
  152. package/docs/api/tuning.md +619 -0
  153. package/package.json +11 -5
  154. package/dist/data/office-psalms-roman.d.ts +0 -15
  155. package/dist/data/office-psalms-roman.js +0 -28
  156. package/dist/data/office-roman.d.ts +0 -19
  157. package/dist/data/office-roman.js +0 -13792
  158. package/dist/engines/score/emitters/midi.d.ts +0 -65
  159. package/dist/engines/score/emitters/midi.js +0 -162
  160. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  161. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -0,0 +1,179 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/census — where a chant sits against the corpus that holds it
3
+ // ---------------------------------------------------------------------------
4
+ // The corpus repo censuses every shipped chant into a block of 221 float32s:
5
+ // modal behaviour, degree histogram, interval bigrams, trigram and cadence
6
+ // vocabulary, chironomy, text setting, formula hits. This engine reads those
7
+ // blocks and answers one question — how typical is this chant, and what is it
8
+ // near?
9
+ //
10
+ // DISTANCE IS COSINE PER FIELD GROUP, never over the flat 221. Cosine
11
+ // on the whole vector is dominated by the 121-float melodic block and by sheer
12
+ // magnitude, so a long Tract would neighbor other long chants for being long.
13
+ // Per-group cosine asks about SHAPE within each dimension, and `all` is the
14
+ // equal-weight mean of those — every dimension one vote, no tunable weights.
15
+ import { CENSUS_BLOCKS_B64, CENSUS_BLOCK_FLOATS, CENSUS_GROUPS, CENSUS_ORDER, } from "../../data/census.js";
16
+ import { attestationCutoff, chantAdmissible } from "../chant/attest.js";
17
+ const GROUP_NAMES = Object.keys(CENSUS_GROUPS);
18
+ // ── The blocks, decoded once ────────────────────────────────────────────────
19
+ // Base64 in a .ts is how the blocks ride in the package (no fs, no fetch, works
20
+ // in a browser). Decoding is deferred so importing tonus does not pay for a
21
+ // census nobody asked for.
22
+ let _blocks = null;
23
+ function blocks() {
24
+ if (_blocks)
25
+ return _blocks;
26
+ const bin = typeof Buffer !== "undefined"
27
+ ? Buffer.from(CENSUS_BLOCKS_B64, "base64")
28
+ : Uint8Array.from(atob(CENSUS_BLOCKS_B64), (c) => c.charCodeAt(0));
29
+ // The bytes are float32 little-endian. A DataView read is used rather than a
30
+ // Float32Array view over the buffer because the base64 decode gives no
31
+ // alignment guarantee, and because a big-endian host would silently
32
+ // byte-swap an aligned view.
33
+ const view = new DataView(bin.buffer, bin.byteOffset, bin.byteLength);
34
+ const out = new Float32Array(bin.byteLength / 4);
35
+ for (let i = 0; i < out.length; i++)
36
+ out[i] = view.getFloat32(i * 4, true);
37
+ _blocks = out;
38
+ return out;
39
+ }
40
+ let _index = null;
41
+ function index() {
42
+ if (!_index)
43
+ _index = new Map(CENSUS_ORDER.map((id, i) => [id, i]));
44
+ return _index;
45
+ }
46
+ /** One group's slice of a chant's block. */
47
+ function slice(blockIndex, group) {
48
+ const { offset, count } = CENSUS_GROUPS[group];
49
+ const start = blockIndex * CENSUS_BLOCK_FLOATS + offset;
50
+ return blocks().subarray(start, start + count);
51
+ }
52
+ /**
53
+ * Cosine similarity, 0–1. Two all-zero vectors are IDENTICAL (1), not
54
+ * undefined: two chants that both sit silent in a dimension agree perfectly
55
+ * about it. One zero vector against a non-zero one shares nothing, so 0.
56
+ */
57
+ function cosine(a, b) {
58
+ let dot = 0;
59
+ let na = 0;
60
+ let nb = 0;
61
+ for (let i = 0; i < a.length; i++) {
62
+ const x = a[i];
63
+ const y = b[i];
64
+ dot += x * y;
65
+ na += x * x;
66
+ nb += y * y;
67
+ }
68
+ if (na === 0 && nb === 0)
69
+ return 1;
70
+ if (na === 0 || nb === 0)
71
+ return 0;
72
+ const c = dot / (Math.sqrt(na) * Math.sqrt(nb));
73
+ // Guard the float edge: a chant against itself must be exactly 1.
74
+ return c > 1 ? 1 : c < -1 ? -1 : c;
75
+ }
76
+ // ── The corpus mean, per group, computed once ───────────────────────────────
77
+ // Typicality is measured against the whole SHIPPED corpus, not against the
78
+ // filtered pool: `before` restricts who may be a neighbor, it does not
79
+ // move the mean.
80
+ let _means = null;
81
+ function means() {
82
+ if (_means)
83
+ return _means;
84
+ const out = {};
85
+ const n = CENSUS_ORDER.length;
86
+ for (const g of GROUP_NAMES) {
87
+ const { count } = CENSUS_GROUPS[g];
88
+ const acc = new Float32Array(count);
89
+ for (let i = 0; i < n; i++) {
90
+ const s = slice(i, g);
91
+ for (let k = 0; k < count; k++)
92
+ acc[k] += s[k];
93
+ }
94
+ for (let k = 0; k < count; k++)
95
+ acc[k] /= n;
96
+ out[g] = acc;
97
+ }
98
+ _means = out;
99
+ return out;
100
+ }
101
+ const VALID_KEYS = new Set(["id", "k", "by", "before"]);
102
+ /**
103
+ * The census of one chant: its profile against the corpus, where it is
104
+ * unusual, and what it is near.
105
+ */
106
+ export function getCensus(query) {
107
+ if (!query || typeof query !== "object") {
108
+ throw new Error('census requires a query: census({ id: "gregobase:1210" }).');
109
+ }
110
+ const unknown = Object.keys(query).filter((k) => !VALID_KEYS.has(k));
111
+ if (unknown.length) {
112
+ throw new Error(`census: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
113
+ `(expected ${[...VALID_KEYS].join(", ")}).`);
114
+ }
115
+ const { id } = query;
116
+ if (typeof id !== "string" || !id) {
117
+ throw new Error("census requires an id (a string), e.g. census({ id: \"gregobase:1210\" }).");
118
+ }
119
+ const self = index().get(id);
120
+ if (self == null) {
121
+ throw new Error(`census: no block for "${id}". The census covers the ${CENSUS_ORDER.length} chants ` +
122
+ "tonus ships; an id from a catalogue at large will not be among them.");
123
+ }
124
+ const by = query.by ?? "all";
125
+ if (by !== "all" && !GROUP_NAMES.includes(by)) {
126
+ throw new Error(`census: unknown field group "${by}" ` +
127
+ `(expected all, ${GROUP_NAMES.join(", ")}).`);
128
+ }
129
+ const k = query.k ?? 8;
130
+ if (!Number.isInteger(k) || k < 0) {
131
+ throw new Error(`census: k must be a non-negative integer, got ${String(query.k)}.`);
132
+ }
133
+ // The shared rule, not a local one — same door as cantus and the day verbs,
134
+ // so `before: 1098` means the same thing here as everywhere else.
135
+ const cutoff = attestationCutoff({ before: query.before }, "census");
136
+ // ── profile: each group against the corpus mean ──────────────────────────
137
+ const mean = means();
138
+ const profile = {};
139
+ for (const g of GROUP_NAMES) {
140
+ const s = slice(self, g);
141
+ profile[g] = {
142
+ values: Array.from(s),
143
+ typicality: cosine(s, mean[g]),
144
+ };
145
+ }
146
+ // ── balance: how far from the corpus mean, and where ─────────────────────
147
+ const typicalities = GROUP_NAMES.map((g) => profile[g].typicality);
148
+ const meanTypicality = typicalities.reduce((a, b) => a + b, 0) / typicalities.length;
149
+ const distance = 1 - meanTypicality;
150
+ // "Deviant" is relative to this chant's OWN mean, not an absolute threshold:
151
+ // the question is where a chant is unlike itself elsewhere, which is what
152
+ // makes it legible. Ties → alphabetical, so the answer is deterministic.
153
+ const deviantGroups = GROUP_NAMES.filter((g) => profile[g].typicality < meanTypicality).sort((a, b) => profile[a].typicality - profile[b].typicality ||
154
+ (a < b ? -1 : a > b ? 1 : 0));
155
+ // ── neighbors ────────────────────────────────────────────────────────────
156
+ const neighbors = [];
157
+ if (k > 0) {
158
+ const groupsToUse = by === "all" ? GROUP_NAMES : [by];
159
+ const selfSlices = groupsToUse.map((g) => slice(self, g));
160
+ const scored = [];
161
+ for (let i = 0; i < CENSUS_ORDER.length; i++) {
162
+ if (i === self)
163
+ continue;
164
+ const otherId = CENSUS_ORDER[i];
165
+ if (cutoff != null && !chantAdmissible(otherId, cutoff))
166
+ continue;
167
+ let sum = 0;
168
+ for (let gi = 0; gi < groupsToUse.length; gi++) {
169
+ sum += cosine(selfSlices[gi], slice(i, groupsToUse[gi]));
170
+ }
171
+ scored.push({ id: otherId, similarity: sum / groupsToUse.length });
172
+ }
173
+ // Ties → lower id, so the same question always has the same answer.
174
+ scored.sort((a, b) => b.similarity - a.similarity || (a.id < b.id ? -1 : 1));
175
+ neighbors.push(...scored.slice(0, k));
176
+ }
177
+ return { id, profile, balance: { distance, deviantGroups }, neighbors, by };
178
+ }
179
+ //# sourceMappingURL=census.js.map
@@ -0,0 +1,55 @@
1
+ /** A field group census() can measure similarity on. */
2
+ export type CensusGroup = "modal" | "degreeHist" | "melodic" | "trigram" | "cadenceFinal" | "cadenceMedial" | "chironomy" | "textual";
3
+ /** `all` = the equal-weight mean of every group's cosine. */
4
+ export type CensusBy = CensusGroup | "all";
5
+ export interface CensusQuery {
6
+ /** The chant to census. Exactly one block per id (blocks are deduped by id). */
7
+ id: string;
8
+ /** How many neighbors to return. Default 8; 0 returns none. */
9
+ k?: number;
10
+ /** Which field group similarity is measured on. Default "all". */
11
+ by?: CensusBy;
12
+ /**
13
+ * Restrict neighbors to chants attested by this year. Unattested chants are
14
+ * EXCLUDED, not assumed old — the same rule as `cantus({ before })`.
15
+ */
16
+ before?: number;
17
+ }
18
+ /** One field group's reading for a chant, beside the corpus norm. */
19
+ export interface CensusGroupProfile {
20
+ /** The chant's values for this group. */
21
+ values: readonly number[];
22
+ /**
23
+ * Cosine of this group against the corpus mean, 0–1. Low means the chant
24
+ * uses this dimension unlike the rest of the corpus.
25
+ */
26
+ typicality: number;
27
+ }
28
+ export interface CensusNeighbor {
29
+ id: string;
30
+ /** Cosine similarity on the chosen group(s), 0–1. Higher is nearer. */
31
+ similarity: number;
32
+ }
33
+ export interface Census {
34
+ /** The chant censused. */
35
+ id: string;
36
+ /** Per-group readings, keyed by group name. */
37
+ profile: Readonly<Record<CensusGroup, CensusGroupProfile>>;
38
+ balance: {
39
+ /**
40
+ * Mean cosine distance from the corpus centre across all groups, 0–1.
41
+ * 0 is a chant at the corpus mean; 1 has nothing in common with it.
42
+ */
43
+ distance: number;
44
+ /**
45
+ * Groups whose typicality falls furthest below the chant's own mean —
46
+ * where this chant is unusual, named. Most deviant first.
47
+ */
48
+ deviantGroups: readonly CensusGroup[];
49
+ };
50
+ /** Nearest chants on the chosen group, nearest first. Ties → lower id. */
51
+ neighbors: readonly CensusNeighbor[];
52
+ /** Which group the neighbor sweep measured on. */
53
+ by: CensusBy;
54
+ }
55
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,8 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/census/types — the census verb's shapes
3
+ // ---------------------------------------------------------------------------
4
+ // The field names are English by design. The census is measurement, not
5
+ // repertoire: it says how a chant sits against the corpus it belongs to, and
6
+ // the vocabulary for that is arithmetic, not liturgy.
7
+ export {};
8
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A year → the latest century wholly attested by it, on the same scale as
3
+ * `Attestation.century` (10 = the 900s).
4
+ *
5
+ * CANTUS dates a manuscript only to its century, so `before: 1098` cannot mean
6
+ * "witnessed before 1098" — a book dated "11th century" may have been written
7
+ * in 1099. Admitting the whole 11th century would let a caller asking for 1098
8
+ * receive chants first written down after it, which is the one thing this
9
+ * filter exists to prevent. So a year admits only centuries that CLOSED before
10
+ * it: 1098 → 10 (through the 900s), 1100 → 11.
11
+ */
12
+ export declare function centuryOf(year: number): number;
13
+ /**
14
+ * The attestation cutoff a query asks for, as a century number — or null when
15
+ * the query carries no era view.
16
+ */
17
+ export declare function attestationCutoff(query: {
18
+ before?: number;
19
+ }, method: string): number | null;
20
+ /**
21
+ * The cutoff a DAY verb serves under: the query's own view wins; otherwise the
22
+ * view `festum({ before })` stamped on the Feast rides along, so one argument
23
+ * at the calendar door carries through the whole day. Feasts resolved together
24
+ * came from one festum call and share one view — the first stamp speaks for
25
+ * the set.
26
+ */
27
+ export declare function eraCutoff(query: {
28
+ before?: number;
29
+ }, feasts: readonly {
30
+ before?: number;
31
+ }[] | null | undefined, method: string): number | null;
32
+ /**
33
+ * ONE admissibility rule for every door — the book (`cantus`) and the day
34
+ * verbs (`proprium`, `ordinarium`, `officium`) alike. Evidence, not
35
+ * existence: an undated chant is excluded under any cutoff rather than
36
+ * assumed old, and `both` satisfies either cursus.
37
+ */
38
+ export declare function chantAdmissible(id: string, cutoff: number | null, cursus?: "monastic" | "secular"): boolean;
39
+ //# sourceMappingURL=attest.d.ts.map
@@ -0,0 +1,90 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/chant/attest — the era view's one admissibility rule
3
+ //
4
+ // A LEAF module, importing data only: every chant verb (cantus, proprium,
5
+ // ordinarium, officium) draws its attestation filtering from here, so no verb
6
+ // needs to import another verb's module to share the rule — the chant ↔
7
+ // ordinary import cycle this replaces died of exactly that (a TDZ
8
+ // ReferenceError whenever ordinary.js loaded first).
9
+ //
10
+ // ── A FILTER THAT LEFT AND CAME BACK ───────────────────────────────────────
11
+ // This filter was once retired because its data had a genre-shaped hole: 31%
12
+ // of the corpus undated, 93% of RESPONSORIES among them, so `before: 1098`
13
+ // deleted the Hartker repertory from Epiphany Matins — wrong about the
14
+ // liturgy on the exact question the library exists to answer. The rule was
15
+ // never the problem; the coverage was. The corpus side closed it (crosswalk
16
+ // gap-fill + matcher v2: composite respond+verse, the incipit-entry rule,
17
+ // containment, genre disambiguation — all gold-gated): 85% of shipped records
18
+ // now carry a dated witness and responsories are 92% DATED. What remains
19
+ // undated is deliberate (psalter, formulas) or editorial territory (hymns,
20
+ // modern propers) — not a genre bias. So the evidence law stands: an undated
21
+ // chant is EXCLUDED under any cutoff. Silence is not evidence.
22
+ //
23
+ // A `century` option is deliberately absent. It was always `before: N * 100`
24
+ // in different clothes — one cutoff internally, two spellings at the door —
25
+ // so the two spellings converged on the one that is a year.
26
+ // ---------------------------------------------------------------------------
27
+ import { ATTESTATION } from "../../data/attestation.js";
28
+ /**
29
+ * A year → the latest century wholly attested by it, on the same scale as
30
+ * `Attestation.century` (10 = the 900s).
31
+ *
32
+ * CANTUS dates a manuscript only to its century, so `before: 1098` cannot mean
33
+ * "witnessed before 1098" — a book dated "11th century" may have been written
34
+ * in 1099. Admitting the whole 11th century would let a caller asking for 1098
35
+ * receive chants first written down after it, which is the one thing this
36
+ * filter exists to prevent. So a year admits only centuries that CLOSED before
37
+ * it: 1098 → 10 (through the 900s), 1100 → 11.
38
+ */
39
+ export function centuryOf(year) {
40
+ // floor, not ceil−1: the two agree everywhere except exact century
41
+ // multiples, where ceil−1 wrongly excluded the century that had just CLOSED
42
+ // (1100 → 10, so `before: 1100` refused the 1000s) — and the round inputs
43
+ // callers actually reach for are precisely the multiples that were wrong.
44
+ return Math.floor(year / 100);
45
+ }
46
+ /**
47
+ * The attestation cutoff a query asks for, as a century number — or null when
48
+ * the query carries no era view.
49
+ */
50
+ export function attestationCutoff(query, method) {
51
+ if (query.before == null)
52
+ return null;
53
+ const cutoff = centuryOf(query.before);
54
+ if (!Number.isFinite(cutoff)) {
55
+ throw new Error(`${method}: before must be a year — the cutoff is the latest century number ` +
56
+ `(10 = the 900s) wholly closed by it; e.g. ${method}({ before: 1098 })`);
57
+ }
58
+ return cutoff;
59
+ }
60
+ /**
61
+ * The cutoff a DAY verb serves under: the query's own view wins; otherwise the
62
+ * view `festum({ before })` stamped on the Feast rides along, so one argument
63
+ * at the calendar door carries through the whole day. Feasts resolved together
64
+ * came from one festum call and share one view — the first stamp speaks for
65
+ * the set.
66
+ */
67
+ export function eraCutoff(query, feasts, method) {
68
+ const own = attestationCutoff(query, method);
69
+ if (own != null)
70
+ return own;
71
+ const stamped = feasts?.find((f) => f?.before != null);
72
+ return stamped ? centuryOf(stamped.before) : null;
73
+ }
74
+ /**
75
+ * ONE admissibility rule for every door — the book (`cantus`) and the day
76
+ * verbs (`proprium`, `ordinarium`, `officium`) alike. Evidence, not
77
+ * existence: an undated chant is excluded under any cutoff rather than
78
+ * assumed old, and `both` satisfies either cursus.
79
+ */
80
+ export function chantAdmissible(id, cutoff, cursus) {
81
+ if (cutoff == null && !cursus)
82
+ return true;
83
+ const a = ATTESTATION[id];
84
+ if (cutoff != null && (a == null || a.century > cutoff))
85
+ return false;
86
+ if (cursus && !(a?.cursus === cursus || a?.cursus === "both"))
87
+ return false;
88
+ return true;
89
+ }
90
+ //# sourceMappingURL=attest.js.map
@@ -1,10 +1,28 @@
1
- import type { Chant, CantusQuery } from "./types.js";
1
+ import type { Chant, CantusQuery, ChantSource, Corpus, CorpusLedger, CorpusQuery } from "./types.js";
2
+ import { CANTUS_QUERY_KEYS } from "./types.js";
3
+ export declare const SOURCES: Record<ChantSource, Chant["source"]>;
4
+ /**
5
+ * Metadata and content breakdown for one corpus book (`tonus.corpus`). Pass a
6
+ * source code; get the book's bibliographic identity plus its genre and mode
7
+ * distributions. Computed on first access from the loaded corpus, then cached.
8
+ */
9
+ /**
10
+ * One book's ledger, or the whole shelf's.
11
+ *
12
+ * `corpus("am")` and `corpus({ book: "am" })` are the same question — the bare
13
+ * code came first and keeps working, the object form matches every other verb.
14
+ * `corpus()` with no argument returns the rollup.
15
+ */
16
+ export declare function getCorpus(): CorpusLedger;
17
+ export declare function getCorpus(code: ChantSource): Corpus;
18
+ export declare function getCorpus(query: CorpusQuery): Corpus;
2
19
  export declare function resolveChant(id: string | null): Chant | null;
3
20
  export declare function resolveChants(ids: string[]): Chant[];
21
+ export { CANTUS_QUERY_KEYS };
4
22
  /**
5
- * Cross-corpus chant retrieval (`tonus.cantus`) over GR, LA, LH, and LU.
6
- * A `gabc` field bypasses the corpus and returns a single user
7
- * chant parsed from raw GABC (body or full file with headers).
23
+ * Cross-corpus chant retrieval (`tonus.cantus`) over every corpus book
24
+ * (the codes in SOURCES). A `gabc` field bypasses the corpus and returns a
25
+ * single user chant parsed from raw GABC (body or full file with headers).
8
26
  */
9
27
  export declare function getChants(query?: CantusQuery): Chant[];
10
28
  //# sourceMappingURL=chant.d.ts.map